/** * Application-level retry helpers for SQLITE_BUSY contention. * * SQLite's engine-level `busy_timeout` pragma (set to 30000ms in * {@link applyPerfPragmas}) makes a single statement wait up to 30s for a * competing writer to release its lock. That alone is NOT enough for * highly-parallel CLI invocations of `cleo update --add-labels ...` * (gh#391): up to ~50% of writes still fail with `SQLITE_BUSY: database * is locked` when ≥10 processes hammer the same database from one shell. * * This module adds a **second layer** above the pragma — an async retry * with capped exponential backoff and jitter that wraps the entire * outermost transactional block (BEGIN IMMEDIATE … COMMIT) so contention * is absorbed by re-issuing the whole transaction rather than retrying * individual statements inside an already-rolled-back transaction. * * **Layering contract**: * * 1. `busy_timeout=30000ms` (engine-level, in {@link applyPerfPragmas}) — * handles the *common* case where a competing writer commits within * 30 seconds. The statement waits in-place; no application code runs. * 2. `withWriteRetry` (this module, app-level) — handles the *uncommon* * case where the engine-level wait expires (or the writer holds a * RESERVED lock during a long transaction). Retries the entire * outermost transaction 4 times with 100/200/400/800ms (± 50ms * jitter) backoff before throwing {@link E_WRITE_CONTENTION}. * * Total worst-case latency is therefore bounded by * `MAX_ATTEMPTS × busy_timeout + sum(backoffs)` ≈ `4 × 30000 + 1500` = * `~121.5s`. In practice the first retry usually succeeds because the * competing writer has committed by then. * * **Why a separate module from migration-manager.ts**: the migration * runner has its OWN retry loop (synchronous, uses `Atomics.wait`, * scoped to `migrate()` calls). This module is the canonical home for * the async, transaction-wrapping retry primitive used by routine * task-row mutations. `isSqliteBusy` is co-located here so all callers * import it from one place; `migration-manager.ts` re-exports for * backward compatibility. * * @bug gh-391 — parallel `cleo update --add-labels` lost ~50% of writes * to SQLITE_BUSY before this primitive was introduced. * @task T9839 — SG-GH-TRIAGE-2026-05-21 saga member; see PR for evidence. * * @example * ```ts * import { withWriteRetry } from './with-retry.js'; * * await withWriteRetry(async () => { * nativeDb.prepare('BEGIN IMMEDIATE').run(); * try { * await db.update(schema.tasks).set({ ... }).where(...).run(); * nativeDb.prepare('COMMIT').run(); * } catch (err) { * nativeDb.prepare('ROLLBACK').run(); * throw err; * } * }); * ``` */ import type { DatabaseSync } from 'node:sqlite'; import type { NodeSQLiteDatabase } from 'drizzle-orm/node-sqlite'; /** * Check if an error indicates SQLite was BUSY (lock held by another * connection or process). Matches both `SQLITE_BUSY` literal error * codes and the `database is locked` message variant emitted by some * driver versions. * * Canonical home for the BUSY-detection predicate. `migration-manager.ts` * re-exports this symbol for backward compatibility with the existing * `import { isSqliteBusy } from '../sqlite.js'` chain. * * @param err - The thrown value to inspect. Non-Error values (string, * null, undefined, number, object literal) always return false. * @returns `true` if the error, or one of its first few `cause` links (a * driver wrapper such as drizzle's DrizzleQueryError), carries a SQLITE_BUSY * message or result code (T13615). * * @task T5185 — original implementation; relocated from migration-manager.ts. * @bug gh-391 * * @example * ```ts * try { * db.prepare('BEGIN IMMEDIATE').run(); * } catch (err) { * if (isSqliteBusy(err)) { * // contention — caller decides whether to retry * } else { * throw err; // genuine failure * } * } * ``` */ export declare function isSqliteBusy(err: unknown): boolean; /** * Options for {@link withWriteRetry}. */ export interface WithWriteRetryOptions { /** * Maximum total attempts (including the first call). Default 4. * A value of 1 disables retry entirely; values <1 are coerced to 1. */ maxAttempts?: number; /** * Base backoff delay in milliseconds. Each retry waits * `baseDelayMs × 2^(attempt-1)` ± jitter. Default 100. */ baseDelayMs?: number; /** * Uniform jitter window in milliseconds applied symmetrically around * the computed delay (so actual delay ∈ [delay − jitterMs, delay + jitterMs]). * Capped so the delay never drops below 0. Default 50. */ jitterMs?: number; /** * Optional callback invoked AFTER each failed attempt (before the * delay actually elapses). Lets callers instrument retry behaviour * for tests or telemetry. * * @param attempt - The 1-indexed attempt number that just failed. * @param delayMs - The computed delay (post-jitter) until the next attempt. * @param err - The SQLITE_BUSY error that triggered the retry. */ onRetry?: (attempt: number, delayMs: number, err: unknown) => void; } /** * Wrap an async (or sync) write function in an application-level * SQLITE_BUSY retry loop with capped exponential backoff and jitter. * * Behaviour: * - If `fn()` resolves normally, returns the result. * - If `fn()` throws and `isSqliteBusy(err)` is true, sleeps for * `baseDelayMs × 2^(attempt-1)` ± jitter and re-runs `fn()`. Repeats * until success or `maxAttempts` exhausted. * - If `fn()` throws a non-BUSY error, propagates IMMEDIATELY without retry. * - When all attempts exhaust on BUSY, throws an Error with `.code = * 'E_WRITE_CONTENTION'` and a `.cause` set to the last BUSY error. * * **CRITICAL**: `fn` MUST own the outermost transaction boundary * (BEGIN IMMEDIATE/COMMIT or SAVEPOINT). better-sqlite3 / node:sqlite * throws if `BEGIN IMMEDIATE` is executed while a transaction is * already open, so nesting `withWriteRetry` calls is a bug. Wrap the * OUTERMOST boundary only. * * @param fn - The function to retry. May be sync or async. * @param opts - Optional override of attempt count, base delay, jitter, * or instrumentation callback. * @returns The resolved value of `fn`. * @throws Error with `.code === 'E_WRITE_CONTENTION'` after exhausting retries. * @throws Original error (unwrapped) if it is NOT a SQLITE_BUSY error. * * @bug gh-391 * @task T9839 * * @example * ```ts * await withWriteRetry(() => { * nativeDb.prepare('BEGIN IMMEDIATE').run(); * try { * // ... writes ... * nativeDb.prepare('COMMIT').run(); * } catch (e) { * nativeDb.prepare('ROLLBACK').run(); * throw e; * } * }, { maxAttempts: 4, baseDelayMs: 100 }); * ``` */ export declare function withWriteRetry(fn: () => T | Promise, opts?: WithWriteRetryOptions): Promise; /** * How long a best-effort heartbeat write waits for another connection's write * lock, in milliseconds (T12502 · T12540). A beat that would wait longer is * skipped; the next mutation beats instead. */ export declare const HEARTBEAT_BUSY_TIMEOUT_MS = 50; /** * Run ONE best-effort heartbeat write that never waits on a contended lock * (T12502 claim renewal · T12540 session `lastActivity`). * * node:sqlite is SYNCHRONOUS: a write stuck behind another connection's lock * waits the full `busy_timeout` (30 s) ON the event loop, where no timer can * interrupt it. So this lowers `busy_timeout` for the one statement and treats * `SQLITE_BUSY` as "skip this beat". A single statement autocommits atomically, * and set → run → restore runs with no `await` in between, so no other caller * ever sees the short timeout. A connection that is mid-transaction belongs to * another caller — joining it would commit or roll back with that caller's * work — so the beat is skipped then too. * * @param db - Drizzle handle over `native`. * @param native - The native connection (its transaction state is checked). * @param write - The single synchronous write statement. * @param busyTimeoutMs - Lock wait for this one statement. * @returns The write's result, or `null` when the beat was skipped. * @throws Any error other than `SQLITE_BUSY` raised by `write`. * @example * ```ts * const changes = runHeartbeatWrite(db, native, () => * Number(db.update(schema.sessions).set({ lastActivity }).where(eq(schema.sessions.id, id)).run().changes), * ) ?? 0; * ``` * @task T12540 */ export declare function runHeartbeatWrite(db: NodeSQLiteDatabase, native: DatabaseSync | null, write: () => T, busyTimeoutMs?: number): T | null; //# sourceMappingURL=with-retry.d.ts.map