cairnq 0.14.0 → 0.15.1
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 +3 -3
- package/dist/_protocol/sql/postgres/stats.sql +24 -0
- package/dist/_protocol/sql/sqlite/stats.sql +24 -0
- package/dist/client.d.ts +26 -9
- package/dist/client.js +26 -3
- package/dist/index.d.ts +1 -0
- package/dist/retention.d.ts +64 -9
- package/dist/retention.js +57 -24
- package/dist/store/base.d.ts +19 -0
- package/dist/store/base.js +31 -0
- package/dist/worker.d.ts +5 -0
- package/dist/worker.js +19 -3
- package/package.json +1 -1
- package/src/client.ts +42 -11
- package/src/index.ts +1 -0
- package/src/retention.ts +108 -22
- package/src/store/base.ts +32 -0
- package/src/worker.ts +17 -3
package/README.md
CHANGED
|
@@ -78,9 +78,9 @@ const worker = Worker.sqlite("tasks.db", {
|
|
|
78
78
|
|
|
79
79
|
// Nothing else deletes rows, so give the client a retention cutoff — it sweeps
|
|
80
80
|
// terminal tasks in bounded batches for as long as the handle is open. Tiered
|
|
81
|
-
// retention
|
|
82
|
-
//
|
|
83
|
-
const tasks = CairnQ.sqlite("tasks.db", {
|
|
81
|
+
// retention is the same option in its wider forms: a per-status map, or a list
|
|
82
|
+
// of RetentionRule filtering by anything purge() can (queue, status, name).
|
|
83
|
+
const tasks = CairnQ.sqlite("tasks.db", { retention: 7 * 24 * 3600_000 });
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
A handler that does real side effects should bail out when it loses its lease —
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Task counts grouped by queue and status. Read-only.
|
|
2
|
+
-- A queue appears only while it has rows — terminal tasks count until purge
|
|
3
|
+
-- removes them. The SDK zero-fills the statuses a queue has no rows in.
|
|
4
|
+
--
|
|
5
|
+
-- :queue is optional (pass NULL for every queue; `::text` pins the param's type,
|
|
6
|
+
-- as in list.sql). Unfiltered, this reads every row in the table, so its cost
|
|
7
|
+
-- grows with everything the installation has ever run — and one store carrying
|
|
8
|
+
-- two workloads is the coordination cairnq recommends, so a caller asking about
|
|
9
|
+
-- its own queue should not pay for the other's backlog. Filtered to one queue it
|
|
10
|
+
-- can be served from cairnq_tasks_claim_idx's (queue, status) prefix instead.
|
|
11
|
+
--
|
|
12
|
+
-- Filtered or not, this still COUNTS: the cost is proportional to the rows being
|
|
13
|
+
-- counted, which is the whole queue, terminal rows included. That is fine for a
|
|
14
|
+
-- dashboard and wrong for a poll loop — queue_depth.sql is the bounded question,
|
|
15
|
+
-- and the one to ask on an interval.
|
|
16
|
+
-- When a queue IS named, the SDK rewrites the optional filter below to a plain
|
|
17
|
+
-- equality before preparing this statement (`specialize`) — see purge.sql for
|
|
18
|
+
-- why the optional form cannot be indexed.
|
|
19
|
+
-- params: queue
|
|
20
|
+
select queue, status, count(*) as count
|
|
21
|
+
from cairnq_tasks
|
|
22
|
+
where (:queue::text is null or queue = :queue)
|
|
23
|
+
group by queue, status
|
|
24
|
+
order by queue asc, status asc;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Task counts grouped by queue and status. Read-only.
|
|
2
|
+
-- A queue appears only while it has rows — terminal tasks count until purge
|
|
3
|
+
-- removes them. The SDK zero-fills the statuses a queue has no rows in.
|
|
4
|
+
--
|
|
5
|
+
-- :queue is optional (pass NULL for every queue). Unfiltered, this reads every
|
|
6
|
+
-- row in the table, so its cost grows with everything the installation has ever
|
|
7
|
+
-- run — and one store carrying two workloads is the coordination cairnq
|
|
8
|
+
-- recommends, so a caller asking about its own queue should not pay for the
|
|
9
|
+
-- other's backlog. Filtered to one queue it can be served from
|
|
10
|
+
-- cairnq_tasks_claim_idx's (queue, status) prefix instead.
|
|
11
|
+
--
|
|
12
|
+
-- Filtered or not, this still COUNTS: the cost is proportional to the rows being
|
|
13
|
+
-- counted, which is the whole queue, terminal rows included. That is fine for a
|
|
14
|
+
-- dashboard and wrong for a poll loop — queue_depth.sql is the bounded question,
|
|
15
|
+
-- and the one to ask on an interval.
|
|
16
|
+
-- When a queue IS named, the SDK rewrites the optional filter below to a plain
|
|
17
|
+
-- equality before preparing this statement (`specialize`) — see purge.sql for
|
|
18
|
+
-- why the optional form cannot be indexed.
|
|
19
|
+
-- params: queue
|
|
20
|
+
select queue, status, count(*) as count
|
|
21
|
+
from cairnq_tasks
|
|
22
|
+
where (:queue is null or queue = :queue)
|
|
23
|
+
group by queue, status
|
|
24
|
+
order by queue asc, status asc;
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type RetentionOptions } from "./retention.js";
|
|
2
|
+
import { type Task, type TaskRef, type TaskStatus } from "./models.js";
|
|
2
3
|
import type { PgExecutor } from "./store/pg-executor.js";
|
|
3
4
|
import type { Conflict, ListInput, PurgeInput, TaskStore } from "./store/base.js";
|
|
4
5
|
import { type TaskDef } from "./task.js";
|
|
@@ -33,18 +34,20 @@ export interface ClientOptions {
|
|
|
33
34
|
/** How long a blocked submit waits before raising QueueFull. Default 600_000. */
|
|
34
35
|
maxQueueWaitMs?: number;
|
|
35
36
|
/**
|
|
36
|
-
* Delete terminal tasks
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* Delete terminal tasks on a schedule, for as long as this handle is open.
|
|
38
|
+
* Off unless set — and off means rows accumulate forever, because nothing
|
|
39
|
+
* else in CairnQ removes them.
|
|
40
|
+
*
|
|
41
|
+
* A number keeps every terminal row that many ms. The option form is the
|
|
42
|
+
* same cutoff in its tiered shapes — per status, or per anything `purge`
|
|
43
|
+
* can filter on — plus the sweep's own knobs; see RetentionOptions.
|
|
41
44
|
*/
|
|
42
|
-
|
|
45
|
+
retention?: number | RetentionOptions;
|
|
43
46
|
}
|
|
44
47
|
/** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
|
|
45
48
|
export declare class CairnQ {
|
|
46
49
|
private readonly _store;
|
|
47
|
-
/** null unless `
|
|
50
|
+
/** null unless `retention` was configured. */
|
|
48
51
|
private readonly sweeper;
|
|
49
52
|
constructor(_store: TaskStore, opts?: ClientOptions);
|
|
50
53
|
static sqlite(path: string, opts?: {
|
|
@@ -75,6 +78,11 @@ export declare class CairnQ {
|
|
|
75
78
|
queueDepth(queue: string, maxDepth: number): Promise<number>;
|
|
76
79
|
get(taskId: string): Promise<Task | null>;
|
|
77
80
|
getByKey(key: string): Promise<Task | null>;
|
|
81
|
+
/** The status-only probe wait polls on: id + status, no payload. Public for
|
|
82
|
+
* the same reason it exists — a dashboard or poller that only asks "is it
|
|
83
|
+
* finished yet" should not drag the payload back per ask. */
|
|
84
|
+
getStatus(taskId: string): Promise<TaskRef | null>;
|
|
85
|
+
getStatusByKey(key: string): Promise<TaskRef | null>;
|
|
78
86
|
list(input?: ListInput): Promise<Task[]>;
|
|
79
87
|
cancel(taskId: string): Promise<Task | null>;
|
|
80
88
|
cancelByKey(key: string): Promise<Task | null>;
|
|
@@ -86,13 +94,22 @@ export declare class CairnQ {
|
|
|
86
94
|
}): Promise<Task | null>;
|
|
87
95
|
/** Delete terminal tasks that finished more than `olderThanMs` ago and return
|
|
88
96
|
* their ids. Nothing else in CairnQ removes rows, so a long-lived database
|
|
89
|
-
* needs this on a schedule — `
|
|
97
|
+
* needs this on a schedule — `retention` is this call on a timer. Each call
|
|
90
98
|
* is bounded by `limit` to keep the write short; loop until it returns fewer
|
|
91
99
|
* than `limit`.
|
|
92
100
|
*
|
|
93
101
|
* `queue` / `status` / `name` narrow the sweep — one installation carrying two
|
|
94
102
|
* workloads needs a retention per workload, not one for the whole database. */
|
|
95
103
|
purge(input?: PurgeInput): Promise<string[]>;
|
|
104
|
+
/** Task counts per queue, keyed by status and zero-filled across all statuses
|
|
105
|
+
* — `(await stats()).default.queued` is the backlog of a queue. `queue` narrows
|
|
106
|
+
* the aggregate to one queue, which is also what keeps a caller from paying for
|
|
107
|
+
* the other workloads sharing the installation; a named queue is always
|
|
108
|
+
* present, zero-filled if it has no rows.
|
|
109
|
+
*
|
|
110
|
+
* This counts rows, so it costs what it counts — use it for a dashboard, and
|
|
111
|
+
* poll `queueDepth()` instead, which is bounded. */
|
|
112
|
+
stats(queue?: string): Promise<Record<string, Record<TaskStatus, number>>>;
|
|
96
113
|
/** Wait for a task to finish. Resolves with the terminal Task (any status);
|
|
97
114
|
* throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
|
|
98
115
|
* same wait back up — from another process, or after a longer deadline. */
|
package/dist/client.js
CHANGED
|
@@ -8,7 +8,7 @@ import { DEFAULT_WAIT_TIMEOUT_MS, pollWait, pollWaitByKey } from "./wait.js";
|
|
|
8
8
|
/** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
|
|
9
9
|
export class CairnQ {
|
|
10
10
|
_store;
|
|
11
|
-
/** null unless `
|
|
11
|
+
/** null unless `retention` was configured. */
|
|
12
12
|
sweeper;
|
|
13
13
|
constructor(_store, opts = {}) {
|
|
14
14
|
this._store = _store;
|
|
@@ -25,7 +25,10 @@ export class CairnQ {
|
|
|
25
25
|
// Started here rather than in connect(), which is optional — every other
|
|
26
26
|
// path connects lazily, and retention that silently depends on an optional
|
|
27
27
|
// call is retention that silently does not happen.
|
|
28
|
-
this.sweeper =
|
|
28
|
+
this.sweeper =
|
|
29
|
+
opts.retention != null
|
|
30
|
+
? new RetentionSweeper(_store, typeof opts.retention === "number" ? { olderThanMs: opts.retention } : opts.retention)
|
|
31
|
+
: null;
|
|
29
32
|
this.sweeper?.start();
|
|
30
33
|
}
|
|
31
34
|
static sqlite(path, opts = {}) {
|
|
@@ -81,6 +84,15 @@ export class CairnQ {
|
|
|
81
84
|
getByKey(key) {
|
|
82
85
|
return this._store.getByKey(key);
|
|
83
86
|
}
|
|
87
|
+
/** The status-only probe wait polls on: id + status, no payload. Public for
|
|
88
|
+
* the same reason it exists — a dashboard or poller that only asks "is it
|
|
89
|
+
* finished yet" should not drag the payload back per ask. */
|
|
90
|
+
getStatus(taskId) {
|
|
91
|
+
return this._store.getStatus(taskId);
|
|
92
|
+
}
|
|
93
|
+
getStatusByKey(key) {
|
|
94
|
+
return this._store.getStatusByKey(key);
|
|
95
|
+
}
|
|
84
96
|
list(input) {
|
|
85
97
|
return this._store.list(input);
|
|
86
98
|
}
|
|
@@ -98,7 +110,7 @@ export class CairnQ {
|
|
|
98
110
|
}
|
|
99
111
|
/** Delete terminal tasks that finished more than `olderThanMs` ago and return
|
|
100
112
|
* their ids. Nothing else in CairnQ removes rows, so a long-lived database
|
|
101
|
-
* needs this on a schedule — `
|
|
113
|
+
* needs this on a schedule — `retention` is this call on a timer. Each call
|
|
102
114
|
* is bounded by `limit` to keep the write short; loop until it returns fewer
|
|
103
115
|
* than `limit`.
|
|
104
116
|
*
|
|
@@ -107,6 +119,17 @@ export class CairnQ {
|
|
|
107
119
|
purge(input) {
|
|
108
120
|
return this._store.purge(input);
|
|
109
121
|
}
|
|
122
|
+
/** Task counts per queue, keyed by status and zero-filled across all statuses
|
|
123
|
+
* — `(await stats()).default.queued` is the backlog of a queue. `queue` narrows
|
|
124
|
+
* the aggregate to one queue, which is also what keeps a caller from paying for
|
|
125
|
+
* the other workloads sharing the installation; a named queue is always
|
|
126
|
+
* present, zero-filled if it has no rows.
|
|
127
|
+
*
|
|
128
|
+
* This counts rows, so it costs what it counts — use it for a dashboard, and
|
|
129
|
+
* poll `queueDepth()` instead, which is bounded. */
|
|
130
|
+
stats(queue) {
|
|
131
|
+
return this._store.stats(queue);
|
|
132
|
+
}
|
|
110
133
|
/** Wait for a task to finish. Resolves with the terminal Task (any status);
|
|
111
134
|
* throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
|
|
112
135
|
* same wait back up — from another process, or after a longer deadline. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { CairnQ } from "./client.js";
|
|
2
2
|
export type { CallOptions, ClientOptions, SubmitOptions, WaitOptions } from "./client.js";
|
|
3
|
+
export type { RetentionCutoffs, RetentionOptions, RetentionRule } from "./retention.js";
|
|
3
4
|
export { Worker } from "./worker.js";
|
|
4
5
|
export type { BatchHandler, Handler, TypedHandler, WorkerOptions } from "./worker.js";
|
|
5
6
|
export { TaskContext } from "./context.js";
|
package/dist/retention.d.ts
CHANGED
|
@@ -1,15 +1,67 @@
|
|
|
1
|
-
import type {
|
|
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>>;
|
|
2
6
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
5
59
|
*
|
|
6
60
|
* `purge` exists because nothing else in CairnQ removes rows, and a queue whose
|
|
7
61
|
* payloads carry real data — an image, a document, a batch of embeddings — turns
|
|
8
62
|
* that into a disk leak measured in gigabytes per backfill. Every deployment
|
|
9
63
|
* that runs longer than a demo needs the sweep; leaving it to an external
|
|
10
64
|
* 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.
|
|
13
65
|
*
|
|
14
66
|
* It sweeps in bounded batches with a yield between them, so draining a backlog
|
|
15
67
|
* that accumulated while nothing was sweeping stays a sequence of short writes
|
|
@@ -18,7 +70,7 @@ import type { TaskStore } from "./store/base.js";
|
|
|
18
70
|
*/
|
|
19
71
|
export declare class RetentionSweeper {
|
|
20
72
|
private readonly store;
|
|
21
|
-
private readonly
|
|
73
|
+
private readonly opts;
|
|
22
74
|
/** Whether the scheduled loop is running. */
|
|
23
75
|
private active;
|
|
24
76
|
/** Set by stop(), so a drain in progress can cut itself short too. */
|
|
@@ -38,9 +90,12 @@ export declare class RetentionSweeper {
|
|
|
38
90
|
/** The loop itself, awaited by stop() so no purge outlives the store. */
|
|
39
91
|
private loop;
|
|
40
92
|
private readonly intervalMs;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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);
|
|
44
99
|
start(): void;
|
|
45
100
|
/** Mint a fresh stop signal. */
|
|
46
101
|
private arm;
|
package/dist/retention.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validatePurgeInput } from "./store/base.js";
|
|
1
2
|
/** Sweep every hour unless asked otherwise — often enough that a queue with a
|
|
2
3
|
* day of retention never carries more than an hour of extra rows, rare enough
|
|
3
4
|
* that the sweep is invisible next to the task traffic. */
|
|
@@ -5,17 +6,27 @@ const DEFAULT_INTERVAL_MS = 3_600_000;
|
|
|
5
6
|
/** Rows per purge statement. The same bound `purge` defaults to: big enough that
|
|
6
7
|
* a backlog drains in few statements, small enough that each is a short write. */
|
|
7
8
|
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
|
+
}
|
|
8
22
|
/**
|
|
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.
|
|
23
|
+
* Deletes terminal tasks on a schedule, for as long as the handle is open.
|
|
11
24
|
*
|
|
12
25
|
* `purge` exists because nothing else in CairnQ removes rows, and a queue whose
|
|
13
26
|
* payloads carry real data — an image, a document, a batch of embeddings — turns
|
|
14
27
|
* that into a disk leak measured in gigabytes per backfill. Every deployment
|
|
15
28
|
* that runs longer than a demo needs the sweep; leaving it to an external
|
|
16
29
|
* 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.
|
|
19
30
|
*
|
|
20
31
|
* It sweeps in bounded batches with a yield between them, so draining a backlog
|
|
21
32
|
* that accumulated while nothing was sweeping stays a sequence of short writes
|
|
@@ -24,7 +35,7 @@ const DEFAULT_LIMIT = 1_000;
|
|
|
24
35
|
*/
|
|
25
36
|
export class RetentionSweeper {
|
|
26
37
|
store;
|
|
27
|
-
|
|
38
|
+
opts;
|
|
28
39
|
/** Whether the scheduled loop is running. */
|
|
29
40
|
active = false;
|
|
30
41
|
/** Set by stop(), so a drain in progress can cut itself short too. */
|
|
@@ -44,17 +55,32 @@ export class RetentionSweeper {
|
|
|
44
55
|
/** The loop itself, awaited by stop() so no purge outlives the store. */
|
|
45
56
|
loop = null;
|
|
46
57
|
intervalMs;
|
|
47
|
-
|
|
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) {
|
|
48
64
|
this.store = store;
|
|
49
|
-
this.
|
|
50
|
-
if (!Number.isFinite(olderThanMs) || olderThanMs < 0) {
|
|
51
|
-
throw new Error(`retentionMs must be >= 0, got ${olderThanMs}`);
|
|
52
|
-
}
|
|
65
|
+
this.opts = opts;
|
|
53
66
|
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
54
67
|
if (!Number.isFinite(this.intervalMs) || this.intervalMs < 1) {
|
|
55
|
-
throw new Error(`retention
|
|
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");
|
|
56
77
|
}
|
|
57
78
|
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);
|
|
58
84
|
}
|
|
59
85
|
start() {
|
|
60
86
|
if (this.active)
|
|
@@ -97,9 +123,14 @@ export class RetentionSweeper {
|
|
|
97
123
|
try {
|
|
98
124
|
await this.sweep();
|
|
99
125
|
}
|
|
100
|
-
catch {
|
|
101
|
-
|
|
102
|
-
|
|
126
|
+
catch (err) {
|
|
127
|
+
try {
|
|
128
|
+
this.opts.onError?.(err);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A reporting hook must never take the sweep down with it — the same
|
|
132
|
+
// rule the worker's onError follows.
|
|
133
|
+
}
|
|
103
134
|
}
|
|
104
135
|
}
|
|
105
136
|
}
|
|
@@ -110,16 +141,18 @@ export class RetentionSweeper {
|
|
|
110
141
|
*/
|
|
111
142
|
async sweep() {
|
|
112
143
|
let deleted = 0;
|
|
113
|
-
for (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
144
|
+
for (const input of this.purgeInputs) {
|
|
145
|
+
for (;;) {
|
|
146
|
+
const ids = await this.store.purge(input);
|
|
147
|
+
deleted += ids.length;
|
|
148
|
+
if (this.stopping)
|
|
149
|
+
return deleted;
|
|
150
|
+
if (ids.length < this.limit)
|
|
151
|
+
break;
|
|
152
|
+
// Hand the loop back between batches: a large drain must not starve the
|
|
153
|
+
// submits and claims sharing this process. Ref'd — see sleep().
|
|
154
|
+
await this.sleep(0, true);
|
|
155
|
+
}
|
|
123
156
|
}
|
|
124
157
|
return deleted;
|
|
125
158
|
}
|
package/dist/store/base.d.ts
CHANGED
|
@@ -248,6 +248,25 @@ export declare abstract class TaskStore {
|
|
|
248
248
|
* a producer; `QueueDepthGate` builds the blocking form on top.
|
|
249
249
|
*/
|
|
250
250
|
queueDepth(queue: string, maxDepth: number): Promise<number>;
|
|
251
|
+
/**
|
|
252
|
+
* Task counts grouped by queue, keyed by status and zero-filled —
|
|
253
|
+
* `(await stats()).default.queued` is the backlog of a queue. A queue appears
|
|
254
|
+
* only while it has rows; terminal tasks keep counting until `purge` removes
|
|
255
|
+
* them.
|
|
256
|
+
*
|
|
257
|
+
* `queue` restricts the aggregate to one queue, which is also what stops the
|
|
258
|
+
* caller paying for every other queue's rows: one installation carrying two
|
|
259
|
+
* workloads is the coordination this project recommends, and the unfiltered
|
|
260
|
+
* form reads the whole table. A named queue is always present in the result,
|
|
261
|
+
* zero-filled if it has no rows at all — asking about a specific queue and
|
|
262
|
+
* getting `undefined` back would make every caller write the same fallback.
|
|
263
|
+
*
|
|
264
|
+
* Filtered or not, this COUNTS, so it costs what it counts: a whole queue,
|
|
265
|
+
* terminal rows included. Right for a dashboard, wrong on an interval — poll
|
|
266
|
+
* `queueDepth`, which is bounded, and keep this for when the real numbers are
|
|
267
|
+
* the point.
|
|
268
|
+
*/
|
|
269
|
+
stats(queue?: string): Promise<Record<string, Record<TaskStatus, number>>>;
|
|
251
270
|
/**
|
|
252
271
|
* Take up to `limit` claimable tasks. `names` restricts the claim to task names
|
|
253
272
|
* this caller can actually run — a worker passes its registered handlers.
|
package/dist/store/base.js
CHANGED
|
@@ -509,6 +509,37 @@ export class TaskStore {
|
|
|
509
509
|
const rows = await this.fetch("queue_depth", { queue, max_depth: maxDepth });
|
|
510
510
|
return Number(rows[0]?.headroom ?? 0);
|
|
511
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* Task counts grouped by queue, keyed by status and zero-filled —
|
|
514
|
+
* `(await stats()).default.queued` is the backlog of a queue. A queue appears
|
|
515
|
+
* only while it has rows; terminal tasks keep counting until `purge` removes
|
|
516
|
+
* them.
|
|
517
|
+
*
|
|
518
|
+
* `queue` restricts the aggregate to one queue, which is also what stops the
|
|
519
|
+
* caller paying for every other queue's rows: one installation carrying two
|
|
520
|
+
* workloads is the coordination this project recommends, and the unfiltered
|
|
521
|
+
* form reads the whole table. A named queue is always present in the result,
|
|
522
|
+
* zero-filled if it has no rows at all — asking about a specific queue and
|
|
523
|
+
* getting `undefined` back would make every caller write the same fallback.
|
|
524
|
+
*
|
|
525
|
+
* Filtered or not, this COUNTS, so it costs what it counts: a whole queue,
|
|
526
|
+
* terminal rows included. Right for a dashboard, wrong on an interval — poll
|
|
527
|
+
* `queueDepth`, which is bounded, and keep this for when the real numbers are
|
|
528
|
+
* the point.
|
|
529
|
+
*/
|
|
530
|
+
async stats(queue) {
|
|
531
|
+
const zeros = () => Object.fromEntries(STATUSES.map((s) => [s, 0]));
|
|
532
|
+
const out = {};
|
|
533
|
+
// Seed before the query, not after: a named queue with no rows returns no
|
|
534
|
+
// rows to seed from, and that is exactly the case the promise is about.
|
|
535
|
+
if (queue != null)
|
|
536
|
+
out[queue] = zeros();
|
|
537
|
+
for (const row of await this.fetch("stats", { queue: queue ?? null })) {
|
|
538
|
+
const per = (out[row.queue] ??= zeros());
|
|
539
|
+
per[row.status] = Number(row.count);
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
512
543
|
// ------------------------------------------------------------- worker side
|
|
513
544
|
/**
|
|
514
545
|
* Take up to `limit` claimable tasks. `names` restricts the claim to task names
|
package/dist/worker.d.ts
CHANGED
|
@@ -87,6 +87,8 @@ export declare class Worker {
|
|
|
87
87
|
private readonly backoffMaxMs;
|
|
88
88
|
private stopped;
|
|
89
89
|
private stopWake;
|
|
90
|
+
private freed$;
|
|
91
|
+
private freedWake;
|
|
90
92
|
private readonly stopped$;
|
|
91
93
|
private ownsStore;
|
|
92
94
|
constructor(store: TaskStore, queues: string[], opts?: WorkerOptions);
|
|
@@ -279,6 +281,9 @@ export declare class Worker {
|
|
|
279
281
|
* cuts it short when a task on this worker's queues becomes claimable;
|
|
280
282
|
* stop() interrupts it either way, and sleepOrStop bounds it at `ms` so the
|
|
281
283
|
* poll fallback — which also drives lease recovery — never stretches.
|
|
284
|
+
*
|
|
285
|
+
* `freed` is the third way out: a resource unit coming back frees a claim the
|
|
286
|
+
* store has no reason to announce (see `freed$`).
|
|
282
287
|
*/
|
|
283
288
|
private idle;
|
|
284
289
|
private sleepOrStop;
|
package/dist/worker.js
CHANGED
|
@@ -162,6 +162,13 @@ export class Worker {
|
|
|
162
162
|
backoffMaxMs;
|
|
163
163
|
stopped = false;
|
|
164
164
|
stopWake;
|
|
165
|
+
// Re-armed before each claim, resolved when a call gives a resource unit
|
|
166
|
+
// back. A claim can come back empty because a resource is at capacity rather
|
|
167
|
+
// than the queue, and that release is local — no store notification covers
|
|
168
|
+
// it, so the idle sleep waits on this too. Armed before the claim, so a
|
|
169
|
+
// release landing *during* it still shortens the sleep that follows.
|
|
170
|
+
freed$ = Promise.resolve();
|
|
171
|
+
freedWake = () => { };
|
|
165
172
|
// Resolved once by stop(); every sleep races against it. A stopped worker
|
|
166
173
|
// never restarts, so one promise serves the instance's lifetime.
|
|
167
174
|
stopped$ = new Promise((r) => (this.stopWake = r));
|
|
@@ -353,6 +360,7 @@ export class Worker {
|
|
|
353
360
|
await this.idle(pollMs);
|
|
354
361
|
continue;
|
|
355
362
|
}
|
|
363
|
+
this.freed$ = new Promise((r) => (this.freedWake = r));
|
|
356
364
|
let drawn;
|
|
357
365
|
try {
|
|
358
366
|
drawn = await this.store.claimSession(
|
|
@@ -369,7 +377,7 @@ export class Worker {
|
|
|
369
377
|
continue;
|
|
370
378
|
}
|
|
371
379
|
if (!drawn?.length) {
|
|
372
|
-
await this.idle(pollMs);
|
|
380
|
+
await this.idle(pollMs, this.freed$);
|
|
373
381
|
continue;
|
|
374
382
|
}
|
|
375
383
|
for (const { src, calls } of drawn) {
|
|
@@ -384,6 +392,8 @@ export class Worker {
|
|
|
384
392
|
const p = call.finally(() => {
|
|
385
393
|
this.planner.release(resource);
|
|
386
394
|
running.delete(p);
|
|
395
|
+
if (resource != null)
|
|
396
|
+
this.freedWake();
|
|
387
397
|
});
|
|
388
398
|
running.add(p);
|
|
389
399
|
}
|
|
@@ -746,9 +756,15 @@ export class Worker {
|
|
|
746
756
|
* cuts it short when a task on this worker's queues becomes claimable;
|
|
747
757
|
* stop() interrupts it either way, and sleepOrStop bounds it at `ms` so the
|
|
748
758
|
* poll fallback — which also drives lease recovery — never stretches.
|
|
759
|
+
*
|
|
760
|
+
* `freed` is the third way out: a resource unit coming back frees a claim the
|
|
761
|
+
* store has no reason to announce (see `freed$`).
|
|
749
762
|
*/
|
|
750
|
-
idle(ms) {
|
|
751
|
-
|
|
763
|
+
idle(ms, freed) {
|
|
764
|
+
const racers = [this.sleepOrStop(ms), this.store.claimWake(this.queues, ms)];
|
|
765
|
+
if (freed)
|
|
766
|
+
racers.push(freed);
|
|
767
|
+
return Promise.race(racers);
|
|
752
768
|
}
|
|
753
769
|
sleepOrStop(ms) {
|
|
754
770
|
if (this.stopped)
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { RetentionSweeper } from "./retention.js";
|
|
1
|
+
import { type RetentionOptions, RetentionSweeper } from "./retention.js";
|
|
2
2
|
import { TaskCanceled, TaskFailed } from "./errors.js";
|
|
3
|
-
import { isFailed, isSucceeded, type Task } from "./models.js";
|
|
3
|
+
import { isFailed, isSucceeded, type Task, type TaskRef, type TaskStatus } from "./models.js";
|
|
4
4
|
import { SQLiteStore } from "./store/sqlite.js";
|
|
5
5
|
import { PostgresStore } from "./store/postgres.js";
|
|
6
6
|
import type { PgExecutor } from "./store/pg-executor.js";
|
|
@@ -40,18 +40,20 @@ export interface ClientOptions {
|
|
|
40
40
|
/** How long a blocked submit waits before raising QueueFull. Default 600_000. */
|
|
41
41
|
maxQueueWaitMs?: number;
|
|
42
42
|
/**
|
|
43
|
-
* Delete terminal tasks
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* Delete terminal tasks on a schedule, for as long as this handle is open.
|
|
44
|
+
* Off unless set — and off means rows accumulate forever, because nothing
|
|
45
|
+
* else in CairnQ removes them.
|
|
46
|
+
*
|
|
47
|
+
* A number keeps every terminal row that many ms. The option form is the
|
|
48
|
+
* same cutoff in its tiered shapes — per status, or per anything `purge`
|
|
49
|
+
* can filter on — plus the sweep's own knobs; see RetentionOptions.
|
|
48
50
|
*/
|
|
49
|
-
|
|
51
|
+
retention?: number | RetentionOptions;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
|
|
53
55
|
export class CairnQ {
|
|
54
|
-
/** null unless `
|
|
56
|
+
/** null unless `retention` was configured. */
|
|
55
57
|
private readonly sweeper: RetentionSweeper | null;
|
|
56
58
|
|
|
57
59
|
constructor(
|
|
@@ -71,7 +73,13 @@ export class CairnQ {
|
|
|
71
73
|
// Started here rather than in connect(), which is optional — every other
|
|
72
74
|
// path connects lazily, and retention that silently depends on an optional
|
|
73
75
|
// call is retention that silently does not happen.
|
|
74
|
-
this.sweeper =
|
|
76
|
+
this.sweeper =
|
|
77
|
+
opts.retention != null
|
|
78
|
+
? new RetentionSweeper(
|
|
79
|
+
_store,
|
|
80
|
+
typeof opts.retention === "number" ? { olderThanMs: opts.retention } : opts.retention,
|
|
81
|
+
)
|
|
82
|
+
: null;
|
|
75
83
|
this.sweeper?.start();
|
|
76
84
|
}
|
|
77
85
|
|
|
@@ -145,6 +153,17 @@ export class CairnQ {
|
|
|
145
153
|
return this._store.getByKey(key);
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
/** The status-only probe wait polls on: id + status, no payload. Public for
|
|
157
|
+
* the same reason it exists — a dashboard or poller that only asks "is it
|
|
158
|
+
* finished yet" should not drag the payload back per ask. */
|
|
159
|
+
getStatus(taskId: string): Promise<TaskRef | null> {
|
|
160
|
+
return this._store.getStatus(taskId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
getStatusByKey(key: string): Promise<TaskRef | null> {
|
|
164
|
+
return this._store.getStatusByKey(key);
|
|
165
|
+
}
|
|
166
|
+
|
|
148
167
|
list(input?: ListInput): Promise<Task[]> {
|
|
149
168
|
return this._store.list(input);
|
|
150
169
|
}
|
|
@@ -167,7 +186,7 @@ export class CairnQ {
|
|
|
167
186
|
|
|
168
187
|
/** Delete terminal tasks that finished more than `olderThanMs` ago and return
|
|
169
188
|
* their ids. Nothing else in CairnQ removes rows, so a long-lived database
|
|
170
|
-
* needs this on a schedule — `
|
|
189
|
+
* needs this on a schedule — `retention` is this call on a timer. Each call
|
|
171
190
|
* is bounded by `limit` to keep the write short; loop until it returns fewer
|
|
172
191
|
* than `limit`.
|
|
173
192
|
*
|
|
@@ -177,6 +196,18 @@ export class CairnQ {
|
|
|
177
196
|
return this._store.purge(input);
|
|
178
197
|
}
|
|
179
198
|
|
|
199
|
+
/** Task counts per queue, keyed by status and zero-filled across all statuses
|
|
200
|
+
* — `(await stats()).default.queued` is the backlog of a queue. `queue` narrows
|
|
201
|
+
* the aggregate to one queue, which is also what keeps a caller from paying for
|
|
202
|
+
* the other workloads sharing the installation; a named queue is always
|
|
203
|
+
* present, zero-filled if it has no rows.
|
|
204
|
+
*
|
|
205
|
+
* This counts rows, so it costs what it counts — use it for a dashboard, and
|
|
206
|
+
* poll `queueDepth()` instead, which is bounded. */
|
|
207
|
+
stats(queue?: string): Promise<Record<string, Record<TaskStatus, number>>> {
|
|
208
|
+
return this._store.stats(queue);
|
|
209
|
+
}
|
|
210
|
+
|
|
180
211
|
/** Wait for a task to finish. Resolves with the terminal Task (any status);
|
|
181
212
|
* throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
|
|
182
213
|
* same wait back up — from another process, or after a longer deadline. */
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { CairnQ } from "./client.js";
|
|
2
2
|
export type { CallOptions, ClientOptions, SubmitOptions, WaitOptions } from "./client.js";
|
|
3
|
+
export type { RetentionCutoffs, RetentionOptions, RetentionRule } from "./retention.js";
|
|
3
4
|
export { Worker } from "./worker.js";
|
|
4
5
|
export type { BatchHandler, Handler, TypedHandler, WorkerOptions } from "./worker.js";
|
|
5
6
|
export { TaskContext } from "./context.js";
|
package/src/retention.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TerminalStatus } from "./models.js";
|
|
2
|
+
import { validatePurgeInput, type PurgeInput, type TaskStore } from "./store/base.js";
|
|
2
3
|
|
|
3
4
|
/** Sweep every hour unless asked otherwise — often enough that a queue with a
|
|
4
5
|
* day of retention never carries more than an hour of extra rows, rare enough
|
|
@@ -8,17 +9,83 @@ const DEFAULT_INTERVAL_MS = 3_600_000;
|
|
|
8
9
|
* a backlog drains in few statements, small enough that each is a short write. */
|
|
9
10
|
const DEFAULT_LIMIT = 1_000;
|
|
10
11
|
|
|
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
|
+
|
|
11
16
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
+
/**
|
|
82
|
+
* Deletes terminal tasks on a schedule, for as long as the handle is open.
|
|
14
83
|
*
|
|
15
84
|
* `purge` exists because nothing else in CairnQ removes rows, and a queue whose
|
|
16
85
|
* payloads carry real data — an image, a document, a batch of embeddings — turns
|
|
17
86
|
* that into a disk leak measured in gigabytes per backfill. Every deployment
|
|
18
87
|
* that runs longer than a demo needs the sweep; leaving it to an external
|
|
19
88
|
* 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.
|
|
22
89
|
*
|
|
23
90
|
* It sweeps in bounded batches with a yield between them, so draining a backlog
|
|
24
91
|
* that accumulated while nothing was sweeping stays a sequence of short writes
|
|
@@ -45,20 +112,33 @@ export class RetentionSweeper {
|
|
|
45
112
|
/** The loop itself, awaited by stop() so no purge outlives the store. */
|
|
46
113
|
private loop: Promise<void> | null = null;
|
|
47
114
|
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[];
|
|
48
120
|
|
|
49
121
|
constructor(
|
|
50
122
|
private readonly store: TaskStore,
|
|
51
|
-
private readonly
|
|
52
|
-
opts: { intervalMs?: number } = {},
|
|
123
|
+
private readonly opts: RetentionOptions,
|
|
53
124
|
) {
|
|
54
|
-
if (!Number.isFinite(olderThanMs) || olderThanMs < 0) {
|
|
55
|
-
throw new Error(`retentionMs must be >= 0, got ${olderThanMs}`);
|
|
56
|
-
}
|
|
57
125
|
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
58
126
|
if (!Number.isFinite(this.intervalMs) || this.intervalMs < 1) {
|
|
59
|
-
throw new Error(`retention
|
|
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");
|
|
60
136
|
}
|
|
61
137
|
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);
|
|
62
142
|
}
|
|
63
143
|
|
|
64
144
|
start(): void {
|
|
@@ -102,9 +182,13 @@ export class RetentionSweeper {
|
|
|
102
182
|
if (this.stopping) return;
|
|
103
183
|
try {
|
|
104
184
|
await this.sweep();
|
|
105
|
-
} catch {
|
|
106
|
-
|
|
107
|
-
|
|
185
|
+
} catch (err) {
|
|
186
|
+
try {
|
|
187
|
+
this.opts.onError?.(err);
|
|
188
|
+
} catch {
|
|
189
|
+
// A reporting hook must never take the sweep down with it — the same
|
|
190
|
+
// rule the worker's onError follows.
|
|
191
|
+
}
|
|
108
192
|
}
|
|
109
193
|
}
|
|
110
194
|
}
|
|
@@ -116,14 +200,16 @@ export class RetentionSweeper {
|
|
|
116
200
|
*/
|
|
117
201
|
async sweep(): Promise<number> {
|
|
118
202
|
let deleted = 0;
|
|
119
|
-
for (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
203
|
+
for (const input of this.purgeInputs) {
|
|
204
|
+
for (;;) {
|
|
205
|
+
const ids = await this.store.purge(input);
|
|
206
|
+
deleted += ids.length;
|
|
207
|
+
if (this.stopping) return deleted;
|
|
208
|
+
if (ids.length < this.limit) break;
|
|
209
|
+
// Hand the loop back between batches: a large drain must not starve the
|
|
210
|
+
// submits and claims sharing this process. Ref'd — see sleep().
|
|
211
|
+
await this.sleep(0, true);
|
|
212
|
+
}
|
|
127
213
|
}
|
|
128
214
|
return deleted;
|
|
129
215
|
}
|
package/src/store/base.ts
CHANGED
|
@@ -652,6 +652,38 @@ export abstract class TaskStore {
|
|
|
652
652
|
return Number(rows[0]?.headroom ?? 0);
|
|
653
653
|
}
|
|
654
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Task counts grouped by queue, keyed by status and zero-filled —
|
|
657
|
+
* `(await stats()).default.queued` is the backlog of a queue. A queue appears
|
|
658
|
+
* only while it has rows; terminal tasks keep counting until `purge` removes
|
|
659
|
+
* them.
|
|
660
|
+
*
|
|
661
|
+
* `queue` restricts the aggregate to one queue, which is also what stops the
|
|
662
|
+
* caller paying for every other queue's rows: one installation carrying two
|
|
663
|
+
* workloads is the coordination this project recommends, and the unfiltered
|
|
664
|
+
* form reads the whole table. A named queue is always present in the result,
|
|
665
|
+
* zero-filled if it has no rows at all — asking about a specific queue and
|
|
666
|
+
* getting `undefined` back would make every caller write the same fallback.
|
|
667
|
+
*
|
|
668
|
+
* Filtered or not, this COUNTS, so it costs what it counts: a whole queue,
|
|
669
|
+
* terminal rows included. Right for a dashboard, wrong on an interval — poll
|
|
670
|
+
* `queueDepth`, which is bounded, and keep this for when the real numbers are
|
|
671
|
+
* the point.
|
|
672
|
+
*/
|
|
673
|
+
async stats(queue?: string): Promise<Record<string, Record<TaskStatus, number>>> {
|
|
674
|
+
const zeros = (): Record<TaskStatus, number> =>
|
|
675
|
+
Object.fromEntries(STATUSES.map((s) => [s, 0])) as Record<TaskStatus, number>;
|
|
676
|
+
const out: Record<string, Record<TaskStatus, number>> = {};
|
|
677
|
+
// Seed before the query, not after: a named queue with no rows returns no
|
|
678
|
+
// rows to seed from, and that is exactly the case the promise is about.
|
|
679
|
+
if (queue != null) out[queue] = zeros();
|
|
680
|
+
for (const row of await this.fetch("stats", { queue: queue ?? null })) {
|
|
681
|
+
const per = (out[row.queue] ??= zeros());
|
|
682
|
+
per[row.status as TaskStatus] = Number(row.count);
|
|
683
|
+
}
|
|
684
|
+
return out;
|
|
685
|
+
}
|
|
686
|
+
|
|
655
687
|
// ------------------------------------------------------------- worker side
|
|
656
688
|
/**
|
|
657
689
|
* Take up to `limit` claimable tasks. `names` restricts the claim to task names
|
package/src/worker.ts
CHANGED
|
@@ -281,6 +281,13 @@ export class Worker {
|
|
|
281
281
|
private readonly backoffMaxMs: number;
|
|
282
282
|
private stopped = false;
|
|
283
283
|
private stopWake!: () => void;
|
|
284
|
+
// Re-armed before each claim, resolved when a call gives a resource unit
|
|
285
|
+
// back. A claim can come back empty because a resource is at capacity rather
|
|
286
|
+
// than the queue, and that release is local — no store notification covers
|
|
287
|
+
// it, so the idle sleep waits on this too. Armed before the claim, so a
|
|
288
|
+
// release landing *during* it still shortens the sleep that follows.
|
|
289
|
+
private freed$ = Promise.resolve();
|
|
290
|
+
private freedWake: () => void = () => {};
|
|
284
291
|
// Resolved once by stop(); every sleep races against it. A stopped worker
|
|
285
292
|
// never restarts, so one promise serves the instance's lifetime.
|
|
286
293
|
private readonly stopped$ = new Promise<void>((r) => (this.stopWake = r));
|
|
@@ -519,6 +526,7 @@ export class Worker {
|
|
|
519
526
|
await this.idle(pollMs);
|
|
520
527
|
continue;
|
|
521
528
|
}
|
|
529
|
+
this.freed$ = new Promise<void>((r) => (this.freedWake = r));
|
|
522
530
|
let drawn: Draw[] | undefined;
|
|
523
531
|
try {
|
|
524
532
|
drawn = await this.store.claimSession(
|
|
@@ -536,7 +544,7 @@ export class Worker {
|
|
|
536
544
|
continue;
|
|
537
545
|
}
|
|
538
546
|
if (!drawn?.length) {
|
|
539
|
-
await this.idle(pollMs);
|
|
547
|
+
await this.idle(pollMs, this.freed$);
|
|
540
548
|
continue;
|
|
541
549
|
}
|
|
542
550
|
for (const { src, calls } of drawn) {
|
|
@@ -552,6 +560,7 @@ export class Worker {
|
|
|
552
560
|
const p = call.finally(() => {
|
|
553
561
|
this.planner.release(resource);
|
|
554
562
|
running.delete(p);
|
|
563
|
+
if (resource != null) this.freedWake();
|
|
555
564
|
});
|
|
556
565
|
running.add(p);
|
|
557
566
|
}
|
|
@@ -929,9 +938,14 @@ export class Worker {
|
|
|
929
938
|
* cuts it short when a task on this worker's queues becomes claimable;
|
|
930
939
|
* stop() interrupts it either way, and sleepOrStop bounds it at `ms` so the
|
|
931
940
|
* poll fallback — which also drives lease recovery — never stretches.
|
|
941
|
+
*
|
|
942
|
+
* `freed` is the third way out: a resource unit coming back frees a claim the
|
|
943
|
+
* store has no reason to announce (see `freed$`).
|
|
932
944
|
*/
|
|
933
|
-
private idle(ms: number): Promise<void> {
|
|
934
|
-
|
|
945
|
+
private idle(ms: number, freed?: Promise<void>): Promise<void> {
|
|
946
|
+
const racers = [this.sleepOrStop(ms), this.store.claimWake(this.queues, ms)];
|
|
947
|
+
if (freed) racers.push(freed);
|
|
948
|
+
return Promise.race(racers);
|
|
935
949
|
}
|
|
936
950
|
|
|
937
951
|
private sleepOrStop(ms: number): Promise<void> {
|