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.
Files changed (56) hide show
  1. package/README.md +28 -0
  2. package/dist/_protocol/migrations/postgres/0002_purge_index.sql +6 -0
  3. package/dist/_protocol/migrations/postgres/0003_notify.sql +38 -0
  4. package/dist/_protocol/migrations/sqlite/0002_purge_index.sql +6 -0
  5. package/dist/_protocol/sql/postgres/claim.sql +18 -5
  6. package/dist/_protocol/sql/postgres/fail.sql +28 -8
  7. package/dist/_protocol/sql/postgres/insert_task.sql +6 -3
  8. package/dist/_protocol/sql/postgres/list.sql +3 -1
  9. package/dist/_protocol/sql/postgres/lock_key.sql +9 -0
  10. package/dist/_protocol/sql/postgres/progress.sql +4 -3
  11. package/dist/_protocol/sql/postgres/protocol_version.sql +4 -0
  12. package/dist/_protocol/sql/postgres/purge.sql +25 -0
  13. package/dist/_protocol/sql/postgres/recover_leases.sql +38 -14
  14. package/dist/_protocol/sql/postgres/retry.sql +3 -0
  15. package/dist/_protocol/sql/postgres/stats.sql +8 -0
  16. package/dist/_protocol/sql/sqlite/claim.sql +13 -2
  17. package/dist/_protocol/sql/sqlite/claimable_probe.sql +6 -2
  18. package/dist/_protocol/sql/sqlite/fail.sql +30 -8
  19. package/dist/_protocol/sql/sqlite/list.sql +3 -1
  20. package/dist/_protocol/sql/sqlite/lock_key.sql +5 -0
  21. package/dist/_protocol/sql/sqlite/progress.sql +6 -2
  22. package/dist/_protocol/sql/sqlite/protocol_version.sql +4 -0
  23. package/dist/_protocol/sql/sqlite/purge.sql +18 -0
  24. package/dist/_protocol/sql/sqlite/recover_leases.sql +25 -7
  25. package/dist/_protocol/sql/sqlite/retry.sql +3 -0
  26. package/dist/_protocol/sql/sqlite/stats.sql +8 -0
  27. package/dist/client.d.ts +10 -2
  28. package/dist/client.js +12 -0
  29. package/dist/context.d.ts +17 -1
  30. package/dist/context.js +60 -6
  31. package/dist/errors.d.ts +18 -2
  32. package/dist/errors.js +49 -3
  33. package/dist/index.d.ts +3 -2
  34. package/dist/index.js +2 -1
  35. package/dist/sql.js +16 -9
  36. package/dist/store/base.d.ts +107 -9
  37. package/dist/store/base.js +370 -1
  38. package/dist/store/postgres.d.ts +62 -63
  39. package/dist/store/postgres.js +245 -222
  40. package/dist/store/sqlite.d.ts +34 -59
  41. package/dist/store/sqlite.js +200 -232
  42. package/dist/wait.d.ts +15 -2
  43. package/dist/wait.js +23 -5
  44. package/dist/worker.d.ts +53 -1
  45. package/dist/worker.js +202 -42
  46. package/package.json +9 -2
  47. package/src/client.ts +16 -2
  48. package/src/context.ts +70 -13
  49. package/src/errors.ts +59 -4
  50. package/src/index.ts +3 -1
  51. package/src/sql.ts +15 -8
  52. package/src/store/base.ts +430 -27
  53. package/src/store/postgres.ts +243 -267
  54. package/src/store/sqlite.ts +211 -265
  55. package/src/wait.ts +28 -5
  56. package/src/worker.ts +242 -42
package/src/sql.ts CHANGED
@@ -2,17 +2,22 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
- // Locate the shared cairnq-protocol dir. Resolution: $CAIRNQ_PROTOCOL_DIR ->
6
- // vendored `_protocol/` next to this module -> walk up to `cairnq-protocol/`
7
- // (monorepo dev). Both SDKs load the SAME .sql strings (zero-drift guarantee).
8
- // The dir is laid out per-dialect (sql/<dialect>/*.sql, migrations/<dialect>/*.sql)
9
- // so a second backend (Postgres) slots in beside sqlite; `dialect` picks the subtree.
5
+ // Locate the shared cairnq-protocol dir. Resolution: $CAIRNQ_PROTOCOL_DIR -> walk
6
+ // up to `cairnq-protocol/` (monorepo dev) -> vendored `_protocol/` next to this
7
+ // module (written at publish time). Both SDKs load the SAME .sql strings
8
+ // (zero-drift guarantee). The dir is laid out per-dialect (sql/<dialect>/*.sql,
9
+ // migrations/<dialect>/*.sql) so a second backend (Postgres) slots in beside
10
+ // sqlite; `dialect` picks the subtree.
11
+ //
12
+ // The source tree wins over the vendored copy on purpose: vendoring is a publish
13
+ // step that also runs locally, and a stale `_protocol/` shadowing the canonical
14
+ // SQL means edits to cairnq-protocol/ are silently not under test. An installed
15
+ // package has no repo above it, so it falls through to the vendored copy.
10
16
  export function findProtocolRoot(): string {
11
17
  const env = process.env.CAIRNQ_PROTOCOL_DIR;
12
18
  if (env) return env;
13
- let dir = dirname(fileURLToPath(import.meta.url));
14
- const vendored = join(dir, "_protocol");
15
- if (existsSync(join(vendored, "sql"))) return vendored;
19
+ const start = dirname(fileURLToPath(import.meta.url));
20
+ let dir = start;
16
21
  for (let i = 0; i < 10; i++) {
17
22
  const candidate = join(dir, "cairnq-protocol");
18
23
  if (existsSync(join(candidate, "sql"))) return candidate;
@@ -20,6 +25,8 @@ export function findProtocolRoot(): string {
20
25
  if (parent === dir) break;
21
26
  dir = parent;
22
27
  }
28
+ const vendored = join(start, "_protocol");
29
+ if (existsSync(join(vendored, "sql"))) return vendored;
23
30
  throw new Error("cannot locate cairnq-protocol; set CAIRNQ_PROTOCOL_DIR");
24
31
  }
25
32
 
package/src/store/base.ts CHANGED
@@ -1,6 +1,68 @@
1
- import type { Task } from "../models.js";
1
+ import { newId } from "../ids.js";
2
+ import {
3
+ AlreadyExists,
4
+ errorEnvelope,
5
+ LostLease,
6
+ ProtocolVersionMismatch,
7
+ SerializationError,
8
+ } from "../errors.js";
9
+ import { rowToTask, STATUSES, type Task, type TaskStatus } from "../models.js";
2
10
 
3
- export type Conflict = "reuse" | "reject" | "replace";
11
+ const rejectMangled = function (this: unknown, _key: string, v: unknown): unknown {
12
+ if (typeof v === "number" && !Number.isFinite(v)) {
13
+ throw new SerializationError(`non-finite number ${v} is not JSON-serializable`);
14
+ }
15
+ // In an array these become the literal `null` (in an object they are merely
16
+ // omitted, the JS idiom for "absent") — the twin SDK would read back a null
17
+ // the caller never wrote.
18
+ if (Array.isArray(this) && (v === undefined || typeof v === "function" || typeof v === "symbol")) {
19
+ throw new SerializationError(`${typeof v} inside an array is not JSON-serializable`);
20
+ }
21
+ return v;
22
+ };
23
+
24
+ /** Encode a value for a protocol JSON column, raising SerializationError on
25
+ * anything JSON cannot represent. Refuses what JSON.stringify would silently
26
+ * mangle into `null`: NaN/Infinity anywhere, undefined/function/symbol inside an
27
+ * array, and a top-level undefined that disappears entirely — either way the
28
+ * twin SDK reads back something other than what the caller meant (the Python
29
+ * SDK rejects the same values, via allow_nan=False). */
30
+ export function dumpJson(value: unknown): string {
31
+ let text: string | undefined;
32
+ try {
33
+ text = JSON.stringify(value);
34
+ } catch (err) {
35
+ // BigInt or a circular structure.
36
+ throw new SerializationError(err instanceof Error ? err.message : String(err));
37
+ }
38
+ if (text === undefined) {
39
+ throw new SerializationError(`value of type ${typeof value} is not JSON-serializable`);
40
+ }
41
+ // Every mangled value reaches the output as the literal `null`, so a
42
+ // null-free result needs no strict pass — this keeps the replacer (which
43
+ // forfeits V8's native stringifier) off the hot path.
44
+ if (text.includes("null")) JSON.stringify(value, rejectMangled);
45
+ return text;
46
+ }
47
+
48
+ const SUPPORTED_PROTOCOL_MAJOR = 1;
49
+
50
+ /** Refuse to run against a store whose protocol major this SDK does not speak.
51
+ * The supported major is a protocol fact, not a dialect one — every backend
52
+ * checks it here so the constant can't fork per store. */
53
+ export function checkProtocolVersion(version: number): void {
54
+ if (version !== SUPPORTED_PROTOCOL_MAJOR) {
55
+ throw new ProtocolVersionMismatch(
56
+ `storage protocol_version=${version}, SDK supports ${SUPPORTED_PROTOCOL_MAJOR}`,
57
+ );
58
+ }
59
+ }
60
+
61
+ // CONFLICTS is the canonical declaration; the type derives from it so the
62
+ // runtime guard in submit() and the type can't drift apart (same pattern as
63
+ // STATUSES/TaskStatus in models.ts).
64
+ const CONFLICTS = ["reuse", "reject", "replace"] as const;
65
+ export type Conflict = (typeof CONFLICTS)[number];
4
66
 
5
67
  export interface SubmitInput {
6
68
  name: string;
@@ -18,7 +80,7 @@ export interface SubmitInput {
18
80
  }
19
81
 
20
82
  export interface ListInput {
21
- status?: string | null;
83
+ status?: TaskStatus | null;
22
84
  queue?: string | null;
23
85
  name?: string | null;
24
86
  rootId?: string | null;
@@ -27,41 +89,382 @@ export interface ListInput {
27
89
  offset?: number;
28
90
  }
29
91
 
30
- /** The storage seam. SQLiteStore is the only MVP implementation. */
31
- export interface TaskStore {
32
- connect(): Promise<void>;
33
- close(): Promise<void>;
34
- protocolVersion(): Promise<number>;
35
-
36
- submit(input: SubmitInput): Promise<Task>;
37
- get(taskId: string): Promise<Task | null>;
38
- getByKey(key: string): Promise<Task | null>;
39
- list(input?: ListInput): Promise<Task[]>;
40
- cancel(taskId: string): Promise<Task | null>;
41
- cancelByKey(key: string): Promise<Task | null>;
42
- retry(taskId: string, opts?: { resetAttempt?: boolean }): Promise<Task | null>;
43
- retryByKey(key: string, opts?: { resetAttempt?: boolean }): Promise<Task | null>;
44
-
45
- claim(input: {
92
+ export interface PurgeInput {
93
+ olderThanMs?: number;
94
+ limit?: number;
95
+ }
96
+
97
+ export type Params = Record<string, unknown>;
98
+ /** Runs one named protocol statement and returns its rows. */
99
+ export type Fetch = (name: string, params: Params) => Promise<any[]>;
100
+
101
+ export const LEASE_EXPIRED_ERROR_JSON = dumpJson(
102
+ errorEnvelope({
103
+ type: "LeaseExpired",
104
+ code: "lease_expired",
105
+ message: "task lease expired and max attempts reached",
106
+ retryable: false,
107
+ }),
108
+ );
109
+
110
+ /** Strips SQL line comments, so a `:name` in a header comment isn't a parameter. */
111
+ export const COMMENT = /--[^\n]*/g;
112
+ /** A `:name` placeholder. The lookbehind spares Postgres `::type` casts. */
113
+ export const NAMED = /(?<!:):(\w+)/g;
114
+
115
+ // Statement text is loaded once at construction and never varies, so the parse is
116
+ // memoized on it: every dialect's binding path runs on each query, and re-scanning
117
+ // the SQL each time would put a regex sweep on the worker's poll loop.
118
+ const paramCache = new Map<string, readonly string[]>();
119
+
120
+ /**
121
+ * The parameter names a statement binds, in first-appearance order.
122
+ *
123
+ * Callers pass a superset of parameters and each dialect takes what its own SQL
124
+ * asks for — that is what lets one call site serve both dialects even though e.g.
125
+ * SQLite binds `:lease_until_ms` where Postgres binds `:lease_ms`. This is the one
126
+ * place that decides what counts as a parameter; both dialects' binding goes
127
+ * through it.
128
+ */
129
+ export function statementParams(sql: string): readonly string[] {
130
+ let names = paramCache.get(sql);
131
+ if (!names) {
132
+ const seen = new Set<string>();
133
+ for (const m of sql.replace(COMMENT, "").matchAll(NAMED)) seen.add(m[1]);
134
+ names = [...seen];
135
+ paramCache.set(sql, names);
136
+ }
137
+ return names;
138
+ }
139
+
140
+ /**
141
+ * The storage seam.
142
+ *
143
+ * A backend supplies three things: how to run one protocol statement, how to run
144
+ * several inside a transaction, and how its dialect binds parameters. Everything
145
+ * above that — the submit conflict branches, the *_by_key lookups, the
146
+ * recover-then-claim sequence, the ownership-checked writes — lives here once,
147
+ * because those are protocol decisions rather than storage decisions. Keeping
148
+ * them in one place is what stops SQLite and Postgres from drifting apart in
149
+ * behavior; the shared SQL already stops them from drifting in wording.
150
+ */
151
+ export abstract class TaskStore {
152
+ // ------------------------------------------------------------ dialect seam
153
+ abstract connect(): Promise<void>;
154
+ abstract close(): Promise<void>;
155
+ abstract protocolVersion(): Promise<number>;
156
+
157
+ /** Run one protocol statement outside a transaction, connecting if needed. */
158
+ protected abstract fetch(name: string, params: Params): Promise<any[]>;
159
+ /** Run several statements atomically; `fn` receives a Fetch bound to the txn. */
160
+ protected abstract tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
161
+
162
+ /**
163
+ * Whether it is worth opening the claim transaction at all. SQLite gates its
164
+ * single write lock behind a read-only probe; Postgres readers don't block
165
+ * writers, so it just says yes.
166
+ */
167
+ protected async hasClaimableWork(_params: Params): Promise<boolean> {
168
+ return true;
169
+ }
170
+
171
+ // ------------------------------------------------------------ wake channel
172
+ // Wake-or-timeout contract (PROTOCOL.md "Push wakeups"): resolve when the
173
+ // watched event may have happened, or after timeoutMs at the latest. The
174
+ // default is a plain sleep — polling IS the wake mechanism; a dialect with a
175
+ // push channel (PostgresStore, LISTEN/NOTIFY) resolves earlier.
176
+
177
+ /** Resolves when a task may have become claimable on one of `queues`. The
178
+ * timer is unref'd: the worker races this against its own stop-aware, ref'd
179
+ * sleep, so it must neither hold the process open nor need clearing. */
180
+ claimWake(_queues: string[], timeoutMs: number): Promise<void> {
181
+ return new Promise((resolve) => setTimeout(resolve, timeoutMs).unref?.());
182
+ }
183
+
184
+ /** Resolves when `taskId` may have gone terminal. Plain ref'd sleep —
185
+ * pollWait awaits it directly, so it is what keeps the process alive. */
186
+ taskDoneWake(_taskId: string, timeoutMs: number): Promise<void> {
187
+ return new Promise((resolve) => setTimeout(resolve, timeoutMs));
188
+ }
189
+
190
+ // --------------------------------------------------------------- internals
191
+ /**
192
+ * An ownership-checked worker write (heartbeat/progress/succeed/complete/fail).
193
+ * Each statement's WHERE pins worker_id + a live lease, so 0 rows back means
194
+ * the lease was lost — every such write reports it the same way.
195
+ */
196
+ private async ownedWrite(name: string, taskId: string, params: Params): Promise<Task> {
197
+ const rows = await this.fetch(name, params);
198
+ if (!rows.length) throw new LostLease(taskId);
199
+ return rowToTask(rows[0]);
200
+ }
201
+
202
+ private static one(rows: any[]): Task | null {
203
+ return rows.length ? rowToTask(rows[0]) : null;
204
+ }
205
+
206
+ // ------------------------------------------------------------- client side
207
+ async submit(input: SubmitInput): Promise<Task> {
208
+ const id = newId("task");
209
+ const ins: Params = {
210
+ id,
211
+ name: input.name,
212
+ queue: input.queue ?? "default",
213
+ payload: dumpJson(input.payload ?? {}),
214
+ metadata: dumpJson(input.metadata ?? {}),
215
+ max_attempts: input.maxAttempts ?? 3,
216
+ priority: input.priority ?? 0,
217
+ delay_ms: input.runAtDelayMs ?? 0,
218
+ parent_id: input.parentId ?? null,
219
+ root_id: input.rootId ?? id,
220
+ correlation_id: input.correlationId ?? null,
221
+ };
222
+ const key = input.key ?? null;
223
+ const conflict = input.conflict ?? "reuse";
224
+ // Validate up front: untyped callers otherwise only hit the strategy branch
225
+ // on the second submit of a key, deep inside the transaction.
226
+ if (!CONFLICTS.includes(conflict)) {
227
+ throw new Error(`unknown conflict strategy: ${conflict}`);
228
+ }
229
+ // maxAttempts < 1 would still run once (claim increments before the check),
230
+ // a silently different meaning than the number says; a negative delay is
231
+ // always a mistake. Both fail loudly instead. Only supplied values are
232
+ // checked — the defaults live in the params object alone.
233
+ if (input.maxAttempts != null && input.maxAttempts < 1) {
234
+ throw new Error(`maxAttempts must be >= 1, got ${input.maxAttempts}`);
235
+ }
236
+ if (input.runAtDelayMs != null && input.runAtDelayMs < 0) {
237
+ throw new Error(`runAtDelayMs must be >= 0, got ${input.runAtDelayMs}`);
238
+ }
239
+ if (key === null) return rowToTask((await this.fetch("insert_task", ins))[0]);
240
+
241
+ // A key makes submit a read-then-write, so it has to be one transaction —
242
+ // opened by taking the key's lock, because on Postgres the transaction alone
243
+ // is not enough: concurrent same-key submits must not both see "no existing
244
+ // task" (see lock_key.sql; on SQLite it is a no-op).
245
+ return this.tx(async (fetch) => {
246
+ await fetch("lock_key", { key });
247
+ const existing = (await fetch("get_key", { key })) as { task_id: string }[];
248
+ if (existing.length) {
249
+ // Read the task itself before branching: a concurrent purge (which
250
+ // takes no key lock) may have deleted it — cascading the key row away —
251
+ // between our statements' snapshots. A vanished task means the key is
252
+ // free after all, whatever the strategy.
253
+ const current = (await fetch("get", { id: existing[0].task_id }))[0];
254
+ if (current) {
255
+ if (conflict === "reuse") return rowToTask(current);
256
+ if (conflict === "reject") throw new AlreadyExists(key);
257
+ // "replace": cancel the recorded task, then repoint the key below.
258
+ await fetch("cancel", { id: existing[0].task_id });
259
+ }
260
+ }
261
+ const row = (await fetch("insert_task", ins))[0];
262
+ await fetch("upsert_key", { key, task_id: id });
263
+ return rowToTask(row);
264
+ });
265
+ }
266
+
267
+ async get(taskId: string): Promise<Task | null> {
268
+ return TaskStore.one(await this.fetch("get", { id: taskId }));
269
+ }
270
+
271
+ async getByKey(key: string): Promise<Task | null> {
272
+ return TaskStore.one(await this.fetch("get_by_key", { key }));
273
+ }
274
+
275
+ async list(input: ListInput = {}): Promise<Task[]> {
276
+ // Validate up front, like submit's conflict guard: a typo'd status otherwise
277
+ // matches nothing and returns [] indistinguishably from "no such tasks".
278
+ if (input.status != null && !STATUSES.includes(input.status)) {
279
+ throw new Error(`unknown status filter: ${input.status}`);
280
+ }
281
+ if ((input.limit != null && input.limit < 0) || (input.offset != null && input.offset < 0)) {
282
+ throw new Error(`limit/offset must be >= 0, got limit=${input.limit} offset=${input.offset}`);
283
+ }
284
+ const rows = await this.fetch("list", {
285
+ status: input.status ?? null,
286
+ queue: input.queue ?? null,
287
+ name: input.name ?? null,
288
+ root_id: input.rootId ?? null,
289
+ correlation_id: input.correlationId ?? null,
290
+ limit: input.limit ?? 100,
291
+ offset: input.offset ?? 0,
292
+ });
293
+ return rows.map(rowToTask);
294
+ }
295
+
296
+ async cancel(taskId: string): Promise<Task | null> {
297
+ return TaskStore.one(await this.fetch("cancel", { id: taskId }));
298
+ }
299
+
300
+ async retry(taskId: string, opts: { resetAttempt?: boolean } = {}): Promise<Task | null> {
301
+ return TaskStore.one(
302
+ await this.fetch("retry", { id: taskId, reset_attempt: opts.resetAttempt ?? false }),
303
+ );
304
+ }
305
+
306
+ async cancelByKey(key: string): Promise<Task | null> {
307
+ return this.byKey("cancel", key, {});
308
+ }
309
+
310
+ async retryByKey(key: string, opts: { resetAttempt?: boolean } = {}): Promise<Task | null> {
311
+ return this.byKey("retry", key, { reset_attempt: opts.resetAttempt ?? false });
312
+ }
313
+
314
+ /**
315
+ * Resolve a key to the task it currently points at, then act on that task —
316
+ * under the key's lock, so a concurrent `replace` can't repoint the key
317
+ * between the lookup and the write (the transaction alone is not enough on
318
+ * Postgres; see lock_key.sql).
319
+ */
320
+ private async byKey(name: string, key: string, params: Params): Promise<Task | null> {
321
+ return this.tx(async (fetch) => {
322
+ await fetch("lock_key", { key });
323
+ const existing = (await fetch("get_key", { key })) as { task_id: string }[];
324
+ if (!existing.length) return null;
325
+ return TaskStore.one(await fetch(name, { id: existing[0].task_id, ...params }));
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Delete terminal tasks that completed more than `olderThanMs` ago and return
331
+ * their ids. Nothing else removes rows, so a long-lived database needs this
332
+ * called periodically. Bounded by `limit` to keep each sweep a short write;
333
+ * call it in a loop until it returns fewer than `limit`.
334
+ */
335
+ async purge(input: PurgeInput = {}): Promise<string[]> {
336
+ if (input.olderThanMs != null && input.olderThanMs < 0) {
337
+ throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
338
+ }
339
+ if (input.limit != null && input.limit < 1) {
340
+ throw new Error(`limit must be >= 1, got ${input.limit}`);
341
+ }
342
+ const rows = await this.fetch("purge", {
343
+ older_than_ms: input.olderThanMs ?? 0,
344
+ limit: input.limit ?? 1_000,
345
+ });
346
+ return rows.map((r) => r.id as string);
347
+ }
348
+
349
+ /**
350
+ * Task counts per queue, keyed by status and zero-filled across all statuses —
351
+ * `(await stats()).default.queued` is the backlog of a queue. A queue appears
352
+ * only while it has rows; terminal tasks keep counting until `purge` removes
353
+ * them.
354
+ */
355
+ async stats(): Promise<Record<string, Record<TaskStatus, number>>> {
356
+ const out: Record<string, Record<TaskStatus, number>> = {};
357
+ for (const row of await this.fetch("stats", {})) {
358
+ const per = (out[row.queue] ??= Object.fromEntries(
359
+ STATUSES.map((s) => [s, 0]),
360
+ ) as Record<TaskStatus, number>);
361
+ per[row.status as TaskStatus] = Number(row.count);
362
+ }
363
+ return out;
364
+ }
365
+
366
+ // ------------------------------------------------------------- worker side
367
+ /**
368
+ * Take up to `limit` claimable tasks. `names` restricts the claim to task names
369
+ * this caller can actually run — a worker passes its registered handlers.
370
+ * Queues alone do not partition work, so without it a worker claims a task it
371
+ * cannot run and fails it permanently. Undefined means no filter; an empty
372
+ * array claims nothing.
373
+ */
374
+ async claim(input: {
46
375
  queues: string[];
47
376
  workerId: string;
48
377
  leaseMs?: number;
49
378
  limit?: number;
50
- }): Promise<Task[]>;
51
- heartbeat(input: { taskId: string; workerId: string; leaseMs?: number }): Promise<Task>;
52
- progress(input: {
379
+ names?: string[];
380
+ }): Promise<Task[]> {
381
+ const params: Params = {
382
+ queues: input.queues,
383
+ names: input.names ?? null,
384
+ worker_id: input.workerId,
385
+ lease_ms: input.leaseMs ?? 30_000,
386
+ limit: input.limit ?? 1,
387
+ lease_expired_error: LEASE_EXPIRED_ERROR_JSON,
388
+ };
389
+ if (!(await this.hasClaimableWork(params))) return [];
390
+ // Recovery must share the claim's transaction: a lease reclaimed here has to
391
+ // be visible to the claim that follows, and to nobody in between.
392
+ return this.tx(async (fetch) => {
393
+ await fetch("recover_leases", params);
394
+ return (await fetch("claim", params)).map(rowToTask);
395
+ });
396
+ }
397
+
398
+ async heartbeat(input: { taskId: string; workerId: string; leaseMs?: number }): Promise<Task> {
399
+ return this.ownedWrite("heartbeat", input.taskId, {
400
+ id: input.taskId,
401
+ worker_id: input.workerId,
402
+ lease_ms: input.leaseMs ?? 30_000,
403
+ });
404
+ }
405
+
406
+ async progress(input: {
53
407
  taskId: string;
54
408
  workerId: string;
55
409
  progress: number | null;
56
410
  message: string | null;
57
- }): Promise<Task>;
58
- succeed(input: { taskId: string; workerId: string; result: unknown }): Promise<Task>;
59
- complete(input: { taskId: string; workerId: string; result: unknown }): Promise<Task>;
60
- fail(input: {
411
+ }): Promise<Task> {
412
+ return this.ownedWrite("progress", input.taskId, {
413
+ id: input.taskId,
414
+ worker_id: input.workerId,
415
+ progress: input.progress,
416
+ message: input.message,
417
+ });
418
+ }
419
+
420
+ async succeed(input: { taskId: string; workerId: string; result: unknown }): Promise<Task> {
421
+ return this.ownedWrite("succeed", input.taskId, {
422
+ id: input.taskId,
423
+ worker_id: input.workerId,
424
+ result: input.result == null ? null : dumpJson(input.result),
425
+ message: null,
426
+ });
427
+ }
428
+
429
+ async complete(input: { taskId: string; workerId: string; result: unknown }): Promise<Task> {
430
+ return this.ownedWrite("complete", input.taskId, {
431
+ id: input.taskId,
432
+ worker_id: input.workerId,
433
+ result: input.result == null ? null : dumpJson(input.result),
434
+ });
435
+ }
436
+
437
+ async fail(input: {
61
438
  taskId: string;
62
439
  workerId: string;
63
440
  error: unknown;
64
441
  retryable?: boolean;
65
442
  delayMs?: number;
66
- }): Promise<Task>;
443
+ }): Promise<Task> {
444
+ let error: string;
445
+ try {
446
+ error = dumpJson(input.error ?? {});
447
+ } catch (err) {
448
+ if (!(err instanceof SerializationError)) throw err;
449
+ // A failure record must never itself fail to serialize (a TaskError
450
+ // carrying exotic details would otherwise strand the task until lease
451
+ // expiry). Strip the envelope to its string fields and record that.
452
+ const e = (input.error ?? {}) as { type?: unknown; code?: unknown; message?: unknown };
453
+ error = dumpJson(
454
+ errorEnvelope({
455
+ type: String(e.type ?? "TaskError"),
456
+ code: String(e.code ?? "task_error"),
457
+ message: String(e.message ?? ""),
458
+ retryable: input.retryable !== false,
459
+ }),
460
+ );
461
+ }
462
+ return this.ownedWrite("fail", input.taskId, {
463
+ id: input.taskId,
464
+ worker_id: input.workerId,
465
+ error,
466
+ retryable: input.retryable !== false,
467
+ delay_ms: input.delayMs ?? 0,
468
+ });
469
+ }
67
470
  }