Files
obelisk/tests/write-transaction.test.mjs
T
tommy0103andClaude Opus 4.8 e3e61cc7ab 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>
2026-07-11 00:42:43 +08:00

167 lines
5.2 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { nodeSqliteTransactionAdapter, runWriteTransaction } from '../scripts/tx.ts';
import { hasUnusableTransaction, isBeginBusyFailure, isRetryableWriteFailure } from '../scripts/write-coordinator.ts';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
test('a failed rollback with an active transaction never retries or masks the primary error', () => {
const primary = Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
let active = false;
let workCalls = 0;
const db = {
exec(sql) {
if (sql.startsWith('BEGIN')) {
if (active) throw new Error('cannot start a transaction within a transaction');
active = true;
} else if (sql === 'ROLLBACK') {
throw new Error('rollback failed with I/O error');
} else if (sql === 'COMMIT') {
active = false;
}
},
inTransaction() {
return active;
},
};
assert.throws(
() => runWriteTransaction(db, () => {
workCalls += 1;
throw primary;
}),
error => error === primary,
);
assert.equal(workCalls, 1);
assert.equal(active, true);
});
test('an automatic rollback rethrows the primary error without issuing another rollback', () => {
const primary = Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
let active = false;
let rollbackCalls = 0;
const db = {
exec(sql) {
if (sql.startsWith('BEGIN')) active = true;
if (sql === 'ROLLBACK') rollbackCalls += 1;
},
inTransaction() {
return active;
},
};
assert.throws(
() => runWriteTransaction(db, () => {
active = false;
throw primary;
}),
error => error === primary,
);
assert.equal(rollbackCalls, 0);
assert.equal(primary.obelisk.transactionActive, false);
});
test('an active transaction is rolled back once before the primary error is rethrown', () => {
const primary = new Error('persist failed');
let active = false;
let rollbackCalls = 0;
const db = {
exec(sql) {
if (sql.startsWith('BEGIN')) active = true;
if (sql === 'ROLLBACK') {
rollbackCalls += 1;
active = false;
}
},
inTransaction() {
return active;
},
};
assert.throws(() => runWriteTransaction(db, () => { throw primary; }), error => error === primary);
assert.equal(rollbackCalls, 1);
assert.equal(primary.obelisk.rollbackSucceeded, true);
assert.equal(primary.obelisk.transactionActive, false);
});
test('an unknown post-error transaction state is unsafe for the next file', () => {
const primary = new Error('transaction state unavailable');
const db = {
exec() {},
inTransaction() {
throw new Error('binding cannot report transaction state');
},
};
assert.throws(() => runWriteTransaction(db, () => { throw primary; }), error => error === primary);
assert.equal(primary.obelisk.transactionActive, null);
assert.equal(hasUnusableTransaction(primary), true);
});
test('node:sqlite generic error codes preserve BUSY classification from the message', () => {
const primary = Object.assign(new Error('database is locked'), { code: 'ERR_SQLITE_ERROR' });
let active = false;
const db = {
exec(sql) {
if (sql === 'BEGIN IMMEDIATE') active = true;
},
inTransaction() { return active; },
};
assert.throws(() => runWriteTransaction(db, () => {
active = false;
throw primary;
}), error => error === primary);
assert.equal(primary.obelisk.code, 'SQLITE_BUSY');
assert.equal(isRetryableWriteFailure(primary), true);
});
test('a real node:sqlite BEGIN lock is classified as a deferrable BUSY', () => {
const dbPath = join(mkdtempSync(join(tmpdir(), 'obelisk-node-sqlite-busy-')), 'index.sqlite');
const holder = new DatabaseSync(dbPath);
const contender = new DatabaseSync(dbPath);
holder.exec('PRAGMA busy_timeout=0; CREATE TABLE test (value TEXT); BEGIN IMMEDIATE');
contender.exec('PRAGMA busy_timeout=0');
try {
assert.throws(
() => runWriteTransaction(nodeSqliteTransactionAdapter(contender), () => {}),
error => isBeginBusyFailure(error) && error.obelisk.code === 'SQLITE_BUSY',
);
} finally {
holder.exec('ROLLBACK');
holder.close();
contender.close();
}
});
test('a BEGIN failure with an active transaction is not deferrable', () => {
const primary = Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
let rollbackCalls = 0;
const db = {
exec(sql) {
if (sql === 'BEGIN IMMEDIATE') throw primary;
if (sql === 'ROLLBACK') {
rollbackCalls += 1;
throw new Error('rollback failed with I/O error');
}
},
inTransaction() {
return true;
},
};
assert.throws(() => runWriteTransaction(db, () => {}), error => error === primary);
assert.equal(rollbackCalls, 1);
assert.equal(primary.obelisk.transactionActive, true);
assert.equal(hasUnusableTransaction(primary), true);
assert.equal(isBeginBusyFailure(primary), false);
});