cairnq 0.10.0 → 0.11.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.
Files changed (35) hide show
  1. package/dist/_protocol/migrations/postgres/0001_init.sql +2 -0
  2. package/dist/_protocol/migrations/postgres/0006_claim_name_index.sql +2 -0
  3. package/dist/_protocol/migrations/postgres/0008_claim_due_index.sql +68 -0
  4. package/dist/_protocol/migrations/sqlite/0001_init.sql +2 -0
  5. package/dist/_protocol/migrations/sqlite/0006_claim_name_index.sql +2 -0
  6. package/dist/_protocol/migrations/sqlite/0008_claim_due_index.sql +84 -0
  7. package/dist/_protocol/sql/postgres/claim.sql +6 -3
  8. package/dist/_protocol/sql/postgres/claim_one_name.sql +6 -3
  9. package/dist/_protocol/sql/postgres/claim_one_queue.sql +9 -5
  10. package/dist/_protocol/sql/postgres/claim_one_queue_one_name.sql +6 -3
  11. package/dist/_protocol/sql/sqlite/claim.sql +6 -3
  12. package/dist/_protocol/sql/sqlite/claim_one_name.sql +6 -3
  13. package/dist/_protocol/sql/sqlite/claim_one_queue.sql +9 -6
  14. package/dist/_protocol/sql/sqlite/claim_one_queue_one_name.sql +9 -4
  15. package/dist/errors.d.ts +11 -0
  16. package/dist/errors.js +14 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/models.d.ts +2 -0
  20. package/dist/models.js +2 -2
  21. package/dist/retention.d.ts +13 -1
  22. package/dist/retention.js +26 -11
  23. package/dist/store/base.d.ts +12 -3
  24. package/dist/store/base.js +21 -6
  25. package/dist/store/pg-pool.js +31 -6
  26. package/dist/store/postgres.d.ts +41 -0
  27. package/dist/store/postgres.js +65 -1
  28. package/package.json +1 -1
  29. package/src/errors.ts +15 -0
  30. package/src/index.ts +1 -0
  31. package/src/models.ts +2 -2
  32. package/src/retention.ts +27 -11
  33. package/src/store/base.ts +27 -5
  34. package/src/store/pg-pool.ts +32 -6
  35. package/src/store/postgres.ts +64 -1
@@ -49,6 +49,8 @@ create table if not exists cairnq_tasks (
49
49
  -- desc, created_at_ms asc (run_at_ms applied as a residual filter). Only
50
50
  -- claim_one_queue.sql can read it in claim order; claim.sql's array-valued queue
51
51
  -- filter forces a sort, and past a few thousand queued rows a sequential scan.
52
+ -- The shape as first shipped; migration 0008 rebuilds it around run_at_ms and
53
+ -- says why.
52
54
  create index if not exists cairnq_tasks_claim_idx
53
55
  on cairnq_tasks (queue, status, priority desc, created_at_ms);
54
56
  create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
@@ -19,6 +19,8 @@
19
19
  -- existing large cairnq_tasks this takes a write lock for the build. Deploying
20
20
  -- into a busy database is the case to watch; build it by hand with CONCURRENTLY
21
21
  -- first if that matters, and this statement then becomes a no-op.
22
+ -- The measurements above belong to the shape defined here; migration 0008
23
+ -- rebuilds it around run_at_ms and carries the current numbers.
22
24
  create index if not exists cairnq_tasks_claim_name_idx
23
25
  on cairnq_tasks (queue, status, name, priority desc, created_at_ms);
24
26
 
@@ -0,0 +1,68 @@
1
+ -- Serve the claim's whole ORDER BY from the index, and order by when a task
2
+ -- became DUE rather than by when it was created.
3
+ --
4
+ -- The two halves are one fix. `run_at_ms <= :now_ms` was a residual on the old
5
+ -- (queue, status, priority desc, created_at_ms) index, and a not-yet-due row —
6
+ -- a retry waiting out its backoff, a task submitted with a delay — sorts by the
7
+ -- created_at it has always had, which puts it AHEAD of the rows that are
8
+ -- actually claimable. So every draw walked the whole backoff pile before
9
+ -- reaching anything it could take, inside the transaction that holds the claim.
10
+ -- The pile is largest exactly when a downstream dependency has just failed and
11
+ -- thousands of tasks are backing off together.
12
+ --
13
+ -- Ordering by run_at_ms instead puts every not-yet-due row AFTER the due ones,
14
+ -- so the scan reaches its rows first, and appending `id` lets the index carry the
15
+ -- tie-break as well, which is what lets the planner satisfy the ORDER BY from the
16
+ -- index alone.
17
+ --
18
+ -- The measurements behind this live in the SQLite twin of this file, and they are
19
+ -- SQLite numbers: no Postgres was available where this was written, so this side
20
+ -- is reasoned from the same index shape rather than benchmarked. What holds
21
+ -- across both is the ordering — the due rows are now at the front of the index
22
+ -- range instead of behind every not-yet-due one.
23
+ --
24
+ -- What this does NOT fix, in either dialect: an empty draw still walks the whole
25
+ -- range looking for rows that are not there, and `claimable_probe` — whose
26
+ -- two-armed EXISTS cannot use the index at all — is untouched. A queue deep in
27
+ -- backoff still costs a worker real time per poll.
28
+ --
29
+ -- For a task that was never delayed or retried the change is invisible in
30
+ -- practice: insert_task sets run_at_ms = now + delay, so the two columns hold the
31
+ -- same millisecond for an ordinary submit. (On Postgres "the same millisecond" is
32
+ -- not quite an identity — insert_task evaluates clock_timestamp() once per column
33
+ -- and it advances within a statement, so a submit landing on a millisecond
34
+ -- boundary can differ by 1ms. That reorders two tasks submitted in the same
35
+ -- millisecond, which claim.sql already calls decided by the id's random half
36
+ -- rather than by submit order.) It changes real ordering only for tasks whose
37
+ -- delivery was deferred, and for those "oldest due first" is the fairer answer —
38
+ -- a task that failed and backed off should not cut ahead of everything submitted
39
+ -- while it waited, which is what its original created_at_ms bought it.
40
+ --
41
+ -- Both indexes are rebuilt inside the migration's transaction, which holds a
42
+ -- lock that blocks writes to cairnq_tasks while they build — seconds on a large
43
+ -- table. There is no CONCURRENTLY here: it cannot run inside a transaction, and
44
+ -- the ledger's check-and-apply is one. Upgrade a large deployment in a window
45
+ -- where that pause is affordable.
46
+ --
47
+ -- Same names, new definitions: these indexes ARE "the ones the claim reads", and
48
+ -- every reference to them in the SQL comments and PROTOCOL.md still points at
49
+ -- the right object.
50
+ --
51
+ -- An SDK older than this migration keeps working: it orders by created_at_ms,
52
+ -- finds no index in that order, and sorts. Slower, exactly where this migration
53
+ -- is faster — but note the sharper consequence while a fleet is mixed. Two SDK
54
+ -- versions against one database then disagree about which task is NEXT for any
55
+ -- delayed or retried work: each takes a valid claimable task, no task is lost or
56
+ -- run twice, but the documented claim order holds only within one version. That
57
+ -- is a difference in fairness, not in correctness, which is why protocol_version
58
+ -- stays at 1 — and it is a reason to keep the mixed window short.
59
+ drop index if exists cairnq_tasks_claim_idx;
60
+ create index cairnq_tasks_claim_idx
61
+ on cairnq_tasks (queue, status, priority desc, run_at_ms, id);
62
+
63
+ -- The per-name twin (see 0006), rebuilt on the same principle.
64
+ drop index if exists cairnq_tasks_claim_name_idx;
65
+ create index cairnq_tasks_claim_name_idx
66
+ on cairnq_tasks (queue, status, name, priority desc, run_at_ms, id);
67
+
68
+ update cairnq_meta set value = '8' where key = 'schema_version';
@@ -48,6 +48,8 @@ create table if not exists cairnq_tasks (
48
48
  -- queue+status then the ORDER BY columns is what lets claim_one_queue.sql read
49
49
  -- rows in claim order; claim.sql's list-valued queue filter has to merge several
50
50
  -- ranges of this index, so it sorts instead.
51
+ -- The shape as first shipped; migration 0008 rebuilds it around run_at_ms and
52
+ -- says why.
51
53
  create index if not exists cairnq_tasks_claim_idx
52
54
  on cairnq_tasks (queue, status, priority desc, created_at_ms);
53
55
  create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
@@ -20,6 +20,8 @@
20
20
  -- bloom filter over the subquery and falls back to cairnq_tasks_claim_idx
21
21
  -- (measured 1446us, i.e. no improvement at all). That is why the per-name
22
22
  -- statements exist as separate files rather than the shared one being reused.
23
+ -- The measurements above belong to the shape defined here; migration 0008
24
+ -- rebuilds it around run_at_ms and carries the current numbers.
23
25
  create index if not exists cairnq_tasks_claim_name_idx
24
26
  on cairnq_tasks (queue, status, name, priority desc, created_at_ms);
25
27
 
@@ -0,0 +1,84 @@
1
+ -- Serve the claim's whole ORDER BY from the index, and order by when a task
2
+ -- became DUE rather than by when it was created.
3
+ --
4
+ -- The two halves are one fix. `run_at_ms <= :now_ms` was a residual on the old
5
+ -- (queue, status, priority desc, created_at_ms) index, and a not-yet-due row —
6
+ -- a retry waiting out its backoff, a task submitted with a delay — sorts by the
7
+ -- created_at it has always had, which puts it AHEAD of the rows that are
8
+ -- actually claimable. So every draw walked the whole backoff pile before
9
+ -- reaching anything it could take, inside the transaction that holds the claim.
10
+ -- The pile is largest exactly when a downstream dependency has just failed and
11
+ -- thousands of tasks are backing off together.
12
+ --
13
+ -- Ordering by run_at_ms instead puts every not-yet-due row AFTER the due ones,
14
+ -- so the scan reaches its rows first, and appending `id` lets the index carry the
15
+ -- tie-break as well, which is what lets the planner satisfy the ORDER BY from the
16
+ -- index alone. Measured through the real statement (`claim_one_queue`, one queue,
17
+ -- four names, 20k queued rows backing off), one claim of one task, on the three
18
+ -- SQLite builds that happened to be on the authoring machine:
19
+ --
20
+ -- SQLite linked by one due row an empty draw
21
+ -- 3.39.4 a system python3.11 1166us -> 22us 1110us -> 350us
22
+ -- 3.47.1 a python3.13 (this repo's venv) 3378us -> 116us 3128us -> 1006us
23
+ -- 3.53.4 better-sqlite3 13 (bundled) 1100us -> 72us 1060us -> 50us
24
+ --
25
+ -- Read the columns, not the rows: the win holds on every build, and the absolute
26
+ -- cost swings more between SQLite versions than between before and after on some
27
+ -- of them. better-sqlite3 bundles its own SQLite, so the TypeScript SDK's version
28
+ -- is a dependency; the Python SDK links whatever the interpreter was built
29
+ -- against, so its version belongs to the user's Python and cannot be assumed.
30
+ --
31
+ -- The plan differs by SQLite version and the win does not come from one single
32
+ -- mechanism: 3.39 drops the sorter outright, while 3.53 keeps one and instead
33
+ -- reaches the due rows through a skip-scan. What both have in common is the
34
+ -- ordering — the due rows are now at the front of the range instead of behind
35
+ -- every not-yet-due one. Numbers are SQLite; Postgres was not measured (no
36
+ -- server in the authoring environment), so treat the Postgres side as reasoned
37
+ -- rather than benchmarked.
38
+ --
39
+ -- What this does NOT fix: an empty draw still walks the whole range looking for
40
+ -- rows that are not there (350us above), and `claimable_probe` — whose two-armed
41
+ -- EXISTS cannot use the index at all — is unchanged at ~2.2ms on that backlog.
42
+ -- A queue deep in backoff still costs a worker real time per poll.
43
+ --
44
+ -- For a task that was never delayed or retried the change is invisible in
45
+ -- practice: insert_task sets run_at_ms = now + delay, so the two columns hold the
46
+ -- same millisecond for an ordinary submit. (On Postgres "the same millisecond" is
47
+ -- not quite an identity — insert_task evaluates clock_timestamp() once per column
48
+ -- and it advances within a statement, so a submit landing on a millisecond
49
+ -- boundary can differ by 1ms. That reorders two tasks submitted in the same
50
+ -- millisecond, which claim.sql already calls decided by the id's random half
51
+ -- rather than by submit order.) It changes real ordering only for tasks whose
52
+ -- delivery was deferred, and for those "oldest due first" is the fairer answer —
53
+ -- a task that failed and backed off should not cut ahead of everything submitted
54
+ -- while it waited, which is what its original created_at_ms bought it.
55
+ --
56
+ -- Both indexes are rebuilt inside the migration's write transaction, so every
57
+ -- other process's writes wait while they build — and they wait only as long as
58
+ -- their busy budget (`busyTimeoutMs` / `busy_timeout_ms`, 5s by default) before
59
+ -- failing. Measured on a warm local SSD: ~2.5s + ~3.4s for a 1M-row, 500MB
60
+ -- database, i.e. past that default. Small databases do not notice; see
61
+ -- "Upgrading" in the README for what to do about a large one.
62
+ --
63
+ -- Same names, new definitions: these indexes ARE "the ones the claim reads", and
64
+ -- every reference to them in the SQL comments and PROTOCOL.md still points at
65
+ -- the right object.
66
+ --
67
+ -- An SDK older than this migration keeps working: it orders by created_at_ms,
68
+ -- finds no index in that order, and sorts. Slower, exactly where this migration
69
+ -- is faster — but note the sharper consequence while a fleet is mixed. Two SDK
70
+ -- versions against one database then disagree about which task is NEXT for any
71
+ -- delayed or retried work: each takes a valid claimable task, no task is lost or
72
+ -- run twice, but the documented claim order holds only within one version. That
73
+ -- is a difference in fairness, not in correctness, which is why protocol_version
74
+ -- stays at 1 — and it is a reason to keep the mixed window short.
75
+ drop index if exists cairnq_tasks_claim_idx;
76
+ create index cairnq_tasks_claim_idx
77
+ on cairnq_tasks (queue, status, priority desc, run_at_ms, id);
78
+
79
+ -- The per-name twin (see 0006), rebuilt on the same principle.
80
+ drop index if exists cairnq_tasks_claim_name_idx;
81
+ create index cairnq_tasks_claim_name_idx
82
+ on cairnq_tasks (queue, status, name, priority desc, run_at_ms, id);
83
+
84
+ update cairnq_meta set value = '8' where key = 'schema_version';
@@ -29,10 +29,13 @@ from (
29
29
  and queue = any(:queues::text[])
30
30
  and (:names::text[] is null or name = any(:names::text[]))
31
31
  and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
32
- -- id breaks created_at_ms ties (same-millisecond submits), so claim order
33
- -- is deterministic: FIFO at millisecond granularity; within one millisecond
32
+ -- Ordered by when a task became DUE, not when it was created: see migration
33
+ -- 0008 for why, for what it costs a mixed-version fleet, and for which of
34
+ -- these four statements the index can serve without a sort.
35
+ -- id breaks run_at_ms ties (same-millisecond submits), so claim order is
36
+ -- deterministic: FIFO at millisecond granularity; within one millisecond
34
37
  -- the id's random half decides, stably but not in submit order.
35
- order by priority desc, created_at_ms asc, id asc
38
+ order by priority desc, run_at_ms asc, id asc
36
39
  limit :limit
37
40
  for update skip locked
38
41
  ) sel
@@ -26,10 +26,13 @@ from (
26
26
  and queue = any(:queues::text[])
27
27
  and name = :name
28
28
  and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
29
- -- id breaks created_at_ms ties (same-millisecond submits), so claim order
30
- -- is deterministic: FIFO at millisecond granularity; within one millisecond
29
+ -- Ordered by when a task became DUE, not when it was created: see migration
30
+ -- 0008 for why, for what it costs a mixed-version fleet, and for which of
31
+ -- these four statements the index can serve without a sort.
32
+ -- id breaks run_at_ms ties (same-millisecond submits), so claim order is
33
+ -- deterministic: FIFO at millisecond granularity; within one millisecond
31
34
  -- the id's random half decides, stably but not in submit order.
32
- order by priority desc, created_at_ms asc, id asc
35
+ order by priority desc, run_at_ms asc, id asc
33
36
  limit :limit
34
37
  for update skip locked
35
38
  ) sel
@@ -7,8 +7,9 @@
7
7
  -- + LIMIT over `= any(...)` the planner falls back to a sequential scan and a
8
8
  -- full sort of every claimable row (measured on 20k queued: Seq Scan 20000 rows,
9
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.
10
+ -- form index-scans cairnq_tasks_claim_idx, which since migration 0008 carries the
11
+ -- id tie-break too and so needs no sort node at all — 33 rows read for the same
12
+ -- query.
12
13
  --
13
14
  -- params: queue, names (text[] or null), worker_id, lease_ms, limit
14
15
  update cairnq_tasks t
@@ -24,10 +25,13 @@ from (
24
25
  and queue = :queue
25
26
  and (:names::text[] is null or name = any(:names::text[]))
26
27
  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
28
+ -- Ordered by when a task became DUE, not when it was created: see migration
29
+ -- 0008 for why, for what it costs a mixed-version fleet, and for which of
30
+ -- these four statements the index can serve without a sort.
31
+ -- id breaks run_at_ms ties (same-millisecond submits), so claim order is
32
+ -- deterministic: FIFO at millisecond granularity; within one millisecond
29
33
  -- the id's random half decides, stably but not in submit order.
30
- order by priority desc, created_at_ms asc, id asc
34
+ order by priority desc, run_at_ms asc, id asc
31
35
  limit :limit
32
36
  for update skip locked
33
37
  ) sel
@@ -23,10 +23,13 @@ from (
23
23
  and queue = :queue
24
24
  and name = :name
25
25
  and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
26
- -- id breaks created_at_ms ties (same-millisecond submits), so claim order
27
- -- is deterministic: FIFO at millisecond granularity; within one millisecond
26
+ -- Ordered by when a task became DUE, not when it was created: see migration
27
+ -- 0008 for why, for what it costs a mixed-version fleet, and for which of
28
+ -- these four statements the index can serve without a sort.
29
+ -- id breaks run_at_ms ties (same-millisecond submits), so claim order is
30
+ -- deterministic: FIFO at millisecond granularity; within one millisecond
28
31
  -- the id's random half decides, stably but not in submit order.
29
- order by priority desc, created_at_ms asc, id asc
32
+ order by priority desc, run_at_ms asc, id asc
30
33
  limit :limit
31
34
  for update skip locked
32
35
  ) sel
@@ -22,10 +22,13 @@ where id in (
22
22
  and queue in (select value from json_each(:queues))
23
23
  and (:names is null or name in (select value from json_each(:names)))
24
24
  and run_at_ms <= :now_ms
25
- -- id breaks created_at_ms ties (same-millisecond submits), so claim order
26
- -- is deterministic: FIFO at millisecond granularity; within one millisecond
25
+ -- Ordered by when a task became DUE, not when it was created: see migration
26
+ -- 0008 for why, for what it costs a mixed-version fleet, and for which of
27
+ -- these four statements the index can serve without a sort.
28
+ -- id breaks run_at_ms ties (same-millisecond submits), so claim order is
29
+ -- deterministic: FIFO at millisecond granularity; within one millisecond
27
30
  -- the id's random half decides, stably but not in submit order.
28
- order by priority desc, created_at_ms asc, id asc
31
+ order by priority desc, run_at_ms asc, id asc
29
32
  limit :limit
30
33
  )
31
34
  returning *;
@@ -27,10 +27,13 @@ where id in (
27
27
  and queue in (select value from json_each(:queues))
28
28
  and name = :name
29
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
30
+ -- Ordered by when a task became DUE, not when it was created: see migration
31
+ -- 0008 for why, for what it costs a mixed-version fleet, and for which of
32
+ -- these four statements the index can serve without a sort.
33
+ -- id breaks run_at_ms ties (same-millisecond submits), so claim order is
34
+ -- deterministic: FIFO at millisecond granularity; within one millisecond
32
35
  -- the id's random half decides, stably but not in submit order.
33
- order by priority desc, created_at_ms asc, id asc
36
+ order by priority desc, run_at_ms asc, id asc
34
37
  limit :limit
35
38
  )
36
39
  returning *;
@@ -8,9 +8,9 @@
8
8
  -- longer read rows in claim order; it materializes every claimable row into a
9
9
  -- temp B-tree just to take LIMIT of them. Cost then grows with the queued
10
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.
11
+ -- / 1792us for a backlog of 50 / 2000 / 20000. The equality form reads
12
+ -- cairnq_tasks_claim_idx in claim order the whole ORDER BY, id tie-break
13
+ -- included, since migration 0008 — so it needs no sort at all and stays flat.
14
14
  --
15
15
  -- params: queue, names (JSON array text or null), now_ms, worker_id,
16
16
  -- lease_until_ms, limit
@@ -27,10 +27,13 @@ where id in (
27
27
  and queue = :queue
28
28
  and (:names is null or name in (select value from json_each(:names)))
29
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
30
+ -- Ordered by when a task became DUE, not when it was created: see migration
31
+ -- 0008 for why, for what it costs a mixed-version fleet, and for which of
32
+ -- these four statements the index can serve without a sort.
33
+ -- id breaks run_at_ms ties (same-millisecond submits), so claim order is
34
+ -- deterministic: FIFO at millisecond granularity; within one millisecond
32
35
  -- the id's random half decides, stably but not in submit order.
33
- order by priority desc, created_at_ms asc, id asc
36
+ order by priority desc, run_at_ms asc, id asc
34
37
  limit :limit
35
38
  )
36
39
  returning *;
@@ -7,7 +7,9 @@
7
7
  -- claim_one_name.sql's name equality, and each is there for the reason that file
8
8
  -- gives. Together they let cairnq_tasks_claim_name_idx be read in claim order
9
9
  -- with both leading columns pinned, so the draw is a seek that terminates at
10
- -- :limit rows however deep the backlog is.
10
+ -- :limit rows however deep the backlog is — including a backlog of tasks that are
11
+ -- queued but not yet due, which since migration 0008 sort behind the due ones
12
+ -- rather than ahead of them.
11
13
  -- params: queue, name, now_ms, worker_id, lease_until_ms, limit
12
14
  update cairnq_tasks
13
15
  set
@@ -22,10 +24,13 @@ where id in (
22
24
  and queue = :queue
23
25
  and name = :name
24
26
  and run_at_ms <= :now_ms
25
- -- id breaks created_at_ms ties (same-millisecond submits), so claim order
26
- -- is deterministic: FIFO at millisecond granularity; within one millisecond
27
+ -- Ordered by when a task became DUE, not when it was created: see migration
28
+ -- 0008 for why, for what it costs a mixed-version fleet, and for which of
29
+ -- these four statements the index can serve without a sort.
30
+ -- id breaks run_at_ms ties (same-millisecond submits), so claim order is
31
+ -- deterministic: FIFO at millisecond granularity; within one millisecond
27
32
  -- the id's random half decides, stably but not in submit order.
28
- order by priority desc, created_at_ms asc, id asc
33
+ order by priority desc, run_at_ms asc, id asc
29
34
  limit :limit
30
35
  )
31
36
  returning *;
package/dist/errors.d.ts CHANGED
@@ -101,6 +101,17 @@ export declare class LostLease extends CairnQError {
101
101
  export declare class ProtocolVersionMismatch extends CairnQError {
102
102
  constructor(message: string);
103
103
  }
104
+ /**
105
+ * This store cannot do what was asked, and no argument would change that — the
106
+ * capability belongs to the backend, not to the call.
107
+ *
108
+ * Thrown by `completeIn` on a store with no driver session to share (SQLite has
109
+ * none). A CairnQError rather than a bare Error so the same catch works across
110
+ * both SDKs; the Python SDK raises the same named error.
111
+ */
112
+ export declare class UnsupportedBackend extends CairnQError {
113
+ constructor(message: string);
114
+ }
104
115
  /**
105
116
  * This connection is not pointed at the cairnq installation the rest of the
106
117
  * deployment is using — raised at connect, before any task is written.
package/dist/errors.js CHANGED
@@ -187,6 +187,20 @@ export class ProtocolVersionMismatch extends CairnQError {
187
187
  this.name = "ProtocolVersionMismatch";
188
188
  }
189
189
  }
190
+ /**
191
+ * This store cannot do what was asked, and no argument would change that — the
192
+ * capability belongs to the backend, not to the call.
193
+ *
194
+ * Thrown by `completeIn` on a store with no driver session to share (SQLite has
195
+ * none). A CairnQError rather than a bare Error so the same catch works across
196
+ * both SDKs; the Python SDK raises the same named error.
197
+ */
198
+ export class UnsupportedBackend extends CairnQError {
199
+ constructor(message) {
200
+ super(message);
201
+ this.name = "UnsupportedBackend";
202
+ }
203
+ }
190
204
  /**
191
205
  * This connection is not pointed at the cairnq installation the rest of the
192
206
  * deployment is using — raised at connect, before any task is written.
package/dist/index.d.ts CHANGED
@@ -20,5 +20,5 @@ export type { ListInput, PurgeInput, SubmitInput, Conflict, WatchOptions, WatchS
20
20
  export { DEFAULT_WATCH_POLL_MS } from "./store/base.js";
21
21
  export type { Task, TaskRef, TaskStatus, TerminalStatus } from "./models.js";
22
22
  export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
23
- export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SchemaMismatch, SerializationError, } from "./errors.js";
23
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SchemaMismatch, UnsupportedBackend, SerializationError, } from "./errors.js";
24
24
  export type { FailReason } from "./errors.js";
package/dist/index.js CHANGED
@@ -11,4 +11,4 @@ export { createPoolExecutor } from "./store/pg-pool.js";
11
11
  export { TaskStore } from "./store/base.js";
12
12
  export { DEFAULT_WATCH_POLL_MS } from "./store/base.js";
13
13
  export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
14
- export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SchemaMismatch, SerializationError, } from "./errors.js";
14
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SchemaMismatch, UnsupportedBackend, SerializationError, } from "./errors.js";
package/dist/models.d.ts CHANGED
@@ -31,6 +31,8 @@ export interface TaskRef {
31
31
  id: string;
32
32
  status: TaskStatus;
33
33
  }
34
+ export declare const JSON_COLUMNS: readonly ["payload", "result", "error", "metadata"];
35
+ export declare const MS_COLUMNS: readonly ["lease_until_ms", "run_at_ms", "cancel_requested_at_ms", "created_at_ms", "updated_at_ms", "completed_at_ms"];
34
36
  export declare const TERMINAL: readonly ["succeeded", "failed", "canceled"];
35
37
  export type TerminalStatus = (typeof TERMINAL)[number];
36
38
  export declare function isTerminalStatus(status: TaskStatus): status is TerminalStatus;
package/dist/models.js CHANGED
@@ -3,13 +3,13 @@
3
3
  // truth is the status CHECK constraint in cairnq-protocol's migration, which the
4
4
  // conformance suite pins this set against.
5
5
  export const STATUSES = ["queued", "running", "succeeded", "failed", "canceled"];
6
- const JSON_COLUMNS = ["payload", "result", "error", "metadata"];
6
+ export const JSON_COLUMNS = ["payload", "result", "error", "metadata"];
7
7
  // The bigint columns. `attempt` / `max_attempts` / `priority` are int4 and
8
8
  // `progress` is double precision, so every driver already gives those as numbers;
9
9
  // only int8 has a wire form worth normalizing. Nullability differs per column
10
10
  // (completed_at_ms may be null, created_at_ms may not), so the coercion has to
11
11
  // preserve null rather than turn it into 0.
12
- const MS_COLUMNS = [
12
+ export const MS_COLUMNS = [
13
13
  "lease_until_ms",
14
14
  "run_at_ms",
15
15
  "cancel_requested_at_ms",
@@ -46,7 +46,17 @@ export declare class RetentionSweeper {
46
46
  private active;
47
47
  /** Set by stop(), so a drain in progress can cut itself short too. */
48
48
  private stopping;
49
- /** Resolves the current sleep early, so stop() need not wait out an interval. */
49
+ /**
50
+ * Resolved by stop(); every sleep races it. One signal rather than a handle to
51
+ * the current sleep, because there can be more than one: `sweep()` is public
52
+ * and meant to be called directly for an on-demand drain, and its
53
+ * between-batches yield is a sleep of its own. A single handle let that sleep
54
+ * overwrite the scheduled loop's — and then clear it — leaving stop() nothing
55
+ * to wake and close() blocked until the whole interval (an hour, by default)
56
+ * ran out. Same shape as Worker's `stopped$`, and as the Python twin's
57
+ * asyncio.Event.
58
+ */
59
+ private stopSignal;
50
60
  private wake;
51
61
  /** The loop itself, awaited by stop() so no purge outlives the store. */
52
62
  private loop;
@@ -57,6 +67,8 @@ export declare class RetentionSweeper {
57
67
  private readonly purgeInputs;
58
68
  constructor(store: TaskStore, opts: RetentionOptions);
59
69
  start(): void;
70
+ /** Mint a fresh stop signal. */
71
+ private arm;
60
72
  /** Stop sweeping and wait for the sweep in flight, if any. */
61
73
  stop(): Promise<void>;
62
74
  private run;
package/dist/retention.js CHANGED
@@ -27,8 +27,18 @@ export class RetentionSweeper {
27
27
  active = false;
28
28
  /** Set by stop(), so a drain in progress can cut itself short too. */
29
29
  stopping = false;
30
- /** Resolves the current sleep early, so stop() need not wait out an interval. */
31
- wake = null;
30
+ /**
31
+ * Resolved by stop(); every sleep races it. One signal rather than a handle to
32
+ * the current sleep, because there can be more than one: `sweep()` is public
33
+ * and meant to be called directly for an on-demand drain, and its
34
+ * between-batches yield is a sleep of its own. A single handle let that sleep
35
+ * overwrite the scheduled loop's — and then clear it — leaving stop() nothing
36
+ * to wake and close() blocked until the whole interval (an hour, by default)
37
+ * ran out. Same shape as Worker's `stopped$`, and as the Python twin's
38
+ * asyncio.Event.
39
+ */
40
+ stopSignal;
41
+ wake;
32
42
  /** The loop itself, awaited by stop() so no purge outlives the store. */
33
43
  loop = null;
34
44
  intervalMs;
@@ -52,6 +62,7 @@ export class RetentionSweeper {
52
62
  if (!cutoffs.length) {
53
63
  throw new Error("retention.olderThanMs must name at least one status");
54
64
  }
65
+ this.arm();
55
66
  this.purgeInputs = cutoffs.map(([status, ms]) => ({
56
67
  olderThanMs: ms,
57
68
  status,
@@ -67,13 +78,19 @@ export class RetentionSweeper {
67
78
  return;
68
79
  this.active = true;
69
80
  this.stopping = false;
81
+ // A stopped sweeper can be started again, and the old signal is spent.
82
+ this.arm();
70
83
  this.loop = this.run();
71
84
  }
85
+ /** Mint a fresh stop signal. */
86
+ arm() {
87
+ this.stopSignal = new Promise((resolve) => (this.wake = resolve));
88
+ }
72
89
  /** Stop sweeping and wait for the sweep in flight, if any. */
73
90
  async stop() {
74
91
  this.stopping = true;
75
92
  this.active = false;
76
- this.wake?.();
93
+ this.wake();
77
94
  await this.loop;
78
95
  this.loop = null;
79
96
  }
@@ -123,15 +140,13 @@ export class RetentionSweeper {
123
140
  /** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
124
141
  * pending sweep must never be the reason a process refuses to exit. */
125
142
  sleep(ms) {
126
- return new Promise((resolve) => {
127
- const timer = setTimeout(resolve, ms);
143
+ let timer;
144
+ const nap = new Promise((resolve) => {
145
+ timer = setTimeout(resolve, ms);
128
146
  timer.unref?.();
129
- this.wake = () => {
130
- clearTimeout(timer);
131
- resolve();
132
- };
133
- }).finally(() => {
134
- this.wake = null;
135
147
  });
148
+ // Clear the timer whichever side wins, so a stop is never followed by a
149
+ // leftover sweep timer.
150
+ return Promise.race([nap, this.stopSignal]).finally(() => clearTimeout(timer));
136
151
  }
137
152
  }
@@ -147,6 +147,12 @@ export declare abstract class TaskStore {
147
147
  * `watch` degrades to its timer alone.
148
148
  */
149
149
  protected subscribePush?(onSignal: (signal: WatchSignal) => void): () => void;
150
+ /**
151
+ * Tell a store with a push channel which queues this process will wait on, so
152
+ * it can buffer their notifications and ignore everyone else's. Optional: a
153
+ * store without a push channel has nothing to buffer.
154
+ */
155
+ protected registerWakeable?(queues: string[]): void;
150
156
  /**
151
157
  * Nudge the push channel back up if it has dropped. Called from `watch`'s
152
158
  * timer, which is the only thing keeping a client-side subscriber alive: a
@@ -289,9 +295,12 @@ export declare abstract class TaskStore {
289
295
  * `plan` runs with the write lock held, so it must await nothing but that
290
296
  * callback.
291
297
  *
292
- * `names` is the union `plan` might ask for the probe and the recovery are
293
- * filtered by it. Returns undefined when the probe finds nothing claimable, in
294
- * which case `plan` never runs and no transaction is opened.
298
+ * `names` is the union `plan` might ask for, and it filters the probe's
299
+ * queued-work arm. Lease recovery is deliberately NOT filtered by it — nor is
300
+ * the probe's expired-lease arm because reclaiming a dead worker's task is
301
+ * every worker's job, whatever names it happens to handle. Returns undefined
302
+ * when the probe finds nothing claimable, in which case `plan` never runs and
303
+ * no transaction is opened.
295
304
  */
296
305
  claimSession<T>(input: {
297
306
  queues: string[];