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/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,
|
|
226
|
-
* it, and a worker records a handler result
|
|
227
|
-
* `unserializable_result` failure. The Python
|
|
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 {
|
|
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 {
|
|
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 {
|
|
12
|
-
export {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
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];
|
package/dist/retention.d.ts
CHANGED
|
@@ -1,67 +1,15 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
/**
|
|
112
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
90
|
-
//
|
|
91
|
-
this.
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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 (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
/**
|
|
150
|
-
*
|
|
151
|
-
|
|
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
|
-
|
|
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.
|