akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
171 lines (170 loc) • 8.58 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* `BEGIN IMMEDIATE` transactions and the one SQLite contention classifier.
*
* `db.transaction()` is DEFERRED by default on both Bun and better-sqlite3,
* which means two writers can both perform stale preflight reads and only race
* when they finally attempt the write. Proposal creation, queue mutation and
* schema migration need the write lock BEFORE those reads so concurrent
* processes serialize on the live state rather than clobbering each other.
*
* Every open already carries a 30 s `busy_timeout` (`sqlite-pragmas.ts`), so a
* single blocked statement waits before failing. {@link beginImmediateTransaction}
* additionally retries `BEGIN IMMEDIATE` itself ({@link WITH_IMMEDIATE_TX_MAX_ATTEMPTS}
* attempts) for the rarer case of two writers racing the BEGIN statement
* back-to-back. If every attempt is still contention-shaped
* ({@link isSqliteContentionError} — SQLITE_BUSY/LOCKED, the matching message
* text, or the phantom-BEGIN marker), the exhaustion throw is reclassified
* into `TransientError("STATE_DB_CONTENDED")` (exit 75, #948 addendum) instead
* of surfacing the raw driver text: another akm process (an unrelated
* `improve`, `workflow run`, or task run — not necessarily contending for the
* same row) is writing the database right now. A genuinely unrelated error
* (real corruption, a body-thrown failure) is never reclassified and rethrows
* exactly as raised.
*
* This is the single place SQLITE_BUSY/LOCKED becomes exit 75 for state.db:
* `openStateDatabase`'s migration transaction, every repository write and the
* shared migration runner (`engines/sqlite-migrations.ts`) all go through it.
* `core/state-db.ts` re-exports these helpers for its callers.
*
* @module storage/sqlite-transaction
*/
import { TransientError } from "../core/errors.js";
import { sleepSync } from "../runtime.js";
/**
* Whether `err` is one of the SQLite conditions a concurrent-writer race can
* throw that are transient — the statement did NOT corrupt anything, another
* writer just holds the lock right now — and therefore safe to retry or
* reclassify as ordinary contention rather than a genuine failure:
* - `SQLITE_BUSY` / `SQLITE_LOCKED` (either driver's `.code`).
* - "database is locked" / "database table is locked" message text.
* - the phantom-BEGIN marker synthesized below when `BEGIN IMMEDIATE`
* returns without actually opening a transaction.
*
* This is the single shared classifier for "is this ordinary contention"
* (#948): `reclassifyIndexDbContention` (indexer) and the workflow-runs
* repository's lease-contention classifier (which additionally matches a
* couple of corruption-shaped texts specific to its own narrower cross-process
* race and keeps its own live-lease confirmation before reclassifying) both
* delegate to it. Matching this set alone is never sufficient to declare
* something DEFINITELY contention when a caller needs corroborating evidence
* (see the lease path); for `beginImmediateTransaction`'s own exhaustion case
* there is no such evidence available, so the five 30 s `busy_timeout` waits
* already spent stand as the evidence instead.
*/
export function isSqliteContentionError(err) {
const code = err?.code;
if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED")
return true;
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
return (msg.includes("database is locked") ||
msg.includes("database table is locked") ||
// Phantom BEGIN (see below) — synthesized when BEGIN IMMEDIATE returns
// without opening a transaction. Safe to retry: fn() has not run.
msg.includes("did not open a transaction"));
}
const WITH_IMMEDIATE_TX_MAX_ATTEMPTS = 5;
/**
* Reclassify an exhausted-retry BEGIN failure that is still contention-shaped
* (#948) into a `TransientError("STATE_DB_CONTENDED")`, the same class as a
* held workflow run lock (`RUN_LEASE_HELD`): the driver text is accurate but unhelpful (`{"ok":false,"error":"database is
* locked"}`, exit 70/INTERNAL) — this instead reads as a retryable-shortly
* signal (exit 75, sysexits EX_TEMPFAIL) with the original error preserved as
* `cause` for `--verbose`/debugging. A genuinely unrelated error (not
* contention-shaped) is rethrown exactly as raised, never reclassified.
*/
function throwBeginFailure(err) {
if (isSqliteContentionError(err)) {
const contended = new TransientError("akm's state database is busy (another akm process is writing it); retry shortly.", "STATE_DB_CONTENDED");
contended.cause = err;
throw contended;
}
throw err;
}
/**
* Open, but deliberately do not finish, an immediate transaction.
*
* This is the split-phase counterpart to {@link withImmediateTransaction} for
* the source-update coordinator: index finalization must mutate state.db in a
* transaction that remains pending until content, lockfile, and index
* publication have all succeeded. The caller that asked for this split phase
* owns the matching COMMIT/ROLLBACK.
*
* Only a contention-shaped BEGIN failure is retried. "cannot start a
* transaction within a transaction" is deliberately NOT: it means a
* transaction is already open on this connection (a re-entrant call — handled
* by the entry guard in {@link withImmediateTransaction}), and "retrying" it
* with a ROLLBACK would destroy the caller's transaction (issue #686).
*/
export function beginImmediateTransaction(db) {
if (db.inTransaction) {
throw new Error("beginImmediateTransaction requires a connection with no active transaction");
}
let lastBeginErr;
for (let attempt = 1; attempt <= WITH_IMMEDIATE_TX_MAX_ATTEMPTS; attempt++) {
try {
db.exec("BEGIN IMMEDIATE");
if (!db.inTransaction) {
throw new Error("BEGIN IMMEDIATE did not open a transaction (phantom contention state)");
}
return;
}
catch (err) {
lastBeginErr = err;
if (isSqliteContentionError(err) && attempt < WITH_IMMEDIATE_TX_MAX_ATTEMPTS) {
if (db.inTransaction) {
try {
db.exec("ROLLBACK");
}
catch {
// Transaction already gone — safe to retry BEGIN.
}
}
sleepSync(2 ** (attempt - 1));
continue;
}
throwBeginFailure(err);
}
}
throwBeginFailure(lastBeginErr);
}
/** Run `fn` inside a `BEGIN IMMEDIATE` transaction, joining one that is already open on `db`. */
export function withImmediateTransaction(db, fn) {
// Re-entrancy guard (issue #686): if a transaction is already open on this
// connection (e.g. a nested withImmediateTransaction call inside an outer
// frame's fn), join it — run fn directly with no BEGIN/COMMIT/ROLLBACK of
// our own. Without this, the nested BEGIN throws "cannot start a transaction
// within a transaction", which the old retry path answered with an
// unconditional ROLLBACK — destroying the OUTER transaction and leaving its
// COMMIT to fail with "cannot commit - no transaction is active".
if (db.inTransaction) {
return fn();
}
beginImmediateTransaction(db);
try {
const result = fn();
if (!db.inTransaction) {
// The transaction we opened vanished while fn() ran (e.g. an
// auto-rollback or a stray ROLLBACK inside fn). fn's writes may have
// escaped serialization, so retrying is unsafe — fail loudly instead of
// letting COMMIT throw the opaque "cannot commit - no transaction is
// active" SQLiteError.
throw new Error("withImmediateTransaction invariant violated: transaction opened by BEGIN IMMEDIATE was no longer active after the transaction body ran; refusing to COMMIT (writes may have escaped serialization)");
}
db.exec("COMMIT");
return result;
}
catch (err) {
if (db.inTransaction) {
try {
db.exec("ROLLBACK");
}
catch {
// Ignore rollback failures so the original error is preserved.
}
}
throw err;
}
}