cairnq 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ -- How many more tasks fit in one queue under :max_depth. Read-only.
2
+ --
3
+ -- Returns headroom, not depth: a producer needs "may I enqueue, and how many
4
+ -- more" — and bounding the scan at :max_depth is what keeps this affordable.
5
+ -- COUNT over the whole backlog would read every queued row, so the cost of
6
+ -- asking would grow with exactly the pile-up the caller is trying to stop.
7
+ -- Wrapped as a LIMIT subquery it reads at most :max_depth index entries off
8
+ -- cairnq_tasks_claim_idx (queue, status leading), and headroom saturates at 0
9
+ -- once the queue is full — which is all a gate needs to know.
10
+ --
11
+ -- Counts 'queued' only. A running task already has a worker and is bounded by
12
+ -- that worker's concurrency; the backlog worth pushing back on is the work
13
+ -- nobody has picked up. Delayed tasks (run_at_ms in the future) count: they are
14
+ -- queued work that will run, and excluding them would let an unbounded pile of
15
+ -- them through the gate.
16
+ -- params: queue, max_depth
17
+ select :max_depth - count(*) as headroom
18
+ from (
19
+ select 1 from cairnq_tasks
20
+ where queue = :queue and status = 'queued'
21
+ limit :max_depth
22
+ ) probe;
@@ -0,0 +1,26 @@
1
+ -- How many more tasks fit in one queue under :max_depth. Read-only.
2
+ --
3
+ -- Returns headroom, not depth: a producer needs "may I enqueue, and how many
4
+ -- more" — and bounding the scan at :max_depth is what keeps this affordable.
5
+ -- COUNT over the whole backlog would read every queued row, so the cost of
6
+ -- asking would grow with exactly the pile-up the caller is trying to stop.
7
+ -- Wrapped as a LIMIT subquery it reads at most :max_depth index entries off
8
+ -- cairnq_tasks_claim_idx (queue, status leading), and headroom saturates at 0
9
+ -- once the queue is full — which is all a gate needs to know.
10
+ --
11
+ -- Counts 'queued' only. A running task already has a worker and is bounded by
12
+ -- that worker's concurrency; the backlog worth pushing back on is the work
13
+ -- nobody has picked up. Delayed tasks (run_at_ms in the future) count: they are
14
+ -- queued work that will run, and excluding them would let an unbounded pile of
15
+ -- them through the gate.
16
+ --
17
+ -- Read-only, and it must stay that way: isWriteStatement/_is_write_statement
18
+ -- route a non-select into the group commit, which would put a gate probe behind
19
+ -- SQLite's write lock — the opposite of what a backpressure check is for.
20
+ -- params: queue, max_depth
21
+ select :max_depth - count(*) as headroom
22
+ from (
23
+ select 1 from cairnq_tasks
24
+ where queue = :queue and status = 'queued'
25
+ limit :max_depth
26
+ ) probe;
@@ -0,0 +1,59 @@
1
+ import type { TaskStore } from "./store/base.js";
2
+ /** Per-queue depth limits. A number applies one limit to every queue; a record
3
+ * gates only the queues it names and leaves the rest unbounded. */
4
+ export type QueueDepthLimit = number | Record<string, number>;
5
+ export interface BackpressureOptions {
6
+ /** Queued tasks a queue may hold before `submit` blocks. */
7
+ maxQueueDepth: QueueDepthLimit;
8
+ /** How long a blocked submit waits before raising QueueFull. Default 600_000. */
9
+ maxQueueWaitMs?: number;
10
+ /** First backoff between depth probes; doubles to a 5s ceiling. Default 250. */
11
+ queuePollIntervalMs?: number;
12
+ }
13
+ /**
14
+ * Blocks `submit` while a queue is at its depth limit.
15
+ *
16
+ * Without one of these a producer that outruns its workers is only bounded by
17
+ * disk: the backlog grows, every task's queue wait grows with it, and the
18
+ * failure is a database that filled up rather than a producer that slowed down.
19
+ * A queue is the wrong place to buffer an overload — pushing back on the
20
+ * producer is the point.
21
+ *
22
+ * **A soft limit under several producers.** The check is a read followed by a
23
+ * write that other producers can interleave with, and each holds its own grant,
24
+ * so N producers can overshoot the limit by up to (N-1) * MAX_GRANT tasks. Made
25
+ * exact it would need the depth check inside insert_task's transaction, which
26
+ * puts an unbounded-scan predicate on the hot path of every submit and turns
27
+ * concurrent submits into lock contention — a steep price for a bound whose
28
+ * whole purpose is approximate. Size the limit for the pushback you want, not as
29
+ * a capacity assertion.
30
+ */
31
+ export declare class QueueDepthGate {
32
+ private readonly store;
33
+ /** Remaining grant per queue: submits allowed before the next probe. */
34
+ private readonly headroom;
35
+ /** In-flight probe per queue, so concurrent submits share one read rather
36
+ * than each issuing their own against a queue that is already known full. */
37
+ private readonly probing;
38
+ private readonly limits;
39
+ private readonly maxWaitMs;
40
+ private readonly initialProbeMs;
41
+ constructor(store: TaskStore, opts: BackpressureOptions);
42
+ private validate;
43
+ /** The limit for `queue`, or null when it is not gated. */
44
+ limitFor(queue: string): number | null;
45
+ /**
46
+ * Consume one unit of headroom for `queue`, waiting for room if it is full.
47
+ * Returns immediately for an ungated queue. Raises QueueFull on timeout,
48
+ * having enqueued nothing.
49
+ */
50
+ acquire(queue: string): Promise<void>;
51
+ /**
52
+ * Refresh `queue`'s grant from the store, at most one probe in flight.
53
+ *
54
+ * Callers re-read `headroom` afterwards rather than using a returned value:
55
+ * only the caller that started the probe writes the grant, so waiters that
56
+ * joined it cannot overwrite the units already handed out.
57
+ */
58
+ private probe;
59
+ }
@@ -0,0 +1,122 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { QueueFull } from "./errors.js";
3
+ /**
4
+ * Most tasks a producer may enqueue on one probe's word.
5
+ *
6
+ * The gate probes only when its headroom runs out, so this is what the check
7
+ * costs amortized: one bounded index read per MAX_GRANT submits. It also bounds
8
+ * how far the limit can be overshot — see the class docstring on why several
9
+ * producers make this a soft limit, and why that overshoot is (N-1) * MAX_GRANT
10
+ * rather than unbounded.
11
+ */
12
+ const MAX_GRANT = 64;
13
+ // Named for probing, not polling: wait.ts exports DEFAULT_POLL_MS / MAX_POLL_MS
14
+ // for the get() loop behind wait(), an order of magnitude tighter and answering
15
+ // a different question. Two constants of the same name in one SDK would be read
16
+ // as one policy.
17
+ const INITIAL_PROBE_INTERVAL_MS = 250;
18
+ const MAX_PROBE_INTERVAL_MS = 5_000;
19
+ const DEFAULT_MAX_WAIT_MS = 600_000;
20
+ /**
21
+ * Blocks `submit` while a queue is at its depth limit.
22
+ *
23
+ * Without one of these a producer that outruns its workers is only bounded by
24
+ * disk: the backlog grows, every task's queue wait grows with it, and the
25
+ * failure is a database that filled up rather than a producer that slowed down.
26
+ * A queue is the wrong place to buffer an overload — pushing back on the
27
+ * producer is the point.
28
+ *
29
+ * **A soft limit under several producers.** The check is a read followed by a
30
+ * write that other producers can interleave with, and each holds its own grant,
31
+ * so N producers can overshoot the limit by up to (N-1) * MAX_GRANT tasks. Made
32
+ * exact it would need the depth check inside insert_task's transaction, which
33
+ * puts an unbounded-scan predicate on the hot path of every submit and turns
34
+ * concurrent submits into lock contention — a steep price for a bound whose
35
+ * whole purpose is approximate. Size the limit for the pushback you want, not as
36
+ * a capacity assertion.
37
+ */
38
+ export class QueueDepthGate {
39
+ store;
40
+ /** Remaining grant per queue: submits allowed before the next probe. */
41
+ headroom = new Map();
42
+ /** In-flight probe per queue, so concurrent submits share one read rather
43
+ * than each issuing their own against a queue that is already known full. */
44
+ probing = new Map();
45
+ limits;
46
+ maxWaitMs;
47
+ initialProbeMs;
48
+ constructor(store, opts) {
49
+ this.store = store;
50
+ this.limits = opts.maxQueueDepth;
51
+ this.maxWaitMs = opts.maxQueueWaitMs ?? DEFAULT_MAX_WAIT_MS;
52
+ this.initialProbeMs = opts.queuePollIntervalMs ?? INITIAL_PROBE_INTERVAL_MS;
53
+ if (typeof this.limits === "number")
54
+ this.validate("*", this.limits);
55
+ else
56
+ for (const [q, v] of Object.entries(this.limits))
57
+ this.validate(q, v);
58
+ }
59
+ validate(queue, limit) {
60
+ // A limit of 0 would block every submit forever, which is never what a
61
+ // caller means; catching it here beats a first submit that hangs for
62
+ // maxQueueWaitMs and then raises.
63
+ if (!Number.isInteger(limit) || limit < 1) {
64
+ throw new Error(`maxQueueDepth for ${queue} must be an integer >= 1, got ${limit}`);
65
+ }
66
+ }
67
+ /** The limit for `queue`, or null when it is not gated. */
68
+ limitFor(queue) {
69
+ if (typeof this.limits === "number")
70
+ return this.limits;
71
+ return this.limits[queue] ?? null;
72
+ }
73
+ /**
74
+ * Consume one unit of headroom for `queue`, waiting for room if it is full.
75
+ * Returns immediately for an ungated queue. Raises QueueFull on timeout,
76
+ * having enqueued nothing.
77
+ */
78
+ async acquire(queue) {
79
+ const limit = this.limitFor(queue);
80
+ if (limit == null)
81
+ return;
82
+ const startedAt = Date.now();
83
+ let waitMs = this.initialProbeMs;
84
+ for (;;) {
85
+ const left = this.headroom.get(queue) ?? 0;
86
+ if (left > 0) {
87
+ this.headroom.set(queue, left - 1);
88
+ return;
89
+ }
90
+ await this.probe(queue, limit);
91
+ if ((this.headroom.get(queue) ?? 0) > 0)
92
+ continue;
93
+ const waited = Date.now() - startedAt;
94
+ if (waited >= this.maxWaitMs)
95
+ throw new QueueFull(queue, limit, waited);
96
+ // Back off: a queue at its limit will not drain within one poll interval,
97
+ // and re-probing tightly adds read load to a database already behind.
98
+ await delay(Math.min(waitMs, this.maxWaitMs - waited));
99
+ waitMs = Math.min(waitMs * 2, MAX_PROBE_INTERVAL_MS);
100
+ }
101
+ }
102
+ /**
103
+ * Refresh `queue`'s grant from the store, at most one probe in flight.
104
+ *
105
+ * Callers re-read `headroom` afterwards rather than using a returned value:
106
+ * only the caller that started the probe writes the grant, so waiters that
107
+ * joined it cannot overwrite the units already handed out.
108
+ */
109
+ probe(queue, limit) {
110
+ let p = this.probing.get(queue);
111
+ if (!p) {
112
+ p = this.store
113
+ .queueDepth(queue, limit)
114
+ .then((headroom) => {
115
+ this.headroom.set(queue, Math.min(headroom, MAX_GRANT));
116
+ })
117
+ .finally(() => this.probing.delete(queue));
118
+ this.probing.set(queue, p);
119
+ }
120
+ return p;
121
+ }
122
+ }
package/dist/client.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BackpressureOptions } from "./backpressure.js";
1
2
  import { type Task, type TaskStatus } from "./models.js";
2
3
  import type { ListInput, PurgeInput, SubmitInput, TaskStore } from "./store/base.js";
3
4
  import { type TaskDef } from "./task.js";
@@ -6,23 +7,35 @@ export interface CallOptions extends SubmitOptions {
6
7
  waitTimeoutMs?: number;
7
8
  pollMs?: number;
8
9
  }
10
+ /** Options this handle configures on the store it wraps, rather than the
11
+ * store's own constructor arguments. */
12
+ export type ClientOptions = Partial<BackpressureOptions>;
9
13
  /** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
10
14
  export declare class CairnQ {
11
15
  private readonly _store;
12
- constructor(_store: TaskStore);
16
+ constructor(_store: TaskStore, opts?: ClientOptions);
13
17
  static sqlite(path: string, opts?: {
14
18
  busyTimeoutMs?: number;
15
- }): CairnQ;
19
+ } & ClientOptions): CairnQ;
16
20
  /** Multi-host backend. `dsn` is a libpq connection string; requires the
17
21
  * optional `pg` package. */
18
22
  static postgres(dsn: string, opts?: {
19
23
  max?: number;
20
- }): CairnQ;
24
+ } & ClientOptions): CairnQ;
21
25
  get store(): TaskStore;
22
26
  connect(): Promise<void>;
23
27
  close(): Promise<void>;
28
+ /** Enqueue a task. With `maxQueueDepth` configured this blocks while the
29
+ * target queue is at its limit, and raises QueueFull if it stays there for
30
+ * `maxQueueWaitMs` — see QueueDepthGate for why that bound is approximate
31
+ * across several producers. */
24
32
  submit(name: string, payload?: unknown, opts?: SubmitOptions): Promise<Task>;
25
33
  submit<P, R>(task: TaskDef<P, R>, payload?: P, opts?: SubmitOptions): Promise<Task>;
34
+ /** How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
35
+ * The non-blocking read behind `maxQueueDepth`, for a producer that would
36
+ * rather shed load or pick another queue than wait. Cheaper than `stats()`:
37
+ * bounded at `maxDepth` index entries instead of aggregating the table. */
38
+ queueDepth(queue: string, maxDepth: number): Promise<number>;
26
39
  get(taskId: string): Promise<Task | null>;
27
40
  getByKey(key: string): Promise<Task | null>;
28
41
  list(input?: ListInput): Promise<Task[]>;
package/dist/client.js CHANGED
@@ -7,16 +7,23 @@ import { pollWait } from "./wait.js";
7
7
  /** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
8
8
  export class CairnQ {
9
9
  _store;
10
- constructor(_store) {
10
+ constructor(_store, opts = {}) {
11
11
  this._store = _store;
12
+ // Installed on the store, not held here: every submit path goes through the
13
+ // store, including TaskContext.submit, which this handle never sees.
14
+ if (opts.maxQueueDepth != null) {
15
+ _store.useBackpressure(opts);
16
+ }
12
17
  }
13
- static sqlite(path, opts) {
14
- return new CairnQ(new SQLiteStore(path, opts));
18
+ static sqlite(path, opts = {}) {
19
+ const { busyTimeoutMs, ...client } = opts;
20
+ return new CairnQ(new SQLiteStore(path, { busyTimeoutMs }), client);
15
21
  }
16
22
  /** Multi-host backend. `dsn` is a libpq connection string; requires the
17
23
  * optional `pg` package. */
18
- static postgres(dsn, opts) {
19
- return new CairnQ(new PostgresStore(dsn, opts));
24
+ static postgres(dsn, opts = {}) {
25
+ const { max, ...client } = opts;
26
+ return new CairnQ(new PostgresStore(dsn, { max }), client);
20
27
  }
21
28
  get store() {
22
29
  return this._store;
@@ -30,6 +37,13 @@ export class CairnQ {
30
37
  submit(task, payload, opts = {}) {
31
38
  return this._store.submit({ name: taskName(task), payload, ...opts });
32
39
  }
40
+ /** How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
41
+ * The non-blocking read behind `maxQueueDepth`, for a producer that would
42
+ * rather shed load or pick another queue than wait. Cheaper than `stats()`:
43
+ * bounded at `maxDepth` index entries instead of aggregating the table. */
44
+ queueDepth(queue, maxDepth) {
45
+ return this._store.queueDepth(queue, maxDepth);
46
+ }
33
47
  get(taskId) {
34
48
  return this._store.get(taskId);
35
49
  }
package/dist/errors.d.ts CHANGED
@@ -16,6 +16,16 @@ export declare class AlreadyExists extends CairnQError {
16
16
  key: string;
17
17
  constructor(key: string);
18
18
  }
19
+ /** A gated submit waited out `maxWaitMs` without the queue draining below its
20
+ * depth limit. Nothing was enqueued. Distinct from a slow submit on purpose: a
21
+ * queue this far behind is a capacity problem, and a caller that silently
22
+ * retries forever converts it into an invisible one. */
23
+ export declare class QueueFull extends CairnQError {
24
+ queue: string;
25
+ maxDepth: number;
26
+ waitedMs: number;
27
+ constructor(queue: string, maxDepth: number, waitedMs: number);
28
+ }
19
29
  /** wait/call did not reach a terminal status in time. The task keeps running.
20
30
  * `task` is the last snapshot wait() observed (null if get() found nothing), and
21
31
  * the message says what state it was stuck in — a queued-never-claimed task is
package/dist/errors.js CHANGED
@@ -29,6 +29,23 @@ export class AlreadyExists extends CairnQError {
29
29
  this.name = "AlreadyExists";
30
30
  }
31
31
  }
32
+ /** A gated submit waited out `maxWaitMs` without the queue draining below its
33
+ * depth limit. Nothing was enqueued. Distinct from a slow submit on purpose: a
34
+ * queue this far behind is a capacity problem, and a caller that silently
35
+ * retries forever converts it into an invisible one. */
36
+ export class QueueFull extends CairnQError {
37
+ queue;
38
+ maxDepth;
39
+ waitedMs;
40
+ constructor(queue, maxDepth, waitedMs) {
41
+ super(`queue ${queue} still holds ${maxDepth} or more queued tasks after ` +
42
+ `${waitedMs}ms; refusing to enqueue more`);
43
+ this.queue = queue;
44
+ this.maxDepth = maxDepth;
45
+ this.waitedMs = waitedMs;
46
+ this.name = "QueueFull";
47
+ }
48
+ }
32
49
  /** One line of "why hasn't this finished" from the last snapshot wait()
33
50
  * observed. No worker running, no handler for the name, wrong queue, and two
34
51
  * processes on different database files all look identical from the API side —
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { CairnQ } from "./client.js";
2
- export type { CallOptions, SubmitOptions } from "./client.js";
2
+ export type { CallOptions, ClientOptions, SubmitOptions } from "./client.js";
3
+ export { QueueDepthGate } from "./backpressure.js";
4
+ export type { BackpressureOptions, QueueDepthLimit } from "./backpressure.js";
3
5
  export { Worker } from "./worker.js";
4
6
  export type { Handler, TypedHandler, WorkerOptions } from "./worker.js";
5
7
  export { TaskContext } from "./context.js";
@@ -11,4 +13,4 @@ export { TaskStore } from "./store/base.js";
11
13
  export type { ListInput, PurgeInput, SubmitInput, Conflict } from "./store/base.js";
12
14
  export type { Task, TaskStatus } from "./models.js";
13
15
  export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
14
- export { CairnQError, AlreadyExists, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
16
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { CairnQ } from "./client.js";
2
+ export { QueueDepthGate } from "./backpressure.js";
2
3
  export { Worker } from "./worker.js";
3
4
  export { TaskContext } from "./context.js";
4
5
  export { defineTask } from "./task.js";
@@ -6,4 +7,4 @@ export { SQLiteStore } from "./store/sqlite.js";
6
7
  export { PostgresStore } from "./store/postgres.js";
7
8
  export { TaskStore } from "./store/base.js";
8
9
  export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
9
- export { CairnQError, AlreadyExists, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
10
+ export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
@@ -1,4 +1,5 @@
1
1
  import { type Task, type TaskStatus } from "../models.js";
2
+ import { type BackpressureOptions } from "../backpressure.js";
2
3
  /** Encode a value for a protocol JSON column, raising SerializationError on
3
4
  * anything JSON cannot represent. Refuses what JSON.stringify would silently
4
5
  * mangle into `null`: NaN/Infinity anywhere, undefined/function/symbol inside an
@@ -12,6 +13,9 @@ export declare function dumpJson(value: unknown): string;
12
13
  export declare function checkProtocolVersion(version: number): void;
13
14
  declare const CONFLICTS: readonly ["reuse", "reject", "replace"];
14
15
  export type Conflict = (typeof CONFLICTS)[number];
16
+ /** The queue a submit lands on when it names none. Owned here, where the
17
+ * default is applied, so nothing above has to re-derive it. */
18
+ export declare const DEFAULT_QUEUE = "default";
15
19
  export interface SubmitInput {
16
20
  name: string;
17
21
  payload: unknown;
@@ -69,6 +73,8 @@ export declare function statementParams(sql: string): readonly string[];
69
73
  * behavior; the shared SQL already stops them from drifting in wording.
70
74
  */
71
75
  export declare abstract class TaskStore {
76
+ /** Set by useBackpressure; null means submit is ungated. */
77
+ private gate;
72
78
  abstract connect(): Promise<void>;
73
79
  abstract close(): Promise<void>;
74
80
  abstract protocolVersion(): Promise<number>;
@@ -103,6 +109,15 @@ export declare abstract class TaskStore {
103
109
  */
104
110
  private ownedWrite;
105
111
  private static one;
112
+ /**
113
+ * Bound how deep a queue may get before `submit` blocks. Off unless set.
114
+ *
115
+ * It hangs here rather than on `CairnQ` because the store is the one choke
116
+ * point every submit passes through — a handler spawning children via
117
+ * `TaskContext.submit` is the shape most likely to outrun its workers, and
118
+ * gating only the client would leave exactly that path unbounded.
119
+ */
120
+ useBackpressure(opts: BackpressureOptions): void;
106
121
  submit(input: SubmitInput): Promise<Task>;
107
122
  get(taskId: string): Promise<Task | null>;
108
123
  getByKey(key: string): Promise<Task | null>;
@@ -136,6 +151,15 @@ export declare abstract class TaskStore {
136
151
  * them.
137
152
  */
138
153
  stats(): Promise<Record<string, Record<TaskStatus, number>>>;
154
+ /**
155
+ * How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
156
+ *
157
+ * The cheap half of backpressure: bounded at `maxDepth` index entries, unlike
158
+ * `stats()`, which aggregates the whole table (terminal rows included) and so
159
+ * costs more the longer a database has been running. Use it directly to shed
160
+ * load or shape a producer; `QueueDepthGate` builds the blocking form on top.
161
+ */
162
+ queueDepth(queue: string, maxDepth: number): Promise<number>;
139
163
  /**
140
164
  * Take up to `limit` claimable tasks. `names` restricts the claim to task names
141
165
  * this caller can actually run — a worker passes its registered handlers.
@@ -1,6 +1,7 @@
1
1
  import { newId } from "../ids.js";
2
2
  import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch, SerializationError, } from "../errors.js";
3
3
  import { rowToTask, STATUSES } from "../models.js";
4
+ import { QueueDepthGate } from "../backpressure.js";
4
5
  const rejectMangled = function (_key, v) {
5
6
  if (typeof v === "number" && !Number.isFinite(v)) {
6
7
  throw new SerializationError(`non-finite number ${v} is not JSON-serializable`);
@@ -51,6 +52,9 @@ export function checkProtocolVersion(version) {
51
52
  // runtime guard in submit() and the type can't drift apart (same pattern as
52
53
  // STATUSES/TaskStatus in models.ts).
53
54
  const CONFLICTS = ["reuse", "reject", "replace"];
55
+ /** The queue a submit lands on when it names none. Owned here, where the
56
+ * default is applied, so nothing above has to re-derive it. */
57
+ export const DEFAULT_QUEUE = "default";
54
58
  export const LEASE_EXPIRED_ERROR_JSON = dumpJson(errorEnvelope({
55
59
  type: "LeaseExpired",
56
60
  code: "lease_expired",
@@ -97,6 +101,8 @@ export function statementParams(sql) {
97
101
  * behavior; the shared SQL already stops them from drifting in wording.
98
102
  */
99
103
  export class TaskStore {
104
+ /** Set by useBackpressure; null means submit is ungated. */
105
+ gate = null;
100
106
  /**
101
107
  * Whether it is worth opening the claim transaction at all. SQLite gates its
102
108
  * single write lock behind a read-only probe; Postgres readers don't block
@@ -137,12 +143,23 @@ export class TaskStore {
137
143
  return rows.length ? rowToTask(rows[0]) : null;
138
144
  }
139
145
  // ------------------------------------------------------------- client side
146
+ /**
147
+ * Bound how deep a queue may get before `submit` blocks. Off unless set.
148
+ *
149
+ * It hangs here rather than on `CairnQ` because the store is the one choke
150
+ * point every submit passes through — a handler spawning children via
151
+ * `TaskContext.submit` is the shape most likely to outrun its workers, and
152
+ * gating only the client would leave exactly that path unbounded.
153
+ */
154
+ useBackpressure(opts) {
155
+ this.gate = new QueueDepthGate(this, opts);
156
+ }
140
157
  async submit(input) {
141
158
  const id = newId("task");
142
159
  const ins = {
143
160
  id,
144
161
  name: input.name,
145
- queue: input.queue ?? "default",
162
+ queue: input.queue ?? DEFAULT_QUEUE,
146
163
  payload: dumpJson(input.payload ?? {}),
147
164
  metadata: dumpJson(input.metadata ?? {}),
148
165
  max_attempts: input.maxAttempts ?? 3,
@@ -169,6 +186,11 @@ export class TaskStore {
169
186
  if (input.runAtDelayMs != null && input.runAtDelayMs < 0) {
170
187
  throw new Error(`runAtDelayMs must be >= 0, got ${input.runAtDelayMs}`);
171
188
  }
189
+ // After validation and before the first write: bad arguments should fail
190
+ // now, not after waiting out a full queue. Reads the resolved queue, so the
191
+ // gate cannot throttle one queue while the row lands on another.
192
+ if (this.gate)
193
+ await this.gate.acquire(ins.queue);
172
194
  if (key === null)
173
195
  return rowToTask((await this.fetch("insert_task", ins))[0]);
174
196
  // A key makes submit a read-then-write, so it has to be one transaction —
@@ -284,6 +306,21 @@ export class TaskStore {
284
306
  }
285
307
  return out;
286
308
  }
309
+ /**
310
+ * How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
311
+ *
312
+ * The cheap half of backpressure: bounded at `maxDepth` index entries, unlike
313
+ * `stats()`, which aggregates the whole table (terminal rows included) and so
314
+ * costs more the longer a database has been running. Use it directly to shed
315
+ * load or shape a producer; `QueueDepthGate` builds the blocking form on top.
316
+ */
317
+ async queueDepth(queue, maxDepth) {
318
+ if (!Number.isInteger(maxDepth) || maxDepth < 0) {
319
+ throw new Error(`maxDepth must be a non-negative integer, got ${maxDepth}`);
320
+ }
321
+ const rows = await this.fetch("queue_depth", { queue, max_depth: maxDepth });
322
+ return Number(rows[0]?.headroom ?? 0);
323
+ }
287
324
  // ------------------------------------------------------------- worker side
288
325
  /**
289
326
  * Take up to `limit` claimable tasks. `names` restricts the claim to task names
@@ -35,6 +35,12 @@ export declare class SQLiteStore extends TaskStore {
35
35
  private readonly busyBudgetMs;
36
36
  /** When this connection may next revisit its planner statistics. */
37
37
  private nextStatsRefreshAt;
38
+ /** Which statements are writes — see isWriteStatement. */
39
+ private readonly writes;
40
+ /** Writes waiting to be group-committed — see flush(). */
41
+ private pending;
42
+ /** Whether a flusher is already queued to drain `pending`. */
43
+ private flushing;
38
44
  constructor(path: string, opts?: {
39
45
  busyTimeoutMs?: number;
40
46
  });
@@ -94,6 +100,35 @@ export declare class SQLiteStore extends TaskStore {
94
100
  * interval try, instead of spending an operation's whole retry budget on them.
95
101
  */
96
102
  private maybeRefreshStatistics;
103
+ /**
104
+ * Group commit: one transaction for every write already waiting on the lock.
105
+ *
106
+ * A write costs microseconds to execute and a transaction costs a WAL commit, so
107
+ * N concurrent writes spend nearly all their time on N commits they could have
108
+ * shared. Measured at 200 finalizes: 80µs each one-transaction-apiece against
109
+ * 10µs each in one transaction (`bench/sweep` sweep B).
110
+ *
111
+ * Nothing waits to form a batch — a flusher takes whatever arrived while the
112
+ * previous one held the lock, so this trades no latency for the throughput. What
113
+ * it does trade is atomicity: two callers' writes now land together or not at
114
+ * all. Under at-least-once that is not observable (a lost batch is a
115
+ * redelivery), and it is why every member is resolved only after COMMIT.
116
+ */
117
+ private flush;
118
+ /**
119
+ * Make sure some flusher is draining `pending`, without ever running two.
120
+ *
121
+ * The flusher loops instead of re-arming itself per batch. A caller that awaits
122
+ * its writes one at a time resumes and issues the next one *before* the flusher
123
+ * gets its turn back, so re-arming would cost that write an extra trip through
124
+ * the lock queue — measured as ~2x on sequential writes, which is most of them.
125
+ * Looping picks it up in the same session for free.
126
+ *
127
+ * The exit is safe because the last `pending` check and clearing the flag happen
128
+ * in one synchronous step: a write that arrives before it keeps the loop going,
129
+ * and one that arrives after sees the flag down and starts a new flusher.
130
+ */
131
+ private scheduleFlush;
97
132
  protected fetch(name: string, params: Params): Promise<any[]>;
98
133
  protected tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
99
134
  protected hasClaimableWork(params: Params): Promise<boolean>;