Files
obelisk/tests/app-rollback-guard.test.mjs
T
tommy0103andClaude Opus 4.8 44029676d4 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>
2026-07-10 16:36:36 +08:00

93 lines
3.8 KiB
JavaScript

// Regression test for the "cannot rollback - no transaction is active" crash.
// SQLite auto-rolls back certain failures (SQLITE_BUSY, disk full, ...). The
// per-file build loop then ran an explicit ROLLBACK in its catch, which threw a
// SECOND error over the real one and aborted the whole build instead of skipping
// just the bad file. buildIndex now uses a guarded rollback; this test injects a
// DB that faithfully reproduces the condition and asserts the build survives.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const require = createRequire(import.meta.url);
import { buildIndex } from '../app/src/main/indexer.ts';
const { DatabaseSync } = require('node:sqlite');
// Wraps node:sqlite and simulates SQLite's auto-rollback-on-error: the first
// write inside a transaction throws a BUSY-like error AND ends the real
// transaction, so a following explicit ROLLBACK errors with "no transaction".
class AutoRollbackOnceDatabase {
constructor(dbPath) {
this.db = new DatabaseSync(dbPath);
this.inTxn = false;
this.fired = false;
}
pragma(statement) { this.db.exec(`PRAGMA ${statement}`); }
exec(sql) {
const head = sql.trim().slice(0, 8).toUpperCase();
if (head.startsWith('BEGIN')) { this.inTxn = true; return this.db.exec('BEGIN'); }
if (head.startsWith('COMMIT')) { this.inTxn = false; return this.db.exec('COMMIT'); }
if (head.startsWith('ROLLBACK')) {
if (!this.inTxn) throw new Error('cannot rollback - no transaction is active');
this.inTxn = false;
return this.db.exec('ROLLBACK');
}
return this.db.exec(sql);
}
prepare(sql) {
const stmt = this.db.prepare(sql);
const self = this;
return {
get: (...args) => stmt.get(...args),
all: (...args) => stmt.all(...args),
run: (...args) => {
if (!self.fired && self.inTxn) {
self.fired = true;
self.db.exec('ROLLBACK'); // SQLite auto-rolled the txn back on the error
self.inTxn = false;
throw new Error('SQLITE_BUSY: database is locked');
}
return stmt.run(...args);
},
};
}
close() { return this.db.close(); }
}
test('a per-file write that auto-rolls-back the transaction is skipped, not fatal', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-rollback-guard-'));
const projectDir = join(home, '.claude', 'projects', '-tmp-proj');
mkdirSync(projectDir, { recursive: true });
const msg = (uuid) => JSON.stringify({
uuid, type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/tmp/proj',
message: { role: 'user', content: `hello ${uuid}` },
}) + '\n';
writeFileSync(join(projectDir, 'alpha.jsonl'), msg('a1'));
writeFileSync(join(projectDir, 'beta.jsonl'), msg('b1'));
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
mkdirSync(join(home, '.obelisk'), { recursive: true });
// The unguarded ROLLBACK used to make this throw the masking rollback error.
let result;
assert.doesNotThrow(() => {
result = buildIndex({
force: true,
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
projectsDir: join(home, '.claude', 'projects'),
dbPath,
DatabaseImpl: AutoRollbackOnceDatabase,
});
}, 'build must not abort on a transaction-aborting per-file error');
// Exactly one file's write was poisoned; the other indexed cleanly.
const check = new DatabaseSync(dbPath);
const sessions = check.prepare('SELECT COUNT(*) AS c FROM sessions').get().c;
check.close();
assert.equal(sessions, 1, 'the surviving file is indexed; the poisoned one is skipped');
assert.ok(result.files >= 2, 'both files were discovered');
});