cairnq 0.1.0 → 0.2.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/README.md +28 -0
- package/dist/_protocol/migrations/postgres/0002_purge_index.sql +6 -0
- package/dist/_protocol/migrations/postgres/0003_notify.sql +38 -0
- package/dist/_protocol/migrations/sqlite/0002_purge_index.sql +6 -0
- package/dist/_protocol/sql/postgres/claim.sql +18 -5
- package/dist/_protocol/sql/postgres/fail.sql +28 -8
- package/dist/_protocol/sql/postgres/insert_task.sql +6 -3
- package/dist/_protocol/sql/postgres/list.sql +3 -1
- package/dist/_protocol/sql/postgres/lock_key.sql +9 -0
- package/dist/_protocol/sql/postgres/progress.sql +4 -3
- package/dist/_protocol/sql/postgres/protocol_version.sql +4 -0
- package/dist/_protocol/sql/postgres/purge.sql +25 -0
- package/dist/_protocol/sql/postgres/recover_leases.sql +38 -14
- package/dist/_protocol/sql/postgres/retry.sql +3 -0
- package/dist/_protocol/sql/postgres/stats.sql +8 -0
- package/dist/_protocol/sql/sqlite/claim.sql +13 -2
- package/dist/_protocol/sql/sqlite/claimable_probe.sql +6 -2
- package/dist/_protocol/sql/sqlite/fail.sql +30 -8
- package/dist/_protocol/sql/sqlite/list.sql +3 -1
- package/dist/_protocol/sql/sqlite/lock_key.sql +5 -0
- package/dist/_protocol/sql/sqlite/progress.sql +6 -2
- package/dist/_protocol/sql/sqlite/protocol_version.sql +4 -0
- package/dist/_protocol/sql/sqlite/purge.sql +18 -0
- package/dist/_protocol/sql/sqlite/recover_leases.sql +25 -7
- package/dist/_protocol/sql/sqlite/retry.sql +3 -0
- package/dist/_protocol/sql/sqlite/stats.sql +8 -0
- package/dist/client.d.ts +10 -2
- package/dist/client.js +12 -0
- package/dist/context.d.ts +17 -1
- package/dist/context.js +60 -6
- package/dist/errors.d.ts +18 -2
- package/dist/errors.js +49 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/sql.js +16 -9
- package/dist/store/base.d.ts +107 -9
- package/dist/store/base.js +370 -1
- package/dist/store/postgres.d.ts +62 -63
- package/dist/store/postgres.js +245 -222
- package/dist/store/sqlite.d.ts +34 -59
- package/dist/store/sqlite.js +200 -232
- package/dist/wait.d.ts +15 -2
- package/dist/wait.js +23 -5
- package/dist/worker.d.ts +53 -1
- package/dist/worker.js +202 -42
- package/package.json +9 -2
- package/src/client.ts +16 -2
- package/src/context.ts +70 -13
- package/src/errors.ts +59 -4
- package/src/index.ts +3 -1
- package/src/sql.ts +15 -8
- package/src/store/base.ts +430 -27
- package/src/store/postgres.ts +243 -267
- package/src/store/sqlite.ts +211 -265
- package/src/wait.ts +28 -5
- 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`.
|
|
@@ -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,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';
|
|
@@ -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
|
-
--
|
|
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
|
|
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
|
|
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
|
|
22
|
-
|
|
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
|
|
@@ -1,18 +1,38 @@
|
|
|
1
|
-
-- Fail a task (Postgres dialect). Ownership-checked.
|
|
2
|
-
--
|
|
3
|
-
--
|
|
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
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
lease_until_ms = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
|
|
27
|
+
then null else lease_until_ms end,
|
|
28
|
+
run_at_ms = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
|
|
13
29
|
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
|
|
30
|
+
completed_at_ms = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
|
|
15
31
|
then null else (extract(epoch from now()) * 1000)::bigint end,
|
|
32
|
+
progress = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
|
|
33
|
+
then null else progress end,
|
|
34
|
+
message = case when cancel_requested_at_ms is null and :retryable and attempt < max_attempts
|
|
35
|
+
then null else message end,
|
|
16
36
|
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
17
37
|
where id = :id
|
|
18
38
|
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
|
|
18
|
+
(extract(epoch from clock_timestamp()) * 1000)::bigint + :delay_ms,
|
|
16
19
|
:parent_id, :root_id, :correlation_id,
|
|
17
|
-
(extract(epoch from
|
|
18
|
-
(extract(epoch from
|
|
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
|
-
|
|
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.
|
|
3
|
-
-- prior
|
|
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,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,44 @@
|
|
|
1
1
|
-- Reclaim tasks whose lease expired (Postgres dialect). Run inside the same write
|
|
2
|
-
-- transaction as claim, just before it.
|
|
3
|
-
--
|
|
4
|
-
--
|
|
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
|
|
9
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
and lease_until_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
|
|
42
|
+
for update skip locked
|
|
43
|
+
)
|
|
20
44
|
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;
|
|
@@ -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
|
-
--
|
|
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
|
-
|
|
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 *;
|
|
@@ -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
|
|
5
|
-
--
|
|
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
|
|
@@ -1,16 +1,38 @@
|
|
|
1
|
-
-- Fail a task. Ownership-checked.
|
|
2
|
-
--
|
|
3
|
-
--
|
|
1
|
+
-- Fail a task. Ownership-checked. One CASE-based statement decides all three
|
|
2
|
+
-- 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 0/1. :error is a JSON envelope text.
|
|
5
15
|
-- params: id, worker_id, now_ms, error, retryable, delay_ms
|
|
6
16
|
update cairnq_tasks
|
|
7
17
|
set
|
|
8
|
-
status = case
|
|
18
|
+
status = case
|
|
19
|
+
when cancel_requested_at_ms is not null then 'canceled'
|
|
20
|
+
when :retryable = 1 and attempt < max_attempts then 'queued'
|
|
21
|
+
else 'failed'
|
|
22
|
+
end,
|
|
9
23
|
error = :error,
|
|
10
|
-
worker_id = case when :retryable = 1 and attempt < max_attempts
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
worker_id = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
|
|
25
|
+
then null else worker_id end,
|
|
26
|
+
lease_until_ms = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
|
|
27
|
+
then null else lease_until_ms end,
|
|
28
|
+
run_at_ms = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
|
|
29
|
+
then :now_ms + :delay_ms else run_at_ms end,
|
|
30
|
+
completed_at_ms = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
|
|
31
|
+
then null else :now_ms end,
|
|
32
|
+
progress = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
|
|
33
|
+
then null else progress end,
|
|
34
|
+
message = case when cancel_requested_at_ms is null and :retryable = 1 and attempt < max_attempts
|
|
35
|
+
then null else message end,
|
|
14
36
|
updated_at_ms = :now_ms
|
|
15
37
|
where id = :id
|
|
16
38
|
and status = 'running'
|
|
@@ -7,5 +7,7 @@ where (:status is null or status = :status)
|
|
|
7
7
|
and (:name is null or name = :name)
|
|
8
8
|
and (:root_id is null or root_id = :root_id)
|
|
9
9
|
and (:correlation_id is null or correlation_id = :correlation_id)
|
|
10
|
-
|
|
10
|
+
-- id breaks created_at_ms ties, as in claim.sql: without it, paginating with
|
|
11
|
+
-- offset across same-millisecond rows could repeat or skip a task.
|
|
12
|
+
order by created_at_ms desc, id desc
|
|
11
13
|
limit :limit offset :offset;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- No-op on SQLite: BEGIN IMMEDIATE already serializes every keyed transaction
|
|
2
|
+
-- on the database's single write lock, so there is nothing further to lock.
|
|
3
|
+
-- Exists so the shared TaskStore logic can take the key lock unconditionally;
|
|
4
|
+
-- see the postgres dialect for the real one.
|
|
5
|
+
select 1 as locked;
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
-- Update progress/message. Ownership-checked. Does not change status.
|
|
2
2
|
-- params: id, worker_id, now_ms, progress, message
|
|
3
|
-
--
|
|
3
|
+
-- Both fields are coalesced, symmetrically: progress(value) keeps the prior
|
|
4
|
+
-- message, progress(null, message) keeps the prior fraction. Passing null means
|
|
5
|
+
-- "leave this alone", never "clear it".
|
|
4
6
|
update cairnq_tasks
|
|
5
|
-
set progress =
|
|
7
|
+
set progress = coalesce(:progress, progress),
|
|
8
|
+
message = coalesce(:message, message),
|
|
9
|
+
updated_at_ms = :now_ms
|
|
6
10
|
where id = :id
|
|
7
11
|
and status = 'running'
|
|
8
12
|
and worker_id = :worker_id
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Retention: delete terminal tasks that completed before a cutoff. Nothing else
|
|
2
|
+
-- ever removes rows, so without this a long-lived database only grows.
|
|
3
|
+
-- Bounded by :limit so a large backlog is drained in short transactions instead
|
|
4
|
+
-- of one long write that blocks every other writer. The key pointer of a purged
|
|
5
|
+
-- task goes with it via cairnq_task_keys' ON DELETE CASCADE.
|
|
6
|
+
-- The LIMIT lives in a subquery: plain `delete ... limit` needs a non-default
|
|
7
|
+
-- SQLite build option.
|
|
8
|
+
-- params: before_ms, limit
|
|
9
|
+
delete from cairnq_tasks
|
|
10
|
+
where id in (
|
|
11
|
+
select id from cairnq_tasks
|
|
12
|
+
where status in ('succeeded', 'failed', 'canceled')
|
|
13
|
+
and completed_at_ms is not null
|
|
14
|
+
and completed_at_ms < :before_ms
|
|
15
|
+
order by completed_at_ms asc
|
|
16
|
+
limit :limit
|
|
17
|
+
)
|
|
18
|
+
returning id;
|
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
-- Reclaim tasks whose lease expired. Run inside the same write transaction as
|
|
2
|
-
-- claim, just before it.
|
|
3
|
-
--
|
|
2
|
+
-- 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.
|
|
4
7
|
-- params: now_ms, lease_expired_error (JSON envelope text)
|
|
5
8
|
update cairnq_tasks
|
|
6
9
|
set
|
|
7
|
-
status = case
|
|
8
|
-
|
|
10
|
+
status = case
|
|
11
|
+
when cancel_requested_at_ms is not null then 'canceled'
|
|
12
|
+
when attempt < max_attempts then 'queued'
|
|
13
|
+
else 'failed'
|
|
14
|
+
end,
|
|
15
|
+
worker_id = case when cancel_requested_at_ms is null and attempt < max_attempts
|
|
16
|
+
then null else worker_id end,
|
|
9
17
|
lease_until_ms = null,
|
|
10
|
-
run_at_ms = case when
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
run_at_ms = case when cancel_requested_at_ms is null and attempt < max_attempts
|
|
19
|
+
then :now_ms else run_at_ms end,
|
|
20
|
+
-- Only the failed branch records lease expiry: a canceled task did not fail.
|
|
21
|
+
error = case when cancel_requested_at_ms is null and attempt >= max_attempts
|
|
22
|
+
then :lease_expired_error else error end,
|
|
23
|
+
completed_at_ms = case when cancel_requested_at_ms is null and attempt < max_attempts
|
|
24
|
+
then completed_at_ms else :now_ms end,
|
|
25
|
+
-- Only the requeue branch clears them: they describe the dead attempt, and a
|
|
26
|
+
-- task waiting to be redelivered must not report its progress bar.
|
|
27
|
+
progress = case when cancel_requested_at_ms is null and attempt < max_attempts
|
|
28
|
+
then null else progress end,
|
|
29
|
+
message = case when cancel_requested_at_ms is null and attempt < max_attempts
|
|
30
|
+
then null else message end,
|
|
13
31
|
updated_at_ms = :now_ms
|
|
14
32
|
where status = 'running'
|
|
15
33
|
and lease_until_ms is not null
|
|
@@ -10,6 +10,9 @@ set
|
|
|
10
10
|
run_at_ms = :now_ms,
|
|
11
11
|
cancel_requested_at_ms = null,
|
|
12
12
|
completed_at_ms = null,
|
|
13
|
+
-- The previous attempt's progress bar dies with the attempt.
|
|
14
|
+
progress = null,
|
|
15
|
+
message = null,
|
|
13
16
|
attempt = case when :reset_attempt = 1 then 0 else attempt end,
|
|
14
17
|
updated_at_ms = :now_ms
|
|
15
18
|
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;
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type Task } from "./models.js";
|
|
2
|
-
import type { ListInput, SubmitInput, TaskStore } from "./store/base.js";
|
|
1
|
+
import { type Task, type TaskStatus } from "./models.js";
|
|
2
|
+
import type { ListInput, PurgeInput, SubmitInput, TaskStore } from "./store/base.js";
|
|
3
3
|
import { type TaskDef } from "./task.js";
|
|
4
4
|
export type SubmitOptions = Omit<SubmitInput, "name" | "payload">;
|
|
5
5
|
export interface CallOptions extends SubmitOptions {
|
|
@@ -34,6 +34,14 @@ export declare class CairnQ {
|
|
|
34
34
|
retryByKey(key: string, opts?: {
|
|
35
35
|
resetAttempt?: boolean;
|
|
36
36
|
}): Promise<Task | null>;
|
|
37
|
+
/** Delete terminal tasks that finished more than `olderThanMs` ago and return
|
|
38
|
+
* their ids. Nothing else in CairnQ removes rows, so a long-lived database
|
|
39
|
+
* needs this on a schedule. Each call is bounded by `limit` to keep the write
|
|
40
|
+
* short; loop until it returns fewer than `limit`. */
|
|
41
|
+
purge(input?: PurgeInput): Promise<string[]>;
|
|
42
|
+
/** Task counts per queue, keyed by status and zero-filled across all statuses
|
|
43
|
+
* — `(await stats()).default.queued` is the backlog of a queue. */
|
|
44
|
+
stats(): Promise<Record<string, Record<TaskStatus, number>>>;
|
|
37
45
|
wait(taskId: string, opts?: {
|
|
38
46
|
timeoutMs?: number;
|
|
39
47
|
pollMs?: number;
|
package/dist/client.js
CHANGED
|
@@ -51,6 +51,18 @@ export class CairnQ {
|
|
|
51
51
|
retryByKey(key, opts) {
|
|
52
52
|
return this._store.retryByKey(key, opts);
|
|
53
53
|
}
|
|
54
|
+
/** Delete terminal tasks that finished more than `olderThanMs` ago and return
|
|
55
|
+
* their ids. Nothing else in CairnQ removes rows, so a long-lived database
|
|
56
|
+
* needs this on a schedule. Each call is bounded by `limit` to keep the write
|
|
57
|
+
* short; loop until it returns fewer than `limit`. */
|
|
58
|
+
purge(input) {
|
|
59
|
+
return this._store.purge(input);
|
|
60
|
+
}
|
|
61
|
+
/** Task counts per queue, keyed by status and zero-filled across all statuses
|
|
62
|
+
* — `(await stats()).default.queued` is the backlog of a queue. */
|
|
63
|
+
stats() {
|
|
64
|
+
return this._store.stats();
|
|
65
|
+
}
|
|
54
66
|
wait(taskId, opts = {}) {
|
|
55
67
|
return pollWait(this._store, taskId, {
|
|
56
68
|
timeoutMs: opts.timeoutMs ?? 30_000,
|