|
@@ -159,6 +159,34 @@ function computeContentHash(content: string): string {
|
|
|
// 造成 ffmpeg 进程堆积、CPU 打满、临时文件撑爆磁盘。
|
|
// 造成 ffmpeg 进程堆积、CPU 打满、临时文件撑爆磁盘。
|
|
|
const mergingChapters = new Set<number>();
|
|
const mergingChapters = new Set<number>();
|
|
|
|
|
|
|
|
|
|
+// 全局合并并发上限:同一时刻最多跑 1 个章节合并。
|
|
|
|
|
+// 即使多本书/多章节同时就绪,也排队串行执行,避免 N 个 ffmpeg 并发把
|
|
|
|
|
+// 服务器 load 推爆(曾观测到 load 飙到 48+)。合并非延迟敏感,串行即可。
|
|
|
|
|
+let mergeRunning = 0;
|
|
|
|
|
+const mergeWaitQueue: Array<() => void> = [];
|
|
|
|
|
+const MAX_CONCURRENT_MERGE = 1;
|
|
|
|
|
+
|
|
|
|
|
+async function acquireMergeSlot(): Promise<() => void> {
|
|
|
|
|
+ if (mergeRunning < MAX_CONCURRENT_MERGE) {
|
|
|
|
|
+ mergeRunning++;
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ mergeRunning--;
|
|
|
|
|
+ const next = mergeWaitQueue.shift();
|
|
|
|
|
+ if (next) next();
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ return new Promise<() => void>((resolve) => {
|
|
|
|
|
+ mergeWaitQueue.push(() => {
|
|
|
|
|
+ mergeRunning++;
|
|
|
|
|
+ resolve(() => {
|
|
|
|
|
+ mergeRunning--;
|
|
|
|
|
+ const next = mergeWaitQueue.shift();
|
|
|
|
|
+ if (next) next();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export async function tryAutoMerge(
|
|
export async function tryAutoMerge(
|
|
|
completedLeafId: number,
|
|
completedLeafId: number,
|
|
|
bookId: number | null,
|
|
bookId: number | null,
|
|
@@ -272,17 +300,29 @@ export async function tryAutoMerge(
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 6. 触发合并(加内存锁,防止同一章节并发重复合并)
|
|
|
|
|
|
|
+ // 6. 触发合并(章节级内存锁防重复 + 全局信号量限并发)
|
|
|
if (mergingChapters.has(chapterId)) {
|
|
if (mergingChapters.has(chapterId)) {
|
|
|
- // 该章节已有合并任务在进行中,跳过本次触发
|
|
|
|
|
|
|
+ // 该章节已有合并任务在进行中/排队中,跳过本次触发
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
mergingChapters.add(chapterId);
|
|
mergingChapters.add(chapterId);
|
|
|
- console.log(
|
|
|
|
|
- `[AutoMerge] 🎵 章节${chapterId}下所有${leafNodesUnderChapter.length}个叶节点音频已就绪,开始自动合并...`,
|
|
|
|
|
- );
|
|
|
|
|
|
|
|
|
|
|
|
+ // 全局串行:同时只跑 1 个合并,其余排队,避免并发 ffmpeg 压垮服务器
|
|
|
|
|
+ const releaseSlot = await acquireMergeSlot();
|
|
|
try {
|
|
try {
|
|
|
|
|
+ // 取到槽位后复查一次:可能在排队期间该章节已被其它流程合并完成
|
|
|
|
|
+ const fresh = await prisma.bookChapter.findUnique({
|
|
|
|
|
+ where: { id: chapterId },
|
|
|
|
|
+ select: { audioUrl: true },
|
|
|
|
|
+ });
|
|
|
|
|
+ if (fresh?.audioUrl && fresh.audioUrl.includes('_merged')) {
|
|
|
|
|
+ console.log(`[AutoMerge] 章节${chapterId}已被合并,跳过`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ `[AutoMerge] 🎵 章节${chapterId}下所有${leafNodesUnderChapter.length}个叶节点音频已就绪,开始自动合并...`,
|
|
|
|
|
+ );
|
|
|
const mergedUrl = await mergeChapterAudios(chapterId);
|
|
const mergedUrl = await mergeChapterAudios(chapterId);
|
|
|
if (mergedUrl) {
|
|
if (mergedUrl) {
|
|
|
console.log(`[AutoMerge] ✅ 章节${chapterId}音频自动合并完成: ${mergedUrl}`);
|
|
console.log(`[AutoMerge] ✅ 章节${chapterId}音频自动合并完成: ${mergedUrl}`);
|
|
@@ -290,6 +330,7 @@ export async function tryAutoMerge(
|
|
|
console.warn(`[AutoMerge] ⚠️ 章节${chapterId}合并返回空结果`);
|
|
console.warn(`[AutoMerge] ⚠️ 章节${chapterId}合并返回空结果`);
|
|
|
}
|
|
}
|
|
|
} finally {
|
|
} finally {
|
|
|
|
|
+ releaseSlot();
|
|
|
mergingChapters.delete(chapterId);
|
|
mergingChapters.delete(chapterId);
|
|
|
}
|
|
}
|
|
|
} catch (err) {
|
|
} catch (err) {
|