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/dist/store/base.js
CHANGED
|
@@ -1 +1,370 @@
|
|
|
1
|
-
|
|
1
|
+
import { newId } from "../ids.js";
|
|
2
|
+
import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch, SerializationError, } from "../errors.js";
|
|
3
|
+
import { rowToTask, STATUSES } from "../models.js";
|
|
4
|
+
const rejectMangled = function (_key, v) {
|
|
5
|
+
if (typeof v === "number" && !Number.isFinite(v)) {
|
|
6
|
+
throw new SerializationError(`non-finite number ${v} is not JSON-serializable`);
|
|
7
|
+
}
|
|
8
|
+
// In an array these become the literal `null` (in an object they are merely
|
|
9
|
+
// omitted, the JS idiom for "absent") — the twin SDK would read back a null
|
|
10
|
+
// the caller never wrote.
|
|
11
|
+
if (Array.isArray(this) && (v === undefined || typeof v === "function" || typeof v === "symbol")) {
|
|
12
|
+
throw new SerializationError(`${typeof v} inside an array is not JSON-serializable`);
|
|
13
|
+
}
|
|
14
|
+
return v;
|
|
15
|
+
};
|
|
16
|
+
/** Encode a value for a protocol JSON column, raising SerializationError on
|
|
17
|
+
* anything JSON cannot represent. Refuses what JSON.stringify would silently
|
|
18
|
+
* mangle into `null`: NaN/Infinity anywhere, undefined/function/symbol inside an
|
|
19
|
+
* array, and a top-level undefined that disappears entirely — either way the
|
|
20
|
+
* twin SDK reads back something other than what the caller meant (the Python
|
|
21
|
+
* SDK rejects the same values, via allow_nan=False). */
|
|
22
|
+
export function dumpJson(value) {
|
|
23
|
+
let text;
|
|
24
|
+
try {
|
|
25
|
+
text = JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
// BigInt or a circular structure.
|
|
29
|
+
throw new SerializationError(err instanceof Error ? err.message : String(err));
|
|
30
|
+
}
|
|
31
|
+
if (text === undefined) {
|
|
32
|
+
throw new SerializationError(`value of type ${typeof value} is not JSON-serializable`);
|
|
33
|
+
}
|
|
34
|
+
// Every mangled value reaches the output as the literal `null`, so a
|
|
35
|
+
// null-free result needs no strict pass — this keeps the replacer (which
|
|
36
|
+
// forfeits V8's native stringifier) off the hot path.
|
|
37
|
+
if (text.includes("null"))
|
|
38
|
+
JSON.stringify(value, rejectMangled);
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
const SUPPORTED_PROTOCOL_MAJOR = 1;
|
|
42
|
+
/** Refuse to run against a store whose protocol major this SDK does not speak.
|
|
43
|
+
* The supported major is a protocol fact, not a dialect one — every backend
|
|
44
|
+
* checks it here so the constant can't fork per store. */
|
|
45
|
+
export function checkProtocolVersion(version) {
|
|
46
|
+
if (version !== SUPPORTED_PROTOCOL_MAJOR) {
|
|
47
|
+
throw new ProtocolVersionMismatch(`storage protocol_version=${version}, SDK supports ${SUPPORTED_PROTOCOL_MAJOR}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// CONFLICTS is the canonical declaration; the type derives from it so the
|
|
51
|
+
// runtime guard in submit() and the type can't drift apart (same pattern as
|
|
52
|
+
// STATUSES/TaskStatus in models.ts).
|
|
53
|
+
const CONFLICTS = ["reuse", "reject", "replace"];
|
|
54
|
+
export const LEASE_EXPIRED_ERROR_JSON = dumpJson(errorEnvelope({
|
|
55
|
+
type: "LeaseExpired",
|
|
56
|
+
code: "lease_expired",
|
|
57
|
+
message: "task lease expired and max attempts reached",
|
|
58
|
+
retryable: false,
|
|
59
|
+
}));
|
|
60
|
+
/** Strips SQL line comments, so a `:name` in a header comment isn't a parameter. */
|
|
61
|
+
export const COMMENT = /--[^\n]*/g;
|
|
62
|
+
/** A `:name` placeholder. The lookbehind spares Postgres `::type` casts. */
|
|
63
|
+
export const NAMED = /(?<!:):(\w+)/g;
|
|
64
|
+
// Statement text is loaded once at construction and never varies, so the parse is
|
|
65
|
+
// memoized on it: every dialect's binding path runs on each query, and re-scanning
|
|
66
|
+
// the SQL each time would put a regex sweep on the worker's poll loop.
|
|
67
|
+
const paramCache = new Map();
|
|
68
|
+
/**
|
|
69
|
+
* The parameter names a statement binds, in first-appearance order.
|
|
70
|
+
*
|
|
71
|
+
* Callers pass a superset of parameters and each dialect takes what its own SQL
|
|
72
|
+
* asks for — that is what lets one call site serve both dialects even though e.g.
|
|
73
|
+
* SQLite binds `:lease_until_ms` where Postgres binds `:lease_ms`. This is the one
|
|
74
|
+
* place that decides what counts as a parameter; both dialects' binding goes
|
|
75
|
+
* through it.
|
|
76
|
+
*/
|
|
77
|
+
export function statementParams(sql) {
|
|
78
|
+
let names = paramCache.get(sql);
|
|
79
|
+
if (!names) {
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (const m of sql.replace(COMMENT, "").matchAll(NAMED))
|
|
82
|
+
seen.add(m[1]);
|
|
83
|
+
names = [...seen];
|
|
84
|
+
paramCache.set(sql, names);
|
|
85
|
+
}
|
|
86
|
+
return names;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The storage seam.
|
|
90
|
+
*
|
|
91
|
+
* A backend supplies three things: how to run one protocol statement, how to run
|
|
92
|
+
* several inside a transaction, and how its dialect binds parameters. Everything
|
|
93
|
+
* above that — the submit conflict branches, the *_by_key lookups, the
|
|
94
|
+
* recover-then-claim sequence, the ownership-checked writes — lives here once,
|
|
95
|
+
* because those are protocol decisions rather than storage decisions. Keeping
|
|
96
|
+
* them in one place is what stops SQLite and Postgres from drifting apart in
|
|
97
|
+
* behavior; the shared SQL already stops them from drifting in wording.
|
|
98
|
+
*/
|
|
99
|
+
export class TaskStore {
|
|
100
|
+
/**
|
|
101
|
+
* Whether it is worth opening the claim transaction at all. SQLite gates its
|
|
102
|
+
* single write lock behind a read-only probe; Postgres readers don't block
|
|
103
|
+
* writers, so it just says yes.
|
|
104
|
+
*/
|
|
105
|
+
async hasClaimableWork(_params) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
// ------------------------------------------------------------ wake channel
|
|
109
|
+
// Wake-or-timeout contract (PROTOCOL.md "Push wakeups"): resolve when the
|
|
110
|
+
// watched event may have happened, or after timeoutMs at the latest. The
|
|
111
|
+
// default is a plain sleep — polling IS the wake mechanism; a dialect with a
|
|
112
|
+
// push channel (PostgresStore, LISTEN/NOTIFY) resolves earlier.
|
|
113
|
+
/** Resolves when a task may have become claimable on one of `queues`. The
|
|
114
|
+
* timer is unref'd: the worker races this against its own stop-aware, ref'd
|
|
115
|
+
* sleep, so it must neither hold the process open nor need clearing. */
|
|
116
|
+
claimWake(_queues, timeoutMs) {
|
|
117
|
+
return new Promise((resolve) => setTimeout(resolve, timeoutMs).unref?.());
|
|
118
|
+
}
|
|
119
|
+
/** Resolves when `taskId` may have gone terminal. Plain ref'd sleep —
|
|
120
|
+
* pollWait awaits it directly, so it is what keeps the process alive. */
|
|
121
|
+
taskDoneWake(_taskId, timeoutMs) {
|
|
122
|
+
return new Promise((resolve) => setTimeout(resolve, timeoutMs));
|
|
123
|
+
}
|
|
124
|
+
// --------------------------------------------------------------- internals
|
|
125
|
+
/**
|
|
126
|
+
* An ownership-checked worker write (heartbeat/progress/succeed/complete/fail).
|
|
127
|
+
* Each statement's WHERE pins worker_id + a live lease, so 0 rows back means
|
|
128
|
+
* the lease was lost — every such write reports it the same way.
|
|
129
|
+
*/
|
|
130
|
+
async ownedWrite(name, taskId, params) {
|
|
131
|
+
const rows = await this.fetch(name, params);
|
|
132
|
+
if (!rows.length)
|
|
133
|
+
throw new LostLease(taskId);
|
|
134
|
+
return rowToTask(rows[0]);
|
|
135
|
+
}
|
|
136
|
+
static one(rows) {
|
|
137
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
138
|
+
}
|
|
139
|
+
// ------------------------------------------------------------- client side
|
|
140
|
+
async submit(input) {
|
|
141
|
+
const id = newId("task");
|
|
142
|
+
const ins = {
|
|
143
|
+
id,
|
|
144
|
+
name: input.name,
|
|
145
|
+
queue: input.queue ?? "default",
|
|
146
|
+
payload: dumpJson(input.payload ?? {}),
|
|
147
|
+
metadata: dumpJson(input.metadata ?? {}),
|
|
148
|
+
max_attempts: input.maxAttempts ?? 3,
|
|
149
|
+
priority: input.priority ?? 0,
|
|
150
|
+
delay_ms: input.runAtDelayMs ?? 0,
|
|
151
|
+
parent_id: input.parentId ?? null,
|
|
152
|
+
root_id: input.rootId ?? id,
|
|
153
|
+
correlation_id: input.correlationId ?? null,
|
|
154
|
+
};
|
|
155
|
+
const key = input.key ?? null;
|
|
156
|
+
const conflict = input.conflict ?? "reuse";
|
|
157
|
+
// Validate up front: untyped callers otherwise only hit the strategy branch
|
|
158
|
+
// on the second submit of a key, deep inside the transaction.
|
|
159
|
+
if (!CONFLICTS.includes(conflict)) {
|
|
160
|
+
throw new Error(`unknown conflict strategy: ${conflict}`);
|
|
161
|
+
}
|
|
162
|
+
// maxAttempts < 1 would still run once (claim increments before the check),
|
|
163
|
+
// a silently different meaning than the number says; a negative delay is
|
|
164
|
+
// always a mistake. Both fail loudly instead. Only supplied values are
|
|
165
|
+
// checked — the defaults live in the params object alone.
|
|
166
|
+
if (input.maxAttempts != null && input.maxAttempts < 1) {
|
|
167
|
+
throw new Error(`maxAttempts must be >= 1, got ${input.maxAttempts}`);
|
|
168
|
+
}
|
|
169
|
+
if (input.runAtDelayMs != null && input.runAtDelayMs < 0) {
|
|
170
|
+
throw new Error(`runAtDelayMs must be >= 0, got ${input.runAtDelayMs}`);
|
|
171
|
+
}
|
|
172
|
+
if (key === null)
|
|
173
|
+
return rowToTask((await this.fetch("insert_task", ins))[0]);
|
|
174
|
+
// A key makes submit a read-then-write, so it has to be one transaction —
|
|
175
|
+
// opened by taking the key's lock, because on Postgres the transaction alone
|
|
176
|
+
// is not enough: concurrent same-key submits must not both see "no existing
|
|
177
|
+
// task" (see lock_key.sql; on SQLite it is a no-op).
|
|
178
|
+
return this.tx(async (fetch) => {
|
|
179
|
+
await fetch("lock_key", { key });
|
|
180
|
+
const existing = (await fetch("get_key", { key }));
|
|
181
|
+
if (existing.length) {
|
|
182
|
+
// Read the task itself before branching: a concurrent purge (which
|
|
183
|
+
// takes no key lock) may have deleted it — cascading the key row away —
|
|
184
|
+
// between our statements' snapshots. A vanished task means the key is
|
|
185
|
+
// free after all, whatever the strategy.
|
|
186
|
+
const current = (await fetch("get", { id: existing[0].task_id }))[0];
|
|
187
|
+
if (current) {
|
|
188
|
+
if (conflict === "reuse")
|
|
189
|
+
return rowToTask(current);
|
|
190
|
+
if (conflict === "reject")
|
|
191
|
+
throw new AlreadyExists(key);
|
|
192
|
+
// "replace": cancel the recorded task, then repoint the key below.
|
|
193
|
+
await fetch("cancel", { id: existing[0].task_id });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const row = (await fetch("insert_task", ins))[0];
|
|
197
|
+
await fetch("upsert_key", { key, task_id: id });
|
|
198
|
+
return rowToTask(row);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async get(taskId) {
|
|
202
|
+
return TaskStore.one(await this.fetch("get", { id: taskId }));
|
|
203
|
+
}
|
|
204
|
+
async getByKey(key) {
|
|
205
|
+
return TaskStore.one(await this.fetch("get_by_key", { key }));
|
|
206
|
+
}
|
|
207
|
+
async list(input = {}) {
|
|
208
|
+
// Validate up front, like submit's conflict guard: a typo'd status otherwise
|
|
209
|
+
// matches nothing and returns [] indistinguishably from "no such tasks".
|
|
210
|
+
if (input.status != null && !STATUSES.includes(input.status)) {
|
|
211
|
+
throw new Error(`unknown status filter: ${input.status}`);
|
|
212
|
+
}
|
|
213
|
+
if ((input.limit != null && input.limit < 0) || (input.offset != null && input.offset < 0)) {
|
|
214
|
+
throw new Error(`limit/offset must be >= 0, got limit=${input.limit} offset=${input.offset}`);
|
|
215
|
+
}
|
|
216
|
+
const rows = await this.fetch("list", {
|
|
217
|
+
status: input.status ?? null,
|
|
218
|
+
queue: input.queue ?? null,
|
|
219
|
+
name: input.name ?? null,
|
|
220
|
+
root_id: input.rootId ?? null,
|
|
221
|
+
correlation_id: input.correlationId ?? null,
|
|
222
|
+
limit: input.limit ?? 100,
|
|
223
|
+
offset: input.offset ?? 0,
|
|
224
|
+
});
|
|
225
|
+
return rows.map(rowToTask);
|
|
226
|
+
}
|
|
227
|
+
async cancel(taskId) {
|
|
228
|
+
return TaskStore.one(await this.fetch("cancel", { id: taskId }));
|
|
229
|
+
}
|
|
230
|
+
async retry(taskId, opts = {}) {
|
|
231
|
+
return TaskStore.one(await this.fetch("retry", { id: taskId, reset_attempt: opts.resetAttempt ?? false }));
|
|
232
|
+
}
|
|
233
|
+
async cancelByKey(key) {
|
|
234
|
+
return this.byKey("cancel", key, {});
|
|
235
|
+
}
|
|
236
|
+
async retryByKey(key, opts = {}) {
|
|
237
|
+
return this.byKey("retry", key, { reset_attempt: opts.resetAttempt ?? false });
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Resolve a key to the task it currently points at, then act on that task —
|
|
241
|
+
* under the key's lock, so a concurrent `replace` can't repoint the key
|
|
242
|
+
* between the lookup and the write (the transaction alone is not enough on
|
|
243
|
+
* Postgres; see lock_key.sql).
|
|
244
|
+
*/
|
|
245
|
+
async byKey(name, key, params) {
|
|
246
|
+
return this.tx(async (fetch) => {
|
|
247
|
+
await fetch("lock_key", { key });
|
|
248
|
+
const existing = (await fetch("get_key", { key }));
|
|
249
|
+
if (!existing.length)
|
|
250
|
+
return null;
|
|
251
|
+
return TaskStore.one(await fetch(name, { id: existing[0].task_id, ...params }));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Delete terminal tasks that completed more than `olderThanMs` ago and return
|
|
256
|
+
* their ids. Nothing else removes rows, so a long-lived database needs this
|
|
257
|
+
* called periodically. Bounded by `limit` to keep each sweep a short write;
|
|
258
|
+
* call it in a loop until it returns fewer than `limit`.
|
|
259
|
+
*/
|
|
260
|
+
async purge(input = {}) {
|
|
261
|
+
if (input.olderThanMs != null && input.olderThanMs < 0) {
|
|
262
|
+
throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
|
|
263
|
+
}
|
|
264
|
+
if (input.limit != null && input.limit < 1) {
|
|
265
|
+
throw new Error(`limit must be >= 1, got ${input.limit}`);
|
|
266
|
+
}
|
|
267
|
+
const rows = await this.fetch("purge", {
|
|
268
|
+
older_than_ms: input.olderThanMs ?? 0,
|
|
269
|
+
limit: input.limit ?? 1_000,
|
|
270
|
+
});
|
|
271
|
+
return rows.map((r) => r.id);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Task counts per queue, keyed by status and zero-filled across all statuses —
|
|
275
|
+
* `(await stats()).default.queued` is the backlog of a queue. A queue appears
|
|
276
|
+
* only while it has rows; terminal tasks keep counting until `purge` removes
|
|
277
|
+
* them.
|
|
278
|
+
*/
|
|
279
|
+
async stats() {
|
|
280
|
+
const out = {};
|
|
281
|
+
for (const row of await this.fetch("stats", {})) {
|
|
282
|
+
const per = (out[row.queue] ??= Object.fromEntries(STATUSES.map((s) => [s, 0])));
|
|
283
|
+
per[row.status] = Number(row.count);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
// ------------------------------------------------------------- worker side
|
|
288
|
+
/**
|
|
289
|
+
* Take up to `limit` claimable tasks. `names` restricts the claim to task names
|
|
290
|
+
* this caller can actually run — a worker passes its registered handlers.
|
|
291
|
+
* Queues alone do not partition work, so without it a worker claims a task it
|
|
292
|
+
* cannot run and fails it permanently. Undefined means no filter; an empty
|
|
293
|
+
* array claims nothing.
|
|
294
|
+
*/
|
|
295
|
+
async claim(input) {
|
|
296
|
+
const params = {
|
|
297
|
+
queues: input.queues,
|
|
298
|
+
names: input.names ?? null,
|
|
299
|
+
worker_id: input.workerId,
|
|
300
|
+
lease_ms: input.leaseMs ?? 30_000,
|
|
301
|
+
limit: input.limit ?? 1,
|
|
302
|
+
lease_expired_error: LEASE_EXPIRED_ERROR_JSON,
|
|
303
|
+
};
|
|
304
|
+
if (!(await this.hasClaimableWork(params)))
|
|
305
|
+
return [];
|
|
306
|
+
// Recovery must share the claim's transaction: a lease reclaimed here has to
|
|
307
|
+
// be visible to the claim that follows, and to nobody in between.
|
|
308
|
+
return this.tx(async (fetch) => {
|
|
309
|
+
await fetch("recover_leases", params);
|
|
310
|
+
return (await fetch("claim", params)).map(rowToTask);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async heartbeat(input) {
|
|
314
|
+
return this.ownedWrite("heartbeat", input.taskId, {
|
|
315
|
+
id: input.taskId,
|
|
316
|
+
worker_id: input.workerId,
|
|
317
|
+
lease_ms: input.leaseMs ?? 30_000,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
async progress(input) {
|
|
321
|
+
return this.ownedWrite("progress", input.taskId, {
|
|
322
|
+
id: input.taskId,
|
|
323
|
+
worker_id: input.workerId,
|
|
324
|
+
progress: input.progress,
|
|
325
|
+
message: input.message,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async succeed(input) {
|
|
329
|
+
return this.ownedWrite("succeed", input.taskId, {
|
|
330
|
+
id: input.taskId,
|
|
331
|
+
worker_id: input.workerId,
|
|
332
|
+
result: input.result == null ? null : dumpJson(input.result),
|
|
333
|
+
message: null,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async complete(input) {
|
|
337
|
+
return this.ownedWrite("complete", input.taskId, {
|
|
338
|
+
id: input.taskId,
|
|
339
|
+
worker_id: input.workerId,
|
|
340
|
+
result: input.result == null ? null : dumpJson(input.result),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async fail(input) {
|
|
344
|
+
let error;
|
|
345
|
+
try {
|
|
346
|
+
error = dumpJson(input.error ?? {});
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
if (!(err instanceof SerializationError))
|
|
350
|
+
throw err;
|
|
351
|
+
// A failure record must never itself fail to serialize (a TaskError
|
|
352
|
+
// carrying exotic details would otherwise strand the task until lease
|
|
353
|
+
// expiry). Strip the envelope to its string fields and record that.
|
|
354
|
+
const e = (input.error ?? {});
|
|
355
|
+
error = dumpJson(errorEnvelope({
|
|
356
|
+
type: String(e.type ?? "TaskError"),
|
|
357
|
+
code: String(e.code ?? "task_error"),
|
|
358
|
+
message: String(e.message ?? ""),
|
|
359
|
+
retryable: input.retryable !== false,
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
return this.ownedWrite("fail", input.taskId, {
|
|
363
|
+
id: input.taskId,
|
|
364
|
+
worker_id: input.workerId,
|
|
365
|
+
error,
|
|
366
|
+
retryable: input.retryable !== false,
|
|
367
|
+
delay_ms: input.delayMs ?? 0,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -1,29 +1,62 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import type { ListInput, SubmitInput, TaskStore } from "./base.js";
|
|
1
|
+
import { type Fetch, type Params, TaskStore } from "./base.js";
|
|
3
2
|
/**
|
|
4
|
-
*
|
|
3
|
+
* The rewritten SQL and the order its `$n` slots must be filled in.
|
|
4
|
+
*
|
|
5
|
+
* Translates the protocol's named-parameter SQL (`:name`) into Postgres positional
|
|
5
6
|
* placeholders (`$1`), collapsing each DISTINCT name to ONE slot — statements reuse
|
|
6
|
-
* a name across CASE branches / IS NULL guards (e.g. list.sql).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* a name across CASE branches / IS NULL guards (e.g. list.sql). Which names count
|
|
8
|
+
* as parameters is `statementParams`' decision, shared with the SQLite binding path
|
|
9
|
+
* so the two can't disagree about, say, a `::type` cast.
|
|
10
|
+
*
|
|
11
|
+
* Memoized on the statement text, which is loaded once and never varies: this runs
|
|
12
|
+
* on every query, including the worker's poll loop, for a result that cannot have
|
|
13
|
+
* changed. Exported for unit testing.
|
|
9
14
|
*/
|
|
10
|
-
export declare function
|
|
15
|
+
export declare function positionalStatement(sql: string): {
|
|
16
|
+
text: string;
|
|
17
|
+
order: readonly string[];
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The statement's rewritten text plus this call's values, in slot order. Names the
|
|
21
|
+
* statement does not use are simply not bound, so callers may pass a superset.
|
|
22
|
+
*/
|
|
23
|
+
export declare function toPositional(sql: string, params: Params): {
|
|
11
24
|
text: string;
|
|
12
25
|
values: unknown[];
|
|
13
26
|
};
|
|
14
27
|
/**
|
|
15
|
-
* PostgresStore —
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
28
|
+
* PostgresStore — the Postgres dialect of the shared cairnq-protocol SQL.
|
|
29
|
+
*
|
|
30
|
+
* Everything protocol-shaped lives in TaskStore; this file is only what Postgres
|
|
31
|
+
* does differently: a `pg` Pool, `:name` -> `$n` translation, and time taken from
|
|
32
|
+
* the DB clock (`now()`) instead of from the SDK, which is what makes this backend
|
|
33
|
+
* multi-host — unlike SQLite it coordinates API and worker processes across
|
|
34
|
+
* machines, with no shared clock to agree on. claim uses FOR UPDATE SKIP LOCKED
|
|
35
|
+
* and needs no claimable_probe, because PG readers don't block writers. JSON
|
|
36
|
+
* columns are jsonb (bound as JSON text, read back as objects by rowToTask).
|
|
20
37
|
*/
|
|
21
|
-
export declare class PostgresStore
|
|
38
|
+
export declare class PostgresStore extends TaskStore {
|
|
22
39
|
private readonly dsn;
|
|
23
40
|
private readonly opts;
|
|
24
41
|
private pool;
|
|
25
42
|
private connecting;
|
|
26
43
|
private readonly statements;
|
|
44
|
+
private listener;
|
|
45
|
+
private listenerConnecting;
|
|
46
|
+
/** LISTEN is off for good: the store was closed, or the server accepted a
|
|
47
|
+
* connection but refused LISTEN (e.g. a transaction-mode pooler) —
|
|
48
|
+
* deterministic, so retrying would fail the same way every time. */
|
|
49
|
+
private listenerUnavailable;
|
|
50
|
+
/** A failure to even connect is transient (network blip, server restarting):
|
|
51
|
+
* retry, but not before this time, backing off so a down server is not
|
|
52
|
+
* hammered from the poll loop. */
|
|
53
|
+
private listenerRetryAt;
|
|
54
|
+
private listenerBackoffMs;
|
|
55
|
+
/** Queues notified while nobody was waiting; consumed by the next claimWake
|
|
56
|
+
* so a wake that lands between polls is not lost. */
|
|
57
|
+
private readonly pendingQueues;
|
|
58
|
+
/** Wake callbacks by key: "queued" (broadcast) or "done:<task id>". */
|
|
59
|
+
private readonly waiters;
|
|
27
60
|
constructor(dsn: string, opts?: {
|
|
28
61
|
max?: number;
|
|
29
62
|
});
|
|
@@ -34,54 +67,20 @@ export declare class PostgresStore implements TaskStore {
|
|
|
34
67
|
private applyMigrations;
|
|
35
68
|
private readProtocolVersion;
|
|
36
69
|
protocolVersion(): Promise<number>;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
claim(input: {
|
|
54
|
-
queues: string[];
|
|
55
|
-
workerId: string;
|
|
56
|
-
leaseMs?: number;
|
|
57
|
-
limit?: number;
|
|
58
|
-
}): Promise<Task[]>;
|
|
59
|
-
heartbeat(input: {
|
|
60
|
-
taskId: string;
|
|
61
|
-
workerId: string;
|
|
62
|
-
leaseMs?: number;
|
|
63
|
-
}): Promise<Task>;
|
|
64
|
-
progress(input: {
|
|
65
|
-
taskId: string;
|
|
66
|
-
workerId: string;
|
|
67
|
-
progress: number | null;
|
|
68
|
-
message: string | null;
|
|
69
|
-
}): Promise<Task>;
|
|
70
|
-
succeed(input: {
|
|
71
|
-
taskId: string;
|
|
72
|
-
workerId: string;
|
|
73
|
-
result: unknown;
|
|
74
|
-
}): Promise<Task>;
|
|
75
|
-
complete(input: {
|
|
76
|
-
taskId: string;
|
|
77
|
-
workerId: string;
|
|
78
|
-
result: unknown;
|
|
79
|
-
}): Promise<Task>;
|
|
80
|
-
fail(input: {
|
|
81
|
-
taskId: string;
|
|
82
|
-
workerId: string;
|
|
83
|
-
error: unknown;
|
|
84
|
-
retryable?: boolean;
|
|
85
|
-
delayMs?: number;
|
|
86
|
-
}): Promise<Task>;
|
|
70
|
+
claimWake(queues: string[], timeoutMs: number): Promise<void>;
|
|
71
|
+
taskDoneWake(taskId: string, timeoutMs: number): Promise<void>;
|
|
72
|
+
/** A promise resolving on notification-or-timeout, deregistering either way.
|
|
73
|
+
* The timer is unref'd: while a listener exists its socket keeps the process
|
|
74
|
+
* alive, and dropListener wakes every waiter the moment it goes away. */
|
|
75
|
+
private wakeOn;
|
|
76
|
+
/** True once the LISTEN connection is up; starts connecting it otherwise
|
|
77
|
+
* (respecting the transient-failure backoff). Callers fall back to plain
|
|
78
|
+
* polling until it is ready (or forever, if it can't be established) —
|
|
79
|
+
* correctness never depends on it. */
|
|
80
|
+
private listenerReady;
|
|
81
|
+
private startListener;
|
|
82
|
+
private onNotification;
|
|
83
|
+
private dropListener;
|
|
84
|
+
protected fetch(name: string, params: Params): Promise<any[]>;
|
|
85
|
+
protected tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
|
|
87
86
|
}
|