Files
obelisk/tests/app-rollback-guard.test.mjs
T

297 lines
11 KiB
JavaScript
Raw Normal View History

2026-07-10 18:10:45 +08:00
// Regression tests for the write-transaction runner (docs/adr/0006):
// - a transient BUSY (auto-rolled-back txn) is retried and recovers;
// - a persistent BUSY exhausts retries, and that file is SKIPPED, not fatal;
// - the guarded rollback never masks the real error ("cannot rollback ...").
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');
2026-07-10 18:10:45 +08:00
// Wraps node:sqlite and simulates SQLite auto-rollback-on-error: a poisoned write
// throws a BUSY-like error AND ends the real transaction, so a following explicit
// ROLLBACK errors with "no transaction is active". `shouldPoison(args)` decides
// which writes are poisoned.
function makeDbClass(shouldPoison) {
return class PoisonDatabase {
constructor(dbPath) {
this.db = new DatabaseSync(dbPath);
this.inTxn = false;
}
2026-07-10 18:10:45 +08:00
get inTransaction() { return this.inTxn; }
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.inTxn && shouldPoison(args)) {
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(); }
};
}
2026-07-10 18:10:45 +08:00
function twoFileHome(alphaContent, betaContent) {
const home = mkdtempSync(join(tmpdir(), 'obelisk-tx-'));
const projectDir = join(home, '.claude', 'projects', '-tmp-proj');
mkdirSync(projectDir, { recursive: true });
mkdirSync(join(home, '.obelisk'), { recursive: true });
2026-07-10 18:10:45 +08:00
const msg = (uuid, content) => JSON.stringify({
uuid, type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/tmp/proj',
message: { role: 'user', content },
}) + '\n';
writeFileSync(join(projectDir, 'alpha.jsonl'), msg('a1', alphaContent));
writeFileSync(join(projectDir, 'beta.jsonl'), msg('b1', betaContent));
return { home, dbPath: join(home, '.obelisk', 'obelisk.sqlite'), projectsDir: join(home, '.claude', 'projects') };
}
function subagentHome(description = 'first description') {
const home = mkdtempSync(join(tmpdir(), 'obelisk-meta-tx-'));
const projectDir = join(home, '.claude', 'projects', '-tmp-proj');
const subagentDir = join(projectDir, 'session', 'subagents');
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
mkdirSync(subagentDir, { recursive: true });
mkdirSync(join(home, '.obelisk'), { recursive: true });
const message = uuid => JSON.stringify({
uuid,
type: 'user',
timestamp: '2026-06-10T10:00:00Z',
cwd: '/tmp/proj',
message: { role: 'user', content: `message ${uuid}` },
}) + '\n';
writeFileSync(join(projectDir, 'session.jsonl'), message('main-message'));
writeFileSync(join(subagentDir, 'agent.jsonl'), message('agent-message'));
const metaPath = join(subagentDir, 'agent.meta.json');
writeFileSync(metaPath, JSON.stringify({ agentType: 'Explore', description }));
return {
home,
dbPath,
projectsDir: join(home, '.claude', 'projects'),
metaPath,
changedMetaPath: join('-tmp-proj', 'session', 'subagents', 'agent.meta.json'),
};
}
function run(home, dbPath, projectsDir, DatabaseImpl) {
return buildIndex({
force: true,
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
projectsDir,
dbPath,
DatabaseImpl,
});
}
function makeBeginBusyDbClass(shouldFail) {
const Base = makeDbClass(() => false);
return class BeginBusyDatabase extends Base {
constructor(dbPath) {
super(dbPath);
this.isWriterLease = dbPath.endsWith('writer.lock.sqlite');
this.beginCalls = 0;
}
exec(sql) {
if (!this.isWriterLease && sql.trim().toUpperCase().startsWith('BEGIN')) {
this.beginCalls += 1;
if (shouldFail(this.beginCalls)) {
throw Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
}
}
return super.exec(sql);
}
};
}
test('a failed changed file is not reported as an affected session', () => {
const { home, dbPath, projectsDir } = twoFileHome('POISON alpha', 'hello beta');
const Db = makeDbClass((args) => args.some(a => typeof a === 'string' && a.includes('POISON')));
const result = buildIndex({
force: false,
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
projectsDir,
dbPath,
DatabaseImpl: Db,
changedPaths: ['-tmp-proj/alpha.jsonl'],
});
assert.deepEqual(result.affectedSessionIds, []);
assert.equal(result.skipped, 1);
});
test('a transient BUSY during force cleanup is retried and recovers', () => {
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
// Fire exactly once, on the first write inside a transaction, then never again.
let fired = false;
const Db = makeDbClass(() => (fired ? false : (fired = true)));
let result;
2026-07-10 18:10:45 +08:00
assert.doesNotThrow(() => { result = run(home, dbPath, projectsDir, Db); });
const check = new DatabaseSync(dbPath);
const sessions = check.prepare('SELECT COUNT(*) AS c FROM sessions').get().c;
check.close();
2026-07-10 18:10:45 +08:00
assert.equal(sessions, 2, 'the retried file recovered; both files indexed');
assert.equal(result.skipped, 0, 'nothing was skipped');
});
test('a transient BUSY during a file transaction is retried and recovers', () => {
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
let fired = false;
const Db = makeDbClass((args) => {
const isAlphaWrite = args.some(arg => typeof arg === 'string' && arg.includes('hello alpha'));
if (!isAlphaWrite || fired) return false;
fired = true;
return true;
});
const result = run(home, dbPath, projectsDir, Db);
const check = new DatabaseSync(dbPath);
const sessions = check.prepare('SELECT COUNT(*) AS c FROM sessions').get().c;
check.close();
assert.equal(sessions, 2);
assert.equal(result.skipped, 0);
});
test('a persistent BUSY exhausts retries and skips just that file, not the build', () => {
const { home, dbPath, projectsDir } = twoFileHome('POISON alpha', 'hello beta');
// Always poison writes that carry alpha's marker text; beta is untouched.
const Db = makeDbClass((args) => args.some(a => typeof a === 'string' && a.includes('POISON')));
let result;
assert.doesNotThrow(() => { result = run(home, dbPath, projectsDir, Db); });
const check = new DatabaseSync(dbPath);
const sessions = check.prepare('SELECT id FROM sessions ORDER BY id').all().map(r => r.id);
check.close();
assert.deepEqual(sessions, ['beta'], 'the persistently-failing file is skipped; the other indexes');
assert.equal(result.skipped, 1, 'the skipped file is reported in the build result');
assert.equal(result.skippedFiles[0].diagnostics?.phase, 'work', 'diagnostics record the failing phase');
});
test('BEGIN contention during force cleanup defers the build', () => {
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
const Db = makeBeginBusyDbClass(beginCall => beginCall === 1);
const result = run(home, dbPath, projectsDir, Db);
assert.equal(result.deferred, true);
assert.equal(result.reason, 'database_busy');
});
test('BEGIN contention during finalize defers the build', () => {
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
const Db = makeBeginBusyDbClass(beginCall => beginCall === 3);
const result = buildIndex({
force: false,
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
projectsDir,
dbPath,
DatabaseImpl: Db,
});
assert.equal(result.deferred, true);
assert.equal(result.reason, 'database_busy');
});
test('a finalize database error is propagated instead of swallowed as malformed input', () => {
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
const workflowDir = join(projectsDir, '-tmp-proj', 'alpha', 'workflows');
mkdirSync(workflowDir, { recursive: true });
writeFileSync(join(workflowDir, 'run.json'), JSON.stringify({
runId: 'workflow-1',
workflowName: 'POISON WORKFLOW',
}));
const Db = makeDbClass(args => args.some(arg => typeof arg === 'string' && arg.includes('POISON WORKFLOW')));
assert.throws(() => buildIndex({
force: false,
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
projectsDir,
dbPath,
DatabaseImpl: Db,
}), /SQLITE_BUSY/);
});
test('a changed subagent meta file is applied and reported only after its file transaction commits', () => {
const { home, dbPath, projectsDir, metaPath, changedMetaPath } = subagentHome();
const Db = makeDbClass(() => false);
buildIndex({
force: true,
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
projectsDir,
dbPath,
DatabaseImpl: Db,
});
writeFileSync(metaPath, JSON.stringify({ agentType: 'Explore', description: 'updated description' }));
const result = buildIndex({
force: false,
changedPaths: [changedMetaPath],
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
projectsDir,
dbPath,
DatabaseImpl: Db,
});
const check = new DatabaseSync(dbPath, { readOnly: true });
assert.equal(check.prepare('SELECT description FROM subagents WHERE agent_id=?').get('agent').description, 'updated description');
check.close();
assert.deepEqual(result.affectedSessionIds, ['session']);
});
test('a failed subagent meta transaction does not report its session as affected', () => {
const { home, dbPath, projectsDir, metaPath, changedMetaPath } = subagentHome();
buildIndex({
force: true,
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
projectsDir,
dbPath,
DatabaseImpl: makeDbClass(() => false),
});
writeFileSync(metaPath, JSON.stringify({ agentType: 'Explore', description: 'POISON META' }));
const FailingDb = makeDbClass(args => args.some(arg => typeof arg === 'string' && arg.includes('POISON META')));
const result = buildIndex({
force: false,
changedPaths: [changedMetaPath],
claudeDir: join(home, '.claude'),
codexDir: join(home, '.codex'),
projectsDir,
dbPath,
DatabaseImpl: FailingDb,
});
assert.deepEqual(result.affectedSessionIds, []);
assert.equal(result.skipped, 1);
});