cairnq 0.7.0 → 0.9.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 +69 -4
- package/dist/_protocol/migrations/postgres/0007_purge_status_index.sql +11 -0
- package/dist/_protocol/migrations/sqlite/0007_purge_status_index.sql +11 -0
- package/dist/_protocol/sql/postgres/get_status.sql +7 -0
- package/dist/_protocol/sql/postgres/get_status_by_key.sql +7 -0
- package/dist/_protocol/sql/postgres/purge.sql +8 -1
- package/dist/_protocol/sql/sqlite/get_status.sql +6 -0
- package/dist/_protocol/sql/sqlite/get_status_by_key.sql +7 -0
- package/dist/_protocol/sql/sqlite/purge.sql +7 -1
- package/dist/client.d.ts +32 -15
- package/dist/client.js +38 -12
- package/dist/context.d.ts +25 -0
- package/dist/context.js +33 -0
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +7 -4
- package/dist/index.d.ts +9 -5
- package/dist/index.js +4 -1
- package/dist/models.d.ts +14 -2
- package/dist/models.js +12 -1
- package/dist/retention.d.ts +15 -3
- package/dist/retention.js +36 -14
- package/dist/store/base.d.ts +118 -1
- package/dist/store/base.js +122 -20
- package/dist/store/pg-executor.d.ts +77 -0
- package/dist/store/pg-executor.js +26 -0
- package/dist/store/pg-pool.d.ts +16 -0
- package/dist/store/pg-pool.js +147 -0
- package/dist/store/postgres.d.ts +41 -13
- package/dist/store/postgres.js +135 -147
- package/dist/store/sqlite.js +20 -2
- package/dist/wait.d.ts +9 -4
- package/dist/wait.js +27 -13
- package/dist/worker.d.ts +6 -3
- package/dist/worker.js +6 -5
- package/package.json +6 -4
- package/src/client.ts +60 -21
- package/src/context.ts +37 -0
- package/src/errors.ts +7 -4
- package/src/index.ts +16 -4
- package/src/models.ts +24 -3
- package/src/retention.ts +45 -15
- package/src/store/base.ts +204 -9
- package/src/store/pg-executor.ts +90 -0
- package/src/store/pg-pool.ts +156 -0
- package/src/store/postgres.ts +144 -141
- package/src/store/sqlite.ts +23 -2
- package/src/wait.ts +35 -14
- package/src/worker.ts +8 -6
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# cairnq (TypeScript / Node)
|
|
2
2
|
|
|
3
3
|
SQLite-first, cross-language, storage-centered durable task runtime. The
|
|
4
|
-
TypeScript SDK (Node ≥ 20
|
|
5
|
-
|
|
4
|
+
TypeScript SDK (Node ≥ 20). API and worker processes coordinate only through a
|
|
5
|
+
shared SQLite file.
|
|
6
|
+
|
|
7
|
+
Both drivers are optional peers — install the one you use:
|
|
8
|
+
`npm i better-sqlite3` for the SQLite backend, `npm i pg` for Postgres. Importing
|
|
9
|
+
`cairnq` loads neither, so a Postgres-only deployment builds no native module.
|
|
6
10
|
|
|
7
11
|
```ts
|
|
8
12
|
import { CairnQ, Worker } from "cairnq";
|
|
@@ -75,9 +79,11 @@ const worker = Worker.sqlite("tasks.db", {
|
|
|
75
79
|
});
|
|
76
80
|
|
|
77
81
|
// Nothing else deletes rows, so give the client a retention policy — it sweeps
|
|
78
|
-
// terminal tasks in bounded batches for as long as the handle is open.
|
|
82
|
+
// terminal tasks in bounded batches for as long as the handle is open. A
|
|
83
|
+
// per-status map keeps each status on its own clock (statuses left out are
|
|
84
|
+
// never swept): spent results go in minutes, failures stay for diagnosis.
|
|
79
85
|
const tasks = CairnQ.sqlite("tasks.db", {
|
|
80
|
-
retention: { olderThanMs: 7 * 24 * 3600_000 },
|
|
86
|
+
retention: { olderThanMs: { succeeded: 300_000, failed: 7 * 24 * 3600_000 } },
|
|
81
87
|
});
|
|
82
88
|
```
|
|
83
89
|
|
|
@@ -96,5 +102,64 @@ worker.task("long.job", async (ctx) => {
|
|
|
96
102
|
Same code, Postgres instead of the file — `CairnQ.postgres(dsn)` /
|
|
97
103
|
`Worker.postgres(dsn)`. Requires the optional `pg` peer dependency (`npm i pg`).
|
|
98
104
|
|
|
105
|
+
### Sharing the application's connection
|
|
106
|
+
|
|
107
|
+
Given a `PgExecutor` instead of a DSN, cairnq runs inside a session the
|
|
108
|
+
application already has — no second driver, no second pool:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { CairnQ, type PgExecutor } from "cairnq";
|
|
112
|
+
|
|
113
|
+
const executor: PgExecutor = { /* ~30 lines over your driver */ };
|
|
114
|
+
const tasks = CairnQ.postgres(executor);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`schema` (DSN form only) puts cairnq's tables in a schema of their own:
|
|
118
|
+
`CairnQ.postgres(dsn, { schema: "cairnq" })` creates it if absent and sets
|
|
119
|
+
`search_path` per connection. The protocol's SQL names no schema, so nothing else
|
|
120
|
+
changes. With your own executor, the search_path is yours to set.
|
|
121
|
+
|
|
122
|
+
Two things an adapter must get right: `int8` has to come back as a JS number
|
|
123
|
+
(every cairnq `*_ms` is an epoch or a counter, all inside the safe range), and
|
|
124
|
+
`jsonb` as an object rather than a string. An executor cairnq was handed is
|
|
125
|
+
never closed by cairnq.
|
|
126
|
+
|
|
127
|
+
That shared session is also what lets a task's settlement commit together with
|
|
128
|
+
the rows the task produced:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
worker.task("render.document", async (ctx, payload) => {
|
|
132
|
+
const rendered = await render(payload);
|
|
133
|
+
return ctx.succeedIn(async (session) => {
|
|
134
|
+
await db.withSession(session).insert(pages).values(rendered);
|
|
135
|
+
return { pages: rendered.length }; // becomes the task's result
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Without it the two are separate transactions, and a crash between them leaves
|
|
141
|
+
work durable while the task still reads as running — on retry, recomputed. If
|
|
142
|
+
the lease turns out to be gone, the settlement matches no row and the caller's
|
|
143
|
+
writes roll back with it.
|
|
144
|
+
|
|
145
|
+
## Watching
|
|
146
|
+
|
|
147
|
+
`watch` calls back when the tasks on a queue may have changed — for a dashboard
|
|
148
|
+
that would otherwise poll:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
const stop = tasks.watch({ queues: ["render"] }, async (signal) => {
|
|
152
|
+
if (signal.reason === "done") return refreshOne(signal.taskId!);
|
|
153
|
+
setCounts(await tasks.stats());
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
It is notify-accelerated polling, not an event log. On Postgres an idle watch
|
|
158
|
+
costs nothing and a signal lands within milliseconds; where LISTEN is
|
|
159
|
+
unavailable — a transaction-mode pooler, or SQLite, which has no channel — the
|
|
160
|
+
timer alone still delivers `poll` signals. So the same consumer is correct either
|
|
161
|
+
way, and only its promptness differs. Treat a signal as "re-read now"; the truth
|
|
162
|
+
is in `stats()` / `list()` / `get()`.
|
|
163
|
+
|
|
99
164
|
The protocol (schema + canonical SQL) lives in `../cairnq-protocol` and is shared
|
|
100
165
|
verbatim with the Python SDK. See `../cairnq-protocol/PROTOCOL.md`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Serves purge.sql's optional status filter: without it a filtered sweep walks
|
|
2
|
+
-- cairnq_tasks_completed_idx and visits the table row of every terminal task
|
|
3
|
+
-- older than the cutoff just to discard the wrong statuses — worst exactly in
|
|
4
|
+
-- the tiered configuration the filter exists for (a minutes-scale succeeded
|
|
5
|
+
-- cutoff scanning a day's worth of retained failed rows, every sweep). With
|
|
6
|
+
-- (status, completed_at_ms) each filtered sweep is a bounded range seek already
|
|
7
|
+
-- in completion order. Unfiltered purge keeps using cairnq_tasks_completed_idx.
|
|
8
|
+
create index if not exists cairnq_tasks_status_completed_idx
|
|
9
|
+
on cairnq_tasks (status, completed_at_ms);
|
|
10
|
+
|
|
11
|
+
update cairnq_meta set value = '7' where key = 'schema_version';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Serves purge.sql's optional status filter: without it a filtered sweep walks
|
|
2
|
+
-- cairnq_tasks_completed_idx and visits the table row of every terminal task
|
|
3
|
+
-- older than the cutoff just to discard the wrong statuses — worst exactly in
|
|
4
|
+
-- the tiered configuration the filter exists for (a minutes-scale succeeded
|
|
5
|
+
-- cutoff scanning a day's worth of retained failed rows, every sweep). With
|
|
6
|
+
-- (status, completed_at_ms) each filtered sweep is a bounded range seek already
|
|
7
|
+
-- in completion order. Unfiltered purge keeps using cairnq_tasks_completed_idx.
|
|
8
|
+
create index if not exists cairnq_tasks_status_completed_idx
|
|
9
|
+
on cairnq_tasks (status, completed_at_ms);
|
|
10
|
+
|
|
11
|
+
update cairnq_meta set value = '7' where key = 'schema_version';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- The status-only probe behind wait/call polling (Postgres dialect). A pending
|
|
2
|
+
-- task's whole row is dead weight to a loop that only asks "is it finished yet"
|
|
3
|
+
-- — with a large payload it re-reads and re-parses megabytes per second of
|
|
4
|
+
-- waiting. The full row is fetched once, via get.sql, when this reports a
|
|
5
|
+
-- terminal status.
|
|
6
|
+
-- params: id
|
|
7
|
+
select id, status from cairnq_tasks where id = :id;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- get_status.sql, following a key instead of an id (Postgres dialect) — the
|
|
2
|
+
-- probe behind wait_by_key. Resolves the key on every read, so a `replace`
|
|
3
|
+
-- landing mid-wait moves the wait onto the new task.
|
|
4
|
+
-- params: key
|
|
5
|
+
select t.id, t.status from cairnq_tasks t
|
|
6
|
+
join cairnq_task_keys k on k.task_id = t.id
|
|
7
|
+
where k.key = :key;
|
|
@@ -11,11 +11,18 @@
|
|
|
11
11
|
-- live task. Locking the rows in the subselect freezes them terminal until the
|
|
12
12
|
-- delete commits; a concurrent retry then re-evaluates against the deleted row
|
|
13
13
|
-- and correctly finds nothing.
|
|
14
|
-
--
|
|
14
|
+
-- The status/name filters are optional (pass NULL to skip; `::text` pins the
|
|
15
|
+
-- param's type, as in list.sql): retention needs are tiered — a succeeded row
|
|
16
|
+
-- is spent once its result is consumed, while a failed one is worth keeping
|
|
17
|
+
-- for diagnosis — and without them the shortest-lived tier sets the retention
|
|
18
|
+
-- for every row.
|
|
19
|
+
-- params: older_than_ms, status, name, limit
|
|
15
20
|
delete from cairnq_tasks
|
|
16
21
|
where id in (
|
|
17
22
|
select id from cairnq_tasks
|
|
18
23
|
where status in ('succeeded', 'failed', 'canceled')
|
|
24
|
+
and (:status::text is null or status = :status)
|
|
25
|
+
and (:name::text is null or name = :name)
|
|
19
26
|
and completed_at_ms is not null
|
|
20
27
|
and completed_at_ms < (extract(epoch from now()) * 1000)::bigint - :older_than_ms
|
|
21
28
|
order by completed_at_ms asc
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- The status-only probe behind wait/call polling. A pending task's whole row is
|
|
2
|
+
-- dead weight to a loop that only asks "is it finished yet" — with a large
|
|
3
|
+
-- payload it re-reads and re-parses megabytes per second of waiting. The full
|
|
4
|
+
-- row is fetched once, via get.sql, when this reports a terminal status.
|
|
5
|
+
-- params: id
|
|
6
|
+
select id, status from cairnq_tasks where id = :id;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- get_status.sql, following a key instead of an id — the probe behind
|
|
2
|
+
-- wait_by_key. Resolves the key on every read, so a `replace` landing mid-wait
|
|
3
|
+
-- moves the wait onto the new task.
|
|
4
|
+
-- params: key
|
|
5
|
+
select t.id, t.status from cairnq_tasks t
|
|
6
|
+
join cairnq_task_keys k on k.task_id = t.id
|
|
7
|
+
where k.key = :key;
|
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
-- task goes with it via cairnq_task_keys' ON DELETE CASCADE.
|
|
6
6
|
-- The LIMIT lives in a subquery: plain `delete ... limit` needs a non-default
|
|
7
7
|
-- SQLite build option.
|
|
8
|
-
--
|
|
8
|
+
-- The status/name filters are optional (pass NULL to skip, as in list.sql):
|
|
9
|
+
-- retention needs are tiered — a succeeded row is spent once its result is
|
|
10
|
+
-- consumed, while a failed one is worth keeping for diagnosis — and without
|
|
11
|
+
-- them the shortest-lived tier sets the retention for every row.
|
|
12
|
+
-- params: before_ms, status, name, limit
|
|
9
13
|
delete from cairnq_tasks
|
|
10
14
|
where id in (
|
|
11
15
|
select id from cairnq_tasks
|
|
12
16
|
where status in ('succeeded', 'failed', 'canceled')
|
|
17
|
+
and (:status is null or status = :status)
|
|
18
|
+
and (:name is null or name = :name)
|
|
13
19
|
and completed_at_ms is not null
|
|
14
20
|
and completed_at_ms < :before_ms
|
|
15
21
|
order by completed_at_ms asc
|
package/dist/client.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { BackpressureOptions } from "./backpressure.js";
|
|
2
2
|
import { type RetentionOptions } from "./retention.js";
|
|
3
|
-
import { type Task, type TaskStatus } from "./models.js";
|
|
4
|
-
import type {
|
|
3
|
+
import { type Task, type TaskRef, type TaskStatus } from "./models.js";
|
|
4
|
+
import type { PgExecutor } from "./store/pg-executor.js";
|
|
5
|
+
import type { ListInput, PurgeInput, SubmitInput, TaskStore, WatchOptions, WatchSignal } from "./store/base.js";
|
|
5
6
|
import { type TaskDef } from "./task.js";
|
|
7
|
+
import { type PollOptions } from "./wait.js";
|
|
6
8
|
export type SubmitOptions = Omit<SubmitInput, "name" | "payload">;
|
|
7
|
-
|
|
9
|
+
/** The wait loop's knobs with the timeout optional (default 30s) — the public
|
|
10
|
+
* face of PollOptions, whose comments document each knob. */
|
|
11
|
+
export type WaitOptions = Partial<PollOptions>;
|
|
12
|
+
export interface CallOptions extends SubmitOptions, Omit<WaitOptions, "timeoutMs"> {
|
|
8
13
|
waitTimeoutMs?: number;
|
|
9
|
-
pollMs?: number;
|
|
10
14
|
}
|
|
11
15
|
/** Options this handle configures on the store it wraps, rather than the
|
|
12
16
|
* store's own constructor arguments. */
|
|
@@ -25,10 +29,13 @@ export declare class CairnQ {
|
|
|
25
29
|
static sqlite(path: string, opts?: {
|
|
26
30
|
busyTimeoutMs?: number;
|
|
27
31
|
} & ClientOptions): CairnQ;
|
|
28
|
-
/** Multi-host backend. `
|
|
29
|
-
* optional `pg` package
|
|
30
|
-
|
|
32
|
+
/** Multi-host backend. `source` is a libpq connection string — which requires
|
|
33
|
+
* the optional `pg` package — or a PgExecutor over a driver the application
|
|
34
|
+
* already runs (an ORM's pool, say), which cairnq then shares instead of
|
|
35
|
+
* opening a second one. */
|
|
36
|
+
static postgres(source: string | PgExecutor, opts?: {
|
|
31
37
|
max?: number;
|
|
38
|
+
schema?: string;
|
|
32
39
|
} & ClientOptions): CairnQ;
|
|
33
40
|
get store(): TaskStore;
|
|
34
41
|
connect(): Promise<void>;
|
|
@@ -48,6 +55,11 @@ export declare class CairnQ {
|
|
|
48
55
|
queueDepth(queue: string, maxDepth: number): Promise<number>;
|
|
49
56
|
get(taskId: string): Promise<Task | null>;
|
|
50
57
|
getByKey(key: string): Promise<Task | null>;
|
|
58
|
+
/** The status-only probe wait polls on: id + status, no payload. Public for
|
|
59
|
+
* the same reason it exists — a dashboard or poller that only asks "is it
|
|
60
|
+
* finished yet" should not drag the payload back per ask. */
|
|
61
|
+
getStatus(taskId: string): Promise<TaskRef | null>;
|
|
62
|
+
getStatusByKey(key: string): Promise<TaskRef | null>;
|
|
51
63
|
list(input?: ListInput): Promise<Task[]>;
|
|
52
64
|
cancel(taskId: string): Promise<Task | null>;
|
|
53
65
|
cancelByKey(key: string): Promise<Task | null>;
|
|
@@ -65,22 +77,27 @@ export declare class CairnQ {
|
|
|
65
77
|
/** Task counts per queue, keyed by status and zero-filled across all statuses
|
|
66
78
|
* — `(await stats()).default.queued` is the backlog of a queue. */
|
|
67
79
|
stats(): Promise<Record<string, Record<TaskStatus, number>>>;
|
|
80
|
+
/**
|
|
81
|
+
* Call `onSignal` when the tasks on `queues` may have changed. Returns an
|
|
82
|
+
* unsubscribe.
|
|
83
|
+
*
|
|
84
|
+
* Notify-accelerated polling, not an event log: a signal means "re-read now",
|
|
85
|
+
* and `stats()` / `list()` / `get()` are where the truth is. On Postgres an
|
|
86
|
+
* idle watch costs nothing and signals land in milliseconds; everywhere else
|
|
87
|
+
* the timer alone still delivers, so the same consumer code is correct either
|
|
88
|
+
* way. See TaskStore.watch for the full contract.
|
|
89
|
+
*/
|
|
90
|
+
watch(opts: WatchOptions, onSignal: (signal: WatchSignal) => void): () => void;
|
|
68
91
|
/** Wait for a task to finish. Resolves with the terminal Task (any status);
|
|
69
92
|
* throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
|
|
70
93
|
* same wait back up — from another process, or after a longer deadline. */
|
|
71
|
-
wait(taskId: string, opts?:
|
|
72
|
-
timeoutMs?: number;
|
|
73
|
-
pollMs?: number;
|
|
74
|
-
}): Promise<Task>;
|
|
94
|
+
wait(taskId: string, opts?: WaitOptions): Promise<Task>;
|
|
75
95
|
/** Wait for whatever task the `key` currently points at — the cross-process
|
|
76
96
|
* form of picking a wait back up, when the id was never in hand or the process
|
|
77
97
|
* that held it is gone. Re-resolves the key on each poll, so a `replace`
|
|
78
98
|
* landing mid-wait moves the wait onto the new task, and a key with no task
|
|
79
99
|
* yet is waited for rather than rejected. */
|
|
80
|
-
waitByKey(key: string, opts?:
|
|
81
|
-
timeoutMs?: number;
|
|
82
|
-
pollMs?: number;
|
|
83
|
-
}): Promise<Task>;
|
|
100
|
+
waitByKey(key: string, opts?: WaitOptions): Promise<Task>;
|
|
84
101
|
/** submit + wait. Resolves with the result on success; rejects with
|
|
85
102
|
* TaskFailed / TaskCanceled / TaskTimeout otherwise. Pass a TaskDef and the
|
|
86
103
|
* resolved value is typed as its Result.
|
package/dist/client.js
CHANGED
|
@@ -4,7 +4,7 @@ import { isFailed, isSucceeded } from "./models.js";
|
|
|
4
4
|
import { SQLiteStore } from "./store/sqlite.js";
|
|
5
5
|
import { PostgresStore } from "./store/postgres.js";
|
|
6
6
|
import { taskName } from "./task.js";
|
|
7
|
-
import { pollWait, pollWaitByKey } from "./wait.js";
|
|
7
|
+
import { DEFAULT_WAIT_TIMEOUT_MS, pollWait, pollWaitByKey } from "./wait.js";
|
|
8
8
|
/** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
|
|
9
9
|
export class CairnQ {
|
|
10
10
|
_store;
|
|
@@ -29,11 +29,13 @@ export class CairnQ {
|
|
|
29
29
|
const { busyTimeoutMs, ...client } = opts;
|
|
30
30
|
return new CairnQ(new SQLiteStore(path, { busyTimeoutMs }), client);
|
|
31
31
|
}
|
|
32
|
-
/** Multi-host backend. `
|
|
33
|
-
* optional `pg` package
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
/** Multi-host backend. `source` is a libpq connection string — which requires
|
|
33
|
+
* the optional `pg` package — or a PgExecutor over a driver the application
|
|
34
|
+
* already runs (an ORM's pool, say), which cairnq then shares instead of
|
|
35
|
+
* opening a second one. */
|
|
36
|
+
static postgres(source, opts = {}) {
|
|
37
|
+
const { max, schema, ...client } = opts;
|
|
38
|
+
return new CairnQ(new PostgresStore(source, { max, schema }), client);
|
|
37
39
|
}
|
|
38
40
|
get store() {
|
|
39
41
|
return this._store;
|
|
@@ -63,6 +65,15 @@ export class CairnQ {
|
|
|
63
65
|
getByKey(key) {
|
|
64
66
|
return this._store.getByKey(key);
|
|
65
67
|
}
|
|
68
|
+
/** The status-only probe wait polls on: id + status, no payload. Public for
|
|
69
|
+
* the same reason it exists — a dashboard or poller that only asks "is it
|
|
70
|
+
* finished yet" should not drag the payload back per ask. */
|
|
71
|
+
getStatus(taskId) {
|
|
72
|
+
return this._store.getStatus(taskId);
|
|
73
|
+
}
|
|
74
|
+
getStatusByKey(key) {
|
|
75
|
+
return this._store.getStatusByKey(key);
|
|
76
|
+
}
|
|
66
77
|
list(input) {
|
|
67
78
|
return this._store.list(input);
|
|
68
79
|
}
|
|
@@ -90,13 +101,28 @@ export class CairnQ {
|
|
|
90
101
|
stats() {
|
|
91
102
|
return this._store.stats();
|
|
92
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Call `onSignal` when the tasks on `queues` may have changed. Returns an
|
|
106
|
+
* unsubscribe.
|
|
107
|
+
*
|
|
108
|
+
* Notify-accelerated polling, not an event log: a signal means "re-read now",
|
|
109
|
+
* and `stats()` / `list()` / `get()` are where the truth is. On Postgres an
|
|
110
|
+
* idle watch costs nothing and signals land in milliseconds; everywhere else
|
|
111
|
+
* the timer alone still delivers, so the same consumer code is correct either
|
|
112
|
+
* way. See TaskStore.watch for the full contract.
|
|
113
|
+
*/
|
|
114
|
+
watch(opts, onSignal) {
|
|
115
|
+
return this._store.watch(opts, onSignal);
|
|
116
|
+
}
|
|
93
117
|
/** Wait for a task to finish. Resolves with the terminal Task (any status);
|
|
94
118
|
* throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
|
|
95
119
|
* same wait back up — from another process, or after a longer deadline. */
|
|
96
120
|
wait(taskId, opts = {}) {
|
|
121
|
+
// `??`, not a spread default: a caller forwarding `timeoutMs: undefined`
|
|
122
|
+
// (call() does) must still get the default, and a spread would override it.
|
|
97
123
|
return pollWait(this._store, taskId, {
|
|
98
|
-
|
|
99
|
-
|
|
124
|
+
...opts,
|
|
125
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS,
|
|
100
126
|
});
|
|
101
127
|
}
|
|
102
128
|
/** Wait for whatever task the `key` currently points at — the cross-process
|
|
@@ -106,14 +132,14 @@ export class CairnQ {
|
|
|
106
132
|
* yet is waited for rather than rejected. */
|
|
107
133
|
waitByKey(key, opts = {}) {
|
|
108
134
|
return pollWaitByKey(this._store, key, {
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
...opts,
|
|
136
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS,
|
|
111
137
|
});
|
|
112
138
|
}
|
|
113
139
|
async call(task, payload, opts = {}) {
|
|
114
|
-
const { waitTimeoutMs
|
|
140
|
+
const { waitTimeoutMs, pollMs, maxPollMs, ...submit } = opts;
|
|
115
141
|
const created = await this.submit(taskName(task), payload, submit);
|
|
116
|
-
const final = await
|
|
142
|
+
const final = await this.wait(created.id, { timeoutMs: waitTimeoutMs, pollMs, maxPollMs });
|
|
117
143
|
if (isSucceeded(final))
|
|
118
144
|
return final.result;
|
|
119
145
|
if (isFailed(final))
|
package/dist/context.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type FailReason } from "./errors.js";
|
|
|
2
2
|
import { type Task } from "./models.js";
|
|
3
3
|
import type { SubmitOptions } from "./client.js";
|
|
4
4
|
import type { TaskStore } from "./store/base.js";
|
|
5
|
+
import type { PgSession } from "./store/pg-executor.js";
|
|
5
6
|
import { type TaskDef } from "./task.js";
|
|
6
7
|
export interface TaskContextOptions {
|
|
7
8
|
retryBackoffMs?: number;
|
|
@@ -74,6 +75,30 @@ export declare class TaskContext {
|
|
|
74
75
|
* this task was already settled.
|
|
75
76
|
*/
|
|
76
77
|
succeed(result?: unknown): Promise<Task | null>;
|
|
78
|
+
/**
|
|
79
|
+
* Finalize this task as succeeded, committing the caller's own writes in the
|
|
80
|
+
* SAME transaction as the settlement. Whatever `write` returns becomes the
|
|
81
|
+
* task's result.
|
|
82
|
+
*
|
|
83
|
+
* await ctx.succeedIn(async (session) => {
|
|
84
|
+
* await db.withSession(session).insert(pages).values(rendered)
|
|
85
|
+
* return { pages: rendered.length }
|
|
86
|
+
* })
|
|
87
|
+
*
|
|
88
|
+
* The alternative — write the rows, then settle — has a window between the two
|
|
89
|
+
* commits where the work is durable but the task still reads as running. A
|
|
90
|
+
* crash there re-runs the whole task, which for a render or an ingest means
|
|
91
|
+
* recomputing it, and for non-idempotent work means doing it twice.
|
|
92
|
+
*
|
|
93
|
+
* `session` is the driver's, so this needs a Postgres store built on a
|
|
94
|
+
* PgExecutor the application shares with its own driver; anything else throws.
|
|
95
|
+
* If the settlement finds the lease gone, `write`'s work is rolled back with
|
|
96
|
+
* it and LostLease is raised. Returns null if this task was already settled.
|
|
97
|
+
*
|
|
98
|
+
* `write` may be replayed if the backend retries the transaction — derive
|
|
99
|
+
* nothing inside it that cannot be derived twice.
|
|
100
|
+
*/
|
|
101
|
+
succeedIn<T>(write: (session: PgSession) => Promise<T>): Promise<Task | null>;
|
|
77
102
|
/**
|
|
78
103
|
* Finalize this task as failed, now. `error` may be a string reason, an Error,
|
|
79
104
|
* a TaskError (which carries its own retryability), or a ready envelope.
|
package/dist/context.js
CHANGED
|
@@ -184,6 +184,39 @@ export class TaskContext {
|
|
|
184
184
|
this.markSettled();
|
|
185
185
|
return task;
|
|
186
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Finalize this task as succeeded, committing the caller's own writes in the
|
|
189
|
+
* SAME transaction as the settlement. Whatever `write` returns becomes the
|
|
190
|
+
* task's result.
|
|
191
|
+
*
|
|
192
|
+
* await ctx.succeedIn(async (session) => {
|
|
193
|
+
* await db.withSession(session).insert(pages).values(rendered)
|
|
194
|
+
* return { pages: rendered.length }
|
|
195
|
+
* })
|
|
196
|
+
*
|
|
197
|
+
* The alternative — write the rows, then settle — has a window between the two
|
|
198
|
+
* commits where the work is durable but the task still reads as running. A
|
|
199
|
+
* crash there re-runs the whole task, which for a render or an ingest means
|
|
200
|
+
* recomputing it, and for non-idempotent work means doing it twice.
|
|
201
|
+
*
|
|
202
|
+
* `session` is the driver's, so this needs a Postgres store built on a
|
|
203
|
+
* PgExecutor the application shares with its own driver; anything else throws.
|
|
204
|
+
* If the settlement finds the lease gone, `write`'s work is rolled back with
|
|
205
|
+
* it and LostLease is raised. Returns null if this task was already settled.
|
|
206
|
+
*
|
|
207
|
+
* `write` may be replayed if the backend retries the transaction — derive
|
|
208
|
+
* nothing inside it that cannot be derived twice.
|
|
209
|
+
*/
|
|
210
|
+
async succeedIn(write) {
|
|
211
|
+
if (this.isSettled)
|
|
212
|
+
return null;
|
|
213
|
+
const task = await this.owned(async () => {
|
|
214
|
+
const { task } = await this.store.completeIn({ taskId: this.task.id, workerId: this.workerId }, write);
|
|
215
|
+
return task;
|
|
216
|
+
});
|
|
217
|
+
this.markSettled();
|
|
218
|
+
return task;
|
|
219
|
+
}
|
|
187
220
|
/**
|
|
188
221
|
* Finalize this task as failed, now. `error` may be a string reason, an Error,
|
|
189
222
|
* a TaskError (which carries its own retryability), or a ready envelope.
|
package/dist/errors.d.ts
CHANGED
|
@@ -80,10 +80,12 @@ export declare class TaskCanceled extends CairnQError {
|
|
|
80
80
|
* anywhere. Nothing inside the blocked handler can observe that, which is why it
|
|
81
81
|
* is reported through `onError` alongside the other things the run loop survived.
|
|
82
82
|
*
|
|
83
|
-
* The cause is
|
|
83
|
+
* The usual cause is synchronous work in a handler: a tight loop, a large
|
|
84
84
|
* JSON.parse, a `*Sync` filesystem or crypto call. Node has one loop and no way
|
|
85
85
|
* to preempt it — move the work to a worker thread, a child process, or an async
|
|
86
|
-
* API that yields.
|
|
86
|
+
* API that yields. The other cause is a worker simply oversubscribed for its
|
|
87
|
+
* `leaseMs` — nothing is blocking, there is just more work than turns — which the
|
|
88
|
+
* same report covers, because the lease is at equal risk either way.
|
|
87
89
|
*/
|
|
88
90
|
export declare class EventLoopBlocked extends CairnQError {
|
|
89
91
|
readonly lateMs: number;
|
package/dist/errors.js
CHANGED
|
@@ -150,10 +150,12 @@ export class TaskCanceled extends CairnQError {
|
|
|
150
150
|
* anywhere. Nothing inside the blocked handler can observe that, which is why it
|
|
151
151
|
* is reported through `onError` alongside the other things the run loop survived.
|
|
152
152
|
*
|
|
153
|
-
* The cause is
|
|
153
|
+
* The usual cause is synchronous work in a handler: a tight loop, a large
|
|
154
154
|
* JSON.parse, a `*Sync` filesystem or crypto call. Node has one loop and no way
|
|
155
155
|
* to preempt it — move the work to a worker thread, a child process, or an async
|
|
156
|
-
* API that yields.
|
|
156
|
+
* API that yields. The other cause is a worker simply oversubscribed for its
|
|
157
|
+
* `leaseMs` — nothing is blocking, there is just more work than turns — which the
|
|
158
|
+
* same report covers, because the lease is at equal risk either way.
|
|
157
159
|
*/
|
|
158
160
|
export class EventLoopBlocked extends CairnQError {
|
|
159
161
|
lateMs;
|
|
@@ -161,8 +163,9 @@ export class EventLoopBlocked extends CairnQError {
|
|
|
161
163
|
leaseMs;
|
|
162
164
|
constructor(lateMs, intervalMs, leaseMs) {
|
|
163
165
|
super(`heartbeat beat was ${lateMs}ms late (interval ${intervalMs}ms, lease ${leaseMs}ms): ` +
|
|
164
|
-
`the event loop was blocked long enough to miss a beat
|
|
165
|
-
`
|
|
166
|
+
`the event loop was blocked long enough to miss a beat, so this worker's leases ` +
|
|
167
|
+
`are at risk. Usually synchronous work in a handler (move it off the loop); ` +
|
|
168
|
+
`otherwise the worker is oversubscribed for its leaseMs.`);
|
|
166
169
|
this.lateMs = lateMs;
|
|
167
170
|
this.intervalMs = intervalMs;
|
|
168
171
|
this.leaseMs = leaseMs;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export { CairnQ } from "./client.js";
|
|
2
|
-
export type { CallOptions, ClientOptions, SubmitOptions } from "./client.js";
|
|
2
|
+
export type { CallOptions, ClientOptions, SubmitOptions, WaitOptions } from "./client.js";
|
|
3
3
|
export { QueueDepthGate } from "./backpressure.js";
|
|
4
4
|
export type { BackpressureOptions, QueueDepthLimit } from "./backpressure.js";
|
|
5
5
|
export { RetentionSweeper } from "./retention.js";
|
|
6
|
-
export type { RetentionOptions } from "./retention.js";
|
|
6
|
+
export type { RetentionCutoffs, RetentionOptions } from "./retention.js";
|
|
7
7
|
export { Worker } from "./worker.js";
|
|
8
8
|
export type { BatchHandler, Handler, TypedHandler, WorkerOptions } from "./worker.js";
|
|
9
9
|
export { TaskContext } from "./context.js";
|
|
@@ -12,9 +12,13 @@ export { defineTask } from "./task.js";
|
|
|
12
12
|
export type { TaskDef } from "./task.js";
|
|
13
13
|
export { SQLiteStore } from "./store/sqlite.js";
|
|
14
14
|
export { PostgresStore } from "./store/postgres.js";
|
|
15
|
+
export { ListenUnavailable } from "./store/pg-executor.js";
|
|
16
|
+
export type { PgExecutor, PgSession, Row } from "./store/pg-executor.js";
|
|
17
|
+
export { createPoolExecutor } from "./store/pg-pool.js";
|
|
15
18
|
export { TaskStore } from "./store/base.js";
|
|
16
|
-
export type { ListInput, PurgeInput, SubmitInput, Conflict } from "./store/base.js";
|
|
17
|
-
export
|
|
18
|
-
export {
|
|
19
|
+
export type { ListInput, PurgeInput, SubmitInput, Conflict, WatchOptions, WatchSignal, } from "./store/base.js";
|
|
20
|
+
export { DEFAULT_WATCH_POLL_MS } from "./store/base.js";
|
|
21
|
+
export type { Task, TaskRef, TaskStatus, TerminalStatus } from "./models.js";
|
|
22
|
+
export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
|
|
19
23
|
export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
|
|
20
24
|
export type { FailReason } from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,9 @@ export { TaskContext } from "./context.js";
|
|
|
6
6
|
export { defineTask } from "./task.js";
|
|
7
7
|
export { SQLiteStore } from "./store/sqlite.js";
|
|
8
8
|
export { PostgresStore } from "./store/postgres.js";
|
|
9
|
+
export { ListenUnavailable } from "./store/pg-executor.js";
|
|
10
|
+
export { createPoolExecutor } from "./store/pg-pool.js";
|
|
9
11
|
export { TaskStore } from "./store/base.js";
|
|
10
|
-
export {
|
|
12
|
+
export { DEFAULT_WATCH_POLL_MS } from "./store/base.js";
|
|
13
|
+
export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
|
|
11
14
|
export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
|
package/dist/models.d.ts
CHANGED
|
@@ -25,9 +25,21 @@ export interface Task {
|
|
|
25
25
|
updated_at_ms: number;
|
|
26
26
|
completed_at_ms: number | null;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
/** The id + status pair the wait loop polls on (see get_status.sql) — a probe,
|
|
29
|
+
* not a snapshot: everything else about the task is deliberately not read. */
|
|
30
|
+
export interface TaskRef {
|
|
31
|
+
id: string;
|
|
32
|
+
status: TaskStatus;
|
|
33
|
+
}
|
|
34
|
+
export declare const TERMINAL: readonly ["succeeded", "failed", "canceled"];
|
|
35
|
+
export type TerminalStatus = (typeof TERMINAL)[number];
|
|
36
|
+
export declare function isTerminalStatus(status: TaskStatus): status is TerminalStatus;
|
|
37
|
+
/** Map a probe row (see get_status.sql) to a TaskRef — the ref twin of
|
|
38
|
+
* rowToTask, so the row shape stays models' knowledge alone. */
|
|
39
|
+
export declare function rowToRef(row: Record<string, unknown>): TaskRef;
|
|
29
40
|
export declare function rowToTask(row: Record<string, unknown>): Task;
|
|
30
|
-
|
|
41
|
+
/** Accepts anything carrying a status — a Task or a TaskRef probe. */
|
|
42
|
+
export declare function isTerminal(task: Pick<Task, "status">): boolean;
|
|
31
43
|
export declare function cancelRequested(task: Task): boolean;
|
|
32
44
|
export declare const isQueued: (task: Task) => boolean;
|
|
33
45
|
export declare const isRunning: (task: Task) => boolean;
|
package/dist/models.js
CHANGED
|
@@ -4,7 +4,17 @@
|
|
|
4
4
|
// conformance suite pins this set against.
|
|
5
5
|
export const STATUSES = ["queued", "running", "succeeded", "failed", "canceled"];
|
|
6
6
|
const JSON_COLUMNS = ["payload", "result", "error", "metadata"];
|
|
7
|
+
// As a const tuple so TerminalStatus derives from it — the same declare-once
|
|
8
|
+
// pattern as STATUSES/TaskStatus above.
|
|
7
9
|
export const TERMINAL = ["succeeded", "failed", "canceled"];
|
|
10
|
+
export function isTerminalStatus(status) {
|
|
11
|
+
return TERMINAL.includes(status);
|
|
12
|
+
}
|
|
13
|
+
/** Map a probe row (see get_status.sql) to a TaskRef — the ref twin of
|
|
14
|
+
* rowToTask, so the row shape stays models' knowledge alone. */
|
|
15
|
+
export function rowToRef(row) {
|
|
16
|
+
return { id: row.id, status: row.status };
|
|
17
|
+
}
|
|
8
18
|
export function rowToTask(row) {
|
|
9
19
|
const t = { ...row };
|
|
10
20
|
for (const col of JSON_COLUMNS) {
|
|
@@ -16,8 +26,9 @@ export function rowToTask(row) {
|
|
|
16
26
|
}
|
|
17
27
|
return t;
|
|
18
28
|
}
|
|
29
|
+
/** Accepts anything carrying a status — a Task or a TaskRef probe. */
|
|
19
30
|
export function isTerminal(task) {
|
|
20
|
-
return
|
|
31
|
+
return isTerminalStatus(task.status);
|
|
21
32
|
}
|
|
22
33
|
export function cancelRequested(task) {
|
|
23
34
|
return task.cancel_requested_at_ms != null;
|
package/dist/retention.d.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TerminalStatus } from "./models.js";
|
|
2
|
+
import { type TaskStore } from "./store/base.js";
|
|
3
|
+
/** Per-status cutoffs. A status left out is never swept — granular retention is
|
|
4
|
+
* an explicit statement of what may go, not a default for what wasn't named. */
|
|
5
|
+
export type RetentionCutoffs = Partial<Record<TerminalStatus, number>>;
|
|
2
6
|
export interface RetentionOptions {
|
|
3
7
|
/**
|
|
4
8
|
* How long a terminal task is kept after it finished. Required: there is no
|
|
5
9
|
* safe default for how long someone else's results stay readable.
|
|
10
|
+
*
|
|
11
|
+
* A number keeps every terminal status the same time. Retention needs are
|
|
12
|
+
* often tiered — a succeeded row is spent once its result is consumed, while
|
|
13
|
+
* a failed one is worth keeping for diagnosis — so a per-status map sets a
|
|
14
|
+
* cutoff per status instead: `{ succeeded: 300_000, failed: 86_400_000 }`.
|
|
6
15
|
*/
|
|
7
|
-
olderThanMs: number;
|
|
16
|
+
olderThanMs: number | RetentionCutoffs;
|
|
8
17
|
/** Time between sweeps. Default 3_600_000 (one hour). */
|
|
9
18
|
intervalMs?: number;
|
|
10
19
|
/** Rows deleted per statement while draining. Default 1_000. */
|
|
@@ -42,7 +51,10 @@ export declare class RetentionSweeper {
|
|
|
42
51
|
/** The loop itself, awaited by stop() so no purge outlives the store. */
|
|
43
52
|
private loop;
|
|
44
53
|
private readonly intervalMs;
|
|
45
|
-
|
|
54
|
+
/** Rows per purge statement while draining — see DEFAULT_LIMIT. */
|
|
55
|
+
private readonly limit;
|
|
56
|
+
/** One purge per cutoff: a lone entry for a number, one per status for a map. */
|
|
57
|
+
private readonly purgeInputs;
|
|
46
58
|
constructor(store: TaskStore, opts: RetentionOptions);
|
|
47
59
|
start(): void;
|
|
48
60
|
/** Stop sweeping and wait for the sweep in flight, if any. */
|