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.
package/src/store/base.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  SerializationError,
8
8
  } from "../errors.js";
9
9
  import { rowToTask, STATUSES, type Task, type TaskStatus } from "../models.js";
10
+ import { type BackpressureOptions, QueueDepthGate } from "../backpressure.js";
10
11
 
11
12
  const rejectMangled = function (this: unknown, _key: string, v: unknown): unknown {
12
13
  if (typeof v === "number" && !Number.isFinite(v)) {
@@ -64,6 +65,10 @@ export function checkProtocolVersion(version: number): void {
64
65
  const CONFLICTS = ["reuse", "reject", "replace"] as const;
65
66
  export type Conflict = (typeof CONFLICTS)[number];
66
67
 
68
+ /** The queue a submit lands on when it names none. Owned here, where the
69
+ * default is applied, so nothing above has to re-derive it. */
70
+ export const DEFAULT_QUEUE = "default";
71
+
67
72
  export interface SubmitInput {
68
73
  name: string;
69
74
  payload: unknown;
@@ -149,6 +154,9 @@ export function statementParams(sql: string): readonly string[] {
149
154
  * behavior; the shared SQL already stops them from drifting in wording.
150
155
  */
151
156
  export abstract class TaskStore {
157
+ /** Set by useBackpressure; null means submit is ungated. */
158
+ private gate: QueueDepthGate | null = null;
159
+
152
160
  // ------------------------------------------------------------ dialect seam
153
161
  abstract connect(): Promise<void>;
154
162
  abstract close(): Promise<void>;
@@ -211,12 +219,24 @@ export abstract class TaskStore {
211
219
  }
212
220
 
213
221
  // ------------------------------------------------------------- client side
222
+ /**
223
+ * Bound how deep a queue may get before `submit` blocks. Off unless set.
224
+ *
225
+ * It hangs here rather than on `CairnQ` because the store is the one choke
226
+ * point every submit passes through — a handler spawning children via
227
+ * `TaskContext.submit` is the shape most likely to outrun its workers, and
228
+ * gating only the client would leave exactly that path unbounded.
229
+ */
230
+ useBackpressure(opts: BackpressureOptions): void {
231
+ this.gate = new QueueDepthGate(this, opts);
232
+ }
233
+
214
234
  async submit(input: SubmitInput): Promise<Task> {
215
235
  const id = newId("task");
216
236
  const ins: Params = {
217
237
  id,
218
238
  name: input.name,
219
- queue: input.queue ?? "default",
239
+ queue: input.queue ?? DEFAULT_QUEUE,
220
240
  payload: dumpJson(input.payload ?? {}),
221
241
  metadata: dumpJson(input.metadata ?? {}),
222
242
  max_attempts: input.maxAttempts ?? 3,
@@ -243,6 +263,10 @@ export abstract class TaskStore {
243
263
  if (input.runAtDelayMs != null && input.runAtDelayMs < 0) {
244
264
  throw new Error(`runAtDelayMs must be >= 0, got ${input.runAtDelayMs}`);
245
265
  }
266
+ // After validation and before the first write: bad arguments should fail
267
+ // now, not after waiting out a full queue. Reads the resolved queue, so the
268
+ // gate cannot throttle one queue while the row lands on another.
269
+ if (this.gate) await this.gate.acquire(ins.queue as string);
246
270
  if (key === null) return rowToTask((await this.fetch("insert_task", ins))[0]);
247
271
 
248
272
  // A key makes submit a read-then-write, so it has to be one transaction —
@@ -370,6 +394,22 @@ export abstract class TaskStore {
370
394
  return out;
371
395
  }
372
396
 
397
+ /**
398
+ * How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
399
+ *
400
+ * The cheap half of backpressure: bounded at `maxDepth` index entries, unlike
401
+ * `stats()`, which aggregates the whole table (terminal rows included) and so
402
+ * costs more the longer a database has been running. Use it directly to shed
403
+ * load or shape a producer; `QueueDepthGate` builds the blocking form on top.
404
+ */
405
+ async queueDepth(queue: string, maxDepth: number): Promise<number> {
406
+ if (!Number.isInteger(maxDepth) || maxDepth < 0) {
407
+ throw new Error(`maxDepth must be a non-negative integer, got ${maxDepth}`);
408
+ }
409
+ const rows = await this.fetch("queue_depth", { queue, max_depth: maxDepth });
410
+ return Number(rows[0]?.headroom ?? 0);
411
+ }
412
+
373
413
  // ------------------------------------------------------------- worker side
374
414
  /**
375
415
  * Take up to `limit` claimable tasks. `names` restricts the claim to task names
@@ -7,6 +7,7 @@ import { nowMs } from "../ids.js";
7
7
  import { loadMigrations, loadStatements } from "../sql.js";
8
8
  import {
9
9
  checkProtocolVersion,
10
+ COMMENT,
10
11
  type Fetch,
11
12
  type Params,
12
13
  statementParams,
@@ -28,7 +29,7 @@ const BUSY_RETRY_MAX_DELAY_MS = 50;
28
29
  * Bounds how long the planner can work from a stale table shape; a minute is
29
30
  * arbitrary but small next to the days a worker holds its connection. It does not
30
31
  * set how often an ANALYZE actually runs — SQLite decides that itself, and only
31
- * when a table has outgrown its statistics by roughly 24x, so a shorter interval
32
+ * once the table has diverged from its statistics by 10x, so a shorter interval
32
33
  * costs more no-ops (a few microseconds each) rather than more analyzing.
33
34
  */
34
35
  const STATS_REFRESH_INTERVAL_MS = 60_000;
@@ -61,6 +62,34 @@ function isMemory(path: string): boolean {
61
62
  return path === ":memory:" || path.includes("mode=memory");
62
63
  }
63
64
 
65
+ /**
66
+ * Whether this statement writes, and so belongs in a group commit.
67
+ *
68
+ * Read from the SQL rather than from a list of statement names, which would be a
69
+ * second place to remember when the protocol gains a statement. Every protocol
70
+ * statement is a single top-level `select`, `insert`, `update` or `delete`.
71
+ *
72
+ * Reads must stay out of the batch: `claimable_probe` exists precisely so an idle
73
+ * worker never takes SQLite's write lock, and a BEGIN IMMEDIATE around it would
74
+ * hand that back.
75
+ */
76
+ function isWriteStatement(sql: string): boolean {
77
+ return !/^\s*select/i.test(sql.replace(COMMENT, ""));
78
+ }
79
+
80
+ /**
81
+ * One write waiting for its turn on the shared connection.
82
+ *
83
+ * The rows go back to the caller that asked for them, so a batch resolves each
84
+ * member with its own result rather than a merged one.
85
+ */
86
+ interface Pending {
87
+ name: string;
88
+ params: Params;
89
+ resolve(rows: any[]): void;
90
+ reject(err: unknown): void;
91
+ }
92
+
64
93
  /**
65
94
  * Whether cairnq_tasks has been analyzed at all.
66
95
  *
@@ -189,6 +218,12 @@ export class SQLiteStore extends TaskStore {
189
218
  private readonly busyBudgetMs: number;
190
219
  /** When this connection may next revisit its planner statistics. */
191
220
  private nextStatsRefreshAt = 0;
221
+ /** Which statements are writes — see isWriteStatement. */
222
+ private readonly writes: Record<string, boolean>;
223
+ /** Writes waiting to be group-committed — see flush(). */
224
+ private pending: Pending[] = [];
225
+ /** Whether a flusher is already queued to drain `pending`. */
226
+ private flushing = false;
192
227
 
193
228
  constructor(
194
229
  private readonly path: string,
@@ -197,6 +232,9 @@ export class SQLiteStore extends TaskStore {
197
232
  super();
198
233
  this.busyBudgetMs = opts.busyTimeoutMs ?? 5000;
199
234
  this.statements = loadStatements("sqlite");
235
+ this.writes = Object.fromEntries(
236
+ Object.entries(this.statements).map(([name, sql]) => [name, isWriteStatement(sql)]),
237
+ );
200
238
  // Only a bare ":memory:" is guaranteed private to its connection, so only
201
239
  // it gets a lock of its own. A "mode=memory" URI stays path-keyed: with
202
240
  // cache=shared it names ONE shared database, and on a build without URI
@@ -422,10 +460,135 @@ export class SQLiteStore extends TaskStore {
422
460
  }
423
461
  }
424
462
 
463
+ /**
464
+ * Group commit: one transaction for every write already waiting on the lock.
465
+ *
466
+ * A write costs microseconds to execute and a transaction costs a WAL commit, so
467
+ * N concurrent writes spend nearly all their time on N commits they could have
468
+ * shared. Measured at 200 finalizes: 80µs each one-transaction-apiece against
469
+ * 10µs each in one transaction (`bench/sweep` sweep B).
470
+ *
471
+ * Nothing waits to form a batch — a flusher takes whatever arrived while the
472
+ * previous one held the lock, so this trades no latency for the throughput. What
473
+ * it does trade is atomicity: two callers' writes now land together or not at
474
+ * all. Under at-least-once that is not observable (a lost batch is a
475
+ * redelivery), and it is why every member is resolved only after COMMIT.
476
+ */
477
+ private flush(db: DB): void {
478
+ // One writer waiting is the uncontended case, and it stays exactly as cheap as
479
+ // before: wrapping a single statement in BEGIN/COMMIT would add two statements
480
+ // to every write on an idle store.
481
+ if (this.pending.length === 1) {
482
+ const only = this.pending[0];
483
+ let rows: any[];
484
+ try {
485
+ rows = this.runNow(only.name, only.params);
486
+ } catch (err) {
487
+ // Leave it pending on a lost write lock: withLock re-runs this flusher.
488
+ if (isBusy(err)) throw err;
489
+ this.pending.shift();
490
+ only.reject(err);
491
+ return;
492
+ }
493
+ this.pending.shift();
494
+ only.resolve(rows);
495
+ return;
496
+ }
497
+
498
+ // BEGIN before consuming, so a lost write lock leaves the batch where the
499
+ // retry will find it — with anything that arrived meanwhile.
500
+ db.exec("BEGIN IMMEDIATE");
501
+ const batch = this.pending;
502
+ this.pending = [];
503
+ const out: { rows?: any[]; err?: unknown }[] = [];
504
+ try {
505
+ for (const w of batch) {
506
+ try {
507
+ out.push({ rows: this.runNow(w.name, w.params) });
508
+ } catch (err) {
509
+ // A statement error aborts that statement, not the transaction, so the
510
+ // rest of the batch is still good and this one waiter carries the error.
511
+ // If SQLite tore the transaction down instead, nothing in it survived
512
+ // and every member has to hear about it.
513
+ if (!db.inTransaction) throw err;
514
+ out.push({ err });
515
+ }
516
+ }
517
+ db.exec("COMMIT");
518
+ } catch (err) {
519
+ if (db.inTransaction) {
520
+ try {
521
+ db.exec("ROLLBACK");
522
+ } catch {
523
+ // Raced with SQLite's own rollback; the transaction is gone either way.
524
+ }
525
+ }
526
+ if (isBusy(err)) {
527
+ // Back to the head of the queue, ahead of later arrivals, so the retry
528
+ // preserves the order the writes were issued in.
529
+ this.pending = batch.concat(this.pending);
530
+ throw err;
531
+ }
532
+ for (const w of batch) w.reject(err);
533
+ return;
534
+ }
535
+ // Only now: before COMMIT a rollback could still take the write back, and a
536
+ // caller holding its row would have observed a write that never happened.
537
+ for (let i = 0; i < batch.length; i++) {
538
+ // Presence, not truthiness — a thrown value is not guaranteed to be one.
539
+ if ("err" in out[i]) batch[i].reject(out[i].err);
540
+ else batch[i].resolve(out[i].rows!);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Make sure some flusher is draining `pending`, without ever running two.
546
+ *
547
+ * The flusher loops instead of re-arming itself per batch. A caller that awaits
548
+ * its writes one at a time resumes and issues the next one *before* the flusher
549
+ * gets its turn back, so re-arming would cost that write an extra trip through
550
+ * the lock queue — measured as ~2x on sequential writes, which is most of them.
551
+ * Looping picks it up in the same session for free.
552
+ *
553
+ * The exit is safe because the last `pending` check and clearing the flag happen
554
+ * in one synchronous step: a write that arrives before it keeps the loop going,
555
+ * and one that arrives after sees the flag down and starts a new flusher.
556
+ */
557
+ private scheduleFlush(db: DB): void {
558
+ if (this.flushing) return;
559
+ this.flushing = true;
560
+ void (async () => {
561
+ try {
562
+ while (this.pending.length) {
563
+ try {
564
+ await this.withLock(() => this.flush(db));
565
+ } catch (err) {
566
+ // flush only throws on a lost write lock, and only after putting its
567
+ // batch back — so reaching here means withLock spent the whole budget
568
+ // and those writes are still queued with nobody else coming for them.
569
+ // Anything that arrived behind them is failed with the same error
570
+ // rather than left hanging: this store cannot write at all right now,
571
+ // which is what a lone write would have been told too.
572
+ const stranded = this.pending;
573
+ this.pending = [];
574
+ for (const w of stranded) w.reject(err);
575
+ }
576
+ }
577
+ } finally {
578
+ this.flushing = false;
579
+ }
580
+ })();
581
+ }
582
+
425
583
  protected async fetch(name: string, params: Params): Promise<any[]> {
426
584
  const db = this.ensure();
427
585
  await this.maybeRefreshStatistics(db);
428
- return this.withLock(() => this.runNow(name, params));
586
+ // Reads keep their own turn on the lock — see isWriteStatement.
587
+ if (!this.writes[name]) return this.withLock(() => this.runNow(name, params));
588
+ return new Promise<any[]>((resolve, reject) => {
589
+ this.pending.push({ name, params, resolve, reject });
590
+ this.scheduleFlush(db);
591
+ });
429
592
  }
430
593
 
431
594
  protected async tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T> {
package/src/worker.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BackpressureOptions } from "./backpressure.js";
1
2
  import { TaskContext } from "./context.js";
2
3
  import { errorEnvelope, LostLease, SerializationError, TaskError } from "./errors.js";
3
4
  import { newId } from "./ids.js";
@@ -14,7 +15,12 @@ export type TypedHandler<P, R> = (ctx: TaskContext, payload: P) => R | Promise<R
14
15
  /** Where an error the worker recovered from came from. */
15
16
  export type ErrorPhase = "claim" | "execute";
16
17
 
17
- export interface WorkerOptions {
18
+ /**
19
+ * Backpressure is accepted here too, not only on CairnQ: a handler spawning
20
+ * children through TaskContext.submit is a producer, and in a worker process
21
+ * there is usually no CairnQ handle to have configured the store.
22
+ */
23
+ export interface WorkerOptions extends Partial<BackpressureOptions> {
18
24
  concurrency?: number;
19
25
  leaseMs?: number;
20
26
  heartbeatIntervalMs?: number;
@@ -33,6 +39,25 @@ export interface WorkerOptions {
33
39
  * a retryable `handler_timeout` failure. Unset disables the ceiling.
34
40
  */
35
41
  maxRunMs?: number;
42
+ /**
43
+ * Resident payload bytes allowed across running handlers, independent of
44
+ * their count.
45
+ *
46
+ * `concurrency` bounds tasks, not memory, so a worker sized for small payloads
47
+ * holds concurrency * largest-payload bytes the moment a batch of big ones
48
+ * arrives — for payloads that carry media inline, that is the difference
49
+ * between megabytes and gigabytes resident. Once the budget is spent the
50
+ * worker stops claiming until running handlers give it back.
51
+ *
52
+ * The bound is on tasks already executing. A claim commits to a whole batch
53
+ * before any size is known, so one batch can overshoot by up to `claimBatch`
54
+ * payloads; lower `claimBatch` to tighten that. A single payload larger than
55
+ * the entire budget still runs — alone, rather than deadlocking the worker.
56
+ *
57
+ * Costs one JSON serialization per task to measure, so it is only computed
58
+ * when set. Unset disables the budget.
59
+ */
60
+ maxInFlightBytes?: number;
36
61
  /**
37
62
  * Called for errors the worker survived — a claim that threw, a store write
38
63
  * that failed while finalizing a task. Without it these are silent: the run
@@ -82,9 +107,35 @@ function timeoutEnvelope(name: string, maxRunMs: number): Record<string, unknown
82
107
 
83
108
  const TIMED_OUT = Symbol("cairnq.timedOut");
84
109
 
110
+ /**
111
+ * Resident size of a task's payload, for the maxInFlightBytes budget.
112
+ *
113
+ * Re-serializes because by this point the wire form is gone: `pg` parses a jsonb
114
+ * column with JSON.parse and discards the text, so on Postgres there is nothing
115
+ * cheaper to read. On SQLite the column does arrive as a string that rowToTask
116
+ * sees before parsing — capturing its length there would make this free, at the
117
+ * cost of carrying a non-protocol field on Task in both SDKs. Left for when the
118
+ * measurement shows up in a profile.
119
+ *
120
+ * What the budget is really after is the memory a payload pins while its handler
121
+ * runs, and its JSON length tracks that closely enough to size one by.
122
+ */
123
+ function payloadBytes(task: Task): number {
124
+ try {
125
+ return Buffer.byteLength(JSON.stringify(task.payload) ?? "");
126
+ } catch {
127
+ // Unmeasurable, and it came out of the store, so it is already resident:
128
+ // charging nothing under-counts, but failing the claim over an accounting
129
+ // detail would drop a task the worker can otherwise run.
130
+ return 0;
131
+ }
132
+ }
133
+
85
134
  export class Worker {
86
135
  private readonly handlers = new Map<string, Handler>();
87
136
  private readonly workerId = newId("worker");
137
+ /** Payload bytes charged to running handlers — see maxInFlightBytes. */
138
+ private inFlightBytes = 0;
88
139
  private stopped = false;
89
140
  private stopWake!: () => void;
90
141
  // Resolved once by stop(); every sleep races against it. A stopped worker
@@ -102,6 +153,14 @@ export class Worker {
102
153
  if (opts.maxRunMs != null && opts.maxRunMs <= 0) {
103
154
  throw new Error(`maxRunMs must be > 0, got ${opts.maxRunMs}`);
104
155
  }
156
+ // 0 would make the budget permanently spent, so the worker would claim
157
+ // nothing and look hung. Rejected here, as the Python SDK does.
158
+ if (opts.maxInFlightBytes != null && opts.maxInFlightBytes <= 0) {
159
+ throw new Error(`maxInFlightBytes must be > 0, got ${opts.maxInFlightBytes}`);
160
+ }
161
+ if (opts.maxQueueDepth != null) {
162
+ store.useBackpressure(opts as BackpressureOptions);
163
+ }
105
164
  }
106
165
 
107
166
  static sqlite(
@@ -208,9 +267,16 @@ export class Worker {
208
267
  running: Set<Promise<void>>,
209
268
  ): Promise<void> {
210
269
  const pollMs = this.opts.pollIntervalMs ?? 500;
270
+ const byteBudget = this.opts.maxInFlightBytes;
211
271
  while (!this.stopped) {
212
272
  const free = concurrency - running.size;
213
- if (free <= 0) {
273
+ // Two ceilings, either of which stops the claim: task count and resident
274
+ // payload bytes. The byte arm is guarded on running.size because it must
275
+ // never be the reason we race an empty set — Promise.race([]) is pending
276
+ // forever, past even stop(). With nothing running, nothing is resident, so
277
+ // the budget cannot be the thing holding us back anyway.
278
+ const overBudget = byteBudget != null && this.inFlightBytes >= byteBudget;
279
+ if (running.size > 0 && (free <= 0 || overBudget)) {
214
280
  // Wait for a slot rather than spinning. execute() never rejects, so
215
281
  // racing these is safe.
216
282
  await Promise.race([...running]);
@@ -241,7 +307,14 @@ export class Worker {
241
307
  continue;
242
308
  }
243
309
  for (const task of claimed) {
244
- const p = this.execute(task, leaseMs).finally(() => running.delete(p));
310
+ // Charged before the handler starts and refunded when it settles, so the
311
+ // budget covers exactly the span the payload is pinned in memory.
312
+ const bytes = byteBudget == null ? 0 : payloadBytes(task);
313
+ this.inFlightBytes += bytes;
314
+ const p = this.execute(task, leaseMs).finally(() => {
315
+ this.inFlightBytes -= bytes;
316
+ running.delete(p);
317
+ });
245
318
  running.add(p);
246
319
  }
247
320
  }