cairnq 0.6.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.
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,11 @@ 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.
79
+ const tasks = CairnQ.sqlite("tasks.db", {
80
+ retention: { olderThanMs: 7 * 24 * 3600_000 },
81
+ });
77
82
  ```
78
83
 
79
84
  A handler that does real side effects should bail out when it loses its lease —
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/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,27 @@ 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 cause is always 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.
87
+ */
88
+ export declare class EventLoopBlocked extends CairnQError {
89
+ readonly lateMs: number;
90
+ readonly intervalMs: number;
91
+ readonly leaseMs: number;
92
+ constructor(lateMs: number, intervalMs: number, leaseMs: number);
93
+ }
65
94
  /** A worker write affected 0 rows: the lease expired and was reclaimed. */
66
95
  export declare class LostLease extends CairnQError {
67
96
  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,35 @@ 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 cause is always 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.
157
+ */
158
+ export class EventLoopBlocked extends CairnQError {
159
+ lateMs;
160
+ intervalMs;
161
+ leaseMs;
162
+ constructor(lateMs, intervalMs, leaseMs) {
163
+ super(`heartbeat beat was ${lateMs}ms late (interval ${intervalMs}ms, lease ${leaseMs}ms): ` +
164
+ `the event loop was blocked long enough to miss a beat. Synchronous work in a ` +
165
+ `handler starves lease renewal — move it off the loop.`);
166
+ this.lateMs = lateMs;
167
+ this.intervalMs = intervalMs;
168
+ this.leaseMs = leaseMs;
169
+ this.name = "EventLoopBlocked";
170
+ }
171
+ }
130
172
  /** A worker write affected 0 rows: the lease expired and was reclaimed. */
131
173
  export class LostLease extends CairnQError {
132
174
  taskId;
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { CairnQ } from "./client.js";
2
2
  export type { CallOptions, ClientOptions, SubmitOptions } 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 { 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";
@@ -14,5 +16,5 @@ export { TaskStore } from "./store/base.js";
14
16
  export type { ListInput, PurgeInput, SubmitInput, Conflict } from "./store/base.js";
15
17
  export type { Task, TaskStatus } from "./models.js";
16
18
  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";
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,5 +1,6 @@
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";
@@ -7,4 +8,4 @@ export { SQLiteStore } from "./store/sqlite.js";
7
8
  export { PostgresStore } from "./store/postgres.js";
8
9
  export { TaskStore } from "./store/base.js";
9
10
  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";
11
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
@@ -0,0 +1,60 @@
1
+ import type { TaskStore } from "./store/base.js";
2
+ export interface RetentionOptions {
3
+ /**
4
+ * How long a terminal task is kept after it finished. Required: there is no
5
+ * safe default for how long someone else's results stay readable.
6
+ */
7
+ olderThanMs: number;
8
+ /** Time between sweeps. Default 3_600_000 (one hour). */
9
+ intervalMs?: number;
10
+ /** Rows deleted per statement while draining. Default 1_000. */
11
+ limit?: number;
12
+ /**
13
+ * Called for a sweep that threw. The next sweep runs on schedule regardless —
14
+ * a purge that failed because the database was busy is not a reason to stop
15
+ * retaining — so without this a store quietly stops being swept. Must not throw.
16
+ */
17
+ onError?: (err: unknown) => void;
18
+ }
19
+ /**
20
+ * Deletes terminal tasks on a schedule, for as long as the handle is open.
21
+ *
22
+ * `purge` exists because nothing else in CairnQ removes rows, and a queue whose
23
+ * payloads carry real data — an image, a document, a batch of embeddings — turns
24
+ * that into a disk leak measured in gigabytes per backfill. Every deployment
25
+ * that runs longer than a demo needs the sweep; leaving it to an external
26
+ * scheduler means the leak is the default and remembering is the opt-in.
27
+ *
28
+ * It sweeps in bounded batches with a yield between them, so draining a backlog
29
+ * that accumulated while nothing was sweeping stays a sequence of short writes
30
+ * rather than one long one — on SQLite that matters, since a long write holds
31
+ * the single write lock against every producer and worker on the file.
32
+ */
33
+ export declare class RetentionSweeper {
34
+ private readonly store;
35
+ private readonly opts;
36
+ /** Whether the scheduled loop is running. */
37
+ private active;
38
+ /** Set by stop(), so a drain in progress can cut itself short too. */
39
+ private stopping;
40
+ /** Resolves the current sleep early, so stop() need not wait out an interval. */
41
+ private wake;
42
+ /** The loop itself, awaited by stop() so no purge outlives the store. */
43
+ private loop;
44
+ private readonly intervalMs;
45
+ private readonly purgeInput;
46
+ constructor(store: TaskStore, opts: RetentionOptions);
47
+ start(): void;
48
+ /** Stop sweeping and wait for the sweep in flight, if any. */
49
+ stop(): Promise<void>;
50
+ private run;
51
+ /**
52
+ * Delete everything past the cutoff now, in bounded batches, and return how
53
+ * many rows went. The scheduled loop calls this; call it directly to drain on
54
+ * demand — after a backfill, or from a maintenance command.
55
+ */
56
+ sweep(): Promise<number>;
57
+ /** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
58
+ * pending sweep must never be the reason a process refuses to exit. */
59
+ private sleep;
60
+ }
@@ -0,0 +1,115 @@
1
+ /** Sweep every hour unless asked otherwise — often enough that a queue with a
2
+ * day of retention never carries more than an hour of extra rows, rare enough
3
+ * that the sweep is invisible next to the task traffic. */
4
+ const DEFAULT_INTERVAL_MS = 3_600_000;
5
+ /** Rows per purge statement. The same bound `purge` defaults to: big enough that
6
+ * a backlog drains in few statements, small enough that each is a short write. */
7
+ const DEFAULT_LIMIT = 1_000;
8
+ /**
9
+ * Deletes terminal tasks on a schedule, for as long as the handle is open.
10
+ *
11
+ * `purge` exists because nothing else in CairnQ removes rows, and a queue whose
12
+ * payloads carry real data — an image, a document, a batch of embeddings — turns
13
+ * that into a disk leak measured in gigabytes per backfill. Every deployment
14
+ * that runs longer than a demo needs the sweep; leaving it to an external
15
+ * scheduler means the leak is the default and remembering is the opt-in.
16
+ *
17
+ * It sweeps in bounded batches with a yield between them, so draining a backlog
18
+ * that accumulated while nothing was sweeping stays a sequence of short writes
19
+ * rather than one long one — on SQLite that matters, since a long write holds
20
+ * the single write lock against every producer and worker on the file.
21
+ */
22
+ export class RetentionSweeper {
23
+ store;
24
+ opts;
25
+ /** Whether the scheduled loop is running. */
26
+ active = false;
27
+ /** Set by stop(), so a drain in progress can cut itself short too. */
28
+ stopping = false;
29
+ /** Resolves the current sleep early, so stop() need not wait out an interval. */
30
+ wake = null;
31
+ /** The loop itself, awaited by stop() so no purge outlives the store. */
32
+ loop = null;
33
+ intervalMs;
34
+ purgeInput;
35
+ constructor(store, opts) {
36
+ this.store = store;
37
+ this.opts = opts;
38
+ if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) {
39
+ throw new Error(`retention.olderThanMs must be >= 0, got ${opts.olderThanMs}`);
40
+ }
41
+ this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
42
+ if (!Number.isFinite(this.intervalMs) || this.intervalMs < 1) {
43
+ throw new Error(`retention.intervalMs must be >= 1, got ${this.intervalMs}`);
44
+ }
45
+ this.purgeInput = { olderThanMs: opts.olderThanMs, limit: opts.limit ?? DEFAULT_LIMIT };
46
+ }
47
+ start() {
48
+ if (this.active)
49
+ return;
50
+ this.active = true;
51
+ this.stopping = false;
52
+ this.loop = this.run();
53
+ }
54
+ /** Stop sweeping and wait for the sweep in flight, if any. */
55
+ async stop() {
56
+ this.stopping = true;
57
+ this.active = false;
58
+ this.wake?.();
59
+ await this.loop;
60
+ this.loop = null;
61
+ }
62
+ async run() {
63
+ // Sleep first: a process that restarts often would otherwise purge on every
64
+ // boot, which is a write burst exactly when the store is busiest.
65
+ while (!this.stopping) {
66
+ await this.sleep(this.intervalMs);
67
+ if (this.stopping)
68
+ return;
69
+ try {
70
+ await this.sweep();
71
+ }
72
+ catch (err) {
73
+ try {
74
+ this.opts.onError?.(err);
75
+ }
76
+ catch {
77
+ // A reporting hook must never take the sweep down with it — the same
78
+ // rule the worker's onError follows.
79
+ }
80
+ }
81
+ }
82
+ }
83
+ /**
84
+ * Delete everything past the cutoff now, in bounded batches, and return how
85
+ * many rows went. The scheduled loop calls this; call it directly to drain on
86
+ * demand — after a backfill, or from a maintenance command.
87
+ */
88
+ async sweep() {
89
+ const limit = this.purgeInput.limit;
90
+ let deleted = 0;
91
+ for (;;) {
92
+ const ids = await this.store.purge(this.purgeInput);
93
+ deleted += ids.length;
94
+ if (ids.length < limit || this.stopping)
95
+ return deleted;
96
+ // Hand the loop back between batches: a large drain must not starve the
97
+ // submits and claims sharing this process.
98
+ await this.sleep(0);
99
+ }
100
+ }
101
+ /** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
102
+ * pending sweep must never be the reason a process refuses to exit. */
103
+ sleep(ms) {
104
+ return new Promise((resolve) => {
105
+ const timer = setTimeout(resolve, ms);
106
+ timer.unref?.();
107
+ this.wake = () => {
108
+ clearTimeout(timer);
109
+ resolve();
110
+ };
111
+ }).finally(() => {
112
+ this.wake = null;
113
+ });
114
+ }
115
+ }
@@ -11,7 +11,7 @@ export declare function dumpJson(value: unknown): string;
11
11
  * The supported major is a protocol fact, not a dialect one — every backend
12
12
  * checks it here so the constant can't fork per store. */
13
13
  export declare function checkProtocolVersion(version: number): void;
14
- declare const CONFLICTS: readonly ["reuse", "reject", "replace"];
14
+ declare const CONFLICTS: readonly ["reuse", "reuse-succeeded", "reject", "replace"];
15
15
  export type Conflict = (typeof CONFLICTS)[number];
16
16
  /** The queue a submit lands on when it names none. Owned here, where the
17
17
  * default is applied, so nothing above has to re-derive it. */
@@ -1,6 +1,6 @@
1
1
  import { newId } from "../ids.js";
2
2
  import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch, SerializationError, } from "../errors.js";
3
- import { rowToTask, STATUSES } from "../models.js";
3
+ import { rowToTask, STATUSES, TERMINAL } from "../models.js";
4
4
  import { QueueDepthGate } from "../backpressure.js";
5
5
  const rejectMangled = function (_key, v) {
6
6
  if (typeof v === "number" && !Number.isFinite(v)) {
@@ -51,7 +51,24 @@ export function checkProtocolVersion(version) {
51
51
  // CONFLICTS is the canonical declaration; the type derives from it so the
52
52
  // runtime guard in submit() and the type can't drift apart (same pattern as
53
53
  // STATUSES/TaskStatus in models.ts).
54
- const CONFLICTS = ["reuse", "reject", "replace"];
54
+ const CONFLICTS = ["reuse", "reuse-succeeded", "reject", "replace"];
55
+ /**
56
+ * Whether a keyed submit's strategy accepts the task the key already points at.
57
+ *
58
+ * Both reuse strategies deduplicate work that is still in play — that is what a
59
+ * key is for, and the answer cannot depend on the outcome of a task that has no
60
+ * outcome yet. They differ only on what a *finished* task means: `reuse` treats
61
+ * the key as free again, while `reuse-succeeded` reads a succeeded task as a
62
+ * cached result. Neither ever hands back a failed or canceled one, which would
63
+ * poison the key for every later submit (see PROTOCOL.md "Key conflict").
64
+ */
65
+ function reusable(conflict, status) {
66
+ if (conflict === "replace")
67
+ return false;
68
+ if (!TERMINAL.includes(status))
69
+ return true;
70
+ return conflict === "reuse-succeeded" && status === "succeeded";
71
+ }
55
72
  /** The queue a submit lands on when it names none. Owned here, where the
56
73
  * default is applied, so nothing above has to re-derive it. */
57
74
  export const DEFAULT_QUEUE = "default";
@@ -207,12 +224,17 @@ export class TaskStore {
207
224
  // free after all, whatever the strategy.
208
225
  const current = (await fetch("get", { id: existing[0].task_id }))[0];
209
226
  if (current) {
210
- if (conflict === "reuse")
211
- return rowToTask(current);
212
227
  if (conflict === "reject")
213
228
  throw new AlreadyExists(key);
214
- // "replace": cancel the recorded task, then repoint the key below.
215
- await fetch("cancel", { id: existing[0].task_id });
229
+ if (reusable(conflict, current.status))
230
+ return rowToTask(current);
231
+ // The strategy declined the recorded task, so the key repoints to the
232
+ // fresh one inserted below. Cancel only what is still live: a terminal
233
+ // task has nothing to stop, and cancelling it would rewrite a settled
234
+ // row (and hand a `canceled` back to whoever is waiting on it).
235
+ if (!TERMINAL.includes(current.status)) {
236
+ await fetch("cancel", { id: existing[0].task_id });
237
+ }
216
238
  }
217
239
  }
218
240
  const row = (await fetch("insert_task", ins))[0];
package/dist/wait.d.ts CHANGED
@@ -2,6 +2,11 @@ import { type Task } from "./models.js";
2
2
  import type { TaskStore } from "./store/base.js";
3
3
  export declare const DEFAULT_POLL_MS = 100;
4
4
  export declare const MAX_POLL_MS = 500;
5
+ export interface PollOptions {
6
+ timeoutMs: number;
7
+ pollMs?: number;
8
+ maxPollMs?: number;
9
+ }
5
10
  /**
6
11
  * Grow the polling interval towards the ceiling.
7
12
  *
@@ -14,8 +19,18 @@ export declare function nextPollMs(current: number, maxMs: number): number;
14
19
  /** Poll get() until terminal or timeout. Returns the terminal Task (any status).
15
20
  * Throws TaskTimeout, leaving the task running. `pollMs` is the *first* interval;
16
21
  * it backs off towards `maxPollMs`. */
17
- export declare function pollWait(store: TaskStore, taskId: string, { timeoutMs, pollMs, maxPollMs, }: {
18
- timeoutMs: number;
19
- pollMs?: number;
20
- maxPollMs?: number;
21
- }): Promise<Task>;
22
+ export declare function pollWait(store: TaskStore, taskId: string, opts: PollOptions): Promise<Task>;
23
+ /**
24
+ * The same wait, following a key instead of an id.
25
+ *
26
+ * The key is re-resolved on every read, because that is what a key means: a
27
+ * pointer to the task that is *current* under it. A `replace` landing mid-wait
28
+ * moves the wait onto the new task rather than reporting the cancellation of the
29
+ * old one, and a key that points at nothing yet is simply not finished — it
30
+ * polls until something appears, the same way waiting on an id that does not
31
+ * exist yet does.
32
+ *
33
+ * There is nothing to subscribe to before the key resolves, so those naps are
34
+ * plain sleeps; once it resolves, the store's push channel applies as usual.
35
+ */
36
+ export declare function pollWaitByKey(store: TaskStore, key: string, opts: PollOptions): Promise<Task>;