cairnq 0.13.0 → 0.14.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 (44) hide show
  1. package/README.md +9 -32
  2. package/dist/_protocol/sql/postgres/purge.sql +10 -10
  3. package/dist/_protocol/sql/sqlite/purge.sql +10 -10
  4. package/dist/backpressure.d.ts +0 -3
  5. package/dist/backpressure.js +1 -3
  6. package/dist/client.d.ts +42 -46
  7. package/dist/client.js +27 -43
  8. package/dist/context.d.ts +23 -1
  9. package/dist/context.js +18 -7
  10. package/dist/errors.d.ts +20 -26
  11. package/dist/errors.js +23 -35
  12. package/dist/index.d.ts +4 -10
  13. package/dist/index.js +3 -6
  14. package/dist/models.d.ts +14 -1
  15. package/dist/models.js +17 -5
  16. package/dist/retention.d.ts +22 -66
  17. package/dist/retention.js +53 -64
  18. package/dist/store/base.d.ts +29 -90
  19. package/dist/store/base.js +136 -123
  20. package/dist/store/pg-executor.d.ts +11 -0
  21. package/dist/store/pg-executor.js +11 -0
  22. package/dist/store/pg-pool.js +14 -0
  23. package/dist/store/postgres.d.ts +18 -12
  24. package/dist/store/postgres.js +23 -29
  25. package/dist/store/sqlite.d.ts +39 -2
  26. package/dist/store/sqlite.js +108 -29
  27. package/dist/worker.d.ts +38 -93
  28. package/dist/worker.js +181 -234
  29. package/package.json +1 -1
  30. package/src/backpressure.ts +1 -5
  31. package/src/client.ts +66 -71
  32. package/src/context.ts +28 -7
  33. package/src/errors.ts +25 -36
  34. package/src/index.ts +4 -27
  35. package/src/models.ts +17 -5
  36. package/src/retention.ts +50 -115
  37. package/src/store/base.ts +131 -166
  38. package/src/store/pg-executor.ts +11 -0
  39. package/src/store/pg-pool.ts +14 -0
  40. package/src/store/postgres.ts +24 -31
  41. package/src/store/sqlite.ts +108 -27
  42. package/src/worker.ts +208 -300
  43. package/dist/_protocol/sql/postgres/stats.sql +0 -23
  44. package/dist/_protocol/sql/sqlite/stats.sql +0 -23
package/dist/errors.js CHANGED
@@ -140,38 +140,6 @@ export class TaskCanceled extends CairnQError {
140
140
  this.name = "TaskCanceled";
141
141
  }
142
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
- }
175
143
  /** A worker write affected 0 rows: the lease expired and was reclaimed. */
176
144
  export class LostLease extends CairnQError {
177
145
  taskId;
@@ -221,10 +189,30 @@ export class SchemaMismatch extends CairnQError {
221
189
  this.name = "SchemaMismatch";
222
190
  }
223
191
  }
192
+ /**
193
+ * The store was closing when this operation asked for it.
194
+ *
195
+ * `close()` waits for the work already accepted — a group commit still holding
196
+ * writes, a transaction with a BEGIN IMMEDIATE open — and turns away everything
197
+ * that arrives after, so that wait cannot be extended indefinitely by a producer
198
+ * that keeps submitting. An operation that lands in that window gets this rather
199
+ * than a driver error about a connection that vanished underneath it.
200
+ *
201
+ * It does not mean the store is finished for good: connecting is lazy, so a
202
+ * store used again after `close()` has returned simply reopens. The Python SDK
203
+ * raises the same named error.
204
+ */
205
+ export class StoreClosed extends CairnQError {
206
+ constructor(message = "store is closing") {
207
+ super(message);
208
+ this.name = "StoreClosed";
209
+ }
210
+ }
224
211
  /** A value could not be encoded for a protocol JSON column (non-finite number,
225
- * BigInt, circular structure, …). Raised at the boundary — submit rejects with
226
- * it, and a worker records a handler result that triggers it as a permanent
227
- * `unserializable_result` failure. The Python SDK raises the same named error. */
212
+ * BigInt, circular structure, an opaque built-in like Map or Set, …). Raised at
213
+ * the boundary — submit rejects with it, and a worker records a handler result
214
+ * that triggers it as a permanent `unserializable_result` failure. The Python
215
+ * SDK raises the same named error. */
228
216
  export class SerializationError extends CairnQError {
229
217
  constructor(message) {
230
218
  super(message);
package/dist/index.d.ts CHANGED
@@ -1,24 +1,18 @@
1
1
  export { CairnQ } from "./client.js";
2
2
  export type { CallOptions, ClientOptions, SubmitOptions, WaitOptions } from "./client.js";
3
- export { QueueDepthGate } from "./backpressure.js";
4
- export type { BackpressureOptions, QueueDepthLimit } from "./backpressure.js";
5
- export { RetentionSweeper } from "./retention.js";
6
- export type { RetentionCutoffs, RetentionOptions, RetentionRule } from "./retention.js";
7
3
  export { Worker } from "./worker.js";
8
4
  export type { BatchHandler, Handler, TypedHandler, WorkerOptions } from "./worker.js";
9
5
  export { TaskContext } from "./context.js";
10
- export type { TaskContextOptions } from "./context.js";
11
6
  export { defineTask } from "./task.js";
12
7
  export type { TaskDef } from "./task.js";
13
8
  export { SQLiteStore } from "./store/sqlite.js";
14
9
  export { PostgresStore } from "./store/postgres.js";
10
+ export { TaskStore } from "./store/base.js";
11
+ export type { Conflict, ListInput, PurgeInput, SubmitInput } from "./store/base.js";
15
12
  export { ListenUnavailable } from "./store/pg-executor.js";
16
13
  export type { PgExecutor, PgSession, Row } from "./store/pg-executor.js";
17
14
  export { createPoolExecutor } from "./store/pg-pool.js";
18
- export { TaskStore } from "./store/base.js";
19
- export type { ListInput, PurgeInput, SubmitInput, Conflict, WatchOptions, WatchSignal, } from "./store/base.js";
20
- export { DEFAULT_WATCH_POLL_MS } from "./store/base.js";
15
+ export { STATUSES, isTerminalStatus } from "./models.js";
21
16
  export type { Task, TaskRef, TaskStatus, TerminalStatus } from "./models.js";
22
- export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
23
- export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SchemaMismatch, UnsupportedBackend, SerializationError, } from "./errors.js";
17
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SchemaMismatch, UnsupportedBackend, SerializationError, StoreClosed, } from "./errors.js";
24
18
  export type { FailReason } from "./errors.js";
package/dist/index.js CHANGED
@@ -1,14 +1,11 @@
1
1
  export { CairnQ } from "./client.js";
2
- export { QueueDepthGate } from "./backpressure.js";
3
- export { RetentionSweeper } from "./retention.js";
4
2
  export { Worker } from "./worker.js";
5
3
  export { TaskContext } from "./context.js";
6
4
  export { defineTask } from "./task.js";
7
5
  export { SQLiteStore } from "./store/sqlite.js";
8
6
  export { PostgresStore } from "./store/postgres.js";
7
+ export { TaskStore } from "./store/base.js";
9
8
  export { ListenUnavailable } from "./store/pg-executor.js";
10
9
  export { createPoolExecutor } from "./store/pg-pool.js";
11
- export { TaskStore } from "./store/base.js";
12
- export { DEFAULT_WATCH_POLL_MS } from "./store/base.js";
13
- export { STATUSES, isTerminal, isTerminalStatus, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
14
- export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, EventLoopBlocked, ProtocolVersionMismatch, SchemaMismatch, UnsupportedBackend, SerializationError, } from "./errors.js";
10
+ export { STATUSES, isTerminalStatus } from "./models.js";
11
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SchemaMismatch, UnsupportedBackend, SerializationError, StoreClosed, } from "./errors.js";
package/dist/models.d.ts CHANGED
@@ -39,7 +39,20 @@ export declare function isTerminalStatus(status: TaskStatus): status is Terminal
39
39
  /** Map a probe row (see get_status.sql) to a TaskRef — the ref twin of
40
40
  * rowToTask, so the row shape stays models' knowledge alone. */
41
41
  export declare function rowToRef(row: Record<string, unknown>): TaskRef;
42
- export declare function rowToTask(row: Record<string, unknown>): Task;
42
+ /**
43
+ * Map a row to a Task.
44
+ *
45
+ * `jsonIsText` says how this driver delivers a JSON column, and it has to be
46
+ * told rather than inferred. SQLite (TEXT) hands back the JSON text to parse; a
47
+ * jsonb-aware driver (`pg`) hands back an already-decoded value. Those two are
48
+ * distinguishable for an object or an array — but not for a JSON *string*, where
49
+ * the text form (`"hello"`, quotes included) and the decoded form (`hello`) are
50
+ * both, simply, strings. Guessing from the value used to double-parse the
51
+ * decoded one: `"s3://…"` threw a SyntaxError, and `"42"` came back as the
52
+ * NUMBER 42 — silent corruption for any payload or result that is a top-level
53
+ * string. The store knows which driver it has; models does not, so it asks.
54
+ */
55
+ export declare function rowToTask(row: Record<string, unknown>, jsonIsText: boolean): Task;
43
56
  /** Accepts anything carrying a status — a Task or a TaskRef probe. */
44
57
  export declare function isTerminal(task: Pick<Task, "status">): boolean;
45
58
  export declare function cancelRequested(task: Task): boolean;
package/dist/models.js CHANGED
@@ -28,14 +28,26 @@ export function isTerminalStatus(status) {
28
28
  export function rowToRef(row) {
29
29
  return { id: row.id, status: row.status };
30
30
  }
31
- export function rowToTask(row) {
31
+ /**
32
+ * Map a row to a Task.
33
+ *
34
+ * `jsonIsText` says how this driver delivers a JSON column, and it has to be
35
+ * told rather than inferred. SQLite (TEXT) hands back the JSON text to parse; a
36
+ * jsonb-aware driver (`pg`) hands back an already-decoded value. Those two are
37
+ * distinguishable for an object or an array — but not for a JSON *string*, where
38
+ * the text form (`"hello"`, quotes included) and the decoded form (`hello`) are
39
+ * both, simply, strings. Guessing from the value used to double-parse the
40
+ * decoded one: `"s3://…"` threw a SyntaxError, and `"42"` came back as the
41
+ * NUMBER 42 — silent corruption for any payload or result that is a top-level
42
+ * string. The store knows which driver it has; models does not, so it asks.
43
+ */
44
+ export function rowToTask(row, jsonIsText) {
32
45
  const t = { ...row };
33
46
  for (const col of JSON_COLUMNS) {
34
47
  const v = row[col];
35
- // The driver decides a JSON column's wire form: SQLite (TEXT) hands back a
36
- // string to parse; a jsonb-aware driver (Postgres `pg`) hands back an
37
- // already-decoded object. Parse only a string — never assume one backend.
38
- t[col] = typeof v === "string" ? JSON.parse(v) : (v ?? null);
48
+ // A SQL NULL and a decoded JSON `null` both arrive as null and both mean
49
+ // "no value"; only a real text form is parsed.
50
+ t[col] = v == null ? null : jsonIsText ? JSON.parse(v) : v;
39
51
  }
40
52
  for (const col of MS_COLUMNS) {
41
53
  const v = row[col];
@@ -1,67 +1,15 @@
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>>;
1
+ import type { TaskStore } from "./store/base.js";
6
2
  /**
7
- * One "these rows may go after this long" statement. Each field left out widens
8
- * what the rule covers; `olderThanMs` is the only required one.
9
- *
10
- * A rule is one `purge` call's filters, so the fields are exactly `PurgeInput`'s
11
- * — deliberately, since a rule the sweeper can express but the store cannot
12
- * enforce would be a lie about what is being deleted.
13
- */
14
- export interface RetentionRule {
15
- /** Only this queue. Absent means every queue. */
16
- queue?: string;
17
- /** Only this terminal status. Absent means all three. */
18
- status?: TerminalStatus;
19
- /** Only this task name. Absent means every name. */
20
- name?: string;
21
- /** How long a row matching this rule is kept after it finished. */
22
- olderThanMs: number;
23
- }
24
- export interface RetentionOptions {
25
- /**
26
- * How long a terminal task is kept after it finished. Required: there is no
27
- * safe default for how long someone else's results stay readable.
28
- *
29
- * Three forms, widening as the deployment does:
30
- *
31
- * - A number keeps every terminal row the same time.
32
- * - A per-status map tiers by outcome — a succeeded row is spent once its
33
- * result is consumed, a failed one is worth keeping for diagnosis:
34
- * `{ succeeded: 300_000, failed: 86_400_000 }`. A status left out is never
35
- * swept.
36
- * - An array of rules tiers by anything `purge` can filter on, which is what a
37
- * store shared by two workloads needs — the recommended way for two
38
- * languages to coordinate is one installation, and an RPC queue read once
39
- * has nothing in common with a durable queue kept for a week:
40
- * `[{ queue: "rpc", olderThanMs: 300_000 },
41
- * { queue: "jobs", status: "failed", olderThanMs: 604_800_000 }]`.
42
- * Rules are independent, each its own sweep — nothing a rule does not match
43
- * is swept, and rules that overlap simply delete the same row once.
44
- */
45
- olderThanMs: number | RetentionCutoffs | RetentionRule[];
46
- /** Time between sweeps. Default 3_600_000 (one hour). */
47
- intervalMs?: number;
48
- /** Rows deleted per statement while draining. Default 1_000. */
49
- limit?: number;
50
- /**
51
- * Called for a sweep that threw. The next sweep runs on schedule regardless —
52
- * a purge that failed because the database was busy is not a reason to stop
53
- * retaining — so without this a store quietly stops being swept. Must not throw.
54
- */
55
- onError?: (err: unknown) => void;
56
- }
57
- /**
58
- * Deletes terminal tasks on a schedule, for as long as the handle is open.
3
+ * Deletes terminal tasks on a schedule, for as long as the handle is open —
4
+ * `purge(olderThanMs)` on a timer, which is the whole mechanism.
59
5
  *
60
6
  * `purge` exists because nothing else in CairnQ removes rows, and a queue whose
61
7
  * payloads carry real data — an image, a document, a batch of embeddings — turns
62
8
  * that into a disk leak measured in gigabytes per backfill. Every deployment
63
9
  * that runs longer than a demo needs the sweep; leaving it to an external
64
10
  * scheduler means the leak is the default and remembering is the opt-in.
11
+ * A deployment whose retention is tiered (per queue, per status) calls `purge`
12
+ * with those filters from its own scheduler instead.
65
13
  *
66
14
  * It sweeps in bounded batches with a yield between them, so draining a backlog
67
15
  * that accumulated while nothing was sweeping stays a sequence of short writes
@@ -70,7 +18,7 @@ export interface RetentionOptions {
70
18
  */
71
19
  export declare class RetentionSweeper {
72
20
  private readonly store;
73
- private readonly opts;
21
+ private readonly olderThanMs;
74
22
  /** Whether the scheduled loop is running. */
75
23
  private active;
76
24
  /** Set by stop(), so a drain in progress can cut itself short too. */
@@ -90,12 +38,9 @@ export declare class RetentionSweeper {
90
38
  /** The loop itself, awaited by stop() so no purge outlives the store. */
91
39
  private loop;
92
40
  private readonly intervalMs;
93
- /** Rows per purge statement while draining — see DEFAULT_LIMIT. */
94
- private readonly limit;
95
- /** One purge per rule: a lone entry for a number, one per status for a map,
96
- * one per element for an array. */
97
- private readonly purgeInputs;
98
- constructor(store: TaskStore, opts: RetentionOptions);
41
+ constructor(store: TaskStore, olderThanMs: number, opts?: {
42
+ intervalMs?: number;
43
+ });
99
44
  start(): void;
100
45
  /** Mint a fresh stop signal. */
101
46
  private arm;
@@ -108,7 +53,18 @@ export declare class RetentionSweeper {
108
53
  * demand — after a backfill, or from a maintenance command.
109
54
  */
110
55
  sweep(): Promise<number>;
111
- /** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
112
- * pending sweep must never be the reason a process refuses to exit. */
56
+ /**
57
+ * Sleep, interruptible by stop().
58
+ *
59
+ * `holdProcess` is the same distinction the store draws between `claimWake`
60
+ * and `taskDoneWake`: a wait nobody is awaiting must not hold the process
61
+ * open, and a wait somebody IS awaiting must. The scheduled loop's interval is
62
+ * the first — retention is housekeeping, and a pending sweep must never be the
63
+ * reason a process refuses to exit. `sweep()`'s between-batches yield is the
64
+ * second: its caller is awaiting the drain, and an unref'd timer there let
65
+ * Node decide the loop was idle and exit mid-drain, leaving that promise
66
+ * unsettled forever (a maintenance command that swept two rows of seven and
67
+ * exited 13). Python's twin never had this, `asyncio.sleep` having no unref.
68
+ */
113
69
  private sleep;
114
70
  }
package/dist/retention.js CHANGED
@@ -1,4 +1,3 @@
1
- import { validatePurgeInput } from "./store/base.js";
2
1
  /** Sweep every hour unless asked otherwise — often enough that a queue with a
3
2
  * day of retention never carries more than an hour of extra rows, rare enough
4
3
  * that the sweep is invisible next to the task traffic. */
@@ -6,27 +5,17 @@ const DEFAULT_INTERVAL_MS = 3_600_000;
6
5
  /** Rows per purge statement. The same bound `purge` defaults to: big enough that
7
6
  * a backlog drains in few statements, small enough that each is a short write. */
8
7
  const DEFAULT_LIMIT = 1_000;
9
- /** The three `olderThanMs` forms as the one form the sweep runs on. The number
10
- * and the per-status map are the rule array's common cases spelled shorter, so
11
- * they are widened here rather than handled separately downstream. */
12
- function toRules(spec) {
13
- if (typeof spec === "number")
14
- return [{ olderThanMs: spec }];
15
- if (Array.isArray(spec))
16
- return spec;
17
- return Object.entries(spec).map(([status, olderThanMs]) => ({
18
- status,
19
- olderThanMs,
20
- }));
21
- }
22
8
  /**
23
- * Deletes terminal tasks on a schedule, for as long as the handle is open.
9
+ * Deletes terminal tasks on a schedule, for as long as the handle is open —
10
+ * `purge(olderThanMs)` on a timer, which is the whole mechanism.
24
11
  *
25
12
  * `purge` exists because nothing else in CairnQ removes rows, and a queue whose
26
13
  * payloads carry real data — an image, a document, a batch of embeddings — turns
27
14
  * that into a disk leak measured in gigabytes per backfill. Every deployment
28
15
  * that runs longer than a demo needs the sweep; leaving it to an external
29
16
  * scheduler means the leak is the default and remembering is the opt-in.
17
+ * A deployment whose retention is tiered (per queue, per status) calls `purge`
18
+ * with those filters from its own scheduler instead.
30
19
  *
31
20
  * It sweeps in bounded batches with a yield between them, so draining a backlog
32
21
  * that accumulated while nothing was sweeping stays a sequence of short writes
@@ -35,7 +24,7 @@ function toRules(spec) {
35
24
  */
36
25
  export class RetentionSweeper {
37
26
  store;
38
- opts;
27
+ olderThanMs;
39
28
  /** Whether the scheduled loop is running. */
40
29
  active = false;
41
30
  /** Set by stop(), so a drain in progress can cut itself short too. */
@@ -55,40 +44,26 @@ export class RetentionSweeper {
55
44
  /** The loop itself, awaited by stop() so no purge outlives the store. */
56
45
  loop = null;
57
46
  intervalMs;
58
- /** Rows per purge statement while draining — see DEFAULT_LIMIT. */
59
- limit;
60
- /** One purge per rule: a lone entry for a number, one per status for a map,
61
- * one per element for an array. */
62
- purgeInputs;
63
- constructor(store, opts) {
47
+ constructor(store, olderThanMs, opts = {}) {
64
48
  this.store = store;
65
- this.opts = opts;
49
+ this.olderThanMs = olderThanMs;
50
+ if (!Number.isFinite(olderThanMs) || olderThanMs < 0) {
51
+ throw new Error(`retentionMs must be >= 0, got ${olderThanMs}`);
52
+ }
66
53
  this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
67
54
  if (!Number.isFinite(this.intervalMs) || this.intervalMs < 1) {
68
- throw new Error(`retention.intervalMs must be >= 1, got ${this.intervalMs}`);
69
- }
70
- this.limit = opts.limit ?? DEFAULT_LIMIT;
71
- const rules = toRules(opts.olderThanMs);
72
- // An empty map or array retains nothing and sweeps nothing — almost
73
- // certainly a bug upstream of this call, so refuse it rather than silently
74
- // never purging.
75
- if (!rules.length) {
76
- throw new Error("retention.olderThanMs must name at least one rule");
55
+ throw new Error(`retention intervalMs must be >= 1, got ${this.intervalMs}`);
77
56
  }
78
57
  this.arm();
79
- this.purgeInputs = rules.map((rule) => ({ ...rule, limit: this.limit }));
80
- // Fail fast on the store's own purge rules (terminal status, cutoff >= 0):
81
- // the sweep runs an hour from now, and its errors only surface via onError.
82
- for (const input of this.purgeInputs)
83
- validatePurgeInput(input);
84
58
  }
85
59
  start() {
86
60
  if (this.active)
87
61
  return;
88
62
  this.active = true;
89
- this.stopping = false;
90
- // A stopped sweeper can be started again, and the old signal is spent.
91
- this.arm();
63
+ // No reset here: stop() clears `stopping` and re-arms the signal before it
64
+ // returns, and the constructor arms the first one — so a sweeper reaching
65
+ // this line always already has a fresh signal and a clear flag. (The Python
66
+ // twin has never had a reset here, for the same reason.)
92
67
  this.loop = this.run();
93
68
  }
94
69
  /** Mint a fresh stop signal. */
@@ -102,6 +77,15 @@ export class RetentionSweeper {
102
77
  this.wake();
103
78
  await this.loop;
104
79
  this.loop = null;
80
+ // Cleared, and the signal re-armed, now that the loop is provably gone.
81
+ // `stopping` is how a sweep in flight cuts itself short, so leaving it set
82
+ // would silently truncate a later on-demand sweep() — a supported call — to
83
+ // its first batch. Leaving the signal spent would be the subtler half of the
84
+ // same bug: that sweep's between-batches yield would resolve on a microtask
85
+ // instead of handing the event loop back, so a long drain would starve the
86
+ // submits and claims sharing this process — exactly what the yield is for.
87
+ this.stopping = false;
88
+ this.arm();
105
89
  }
106
90
  async run() {
107
91
  // Sleep first: a process that restarts often would otherwise purge on every
@@ -113,14 +97,9 @@ export class RetentionSweeper {
113
97
  try {
114
98
  await this.sweep();
115
99
  }
116
- catch (err) {
117
- try {
118
- this.opts.onError?.(err);
119
- }
120
- catch {
121
- // A reporting hook must never take the sweep down with it — the same
122
- // rule the worker's onError follows.
123
- }
100
+ catch {
101
+ // A purge that failed because the database was busy is not a reason to
102
+ // stop retaining — the next sweep runs on schedule regardless.
124
103
  }
125
104
  }
126
105
  }
@@ -131,28 +110,38 @@ export class RetentionSweeper {
131
110
  */
132
111
  async sweep() {
133
112
  let deleted = 0;
134
- for (const input of this.purgeInputs) {
135
- for (;;) {
136
- const ids = await this.store.purge(input);
137
- deleted += ids.length;
138
- if (this.stopping)
139
- return deleted;
140
- if (ids.length < this.limit)
141
- break;
142
- // Hand the loop back between batches: a large drain must not starve the
143
- // submits and claims sharing this process.
144
- await this.sleep(0);
145
- }
113
+ for (;;) {
114
+ const ids = await this.store.purge({ olderThanMs: this.olderThanMs, limit: DEFAULT_LIMIT });
115
+ deleted += ids.length;
116
+ if (this.stopping)
117
+ return deleted;
118
+ if (ids.length < DEFAULT_LIMIT)
119
+ break;
120
+ // Hand the loop back between batches: a large drain must not starve the
121
+ // submits and claims sharing this process. Ref'd — see sleep().
122
+ await this.sleep(0, true);
146
123
  }
147
124
  return deleted;
148
125
  }
149
- /** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
150
- * pending sweep must never be the reason a process refuses to exit. */
151
- sleep(ms) {
126
+ /**
127
+ * Sleep, interruptible by stop().
128
+ *
129
+ * `holdProcess` is the same distinction the store draws between `claimWake`
130
+ * and `taskDoneWake`: a wait nobody is awaiting must not hold the process
131
+ * open, and a wait somebody IS awaiting must. The scheduled loop's interval is
132
+ * the first — retention is housekeeping, and a pending sweep must never be the
133
+ * reason a process refuses to exit. `sweep()`'s between-batches yield is the
134
+ * second: its caller is awaiting the drain, and an unref'd timer there let
135
+ * Node decide the loop was idle and exit mid-drain, leaving that promise
136
+ * unsettled forever (a maintenance command that swept two rows of seven and
137
+ * exited 13). Python's twin never had this, `asyncio.sleep` having no unref.
138
+ */
139
+ sleep(ms, holdProcess = false) {
152
140
  let timer;
153
141
  const nap = new Promise((resolve) => {
154
142
  timer = setTimeout(resolve, ms);
155
- timer.unref?.();
143
+ if (!holdProcess)
144
+ timer.unref?.();
156
145
  });
157
146
  // Clear the timer whichever side wins, so a stop is never followed by a
158
147
  // leftover sweep timer.