cairnq 0.1.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/_protocol/migrations/postgres/0001_init.sql +75 -0
- package/dist/_protocol/migrations/sqlite/0001_init.sql +74 -0
- package/dist/_protocol/sql/postgres/cancel.sql +16 -0
- package/dist/_protocol/sql/postgres/claim.sql +27 -0
- package/dist/_protocol/sql/postgres/complete.sql +17 -0
- package/dist/_protocol/sql/postgres/fail.sql +21 -0
- package/dist/_protocol/sql/postgres/get.sql +2 -0
- package/dist/_protocol/sql/postgres/get_by_key.sql +4 -0
- package/dist/_protocol/sql/postgres/get_key.sql +3 -0
- package/dist/_protocol/sql/postgres/heartbeat.sql +12 -0
- package/dist/_protocol/sql/postgres/insert_task.sql +20 -0
- package/dist/_protocol/sql/postgres/list.sql +13 -0
- package/dist/_protocol/sql/postgres/progress.sql +13 -0
- package/dist/_protocol/sql/postgres/recover_leases.sql +20 -0
- package/dist/_protocol/sql/postgres/retry.sql +17 -0
- package/dist/_protocol/sql/postgres/succeed.sql +16 -0
- package/dist/_protocol/sql/postgres/upsert_key.sql +12 -0
- package/dist/_protocol/sql/sqlite/cancel.sql +12 -0
- package/dist/_protocol/sql/sqlite/claim.sql +20 -0
- package/dist/_protocol/sql/sqlite/claimable_probe.sql +14 -0
- package/dist/_protocol/sql/sqlite/complete.sql +18 -0
- package/dist/_protocol/sql/sqlite/fail.sql +19 -0
- package/dist/_protocol/sql/sqlite/get.sql +2 -0
- package/dist/_protocol/sql/sqlite/get_by_key.sql +4 -0
- package/dist/_protocol/sql/sqlite/get_key.sql +3 -0
- package/dist/_protocol/sql/sqlite/heartbeat.sql +10 -0
- package/dist/_protocol/sql/sqlite/insert_task.sql +16 -0
- package/dist/_protocol/sql/sqlite/list.sql +11 -0
- package/dist/_protocol/sql/sqlite/progress.sql +10 -0
- package/dist/_protocol/sql/sqlite/recover_leases.sql +17 -0
- package/dist/_protocol/sql/sqlite/retry.sql +16 -0
- package/dist/_protocol/sql/sqlite/succeed.sql +15 -0
- package/dist/_protocol/sql/sqlite/upsert_key.sql +7 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.js +70 -0
- package/dist/context.d.ts +31 -0
- package/dist/context.js +78 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.js +100 -0
- package/dist/ids.d.ts +3 -0
- package/dist/ids.js +19 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/models.d.ts +36 -0
- package/dist/models.js +31 -0
- package/dist/sql.d.ts +6 -0
- package/dist/sql.js +44 -0
- package/dist/store/base.d.ts +77 -0
- package/dist/store/base.js +1 -0
- package/dist/store/postgres.d.ts +87 -0
- package/dist/store/postgres.js +349 -0
- package/dist/store/sqlite.d.ts +77 -0
- package/dist/store/sqlite.js +297 -0
- package/dist/task.d.ts +21 -0
- package/dist/task.js +7 -0
- package/dist/wait.d.ts +8 -0
- package/dist/wait.js +18 -0
- package/dist/worker.d.ts +60 -0
- package/dist/worker.js +252 -0
- package/package.json +57 -0
- package/src/client.ts +98 -0
- package/src/context.ts +85 -0
- package/src/errors.ts +112 -0
- package/src/ids.ts +23 -0
- package/src/index.ts +31 -0
- package/src/models.ts +63 -0
- package/src/sql.ts +49 -0
- package/src/store/base.ts +67 -0
- package/src/store/postgres.ts +409 -0
- package/src/store/sqlite.ts +351 -0
- package/src/task.ts +27 -0
- package/src/wait.ts +23 -0
- package/src/worker.ts +284 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jannchie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# cairnq (TypeScript / Node)
|
|
2
|
+
|
|
3
|
+
SQLite-first, cross-language, storage-centered durable task runtime. The
|
|
4
|
+
TypeScript SDK (Node ≥ 20, `better-sqlite3`). API and worker processes coordinate
|
|
5
|
+
only through a shared SQLite file.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { CairnQ, Worker } from "cairnq";
|
|
9
|
+
|
|
10
|
+
// Worker side — a handler always receives (ctx, payload).
|
|
11
|
+
const worker = Worker.sqlite("tasks.db", { queues: ["gpu"] });
|
|
12
|
+
worker.task("image.generate", async (ctx, payload) => {
|
|
13
|
+
await ctx.progress(0.1, "starting");
|
|
14
|
+
return { url: await generate(payload.prompt) };
|
|
15
|
+
});
|
|
16
|
+
await worker.serve(); // runs until SIGINT/SIGTERM, then closes the store
|
|
17
|
+
|
|
18
|
+
// API side
|
|
19
|
+
const tasks = CairnQ.sqlite("tasks.db");
|
|
20
|
+
const task = await tasks.submit("image.generate", { prompt }, {
|
|
21
|
+
key: `user:${userId}:image:${requestId}`,
|
|
22
|
+
queue: "gpu",
|
|
23
|
+
conflict: "reuse",
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Synchronous call (submit + wait):
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { TaskFailed, TaskTimeout } from "cairnq";
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const result = await tasks.call("summary.create", { text }, { waitTimeoutMs: 10_000 });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof TaskFailed) log(err.code, err.message, err.retryable); // envelope fields
|
|
36
|
+
else if (err instanceof TaskTimeout) {
|
|
37
|
+
/* err.taskId keeps running */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Inspect a task by id/key without matching status strings:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { isSucceeded } from "cairnq"; // also isFailed/isCanceled/isRunning/isQueued/isTerminal
|
|
46
|
+
|
|
47
|
+
const task = await tasks.getByKey(key);
|
|
48
|
+
if (task && isSucceeded(task)) use(task.result);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Optionally define a task once and share the symbol across both ends — no string
|
|
52
|
+
drift, the editor finds every caller, and payload + result are fully typed:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { defineTask } from "cairnq";
|
|
56
|
+
|
|
57
|
+
export const summarize = defineTask<{ text: string }, { summary: string }>("summarize");
|
|
58
|
+
|
|
59
|
+
worker.task(summarize, async (ctx, payload) => ({ summary: await run(payload.text) }));
|
|
60
|
+
const { summary } = await tasks.call(summarize, { text }); // typed result, no cast
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Opt-in: every API still accepts a plain name string (cross-language callers use it).
|
|
64
|
+
|
|
65
|
+
The protocol (schema + canonical SQL) lives in `../cairnq-protocol` and is shared
|
|
66
|
+
verbatim with the Python SDK. See `../cairnq-protocol/PROTOCOL.md`.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- CairnQ canonical schema (Postgres dialect) — protocol_version 1
|
|
2
|
+
-- Single source of truth for the task table on Postgres. Idempotent; safe to
|
|
3
|
+
-- re-run. Time is stored as bigint epoch milliseconds (`*_ms`), generated by the
|
|
4
|
+
-- DB clock inside each statement (NOT supplied by the SDK — see sql/postgres/*).
|
|
5
|
+
-- JSON columns are jsonb. Status is a CHECK constraint (not a PG enum) so the
|
|
6
|
+
-- conformance status-set check matches across dialects. Ordered migrations are
|
|
7
|
+
-- canonical; there is no separate schema.sql.
|
|
8
|
+
|
|
9
|
+
create table if not exists cairnq_tasks (
|
|
10
|
+
id text primary key,
|
|
11
|
+
|
|
12
|
+
name text not null,
|
|
13
|
+
queue text not null default 'default',
|
|
14
|
+
|
|
15
|
+
status text not null check (
|
|
16
|
+
status in ('queued', 'running', 'succeeded', 'failed', 'canceled')
|
|
17
|
+
),
|
|
18
|
+
|
|
19
|
+
payload jsonb not null,
|
|
20
|
+
result jsonb,
|
|
21
|
+
error jsonb,
|
|
22
|
+
metadata jsonb not null default '{}'::jsonb,
|
|
23
|
+
|
|
24
|
+
progress double precision,
|
|
25
|
+
message text,
|
|
26
|
+
|
|
27
|
+
attempt integer not null default 0,
|
|
28
|
+
max_attempts integer not null default 3,
|
|
29
|
+
|
|
30
|
+
priority integer not null default 0,
|
|
31
|
+
|
|
32
|
+
worker_id text,
|
|
33
|
+
lease_until_ms bigint,
|
|
34
|
+
|
|
35
|
+
run_at_ms bigint not null,
|
|
36
|
+
|
|
37
|
+
cancel_requested_at_ms bigint,
|
|
38
|
+
|
|
39
|
+
parent_id text,
|
|
40
|
+
root_id text,
|
|
41
|
+
correlation_id text,
|
|
42
|
+
|
|
43
|
+
created_at_ms bigint not null,
|
|
44
|
+
updated_at_ms bigint not null,
|
|
45
|
+
completed_at_ms bigint
|
|
46
|
+
);
|
|
47
|
+
|
|
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).
|
|
50
|
+
create index if not exists cairnq_tasks_claim_idx
|
|
51
|
+
on cairnq_tasks (queue, status, priority desc, created_at_ms);
|
|
52
|
+
create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
|
|
53
|
+
create index if not exists cairnq_tasks_name_idx on cairnq_tasks (name);
|
|
54
|
+
create index if not exists cairnq_tasks_root_idx on cairnq_tasks (root_id);
|
|
55
|
+
create index if not exists cairnq_tasks_correlation_idx on cairnq_tasks (correlation_id);
|
|
56
|
+
|
|
57
|
+
-- key = business-stable pointer to the *current* task for that key.
|
|
58
|
+
-- task_id = one concrete execution. Kept separate (not a unique constraint on
|
|
59
|
+
-- tasks) so reuse / reject / replace are natural.
|
|
60
|
+
create table if not exists cairnq_task_keys (
|
|
61
|
+
key text primary key,
|
|
62
|
+
task_id text not null references cairnq_tasks(id) on delete cascade,
|
|
63
|
+
created_at_ms bigint not null,
|
|
64
|
+
updated_at_ms bigint not null
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
create table if not exists cairnq_meta (
|
|
68
|
+
key text primary key,
|
|
69
|
+
value text not null
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
insert into cairnq_meta (key, value) values ('protocol_version', '1')
|
|
73
|
+
on conflict (key) do nothing;
|
|
74
|
+
insert into cairnq_meta (key, value) values ('schema_version', '1')
|
|
75
|
+
on conflict (key) do nothing;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
-- CairnQ canonical schema (SQLite dialect) — protocol_version 1
|
|
2
|
+
-- Single source of truth for the task table. Idempotent; safe to re-run.
|
|
3
|
+
-- Time is stored as integer epoch milliseconds (`*_ms`). JSON columns are
|
|
4
|
+
-- TEXT validated by json_valid(). There is no separate schema.sql: ordered
|
|
5
|
+
-- migrations are canonical.
|
|
6
|
+
|
|
7
|
+
create table if not exists cairnq_tasks (
|
|
8
|
+
id text primary key,
|
|
9
|
+
|
|
10
|
+
name text not null,
|
|
11
|
+
queue text not null default 'default',
|
|
12
|
+
|
|
13
|
+
status text not null check (
|
|
14
|
+
status in ('queued', 'running', 'succeeded', 'failed', 'canceled')
|
|
15
|
+
),
|
|
16
|
+
|
|
17
|
+
payload text not null check (json_valid(payload)),
|
|
18
|
+
result text check (result is null or json_valid(result)),
|
|
19
|
+
error text check (error is null or json_valid(error)),
|
|
20
|
+
metadata text not null default '{}' check (json_valid(metadata)),
|
|
21
|
+
|
|
22
|
+
progress real,
|
|
23
|
+
message text,
|
|
24
|
+
|
|
25
|
+
attempt integer not null default 0,
|
|
26
|
+
max_attempts integer not null default 3,
|
|
27
|
+
|
|
28
|
+
priority integer not null default 0,
|
|
29
|
+
|
|
30
|
+
worker_id text,
|
|
31
|
+
lease_until_ms integer,
|
|
32
|
+
|
|
33
|
+
run_at_ms integer not null,
|
|
34
|
+
|
|
35
|
+
cancel_requested_at_ms integer,
|
|
36
|
+
|
|
37
|
+
parent_id text,
|
|
38
|
+
root_id text,
|
|
39
|
+
correlation_id text,
|
|
40
|
+
|
|
41
|
+
created_at_ms integer not null,
|
|
42
|
+
updated_at_ms integer not null,
|
|
43
|
+
completed_at_ms integer
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- Serves the claim query: WHERE queue=? AND status='queued' ORDER BY priority
|
|
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.
|
|
49
|
+
create index if not exists cairnq_tasks_claim_idx
|
|
50
|
+
on cairnq_tasks (queue, status, priority desc, created_at_ms);
|
|
51
|
+
create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
|
|
52
|
+
create index if not exists cairnq_tasks_name_idx on cairnq_tasks (name);
|
|
53
|
+
create index if not exists cairnq_tasks_root_idx on cairnq_tasks (root_id);
|
|
54
|
+
create index if not exists cairnq_tasks_correlation_idx on cairnq_tasks (correlation_id);
|
|
55
|
+
|
|
56
|
+
-- key = business-stable pointer to the *current* task for that key.
|
|
57
|
+
-- task_id = one concrete execution. Kept separate (not a unique constraint on
|
|
58
|
+
-- tasks) so reuse / reject / replace are natural.
|
|
59
|
+
create table if not exists cairnq_task_keys (
|
|
60
|
+
key text primary key,
|
|
61
|
+
task_id text not null references cairnq_tasks(id) on delete cascade,
|
|
62
|
+
created_at_ms integer not null,
|
|
63
|
+
updated_at_ms integer not null
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
create table if not exists cairnq_meta (
|
|
67
|
+
key text primary key,
|
|
68
|
+
value text not null
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
insert into cairnq_meta (key, value) values ('protocol_version', '1')
|
|
72
|
+
on conflict(key) do nothing;
|
|
73
|
+
insert into cairnq_meta (key, value) values ('schema_version', '1')
|
|
74
|
+
on conflict(key) do nothing;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Cancel (Postgres dialect). queued -> canceled immediately; running -> set
|
|
2
|
+
-- cancel_requested_at_ms for cooperative exit (worker checks ctx.canceled()).
|
|
3
|
+
-- Single statement covers both. No-op (0 rows) for terminal tasks. Time from the
|
|
4
|
+
-- DB clock.
|
|
5
|
+
-- params: id
|
|
6
|
+
update cairnq_tasks
|
|
7
|
+
set
|
|
8
|
+
status = case when status = 'queued' then 'canceled' else status end,
|
|
9
|
+
cancel_requested_at_ms = case when status = 'running'
|
|
10
|
+
then (extract(epoch from now()) * 1000)::bigint
|
|
11
|
+
else cancel_requested_at_ms end,
|
|
12
|
+
completed_at_ms = case when status = 'queued'
|
|
13
|
+
then (extract(epoch from now()) * 1000)::bigint else completed_at_ms end,
|
|
14
|
+
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
15
|
+
where id = :id and status in ('queued', 'running')
|
|
16
|
+
returning *;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Atomic claim (Postgres dialect). Uses native FOR UPDATE SKIP LOCKED so
|
|
2
|
+
-- concurrent workers never contend on the same task — the native equivalent of
|
|
3
|
+
-- SQLite's single-writer BEGIN IMMEDIATE serialization. No claimable_probe is
|
|
4
|
+
-- needed (PG readers don't block writers). :queues is a text[]; time and the new
|
|
5
|
+
-- lease (now + :lease_ms) come from the DB clock.
|
|
6
|
+
-- recover_leases MUST run first in the SAME transaction. READ COMMITTED suffices:
|
|
7
|
+
-- each UPDATE re-checks its WHERE against the latest committed row, so racing
|
|
8
|
+
-- claims/recovers can neither double-dispatch a task nor double-recover a lease.
|
|
9
|
+
-- params: queues (text[]), worker_id, lease_ms, limit
|
|
10
|
+
update cairnq_tasks t
|
|
11
|
+
set
|
|
12
|
+
status = 'running',
|
|
13
|
+
worker_id = :worker_id,
|
|
14
|
+
lease_until_ms = (extract(epoch from now()) * 1000)::bigint + :lease_ms,
|
|
15
|
+
attempt = attempt + 1,
|
|
16
|
+
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
17
|
+
from (
|
|
18
|
+
select id from cairnq_tasks
|
|
19
|
+
where status = 'queued'
|
|
20
|
+
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
|
|
23
|
+
limit :limit
|
|
24
|
+
for update skip locked
|
|
25
|
+
) sel
|
|
26
|
+
where t.id = sel.id
|
|
27
|
+
returning t.*;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Finalize a task the worker finished running (Postgres dialect). Ownership-checked
|
|
2
|
+
-- like succeed. Atomic cancel-vs-success decision: if a cancel was requested while
|
|
3
|
+
-- the task ran, it finalizes as 'canceled' (the result is discarded — cancel wins);
|
|
4
|
+
-- otherwise 'succeeded' with the given result. Time comes from the DB clock.
|
|
5
|
+
-- params: id, worker_id, result (jsonb or null)
|
|
6
|
+
update cairnq_tasks
|
|
7
|
+
set
|
|
8
|
+
status = case when cancel_requested_at_ms is not null then 'canceled' else 'succeeded' end,
|
|
9
|
+
result = case when cancel_requested_at_ms is not null then result else :result::jsonb end,
|
|
10
|
+
progress = case when cancel_requested_at_ms is not null then progress else 1.0 end,
|
|
11
|
+
completed_at_ms = (extract(epoch from now()) * 1000)::bigint,
|
|
12
|
+
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
13
|
+
where id = :id
|
|
14
|
+
and status = 'running'
|
|
15
|
+
and worker_id = :worker_id
|
|
16
|
+
and lease_until_ms > (extract(epoch from now()) * 1000)::bigint
|
|
17
|
+
returning *;
|
|
@@ -0,0 +1,21 @@
|
|
|
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'.
|
|
4
|
+
-- :retryable is a native boolean. :error is bound as jsonb. Time from the DB clock.
|
|
5
|
+
-- params: id, worker_id, error (jsonb), retryable (boolean), delay_ms
|
|
6
|
+
update cairnq_tasks
|
|
7
|
+
set
|
|
8
|
+
status = case when :retryable and attempt < max_attempts then 'queued' else 'failed' end,
|
|
9
|
+
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
|
|
13
|
+
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
|
|
15
|
+
then null else (extract(epoch from now()) * 1000)::bigint end,
|
|
16
|
+
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
17
|
+
where id = :id
|
|
18
|
+
and status = 'running'
|
|
19
|
+
and worker_id = :worker_id
|
|
20
|
+
and lease_until_ms > (extract(epoch from now()) * 1000)::bigint
|
|
21
|
+
returning *;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Extend the lease (Postgres dialect). Ownership-checked: 0 rows -> caller raises
|
|
2
|
+
-- LostLease. Returns the row (incl. cancel_requested_at_ms) so ctx.canceled() can
|
|
3
|
+
-- ride on it. New lease (now + :lease_ms) and time come from the DB clock.
|
|
4
|
+
-- params: id, worker_id, lease_ms
|
|
5
|
+
update cairnq_tasks
|
|
6
|
+
set lease_until_ms = (extract(epoch from now()) * 1000)::bigint + :lease_ms,
|
|
7
|
+
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
8
|
+
where id = :id
|
|
9
|
+
and status = 'running'
|
|
10
|
+
and worker_id = :worker_id
|
|
11
|
+
and lease_until_ms > (extract(epoch from now()) * 1000)::bigint
|
|
12
|
+
returning *;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Insert a brand-new task (Postgres dialect). The SDK generates :id (ULID) and
|
|
2
|
+
-- :root_id (= :id for top-level tasks). Time comes from the DB clock, so the SDK
|
|
3
|
+
-- passes a relative :delay_ms (not an absolute run_at_ms): run_at = now + delay.
|
|
4
|
+
-- :payload / :metadata are bound as jsonb.
|
|
5
|
+
-- params: id, name, queue, payload, metadata, max_attempts, priority,
|
|
6
|
+
-- delay_ms, parent_id, root_id, correlation_id
|
|
7
|
+
insert into cairnq_tasks (
|
|
8
|
+
id, name, queue, status, payload, metadata,
|
|
9
|
+
max_attempts, priority, run_at_ms,
|
|
10
|
+
parent_id, root_id, correlation_id,
|
|
11
|
+
created_at_ms, updated_at_ms
|
|
12
|
+
) values (
|
|
13
|
+
:id, :name, :queue, 'queued', :payload::jsonb, :metadata::jsonb,
|
|
14
|
+
:max_attempts, :priority,
|
|
15
|
+
(extract(epoch from now()) * 1000)::bigint + :delay_ms,
|
|
16
|
+
:parent_id, :root_id, :correlation_id,
|
|
17
|
+
(extract(epoch from now()) * 1000)::bigint,
|
|
18
|
+
(extract(epoch from now()) * 1000)::bigint
|
|
19
|
+
)
|
|
20
|
+
returning *;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- List tasks with optional filters (Postgres dialect). Every filter param must be
|
|
2
|
+
-- bound; pass NULL to skip it. The `::text` cast on each filter pins the param's
|
|
3
|
+
-- type so PG can plan the `IS NULL` branch (an untyped param there is ambiguous).
|
|
4
|
+
-- Supports chain queries via root_id / correlation_id.
|
|
5
|
+
-- params: status, queue, name, root_id, correlation_id, limit, offset
|
|
6
|
+
select * from cairnq_tasks
|
|
7
|
+
where (:status::text is null or status = :status)
|
|
8
|
+
and (:queue::text is null or queue = :queue)
|
|
9
|
+
and (:name::text is null or name = :name)
|
|
10
|
+
and (:root_id::text is null or root_id = :root_id)
|
|
11
|
+
and (:correlation_id::text is null or correlation_id = :correlation_id)
|
|
12
|
+
order by created_at_ms desc
|
|
13
|
+
limit :limit offset :offset;
|
|
@@ -0,0 +1,13 @@
|
|
|
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.
|
|
4
|
+
-- params: id, worker_id, progress, message
|
|
5
|
+
update cairnq_tasks
|
|
6
|
+
set progress = :progress,
|
|
7
|
+
message = coalesce(:message, message),
|
|
8
|
+
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
9
|
+
where id = :id
|
|
10
|
+
and status = 'running'
|
|
11
|
+
and worker_id = :worker_id
|
|
12
|
+
and lease_until_ms > (extract(epoch from now()) * 1000)::bigint
|
|
13
|
+
returning *;
|
|
@@ -0,0 +1,20 @@
|
|
|
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.
|
|
5
|
+
-- params: lease_expired_error (jsonb envelope)
|
|
6
|
+
update cairnq_tasks
|
|
7
|
+
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,
|
|
10
|
+
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
|
|
20
|
+
returning *;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Manually re-enqueue a failed/canceled task (Postgres dialect). :reset_attempt
|
|
2
|
+
-- (native boolean) controls whether the attempt counter resets to 0. Time from
|
|
3
|
+
-- the DB clock.
|
|
4
|
+
-- params: id, reset_attempt (boolean)
|
|
5
|
+
update cairnq_tasks
|
|
6
|
+
set
|
|
7
|
+
status = 'queued',
|
|
8
|
+
error = null,
|
|
9
|
+
worker_id = null,
|
|
10
|
+
lease_until_ms = null,
|
|
11
|
+
run_at_ms = (extract(epoch from now()) * 1000)::bigint,
|
|
12
|
+
cancel_requested_at_ms = null,
|
|
13
|
+
completed_at_ms = null,
|
|
14
|
+
attempt = case when :reset_attempt then 0 else attempt end,
|
|
15
|
+
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
16
|
+
where id = :id and status in ('failed', 'canceled')
|
|
17
|
+
returning *;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Mark succeeded (Postgres dialect). Ownership-checked. worker_id kept for audit.
|
|
2
|
+
-- :result is bound as jsonb. Time comes from the DB clock.
|
|
3
|
+
-- params: id, worker_id, result (jsonb), message
|
|
4
|
+
update cairnq_tasks
|
|
5
|
+
set
|
|
6
|
+
status = 'succeeded',
|
|
7
|
+
result = :result::jsonb,
|
|
8
|
+
progress = 1.0,
|
|
9
|
+
message = coalesce(:message, message),
|
|
10
|
+
completed_at_ms = (extract(epoch from now()) * 1000)::bigint,
|
|
11
|
+
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
12
|
+
where id = :id
|
|
13
|
+
and status = 'running'
|
|
14
|
+
and worker_id = :worker_id
|
|
15
|
+
and lease_until_ms > (extract(epoch from now()) * 1000)::bigint
|
|
16
|
+
returning *;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Point a key at a task (initial pointer, or repoint on replace). Postgres dialect.
|
|
2
|
+
-- Time comes from the DB clock.
|
|
3
|
+
-- params: key, task_id
|
|
4
|
+
insert into cairnq_task_keys (key, task_id, created_at_ms, updated_at_ms)
|
|
5
|
+
values (
|
|
6
|
+
:key, :task_id,
|
|
7
|
+
(extract(epoch from now()) * 1000)::bigint,
|
|
8
|
+
(extract(epoch from now()) * 1000)::bigint
|
|
9
|
+
)
|
|
10
|
+
on conflict (key) do update set
|
|
11
|
+
task_id = excluded.task_id,
|
|
12
|
+
updated_at_ms = excluded.updated_at_ms;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Cancel. queued -> canceled immediately; running -> set cancel_requested_at_ms
|
|
2
|
+
-- for cooperative exit (worker checks ctx.canceled()). Single statement covers
|
|
3
|
+
-- both. No-op (0 rows) for terminal tasks.
|
|
4
|
+
-- params: id, now_ms
|
|
5
|
+
update cairnq_tasks
|
|
6
|
+
set
|
|
7
|
+
status = case when status = 'queued' then 'canceled' else status end,
|
|
8
|
+
cancel_requested_at_ms = case when status = 'running' then :now_ms else cancel_requested_at_ms end,
|
|
9
|
+
completed_at_ms = case when status = 'queued' then :now_ms else completed_at_ms end,
|
|
10
|
+
updated_at_ms = :now_ms
|
|
11
|
+
where id = :id and status in ('queued', 'running')
|
|
12
|
+
returning *;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Atomic claim. :queues is a JSON array of queue names. :lease_until_ms is
|
|
2
|
+
-- precomputed by the SDK (= now_ms + lease_ms). Single UPDATE ... RETURNING
|
|
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
|
|
5
|
+
update cairnq_tasks
|
|
6
|
+
set
|
|
7
|
+
status = 'running',
|
|
8
|
+
worker_id = :worker_id,
|
|
9
|
+
lease_until_ms = :lease_until_ms,
|
|
10
|
+
attempt = attempt + 1,
|
|
11
|
+
updated_at_ms = :now_ms
|
|
12
|
+
where id in (
|
|
13
|
+
select id from cairnq_tasks
|
|
14
|
+
where status = 'queued'
|
|
15
|
+
and queue in (select value from json_each(:queues))
|
|
16
|
+
and run_at_ms <= :now_ms
|
|
17
|
+
order by priority desc, created_at_ms asc
|
|
18
|
+
limit :limit
|
|
19
|
+
)
|
|
20
|
+
returning *;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- Read-only check: is there anything worth opening a write transaction for?
|
|
2
|
+
-- Run before claim so idle workers don't take a write lock every poll (which
|
|
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
|
|
6
|
+
select exists(
|
|
7
|
+
select 1 from cairnq_tasks
|
|
8
|
+
where (status = 'queued'
|
|
9
|
+
and queue in (select value from json_each(:queues))
|
|
10
|
+
and run_at_ms <= :now_ms)
|
|
11
|
+
or (status = 'running'
|
|
12
|
+
and lease_until_ms is not null
|
|
13
|
+
and lease_until_ms <= :now_ms)
|
|
14
|
+
) as has_work;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Finalize a task the worker finished running. Ownership-checked like succeed.
|
|
2
|
+
-- Atomic cancel-vs-success decision: if a cancel was requested while the task
|
|
3
|
+
-- ran, it finalizes as 'canceled' (the result is discarded — cancel wins);
|
|
4
|
+
-- otherwise 'succeeded' with the given result. This is how a running task
|
|
5
|
+
-- reaches the 'canceled' terminal state (cooperative cancel, §7).
|
|
6
|
+
-- params: id, worker_id, now_ms, result (JSON text or null)
|
|
7
|
+
update cairnq_tasks
|
|
8
|
+
set
|
|
9
|
+
status = case when cancel_requested_at_ms is not null then 'canceled' else 'succeeded' end,
|
|
10
|
+
result = case when cancel_requested_at_ms is not null then result else :result end,
|
|
11
|
+
progress = case when cancel_requested_at_ms is not null then progress else 1.0 end,
|
|
12
|
+
completed_at_ms = :now_ms,
|
|
13
|
+
updated_at_ms = :now_ms
|
|
14
|
+
where id = :id
|
|
15
|
+
and status = 'running'
|
|
16
|
+
and worker_id = :worker_id
|
|
17
|
+
and lease_until_ms > :now_ms
|
|
18
|
+
returning *;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Fail a task. Ownership-checked. Single CASE-based statement handles both
|
|
2
|
+
-- branches atomically: retryable && attempt < max_attempts -> requeue with
|
|
3
|
+
-- backoff (run_at = now + delay_ms); otherwise -> terminal 'failed'.
|
|
4
|
+
-- :retryable is 0/1. :error is a JSON envelope text.
|
|
5
|
+
-- params: id, worker_id, now_ms, error, retryable, delay_ms
|
|
6
|
+
update cairnq_tasks
|
|
7
|
+
set
|
|
8
|
+
status = case when :retryable = 1 and attempt < max_attempts then 'queued' else 'failed' end,
|
|
9
|
+
error = :error,
|
|
10
|
+
worker_id = case when :retryable = 1 and attempt < max_attempts then null else worker_id end,
|
|
11
|
+
lease_until_ms = case when :retryable = 1 and attempt < max_attempts then null else lease_until_ms end,
|
|
12
|
+
run_at_ms = case when :retryable = 1 and attempt < max_attempts then :now_ms + :delay_ms else run_at_ms end,
|
|
13
|
+
completed_at_ms = case when :retryable = 1 and attempt < max_attempts then null else :now_ms end,
|
|
14
|
+
updated_at_ms = :now_ms
|
|
15
|
+
where id = :id
|
|
16
|
+
and status = 'running'
|
|
17
|
+
and worker_id = :worker_id
|
|
18
|
+
and lease_until_ms > :now_ms
|
|
19
|
+
returning *;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Extend the lease. Ownership-checked: 0 rows -> caller raises LostLease.
|
|
2
|
+
-- Returns the row (incl. cancel_requested_at_ms) so ctx.canceled() can ride on it.
|
|
3
|
+
-- params: id, worker_id, now_ms, lease_until_ms
|
|
4
|
+
update cairnq_tasks
|
|
5
|
+
set lease_until_ms = :lease_until_ms, updated_at_ms = :now_ms
|
|
6
|
+
where id = :id
|
|
7
|
+
and status = 'running'
|
|
8
|
+
and worker_id = :worker_id
|
|
9
|
+
and lease_until_ms > :now_ms
|
|
10
|
+
returning *;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Insert a brand-new task. Caller (SDK) generates :id (ULID) and :root_id
|
|
2
|
+
-- (= :id for top-level tasks) and provides :now_ms.
|
|
3
|
+
-- params: id, name, queue, payload, metadata, max_attempts, priority,
|
|
4
|
+
-- run_at_ms, parent_id, root_id, correlation_id, now_ms
|
|
5
|
+
insert into cairnq_tasks (
|
|
6
|
+
id, name, queue, status, payload, metadata,
|
|
7
|
+
max_attempts, priority, run_at_ms,
|
|
8
|
+
parent_id, root_id, correlation_id,
|
|
9
|
+
created_at_ms, updated_at_ms
|
|
10
|
+
) values (
|
|
11
|
+
:id, :name, :queue, 'queued', :payload, :metadata,
|
|
12
|
+
:max_attempts, :priority, :run_at_ms,
|
|
13
|
+
:parent_id, :root_id, :correlation_id,
|
|
14
|
+
:now_ms, :now_ms
|
|
15
|
+
)
|
|
16
|
+
returning *;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- List tasks with optional filters. Every filter param must be bound; pass
|
|
2
|
+
-- NULL to skip it. Supports chain queries via root_id / correlation_id (§22).
|
|
3
|
+
-- params: status, queue, name, root_id, correlation_id, limit, offset
|
|
4
|
+
select * from cairnq_tasks
|
|
5
|
+
where (:status is null or status = :status)
|
|
6
|
+
and (:queue is null or queue = :queue)
|
|
7
|
+
and (:name is null or name = :name)
|
|
8
|
+
and (:root_id is null or root_id = :root_id)
|
|
9
|
+
and (:correlation_id is null or correlation_id = :correlation_id)
|
|
10
|
+
order by created_at_ms desc
|
|
11
|
+
limit :limit offset :offset;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Update progress/message. Ownership-checked. Does not change status.
|
|
2
|
+
-- params: id, worker_id, now_ms, progress, message
|
|
3
|
+
-- message is coalesced so progress(value) without a message keeps the prior one.
|
|
4
|
+
update cairnq_tasks
|
|
5
|
+
set progress = :progress, message = coalesce(:message, message), updated_at_ms = :now_ms
|
|
6
|
+
where id = :id
|
|
7
|
+
and status = 'running'
|
|
8
|
+
and worker_id = :worker_id
|
|
9
|
+
and lease_until_ms > :now_ms
|
|
10
|
+
returning *;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Reclaim tasks whose lease expired. Run inside the same write transaction as
|
|
2
|
+
-- claim, just before it. attempt < max_attempts -> back to 'queued' for redelivery;
|
|
3
|
+
-- otherwise -> 'failed' with a lease-expired error envelope.
|
|
4
|
+
-- params: now_ms, lease_expired_error (JSON envelope text)
|
|
5
|
+
update cairnq_tasks
|
|
6
|
+
set
|
|
7
|
+
status = case when attempt < max_attempts then 'queued' else 'failed' end,
|
|
8
|
+
worker_id = case when attempt < max_attempts then null else worker_id end,
|
|
9
|
+
lease_until_ms = null,
|
|
10
|
+
run_at_ms = case when attempt < max_attempts then :now_ms else run_at_ms end,
|
|
11
|
+
error = case when attempt >= max_attempts then :lease_expired_error else error end,
|
|
12
|
+
completed_at_ms = case when attempt >= max_attempts then :now_ms else completed_at_ms end,
|
|
13
|
+
updated_at_ms = :now_ms
|
|
14
|
+
where status = 'running'
|
|
15
|
+
and lease_until_ms is not null
|
|
16
|
+
and lease_until_ms <= :now_ms
|
|
17
|
+
returning *;
|