cairnq 0.5.0 → 0.7.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 (39) hide show
  1. package/README.md +11 -5
  2. package/dist/_protocol/migrations/postgres/0006_claim_name_index.sql +25 -0
  3. package/dist/_protocol/migrations/sqlite/0006_claim_name_index.sql +26 -0
  4. package/dist/_protocol/sql/postgres/claim_one_name.sql +37 -0
  5. package/dist/_protocol/sql/postgres/claim_one_queue_one_name.sql +34 -0
  6. package/dist/_protocol/sql/postgres/heartbeat_batch.sql +24 -0
  7. package/dist/_protocol/sql/sqlite/claim_one_name.sql +36 -0
  8. package/dist/_protocol/sql/sqlite/claim_one_queue_one_name.sql +31 -0
  9. package/dist/_protocol/sql/sqlite/heartbeat_batch.sql +29 -0
  10. package/dist/backoff.d.ts +31 -0
  11. package/dist/backoff.js +40 -0
  12. package/dist/client.d.ts +28 -2
  13. package/dist/client.js +30 -3
  14. package/dist/context.d.ts +48 -2
  15. package/dist/context.js +101 -10
  16. package/dist/errors.d.ts +60 -4
  17. package/dist/errors.js +94 -9
  18. package/dist/index.d.ts +6 -2
  19. package/dist/index.js +2 -1
  20. package/dist/retention.d.ts +60 -0
  21. package/dist/retention.js +115 -0
  22. package/dist/store/base.d.ts +50 -1
  23. package/dist/store/base.js +114 -19
  24. package/dist/store/sqlite.js +4 -1
  25. package/dist/wait.d.ts +20 -5
  26. package/dist/wait.js +34 -9
  27. package/dist/worker.d.ts +214 -16
  28. package/dist/worker.js +500 -131
  29. package/package.json +1 -1
  30. package/src/backoff.ts +53 -0
  31. package/src/client.ts +43 -5
  32. package/src/context.ts +116 -9
  33. package/src/errors.ts +101 -9
  34. package/src/index.ts +6 -1
  35. package/src/retention.ts +136 -0
  36. package/src/store/base.ts +121 -17
  37. package/src/store/sqlite.ts +4 -1
  38. package/src/wait.ts +60 -16
  39. package/src/worker.ts +640 -146
package/README.md CHANGED
@@ -34,7 +34,9 @@ try {
34
34
  } catch (err) {
35
35
  if (err instanceof TaskFailed) log(err.code, err.message, err.retryable); // envelope fields
36
36
  else if (err instanceof TaskTimeout) {
37
- /* err.taskId keeps running */
37
+ // The task keeps running — resume the wait instead of submitting again.
38
+ const result = await tasks.wait(err.taskId, { timeoutMs: 60_000 });
39
+ // …or tasks.waitByKey(key), from a process that never held the id.
38
40
  }
39
41
  }
40
42
  ```
@@ -66,13 +68,17 @@ Opt-in: every API still accepts a plain name string (cross-language callers use
66
68
 
67
69
  ```ts
68
70
  const worker = Worker.sqlite("tasks.db", {
69
- concurrency: 4,
70
- retryBackoffMs: 1_000, // doubles per attempt, capped by retryBackoffMaxMs (30s); 0 disables
71
+ concurrency: 4, // handler calls at once; use maxInFlightBytes to bound memory
72
+ retryBackoffMs: 1_000, // window doubles per attempt, capped at retryBackoffMaxMs (30s),
73
+ // jittered over its upper half; 0 disables
71
74
  onError: (err, info) => log.warn({ err, ...info }), // claims/writes the loop survived
72
75
  });
73
76
 
74
- // Nothing else deletes rows. Sweep terminal tasks on a schedule.
75
- await tasks.purge({ olderThanMs: 7 * 24 * 3600_000 });
77
+ // 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.
79
+ const tasks = CairnQ.sqlite("tasks.db", {
80
+ retention: { olderThanMs: 7 * 24 * 3600_000 },
81
+ });
76
82
  ```
77
83
 
78
84
  A handler that does real side effects should bail out when it loses its lease —
@@ -0,0 +1,25 @@
1
+ -- Serves the per-name claim: claim_one_name.sql and claim_one_queue_one_name.sql,
2
+ -- which a worker uses to draw a separate quota for each task name that sizes
3
+ -- itself (a `batch`, or its own concurrency). See "Batch delivery" in PROTOCOL.md.
4
+ --
5
+ -- cairnq_tasks_claim_idx does not cover it: `name` is not in that index, so a
6
+ -- name filter is a residual applied while walking the queue in claim order. The
7
+ -- cost lands hardest on a name with *nothing* queued — the claim walks every
8
+ -- claimable row in the queue looking for `limit` matches and finds none — and a
9
+ -- worker makes one such draw per registered name, per poll, inside the claim's
10
+ -- transaction, holding its FOR UPDATE row locks for the whole of it.
11
+ --
12
+ -- Both indexes are needed. `name` sits before the ORDER BY columns here, so this
13
+ -- one can only be read in claim order when `name` is an equality — exactly the
14
+ -- per-name draws. A claim with no name filter, or an array-valued one, still
15
+ -- reads cairnq_tasks_claim_idx and is unaffected.
16
+ --
17
+ -- NOT built CONCURRENTLY: migrations run inside a transaction (see the runner's
18
+ -- `lock table cairnq_migrations`), and CREATE INDEX CONCURRENTLY cannot. On an
19
+ -- existing large cairnq_tasks this takes a write lock for the build. Deploying
20
+ -- into a busy database is the case to watch; build it by hand with CONCURRENTLY
21
+ -- first if that matters, and this statement then becomes a no-op.
22
+ create index if not exists cairnq_tasks_claim_name_idx
23
+ on cairnq_tasks (queue, status, name, priority desc, created_at_ms);
24
+
25
+ update cairnq_meta set value = '6' where key = 'schema_version';
@@ -0,0 +1,26 @@
1
+ -- Serves the per-name claim: claim_one_name.sql and claim_one_queue_one_name.sql,
2
+ -- which a worker uses to draw a separate quota for each task name that sizes
3
+ -- itself (a `batch`, or its own concurrency). See "Batch delivery" in PROTOCOL.md.
4
+ --
5
+ -- cairnq_tasks_claim_idx does not cover it: `name` is not in that index, so a
6
+ -- name filter is a residual applied while walking the queue in claim order. The
7
+ -- cost lands hardest on a name with *nothing* queued — the claim walks every
8
+ -- claimable row in the queue looking for `limit` matches and finds none — and a
9
+ -- worker makes one such draw per registered name, per poll, inside the claim's
10
+ -- write transaction. Measured on a 20k backlog: 1116us for an empty name's draw
11
+ -- against cairnq_tasks_claim_idx, 8.8us against this one.
12
+ --
13
+ -- Both indexes are needed. `name` sits before the ORDER BY columns here, so this
14
+ -- one can only be read in claim order when `name` is an equality — exactly the
15
+ -- per-name draws. A claim with no name filter, or a list-valued one, still reads
16
+ -- cairnq_tasks_claim_idx and is unaffected (measured flat at ~13-16us either way).
17
+ --
18
+ -- The equality is what makes it usable: `name in (select value from json_each(?))`
19
+ -- does NOT reach this index even for a single-element list — SQLite builds a
20
+ -- bloom filter over the subquery and falls back to cairnq_tasks_claim_idx
21
+ -- (measured 1446us, i.e. no improvement at all). That is why the per-name
22
+ -- statements exist as separate files rather than the shared one being reused.
23
+ create index if not exists cairnq_tasks_claim_name_idx
24
+ on cairnq_tasks (queue, status, name, priority desc, created_at_ms);
25
+
26
+ update cairnq_meta set value = '6' where key = 'schema_version';
@@ -0,0 +1,37 @@
1
+ -- claim, for a caller drawing exactly ONE task name (Postgres dialect).
2
+ -- Byte-for-byte claim.sql except that the name filter is an equality on :name
3
+ -- instead of `= any(:names)` — a drift-guard test asserts precisely that, so
4
+ -- treat claim.sql as the source and re-derive this file when it changes.
5
+ --
6
+ -- It exists so the draw can reach cairnq_tasks_claim_name_idx, whose leading
7
+ -- columns are (queue, status, name): an array-valued name filter cannot be read
8
+ -- in claim order against it, so the name falls back to a residual on
9
+ -- cairnq_tasks_claim_idx and a draw for a name with nothing queued scans the
10
+ -- whole claimable backlog — inside the transaction, holding its row locks. See
11
+ -- migration 0006.
12
+ --
13
+ -- Used for the per-name quotas a worker draws for names that size themselves —
14
+ -- a `batch`, or their own concurrency. See "Batch delivery" in PROTOCOL.md.
15
+ -- params: queues (text[]), name, worker_id, lease_ms, limit
16
+ update cairnq_tasks t
17
+ set
18
+ status = 'running',
19
+ worker_id = :worker_id,
20
+ lease_until_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint + :lease_ms,
21
+ attempt = attempt + 1,
22
+ updated_at_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint
23
+ from (
24
+ select id from cairnq_tasks
25
+ where status = 'queued'
26
+ and queue = any(:queues::text[])
27
+ and name = :name
28
+ and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
29
+ -- id breaks created_at_ms ties (same-millisecond submits), so claim order
30
+ -- is deterministic: FIFO at millisecond granularity; within one millisecond
31
+ -- the id's random half decides, stably but not in submit order.
32
+ order by priority desc, created_at_ms asc, id asc
33
+ limit :limit
34
+ for update skip locked
35
+ ) sel
36
+ where t.id = sel.id
37
+ returning t.*;
@@ -0,0 +1,34 @@
1
+ -- claim, for a caller watching ONE queue and drawing ONE task name (Postgres
2
+ -- dialect) — the common shape for a batched worker. Byte-for-byte claim.sql
3
+ -- except that both the queue and the name filters are equalities; a drift-guard
4
+ -- test asserts precisely that, so treat claim.sql as the source and re-derive
5
+ -- this file when it changes.
6
+ --
7
+ -- It is the combination of claim_one_queue.sql's queue equality and
8
+ -- claim_one_name.sql's name equality, and each is there for the reason that file
9
+ -- gives. Together they pin both leading columns of cairnq_tasks_claim_name_idx,
10
+ -- so the draw is an index scan in claim order that stops at :limit rows however
11
+ -- deep the backlog is.
12
+ -- params: queue, name, worker_id, lease_ms, limit
13
+ update cairnq_tasks t
14
+ set
15
+ status = 'running',
16
+ worker_id = :worker_id,
17
+ lease_until_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint + :lease_ms,
18
+ attempt = attempt + 1,
19
+ updated_at_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint
20
+ from (
21
+ select id from cairnq_tasks
22
+ where status = 'queued'
23
+ and queue = :queue
24
+ and name = :name
25
+ and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
26
+ -- id breaks created_at_ms ties (same-millisecond submits), so claim order
27
+ -- is deterministic: FIFO at millisecond granularity; within one millisecond
28
+ -- the id's random half decides, stably but not in submit order.
29
+ order by priority desc, created_at_ms asc, id asc
30
+ limit :limit
31
+ for update skip locked
32
+ ) sel
33
+ where t.id = sel.id
34
+ returning t.*;
@@ -0,0 +1,24 @@
1
+ -- Extend the lease on several tasks at once (Postgres dialect) — the heartbeat
2
+ -- for batch delivery. Ownership-checked per row, exactly as heartbeat.sql: a task
3
+ -- whose lease this worker no longer holds simply does not come back, so the
4
+ -- caller learns which ones it lost by which ids are absent from the result rather
5
+ -- than by an error. One statement per beat replaces one round trip per leased
6
+ -- task, which for a 256-task batch is the difference between one write and 256.
7
+ --
8
+ -- Returns only what the beat needs, unlike heartbeat.sql's `returning *`: the
9
+ -- singular statement hands its row to the caller as `ctx.heartbeat()`'s public
10
+ -- return value, while this one's rows never leave the worker — they answer "still
11
+ -- mine?" (presence) and "cancelled?" (the one column). Whole rows here would pull
12
+ -- every payload back on every beat: 256 tasks * 4KB is a megabyte re-read every
13
+ -- lease/3 for the life of the call, and a JSON parse per row to discard it.
14
+ --
15
+ -- New lease (now + :lease_ms) and time come from the DB clock.
16
+ -- params: ids (text[]), worker_id, lease_ms
17
+ update cairnq_tasks
18
+ set lease_until_ms = (extract(epoch from now()) * 1000)::bigint + :lease_ms,
19
+ updated_at_ms = (extract(epoch from now()) * 1000)::bigint
20
+ where id = any(:ids::text[])
21
+ and status = 'running'
22
+ and worker_id = :worker_id
23
+ and lease_until_ms > (extract(epoch from now()) * 1000)::bigint
24
+ returning id, cancel_requested_at_ms;
@@ -0,0 +1,36 @@
1
+ -- claim, for a caller drawing exactly ONE task name. Byte-for-byte claim.sql
2
+ -- except that the name filter is an equality on :name instead of an IN over
3
+ -- :names — a drift-guard test asserts precisely that, so treat claim.sql as the
4
+ -- source and re-derive this file when it changes.
5
+ --
6
+ -- It exists because `name in (select value from json_each(:names))` cannot reach
7
+ -- cairnq_tasks_claim_name_idx, even for a single-element list: SQLite builds a
8
+ -- bloom filter over the subquery and falls back to cairnq_tasks_claim_idx, where
9
+ -- the name is a residual and a draw for a name with nothing queued walks the
10
+ -- whole claimable backlog. Measured on a 20k backlog: 1446us for the json_each
11
+ -- form, 8.8us for this one. See migration 0006.
12
+ --
13
+ -- Used for the per-name quotas a worker draws for names that size themselves —
14
+ -- a `batch`, or their own concurrency. See "Batch delivery" in PROTOCOL.md.
15
+ -- params: queues (JSON array text), name, now_ms, worker_id, lease_until_ms,
16
+ -- limit
17
+ update cairnq_tasks
18
+ set
19
+ status = 'running',
20
+ worker_id = :worker_id,
21
+ lease_until_ms = :lease_until_ms,
22
+ attempt = attempt + 1,
23
+ updated_at_ms = :now_ms
24
+ where id in (
25
+ select id from cairnq_tasks
26
+ where status = 'queued'
27
+ and queue in (select value from json_each(:queues))
28
+ and name = :name
29
+ and run_at_ms <= :now_ms
30
+ -- id breaks created_at_ms ties (same-millisecond submits), so claim order
31
+ -- is deterministic: FIFO at millisecond granularity; within one millisecond
32
+ -- the id's random half decides, stably but not in submit order.
33
+ order by priority desc, created_at_ms asc, id asc
34
+ limit :limit
35
+ )
36
+ returning *;
@@ -0,0 +1,31 @@
1
+ -- claim, for a caller watching ONE queue and drawing ONE task name — the common
2
+ -- shape for a batched worker. Byte-for-byte claim.sql except that both the queue
3
+ -- and the name filters are equalities; a drift-guard test asserts precisely that,
4
+ -- so treat claim.sql as the source and re-derive this file when it changes.
5
+ --
6
+ -- It is the combination of claim_one_queue.sql's queue equality and
7
+ -- claim_one_name.sql's name equality, and each is there for the reason that file
8
+ -- gives. Together they let cairnq_tasks_claim_name_idx be read in claim order
9
+ -- with both leading columns pinned, so the draw is a seek that terminates at
10
+ -- :limit rows however deep the backlog is.
11
+ -- params: queue, name, now_ms, worker_id, lease_until_ms, limit
12
+ update cairnq_tasks
13
+ set
14
+ status = 'running',
15
+ worker_id = :worker_id,
16
+ lease_until_ms = :lease_until_ms,
17
+ attempt = attempt + 1,
18
+ updated_at_ms = :now_ms
19
+ where id in (
20
+ select id from cairnq_tasks
21
+ where status = 'queued'
22
+ and queue = :queue
23
+ and name = :name
24
+ and run_at_ms <= :now_ms
25
+ -- id breaks created_at_ms ties (same-millisecond submits), so claim order
26
+ -- is deterministic: FIFO at millisecond granularity; within one millisecond
27
+ -- the id's random half decides, stably but not in submit order.
28
+ order by priority desc, created_at_ms asc, id asc
29
+ limit :limit
30
+ )
31
+ returning *;
@@ -0,0 +1,29 @@
1
+ -- Extend the lease on several tasks at once — the heartbeat for batch delivery.
2
+ -- Ownership-checked per row, exactly as heartbeat.sql: a task whose lease this
3
+ -- worker no longer holds simply does not come back, so the caller learns which
4
+ -- ones it lost by which ids are absent from the result rather than by an error.
5
+ -- One statement per beat is the point: a batch handler holding 256 leases would
6
+ -- otherwise write 256 rows every heartbeat interval, which on SQLite means 256
7
+ -- turns of the single write lock for work nobody is waiting on.
8
+ --
9
+ -- Returns only what the beat needs, unlike heartbeat.sql's `returning *`: the
10
+ -- singular statement hands its row to the caller as `ctx.heartbeat()`'s public
11
+ -- return value, while this one's rows never leave the worker — they answer "still
12
+ -- mine?" (presence) and "cancelled?" (the one column). Whole rows here would pull
13
+ -- every payload back on every beat: 256 tasks * 4KB is a megabyte re-read every
14
+ -- lease/3 for the life of the call, and a JSON parse per row to discard it.
15
+ -- Unlike heartbeat.sql, this one's plan depends on statistics: json_each() hides
16
+ -- the id list's length, so without sqlite_stat1 the planner drives the update off
17
+ -- cairnq_tasks_status_idx and walks every 'running' row in the database per beat
18
+ -- instead of doing primary-key lookups. Both SDKs ANALYZE on open and revisit
19
+ -- once a minute per connection (see "Planner statistics" in PROTOCOL.md), so this
20
+ -- is a warm-up window rather than a standing cost — but the statement it replaced
21
+ -- had no such dependency, which is why it is called out here.
22
+ -- params: ids (JSON array text), worker_id, now_ms, lease_until_ms
23
+ update cairnq_tasks
24
+ set lease_until_ms = :lease_until_ms, updated_at_ms = :now_ms
25
+ where id in (select value from json_each(:ids))
26
+ and status = 'running'
27
+ and worker_id = :worker_id
28
+ and lease_until_ms > :now_ms
29
+ returning id, cancel_requested_at_ms;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Retry backoff, in its own module because two callers need it.
3
+ *
4
+ * The worker computes it when a handler's failure ends an attempt; TaskContext
5
+ * computes it when a handler fails one task of a batch itself. Keeping it in
6
+ * worker.ts would make context.ts import the module that imports it.
7
+ */
8
+ export declare const DEFAULT_RETRY_BACKOFF_MS = 1000;
9
+ export declare const DEFAULT_RETRY_BACKOFF_MAX_MS = 30000;
10
+ /**
11
+ * Exponential backoff with equal jitter: the window doubles per attempt up to
12
+ * `maxMs`, and the delay lands uniformly in its upper half, `[w/2, w)`.
13
+ *
14
+ * The jitter is what keeps a fleet from retrying in lockstep. Failures align
15
+ * when the downstream fails fast enough that a whole concurrency batch raises
16
+ * at once (connection refused, DNS gone), and capped exponential backoff then
17
+ * *preserves* that alignment — once every task sits at `maxMs`, they all retry
18
+ * on the same beat forever. Spreading over half the window breaks it; keeping
19
+ * the lower half as a floor means jitter never shortens the wait to less than
20
+ * half of what plain exponential backoff would have asked for.
21
+ *
22
+ * `rand` is injected so tests can pin an exact delay.
23
+ */
24
+ export declare function retryDelayMs(attempt: number, baseMs: number, maxMs: number, rand?: () => number): number;
25
+ /**
26
+ * The delay a `fail` write should carry. Not just the backoff: a permanent
27
+ * failure is never re-run, so it always delays 0. Both settlement paths — the
28
+ * worker's and a handler's `ctx.fail` — go through this, so they cannot end up
29
+ * backing off differently.
30
+ */
31
+ export declare function failDelayMs(attempt: number, retryable: boolean, baseMs: number, maxMs: number, rand?: () => number): number;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Retry backoff, in its own module because two callers need it.
3
+ *
4
+ * The worker computes it when a handler's failure ends an attempt; TaskContext
5
+ * computes it when a handler fails one task of a batch itself. Keeping it in
6
+ * worker.ts would make context.ts import the module that imports it.
7
+ */
8
+ export const DEFAULT_RETRY_BACKOFF_MS = 1_000;
9
+ export const DEFAULT_RETRY_BACKOFF_MAX_MS = 30_000;
10
+ /**
11
+ * Exponential backoff with equal jitter: the window doubles per attempt up to
12
+ * `maxMs`, and the delay lands uniformly in its upper half, `[w/2, w)`.
13
+ *
14
+ * The jitter is what keeps a fleet from retrying in lockstep. Failures align
15
+ * when the downstream fails fast enough that a whole concurrency batch raises
16
+ * at once (connection refused, DNS gone), and capped exponential backoff then
17
+ * *preserves* that alignment — once every task sits at `maxMs`, they all retry
18
+ * on the same beat forever. Spreading over half the window breaks it; keeping
19
+ * the lower half as a floor means jitter never shortens the wait to less than
20
+ * half of what plain exponential backoff would have asked for.
21
+ *
22
+ * `rand` is injected so tests can pin an exact delay.
23
+ */
24
+ export function retryDelayMs(attempt, baseMs, maxMs, rand = Math.random) {
25
+ if (baseMs <= 0)
26
+ return 0;
27
+ const exponent = Math.max(0, attempt - 1);
28
+ const window = Math.min(maxMs, baseMs * 2 ** exponent);
29
+ const floor = Math.floor(window / 2);
30
+ return floor + Math.floor(rand() * (window - floor));
31
+ }
32
+ /**
33
+ * The delay a `fail` write should carry. Not just the backoff: a permanent
34
+ * failure is never re-run, so it always delays 0. Both settlement paths — the
35
+ * worker's and a handler's `ctx.fail` — go through this, so they cannot end up
36
+ * backing off differently.
37
+ */
38
+ export function failDelayMs(attempt, retryable, baseMs, maxMs, rand = Math.random) {
39
+ return retryable ? retryDelayMs(attempt, baseMs, maxMs, rand) : 0;
40
+ }
package/dist/client.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BackpressureOptions } from "./backpressure.js";
2
+ import { type RetentionOptions } from "./retention.js";
2
3
  import { type Task, type TaskStatus } from "./models.js";
3
4
  import type { ListInput, PurgeInput, SubmitInput, TaskStore } from "./store/base.js";
4
5
  import { type TaskDef } from "./task.js";
@@ -9,10 +10,17 @@ export interface CallOptions extends SubmitOptions {
9
10
  }
10
11
  /** Options this handle configures on the store it wraps, rather than the
11
12
  * store's own constructor arguments. */
12
- export type ClientOptions = Partial<BackpressureOptions>;
13
+ export type ClientOptions = Partial<BackpressureOptions> & {
14
+ /** Delete terminal tasks older than a cutoff, on a schedule, for as long as
15
+ * this handle is open. Off unless set — and off means rows accumulate forever,
16
+ * because nothing else in CairnQ removes them. */
17
+ retention?: RetentionOptions;
18
+ };
13
19
  /** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
14
20
  export declare class CairnQ {
15
21
  private readonly _store;
22
+ /** null unless `retention` was configured. */
23
+ private readonly sweeper;
16
24
  constructor(_store: TaskStore, opts?: ClientOptions);
17
25
  static sqlite(path: string, opts?: {
18
26
  busyTimeoutMs?: number;
@@ -24,6 +32,8 @@ export declare class CairnQ {
24
32
  } & ClientOptions): CairnQ;
25
33
  get store(): TaskStore;
26
34
  connect(): Promise<void>;
35
+ /** Stop retention (waiting for a sweep in flight, so no purge outlives the
36
+ * store) and close the store. */
27
37
  close(): Promise<void>;
28
38
  /** Enqueue a task. With `maxQueueDepth` configured this blocks while the
29
39
  * target queue is at its limit, and raises QueueFull if it stays there for
@@ -55,13 +65,29 @@ export declare class CairnQ {
55
65
  /** Task counts per queue, keyed by status and zero-filled across all statuses
56
66
  * — `(await stats()).default.queued` is the backlog of a queue. */
57
67
  stats(): Promise<Record<string, Record<TaskStatus, number>>>;
68
+ /** Wait for a task to finish. Resolves with the terminal Task (any status);
69
+ * throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
70
+ * same wait back up — from another process, or after a longer deadline. */
58
71
  wait(taskId: string, opts?: {
59
72
  timeoutMs?: number;
60
73
  pollMs?: number;
61
74
  }): Promise<Task>;
75
+ /** Wait for whatever task the `key` currently points at — the cross-process
76
+ * form of picking a wait back up, when the id was never in hand or the process
77
+ * that held it is gone. Re-resolves the key on each poll, so a `replace`
78
+ * landing mid-wait moves the wait onto the new task, and a key with no task
79
+ * yet is waited for rather than rejected. */
80
+ waitByKey(key: string, opts?: {
81
+ timeoutMs?: number;
82
+ pollMs?: number;
83
+ }): Promise<Task>;
62
84
  /** submit + wait. Resolves with the result on success; rejects with
63
85
  * TaskFailed / TaskCanceled / TaskTimeout otherwise. Pass a TaskDef and the
64
- * resolved value is typed as its Result. */
86
+ * resolved value is typed as its Result.
87
+ *
88
+ * `waitTimeoutMs` bounds the wait, not the task: on timeout the task runs on,
89
+ * and `wait(err.taskId)` — or `waitByKey`, from a process that only has the
90
+ * key — resumes the wait rather than starting the work over. */
65
91
  call(name: string, payload?: unknown, opts?: CallOptions): Promise<unknown>;
66
92
  call<P, R>(task: TaskDef<P, R>, payload?: P, opts?: CallOptions): Promise<R>;
67
93
  }
package/dist/client.js CHANGED
@@ -1,12 +1,15 @@
1
+ import { RetentionSweeper } from "./retention.js";
1
2
  import { TaskCanceled, TaskFailed } from "./errors.js";
2
3
  import { isFailed, isSucceeded } from "./models.js";
3
4
  import { SQLiteStore } from "./store/sqlite.js";
4
5
  import { PostgresStore } from "./store/postgres.js";
5
6
  import { taskName } from "./task.js";
6
- import { pollWait } from "./wait.js";
7
+ import { pollWait, pollWaitByKey } from "./wait.js";
7
8
  /** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
8
9
  export class CairnQ {
9
10
  _store;
11
+ /** null unless `retention` was configured. */
12
+ sweeper;
10
13
  constructor(_store, opts = {}) {
11
14
  this._store = _store;
12
15
  // Installed on the store, not held here: every submit path goes through the
@@ -14,6 +17,13 @@ export class CairnQ {
14
17
  if (opts.maxQueueDepth != null) {
15
18
  _store.useBackpressure(opts);
16
19
  }
20
+ // Retention is the opposite case: it belongs to the handle, because a worker
21
+ // sharing the store must not also be deleting rows behind the API's back.
22
+ // Started here rather than in connect(), which is optional — every other
23
+ // path connects lazily, and retention that silently depends on an optional
24
+ // call is retention that silently does not happen.
25
+ this.sweeper = opts.retention ? new RetentionSweeper(_store, opts.retention) : null;
26
+ this.sweeper?.start();
17
27
  }
18
28
  static sqlite(path, opts = {}) {
19
29
  const { busyTimeoutMs, ...client } = opts;
@@ -31,8 +41,11 @@ export class CairnQ {
31
41
  connect() {
32
42
  return this._store.connect();
33
43
  }
34
- close() {
35
- return this._store.close();
44
+ /** Stop retention (waiting for a sweep in flight, so no purge outlives the
45
+ * store) and close the store. */
46
+ async close() {
47
+ await this.sweeper?.stop();
48
+ await this._store.close();
36
49
  }
37
50
  submit(task, payload, opts = {}) {
38
51
  return this._store.submit({ name: taskName(task), payload, ...opts });
@@ -77,12 +90,26 @@ export class CairnQ {
77
90
  stats() {
78
91
  return this._store.stats();
79
92
  }
93
+ /** Wait for a task to finish. Resolves with the terminal Task (any status);
94
+ * throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
95
+ * same wait back up — from another process, or after a longer deadline. */
80
96
  wait(taskId, opts = {}) {
81
97
  return pollWait(this._store, taskId, {
82
98
  timeoutMs: opts.timeoutMs ?? 30_000,
83
99
  pollMs: opts.pollMs,
84
100
  });
85
101
  }
102
+ /** Wait for whatever task the `key` currently points at — the cross-process
103
+ * form of picking a wait back up, when the id was never in hand or the process
104
+ * that held it is gone. Re-resolves the key on each poll, so a `replace`
105
+ * landing mid-wait moves the wait onto the new task, and a key with no task
106
+ * yet is waited for rather than rejected. */
107
+ waitByKey(key, opts = {}) {
108
+ return pollWaitByKey(this._store, key, {
109
+ timeoutMs: opts.timeoutMs ?? 30_000,
110
+ pollMs: opts.pollMs,
111
+ });
112
+ }
86
113
  async call(task, payload, opts = {}) {
87
114
  const { waitTimeoutMs = 30_000, pollMs, ...submit } = opts;
88
115
  const created = await this.submit(taskName(task), payload, submit);
package/dist/context.d.ts CHANGED
@@ -1,8 +1,20 @@
1
+ import { type FailReason } from "./errors.js";
1
2
  import { type Task } from "./models.js";
2
3
  import type { SubmitOptions } from "./client.js";
3
4
  import type { TaskStore } from "./store/base.js";
4
5
  import { type TaskDef } from "./task.js";
5
- /** Handed to a task handler. Worker-side capabilities mirror the Python SDK. */
6
+ export interface TaskContextOptions {
7
+ retryBackoffMs?: number;
8
+ retryBackoffMaxMs?: number;
9
+ }
10
+ /**
11
+ * Handed to a task handler. Worker-side capabilities mirror the Python SDK.
12
+ *
13
+ * One of these per task, whether a handler is delivered one task or a batch: a
14
+ * batch handler receives a `TaskContext[]`, so a single-task handler's `ctx` is
15
+ * literally the batch-of-one element. Lease, cancellation and settlement are per
16
+ * task, which is why they live here rather than on anything batch-shaped.
17
+ */
6
18
  export declare class TaskContext {
7
19
  private readonly store;
8
20
  private readonly task;
@@ -11,7 +23,10 @@ export declare class TaskContext {
11
23
  private readonly abort;
12
24
  private leaseLost;
13
25
  private cancelSeen;
14
- constructor(store: TaskStore, task: Task, workerId: string, leaseMs: number);
26
+ private isSettled;
27
+ private readonly backoffMs;
28
+ private readonly backoffMaxMs;
29
+ constructor(store: TaskStore, task: Task, workerId: string, leaseMs: number, opts?: TaskContextOptions);
15
30
  get taskId(): string;
16
31
  get name(): string;
17
32
  get queue(): string;
@@ -20,6 +35,14 @@ export declare class TaskContext {
20
35
  get rootId(): string | null;
21
36
  get correlationId(): string | null;
22
37
  get payload(): any;
38
+ /**
39
+ * True once this task reached a terminal state — whether the handler settled
40
+ * it with succeed()/fail() or the worker settled it on the handler's behalf.
41
+ * The heartbeat and the settlement paths both read it.
42
+ */
43
+ get settled(): boolean;
44
+ /** @internal Called by the worker when it finalizes this task itself. */
45
+ markSettled(): void;
23
46
  /**
24
47
  * True once this worker has lost the task's lease — it expired and another
25
48
  * worker reclaimed it. Nothing this handler writes will be recorded any more
@@ -32,11 +55,34 @@ export declare class TaskContext {
32
55
  /** @internal Called by the worker when an owned write reports a lost lease. */
33
56
  markLeaseLost(): void;
34
57
  private observe;
58
+ /**
59
+ * @internal The same observation from just the flag, for a caller that read it
60
+ * without materializing a Task — the shared heartbeat, whose statement returns
61
+ * only the id and the cancel column precisely so it does not have to drag
62
+ * every payload back on every beat.
63
+ */
64
+ observeCancel(cancelRequested: boolean): void;
35
65
  private owned;
36
66
  progress(value: number | null, message?: string | null): Promise<Task>;
37
67
  heartbeat(): Promise<Task>;
38
68
  /** Cooperative cancel check. Free once a heartbeat has already seen the flag. */
39
69
  canceled(): Promise<boolean>;
70
+ /**
71
+ * Finalize this task as succeeded, now, without waiting for the handler to
72
+ * return. `complete` semantics: a cancel requested while it ran wins and the
73
+ * task finalizes as canceled instead, its result discarded. Returns null if
74
+ * this task was already settled.
75
+ */
76
+ succeed(result?: unknown): Promise<Task | null>;
77
+ /**
78
+ * Finalize this task as failed, now. `error` may be a string reason, an Error,
79
+ * a TaskError (which carries its own retryability), or a ready envelope.
80
+ * Retryable failures get the worker's backoff and are re-queued while attempts
81
+ * remain, exactly as a thrown error would be. Returns null if already settled.
82
+ */
83
+ fail(error?: FailReason, opts?: {
84
+ retryable?: boolean;
85
+ }): Promise<Task | null>;
40
86
  /** Submit a child task; parent/root/correlation are wired automatically. */
41
87
  submit(name: string, payload?: unknown, opts?: SubmitOptions): Promise<Task>;
42
88
  submit<P, R>(task: TaskDef<P, R>, payload?: P, opts?: SubmitOptions): Promise<Task>;