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.
@@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path";
3
3
  import Database from "better-sqlite3";
4
4
  import { nowMs } from "../ids.js";
5
5
  import { loadMigrations, loadStatements } from "../sql.js";
6
- import { checkProtocolVersion, statementParams, TaskStore, } from "./base.js";
6
+ import { checkProtocolVersion, COMMENT, statementParams, TaskStore, } from "./base.js";
7
7
  const WAL_RETRY_DELAY_MS = 50;
8
8
  const WAL_RETRY_BUDGET_MS = 5_000;
9
9
  const BUSY_RETRY_BASE_MS = 1;
@@ -14,7 +14,7 @@ const BUSY_RETRY_MAX_DELAY_MS = 50;
14
14
  * Bounds how long the planner can work from a stale table shape; a minute is
15
15
  * arbitrary but small next to the days a worker holds its connection. It does not
16
16
  * set how often an ANALYZE actually runs — SQLite decides that itself, and only
17
- * when a table has outgrown its statistics by roughly 24x, so a shorter interval
17
+ * once the table has diverged from its statistics by 10x, so a shorter interval
18
18
  * costs more no-ops (a few microseconds each) rather than more analyzing.
19
19
  */
20
20
  const STATS_REFRESH_INTERVAL_MS = 60_000;
@@ -43,6 +43,20 @@ function isBusy(err) {
43
43
  function isMemory(path) {
44
44
  return path === ":memory:" || path.includes("mode=memory");
45
45
  }
46
+ /**
47
+ * Whether this statement writes, and so belongs in a group commit.
48
+ *
49
+ * Read from the SQL rather than from a list of statement names, which would be a
50
+ * second place to remember when the protocol gains a statement. Every protocol
51
+ * statement is a single top-level `select`, `insert`, `update` or `delete`.
52
+ *
53
+ * Reads must stay out of the batch: `claimable_probe` exists precisely so an idle
54
+ * worker never takes SQLite's write lock, and a BEGIN IMMEDIATE around it would
55
+ * hand that back.
56
+ */
57
+ function isWriteStatement(sql) {
58
+ return !/^\s*select/i.test(sql.replace(COMMENT, ""));
59
+ }
46
60
  /**
47
61
  * Whether cairnq_tasks has been analyzed at all.
48
62
  *
@@ -170,11 +184,18 @@ export class SQLiteStore extends TaskStore {
170
184
  busyBudgetMs;
171
185
  /** When this connection may next revisit its planner statistics. */
172
186
  nextStatsRefreshAt = 0;
187
+ /** Which statements are writes — see isWriteStatement. */
188
+ writes;
189
+ /** Writes waiting to be group-committed — see flush(). */
190
+ pending = [];
191
+ /** Whether a flusher is already queued to drain `pending`. */
192
+ flushing = false;
173
193
  constructor(path, opts = {}) {
174
194
  super();
175
195
  this.path = path;
176
196
  this.busyBudgetMs = opts.busyTimeoutMs ?? 5000;
177
197
  this.statements = loadStatements("sqlite");
198
+ this.writes = Object.fromEntries(Object.entries(this.statements).map(([name, sql]) => [name, isWriteStatement(sql)]));
178
199
  // Only a bare ":memory:" is guaranteed private to its connection, so only
179
200
  // it gets a lock of its own. A "mode=memory" URI stays path-keyed: with
180
201
  // cache=shared it names ONE shared database, and on a build without URI
@@ -389,10 +410,146 @@ export class SQLiteStore extends TaskStore {
389
410
  throw err;
390
411
  }
391
412
  }
413
+ /**
414
+ * Group commit: one transaction for every write already waiting on the lock.
415
+ *
416
+ * A write costs microseconds to execute and a transaction costs a WAL commit, so
417
+ * N concurrent writes spend nearly all their time on N commits they could have
418
+ * shared. Measured at 200 finalizes: 80µs each one-transaction-apiece against
419
+ * 10µs each in one transaction (`bench/sweep` sweep B).
420
+ *
421
+ * Nothing waits to form a batch — a flusher takes whatever arrived while the
422
+ * previous one held the lock, so this trades no latency for the throughput. What
423
+ * it does trade is atomicity: two callers' writes now land together or not at
424
+ * all. Under at-least-once that is not observable (a lost batch is a
425
+ * redelivery), and it is why every member is resolved only after COMMIT.
426
+ */
427
+ flush(db) {
428
+ // One writer waiting is the uncontended case, and it stays exactly as cheap as
429
+ // before: wrapping a single statement in BEGIN/COMMIT would add two statements
430
+ // to every write on an idle store.
431
+ if (this.pending.length === 1) {
432
+ const only = this.pending[0];
433
+ let rows;
434
+ try {
435
+ rows = this.runNow(only.name, only.params);
436
+ }
437
+ catch (err) {
438
+ // Leave it pending on a lost write lock: withLock re-runs this flusher.
439
+ if (isBusy(err))
440
+ throw err;
441
+ this.pending.shift();
442
+ only.reject(err);
443
+ return;
444
+ }
445
+ this.pending.shift();
446
+ only.resolve(rows);
447
+ return;
448
+ }
449
+ // BEGIN before consuming, so a lost write lock leaves the batch where the
450
+ // retry will find it — with anything that arrived meanwhile.
451
+ db.exec("BEGIN IMMEDIATE");
452
+ const batch = this.pending;
453
+ this.pending = [];
454
+ const out = [];
455
+ try {
456
+ for (const w of batch) {
457
+ try {
458
+ out.push({ rows: this.runNow(w.name, w.params) });
459
+ }
460
+ catch (err) {
461
+ // A statement error aborts that statement, not the transaction, so the
462
+ // rest of the batch is still good and this one waiter carries the error.
463
+ // If SQLite tore the transaction down instead, nothing in it survived
464
+ // and every member has to hear about it.
465
+ if (!db.inTransaction)
466
+ throw err;
467
+ out.push({ err });
468
+ }
469
+ }
470
+ db.exec("COMMIT");
471
+ }
472
+ catch (err) {
473
+ if (db.inTransaction) {
474
+ try {
475
+ db.exec("ROLLBACK");
476
+ }
477
+ catch {
478
+ // Raced with SQLite's own rollback; the transaction is gone either way.
479
+ }
480
+ }
481
+ if (isBusy(err)) {
482
+ // Back to the head of the queue, ahead of later arrivals, so the retry
483
+ // preserves the order the writes were issued in.
484
+ this.pending = batch.concat(this.pending);
485
+ throw err;
486
+ }
487
+ for (const w of batch)
488
+ w.reject(err);
489
+ return;
490
+ }
491
+ // Only now: before COMMIT a rollback could still take the write back, and a
492
+ // caller holding its row would have observed a write that never happened.
493
+ for (let i = 0; i < batch.length; i++) {
494
+ // Presence, not truthiness — a thrown value is not guaranteed to be one.
495
+ if ("err" in out[i])
496
+ batch[i].reject(out[i].err);
497
+ else
498
+ batch[i].resolve(out[i].rows);
499
+ }
500
+ }
501
+ /**
502
+ * Make sure some flusher is draining `pending`, without ever running two.
503
+ *
504
+ * The flusher loops instead of re-arming itself per batch. A caller that awaits
505
+ * its writes one at a time resumes and issues the next one *before* the flusher
506
+ * gets its turn back, so re-arming would cost that write an extra trip through
507
+ * the lock queue — measured as ~2x on sequential writes, which is most of them.
508
+ * Looping picks it up in the same session for free.
509
+ *
510
+ * The exit is safe because the last `pending` check and clearing the flag happen
511
+ * in one synchronous step: a write that arrives before it keeps the loop going,
512
+ * and one that arrives after sees the flag down and starts a new flusher.
513
+ */
514
+ scheduleFlush(db) {
515
+ if (this.flushing)
516
+ return;
517
+ this.flushing = true;
518
+ void (async () => {
519
+ try {
520
+ while (this.pending.length) {
521
+ try {
522
+ await this.withLock(() => this.flush(db));
523
+ }
524
+ catch (err) {
525
+ // flush only throws on a lost write lock, and only after putting its
526
+ // batch back — so reaching here means withLock spent the whole budget
527
+ // and those writes are still queued with nobody else coming for them.
528
+ // Anything that arrived behind them is failed with the same error
529
+ // rather than left hanging: this store cannot write at all right now,
530
+ // which is what a lone write would have been told too.
531
+ const stranded = this.pending;
532
+ this.pending = [];
533
+ for (const w of stranded)
534
+ w.reject(err);
535
+ }
536
+ }
537
+ }
538
+ finally {
539
+ this.flushing = false;
540
+ }
541
+ })();
542
+ }
392
543
  async fetch(name, params) {
393
544
  const db = this.ensure();
394
545
  await this.maybeRefreshStatistics(db);
395
- return this.withLock(() => this.runNow(name, params));
546
+ // Reads keep their own turn on the lock — see isWriteStatement.
547
+ if (!this.writes[name])
548
+ return this.withLock(() => this.runNow(name, params));
549
+ return new Promise((resolve, reject) => {
550
+ this.pending.push({ name, params, resolve, reject });
551
+ this.scheduleFlush(db);
552
+ });
396
553
  }
397
554
  async tx(fn) {
398
555
  const db = this.ensure();
package/dist/worker.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BackpressureOptions } from "./backpressure.js";
1
2
  import { TaskContext } from "./context.js";
2
3
  import type { TaskStore } from "./store/base.js";
3
4
  import { type TaskDef } from "./task.js";
@@ -6,7 +7,12 @@ export type Handler = (ctx: TaskContext, payload: any) => unknown | Promise<unkn
6
7
  export type TypedHandler<P, R> = (ctx: TaskContext, payload: P) => R | Promise<R>;
7
8
  /** Where an error the worker recovered from came from. */
8
9
  export type ErrorPhase = "claim" | "execute";
9
- export interface WorkerOptions {
10
+ /**
11
+ * Backpressure is accepted here too, not only on CairnQ: a handler spawning
12
+ * children through TaskContext.submit is a producer, and in a worker process
13
+ * there is usually no CairnQ handle to have configured the store.
14
+ */
15
+ export interface WorkerOptions extends Partial<BackpressureOptions> {
10
16
  concurrency?: number;
11
17
  leaseMs?: number;
12
18
  heartbeatIntervalMs?: number;
@@ -25,6 +31,25 @@ export interface WorkerOptions {
25
31
  * a retryable `handler_timeout` failure. Unset disables the ceiling.
26
32
  */
27
33
  maxRunMs?: number;
34
+ /**
35
+ * Resident payload bytes allowed across running handlers, independent of
36
+ * their count.
37
+ *
38
+ * `concurrency` bounds tasks, not memory, so a worker sized for small payloads
39
+ * holds concurrency * largest-payload bytes the moment a batch of big ones
40
+ * arrives — for payloads that carry media inline, that is the difference
41
+ * between megabytes and gigabytes resident. Once the budget is spent the
42
+ * worker stops claiming until running handlers give it back.
43
+ *
44
+ * The bound is on tasks already executing. A claim commits to a whole batch
45
+ * before any size is known, so one batch can overshoot by up to `claimBatch`
46
+ * payloads; lower `claimBatch` to tighten that. A single payload larger than
47
+ * the entire budget still runs — alone, rather than deadlocking the worker.
48
+ *
49
+ * Costs one JSON serialization per task to measure, so it is only computed
50
+ * when set. Unset disables the budget.
51
+ */
52
+ maxInFlightBytes?: number;
28
53
  /**
29
54
  * Called for errors the worker survived — a claim that threw, a store write
30
55
  * that failed while finalizing a task. Without it these are silent: the run
@@ -44,6 +69,8 @@ export declare class Worker {
44
69
  private readonly opts;
45
70
  private readonly handlers;
46
71
  private readonly workerId;
72
+ /** Payload bytes charged to running handlers — see maxInFlightBytes. */
73
+ private inFlightBytes;
47
74
  private stopped;
48
75
  private stopWake;
49
76
  private readonly stopped$;
package/dist/worker.js CHANGED
@@ -41,12 +41,38 @@ function timeoutEnvelope(name, maxRunMs) {
41
41
  });
42
42
  }
43
43
  const TIMED_OUT = Symbol("cairnq.timedOut");
44
+ /**
45
+ * Resident size of a task's payload, for the maxInFlightBytes budget.
46
+ *
47
+ * Re-serializes because by this point the wire form is gone: `pg` parses a jsonb
48
+ * column with JSON.parse and discards the text, so on Postgres there is nothing
49
+ * cheaper to read. On SQLite the column does arrive as a string that rowToTask
50
+ * sees before parsing — capturing its length there would make this free, at the
51
+ * cost of carrying a non-protocol field on Task in both SDKs. Left for when the
52
+ * measurement shows up in a profile.
53
+ *
54
+ * What the budget is really after is the memory a payload pins while its handler
55
+ * runs, and its JSON length tracks that closely enough to size one by.
56
+ */
57
+ function payloadBytes(task) {
58
+ try {
59
+ return Buffer.byteLength(JSON.stringify(task.payload) ?? "");
60
+ }
61
+ catch {
62
+ // Unmeasurable, and it came out of the store, so it is already resident:
63
+ // charging nothing under-counts, but failing the claim over an accounting
64
+ // detail would drop a task the worker can otherwise run.
65
+ return 0;
66
+ }
67
+ }
44
68
  export class Worker {
45
69
  store;
46
70
  queues;
47
71
  opts;
48
72
  handlers = new Map();
49
73
  workerId = newId("worker");
74
+ /** Payload bytes charged to running handlers — see maxInFlightBytes. */
75
+ inFlightBytes = 0;
50
76
  stopped = false;
51
77
  stopWake;
52
78
  // Resolved once by stop(); every sleep races against it. A stopped worker
@@ -62,6 +88,14 @@ export class Worker {
62
88
  if (opts.maxRunMs != null && opts.maxRunMs <= 0) {
63
89
  throw new Error(`maxRunMs must be > 0, got ${opts.maxRunMs}`);
64
90
  }
91
+ // 0 would make the budget permanently spent, so the worker would claim
92
+ // nothing and look hung. Rejected here, as the Python SDK does.
93
+ if (opts.maxInFlightBytes != null && opts.maxInFlightBytes <= 0) {
94
+ throw new Error(`maxInFlightBytes must be > 0, got ${opts.maxInFlightBytes}`);
95
+ }
96
+ if (opts.maxQueueDepth != null) {
97
+ store.useBackpressure(opts);
98
+ }
65
99
  }
66
100
  static sqlite(path, opts = {}) {
67
101
  const { queues = ["default"], busyTimeoutMs, ...rest } = opts;
@@ -146,9 +180,16 @@ export class Worker {
146
180
  }
147
181
  async loop(concurrency, batch, leaseMs, running) {
148
182
  const pollMs = this.opts.pollIntervalMs ?? 500;
183
+ const byteBudget = this.opts.maxInFlightBytes;
149
184
  while (!this.stopped) {
150
185
  const free = concurrency - running.size;
151
- if (free <= 0) {
186
+ // Two ceilings, either of which stops the claim: task count and resident
187
+ // payload bytes. The byte arm is guarded on running.size because it must
188
+ // never be the reason we race an empty set — Promise.race([]) is pending
189
+ // forever, past even stop(). With nothing running, nothing is resident, so
190
+ // the budget cannot be the thing holding us back anyway.
191
+ const overBudget = byteBudget != null && this.inFlightBytes >= byteBudget;
192
+ if (running.size > 0 && (free <= 0 || overBudget)) {
152
193
  // Wait for a slot rather than spinning. execute() never rejects, so
153
194
  // racing these is safe.
154
195
  await Promise.race([...running]);
@@ -180,7 +221,14 @@ export class Worker {
180
221
  continue;
181
222
  }
182
223
  for (const task of claimed) {
183
- const p = this.execute(task, leaseMs).finally(() => running.delete(p));
224
+ // Charged before the handler starts and refunded when it settles, so the
225
+ // budget covers exactly the span the payload is pinned in memory.
226
+ const bytes = byteBudget == null ? 0 : payloadBytes(task);
227
+ this.inFlightBytes += bytes;
228
+ const p = this.execute(task, leaseMs).finally(() => {
229
+ this.inFlightBytes -= bytes;
230
+ running.delete(p);
231
+ });
184
232
  running.add(p);
185
233
  }
186
234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairnq",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "SQLite-first, cross-language, storage-centered durable task runtime",
5
5
  "license": "MIT",
6
6
  "author": "Jannchie <jannchie@gmail.com>",
@@ -31,15 +31,16 @@
31
31
  }
32
32
  },
33
33
  "files": ["dist", "src"],
34
- "engines": { "node": ">=20" },
34
+ "engines": { "node": ">=22" },
35
35
  "scripts": {
36
36
  "bench": "tsx bench/run.ts",
37
+ "bench:sweep": "tsx bench/sweep.ts",
37
38
  "build": "tsc -p tsconfig.json",
38
39
  "test": "vitest run",
39
40
  "typecheck": "tsc -p tsconfig.json --noEmit"
40
41
  },
41
42
  "dependencies": {
42
- "better-sqlite3": "^11.3.0"
43
+ "better-sqlite3": "^13.0.2"
43
44
  },
44
45
  "peerDependencies": {
45
46
  "pg": "^8.13.0"
@@ -55,8 +56,8 @@
55
56
  "@types/pg": "^8.11.10",
56
57
  "pg": "^8.13.0",
57
58
  "tsx": "^4.19.0",
58
- "typescript": "^5.6.0",
59
- "vitest": "^2.1.0"
59
+ "typescript": "^7.0.2",
60
+ "vitest": "^4.1.10"
60
61
  },
61
62
  "pnpm": {
62
63
  "onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
@@ -0,0 +1,140 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { QueueFull } from "./errors.js";
3
+ import type { TaskStore } from "./store/base.js";
4
+
5
+ /**
6
+ * Most tasks a producer may enqueue on one probe's word.
7
+ *
8
+ * The gate probes only when its headroom runs out, so this is what the check
9
+ * costs amortized: one bounded index read per MAX_GRANT submits. It also bounds
10
+ * how far the limit can be overshot — see the class docstring on why several
11
+ * producers make this a soft limit, and why that overshoot is (N-1) * MAX_GRANT
12
+ * rather than unbounded.
13
+ */
14
+ const MAX_GRANT = 64;
15
+
16
+ // Named for probing, not polling: wait.ts exports DEFAULT_POLL_MS / MAX_POLL_MS
17
+ // for the get() loop behind wait(), an order of magnitude tighter and answering
18
+ // a different question. Two constants of the same name in one SDK would be read
19
+ // as one policy.
20
+ const INITIAL_PROBE_INTERVAL_MS = 250;
21
+ const MAX_PROBE_INTERVAL_MS = 5_000;
22
+ const DEFAULT_MAX_WAIT_MS = 600_000;
23
+
24
+ /** Per-queue depth limits. A number applies one limit to every queue; a record
25
+ * gates only the queues it names and leaves the rest unbounded. */
26
+ export type QueueDepthLimit = number | Record<string, number>;
27
+
28
+ export interface BackpressureOptions {
29
+ /** Queued tasks a queue may hold before `submit` blocks. */
30
+ maxQueueDepth: QueueDepthLimit;
31
+ /** How long a blocked submit waits before raising QueueFull. Default 600_000. */
32
+ maxQueueWaitMs?: number;
33
+ /** First backoff between depth probes; doubles to a 5s ceiling. Default 250. */
34
+ queuePollIntervalMs?: number;
35
+ }
36
+
37
+ /**
38
+ * Blocks `submit` while a queue is at its depth limit.
39
+ *
40
+ * Without one of these a producer that outruns its workers is only bounded by
41
+ * disk: the backlog grows, every task's queue wait grows with it, and the
42
+ * failure is a database that filled up rather than a producer that slowed down.
43
+ * A queue is the wrong place to buffer an overload — pushing back on the
44
+ * producer is the point.
45
+ *
46
+ * **A soft limit under several producers.** The check is a read followed by a
47
+ * write that other producers can interleave with, and each holds its own grant,
48
+ * so N producers can overshoot the limit by up to (N-1) * MAX_GRANT tasks. Made
49
+ * exact it would need the depth check inside insert_task's transaction, which
50
+ * puts an unbounded-scan predicate on the hot path of every submit and turns
51
+ * concurrent submits into lock contention — a steep price for a bound whose
52
+ * whole purpose is approximate. Size the limit for the pushback you want, not as
53
+ * a capacity assertion.
54
+ */
55
+ export class QueueDepthGate {
56
+ /** Remaining grant per queue: submits allowed before the next probe. */
57
+ private readonly headroom = new Map<string, number>();
58
+ /** In-flight probe per queue, so concurrent submits share one read rather
59
+ * than each issuing their own against a queue that is already known full. */
60
+ private readonly probing = new Map<string, Promise<void>>();
61
+ private readonly limits: QueueDepthLimit;
62
+ private readonly maxWaitMs: number;
63
+ private readonly initialProbeMs: number;
64
+
65
+ constructor(
66
+ private readonly store: TaskStore,
67
+ opts: BackpressureOptions,
68
+ ) {
69
+ this.limits = opts.maxQueueDepth;
70
+ this.maxWaitMs = opts.maxQueueWaitMs ?? DEFAULT_MAX_WAIT_MS;
71
+ this.initialProbeMs = opts.queuePollIntervalMs ?? INITIAL_PROBE_INTERVAL_MS;
72
+ if (typeof this.limits === "number") this.validate("*", this.limits);
73
+ else for (const [q, v] of Object.entries(this.limits)) this.validate(q, v);
74
+ }
75
+
76
+ private validate(queue: string, limit: number): void {
77
+ // A limit of 0 would block every submit forever, which is never what a
78
+ // caller means; catching it here beats a first submit that hangs for
79
+ // maxQueueWaitMs and then raises.
80
+ if (!Number.isInteger(limit) || limit < 1) {
81
+ throw new Error(`maxQueueDepth for ${queue} must be an integer >= 1, got ${limit}`);
82
+ }
83
+ }
84
+
85
+ /** The limit for `queue`, or null when it is not gated. */
86
+ limitFor(queue: string): number | null {
87
+ if (typeof this.limits === "number") return this.limits;
88
+ return this.limits[queue] ?? null;
89
+ }
90
+
91
+ /**
92
+ * Consume one unit of headroom for `queue`, waiting for room if it is full.
93
+ * Returns immediately for an ungated queue. Raises QueueFull on timeout,
94
+ * having enqueued nothing.
95
+ */
96
+ async acquire(queue: string): Promise<void> {
97
+ const limit = this.limitFor(queue);
98
+ if (limit == null) return;
99
+
100
+ const startedAt = Date.now();
101
+ let waitMs = this.initialProbeMs;
102
+ for (;;) {
103
+ const left = this.headroom.get(queue) ?? 0;
104
+ if (left > 0) {
105
+ this.headroom.set(queue, left - 1);
106
+ return;
107
+ }
108
+ await this.probe(queue, limit);
109
+ if ((this.headroom.get(queue) ?? 0) > 0) continue;
110
+
111
+ const waited = Date.now() - startedAt;
112
+ if (waited >= this.maxWaitMs) throw new QueueFull(queue, limit, waited);
113
+ // Back off: a queue at its limit will not drain within one poll interval,
114
+ // and re-probing tightly adds read load to a database already behind.
115
+ await delay(Math.min(waitMs, this.maxWaitMs - waited));
116
+ waitMs = Math.min(waitMs * 2, MAX_PROBE_INTERVAL_MS);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Refresh `queue`'s grant from the store, at most one probe in flight.
122
+ *
123
+ * Callers re-read `headroom` afterwards rather than using a returned value:
124
+ * only the caller that started the probe writes the grant, so waiters that
125
+ * joined it cannot overwrite the units already handed out.
126
+ */
127
+ private probe(queue: string, limit: number): Promise<void> {
128
+ let p = this.probing.get(queue);
129
+ if (!p) {
130
+ p = this.store
131
+ .queueDepth(queue, limit)
132
+ .then((headroom) => {
133
+ this.headroom.set(queue, Math.min(headroom, MAX_GRANT));
134
+ })
135
+ .finally(() => this.probing.delete(queue));
136
+ this.probing.set(queue, p);
137
+ }
138
+ return p;
139
+ }
140
+ }
package/src/client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BackpressureOptions } from "./backpressure.js";
1
2
  import { TaskCanceled, TaskFailed } from "./errors.js";
2
3
  import { isFailed, isSucceeded, type Task, type TaskStatus } from "./models.js";
3
4
  import { SQLiteStore } from "./store/sqlite.js";
@@ -12,18 +13,33 @@ export interface CallOptions extends SubmitOptions {
12
13
  pollMs?: number;
13
14
  }
14
15
 
16
+ /** Options this handle configures on the store it wraps, rather than the
17
+ * store's own constructor arguments. */
18
+ export type ClientOptions = Partial<BackpressureOptions>;
19
+
15
20
  /** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
16
21
  export class CairnQ {
17
- constructor(private readonly _store: TaskStore) {}
22
+ constructor(
23
+ private readonly _store: TaskStore,
24
+ opts: ClientOptions = {},
25
+ ) {
26
+ // Installed on the store, not held here: every submit path goes through the
27
+ // store, including TaskContext.submit, which this handle never sees.
28
+ if (opts.maxQueueDepth != null) {
29
+ _store.useBackpressure(opts as BackpressureOptions);
30
+ }
31
+ }
18
32
 
19
- static sqlite(path: string, opts?: { busyTimeoutMs?: number }): CairnQ {
20
- return new CairnQ(new SQLiteStore(path, opts));
33
+ static sqlite(path: string, opts: { busyTimeoutMs?: number } & ClientOptions = {}): CairnQ {
34
+ const { busyTimeoutMs, ...client } = opts;
35
+ return new CairnQ(new SQLiteStore(path, { busyTimeoutMs }), client);
21
36
  }
22
37
 
23
38
  /** Multi-host backend. `dsn` is a libpq connection string; requires the
24
39
  * optional `pg` package. */
25
- static postgres(dsn: string, opts?: { max?: number }): CairnQ {
26
- return new CairnQ(new PostgresStore(dsn, opts));
40
+ static postgres(dsn: string, opts: { max?: number } & ClientOptions = {}): CairnQ {
41
+ const { max, ...client } = opts;
42
+ return new CairnQ(new PostgresStore(dsn, { max }), client);
27
43
  }
28
44
 
29
45
  get store(): TaskStore {
@@ -38,12 +54,24 @@ export class CairnQ {
38
54
  return this._store.close();
39
55
  }
40
56
 
57
+ /** Enqueue a task. With `maxQueueDepth` configured this blocks while the
58
+ * target queue is at its limit, and raises QueueFull if it stays there for
59
+ * `maxQueueWaitMs` — see QueueDepthGate for why that bound is approximate
60
+ * across several producers. */
41
61
  submit(name: string, payload?: unknown, opts?: SubmitOptions): Promise<Task>;
42
62
  submit<P, R>(task: TaskDef<P, R>, payload?: P, opts?: SubmitOptions): Promise<Task>;
43
63
  submit(task: string | TaskDef, payload?: unknown, opts: SubmitOptions = {}): Promise<Task> {
44
64
  return this._store.submit({ name: taskName(task), payload, ...opts });
45
65
  }
46
66
 
67
+ /** How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
68
+ * The non-blocking read behind `maxQueueDepth`, for a producer that would
69
+ * rather shed load or pick another queue than wait. Cheaper than `stats()`:
70
+ * bounded at `maxDepth` index entries instead of aggregating the table. */
71
+ queueDepth(queue: string, maxDepth: number): Promise<number> {
72
+ return this._store.queueDepth(queue, maxDepth);
73
+ }
74
+
47
75
  get(taskId: string): Promise<Task | null> {
48
76
  return this._store.get(taskId);
49
77
  }
package/src/errors.ts CHANGED
@@ -37,6 +37,24 @@ export class AlreadyExists extends CairnQError {
37
37
  }
38
38
  }
39
39
 
40
+ /** A gated submit waited out `maxWaitMs` without the queue draining below its
41
+ * depth limit. Nothing was enqueued. Distinct from a slow submit on purpose: a
42
+ * queue this far behind is a capacity problem, and a caller that silently
43
+ * retries forever converts it into an invisible one. */
44
+ export class QueueFull extends CairnQError {
45
+ constructor(
46
+ public queue: string,
47
+ public maxDepth: number,
48
+ public waitedMs: number,
49
+ ) {
50
+ super(
51
+ `queue ${queue} still holds ${maxDepth} or more queued tasks after ` +
52
+ `${waitedMs}ms; refusing to enqueue more`,
53
+ );
54
+ this.name = "QueueFull";
55
+ }
56
+ }
57
+
40
58
  /** One line of "why hasn't this finished" from the last snapshot wait()
41
59
  * observed. No worker running, no handler for the name, wrong queue, and two
42
60
  * processes on different database files all look identical from the API side —
package/src/index.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";
@@ -23,6 +25,7 @@ export {
23
25
  export {
24
26
  CairnQError,
25
27
  AlreadyExists,
28
+ QueueFull,
26
29
  TaskTimeout,
27
30
  TaskFailed,
28
31
  TaskCanceled,