From 4a1ac00105226695d16fb343b44e55a52f44b95b Mon Sep 17 00:00:00 2001 From: sunnnybala Date: Wed, 15 Apr 2026 15:03:05 +0530 Subject: [PATCH] fix(sync): remove nested transaction that deadlocks > 10 file syncs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync.ts wraps the add/modify loop in engine.transaction(), and each importFromContent inside opens another one. PGLite's _runExclusiveTransaction is a non-reentrant mutex — the second call queues on the mutex the first is holding, and the process hangs forever in ep_poll. Reproduced with a 15-file commit: unpatched hangs, patched runs in 3.4s. Fix drops the outer wrap; per-file atomicity is correct anyway (one file's failure should not roll back the others). --- src/commands/sync.ts | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 7034ea0da..601e356da 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -203,29 +203,29 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise< pagesAffected.push(newSlug); } - // Process adds and modifies - const useTransaction = (filtered.added.length + filtered.modified.length) > 10; - const processAddsModifies = async () => { - for (const path of [...filtered.added, ...filtered.modified]) { - const filePath = join(repoPath, path); - if (!existsSync(filePath)) continue; - try { - const result = await importFile(engine, filePath, path, { noEmbed }); - if (result.status === 'imported') { - chunksCreated += result.chunks; - pagesAffected.push(result.slug); - } - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - console.error(` Warning: skipped ${path}: ${msg}`); + // Process adds and modifies. + // + // NOTE: do NOT wrap this loop in engine.transaction(). importFromContent + // already opens its own inner transaction per file, and PGLite transactions + // are not reentrant — they acquire the same _runExclusiveTransaction mutex, + // so a nested call from inside a user callback queues forever on the mutex + // the outer transaction is still holding. Result: incremental sync hangs in + // ep_poll whenever the diff crosses the old > 10 threshold that used to + // trigger the outer wrap. Per-file atomicity is also the right granularity: + // one file's failure should not roll back the others' successful imports. + for (const path of [...filtered.added, ...filtered.modified]) { + const filePath = join(repoPath, path); + if (!existsSync(filePath)) continue; + try { + const result = await importFile(engine, filePath, path, { noEmbed }); + if (result.status === 'imported') { + chunksCreated += result.chunks; + pagesAffected.push(result.slug); } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error(` Warning: skipped ${path}: ${msg}`); } - }; - - if (useTransaction) { - await engine.transaction(async () => { await processAddsModifies(); }); - } else { - await processAddsModifies(); } const elapsed = Date.now() - start;