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.
- package/README.md +9 -32
- package/dist/_protocol/sql/postgres/purge.sql +10 -10
- package/dist/_protocol/sql/sqlite/purge.sql +10 -10
- package/dist/backpressure.d.ts +0 -3
- package/dist/backpressure.js +1 -3
- package/dist/client.d.ts +42 -46
- package/dist/client.js +27 -43
- package/dist/context.d.ts +23 -1
- package/dist/context.js +18 -7
- package/dist/errors.d.ts +20 -26
- package/dist/errors.js +23 -35
- package/dist/index.d.ts +4 -10
- package/dist/index.js +3 -6
- package/dist/models.d.ts +14 -1
- package/dist/models.js +17 -5
- package/dist/retention.d.ts +22 -66
- package/dist/retention.js +53 -64
- package/dist/store/base.d.ts +29 -90
- package/dist/store/base.js +136 -123
- package/dist/store/pg-executor.d.ts +11 -0
- package/dist/store/pg-executor.js +11 -0
- package/dist/store/pg-pool.js +14 -0
- package/dist/store/postgres.d.ts +18 -12
- package/dist/store/postgres.js +23 -29
- package/dist/store/sqlite.d.ts +39 -2
- package/dist/store/sqlite.js +108 -29
- package/dist/worker.d.ts +38 -93
- package/dist/worker.js +181 -234
- package/package.json +1 -1
- package/src/backpressure.ts +1 -5
- package/src/client.ts +66 -71
- package/src/context.ts +28 -7
- package/src/errors.ts +25 -36
- package/src/index.ts +4 -27
- package/src/models.ts +17 -5
- package/src/retention.ts +50 -115
- package/src/store/base.ts +131 -166
- package/src/store/pg-executor.ts +11 -0
- package/src/store/pg-pool.ts +14 -0
- package/src/store/postgres.ts +24 -31
- package/src/store/sqlite.ts +108 -27
- package/src/worker.ts +208 -300
- package/dist/_protocol/sql/postgres/stats.sql +0 -23
- package/dist/_protocol/sql/sqlite/stats.sql +0 -23
package/src/retention.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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
|
|
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
|
-
|
|
148
|
-
//
|
|
149
|
-
this.
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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 (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
/**
|
|
208
|
-
*
|
|
209
|
-
|
|
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.
|