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 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 (per queue, per status) is purge() with filters, from your own
82
- // scheduler.
83
- const tasks = CairnQ.sqlite("tasks.db", { retentionMs: 7 * 24 * 3600_000 });
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 Task } from "./models.js";
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 this many ms after they finished, on a schedule, for
37
- * as long as this handle is open. Off unless set — and off means rows
38
- * accumulate forever, because nothing else in CairnQ removes them. Tiered
39
- * retention (per queue, per status) is `purge()` with filters, from your own
40
- * scheduler.
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
- retentionMs?: number;
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 `retentionMs` was configured. */
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 — `retentionMs` is this call on a timer. Each call
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 `retentionMs` was configured. */
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 = opts.retentionMs != null ? new RetentionSweeper(_store, opts.retentionMs) : null;
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 — `retentionMs` is this call on a timer. Each call
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";
@@ -1,15 +1,67 @@
1
- import type { TaskStore } from "./store/base.js";
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
- * 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.
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 olderThanMs;
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
- constructor(store: TaskStore, olderThanMs: number, opts?: {
42
- intervalMs?: number;
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
- olderThanMs;
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
- constructor(store, olderThanMs, opts = {}) {
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.olderThanMs = olderThanMs;
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 intervalMs must be >= 1, got ${this.intervalMs}`);
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
- // A purge that failed because the database was busy is not a reason to
102
- // stop retaining — the next sweep runs on schedule regardless.
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
- 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);
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
  }
@@ -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.
@@ -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
- return Promise.race([this.sleepOrStop(ms), this.store.claimWake(this.queues, ms)]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairnq",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "SQLite-first, cross-language, storage-centered durable task runtime",
5
5
  "license": "MIT",
6
6
  "author": "Jannchie <jannchie@gmail.com>",
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 this many ms after they finished, on a schedule, for
44
- * as long as this handle is open. Off unless set — and off means rows
45
- * accumulate forever, because nothing else in CairnQ removes them. Tiered
46
- * retention (per queue, per status) is `purge()` with filters, from your own
47
- * scheduler.
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
- retentionMs?: number;
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 `retentionMs` was configured. */
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 = opts.retentionMs != null ? new RetentionSweeper(_store, opts.retentionMs) : null;
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 — `retentionMs` is this call on a timer. Each call
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 { TaskStore } from "./store/base.js";
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
- * 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.
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 olderThanMs: number,
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 intervalMs must be >= 1, got ${this.intervalMs}`);
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
- // A purge that failed because the database was busy is not a reason to
107
- // stop retaining — the next sweep runs on schedule regardless.
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
- 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
+ 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
- return Promise.race([this.sleepOrStop(ms), this.store.claimWake(this.queues, ms)]);
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> {