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/src/retention.ts CHANGED
@@ -1,5 +1,4 @@
1
- import type { TerminalStatus } from "./models.js";
2
- import { validatePurgeInput, type PurgeInput, type TaskStore } from "./store/base.js";
1
+ import type { TaskStore } from "./store/base.js";
3
2
 
4
3
  /** Sweep every hour unless asked otherwise — often enough that a queue with a
5
4
  * day of retention never carries more than an hour of extra rows, rare enough
@@ -9,83 +8,17 @@ const DEFAULT_INTERVAL_MS = 3_600_000;
9
8
  * a backlog drains in few statements, small enough that each is a short write. */
10
9
  const DEFAULT_LIMIT = 1_000;
11
10
 
12
- /** Per-status cutoffs. A status left out is never swept — granular retention is
13
- * an explicit statement of what may go, not a default for what wasn't named. */
14
- export type RetentionCutoffs = Partial<Record<TerminalStatus, number>>;
15
-
16
- /**
17
- * One "these rows may go after this long" statement. Each field left out widens
18
- * what the rule covers; `olderThanMs` is the only required one.
19
- *
20
- * A rule is one `purge` call's filters, so the fields are exactly `PurgeInput`'s
21
- * — deliberately, since a rule the sweeper can express but the store cannot
22
- * enforce would be a lie about what is being deleted.
23
- */
24
- export interface RetentionRule {
25
- /** Only this queue. Absent means every queue. */
26
- queue?: string;
27
- /** Only this terminal status. Absent means all three. */
28
- status?: TerminalStatus;
29
- /** Only this task name. Absent means every name. */
30
- name?: string;
31
- /** How long a row matching this rule is kept after it finished. */
32
- olderThanMs: number;
33
- }
34
-
35
- export interface RetentionOptions {
36
- /**
37
- * How long a terminal task is kept after it finished. Required: there is no
38
- * safe default for how long someone else's results stay readable.
39
- *
40
- * Three forms, widening as the deployment does:
41
- *
42
- * - A number keeps every terminal row the same time.
43
- * - A per-status map tiers by outcome — a succeeded row is spent once its
44
- * result is consumed, a failed one is worth keeping for diagnosis:
45
- * `{ succeeded: 300_000, failed: 86_400_000 }`. A status left out is never
46
- * swept.
47
- * - An array of rules tiers by anything `purge` can filter on, which is what a
48
- * store shared by two workloads needs — the recommended way for two
49
- * languages to coordinate is one installation, and an RPC queue read once
50
- * has nothing in common with a durable queue kept for a week:
51
- * `[{ queue: "rpc", olderThanMs: 300_000 },
52
- * { queue: "jobs", status: "failed", olderThanMs: 604_800_000 }]`.
53
- * Rules are independent, each its own sweep — nothing a rule does not match
54
- * is swept, and rules that overlap simply delete the same row once.
55
- */
56
- olderThanMs: number | RetentionCutoffs | RetentionRule[];
57
- /** Time between sweeps. Default 3_600_000 (one hour). */
58
- intervalMs?: number;
59
- /** Rows deleted per statement while draining. Default 1_000. */
60
- limit?: number;
61
- /**
62
- * Called for a sweep that threw. The next sweep runs on schedule regardless —
63
- * a purge that failed because the database was busy is not a reason to stop
64
- * retaining — so without this a store quietly stops being swept. Must not throw.
65
- */
66
- onError?: (err: unknown) => void;
67
- }
68
-
69
- /** The three `olderThanMs` forms as the one form the sweep runs on. The number
70
- * and the per-status map are the rule array's common cases spelled shorter, so
71
- * they are widened here rather than handled separately downstream. */
72
- function toRules(spec: number | RetentionCutoffs | RetentionRule[]): RetentionRule[] {
73
- if (typeof spec === "number") return [{ olderThanMs: spec }];
74
- if (Array.isArray(spec)) return spec;
75
- return (Object.entries(spec) as [TerminalStatus, number][]).map(([status, olderThanMs]) => ({
76
- status,
77
- olderThanMs,
78
- }));
79
- }
80
-
81
11
  /**
82
- * Deletes terminal tasks on a schedule, for as long as the handle is open.
12
+ * Deletes terminal tasks on a schedule, for as long as the handle is open —
13
+ * `purge(olderThanMs)` on a timer, which is the whole mechanism.
83
14
  *
84
15
  * `purge` exists because nothing else in CairnQ removes rows, and a queue whose
85
16
  * payloads carry real data — an image, a document, a batch of embeddings — turns
86
17
  * that into a disk leak measured in gigabytes per backfill. Every deployment
87
18
  * that runs longer than a demo needs the sweep; leaving it to an external
88
19
  * scheduler means the leak is the default and remembering is the opt-in.
20
+ * A deployment whose retention is tiered (per queue, per status) calls `purge`
21
+ * with those filters from its own scheduler instead.
89
22
  *
90
23
  * It sweeps in bounded batches with a yield between them, so draining a backlog
91
24
  * that accumulated while nothing was sweeping stays a sequence of short writes
@@ -112,41 +45,29 @@ export class RetentionSweeper {
112
45
  /** The loop itself, awaited by stop() so no purge outlives the store. */
113
46
  private loop: Promise<void> | null = null;
114
47
  private readonly intervalMs: number;
115
- /** Rows per purge statement while draining — see DEFAULT_LIMIT. */
116
- private readonly limit: number;
117
- /** One purge per rule: a lone entry for a number, one per status for a map,
118
- * one per element for an array. */
119
- private readonly purgeInputs: PurgeInput[];
120
48
 
121
49
  constructor(
122
50
  private readonly store: TaskStore,
123
- private readonly opts: RetentionOptions,
51
+ private readonly olderThanMs: number,
52
+ opts: { intervalMs?: number } = {},
124
53
  ) {
54
+ if (!Number.isFinite(olderThanMs) || olderThanMs < 0) {
55
+ throw new Error(`retentionMs must be >= 0, got ${olderThanMs}`);
56
+ }
125
57
  this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
126
58
  if (!Number.isFinite(this.intervalMs) || this.intervalMs < 1) {
127
- throw new Error(`retention.intervalMs must be >= 1, got ${this.intervalMs}`);
128
- }
129
- this.limit = opts.limit ?? DEFAULT_LIMIT;
130
- const rules = toRules(opts.olderThanMs);
131
- // An empty map or array retains nothing and sweeps nothing — almost
132
- // certainly a bug upstream of this call, so refuse it rather than silently
133
- // never purging.
134
- if (!rules.length) {
135
- throw new Error("retention.olderThanMs must name at least one rule");
59
+ throw new Error(`retention intervalMs must be >= 1, got ${this.intervalMs}`);
136
60
  }
137
61
  this.arm();
138
- this.purgeInputs = rules.map((rule) => ({ ...rule, limit: this.limit }));
139
- // Fail fast on the store's own purge rules (terminal status, cutoff >= 0):
140
- // the sweep runs an hour from now, and its errors only surface via onError.
141
- for (const input of this.purgeInputs) validatePurgeInput(input);
142
62
  }
143
63
 
144
64
  start(): void {
145
65
  if (this.active) return;
146
66
  this.active = true;
147
- this.stopping = false;
148
- // A stopped sweeper can be started again, and the old signal is spent.
149
- this.arm();
67
+ // No reset here: stop() clears `stopping` and re-arms the signal before it
68
+ // returns, and the constructor arms the first one — so a sweeper reaching
69
+ // this line always already has a fresh signal and a clear flag. (The Python
70
+ // twin has never had a reset here, for the same reason.)
150
71
  this.loop = this.run();
151
72
  }
152
73
 
@@ -162,6 +83,15 @@ export class RetentionSweeper {
162
83
  this.wake();
163
84
  await this.loop;
164
85
  this.loop = null;
86
+ // Cleared, and the signal re-armed, now that the loop is provably gone.
87
+ // `stopping` is how a sweep in flight cuts itself short, so leaving it set
88
+ // would silently truncate a later on-demand sweep() — a supported call — to
89
+ // its first batch. Leaving the signal spent would be the subtler half of the
90
+ // same bug: that sweep's between-batches yield would resolve on a microtask
91
+ // instead of handing the event loop back, so a long drain would starve the
92
+ // submits and claims sharing this process — exactly what the yield is for.
93
+ this.stopping = false;
94
+ this.arm();
165
95
  }
166
96
 
167
97
  private async run(): Promise<void> {
@@ -172,13 +102,9 @@ export class RetentionSweeper {
172
102
  if (this.stopping) return;
173
103
  try {
174
104
  await this.sweep();
175
- } catch (err) {
176
- try {
177
- this.opts.onError?.(err);
178
- } catch {
179
- // A reporting hook must never take the sweep down with it — the same
180
- // rule the worker's onError follows.
181
- }
105
+ } catch {
106
+ // A purge that failed because the database was busy is not a reason to
107
+ // stop retaining — the next sweep runs on schedule regardless.
182
108
  }
183
109
  }
184
110
  }
@@ -190,27 +116,36 @@ export class RetentionSweeper {
190
116
  */
191
117
  async sweep(): Promise<number> {
192
118
  let deleted = 0;
193
- for (const input of this.purgeInputs) {
194
- for (;;) {
195
- const ids = await this.store.purge(input);
196
- deleted += ids.length;
197
- if (this.stopping) return deleted;
198
- if (ids.length < this.limit) break;
199
- // Hand the loop back between batches: a large drain must not starve the
200
- // submits and claims sharing this process.
201
- await this.sleep(0);
202
- }
119
+ for (;;) {
120
+ const ids = await this.store.purge({ olderThanMs: this.olderThanMs, limit: DEFAULT_LIMIT });
121
+ deleted += ids.length;
122
+ if (this.stopping) return deleted;
123
+ if (ids.length < DEFAULT_LIMIT) break;
124
+ // Hand the loop back between batches: a large drain must not starve the
125
+ // submits and claims sharing this process. Ref'd — see sleep().
126
+ await this.sleep(0, true);
203
127
  }
204
128
  return deleted;
205
129
  }
206
130
 
207
- /** Sleep, interruptible by stop(). Unref'd: retention is housekeeping, and a
208
- * pending sweep must never be the reason a process refuses to exit. */
209
- private sleep(ms: number): Promise<void> {
131
+ /**
132
+ * Sleep, interruptible by stop().
133
+ *
134
+ * `holdProcess` is the same distinction the store draws between `claimWake`
135
+ * and `taskDoneWake`: a wait nobody is awaiting must not hold the process
136
+ * open, and a wait somebody IS awaiting must. The scheduled loop's interval is
137
+ * the first — retention is housekeeping, and a pending sweep must never be the
138
+ * reason a process refuses to exit. `sweep()`'s between-batches yield is the
139
+ * second: its caller is awaiting the drain, and an unref'd timer there let
140
+ * Node decide the loop was idle and exit mid-drain, leaving that promise
141
+ * unsettled forever (a maintenance command that swept two rows of seven and
142
+ * exited 13). Python's twin never had this, `asyncio.sleep` having no unref.
143
+ */
144
+ private sleep(ms: number, holdProcess = false): Promise<void> {
210
145
  let timer: NodeJS.Timeout;
211
146
  const nap = new Promise<void>((resolve) => {
212
147
  timer = setTimeout(resolve, ms);
213
- timer.unref?.();
148
+ if (!holdProcess) timer.unref?.();
214
149
  });
215
150
  // Clear the timer whichever side wins, so a stop is never followed by a
216
151
  // leftover sweep timer.