cairnq 0.1.0 → 0.3.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 (68) hide show
  1. package/README.md +28 -0
  2. package/dist/_protocol/migrations/postgres/0001_init.sql +3 -1
  3. package/dist/_protocol/migrations/postgres/0002_purge_index.sql +6 -0
  4. package/dist/_protocol/migrations/postgres/0003_notify.sql +38 -0
  5. package/dist/_protocol/migrations/postgres/0004_lease_index.sql +16 -0
  6. package/dist/_protocol/migrations/postgres/0005_clear_terminal_lease.sql +17 -0
  7. package/dist/_protocol/migrations/sqlite/0001_init.sql +3 -1
  8. package/dist/_protocol/migrations/sqlite/0002_purge_index.sql +6 -0
  9. package/dist/_protocol/migrations/sqlite/0004_lease_index.sql +22 -0
  10. package/dist/_protocol/migrations/sqlite/0005_clear_terminal_lease.sql +17 -0
  11. package/dist/_protocol/sql/postgres/claim.sql +18 -5
  12. package/dist/_protocol/sql/postgres/claim_one_queue.sql +35 -0
  13. package/dist/_protocol/sql/postgres/complete.sql +3 -0
  14. package/dist/_protocol/sql/postgres/fail.sql +30 -8
  15. package/dist/_protocol/sql/postgres/insert_task.sql +6 -3
  16. package/dist/_protocol/sql/postgres/list.sql +3 -1
  17. package/dist/_protocol/sql/postgres/lock_key.sql +9 -0
  18. package/dist/_protocol/sql/postgres/progress.sql +4 -3
  19. package/dist/_protocol/sql/postgres/protocol_version.sql +4 -0
  20. package/dist/_protocol/sql/postgres/purge.sql +25 -0
  21. package/dist/_protocol/sql/postgres/recover_leases.sql +49 -14
  22. package/dist/_protocol/sql/postgres/retry.sql +3 -0
  23. package/dist/_protocol/sql/postgres/stats.sql +8 -0
  24. package/dist/_protocol/sql/postgres/succeed.sql +4 -0
  25. package/dist/_protocol/sql/sqlite/claim.sql +13 -2
  26. package/dist/_protocol/sql/sqlite/claim_one_queue.sql +36 -0
  27. package/dist/_protocol/sql/sqlite/claimable_probe.sql +6 -2
  28. package/dist/_protocol/sql/sqlite/complete.sql +3 -0
  29. package/dist/_protocol/sql/sqlite/fail.sql +32 -8
  30. package/dist/_protocol/sql/sqlite/list.sql +3 -1
  31. package/dist/_protocol/sql/sqlite/lock_key.sql +5 -0
  32. package/dist/_protocol/sql/sqlite/progress.sql +6 -2
  33. package/dist/_protocol/sql/sqlite/protocol_version.sql +4 -0
  34. package/dist/_protocol/sql/sqlite/purge.sql +18 -0
  35. package/dist/_protocol/sql/sqlite/recover_leases.sql +25 -7
  36. package/dist/_protocol/sql/sqlite/retry.sql +3 -0
  37. package/dist/_protocol/sql/sqlite/stats.sql +8 -0
  38. package/dist/_protocol/sql/sqlite/succeed.sql +4 -0
  39. package/dist/client.d.ts +10 -2
  40. package/dist/client.js +12 -0
  41. package/dist/context.d.ts +17 -1
  42. package/dist/context.js +60 -6
  43. package/dist/errors.d.ts +18 -2
  44. package/dist/errors.js +49 -3
  45. package/dist/index.d.ts +3 -2
  46. package/dist/index.js +2 -1
  47. package/dist/sql.js +16 -9
  48. package/dist/store/base.d.ts +114 -9
  49. package/dist/store/base.js +376 -1
  50. package/dist/store/postgres.d.ts +62 -63
  51. package/dist/store/postgres.js +245 -222
  52. package/dist/store/sqlite.d.ts +83 -60
  53. package/dist/store/sqlite.js +370 -234
  54. package/dist/wait.d.ts +15 -2
  55. package/dist/wait.js +23 -5
  56. package/dist/worker.d.ts +53 -1
  57. package/dist/worker.js +202 -42
  58. package/package.json +9 -2
  59. package/src/client.ts +16 -2
  60. package/src/context.ts +70 -13
  61. package/src/errors.ts +59 -4
  62. package/src/index.ts +3 -1
  63. package/src/sql.ts +15 -8
  64. package/src/store/base.ts +443 -27
  65. package/src/store/postgres.ts +243 -267
  66. package/src/store/sqlite.ts +378 -263
  67. package/src/wait.ts +28 -5
  68. package/src/worker.ts +242 -42
package/README.md CHANGED
@@ -62,5 +62,33 @@ const { summary } = await tasks.call(summarize, { text }); // typed result, no c
62
62
 
63
63
  Opt-in: every API still accepts a plain name string (cross-language callers use it).
64
64
 
65
+ ## Running it in production
66
+
67
+ ```ts
68
+ const worker = Worker.sqlite("tasks.db", {
69
+ concurrency: 4,
70
+ retryBackoffMs: 1_000, // doubles per attempt, capped by retryBackoffMaxMs (30s); 0 disables
71
+ onError: (err, info) => log.warn({ err, ...info }), // claims/writes the loop survived
72
+ });
73
+
74
+ // Nothing else deletes rows. Sweep terminal tasks on a schedule.
75
+ await tasks.purge({ olderThanMs: 7 * 24 * 3600_000 });
76
+ ```
77
+
78
+ A handler that does real side effects should bail out when it loses its lease —
79
+ the task is already running on another worker and nothing it writes is recorded:
80
+
81
+ ```ts
82
+ worker.task("long.job", async (ctx) => {
83
+ const res = await fetch(url, { signal: ctx.signal }); // aborts on lease loss
84
+ if (ctx.lostLease || (await ctx.canceled())) return;
85
+ });
86
+ ```
87
+
88
+ ## Multi-host
89
+
90
+ Same code, Postgres instead of the file — `CairnQ.postgres(dsn)` /
91
+ `Worker.postgres(dsn)`. Requires the optional `pg` peer dependency (`npm i pg`).
92
+
65
93
  The protocol (schema + canonical SQL) lives in `../cairnq-protocol` and is shared
66
94
  verbatim with the Python SDK. See `../cairnq-protocol/PROTOCOL.md`.
@@ -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,6 @@
1
+ -- Serves purge.sql: scan terminal tasks in completion order. Without it the
2
+ -- retention sweep is a full table scan of exactly the rows that accumulate most.
3
+ create index if not exists cairnq_tasks_completed_idx
4
+ on cairnq_tasks (completed_at_ms);
5
+
6
+ update cairnq_meta set value = '2' where key = 'schema_version';
@@ -0,0 +1,38 @@
1
+ -- Push-based wakeups (Postgres only). A row trigger emits:
2
+ -- cairnq_queued (payload: queue name) when a task becomes claimable-soon:
3
+ -- inserted queued, or requeued by a
4
+ -- retryable fail / retry / recovery;
5
+ -- cairnq_done (payload: task id) when a task reaches a terminal status.
6
+ -- The trigger lives in the database, not in the SDKs, so every writer — either
7
+ -- SDK, any version, even hand-run SQL — wakes listeners. See PROTOCOL.md
8
+ -- ("Push wakeups") for the contract; in short, notifications only cut a poll
9
+ -- sleep short and are never required for correctness. Additive:
10
+ -- protocol_version stays 1.
11
+ --
12
+ -- Trigger guards, hottest write first:
13
+ -- - WHEN keeps claim (-> 'running', the most frequent status write) from
14
+ -- entering plpgsql at all;
15
+ -- - UPDATE OF status keeps heartbeat/progress from firing the trigger;
16
+ -- - the IS DISTINCT FROM checks skip a SET that rewrites the same value
17
+ -- (e.g. cancel.sql on an already-running task).
18
+
19
+ create or replace function cairnq_notify() returns trigger as $$
20
+ begin
21
+ if new.status = 'queued'
22
+ and (tg_op = 'INSERT' or old.status is distinct from new.status) then
23
+ perform pg_notify('cairnq_queued', new.queue);
24
+ elsif tg_op = 'UPDATE'
25
+ and new.status in ('succeeded', 'failed', 'canceled')
26
+ and old.status not in ('succeeded', 'failed', 'canceled') then
27
+ perform pg_notify('cairnq_done', new.id);
28
+ end if;
29
+ return null;
30
+ end;
31
+ $$ language plpgsql;
32
+
33
+ drop trigger if exists cairnq_tasks_notify on cairnq_tasks;
34
+ create trigger cairnq_tasks_notify
35
+ after insert or update of status on cairnq_tasks
36
+ for each row
37
+ when (new.status is distinct from 'running')
38
+ execute function cairnq_notify();
@@ -0,0 +1,16 @@
1
+ -- Serves recover_leases.sql: find tasks whose lease expired. That statement runs
2
+ -- on every worker's every poll, inside the claim transaction, so it is the one
3
+ -- recovery read on the hot path. See the SQLite twin for the reasoning; it holds
4
+ -- here too, and additionally keeps the FOR UPDATE SKIP LOCKED subquery from
5
+ -- taking row locks it will immediately skip.
6
+ --
7
+ -- Not CONCURRENTLY: migrations run inside the same transaction as their
8
+ -- bookkeeping insert (see PROTOCOL.md), and CREATE INDEX CONCURRENTLY cannot run
9
+ -- in one. On a table this size the plain form's lock is brief; a deployment large
10
+ -- enough to care can build it by hand ahead of the upgrade, and IF NOT EXISTS
11
+ -- makes this migration a no-op then.
12
+ create index if not exists cairnq_tasks_lease_idx
13
+ on cairnq_tasks (lease_until_ms)
14
+ where status = 'running' and lease_until_ms is not null;
15
+
16
+ update cairnq_meta set value = '4' where key = 'schema_version';
@@ -0,0 +1,17 @@
1
+ -- Make the lease invariant true of the whole table, not just of rows written from
2
+ -- here on: `lease_until_ms is not null` if and only if `status = 'running'`.
3
+ --
4
+ -- succeed.sql and complete.sql used to leave the dead attempt's lease behind, so a
5
+ -- succeeded task reported a lease into the future and nobody owned it. The crash
6
+ -- path (recover_leases.sql) always cleared it, so the two ways out of 'running'
7
+ -- disagreed on what a terminal row looks like. The statements now agree; this
8
+ -- catches up the rows they already wrote.
9
+ --
10
+ -- Scoped to `status <> 'running'` rather than to the three terminal states: that is
11
+ -- the invariant itself, and it also covers a 'queued' row should one ever be left
12
+ -- holding a lease.
13
+ update cairnq_tasks
14
+ set lease_until_ms = null
15
+ where status <> 'running' and lease_until_ms is not null;
16
+
17
+ update cairnq_meta set value = '5' where key = 'schema_version';
@@ -45,7 +45,9 @@ create table if not exists cairnq_tasks (
45
45
 
46
46
  -- Serves the claim query: WHERE queue=? AND status='queued' ORDER BY priority
47
47
  -- desc, created_at_ms asc (run_at_ms applied as a residual filter). Leading with
48
- -- queue+status then the ORDER BY columns avoids a sort for single-queue claims.
48
+ -- queue+status then the ORDER BY columns is what lets claim_one_queue.sql read
49
+ -- rows in claim order; claim.sql's list-valued queue filter has to merge several
50
+ -- ranges of this index, so it sorts instead.
49
51
  create index if not exists cairnq_tasks_claim_idx
50
52
  on cairnq_tasks (queue, status, priority desc, created_at_ms);
51
53
  create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
@@ -0,0 +1,6 @@
1
+ -- Serves purge.sql: scan terminal tasks in completion order. Without it the
2
+ -- retention sweep is a full table scan of exactly the rows that accumulate most.
3
+ create index if not exists cairnq_tasks_completed_idx
4
+ on cairnq_tasks (completed_at_ms);
5
+
6
+ update cairnq_meta set value = '2' where key = 'schema_version';
@@ -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';
@@ -6,20 +6,33 @@
6
6
  -- recover_leases MUST run first in the SAME transaction. READ COMMITTED suffices:
7
7
  -- each UPDATE re-checks its WHERE against the latest committed row, so racing
8
8
  -- claims/recovers can neither double-dispatch a task nor double-recover a lease.
9
- -- params: queues (text[]), worker_id, lease_ms, limit
9
+ -- Time is clock_timestamp(), not now(): now() freezes at BEGIN, and this runs
10
+ -- after recover_leases in a transaction that may have waited on row locks — a
11
+ -- lease stamped from the transaction start would already be short by that wait.
12
+ --
13
+ -- :names is the set of task names the caller can actually run, or NULL for no
14
+ -- filter. A worker passes its registered handler names: queues alone do not
15
+ -- partition work, so without this a worker claims a task it has no handler for
16
+ -- and fails it permanently — two workers with different handler sets on one queue
17
+ -- would destroy each other's tasks. An empty array claims nothing.
18
+ -- params: queues (text[]), names (text[] or null), worker_id, lease_ms, limit
10
19
  update cairnq_tasks t
11
20
  set
12
21
  status = 'running',
13
22
  worker_id = :worker_id,
14
- lease_until_ms = (extract(epoch from now()) * 1000)::bigint + :lease_ms,
23
+ lease_until_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint + :lease_ms,
15
24
  attempt = attempt + 1,
16
- updated_at_ms = (extract(epoch from now()) * 1000)::bigint
25
+ updated_at_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint
17
26
  from (
18
27
  select id from cairnq_tasks
19
28
  where status = 'queued'
20
29
  and queue = any(:queues::text[])
21
- and run_at_ms <= (extract(epoch from now()) * 1000)::bigint
22
- order by priority desc, created_at_ms asc
30
+ and (:names::text[] is null or name = any(:names::text[]))
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
34
+ -- the id's random half decides, stably but not in submit order.
35
+ order by priority desc, created_at_ms asc, id asc
23
36
  limit :limit
24
37
  for update skip locked
25
38
  ) sel
@@ -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
@@ -1,18 +1,40 @@
1
- -- Fail a task (Postgres dialect). Ownership-checked. Single CASE-based statement
2
- -- handles both branches atomically: retryable && attempt < max_attempts -> requeue
3
- -- with backoff (run_at = now + :delay_ms); otherwise -> terminal 'failed'.
1
+ -- Fail a task (Postgres dialect). Ownership-checked. One CASE-based statement
2
+ -- decides all three outcomes atomically:
3
+ -- 1. a cancel was requested while it ran -> terminal 'canceled'. Cancel wins,
4
+ -- exactly as in complete.sql: a task the user cancelled must never be
5
+ -- redelivered, whether the attempt ended in a return or in an exception.
6
+ -- 2. retryable && attempt < max_attempts -> requeue with backoff
7
+ -- (run_at = now + :delay_ms).
8
+ -- 3. otherwise -> terminal 'failed'.
9
+ -- The error envelope is recorded on every branch, so a canceled-while-failing
10
+ -- task still carries why its last attempt failed. progress/message describe the
11
+ -- attempt in flight, so only the requeue branch clears them: a terminal record
12
+ -- keeps how far the last attempt got, a re-queued one must not advertise a dead
13
+ -- attempt's progress bar until the next attempt overwrites it.
4
14
  -- :retryable is a native boolean. :error is bound as jsonb. Time from the DB clock.
5
15
  -- params: id, worker_id, error (jsonb), retryable (boolean), delay_ms
6
16
  update cairnq_tasks
7
17
  set
8
- status = case when :retryable and attempt < max_attempts then 'queued' else 'failed' end,
18
+ status = case
19
+ when cancel_requested_at_ms is not null then 'canceled'
20
+ when :retryable and attempt < max_attempts then 'queued'
21
+ else 'failed'
22
+ end,
9
23
  error = :error::jsonb,
10
- worker_id = case when :retryable and attempt < max_attempts then null else worker_id end,
11
- lease_until_ms = case when :retryable and attempt < max_attempts then null else lease_until_ms end,
12
- run_at_ms = case when :retryable and attempt < max_attempts
24
+ worker_id = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
25
+ then null else worker_id end,
26
+ -- Unconditional, unlike worker_id above: all three branches end the attempt
27
+ -- that held the lease — two terminally, one to wait for redelivery — and none
28
+ -- of them leaves anyone owning it (see succeed.sql).
29
+ lease_until_ms = null,
30
+ run_at_ms = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
13
31
  then (extract(epoch from now()) * 1000)::bigint + :delay_ms else run_at_ms end,
14
- completed_at_ms = case when :retryable and attempt < max_attempts
32
+ completed_at_ms = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
15
33
  then null else (extract(epoch from now()) * 1000)::bigint end,
34
+ progress = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
35
+ then null else progress end,
36
+ message = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
37
+ then null else message end,
16
38
  updated_at_ms = (extract(epoch from now()) * 1000)::bigint
17
39
  where id = :id
18
40
  and status = 'running'
@@ -2,6 +2,9 @@
2
2
  -- :root_id (= :id for top-level tasks). Time comes from the DB clock, so the SDK
3
3
  -- passes a relative :delay_ms (not an absolute run_at_ms): run_at = now + delay.
4
4
  -- :payload / :metadata are bound as jsonb.
5
+ -- Time is clock_timestamp(), not now(): a keyed submit runs this after waiting
6
+ -- on the key's advisory lock, and now() freezes at BEGIN — a delayed task's
7
+ -- run_at stamped from the transaction start would fire early by that wait.
5
8
  -- params: id, name, queue, payload, metadata, max_attempts, priority,
6
9
  -- delay_ms, parent_id, root_id, correlation_id
7
10
  insert into cairnq_tasks (
@@ -12,9 +15,9 @@ insert into cairnq_tasks (
12
15
  ) values (
13
16
  :id, :name, :queue, 'queued', :payload::jsonb, :metadata::jsonb,
14
17
  :max_attempts, :priority,
15
- (extract(epoch from now()) * 1000)::bigint + :delay_ms,
18
+ (extract(epoch from clock_timestamp()) * 1000)::bigint + :delay_ms,
16
19
  :parent_id, :root_id, :correlation_id,
17
- (extract(epoch from now()) * 1000)::bigint,
18
- (extract(epoch from now()) * 1000)::bigint
20
+ (extract(epoch from clock_timestamp()) * 1000)::bigint,
21
+ (extract(epoch from clock_timestamp()) * 1000)::bigint
19
22
  )
20
23
  returning *;
@@ -9,5 +9,7 @@ where (:status::text is null or status = :status)
9
9
  and (:name::text is null or name = :name)
10
10
  and (:root_id::text is null or root_id = :root_id)
11
11
  and (:correlation_id::text is null or correlation_id = :correlation_id)
12
- order by created_at_ms desc
12
+ -- id breaks created_at_ms ties, as in claim.sql: without it, paginating with
13
+ -- offset across same-millisecond rows could repeat or skip a task.
14
+ order by created_at_ms desc, id desc
13
15
  limit :limit offset :offset;
@@ -0,0 +1,9 @@
1
+ -- Serialize all keyed operations on one key (submit-with-key and the *_by_key
2
+ -- ops). READ COMMITTED gives those read-then-write sequences nothing to lock
3
+ -- when the key row does not exist yet, so two concurrent same-key submits could
4
+ -- both see "no existing task" and both insert — two live tasks under one key.
5
+ -- An advisory transaction lock on the key's hash closes that window; it
6
+ -- releases with the transaction. A hash collision only over-serializes two
7
+ -- unrelated keys — it can never under-lock.
8
+ -- params: key
9
+ select pg_advisory_xact_lock(hashtextextended(:key::text, 0));
@@ -1,9 +1,10 @@
1
1
  -- Update progress/message (Postgres dialect). Ownership-checked. Does not change
2
- -- status. message is coalesced so progress(value) without a message keeps the
3
- -- prior one. Time comes from the DB clock.
2
+ -- status. Both fields are coalesced, symmetrically: progress(value) keeps the
3
+ -- prior message, progress(null, message) keeps the prior fraction. Passing null
4
+ -- means "leave this alone", never "clear it". Time comes from the DB clock.
4
5
  -- params: id, worker_id, progress, message
5
6
  update cairnq_tasks
6
- set progress = :progress,
7
+ set progress = coalesce(:progress, progress),
7
8
  message = coalesce(:message, message),
8
9
  updated_at_ms = (extract(epoch from now()) * 1000)::bigint
9
10
  where id = :id
@@ -0,0 +1,4 @@
1
+ -- The storage's protocol major, from cairnq_meta (written by the migrations).
2
+ -- Returns no row on a database from before the first migration.
3
+ -- params: (none)
4
+ select value from cairnq_meta where key = 'protocol_version';
@@ -0,0 +1,25 @@
1
+ -- Retention: delete terminal tasks that completed before a cutoff (Postgres
2
+ -- dialect). Nothing else ever removes rows, so without this a long-lived database
3
+ -- only grows. Bounded by :limit so a large backlog is drained in short
4
+ -- transactions. The key pointer of a purged task goes with it via
5
+ -- cairnq_task_keys' ON DELETE CASCADE. The cutoff is relative (:older_than_ms)
6
+ -- because time comes from the DB clock.
7
+ -- FOR UPDATE SKIP LOCKED matters for correctness, not just throughput: without
8
+ -- it the subselect materializes on the statement snapshot and the outer
9
+ -- DELETE's only re-checked qual is the immutable id — so a task retried (and
10
+ -- even re-claimed) after the snapshot would still be deleted, destroying a
11
+ -- live task. Locking the rows in the subselect freezes them terminal until the
12
+ -- delete commits; a concurrent retry then re-evaluates against the deleted row
13
+ -- and correctly finds nothing.
14
+ -- params: older_than_ms, limit
15
+ delete from cairnq_tasks
16
+ where id in (
17
+ select id from cairnq_tasks
18
+ where status in ('succeeded', 'failed', 'canceled')
19
+ and completed_at_ms is not null
20
+ and completed_at_ms < (extract(epoch from now()) * 1000)::bigint - :older_than_ms
21
+ order by completed_at_ms asc
22
+ limit :limit
23
+ for update skip locked
24
+ )
25
+ returning id;
@@ -1,20 +1,55 @@
1
1
  -- Reclaim tasks whose lease expired (Postgres dialect). Run inside the same write
2
- -- transaction as claim, just before it. attempt < max_attempts -> back to 'queued'
3
- -- for redelivery; otherwise -> 'failed' with a lease-expired error envelope. Time
4
- -- comes from the DB clock.
2
+ -- transaction as claim, just before it. Three outcomes, mirroring fail.sql:
3
+ -- 1. a cancel was requested before the worker died -> terminal 'canceled'
4
+ -- (a cancelled task must never be redelivered by the crash path either);
5
+ -- 2. attempt < max_attempts -> back to 'queued' for redelivery;
6
+ -- 3. otherwise -> terminal 'failed' with a lease-expired error envelope.
7
+ -- Time is clock_timestamp() (see claim.sql). FOR UPDATE SKIP LOCKED keeps every
8
+ -- worker's recovery pass non-blocking: a row another worker is already
9
+ -- recovering — or that its owner is finalizing right now — is simply skipped,
10
+ -- that transaction's outcome stands, and (unlike a plain set UPDATE, whose
11
+ -- lock order follows the scan) no two recoverers can deadlock.
5
12
  -- params: lease_expired_error (jsonb envelope)
6
13
  update cairnq_tasks
7
14
  set
8
- status = case when attempt < max_attempts then 'queued' else 'failed' end,
9
- worker_id = case when attempt < max_attempts then null else worker_id end,
15
+ status = case
16
+ when cancel_requested_at_ms is not null then 'canceled'
17
+ when attempt < max_attempts then 'queued'
18
+ else 'failed'
19
+ end,
20
+ worker_id = case when cancel_requested_at_ms is null and attempt < max_attempts
21
+ then null else worker_id end,
10
22
  lease_until_ms = null,
11
- run_at_ms = case when attempt < max_attempts
12
- then (extract(epoch from now()) * 1000)::bigint else run_at_ms end,
13
- error = case when attempt >= max_attempts then :lease_expired_error::jsonb else error end,
14
- completed_at_ms = case when attempt >= max_attempts
15
- then (extract(epoch from now()) * 1000)::bigint else completed_at_ms end,
16
- updated_at_ms = (extract(epoch from now()) * 1000)::bigint
17
- where status = 'running'
18
- and lease_until_ms is not null
19
- and lease_until_ms <= (extract(epoch from now()) * 1000)::bigint
23
+ run_at_ms = case when cancel_requested_at_ms is null and attempt < max_attempts
24
+ then (extract(epoch from clock_timestamp()) * 1000)::bigint else run_at_ms end,
25
+ -- Only the failed branch records lease expiry: a canceled task did not fail.
26
+ error = case when cancel_requested_at_ms is null and attempt >= max_attempts
27
+ then :lease_expired_error::jsonb else error end,
28
+ completed_at_ms = case when cancel_requested_at_ms is null and attempt < max_attempts
29
+ then completed_at_ms else (extract(epoch from clock_timestamp()) * 1000)::bigint end,
30
+ -- Only the requeue branch clears them: they describe the dead attempt, and a
31
+ -- task waiting to be redelivered must not report its progress bar.
32
+ progress = case when cancel_requested_at_ms is null and attempt < max_attempts
33
+ then null else progress end,
34
+ message = case when cancel_requested_at_ms is null and attempt < max_attempts
35
+ then null else message end,
36
+ updated_at_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint
37
+ where id in (
38
+ select id from cairnq_tasks
39
+ where status = 'running'
40
+ and lease_until_ms is not null
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)
53
+ for update skip locked
54
+ )
20
55
  returning *;
@@ -11,6 +11,9 @@ set
11
11
  run_at_ms = (extract(epoch from now()) * 1000)::bigint,
12
12
  cancel_requested_at_ms = null,
13
13
  completed_at_ms = null,
14
+ -- The previous attempt's progress bar dies with the attempt.
15
+ progress = null,
16
+ message = null,
14
17
  attempt = case when :reset_attempt then 0 else attempt end,
15
18
  updated_at_ms = (extract(epoch from now()) * 1000)::bigint
16
19
  where id = :id and status in ('failed', 'canceled')
@@ -0,0 +1,8 @@
1
+ -- Queue depth at a glance: task counts grouped by queue and status. Read-only.
2
+ -- A queue appears only while it has rows — terminal tasks count until purge
3
+ -- removes them. The SDK zero-fills the statuses a queue has no rows in.
4
+ -- params: (none)
5
+ select queue, status, count(*) as count
6
+ from cairnq_tasks
7
+ group by queue, status
8
+ order by queue asc, status asc;
@@ -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
@@ -1,7 +1,14 @@
1
1
  -- Atomic claim. :queues is a JSON array of queue names. :lease_until_ms is
2
2
  -- precomputed by the SDK (= now_ms + lease_ms). Single UPDATE ... RETURNING
3
3
  -- under BEGIN IMMEDIATE is the SQLite equivalent of FOR UPDATE SKIP LOCKED.
4
- -- params: queues (JSON array text), now_ms, worker_id, lease_until_ms, limit
4
+ --
5
+ -- :names is a JSON array of the task names the caller can actually run, or NULL
6
+ -- for no filter. A worker passes its registered handler names: queues alone do
7
+ -- not partition work, so without this a worker claims a task it has no handler
8
+ -- for and fails it permanently — two workers with different handler sets on one
9
+ -- queue would destroy each other's tasks. An empty array claims nothing.
10
+ -- params: queues (JSON array text), names (JSON array text or null), now_ms,
11
+ -- worker_id, lease_until_ms, limit
5
12
  update cairnq_tasks
6
13
  set
7
14
  status = 'running',
@@ -13,8 +20,12 @@ where id in (
13
20
  select id from cairnq_tasks
14
21
  where status = 'queued'
15
22
  and queue in (select value from json_each(:queues))
23
+ and (:names is null or name in (select value from json_each(:names)))
16
24
  and run_at_ms <= :now_ms
17
- order by priority desc, created_at_ms asc
25
+ -- id breaks created_at_ms ties (same-millisecond submits), so claim order
26
+ -- is deterministic: FIFO at millisecond granularity; within one millisecond
27
+ -- the id's random half decides, stably but not in submit order.
28
+ order by priority desc, created_at_ms asc, id asc
18
29
  limit :limit
19
30
  )
20
31
  returning *;
@@ -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 *;
@@ -1,12 +1,16 @@
1
1
  -- Read-only check: is there anything worth opening a write transaction for?
2
2
  -- Run before claim so idle workers don't take a write lock every poll (which
3
3
  -- would serialize all idle workers on SQLite's single writer). Returns has_work
4
- -- = 1 if any task in these queues is claimable, or any lease has expired.
5
- -- params: queues (JSON array text), now_ms
4
+ -- = 1 if any task this caller can run is claimable, or any lease has expired.
5
+ -- Mirrors claim.sql's filters, so the probe never promises work claim will skip.
6
+ -- The expired-lease arm stays unfiltered on purpose: recovering a dead worker's
7
+ -- task is every worker's job, whatever names it happens to handle.
8
+ -- params: queues (JSON array text), names (JSON array text or null), now_ms
6
9
  select exists(
7
10
  select 1 from cairnq_tasks
8
11
  where (status = 'queued'
9
12
  and queue in (select value from json_each(:queues))
13
+ and (:names is null or name in (select value from json_each(:names)))
10
14
  and run_at_ms <= :now_ms)
11
15
  or (status = 'running'
12
16
  and lease_until_ms is not null
@@ -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