Implement the full ADR-0006 plan: three-layer separation of transaction
correctness, retry policy, and cross-process writer coordination.
Layer 1 — scripts/tx.ts (transaction correctness):
- runWriteTransaction executes work exactly once; no internal retry.
- BEGIN IMMEDIATE takes the write lock up front (avoids SQLITE_BUSY_SNAPSHOT).
- Guarded rollback: checks inTransaction() via adapter before attempting
ROLLBACK; never masks the primary exception.
- WriteTxDiagnostics attached to errors: phase, code, label,
rollbackSucceeded, rollbackError, transactionActive.
- Binding adapters (betterSqliteTransactionAdapter, nodeSqliteTransactionAdapter)
mapping better-sqlite3's `.inTransaction` and node:sqlite's `.isTransaction`.
- configureConnection centralizes WAL + synchronous + busy_timeout.
Layer 2 — scripts/write-coordinator.ts (retry policy):
- runRetryableWriteTransaction: bounded retry with total time budget.
- Only retries when the transaction confirmed ended (transactionActive=false)
and the error is SQLITE_BUSY during work/commit phase.
- BEGIN-phase BUSY = abort entire build (isBeginBusyFailure); the caller
returns `{ deferred: true, reason: 'writer_busy' }` instead of waiting.
- hasUnusableTransaction detects a still-active transaction after failure;
aborts the build immediately, never retries.
Layer 3 — scripts/writer-lease.ts (cross-process coordination):
- acquireWriterLease: dedicated writer.lock.sqlite with busy_timeout=0 +
BEGIN IMMEDIATE. Non-blocking attempt; bounded wait with retryDelayMs.
- writerLockPathFor derives lock path from the target DB path.
- Lease held for the entire build; released on completion or failure.
- Lock DB uses DELETE journal (not WAL); crash/close auto-releases.
- All consumers obey: skill acquires at build start (returns deferred if
unavailable); app daemon (via worker) acquires for its build cycle.
Build semantics changes:
- affectedSessionIds updated only after successful commit.
- BuildIndexResult gains skipped/skippedFiles for observability.
- Skill finalize failure now fails the build (was silently warned).
- Checkpoint changed to PASSIVE (TRUNCATE reserved for maintenance/exit).
- Skill buildIndex returns { deferred, reason } on lease contention;
indexer-service reschedules the build (deferredRetryMs) without publishing
a heartbeat (so the build-deferred state is visible to cross-process
arbitration).
- Service publishes heartbeat immediately on start() for correct arbitration.
Tests:
- tests/write-transaction.test.mjs: single-shot execution, diagnostics
propagation, auto-rolled-back transaction detected, rollback failure
captured as metadata, BEGIN IMMEDIATE semantics.
- tests/writer-lease.test.mjs: acquire/release, contention returns null,
bounded wait with release during budget.
- tests/app-writer-lease.test.mjs: better-sqlite3 adapter integration.
- tests/app-rollback-guard.test.mjs: rewritten — transient BUSY recovered
by coordinator, persistent BUSY skips file, begin-busy aborts build,
live-transaction aborts build, phantom affectedSessionIds prevented.
- tests/daemon-arbitration.test.mjs: skill defers to fresh app heartbeat,
builds when heartbeat is stale.
- tests/app-indexer-service.test.mjs: new cases for deferred-retry
scheduling and immediate heartbeat on start.
- app/tests/electron-concurrency.mjs + child: dual-child IPC structure for
real better-sqlite3 contention (holder acquires lock → build child starts
→ delayed release → result collected; persistent contention bounded).
ADR-0006 updated to reflect the implemented design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
159 lines
6.3 KiB
JavaScript
159 lines
6.3 KiB
JavaScript
// Real Electron/better-sqlite3 concurrency test (docs/adr/0006 Phase 2).
|
|
// Run: cd app && npx electron tests/electron-concurrency.mjs
|
|
//
|
|
// Exercises actual dual-connection contention against a WAL database using the
|
|
// Electron-ABI better-sqlite3 that the app uses in production.
|
|
import { app } from 'electron';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createInterface } from 'node:readline';
|
|
import { once } from 'node:events';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import Database from 'better-sqlite3';
|
|
import { buildIndex } from '../out/main/indexer.js';
|
|
|
|
let failures = 0;
|
|
const childScript = join(dirname(fileURLToPath(import.meta.url)), 'electron-concurrency-child.mjs');
|
|
function assert(condition, msg) {
|
|
if (!condition) { console.error('FAIL:', msg); failures++; }
|
|
else console.log('PASS:', msg);
|
|
}
|
|
|
|
function spawnChild(mode, payload) {
|
|
return spawn(process.execPath, [childScript, mode, JSON.stringify(payload)], {
|
|
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
});
|
|
}
|
|
|
|
function lineReader(child) {
|
|
const lines = [];
|
|
const waiters = [];
|
|
createInterface({ input: child.stdout }).on('line', line => {
|
|
lines.push(line);
|
|
for (const waiter of [...waiters]) {
|
|
if (!line.startsWith(waiter.prefix)) continue;
|
|
waiters.splice(waiters.indexOf(waiter), 1);
|
|
waiter.resolve(line);
|
|
}
|
|
});
|
|
return {
|
|
waitFor(prefix) {
|
|
const existing = lines.find(line => line.startsWith(prefix));
|
|
if (existing) return Promise.resolve(existing);
|
|
return new Promise(resolve => waiters.push({ prefix, resolve }));
|
|
},
|
|
};
|
|
}
|
|
|
|
async function waitForSuccess(child) {
|
|
let code = child.exitCode;
|
|
if (code === null) [code] = await once(child, 'exit');
|
|
assert(code === 0, `child exited successfully, code=${code}`);
|
|
}
|
|
|
|
async function run() {
|
|
const home = mkdtempSync(join(tmpdir(), 'obelisk-electron-concurrency-'));
|
|
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
|
const projectsDir = join(home, '.claude', 'projects');
|
|
const projDir = join(projectsDir, '-proj');
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
mkdirSync(projDir, { recursive: true });
|
|
|
|
function msg(uuid) {
|
|
return JSON.stringify({
|
|
uuid, type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/tmp',
|
|
message: { role: 'user', content: `concurrent ${uuid}` },
|
|
}) + '\n';
|
|
}
|
|
for (let i = 0; i < 20; i++) {
|
|
writeFileSync(join(projDir, `s${i}.jsonl`), msg(`m${i}`));
|
|
}
|
|
|
|
console.log('--- Test 1: buildIndex with real better-sqlite3 ---');
|
|
const result = buildIndex({
|
|
force: true,
|
|
claudeDir: join(home, '.claude'),
|
|
codexDir: join(home, '.codex'),
|
|
projectsDir,
|
|
dbPath,
|
|
DatabaseImpl: Database,
|
|
});
|
|
assert(result.files === 20, `indexed 20 files, got ${result.files}`);
|
|
assert(result.skipped === 0, `no files skipped, got ${result.skipped}`);
|
|
|
|
console.log('--- Test 2: concurrent reader during write ---');
|
|
const reader = new Database(dbPath, { readonly: true });
|
|
reader.pragma('journal_mode = WAL');
|
|
const readStmt = reader.prepare('SELECT COUNT(*) AS c FROM sessions');
|
|
const beforeCount = readStmt.get().c;
|
|
assert(beforeCount === 20, `reader sees 20 sessions, got ${beforeCount}`);
|
|
// Incremental build concurrent with open reader
|
|
const result2 = buildIndex({
|
|
force: false,
|
|
claudeDir: join(home, '.claude'),
|
|
codexDir: join(home, '.codex'),
|
|
projectsDir,
|
|
dbPath,
|
|
DatabaseImpl: Database,
|
|
});
|
|
assert(result2.skipped === 0, `concurrent build no skips, got ${result2.skipped}`);
|
|
const duringCount = readStmt.get().c;
|
|
assert(duringCount === 20, `reader snapshot stable, got ${duringCount}`);
|
|
reader.close();
|
|
|
|
console.log('--- Test 3: a real concurrent writer releases within the lease budget ---');
|
|
const lockPath = join(dirname(dbPath), 'writer.lock.sqlite');
|
|
const buildOptions = {
|
|
force: false,
|
|
claudeDir: join(home, '.claude'),
|
|
codexDir: join(home, '.codex'),
|
|
projectsDir,
|
|
dbPath,
|
|
writerLeaseWaitMs: 1500,
|
|
};
|
|
const holder = spawnChild('holder', { lockPath });
|
|
const holderLines = lineReader(holder);
|
|
await holderLines.waitFor('READY');
|
|
const contendedBuild = spawnChild('build', { options: buildOptions });
|
|
const buildLines = lineReader(contendedBuild);
|
|
await buildLines.waitFor('STARTING');
|
|
const startedAt = Date.now();
|
|
await delay(200);
|
|
holder.stdin.write('release\n');
|
|
const resultLine = await buildLines.waitFor('RESULT ');
|
|
const result3 = JSON.parse(resultLine.slice('RESULT '.length));
|
|
const waitedMs = Date.now() - startedAt;
|
|
assert(result3.deferred === false, `contended build completed, reason=${result3.reason}`);
|
|
assert(result3.skipped === 0, `contended build skipped no files, got ${result3.skipped}`);
|
|
assert(waitedMs >= 150, `build overlapped the held lease for ${waitedMs}ms`);
|
|
await Promise.all([waitForSuccess(holder), waitForSuccess(contendedBuild)]);
|
|
|
|
console.log('--- Test 4: persistent writer contention is bounded ---');
|
|
const persistentHolder = spawnChild('holder', { lockPath });
|
|
const persistentHolderLines = lineReader(persistentHolder);
|
|
await persistentHolderLines.waitFor('READY');
|
|
const boundedBuild = spawnChild('build', { options: { ...buildOptions, writerLeaseWaitMs: 200 } });
|
|
const boundedLines = lineReader(boundedBuild);
|
|
await boundedLines.waitFor('STARTING');
|
|
const boundedStartedAt = Date.now();
|
|
const boundedResultLine = await boundedLines.waitFor('RESULT ');
|
|
const boundedResult = JSON.parse(boundedResultLine.slice('RESULT '.length));
|
|
const boundedMs = Date.now() - boundedStartedAt;
|
|
assert(boundedResult.deferred === true, 'persistent contention returns deferred');
|
|
assert(boundedResult.reason === 'writer_busy', `persistent contention reason=${boundedResult.reason}`);
|
|
assert(boundedMs < 1000, `persistent contention returned within budget (${boundedMs}ms)`);
|
|
persistentHolder.stdin.write('release\n');
|
|
await Promise.all([waitForSuccess(persistentHolder), waitForSuccess(boundedBuild)]);
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
console.log('---');
|
|
console.log(failures ? `${failures} TEST(S) FAILED` : 'ALL TESTS PASSED');
|
|
process.exitCode = failures ? 1 : 0;
|
|
}
|
|
|
|
app.whenReady().then(run).finally(() => app.quit());
|