cairnq 0.6.0 → 0.8.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 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
  ```
@@ -72,8 +74,13 @@ const worker = Worker.sqlite("tasks.db", {
72
74
  onError: (err, info) => log.warn({ err, ...info }), // claims/writes the loop survived
73
75
  });
74
76
 
75
- // Nothing else deletes rows. Sweep terminal tasks on a schedule.
76
- 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. A
79
+ // per-status map keeps each status on its own clock (statuses left out are
80
+ // never swept): spent results go in minutes, failures stay for diagnosis.
81
+ const tasks = CairnQ.sqlite("tasks.db", {
82
+ retention: { olderThanMs: { succeeded: 300_000, failed: 7 * 24 * 3600_000 } },
83
+ });
77
84
  ```
78
85
 
79
86
  A handler that does real side effects should bail out when it loses its lease —
@@ -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
- -- params: older_than_ms, limit
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
- -- params: before_ms, limit
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,18 +1,29 @@
1
1
  import type { BackpressureOptions } from "./backpressure.js";
2
- import { type Task, type TaskStatus } from "./models.js";
2
+ import { type RetentionOptions } from "./retention.js";
3
+ import { type Task, type TaskRef, 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";
6
+ import { type PollOptions } from "./wait.js";
5
7
  export type SubmitOptions = Omit<SubmitInput, "name" | "payload">;
6
- export interface CallOptions extends SubmitOptions {
8
+ /** The wait loop's knobs with the timeout optional (default 30s) — the public
9
+ * face of PollOptions, whose comments document each knob. */
10
+ export type WaitOptions = Partial<PollOptions>;
11
+ export interface CallOptions extends SubmitOptions, Omit<WaitOptions, "timeoutMs"> {
7
12
  waitTimeoutMs?: number;
8
- pollMs?: number;
9
13
  }
10
14
  /** Options this handle configures on the store it wraps, rather than the
11
15
  * store's own constructor arguments. */
12
- export type ClientOptions = Partial<BackpressureOptions>;
16
+ export type ClientOptions = Partial<BackpressureOptions> & {
17
+ /** Delete terminal tasks older than a cutoff, on a schedule, for as long as
18
+ * this handle is open. Off unless set — and off means rows accumulate forever,
19
+ * because nothing else in CairnQ removes them. */
20
+ retention?: RetentionOptions;
21
+ };
13
22
  /** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
14
23
  export declare class CairnQ {
15
24
  private readonly _store;
25
+ /** null unless `retention` was configured. */
26
+ private readonly sweeper;
16
27
  constructor(_store: TaskStore, opts?: ClientOptions);
17
28
  static sqlite(path: string, opts?: {
18
29
  busyTimeoutMs?: number;
@@ -24,6 +35,8 @@ export declare class CairnQ {
24
35
  } & ClientOptions): CairnQ;
25
36
  get store(): TaskStore;
26
37
  connect(): Promise<void>;
38
+ /** Stop retention (waiting for a sweep in flight, so no purge outlives the
39
+ * store) and close the store. */
27
40
  close(): Promise<void>;
28
41
  /** Enqueue a task. With `maxQueueDepth` configured this blocks while the
29
42
  * target queue is at its limit, and raises QueueFull if it stays there for
@@ -38,6 +51,11 @@ export declare class CairnQ {
38
51
  queueDepth(queue: string, maxDepth: number): Promise<number>;
39
52
  get(taskId: string): Promise<Task | null>;
40
53
  getByKey(key: string): Promise<Task | null>;
54
+ /** The status-only probe wait polls on: id + status, no payload. Public for
55
+ * the same reason it exists — a dashboard or poller that only asks "is it
56
+ * finished yet" should not drag the payload back per ask. */
57
+ getStatus(taskId: string): Promise<TaskRef | null>;
58
+ getStatusByKey(key: string): Promise<TaskRef | null>;
41
59
  list(input?: ListInput): Promise<Task[]>;
42
60
  cancel(taskId: string): Promise<Task | null>;
43
61
  cancelByKey(key: string): Promise<Task | null>;
@@ -55,13 +73,23 @@ export declare class CairnQ {
55
73
  /** Task counts per queue, keyed by status and zero-filled across all statuses
56
74
  * — `(await stats()).default.queued` is the backlog of a queue. */
57
75
  stats(): Promise<Record<string, Record<TaskStatus, number>>>;
58
- wait(taskId: string, opts?: {
59
- timeoutMs?: number;
60
- pollMs?: number;
61
- }): Promise<Task>;
76
+ /** Wait for a task to finish. Resolves with the terminal Task (any status);
77
+ * throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
78
+ * same wait back up — from another process, or after a longer deadline. */
79
+ wait(taskId: string, opts?: WaitOptions): Promise<Task>;
80
+ /** Wait for whatever task the `key` currently points at — the cross-process
81
+ * form of picking a wait back up, when the id was never in hand or the process
82
+ * that held it is gone. Re-resolves the key on each poll, so a `replace`
83
+ * landing mid-wait moves the wait onto the new task, and a key with no task
84
+ * yet is waited for rather than rejected. */
85
+ waitByKey(key: string, opts?: WaitOptions): Promise<Task>;
62
86
  /** submit + wait. Resolves with the result on success; rejects with
63
87
  * TaskFailed / TaskCanceled / TaskTimeout otherwise. Pass a TaskDef and the
64
- * resolved value is typed as its Result. */
88
+ * resolved value is typed as its Result.
89
+ *
90
+ * `waitTimeoutMs` bounds the wait, not the task: on timeout the task runs on,
91
+ * and `wait(err.taskId)` — or `waitByKey`, from a process that only has the
92
+ * key — resumes the wait rather than starting the work over. */
65
93
  call(name: string, payload?: unknown, opts?: CallOptions): Promise<unknown>;
66
94
  call<P, R>(task: TaskDef<P, R>, payload?: P, opts?: CallOptions): Promise<R>;
67
95
  }
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 { DEFAULT_WAIT_TIMEOUT_MS, 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 });
@@ -50,6 +63,15 @@ export class CairnQ {
50
63
  getByKey(key) {
51
64
  return this._store.getByKey(key);
52
65
  }
66
+ /** The status-only probe wait polls on: id + status, no payload. Public for
67
+ * the same reason it exists — a dashboard or poller that only asks "is it
68
+ * finished yet" should not drag the payload back per ask. */
69
+ getStatus(taskId) {
70
+ return this._store.getStatus(taskId);
71
+ }
72
+ getStatusByKey(key) {
73
+ return this._store.getStatusByKey(key);
74
+ }
53
75
  list(input) {
54
76
  return this._store.list(input);
55
77
  }
@@ -77,16 +99,32 @@ export class CairnQ {
77
99
  stats() {
78
100
  return this._store.stats();
79
101
  }
102
+ /** Wait for a task to finish. Resolves with the terminal Task (any status);
103
+ * throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
104
+ * same wait back up — from another process, or after a longer deadline. */
80
105
  wait(taskId, opts = {}) {
106
+ // `??`, not a spread default: a caller forwarding `timeoutMs: undefined`
107
+ // (call() does) must still get the default, and a spread would override it.
81
108
  return pollWait(this._store, taskId, {
82
- timeoutMs: opts.timeoutMs ?? 30_000,
83
- pollMs: opts.pollMs,
109
+ ...opts,
110
+ timeoutMs: opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS,
111
+ });
112
+ }
113
+ /** Wait for whatever task the `key` currently points at — the cross-process
114
+ * form of picking a wait back up, when the id was never in hand or the process
115
+ * that held it is gone. Re-resolves the key on each poll, so a `replace`
116
+ * landing mid-wait moves the wait onto the new task, and a key with no task
117
+ * yet is waited for rather than rejected. */
118
+ waitByKey(key, opts = {}) {
119
+ return pollWaitByKey(this._store, key, {
120
+ ...opts,
121
+ timeoutMs: opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS,
84
122
  });
85
123
  }
86
124
  async call(task, payload, opts = {}) {
87
- const { waitTimeoutMs = 30_000, pollMs, ...submit } = opts;
125
+ const { waitTimeoutMs, pollMs, maxPollMs, ...submit } = opts;
88
126
  const created = await this.submit(taskName(task), payload, submit);
89
- const final = await pollWait(this._store, created.id, { timeoutMs: waitTimeoutMs, pollMs });
127
+ const final = await this.wait(created.id, { timeoutMs: waitTimeoutMs, pollMs, maxPollMs });
90
128
  if (isSucceeded(final))
91
129
  return final.result;
92
130
  if (isFailed(final))
package/dist/errors.d.ts CHANGED
@@ -35,16 +35,24 @@ export declare class QueueFull extends CairnQError {
35
35
  waitedMs: number;
36
36
  constructor(queue: string, maxDepth: number, waitedMs: number);
37
37
  }
38
- /** wait/call did not reach a terminal status in time. The task keeps running.
39
- * `task` is the last snapshot wait() observed (null if get() found nothing), and
40
- * the message says what state it was stuck in a queued-never-claimed task is
41
- * the classic first-run failure (no worker, no handler, wrong queue or file). */
38
+ /** wait/call did not reach a terminal status in time. The task keeps running, so
39
+ * `taskId` is the handle for picking the wait back up `wait(err.taskId)`
40
+ * re-attaches to the same task from anywhere that can reach the store. `task` is
41
+ * the last snapshot wait() observed (null if the lookup found nothing), and the
42
+ * message says what state it was stuck in — a queued-never-claimed task is the
43
+ * classic first-run failure (no worker, no handler, wrong queue or file).
44
+ *
45
+ * `key` is set when the wait watched a key rather than an id; `taskId` is then
46
+ * the task the key pointed at, or the key itself when it pointed at nothing —
47
+ * there was no id to report. */
42
48
  export declare class TaskTimeout extends CairnQError {
43
49
  taskId: string;
44
50
  readonly task: Task | null;
51
+ readonly key: string | null;
45
52
  constructor(taskId: string, opts?: {
46
53
  timeoutMs?: number;
47
54
  task?: Task | null;
55
+ key?: string | null;
48
56
  });
49
57
  }
50
58
  /** A waited-on task ended in `failed`. The envelope's fields are unpacked onto the
@@ -62,6 +70,29 @@ export declare class TaskCanceled extends CairnQError {
62
70
  taskId: string;
63
71
  constructor(taskId: string);
64
72
  }
73
+ /**
74
+ * A heartbeat beat came back later than its own interval allowed.
75
+ *
76
+ * The heartbeat shares the event loop with the handlers whose leases it renews,
77
+ * so a handler that blocks the loop stops the renewal with it: the lease expires,
78
+ * the task is recovered and redelivered, and a second worker starts computing
79
+ * what the first is still computing — one task, billed twice, with no error
80
+ * anywhere. Nothing inside the blocked handler can observe that, which is why it
81
+ * is reported through `onError` alongside the other things the run loop survived.
82
+ *
83
+ * The usual cause is synchronous work in a handler: a tight loop, a large
84
+ * JSON.parse, a `*Sync` filesystem or crypto call. Node has one loop and no way
85
+ * to preempt it — move the work to a worker thread, a child process, or an async
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.
89
+ */
90
+ export declare class EventLoopBlocked extends CairnQError {
91
+ readonly lateMs: number;
92
+ readonly intervalMs: number;
93
+ readonly leaseMs: number;
94
+ constructor(lateMs: number, intervalMs: number, leaseMs: number);
95
+ }
65
96
  /** A worker write affected 0 rows: the lease expired and was reclaimed. */
66
97
  export declare class LostLease extends CairnQError {
67
98
  taskId: string;
package/dist/errors.js CHANGED
@@ -67,9 +67,12 @@ export class QueueFull extends CairnQError {
67
67
  * observed. No worker running, no handler for the name, wrong queue, and two
68
68
  * processes on different database files all look identical from the API side —
69
69
  * queued, never claimed — so that case names the likely causes. */
70
- function timeoutDetail(task) {
71
- if (!task)
72
- return "task not found — wrong database file, or already purged?";
70
+ function timeoutDetail(task, key) {
71
+ if (!task) {
72
+ return key === null
73
+ ? "task not found — wrong database file, or already purged?"
74
+ : "no task under this key — never submitted, or already purged?";
75
+ }
73
76
  if (isQueued(task)) {
74
77
  const delayMs = task.run_at_ms - nowMs();
75
78
  if (task.attempt === 0 && delayMs <= 0) {
@@ -83,20 +86,30 @@ function timeoutDetail(task) {
83
86
  return "cancel requested, waiting for the handler to observe it";
84
87
  return `still running (attempt ${task.attempt}/${task.max_attempts})`;
85
88
  }
86
- /** wait/call did not reach a terminal status in time. The task keeps running.
87
- * `task` is the last snapshot wait() observed (null if get() found nothing), and
88
- * the message says what state it was stuck in a queued-never-claimed task is
89
- * the classic first-run failure (no worker, no handler, wrong queue or file). */
89
+ /** wait/call did not reach a terminal status in time. The task keeps running, so
90
+ * `taskId` is the handle for picking the wait back up `wait(err.taskId)`
91
+ * re-attaches to the same task from anywhere that can reach the store. `task` is
92
+ * the last snapshot wait() observed (null if the lookup found nothing), and the
93
+ * message says what state it was stuck in — a queued-never-claimed task is the
94
+ * classic first-run failure (no worker, no handler, wrong queue or file).
95
+ *
96
+ * `key` is set when the wait watched a key rather than an id; `taskId` is then
97
+ * the task the key pointed at, or the key itself when it pointed at nothing —
98
+ * there was no id to report. */
90
99
  export class TaskTimeout extends CairnQError {
91
100
  taskId;
92
101
  task;
102
+ key;
93
103
  constructor(taskId, opts = {}) {
104
+ const key = opts.key ?? null;
105
+ const subject = key === null ? `task ${taskId}` : `key ${key}`;
94
106
  super(opts.timeoutMs == null
95
- ? `task ${taskId} did not finish in time`
96
- : `task ${taskId} did not finish within ${opts.timeoutMs}ms: ${timeoutDetail(opts.task ?? null)}`);
107
+ ? `${subject} did not finish in time`
108
+ : `${subject} did not finish within ${opts.timeoutMs}ms: ${timeoutDetail(opts.task ?? null, key)}`);
97
109
  this.taskId = taskId;
98
110
  this.name = "TaskTimeout";
99
111
  this.task = opts.task ?? null;
112
+ this.key = key;
100
113
  }
101
114
  }
102
115
  /** A waited-on task ended in `failed`. The envelope's fields are unpacked onto the
@@ -127,6 +140,38 @@ export class TaskCanceled extends CairnQError {
127
140
  this.name = "TaskCanceled";
128
141
  }
129
142
  }
143
+ /**
144
+ * A heartbeat beat came back later than its own interval allowed.
145
+ *
146
+ * The heartbeat shares the event loop with the handlers whose leases it renews,
147
+ * so a handler that blocks the loop stops the renewal with it: the lease expires,
148
+ * the task is recovered and redelivered, and a second worker starts computing
149
+ * what the first is still computing — one task, billed twice, with no error
150
+ * anywhere. Nothing inside the blocked handler can observe that, which is why it
151
+ * is reported through `onError` alongside the other things the run loop survived.
152
+ *
153
+ * The usual cause is synchronous work in a handler: a tight loop, a large
154
+ * JSON.parse, a `*Sync` filesystem or crypto call. Node has one loop and no way
155
+ * to preempt it — move the work to a worker thread, a child process, or an async
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.
159
+ */
160
+ export class EventLoopBlocked extends CairnQError {
161
+ lateMs;
162
+ intervalMs;
163
+ leaseMs;
164
+ constructor(lateMs, intervalMs, leaseMs) {
165
+ super(`heartbeat beat was ${lateMs}ms late (interval ${intervalMs}ms, lease ${leaseMs}ms): ` +
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.`);
169
+ this.lateMs = lateMs;
170
+ this.intervalMs = intervalMs;
171
+ this.leaseMs = leaseMs;
172
+ this.name = "EventLoopBlocked";
173
+ }
174
+ }
130
175
  /** A worker write affected 0 rows: the lease expired and was reclaimed. */
131
176
  export class LostLease extends CairnQError {
132
177
  taskId;
package/dist/index.d.ts CHANGED
@@ -1,7 +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
+ export { RetentionSweeper } from "./retention.js";
6
+ export type { RetentionCutoffs, RetentionOptions } from "./retention.js";
5
7
  export { Worker } from "./worker.js";
6
8
  export type { BatchHandler, Handler, TypedHandler, WorkerOptions } from "./worker.js";
7
9
  export { TaskContext } from "./context.js";
@@ -12,7 +14,7 @@ export { SQLiteStore } from "./store/sqlite.js";
12
14
  export { PostgresStore } from "./store/postgres.js";
13
15
  export { TaskStore } from "./store/base.js";
14
16
  export type { ListInput, PurgeInput, SubmitInput, Conflict } from "./store/base.js";
15
- export type { Task, TaskStatus } from "./models.js";
16
- export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
17
- export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
17
+ export type { Task, TaskRef, TaskStatus, TerminalStatus } from "./models.js";
18
+ export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
19
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
18
20
  export type { FailReason } from "./errors.js";
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  export { CairnQ } from "./client.js";
2
2
  export { QueueDepthGate } from "./backpressure.js";
3
+ export { RetentionSweeper } from "./retention.js";
3
4
  export { Worker } from "./worker.js";
4
5
  export { TaskContext } from "./context.js";
5
6
  export { defineTask } from "./task.js";
6
7
  export { SQLiteStore } from "./store/sqlite.js";
7
8
  export { PostgresStore } from "./store/postgres.js";
8
9
  export { TaskStore } from "./store/base.js";
9
- export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
10
- export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
10
+ export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
11
+ 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
- export declare const TERMINAL: TaskStatus[];
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
- export declare function isTerminal(task: Task): boolean;
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 TERMINAL.includes(task.status);
31
+ return isTerminalStatus(task.status);
21
32
  }
22
33
  export function cancelRequested(task) {
23
34
  return task.cancel_requested_at_ms != null;
@@ -0,0 +1,72 @@
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>>;
6
+ export interface RetentionOptions {
7
+ /**
8
+ * How long a terminal task is kept after it finished. Required: there is no
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 }`.
15
+ */
16
+ olderThanMs: number | RetentionCutoffs;
17
+ /** Time between sweeps. Default 3_600_000 (one hour). */
18
+ intervalMs?: number;
19
+ /** Rows deleted per statement while draining. Default 1_000. */
20
+ limit?: number;
21
+ /**
22
+ * Called for a sweep that threw. The next sweep runs on schedule regardless —
23
+ * a purge that failed because the database was busy is not a reason to stop
24
+ * retaining — so without this a store quietly stops being swept. Must not throw.
25
+ */
26
+ onError?: (err: unknown) => void;
27
+ }
28
+ /**
29
+ * Deletes terminal tasks on a schedule, for as long as the handle is open.
30
+ *
31
+ * `purge` exists because nothing else in CairnQ removes rows, and a queue whose
32
+ * payloads carry real data — an image, a document, a batch of embeddings — turns
33
+ * that into a disk leak measured in gigabytes per backfill. Every deployment
34
+ * that runs longer than a demo needs the sweep; leaving it to an external
35
+ * scheduler means the leak is the default and remembering is the opt-in.
36
+ *
37
+ * It sweeps in bounded batches with a yield between them, so draining a backlog
38
+ * that accumulated while nothing was sweeping stays a sequence of short writes
39
+ * rather than one long one — on SQLite that matters, since a long write holds
40
+ * the single write lock against every producer and worker on the file.
41
+ */
42
+ export declare class RetentionSweeper {
43
+ private readonly store;
44
+ private readonly opts;
45
+ /** Whether the scheduled loop is running. */
46
+ private active;
47
+ /** Set by stop(), so a drain in progress can cut itself short too. */
48
+ private stopping;
49
+ /** Resolves the current sleep early, so stop() need not wait out an interval. */
50
+ private wake;
51
+ /** The loop itself, awaited by stop() so no purge outlives the store. */
52
+ private loop;
53
+ private readonly intervalMs;
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;
58
+ constructor(store: TaskStore, opts: RetentionOptions);
59
+ start(): void;
60
+ /** Stop sweeping and wait for the sweep in flight, if any. */
61
+ stop(): Promise<void>;
62
+ private run;
63
+ /**
64
+ * Delete everything past the cutoff now, in bounded batches, and return how
65
+ * many rows went. The scheduled loop calls this; call it directly to drain on
66
+ * demand — after a backfill, or from a maintenance command.
67
+ */
68
+ sweep(): Promise<number>;
69
+ /** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
70
+ * pending sweep must never be the reason a process refuses to exit. */
71
+ private sleep;
72
+ }