Просмотр исходного кода

fix: prevent duplicate chapter writes with book lock and index-based numbering

- StateManager.getNextChapterNumber: use index.json instead of
  counting .md files to prevent race conditions
- StateManager.acquireBookLock: file-based lock prevents concurrent
  writes to the same book
- PipelineRunner.writeNextChapter: acquire/release lock around
  the full write-audit-save cycle
majx_mac 8 месяцев назад
Родитель
Сommit
91d19b6729
2 измененных файлов с 36 добавлено и 12 удалено
  1. 9 0
      packages/core/src/pipeline/runner.ts
  2. 27 12
      packages/core/src/state/manager.ts

+ 9 - 0
packages/core/src/pipeline/runner.ts

@@ -66,6 +66,15 @@ export class PipelineRunner {
   }
   }
 
 
   async writeNextChapter(bookId: string): Promise<ChapterPipelineResult> {
   async writeNextChapter(bookId: string): Promise<ChapterPipelineResult> {
+    const releaseLock = await this.state.acquireBookLock(bookId);
+    try {
+      return await this._writeNextChapterLocked(bookId);
+    } finally {
+      await releaseLock();
+    }
+  }
+
+  private async _writeNextChapterLocked(bookId: string): Promise<ChapterPipelineResult> {
     const book = await this.state.loadBookConfig(bookId);
     const book = await this.state.loadBookConfig(bookId);
     const bookDir = this.state.bookDir(bookId);
     const bookDir = this.state.bookDir(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);

+ 27 - 12
packages/core/src/state/manager.ts

@@ -1,4 +1,4 @@
-import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
+import { readFile, writeFile, mkdir, readdir, stat, unlink } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import type { BookConfig } from "../models/book.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
 import type { ChapterMeta } from "../models/chapter.js";
@@ -6,6 +6,28 @@ import type { ChapterMeta } from "../models/chapter.js";
 export class StateManager {
 export class StateManager {
   constructor(private readonly projectRoot: string) {}
   constructor(private readonly projectRoot: string) {}
 
 
+  async acquireBookLock(bookId: string): Promise<() => Promise<void>> {
+    const lockPath = join(this.bookDir(bookId), ".write.lock");
+    try {
+      await stat(lockPath);
+      const lockData = await readFile(lockPath, "utf-8");
+      throw new Error(
+        `Book "${bookId}" is locked by another process (${lockData}). ` +
+          `If this is stale, delete ${lockPath}`,
+      );
+    } catch (e) {
+      if (e instanceof Error && e.message.includes("is locked")) throw e;
+    }
+    await writeFile(lockPath, `pid:${process.pid} ts:${Date.now()}`, "utf-8");
+    return async () => {
+      try {
+        await unlink(lockPath);
+      } catch {
+        // ignore
+      }
+    };
+  }
+
   get booksDir(): string {
   get booksDir(): string {
     return join(this.projectRoot, "books");
     return join(this.projectRoot, "books");
   }
   }
@@ -61,17 +83,10 @@ export class StateManager {
   }
   }
 
 
   async getNextChapterNumber(bookId: string): Promise<number> {
   async getNextChapterNumber(bookId: string): Promise<number> {
-    const chaptersDir = join(this.bookDir(bookId), "chapters");
-    try {
-      const files = await readdir(chaptersDir);
-      const chapterFiles = files.filter((f) => f.endsWith(".md")).sort();
-      if (chapterFiles.length === 0) return 1;
-      const lastFile = chapterFiles[chapterFiles.length - 1]!;
-      const match = lastFile.match(/^(\d+)/);
-      return match ? parseInt(match[1], 10) + 1 : 1;
-    } catch {
-      return 1;
-    }
+    const index = await this.loadChapterIndex(bookId);
+    if (index.length === 0) return 1;
+    const maxNum = Math.max(...index.map((ch) => ch.number));
+    return maxNum + 1;
   }
   }
 
 
   async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
   async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {