cairnq 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,7 +46,9 @@ create table if not exists cairnq_tasks (
46
46
  );
47
47
 
48
48
  -- Serves the claim query: WHERE queue=? AND status='queued' ORDER BY priority
49
- -- desc, created_at_ms asc (run_at_ms applied as a residual filter).
49
+ -- desc, created_at_ms asc (run_at_ms applied as a residual filter). Only
50
+ -- claim_one_queue.sql can read it in claim order; claim.sql's array-valued queue
51
+ -- filter forces a sort, and past a few thousand queued rows a sequential scan.
50
52
  create index if not exists cairnq_tasks_claim_idx
51
53
  on cairnq_tasks (queue, status, priority desc, created_at_ms);
52
54
  create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
@@ -0,0 +1,16 @@
1
+ -- Serves recover_leases.sql: find tasks whose lease expired. That statement runs
2
+ -- on every worker's every poll, inside the claim transaction, so it is the one
3
+ -- recovery read on the hot path. See the SQLite twin for the reasoning; it holds
4
+ -- here too, and additionally keeps the FOR UPDATE SKIP LOCKED subquery from
5
+ -- taking row locks it will immediately skip.
6
+ --
7
+ -- Not CONCURRENTLY: migrations run inside the same transaction as their
8
+ -- bookkeeping insert (see PROTOCOL.md), and CREATE INDEX CONCURRENTLY cannot run
9
+ -- in one. On a table this size the plain form's lock is brief; a deployment large
10
+ -- enough to care can build it by hand ahead of the upgrade, and IF NOT EXISTS
11
+ -- makes this migration a no-op then.
12
+ create index if not exists cairnq_tasks_lease_idx
13
+ on cairnq_tasks (lease_until_ms)
14
+ where status = 'running' and lease_until_ms is not null;
15
+
16
+ update cairnq_meta set value = '4' where key = 'schema_version';
@@ -0,0 +1,17 @@
1
+ -- Make the lease invariant true of the whole table, not just of rows written from
2
+ -- here on: `lease_until_ms is not null` if and only if `status = 'running'`.
3
+ --
4
+ -- succeed.sql and complete.sql used to leave the dead attempt's lease behind, so a
5
+ -- succeeded task reported a lease into the future and nobody owned it. The crash
6
+ -- path (recover_leases.sql) always cleared it, so the two ways out of 'running'
7
+ -- disagreed on what a terminal row looks like. The statements now agree; this
8
+ -- catches up the rows they already wrote.
9
+ --
10
+ -- Scoped to `status <> 'running'` rather than to the three terminal states: that is
11
+ -- the invariant itself, and it also covers a 'queued' row should one ever be left
12
+ -- holding a lease.
13
+ update cairnq_tasks
14
+ set lease_until_ms = null
15
+ where status <> 'running' and lease_until_ms is not null;
16
+
17
+ update cairnq_meta set value = '5' where key = 'schema_version';
@@ -45,7 +45,9 @@ create table if not exists cairnq_tasks (
45
45
 
46
46
  -- Serves the claim query: WHERE queue=? AND status='queued' ORDER BY priority
47
47
  -- desc, created_at_ms asc (run_at_ms applied as a residual filter). Leading with
48
- -- queue+status then the ORDER BY columns avoids a sort for single-queue claims.
48
+ -- queue+status then the ORDER BY columns is what lets claim_one_queue.sql read
49
+ -- rows in claim order; claim.sql's list-valued queue filter has to merge several
50
+ -- ranges of this index, so it sorts instead.
49
51
  create index if not exists cairnq_tasks_claim_idx
50
52
  on cairnq_tasks (queue, status, priority desc, created_at_ms);
51
53
  create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
@@ -0,0 +1,22 @@
1
+ -- Serves recover_leases.sql: find tasks whose lease expired. That statement runs
2
+ -- on every worker's every poll, inside the claim transaction, so it is the one
3
+ -- recovery read on the hot path.
4
+ --
5
+ -- Partial, because the rows it looks for are a tiny slice of the table: only
6
+ -- 'running' rows can hold a lease, and their count is bounded by total worker
7
+ -- concurrency, while terminal rows accumulate until purge. A partial index stays
8
+ -- that small no matter how large the table grows, and — unlike the plain
9
+ -- cairnq_tasks_status_idx it replaces on this path — it carries lease_until_ms,
10
+ -- so the expiry test is a range scan rather than a row visit per running task.
11
+ --
12
+ -- Both predicate terms appear verbatim in recover_leases.sql's WHERE clause:
13
+ -- SQLite only uses a partial index when it can match each term syntactically.
14
+ -- It also needs statistics to prefer it, which is why both SDKs run
15
+ -- `PRAGMA optimize` when they open a database.
16
+ --
17
+ -- On the missing 0003 and the schema_version jump, see PROTOCOL.md §Versioning.
18
+ create index if not exists cairnq_tasks_lease_idx
19
+ on cairnq_tasks (lease_until_ms)
20
+ where status = 'running' and lease_until_ms is not null;
21
+
22
+ update cairnq_meta set value = '4' where key = 'schema_version';
@@ -0,0 +1,17 @@
1
+ -- Make the lease invariant true of the whole table, not just of rows written from
2
+ -- here on: `lease_until_ms is not null` if and only if `status = 'running'`.
3
+ --
4
+ -- succeed.sql and complete.sql used to leave the dead attempt's lease behind, so a
5
+ -- succeeded task reported a lease into the future and nobody owned it. The crash
6
+ -- path (recover_leases.sql) always cleared it, so the two ways out of 'running'
7
+ -- disagreed on what a terminal row looks like. The statements now agree; this
8
+ -- catches up the rows they already wrote.
9
+ --
10
+ -- Scoped to `status <> 'running'` rather than to the three terminal states: that is
11
+ -- the invariant itself, and it also covers a 'queued' row should one ever be left
12
+ -- holding a lease.
13
+ update cairnq_tasks
14
+ set lease_until_ms = null
15
+ where status <> 'running' and lease_until_ms is not null;
16
+
17
+ update cairnq_meta set value = '5' where key = 'schema_version';
@@ -0,0 +1,35 @@
1
+ -- claim, for a caller watching exactly ONE queue. Byte-for-byte claim.sql except
2
+ -- that the queue filter is an equality on :queue instead of `= any(:queues)` — a
3
+ -- drift-guard test asserts precisely that, so treat claim.sql as the source and
4
+ -- re-derive this file when it changes.
5
+ --
6
+ -- It exists because the array form cannot be read in claim order: with ORDER BY
7
+ -- + LIMIT over `= any(...)` the planner falls back to a sequential scan and a
8
+ -- full sort of every claimable row (measured on 20k queued: Seq Scan 20000 rows,
9
+ -- quicksort 1861kB), inside the transaction that holds the claim. The equality
10
+ -- form index-scans cairnq_tasks_claim_idx and only incrementally sorts the id
11
+ -- tie-break — 33 rows read for the same query.
12
+ --
13
+ -- params: queue, names (text[] or null), worker_id, lease_ms, limit
14
+ update cairnq_tasks t
15
+ set
16
+ status = 'running',
17
+ worker_id = :worker_id,
18
+ lease_until_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint + :lease_ms,
19
+ attempt = attempt + 1,
20
+ updated_at_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint
21
+ from (
22
+ select id from cairnq_tasks
23
+ where status = 'queued'
24
+ and queue = :queue
25
+ and (:names::text[] is null or name = any(:names::text[]))
26
+ and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
27
+ -- id breaks created_at_ms ties (same-millisecond submits), so claim order
28
+ -- is deterministic: FIFO at millisecond granularity; within one millisecond
29
+ -- the id's random half decides, stably but not in submit order.
30
+ order by priority desc, created_at_ms asc, id asc
31
+ limit :limit
32
+ for update skip locked
33
+ ) sel
34
+ where t.id = sel.id
35
+ returning t.*;
@@ -8,6 +8,9 @@ set
8
8
  status = case when cancel_requested_at_ms is not null then 'canceled' else 'succeeded' end,
9
9
  result = case when cancel_requested_at_ms is not null then result else :result::jsonb end,
10
10
  progress = case when cancel_requested_at_ms is not null then progress else 1.0 end,
11
+ -- Terminal on both branches, so unconditional: a lease describes an attempt in
12
+ -- flight and this one just ended (see succeed.sql).
13
+ lease_until_ms = null,
11
14
  completed_at_ms = (extract(epoch from now()) * 1000)::bigint,
12
15
  updated_at_ms = (extract(epoch from now()) * 1000)::bigint
13
16
  where id = :id
@@ -23,8 +23,10 @@ set
23
23
  error = :error::jsonb,
24
24
  worker_id = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
25
25
  then null else worker_id end,
26
- lease_until_ms = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
27
- then null else lease_until_ms end,
26
+ -- Unconditional, unlike worker_id above: all three branches end the attempt
27
+ -- that held the lease — two terminally, one to wait for redelivery — and none
28
+ -- of them leaves anyone owning it (see succeed.sql).
29
+ lease_until_ms = null,
28
30
  run_at_ms = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
29
31
  then (extract(epoch from now()) * 1000)::bigint + :delay_ms else run_at_ms end,
30
32
  completed_at_ms = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
@@ -38,7 +38,18 @@ where id in (
38
38
  select id from cairnq_tasks
39
39
  where status = 'running'
40
40
  and lease_until_ms is not null
41
- and lease_until_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
41
+ -- The scalar subselect is load-bearing, not noise: clock_timestamp() is
42
+ -- VOLATILE, so inlined here it becomes a per-row filter and this scan
43
+ -- degrades to reading every running task (20k in flight: Seq Scan 20000
44
+ -- rows, 19997 removed by filter). Wrapped, it is an InitPlan evaluated once
45
+ -- and usable as an index bound on cairnq_tasks_lease_idx — same 20k table,
46
+ -- 3 rows read. Do not "simplify" it away.
47
+ --
48
+ -- Deliberately not now(): that is STABLE and would also index, but it
49
+ -- freezes at BEGIN, and this runs in a transaction that may have waited on
50
+ -- row locks — the cutoff would then be older than the caller thinks and
51
+ -- leave expired leases behind for another poll.
52
+ and lease_until_ms <= (select (extract(epoch from clock_timestamp()) * 1000)::bigint)
42
53
  for update skip locked
43
54
  )
44
55
  returning *;
@@ -7,6 +7,10 @@ set
7
7
  result = :result::jsonb,
8
8
  progress = 1.0,
9
9
  message = coalesce(:message, message),
10
+ -- A lease describes an attempt in flight; this one just ended. worker_id is
11
+ -- what carries the audit trail (who ran it), so nothing is lost by clearing
12
+ -- it, and the terminal-lease invariant holds — see PROTOCOL.md §Lease model.
13
+ lease_until_ms = null,
10
14
  completed_at_ms = (extract(epoch from now()) * 1000)::bigint,
11
15
  updated_at_ms = (extract(epoch from now()) * 1000)::bigint
12
16
  where id = :id
@@ -0,0 +1,36 @@
1
+ -- claim, for a caller watching exactly ONE queue. Byte-for-byte claim.sql except
2
+ -- that the queue filter is an equality on :queue instead of an IN over :queues —
3
+ -- a drift-guard test asserts precisely that, so treat claim.sql as the source and
4
+ -- re-derive this file when it changes.
5
+ --
6
+ -- It exists because the IN form costs a full sort. json_each() hides the list's
7
+ -- length from the planner, so SQLite must merge several index ranges and can no
8
+ -- longer read rows in claim order; it materializes every claimable row into a
9
+ -- temp B-tree just to take LIMIT of them. Cost then grows with the queued
10
+ -- backlog, inside the write transaction, on every claim: measured at 21us / 239us
11
+ -- / 1792us for a backlog of 50 / 2000 / 20000. The equality form keeps
12
+ -- cairnq_tasks_claim_idx in claim order, needs only a partial sort for the id
13
+ -- tie-break, and stays flat at ~12us.
14
+ --
15
+ -- params: queue, names (JSON array text or null), now_ms, worker_id,
16
+ -- lease_until_ms, limit
17
+ update cairnq_tasks
18
+ set
19
+ status = 'running',
20
+ worker_id = :worker_id,
21
+ lease_until_ms = :lease_until_ms,
22
+ attempt = attempt + 1,
23
+ updated_at_ms = :now_ms
24
+ where id in (
25
+ select id from cairnq_tasks
26
+ where status = 'queued'
27
+ and queue = :queue
28
+ and (:names is null or name in (select value from json_each(:names)))
29
+ and run_at_ms <= :now_ms
30
+ -- id breaks created_at_ms ties (same-millisecond submits), so claim order
31
+ -- is deterministic: FIFO at millisecond granularity; within one millisecond
32
+ -- the id's random half decides, stably but not in submit order.
33
+ order by priority desc, created_at_ms asc, id asc
34
+ limit :limit
35
+ )
36
+ returning *;
@@ -9,6 +9,9 @@ set
9
9
  status = case when cancel_requested_at_ms is not null then 'canceled' else 'succeeded' end,
10
10
  result = case when cancel_requested_at_ms is not null then result else :result end,
11
11
  progress = case when cancel_requested_at_ms is not null then progress else 1.0 end,
12
+ -- Terminal on both branches, so unconditional: a lease describes an attempt in
13
+ -- flight and this one just ended (see succeed.sql).
14
+ lease_until_ms = null,
12
15
  completed_at_ms = :now_ms,
13
16
  updated_at_ms = :now_ms
14
17
  where id = :id
@@ -23,8 +23,10 @@ set
23
23
  error = :error,
24
24
  worker_id = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
25
25
  then null else worker_id end,
26
- lease_until_ms = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
27
- then null else lease_until_ms end,
26
+ -- Unconditional, unlike worker_id above: all three branches end the attempt
27
+ -- that held the lease — two terminally, one to wait for redelivery — and none
28
+ -- of them leaves anyone owning it (see succeed.sql).
29
+ lease_until_ms = null,
28
30
  run_at_ms = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
29
31
  then :now_ms + :delay_ms else run_at_ms end,
30
32
  completed_at_ms = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
@@ -6,6 +6,10 @@ set
6
6
  result = :result,
7
7
  progress = 1.0,
8
8
  message = coalesce(:message, message),
9
+ -- A lease describes an attempt in flight; this one just ended. worker_id is
10
+ -- what carries the audit trail (who ran it), so nothing is lost by clearing
11
+ -- it, and the terminal-lease invariant holds — see PROTOCOL.md §Lease model.
12
+ lease_until_ms = null,
9
13
  completed_at_ms = :now_ms,
10
14
  updated_at_ms = :now_ms
11
15
  where id = :id
@@ -74,7 +74,14 @@ export declare abstract class TaskStore {
74
74
  abstract protocolVersion(): Promise<number>;
75
75
  /** Run one protocol statement outside a transaction, connecting if needed. */
76
76
  protected abstract fetch(name: string, params: Params): Promise<any[]>;
77
- /** Run several statements atomically; `fn` receives a Fetch bound to the txn. */
77
+ /**
78
+ * Run several statements atomically; `fn` receives a Fetch bound to the txn.
79
+ *
80
+ * A backend may invoke `fn` more than once, retrying the transaction after a
81
+ * transient failure (SQLite does, on write-lock contention). So `fn` must be
82
+ * replayable: derive nothing inside it that the caller cannot derive twice —
83
+ * build ids and payloads before opening the transaction, not within it.
84
+ */
78
85
  protected abstract tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
79
86
  /**
80
87
  * Whether it is worth opening the claim transaction at all. SQLite gates its
@@ -293,8 +293,14 @@ export class TaskStore {
293
293
  * array claims nothing.
294
294
  */
295
295
  async claim(input) {
296
+ // One queue is the common case and gets its own statement: a list-valued queue
297
+ // filter cannot be read in claim order, so the planner sorts every claimable
298
+ // row to take LIMIT of them, and claim's cost grows with the queued backlog
299
+ // while it holds the claim transaction. See claim_one_queue.sql.
300
+ const oneQueue = input.queues.length === 1;
296
301
  const params = {
297
302
  queues: input.queues,
303
+ queue: oneQueue ? input.queues[0] : null,
298
304
  names: input.names ?? null,
299
305
  worker_id: input.workerId,
300
306
  lease_ms: input.leaseMs ?? 30_000,
@@ -307,7 +313,7 @@ export class TaskStore {
307
313
  // be visible to the claim that follows, and to nobody in between.
308
314
  return this.tx(async (fetch) => {
309
315
  await fetch("recover_leases", params);
310
- return (await fetch("claim", params)).map(rowToTask);
316
+ return (await fetch(oneQueue ? "claim_one_queue" : "claim", params)).map(rowToTask);
311
317
  });
312
318
  }
313
319
  async heartbeat(input) {
@@ -9,17 +9,32 @@ import { type Fetch, type Params, TaskStore } from "./base.js";
9
9
  *
10
10
  * The driver being synchronous suits SQLite's single writer: claim is one short
11
11
  * transaction, the handler runs outside any transaction, and
12
- * progress/heartbeat/succeed/fail are each their own short write. Cross-process
13
- * contention is absorbed by busy_timeout.
12
+ * progress/heartbeat/succeed/fail are each their own short write.
13
+ *
14
+ * Cross-process contention is absorbed by retrying in JavaScript, not by
15
+ * busy_timeout. The two cost the same wait but not the same blocking: a nonzero
16
+ * busy_timeout waits *inside* the synchronous driver, so a caller that loses the
17
+ * write lock stalls this process's event loop for up to the whole timeout — the
18
+ * P99 of an HTTP server that submits tasks. Executing a statement takes
19
+ * microseconds; waiting for a lock takes milliseconds to seconds, and only the
20
+ * second part needs to happen off the thread. So busy_timeout goes to 0 (fail
21
+ * immediately) and the wait becomes an awaited backoff, which the event loop runs
22
+ * through. The budget is the same either way — `busyTimeoutMs`.
23
+ *
24
+ * The open path keeps a real busy_timeout: it is synchronous by nature (WAL
25
+ * switch, migrations) and happens once, under the caller's `connect()`.
14
26
  */
15
27
  export declare class SQLiteStore extends TaskStore {
16
28
  private readonly path;
17
- private readonly opts;
18
29
  private db;
19
30
  private stmts;
20
31
  private readonly statements;
21
32
  /** This store's entry in `fileLocks` — see there for why it is per-database. */
22
33
  private readonly lockKey;
34
+ /** How long a single operation may keep retrying a lost write lock. */
35
+ private readonly busyBudgetMs;
36
+ /** When this connection may next revisit its planner statistics. */
37
+ private nextStatsRefreshAt;
23
38
  constructor(path: string, opts?: {
24
39
  busyTimeoutMs?: number;
25
40
  });
@@ -44,8 +59,41 @@ export declare class SQLiteStore extends TaskStore {
44
59
  */
45
60
  private bind;
46
61
  private runNow;
47
- /** Serialize an operation against every other operation on this database. */
62
+ /** Queue an operation behind every other operation on this database. */
63
+ private enqueue;
64
+ /**
65
+ * Serialize an operation against this database, waiting out a lost write lock on
66
+ * a jittered backoff. Replaces busy_timeout's synchronous wait (see the class
67
+ * comment); on exhausting the budget the original SQLITE_BUSY surfaces, which is
68
+ * what a nonzero busy_timeout would have thrown too.
69
+ *
70
+ * Each attempt re-queues rather than backing off while holding its turn: the
71
+ * contention left to retry is cross-process, and under WAL a *reader* never sees
72
+ * SQLITE_BUSY at all — so sleeping in place would stall this process's reads
73
+ * (including the worker's own poll) on a lock they were never waiting for.
74
+ *
75
+ * Retrying is safe because an attempt is one statement, or one transaction that
76
+ * has already rolled back: nothing partially applied survives it. `fn` may
77
+ * therefore run more than once and must not carry effects of its own — the
78
+ * callers in TaskStore build their ids and payloads before opening one.
79
+ */
48
80
  private withLock;
81
+ /**
82
+ * Revisit this connection's planner statistics, at most once per
83
+ * STATS_REFRESH_INTERVAL_MS.
84
+ *
85
+ * A connection lives for days, and the statements were prepared against whatever
86
+ * the table looked like when it opened — a worker started against an empty
87
+ * database plans as if it were still empty however large the backlog grows. The
88
+ * prepared statements do pick the refreshed plans up: ANALYZE bumps the schema
89
+ * cookie, so SQLite silently re-prepares them on next use. That is what makes
90
+ * this worth doing rather than a restart-only concern.
91
+ *
92
+ * Queued rather than run under `withLock`: statistics are best-effort, so losing
93
+ * the write lock to another process should cost nothing — skip and let the next
94
+ * interval try, instead of spending an operation's whole retry budget on them.
95
+ */
96
+ private maybeRefreshStatistics;
49
97
  protected fetch(name: string, params: Params): Promise<any[]>;
50
98
  protected tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
51
99
  protected hasClaimableWork(params: Params): Promise<boolean>;
@@ -6,29 +6,98 @@ import { loadMigrations, loadStatements } from "../sql.js";
6
6
  import { checkProtocolVersion, statementParams, TaskStore, } from "./base.js";
7
7
  const WAL_RETRY_DELAY_MS = 50;
8
8
  const WAL_RETRY_BUDGET_MS = 5_000;
9
+ const BUSY_RETRY_BASE_MS = 1;
10
+ const BUSY_RETRY_MAX_DELAY_MS = 50;
11
+ /**
12
+ * How often a live connection revisits its planner statistics.
13
+ *
14
+ * Bounds how long the planner can work from a stale table shape; a minute is
15
+ * arbitrary but small next to the days a worker holds its connection. It does not
16
+ * set how often an ANALYZE actually runs — SQLite decides that itself, and only
17
+ * once the table has diverged from its statistics by 10x, so a shorter interval
18
+ * costs more no-ops (a few microseconds each) rather than more analyzing.
19
+ */
20
+ const STATS_REFRESH_INTERVAL_MS = 60_000;
9
21
  /** Sleep without yielding — the whole open path is synchronous already. */
10
22
  function sleepSync(ms) {
11
23
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12
24
  }
25
+ /** Sleep by yielding to the event loop — the point of the busy retry loop. */
26
+ function sleep(ms) {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
29
+ /**
30
+ * Whether this error is SQLite refusing to wait for the write lock.
31
+ *
32
+ * Prefix match: the code carries detail suffixes (`SQLITE_BUSY_SNAPSHOT`). Only
33
+ * SQLITE_BUSY qualifies — SQLITE_LOCKED is same-connection table contention,
34
+ * which the per-file lock prevents and a retry could not resolve anyway.
35
+ */
36
+ function isBusy(err) {
37
+ if (!err || typeof err !== "object")
38
+ return false;
39
+ const code = err.code;
40
+ return typeof code === "string" && code.startsWith("SQLITE_BUSY");
41
+ }
13
42
  /** Whether this path names an in-memory database rather than a file. */
14
43
  function isMemory(path) {
15
44
  return path === ":memory:" || path.includes("mode=memory");
16
45
  }
46
+ /**
47
+ * Whether cairnq_tasks has been analyzed at all.
48
+ *
49
+ * Two steps because sqlite_stat1 does not exist until something runs ANALYZE, and
50
+ * querying a missing table is an error rather than an empty result.
51
+ */
52
+ function hasStatistics(db) {
53
+ const table = db
54
+ .prepare("select 1 from sqlite_master where type = 'table' and name = 'sqlite_stat1'")
55
+ .get();
56
+ if (!table)
57
+ return false;
58
+ return Boolean(db.prepare("select 1 from sqlite_stat1 where tbl = 'cairnq_tasks'").get());
59
+ }
60
+ /**
61
+ * Bring cairnq_tasks' statistics up to date, cheaply enough to call on a timer.
62
+ *
63
+ * Without them the planner misreads `status = 'running'` as a large fraction of the
64
+ * table and passes over the partial cairnq_tasks_lease_idx that lease recovery is
65
+ * indexed for.
66
+ *
67
+ * The explicit bootstrap is not redundant with `PRAGMA optimize`. Before SQLite
68
+ * 3.46 the pragma skips a table that has no sqlite_stat1 entry entirely — no mask
69
+ * changes that, verified on 3.45.1 — so on those builds it can never produce the
70
+ * *first* statistics, and the index stays unused for the life of the database.
71
+ * Distro Pythons link exactly those builds (Ubuntu 24.04 ships 3.45.1), while
72
+ * better-sqlite3 bundles its own newer one, so this is also what keeps the two SDKs
73
+ * behaving alike rather than by luck of packaging.
74
+ *
75
+ * Once an entry exists, every version's pragma applies its own growth heuristic,
76
+ * which is the part worth deferring to: it is a few microseconds when there is
77
+ * nothing to do, where a bare ANALYZE would rescan the table every time.
78
+ */
79
+ function refreshStatistics(db) {
80
+ if (hasStatistics(db))
81
+ db.pragma("optimize");
82
+ // Scoped to the one table whose shape the planner gets wrong; the key and meta
83
+ // tables are read by primary key, where statistics change nothing. A database
84
+ // this one shares with the caller's own tables is left alone.
85
+ else
86
+ db.exec("ANALYZE cairnq_tasks");
87
+ }
17
88
  /**
18
89
  * Serializes every SQLiteStore on one database file, process-wide.
19
90
  *
20
91
  * better-sqlite3 is synchronous, and a transaction holds SQLite's write lock
21
- * across `await`s (the callback seam is shared with Postgres, so it is async). A
22
- * second connection in this process then blocks the only thread waiting for that
23
- * lock, and the holder can never reach COMMIT reaching it needs the thread the
24
- * waiter is sitting on. busy_timeout cannot break that inversion, being one
25
- * thread; the wait just burns the timeout and throws "database is locked". So the
26
- * two must not overlap at all.
92
+ * across `await`s (the callback seam is shared with Postgres, so it is async). Two
93
+ * connections in this process would then contend for that lock the expensive way:
94
+ * every loser spends SQLITE_BUSY retries and backoff on a holder it could simply
95
+ * have queued behind.
27
96
  *
28
97
  * Keyed by database, not by store: what the lock protects is the file. Across
29
- * processes there is no inversion (the holder keeps its own thread) and
30
- * busy_timeout still applies. An in-memory database is private to one connection
31
- * and gets a key of its own.
98
+ * processes there is nothing to serialize from here — each holder has its own
99
+ * thread, and `withLock`'s retry absorbs that contention. An in-memory database is
100
+ * private to one connection and gets a key of its own.
32
101
  */
33
102
  const fileLocks = new Map();
34
103
  let memoryDbSeq = 0;
@@ -56,8 +125,7 @@ function enableWal(db) {
56
125
  return;
57
126
  }
58
127
  catch (err) {
59
- const message = String(err.message ?? err);
60
- if (!/locked|busy/i.test(message))
128
+ if (!isBusy(err))
61
129
  throw err;
62
130
  }
63
131
  if (Date.now() >= deadline) {
@@ -76,21 +144,36 @@ function enableWal(db) {
76
144
  *
77
145
  * The driver being synchronous suits SQLite's single writer: claim is one short
78
146
  * transaction, the handler runs outside any transaction, and
79
- * progress/heartbeat/succeed/fail are each their own short write. Cross-process
80
- * contention is absorbed by busy_timeout.
147
+ * progress/heartbeat/succeed/fail are each their own short write.
148
+ *
149
+ * Cross-process contention is absorbed by retrying in JavaScript, not by
150
+ * busy_timeout. The two cost the same wait but not the same blocking: a nonzero
151
+ * busy_timeout waits *inside* the synchronous driver, so a caller that loses the
152
+ * write lock stalls this process's event loop for up to the whole timeout — the
153
+ * P99 of an HTTP server that submits tasks. Executing a statement takes
154
+ * microseconds; waiting for a lock takes milliseconds to seconds, and only the
155
+ * second part needs to happen off the thread. So busy_timeout goes to 0 (fail
156
+ * immediately) and the wait becomes an awaited backoff, which the event loop runs
157
+ * through. The budget is the same either way — `busyTimeoutMs`.
158
+ *
159
+ * The open path keeps a real busy_timeout: it is synchronous by nature (WAL
160
+ * switch, migrations) and happens once, under the caller's `connect()`.
81
161
  */
82
162
  export class SQLiteStore extends TaskStore {
83
163
  path;
84
- opts;
85
164
  db = null;
86
165
  stmts = {};
87
166
  statements;
88
167
  /** This store's entry in `fileLocks` — see there for why it is per-database. */
89
168
  lockKey;
169
+ /** How long a single operation may keep retrying a lost write lock. */
170
+ busyBudgetMs;
171
+ /** When this connection may next revisit its planner statistics. */
172
+ nextStatsRefreshAt = 0;
90
173
  constructor(path, opts = {}) {
91
174
  super();
92
175
  this.path = path;
93
- this.opts = opts;
176
+ this.busyBudgetMs = opts.busyTimeoutMs ?? 5000;
94
177
  this.statements = loadStatements("sqlite");
95
178
  // Only a bare ":memory:" is guaranteed private to its connection, so only
96
179
  // it gets a lock of its own. A "mode=memory" URI stays path-keyed: with
@@ -118,15 +201,30 @@ export class SQLiteStore extends TaskStore {
118
201
  if (!memory)
119
202
  mkdirSync(dirname(this.path), { recursive: true });
120
203
  const db = new Database(this.path);
121
- // busy_timeout first, so every later statement waits out contention instead
122
- // of failing instantly.
123
- db.pragma(`busy_timeout = ${this.opts.busyTimeoutMs ?? 5000}`);
204
+ // Only the synchronous part of the open path gets a real busy_timeout: the WAL
205
+ // switch and the migrations cannot await a retry. See the class comment.
206
+ db.pragma(`busy_timeout = ${this.busyBudgetMs}`);
124
207
  // WAL exists so several processes can share one file. An in-memory database
125
208
  // is private to this connection, so there is nothing to share or wait for.
126
209
  if (!memory)
127
210
  enableWal(db);
128
211
  db.pragma("foreign_keys = ON");
129
212
  this.applyMigrations(db);
213
+ // Everything past here either awaits its retry or is optional, so stop blocking.
214
+ db.pragma("busy_timeout = 0");
215
+ // Give the query planner statistics (see refreshStatistics), repeated on a timer
216
+ // from here on (see maybeRefreshStatistics).
217
+ try {
218
+ refreshStatistics(db);
219
+ }
220
+ catch (err) {
221
+ // Statistics are an optimization, never correctness, so losing them to a
222
+ // concurrent writer must not fail the open — the next one gets another
223
+ // chance. Anything else is a real fault and belongs to the caller.
224
+ if (!isBusy(err))
225
+ throw err;
226
+ }
227
+ this.nextStatsRefreshAt = Date.now() + STATS_REFRESH_INTERVAL_MS;
130
228
  for (const [name, sql] of Object.entries(this.statements)) {
131
229
  this.stmts[name] = db.prepare(sql);
132
230
  }
@@ -222,23 +320,89 @@ export class SQLiteStore extends TaskStore {
222
320
  }
223
321
  return stmt.all(bound);
224
322
  }
225
- /** Serialize an operation against every other operation on this database. */
226
- withLock(fn) {
323
+ /** Queue an operation behind every other operation on this database. */
324
+ enqueue(fn) {
227
325
  const previous = fileLocks.get(this.lockKey) ?? Promise.resolve();
228
326
  const run = previous.then(fn, fn);
229
327
  fileLocks.set(this.lockKey, run.then(() => undefined, () => undefined));
230
328
  return run;
231
329
  }
330
+ /**
331
+ * Serialize an operation against this database, waiting out a lost write lock on
332
+ * a jittered backoff. Replaces busy_timeout's synchronous wait (see the class
333
+ * comment); on exhausting the budget the original SQLITE_BUSY surfaces, which is
334
+ * what a nonzero busy_timeout would have thrown too.
335
+ *
336
+ * Each attempt re-queues rather than backing off while holding its turn: the
337
+ * contention left to retry is cross-process, and under WAL a *reader* never sees
338
+ * SQLITE_BUSY at all — so sleeping in place would stall this process's reads
339
+ * (including the worker's own poll) on a lock they were never waiting for.
340
+ *
341
+ * Retrying is safe because an attempt is one statement, or one transaction that
342
+ * has already rolled back: nothing partially applied survives it. `fn` may
343
+ * therefore run more than once and must not carry effects of its own — the
344
+ * callers in TaskStore build their ids and payloads before opening one.
345
+ */
346
+ async withLock(fn) {
347
+ const deadline = Date.now() + this.busyBudgetMs;
348
+ let delay = BUSY_RETRY_BASE_MS;
349
+ for (;;) {
350
+ try {
351
+ return await this.enqueue(fn);
352
+ }
353
+ catch (err) {
354
+ if (!isBusy(err) || Date.now() >= deadline)
355
+ throw err;
356
+ // Jitter so several losers don't wake together and collide again.
357
+ await sleep(delay * (0.5 + Math.random()));
358
+ delay = Math.min(delay * 2, BUSY_RETRY_MAX_DELAY_MS);
359
+ }
360
+ }
361
+ }
362
+ /**
363
+ * Revisit this connection's planner statistics, at most once per
364
+ * STATS_REFRESH_INTERVAL_MS.
365
+ *
366
+ * A connection lives for days, and the statements were prepared against whatever
367
+ * the table looked like when it opened — a worker started against an empty
368
+ * database plans as if it were still empty however large the backlog grows. The
369
+ * prepared statements do pick the refreshed plans up: ANALYZE bumps the schema
370
+ * cookie, so SQLite silently re-prepares them on next use. That is what makes
371
+ * this worth doing rather than a restart-only concern.
372
+ *
373
+ * Queued rather than run under `withLock`: statistics are best-effort, so losing
374
+ * the write lock to another process should cost nothing — skip and let the next
375
+ * interval try, instead of spending an operation's whole retry budget on them.
376
+ */
377
+ async maybeRefreshStatistics(db) {
378
+ const now = Date.now();
379
+ if (now < this.nextStatsRefreshAt)
380
+ return;
381
+ // Claim the slot before running, not after: otherwise a burst of concurrent
382
+ // operations all see it due and queue an ANALYZE apiece.
383
+ this.nextStatsRefreshAt = now + STATS_REFRESH_INTERVAL_MS;
384
+ try {
385
+ await this.enqueue(() => refreshStatistics(db));
386
+ }
387
+ catch (err) {
388
+ if (!isBusy(err))
389
+ throw err;
390
+ }
391
+ }
232
392
  async fetch(name, params) {
233
- this.ensure();
393
+ const db = this.ensure();
394
+ await this.maybeRefreshStatistics(db);
234
395
  return this.withLock(() => this.runNow(name, params));
235
396
  }
236
397
  async tx(fn) {
237
398
  const db = this.ensure();
399
+ await this.maybeRefreshStatistics(db);
238
400
  // BEGIN IMMEDIATE by hand rather than db.transaction(): the callback is async
239
401
  // (the seam is shared with Postgres), and better-sqlite3's wrapper only takes
240
402
  // a synchronous one. The lock above makes the manual version safe.
241
403
  return this.withLock(async () => {
404
+ // With busy_timeout at 0 this is where a lost write lock surfaces, and it
405
+ // fails before the transaction exists — so the retry re-runs `fn` cleanly.
242
406
  db.exec("BEGIN IMMEDIATE");
243
407
  try {
244
408
  const out = await fn(async (name, params) => this.runNow(name, params));
@@ -246,11 +410,15 @@ export class SQLiteStore extends TaskStore {
246
410
  return out;
247
411
  }
248
412
  catch (err) {
249
- try {
250
- db.exec("ROLLBACK");
251
- }
252
- catch {
253
- // Already rolled back by SQLite (e.g. a constraint abort).
413
+ // Nothing to roll back when BEGIN was what failed — the common case under
414
+ // contention — or when SQLite already did it (a constraint abort).
415
+ if (db.inTransaction) {
416
+ try {
417
+ db.exec("ROLLBACK");
418
+ }
419
+ catch {
420
+ // Raced with SQLite's own rollback; the transaction is gone either way.
421
+ }
254
422
  }
255
423
  throw err;
256
424
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairnq",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "SQLite-first, cross-language, storage-centered durable task runtime",
5
5
  "license": "MIT",
6
6
  "author": "Jannchie <jannchie@gmail.com>",
@@ -31,7 +31,7 @@
31
31
  }
32
32
  },
33
33
  "files": ["dist", "src"],
34
- "engines": { "node": ">=20" },
34
+ "engines": { "node": ">=22" },
35
35
  "scripts": {
36
36
  "bench": "tsx bench/run.ts",
37
37
  "build": "tsc -p tsconfig.json",
@@ -39,7 +39,7 @@
39
39
  "typecheck": "tsc -p tsconfig.json --noEmit"
40
40
  },
41
41
  "dependencies": {
42
- "better-sqlite3": "^11.3.0"
42
+ "better-sqlite3": "^13.0.2"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "pg": "^8.13.0"
@@ -55,8 +55,8 @@
55
55
  "@types/pg": "^8.11.10",
56
56
  "pg": "^8.13.0",
57
57
  "tsx": "^4.19.0",
58
- "typescript": "^5.6.0",
59
- "vitest": "^2.1.0"
58
+ "typescript": "^7.0.2",
59
+ "vitest": "^4.1.10"
60
60
  },
61
61
  "pnpm": {
62
62
  "onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
package/src/store/base.ts CHANGED
@@ -156,7 +156,14 @@ export abstract class TaskStore {
156
156
 
157
157
  /** Run one protocol statement outside a transaction, connecting if needed. */
158
158
  protected abstract fetch(name: string, params: Params): Promise<any[]>;
159
- /** Run several statements atomically; `fn` receives a Fetch bound to the txn. */
159
+ /**
160
+ * Run several statements atomically; `fn` receives a Fetch bound to the txn.
161
+ *
162
+ * A backend may invoke `fn` more than once, retrying the transaction after a
163
+ * transient failure (SQLite does, on write-lock contention). So `fn` must be
164
+ * replayable: derive nothing inside it that the caller cannot derive twice —
165
+ * build ids and payloads before opening the transaction, not within it.
166
+ */
160
167
  protected abstract tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
161
168
 
162
169
  /**
@@ -378,8 +385,14 @@ export abstract class TaskStore {
378
385
  limit?: number;
379
386
  names?: string[];
380
387
  }): Promise<Task[]> {
388
+ // One queue is the common case and gets its own statement: a list-valued queue
389
+ // filter cannot be read in claim order, so the planner sorts every claimable
390
+ // row to take LIMIT of them, and claim's cost grows with the queued backlog
391
+ // while it holds the claim transaction. See claim_one_queue.sql.
392
+ const oneQueue = input.queues.length === 1;
381
393
  const params: Params = {
382
394
  queues: input.queues,
395
+ queue: oneQueue ? input.queues[0] : null,
383
396
  names: input.names ?? null,
384
397
  worker_id: input.workerId,
385
398
  lease_ms: input.leaseMs ?? 30_000,
@@ -391,7 +404,7 @@ export abstract class TaskStore {
391
404
  // be visible to the claim that follows, and to nobody in between.
392
405
  return this.tx(async (fetch) => {
393
406
  await fetch("recover_leases", params);
394
- return (await fetch("claim", params)).map(rowToTask);
407
+ return (await fetch(oneQueue ? "claim_one_queue" : "claim", params)).map(rowToTask);
395
408
  });
396
409
  }
397
410
 
@@ -19,31 +19,104 @@ type Stmt = Database.Statement;
19
19
  const WAL_RETRY_DELAY_MS = 50;
20
20
  const WAL_RETRY_BUDGET_MS = 5_000;
21
21
 
22
+ const BUSY_RETRY_BASE_MS = 1;
23
+ const BUSY_RETRY_MAX_DELAY_MS = 50;
24
+
25
+ /**
26
+ * How often a live connection revisits its planner statistics.
27
+ *
28
+ * Bounds how long the planner can work from a stale table shape; a minute is
29
+ * arbitrary but small next to the days a worker holds its connection. It does not
30
+ * set how often an ANALYZE actually runs — SQLite decides that itself, and only
31
+ * once the table has diverged from its statistics by 10x, so a shorter interval
32
+ * costs more no-ops (a few microseconds each) rather than more analyzing.
33
+ */
34
+ const STATS_REFRESH_INTERVAL_MS = 60_000;
35
+
22
36
  /** Sleep without yielding — the whole open path is synchronous already. */
23
37
  function sleepSync(ms: number): void {
24
38
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
25
39
  }
26
40
 
41
+ /** Sleep by yielding to the event loop — the point of the busy retry loop. */
42
+ function sleep(ms: number): Promise<void> {
43
+ return new Promise((resolve) => setTimeout(resolve, ms));
44
+ }
45
+
46
+ /**
47
+ * Whether this error is SQLite refusing to wait for the write lock.
48
+ *
49
+ * Prefix match: the code carries detail suffixes (`SQLITE_BUSY_SNAPSHOT`). Only
50
+ * SQLITE_BUSY qualifies — SQLITE_LOCKED is same-connection table contention,
51
+ * which the per-file lock prevents and a retry could not resolve anyway.
52
+ */
53
+ function isBusy(err: unknown): boolean {
54
+ if (!err || typeof err !== "object") return false;
55
+ const code = (err as { code?: unknown }).code;
56
+ return typeof code === "string" && code.startsWith("SQLITE_BUSY");
57
+ }
58
+
27
59
  /** Whether this path names an in-memory database rather than a file. */
28
60
  function isMemory(path: string): boolean {
29
61
  return path === ":memory:" || path.includes("mode=memory");
30
62
  }
31
63
 
64
+ /**
65
+ * Whether cairnq_tasks has been analyzed at all.
66
+ *
67
+ * Two steps because sqlite_stat1 does not exist until something runs ANALYZE, and
68
+ * querying a missing table is an error rather than an empty result.
69
+ */
70
+ function hasStatistics(db: DB): boolean {
71
+ const table = db
72
+ .prepare("select 1 from sqlite_master where type = 'table' and name = 'sqlite_stat1'")
73
+ .get();
74
+ if (!table) return false;
75
+ return Boolean(
76
+ db.prepare("select 1 from sqlite_stat1 where tbl = 'cairnq_tasks'").get(),
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Bring cairnq_tasks' statistics up to date, cheaply enough to call on a timer.
82
+ *
83
+ * Without them the planner misreads `status = 'running'` as a large fraction of the
84
+ * table and passes over the partial cairnq_tasks_lease_idx that lease recovery is
85
+ * indexed for.
86
+ *
87
+ * The explicit bootstrap is not redundant with `PRAGMA optimize`. Before SQLite
88
+ * 3.46 the pragma skips a table that has no sqlite_stat1 entry entirely — no mask
89
+ * changes that, verified on 3.45.1 — so on those builds it can never produce the
90
+ * *first* statistics, and the index stays unused for the life of the database.
91
+ * Distro Pythons link exactly those builds (Ubuntu 24.04 ships 3.45.1), while
92
+ * better-sqlite3 bundles its own newer one, so this is also what keeps the two SDKs
93
+ * behaving alike rather than by luck of packaging.
94
+ *
95
+ * Once an entry exists, every version's pragma applies its own growth heuristic,
96
+ * which is the part worth deferring to: it is a few microseconds when there is
97
+ * nothing to do, where a bare ANALYZE would rescan the table every time.
98
+ */
99
+ function refreshStatistics(db: DB): void {
100
+ if (hasStatistics(db)) db.pragma("optimize");
101
+ // Scoped to the one table whose shape the planner gets wrong; the key and meta
102
+ // tables are read by primary key, where statistics change nothing. A database
103
+ // this one shares with the caller's own tables is left alone.
104
+ else db.exec("ANALYZE cairnq_tasks");
105
+ }
106
+
32
107
  /**
33
108
  * Serializes every SQLiteStore on one database file, process-wide.
34
109
  *
35
110
  * better-sqlite3 is synchronous, and a transaction holds SQLite's write lock
36
- * across `await`s (the callback seam is shared with Postgres, so it is async). A
37
- * second connection in this process then blocks the only thread waiting for that
38
- * lock, and the holder can never reach COMMIT reaching it needs the thread the
39
- * waiter is sitting on. busy_timeout cannot break that inversion, being one
40
- * thread; the wait just burns the timeout and throws "database is locked". So the
41
- * two must not overlap at all.
111
+ * across `await`s (the callback seam is shared with Postgres, so it is async). Two
112
+ * connections in this process would then contend for that lock the expensive way:
113
+ * every loser spends SQLITE_BUSY retries and backoff on a holder it could simply
114
+ * have queued behind.
42
115
  *
43
116
  * Keyed by database, not by store: what the lock protects is the file. Across
44
- * processes there is no inversion (the holder keeps its own thread) and
45
- * busy_timeout still applies. An in-memory database is private to one connection
46
- * and gets a key of its own.
117
+ * processes there is nothing to serialize from here — each holder has its own
118
+ * thread, and `withLock`'s retry absorbs that contention. An in-memory database is
119
+ * private to one connection and gets a key of its own.
47
120
  */
48
121
  const fileLocks = new Map<string, Promise<unknown>>();
49
122
  let memoryDbSeq = 0;
@@ -70,8 +143,7 @@ function enableWal(db: DB): void {
70
143
  const rows = db.pragma("journal_mode = WAL") as { journal_mode?: string }[];
71
144
  if (rows[0]?.journal_mode?.toLowerCase() === "wal") return;
72
145
  } catch (err) {
73
- const message = String((err as Error).message ?? err);
74
- if (!/locked|busy/i.test(message)) throw err;
146
+ if (!isBusy(err)) throw err;
75
147
  }
76
148
  if (Date.now() >= deadline) {
77
149
  throw new Error(
@@ -92,8 +164,20 @@ function enableWal(db: DB): void {
92
164
  *
93
165
  * The driver being synchronous suits SQLite's single writer: claim is one short
94
166
  * transaction, the handler runs outside any transaction, and
95
- * progress/heartbeat/succeed/fail are each their own short write. Cross-process
96
- * contention is absorbed by busy_timeout.
167
+ * progress/heartbeat/succeed/fail are each their own short write.
168
+ *
169
+ * Cross-process contention is absorbed by retrying in JavaScript, not by
170
+ * busy_timeout. The two cost the same wait but not the same blocking: a nonzero
171
+ * busy_timeout waits *inside* the synchronous driver, so a caller that loses the
172
+ * write lock stalls this process's event loop for up to the whole timeout — the
173
+ * P99 of an HTTP server that submits tasks. Executing a statement takes
174
+ * microseconds; waiting for a lock takes milliseconds to seconds, and only the
175
+ * second part needs to happen off the thread. So busy_timeout goes to 0 (fail
176
+ * immediately) and the wait becomes an awaited backoff, which the event loop runs
177
+ * through. The budget is the same either way — `busyTimeoutMs`.
178
+ *
179
+ * The open path keeps a real busy_timeout: it is synchronous by nature (WAL
180
+ * switch, migrations) and happens once, under the caller's `connect()`.
97
181
  */
98
182
  export class SQLiteStore extends TaskStore {
99
183
  private db: DB | null = null;
@@ -101,12 +185,17 @@ export class SQLiteStore extends TaskStore {
101
185
  private readonly statements: Record<string, string>;
102
186
  /** This store's entry in `fileLocks` — see there for why it is per-database. */
103
187
  private readonly lockKey: string;
188
+ /** How long a single operation may keep retrying a lost write lock. */
189
+ private readonly busyBudgetMs: number;
190
+ /** When this connection may next revisit its planner statistics. */
191
+ private nextStatsRefreshAt = 0;
104
192
 
105
193
  constructor(
106
194
  private readonly path: string,
107
- private readonly opts: { busyTimeoutMs?: number } = {},
195
+ opts: { busyTimeoutMs?: number } = {},
108
196
  ) {
109
197
  super();
198
+ this.busyBudgetMs = opts.busyTimeoutMs ?? 5000;
110
199
  this.statements = loadStatements("sqlite");
111
200
  // Only a bare ":memory:" is guaranteed private to its connection, so only
112
201
  // it gets a lock of its own. A "mode=memory" URI stays path-keyed: with
@@ -135,14 +224,27 @@ export class SQLiteStore extends TaskStore {
135
224
  const memory = isMemory(this.path);
136
225
  if (!memory) mkdirSync(dirname(this.path), { recursive: true });
137
226
  const db = new Database(this.path);
138
- // busy_timeout first, so every later statement waits out contention instead
139
- // of failing instantly.
140
- db.pragma(`busy_timeout = ${this.opts.busyTimeoutMs ?? 5000}`);
227
+ // Only the synchronous part of the open path gets a real busy_timeout: the WAL
228
+ // switch and the migrations cannot await a retry. See the class comment.
229
+ db.pragma(`busy_timeout = ${this.busyBudgetMs}`);
141
230
  // WAL exists so several processes can share one file. An in-memory database
142
231
  // is private to this connection, so there is nothing to share or wait for.
143
232
  if (!memory) enableWal(db);
144
233
  db.pragma("foreign_keys = ON");
145
234
  this.applyMigrations(db);
235
+ // Everything past here either awaits its retry or is optional, so stop blocking.
236
+ db.pragma("busy_timeout = 0");
237
+ // Give the query planner statistics (see refreshStatistics), repeated on a timer
238
+ // from here on (see maybeRefreshStatistics).
239
+ try {
240
+ refreshStatistics(db);
241
+ } catch (err) {
242
+ // Statistics are an optimization, never correctness, so losing them to a
243
+ // concurrent writer must not fail the open — the next one gets another
244
+ // chance. Anything else is a real fault and belongs to the caller.
245
+ if (!isBusy(err)) throw err;
246
+ }
247
+ this.nextStatsRefreshAt = Date.now() + STATS_REFRESH_INTERVAL_MS;
146
248
  for (const [name, sql] of Object.entries(this.statements)) {
147
249
  this.stmts[name] = db.prepare(sql);
148
250
  }
@@ -247,8 +349,8 @@ export class SQLiteStore extends TaskStore {
247
349
  return stmt.all(bound) as any[];
248
350
  }
249
351
 
250
- /** Serialize an operation against every other operation on this database. */
251
- private withLock<T>(fn: () => T | Promise<T>): Promise<T> {
352
+ /** Queue an operation behind every other operation on this database. */
353
+ private enqueue<T>(fn: () => T | Promise<T>): Promise<T> {
252
354
  const previous = fileLocks.get(this.lockKey) ?? Promise.resolve();
253
355
  const run = previous.then(fn, fn) as Promise<T>;
254
356
  fileLocks.set(
@@ -261,27 +363,94 @@ export class SQLiteStore extends TaskStore {
261
363
  return run;
262
364
  }
263
365
 
366
+ /**
367
+ * Serialize an operation against this database, waiting out a lost write lock on
368
+ * a jittered backoff. Replaces busy_timeout's synchronous wait (see the class
369
+ * comment); on exhausting the budget the original SQLITE_BUSY surfaces, which is
370
+ * what a nonzero busy_timeout would have thrown too.
371
+ *
372
+ * Each attempt re-queues rather than backing off while holding its turn: the
373
+ * contention left to retry is cross-process, and under WAL a *reader* never sees
374
+ * SQLITE_BUSY at all — so sleeping in place would stall this process's reads
375
+ * (including the worker's own poll) on a lock they were never waiting for.
376
+ *
377
+ * Retrying is safe because an attempt is one statement, or one transaction that
378
+ * has already rolled back: nothing partially applied survives it. `fn` may
379
+ * therefore run more than once and must not carry effects of its own — the
380
+ * callers in TaskStore build their ids and payloads before opening one.
381
+ */
382
+ private async withLock<T>(fn: () => T | Promise<T>): Promise<T> {
383
+ const deadline = Date.now() + this.busyBudgetMs;
384
+ let delay = BUSY_RETRY_BASE_MS;
385
+ for (;;) {
386
+ try {
387
+ return await this.enqueue(fn);
388
+ } catch (err) {
389
+ if (!isBusy(err) || Date.now() >= deadline) throw err;
390
+ // Jitter so several losers don't wake together and collide again.
391
+ await sleep(delay * (0.5 + Math.random()));
392
+ delay = Math.min(delay * 2, BUSY_RETRY_MAX_DELAY_MS);
393
+ }
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Revisit this connection's planner statistics, at most once per
399
+ * STATS_REFRESH_INTERVAL_MS.
400
+ *
401
+ * A connection lives for days, and the statements were prepared against whatever
402
+ * the table looked like when it opened — a worker started against an empty
403
+ * database plans as if it were still empty however large the backlog grows. The
404
+ * prepared statements do pick the refreshed plans up: ANALYZE bumps the schema
405
+ * cookie, so SQLite silently re-prepares them on next use. That is what makes
406
+ * this worth doing rather than a restart-only concern.
407
+ *
408
+ * Queued rather than run under `withLock`: statistics are best-effort, so losing
409
+ * the write lock to another process should cost nothing — skip and let the next
410
+ * interval try, instead of spending an operation's whole retry budget on them.
411
+ */
412
+ private async maybeRefreshStatistics(db: DB): Promise<void> {
413
+ const now = Date.now();
414
+ if (now < this.nextStatsRefreshAt) return;
415
+ // Claim the slot before running, not after: otherwise a burst of concurrent
416
+ // operations all see it due and queue an ANALYZE apiece.
417
+ this.nextStatsRefreshAt = now + STATS_REFRESH_INTERVAL_MS;
418
+ try {
419
+ await this.enqueue(() => refreshStatistics(db));
420
+ } catch (err) {
421
+ if (!isBusy(err)) throw err;
422
+ }
423
+ }
424
+
264
425
  protected async fetch(name: string, params: Params): Promise<any[]> {
265
- this.ensure();
426
+ const db = this.ensure();
427
+ await this.maybeRefreshStatistics(db);
266
428
  return this.withLock(() => this.runNow(name, params));
267
429
  }
268
430
 
269
431
  protected async tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T> {
270
432
  const db = this.ensure();
433
+ await this.maybeRefreshStatistics(db);
271
434
  // BEGIN IMMEDIATE by hand rather than db.transaction(): the callback is async
272
435
  // (the seam is shared with Postgres), and better-sqlite3's wrapper only takes
273
436
  // a synchronous one. The lock above makes the manual version safe.
274
437
  return this.withLock(async () => {
438
+ // With busy_timeout at 0 this is where a lost write lock surfaces, and it
439
+ // fails before the transaction exists — so the retry re-runs `fn` cleanly.
275
440
  db.exec("BEGIN IMMEDIATE");
276
441
  try {
277
442
  const out = await fn(async (name, params) => this.runNow(name, params));
278
443
  db.exec("COMMIT");
279
444
  return out;
280
445
  } catch (err) {
281
- try {
282
- db.exec("ROLLBACK");
283
- } catch {
284
- // Already rolled back by SQLite (e.g. a constraint abort).
446
+ // Nothing to roll back when BEGIN was what failed — the common case under
447
+ // contention — or when SQLite already did it (a constraint abort).
448
+ if (db.inTransaction) {
449
+ try {
450
+ db.exec("ROLLBACK");
451
+ } catch {
452
+ // Raced with SQLite's own rollback; the transaction is gone either way.
453
+ }
285
454
  }
286
455
  throw err;
287
456
  }