fix(indexer): guard cleanup rollback so it never masks the real build error

Reported as "cannot rollback - no transaction is active" — a secondary error.
SQLite auto-rolls back certain failures (SQLITE_BUSY / SQLITE_BUSY_SNAPSHOT,
disk full), after which the per-file loop's unguarded ROLLBACK in its catch
threw over the real error and aborted the whole build instead of skipping just
the bad file.

Stopgap only (see docs/adr/0006 for the planned full fix):
- Add safeRollback(db) in both indexers: it swallows only the rollback's own
  error, so the true cause surfaces. Per-file failures are logged and the build
  continues; the finalize failure still propagates.
- Give the skill's node:sqlite connection an explicit PRAGMA busy_timeout=5000
  (it has no default). The app adds none: better-sqlite3 already defaults to
  5000ms, so busy_timeout is NOT the root-cause fix and is not treated as one.

Add tests/app-rollback-guard.test.mjs: injects a DB that faithfully reproduces
"a write auto-rolls back the txn, then ROLLBACK errors" and asserts the build
survives (bad file skipped, other file indexed). Revert-checked: without the
guard the test fails with the exact "cannot rollback - no transaction is active".

docs/adr/0006 records the real fix (shared runWriteTransaction, single-writer
coordination, BEGIN IMMEDIATE, whole-transaction retry, PASSIVE checkpointing)
as deferred, two-phase work — and why bumping busy_timeout is not it.

Verified: suite 124/124, typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tommy0103
2026-07-10 16:36:36 +08:00
co-authored by Claude Opus 4.8
parent e44ab7a1da
commit 44029676d4
5 changed files with 193 additions and 4 deletions
+11 -2
View File
@@ -35,6 +35,15 @@ interface FileInfo {
source?: string;
}
// A rollback inside a catch must never throw over the real error. SQLite
// auto-rolls back certain failures (SQLITE_BUSY, disk full, ...); a following
// explicit ROLLBACK then throws "cannot rollback - no transaction is active",
// which would both mask the true cause and turn a skippable per-file error into
// a whole-build failure. Swallow only the rollback's own error.
function safeRollback(db: { exec: (sql: string) => unknown }) {
try { db.exec('ROLLBACK'); } catch { /* no active transaction */ }
}
function resolveSchemaPath() {
const candidates = [
path.join(__dirname, 'schema.sql'),
@@ -576,7 +585,7 @@ function buildIndex({
if (file.source !== 'codex') indexSubagentMeta(db, file);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
safeRollback(db);
console.warn(`Warning: failed to index ${file.path}: ${(error as Error).message}`);
}
}
@@ -596,7 +605,7 @@ function buildIndex({
if (latestSourceMtime) writeIndexMarker(db, '__last_source_mtime__', latestSourceMtime);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
safeRollback(db);
throw error;
}
return { files: files.length, latestSourceMtime, affectedSessionIds: [...affectedSessionIds], ftsRebuilt };