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.
- package/dist/_protocol/migrations/postgres/0001_init.sql +3 -1
- package/dist/_protocol/migrations/postgres/0004_lease_index.sql +16 -0
- package/dist/_protocol/migrations/postgres/0005_clear_terminal_lease.sql +17 -0
- package/dist/_protocol/migrations/sqlite/0001_init.sql +3 -1
- package/dist/_protocol/migrations/sqlite/0004_lease_index.sql +22 -0
- package/dist/_protocol/migrations/sqlite/0005_clear_terminal_lease.sql +17 -0
- package/dist/_protocol/sql/postgres/claim_one_queue.sql +35 -0
- package/dist/_protocol/sql/postgres/complete.sql +3 -0
- package/dist/_protocol/sql/postgres/fail.sql +4 -2
- package/dist/_protocol/sql/postgres/recover_leases.sql +12 -1
- package/dist/_protocol/sql/postgres/succeed.sql +4 -0
- package/dist/_protocol/sql/sqlite/claim_one_queue.sql +36 -0
- package/dist/_protocol/sql/sqlite/complete.sql +3 -0
- package/dist/_protocol/sql/sqlite/fail.sql +4 -2
- package/dist/_protocol/sql/sqlite/succeed.sql +4 -0
- package/dist/store/base.d.ts +8 -1
- package/dist/store/base.js +7 -1
- package/dist/store/sqlite.d.ts +52 -4
- package/dist/store/sqlite.js +194 -26
- package/package.json +5 -5
- package/src/store/base.ts +15 -2
- package/src/store/sqlite.ts +193 -24
|
@@ -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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
package/dist/store/base.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/dist/store/base.js
CHANGED
|
@@ -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) {
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -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.
|
|
13
|
-
*
|
|
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
|
-
/**
|
|
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>;
|
package/dist/store/sqlite.js
CHANGED
|
@@ -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).
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
30
|
-
*
|
|
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
|
-
|
|
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.
|
|
80
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
db.pragma(`busy_timeout = ${this.
|
|
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
|
-
/**
|
|
226
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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.
|
|
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": ">=
|
|
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": "^
|
|
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": "^
|
|
59
|
-
"vitest": "^
|
|
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
|
-
/**
|
|
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
|
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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).
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
45
|
-
*
|
|
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
|
-
|
|
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.
|
|
96
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
db.pragma(`busy_timeout = ${this.
|
|
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
|
-
/**
|
|
251
|
-
private
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
}
|