fix: coordinate sqlite index writers

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>
This commit is contained in:
tommy0103
2026-07-11 00:42:43 +08:00
co-authored by Claude Opus 4.8
parent 44029676d4
commit e3e61cc7ab
25 changed files with 2173 additions and 471 deletions
+42 -9
View File
@@ -8,6 +8,7 @@ const DEFAULT_DEBOUNCE_MS = 2000;
const DEFAULT_STABILITY_MS = 500;
const DEFAULT_HEARTBEAT_MS = 30000;
const DEFAULT_WATCH_RETRY_MS = 5000;
const DEFAULT_DEFERRED_RETRY_MS = 250;
type TimerHandle = ReturnType<typeof setTimeout>;
@@ -22,6 +23,15 @@ interface Watcher {
close(): unknown;
}
interface IndexerBuildResult {
deferred?: boolean;
}
type IndexerBuild = (args: {
reason?: string;
changedPaths?: string[];
}) => IndexerBuildResult | void | Promise<IndexerBuildResult | void>;
interface IndexerServiceOptions {
projectsDir?: string;
watchDirs?: string | string[];
@@ -29,8 +39,9 @@ interface IndexerServiceOptions {
stabilityMs?: number;
heartbeatMs?: number;
watchRetryMs?: number;
buildIndex?: (args: { reason?: string; changedPaths?: string[] }) => unknown;
writeHeartbeat?: () => void;
deferredRetryMs?: number;
buildIndex?: IndexerBuild;
writeHeartbeat?: () => unknown;
watchProjects?: (onChange: (changedPath: string) => void) => Watcher | null;
chokidar?: any;
timers?: Timers;
@@ -44,6 +55,7 @@ function createIndexerService({
stabilityMs = DEFAULT_STABILITY_MS,
heartbeatMs = DEFAULT_HEARTBEAT_MS,
watchRetryMs = DEFAULT_WATCH_RETRY_MS,
deferredRetryMs = DEFAULT_DEFERRED_RETRY_MS,
buildIndex,
writeHeartbeat = () => {},
watchProjects,
@@ -100,6 +112,7 @@ function createIndexerService({
let stabilityTimer: TimerHandle | null = null;
let heartbeatTimer: TimerHandle | null = null;
let watchRetryTimer: TimerHandle | null = null;
let deferredRetryTimer: TimerHandle | null = null;
let watcher: Watcher | null = null;
let stopped = false;
let running = false;
@@ -124,6 +137,15 @@ function createIndexerService({
return paths;
};
const publishHeartbeat = () => {
try {
return writeHeartbeat();
} catch (error) {
logger.warn?.(`Obelisk heartbeat failed: ${(error as Error).message}`);
return false;
}
};
const runBuildNow = (reason = "manual", paths: string[] | undefined = undefined) => {
addChangedPath(paths);
if (stopped) return idlePromise;
@@ -135,8 +157,18 @@ function createIndexerService({
pending = false;
const buildChangedPaths = takeChangedPaths();
idlePromise = (async () => {
await buildIndex({ reason, changedPaths: buildChangedPaths });
writeHeartbeat();
const result = await buildIndex({ reason, changedPaths: buildChangedPaths });
if (result?.deferred) {
addChangedPath(buildChangedPaths);
if (!stopped && !deferredRetryTimer) {
deferredRetryTimer = timers.setTimeout(() => {
deferredRetryTimer = null;
runBuildNow('writer-lease');
}, deferredRetryMs);
}
return;
}
publishHeartbeat();
})()
.catch((error) => {
// A build in flight when the service is stopped (e.g. a manual rebuild
@@ -158,6 +190,8 @@ function createIndexerService({
addChangedPath(changedPath);
lastReason = reason;
if (running) pending = true;
if (deferredRetryTimer) timers.clearTimeout(deferredRetryTimer);
deferredRetryTimer = null;
if (buildTimer) timers.clearTimeout(buildTimer);
if (stabilityTimer) timers.clearTimeout(stabilityTimer);
buildTimer = timers.setTimeout(() => {
@@ -186,15 +220,12 @@ function createIndexerService({
const start = ({ buildOnStart = true } = {}) => {
stopped = false;
publishHeartbeat();
if (buildOnStart) scheduleBuild('startup');
startWatching();
if (typeof timers.setInterval === 'function') {
heartbeatTimer = timers.setInterval(() => {
try {
writeHeartbeat();
} catch (error) {
logger.warn?.(`Obelisk heartbeat failed: ${(error as Error).message}`);
}
publishHeartbeat();
}, heartbeatMs);
}
};
@@ -208,6 +239,8 @@ function createIndexerService({
stabilityTimer = null;
if (watchRetryTimer) timers.clearTimeout(watchRetryTimer);
watchRetryTimer = null;
if (deferredRetryTimer) timers.clearTimeout(deferredRetryTimer);
deferredRetryTimer = null;
if (heartbeatTimer && typeof timers.clearInterval === 'function') timers.clearInterval(heartbeatTimer);
heartbeatTimer = null;
if (watcher?.close) watcher.close();