cairnq 0.4.0 → 0.6.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.
Files changed (39) hide show
  1. package/README.md +3 -2
  2. package/dist/_protocol/migrations/postgres/0006_claim_name_index.sql +25 -0
  3. package/dist/_protocol/migrations/sqlite/0006_claim_name_index.sql +26 -0
  4. package/dist/_protocol/sql/postgres/claim_one_name.sql +37 -0
  5. package/dist/_protocol/sql/postgres/claim_one_queue_one_name.sql +34 -0
  6. package/dist/_protocol/sql/postgres/heartbeat_batch.sql +24 -0
  7. package/dist/_protocol/sql/postgres/queue_depth.sql +22 -0
  8. package/dist/_protocol/sql/sqlite/claim_one_name.sql +36 -0
  9. package/dist/_protocol/sql/sqlite/claim_one_queue_one_name.sql +31 -0
  10. package/dist/_protocol/sql/sqlite/heartbeat_batch.sql +29 -0
  11. package/dist/_protocol/sql/sqlite/queue_depth.sql +26 -0
  12. package/dist/backoff.d.ts +31 -0
  13. package/dist/backoff.js +40 -0
  14. package/dist/backpressure.d.ts +59 -0
  15. package/dist/backpressure.js +122 -0
  16. package/dist/client.d.ts +16 -3
  17. package/dist/client.js +19 -5
  18. package/dist/context.d.ts +48 -2
  19. package/dist/context.js +101 -10
  20. package/dist/errors.d.ts +37 -0
  21. package/dist/errors.js +60 -0
  22. package/dist/index.d.ts +7 -3
  23. package/dist/index.js +2 -1
  24. package/dist/store/base.d.ts +73 -0
  25. package/dist/store/base.js +124 -14
  26. package/dist/store/sqlite.d.ts +35 -0
  27. package/dist/store/sqlite.js +163 -3
  28. package/dist/worker.d.ts +238 -13
  29. package/dist/worker.js +512 -120
  30. package/package.json +2 -1
  31. package/src/backoff.ts +53 -0
  32. package/src/backpressure.ts +140 -0
  33. package/src/client.ts +33 -5
  34. package/src/context.ts +116 -9
  35. package/src/errors.ts +66 -0
  36. package/src/index.ts +7 -2
  37. package/src/store/base.ts +136 -13
  38. package/src/store/sqlite.ts +168 -2
  39. package/src/worker.ts +671 -132
@@ -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
@@ -293,27 +330,77 @@ export class TaskStore {
293
330
  * array claims nothing.
294
331
  */
295
332
  async claim(input) {
296
- // One queue is the common case and gets its own statement: a list-valued queue
297
- // filter cannot be read in claim order, so the planner sorts every claimable
298
- // row to take LIMIT of them, and claim's cost grows with the queued backlog
299
- // while it holds the claim transaction. See claim_one_queue.sql.
333
+ const names = input.names ?? null;
334
+ const claimed = await this.claimSession({ queues: input.queues, workerId: input.workerId, leaseMs: input.leaseMs, names }, (claim) => claim(names, input.limit ?? 1));
335
+ return claimed ?? [];
336
+ }
337
+ /**
338
+ * Open one claim transaction and let the caller draw from it repeatedly.
339
+ *
340
+ * The transaction is what has to live here: the read-only probe that keeps an
341
+ * idle worker off SQLite's single write lock, the `recover_leases` whose
342
+ * reclaimed leases must be visible to the claims that follow and to nobody in
343
+ * between, and the write lock itself. *What* gets claimed under it is the
344
+ * caller's business — a worker drawing a separate quota per task name is
345
+ * scheduling policy, and this layer has no vocabulary for the "handler call"
346
+ * that policy is denominated in. It knows queues, names, limits and rows.
347
+ *
348
+ * `plan` is handed a `claim(names, limit)` it may call any number of times,
349
+ * each a separate statement under the same lock and the same recovery, and
350
+ * each free to size itself from what the previous one returned. That feedback
351
+ * is the reason this is a callback rather than a list of quotas: a caller
352
+ * dividing a budget up front has to guess, and every share handed to a name
353
+ * with nothing queued is a slot left idle until the next poll.
354
+ *
355
+ * `plan` runs with the write lock held, so it must await nothing but that
356
+ * callback.
357
+ *
358
+ * `names` is the union `plan` might ask for — the probe and the recovery are
359
+ * filtered by it. Returns undefined when the probe finds nothing claimable, in
360
+ * which case `plan` never runs and no transaction is opened.
361
+ */
362
+ async claimSession(input, plan) {
363
+ // A list-valued filter cannot be read in claim order, so the planner sorts
364
+ // every claimable row to take LIMIT of them and the claim's cost grows with
365
+ // the backlog while it holds the transaction. Both filters therefore have an
366
+ // equality form, picked per draw: one queue is the common deployment, and one
367
+ // name is every per-name quota. See claim_one_queue.sql and claim_one_name.sql.
300
368
  const oneQueue = input.queues.length === 1;
301
- const params = {
369
+ const base = {
302
370
  queues: input.queues,
303
371
  queue: oneQueue ? input.queues[0] : null,
304
- names: input.names ?? null,
372
+ names: input.names,
373
+ name: null,
305
374
  worker_id: input.workerId,
306
375
  lease_ms: input.leaseMs ?? 30_000,
307
- limit: input.limit ?? 1,
376
+ limit: 1,
308
377
  lease_expired_error: LEASE_EXPIRED_ERROR_JSON,
309
378
  };
310
- if (!(await this.hasClaimableWork(params)))
311
- return [];
312
- // Recovery must share the claim's transaction: a lease reclaimed here has to
313
- // be visible to the claim that follows, and to nobody in between.
379
+ if (!(await this.hasClaimableWork(base)))
380
+ return undefined;
314
381
  return this.tx(async (fetch) => {
315
- await fetch("recover_leases", params);
316
- return (await fetch(oneQueue ? "claim_one_queue" : "claim", params)).map(rowToTask);
382
+ await fetch("recover_leases", base);
383
+ return plan(async (names, limit) => {
384
+ // A draw asking for nothing, or filtered to no names, claims nothing —
385
+ // answer it here rather than spending a statement to learn that.
386
+ if (limit <= 0 || names?.length === 0)
387
+ return [];
388
+ const oneName = names?.length === 1;
389
+ const statement = oneName
390
+ ? oneQueue
391
+ ? "claim_one_queue_one_name"
392
+ : "claim_one_name"
393
+ : oneQueue
394
+ ? "claim_one_queue"
395
+ : "claim";
396
+ const rows = await fetch(statement, {
397
+ ...base,
398
+ names,
399
+ name: oneName ? names[0] : null,
400
+ limit,
401
+ });
402
+ return rows.map(rowToTask);
403
+ });
317
404
  });
318
405
  }
319
406
  async heartbeat(input) {
@@ -323,6 +410,29 @@ export class TaskStore {
323
410
  lease_ms: input.leaseMs ?? 30_000,
324
411
  });
325
412
  }
413
+ /**
414
+ * Renew several leases in one statement. Returns `taskId -> cancel requested`
415
+ * for the tasks this worker still holds.
416
+ *
417
+ * Deliberately not an ownedWrite: ownership is per task here, so there is no
418
+ * single answer to "did it work". A task **absent** from the result lost its
419
+ * lease, and the caller decides what that means for that one task rather than
420
+ * failing the whole beat.
421
+ *
422
+ * It returns flags rather than Tasks because nothing downstream needs a task:
423
+ * the caller renews leases and observes cancellation, and whole rows would drag
424
+ * every payload back on every beat for the life of the call.
425
+ */
426
+ async heartbeatBatch(input) {
427
+ if (!input.taskIds.length)
428
+ return new Map();
429
+ const rows = await this.fetch("heartbeat_batch", {
430
+ ids: input.taskIds,
431
+ worker_id: input.workerId,
432
+ lease_ms: input.leaseMs ?? 30_000,
433
+ });
434
+ return new Map(rows.map((r) => [r.id, r.cancel_requested_at_ms != null]));
435
+ }
326
436
  async progress(input) {
327
437
  return this.ownedWrite("progress", input.taskId, {
328
438
  id: input.taskId,
@@ -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>;
@@ -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;
@@ -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
@@ -292,7 +313,10 @@ export class SQLiteStore extends TaskStore {
292
313
  bound[name] = now - params.older_than_ms;
293
314
  break;
294
315
  case "queues":
295
- bound[name] = JSON.stringify(params.queues);
316
+ case "ids":
317
+ // json_each needs a JSON array. Postgres binds the array itself as
318
+ // text[], so only this dialect encodes.
319
+ bound[name] = JSON.stringify(params[name]);
296
320
  break;
297
321
  case "names":
298
322
  // json_each needs a JSON array; null stays null so the SQL's
@@ -389,10 +413,146 @@ export class SQLiteStore extends TaskStore {
389
413
  throw err;
390
414
  }
391
415
  }
416
+ /**
417
+ * Group commit: one transaction for every write already waiting on the lock.
418
+ *
419
+ * A write costs microseconds to execute and a transaction costs a WAL commit, so
420
+ * N concurrent writes spend nearly all their time on N commits they could have
421
+ * shared. Measured at 200 finalizes: 80µs each one-transaction-apiece against
422
+ * 10µs each in one transaction (`bench/sweep` sweep B).
423
+ *
424
+ * Nothing waits to form a batch — a flusher takes whatever arrived while the
425
+ * previous one held the lock, so this trades no latency for the throughput. What
426
+ * it does trade is atomicity: two callers' writes now land together or not at
427
+ * all. Under at-least-once that is not observable (a lost batch is a
428
+ * redelivery), and it is why every member is resolved only after COMMIT.
429
+ */
430
+ flush(db) {
431
+ // One writer waiting is the uncontended case, and it stays exactly as cheap as
432
+ // before: wrapping a single statement in BEGIN/COMMIT would add two statements
433
+ // to every write on an idle store.
434
+ if (this.pending.length === 1) {
435
+ const only = this.pending[0];
436
+ let rows;
437
+ try {
438
+ rows = this.runNow(only.name, only.params);
439
+ }
440
+ catch (err) {
441
+ // Leave it pending on a lost write lock: withLock re-runs this flusher.
442
+ if (isBusy(err))
443
+ throw err;
444
+ this.pending.shift();
445
+ only.reject(err);
446
+ return;
447
+ }
448
+ this.pending.shift();
449
+ only.resolve(rows);
450
+ return;
451
+ }
452
+ // BEGIN before consuming, so a lost write lock leaves the batch where the
453
+ // retry will find it — with anything that arrived meanwhile.
454
+ db.exec("BEGIN IMMEDIATE");
455
+ const batch = this.pending;
456
+ this.pending = [];
457
+ const out = [];
458
+ try {
459
+ for (const w of batch) {
460
+ try {
461
+ out.push({ rows: this.runNow(w.name, w.params) });
462
+ }
463
+ catch (err) {
464
+ // A statement error aborts that statement, not the transaction, so the
465
+ // rest of the batch is still good and this one waiter carries the error.
466
+ // If SQLite tore the transaction down instead, nothing in it survived
467
+ // and every member has to hear about it.
468
+ if (!db.inTransaction)
469
+ throw err;
470
+ out.push({ err });
471
+ }
472
+ }
473
+ db.exec("COMMIT");
474
+ }
475
+ catch (err) {
476
+ if (db.inTransaction) {
477
+ try {
478
+ db.exec("ROLLBACK");
479
+ }
480
+ catch {
481
+ // Raced with SQLite's own rollback; the transaction is gone either way.
482
+ }
483
+ }
484
+ if (isBusy(err)) {
485
+ // Back to the head of the queue, ahead of later arrivals, so the retry
486
+ // preserves the order the writes were issued in.
487
+ this.pending = batch.concat(this.pending);
488
+ throw err;
489
+ }
490
+ for (const w of batch)
491
+ w.reject(err);
492
+ return;
493
+ }
494
+ // Only now: before COMMIT a rollback could still take the write back, and a
495
+ // caller holding its row would have observed a write that never happened.
496
+ for (let i = 0; i < batch.length; i++) {
497
+ // Presence, not truthiness — a thrown value is not guaranteed to be one.
498
+ if ("err" in out[i])
499
+ batch[i].reject(out[i].err);
500
+ else
501
+ batch[i].resolve(out[i].rows);
502
+ }
503
+ }
504
+ /**
505
+ * Make sure some flusher is draining `pending`, without ever running two.
506
+ *
507
+ * The flusher loops instead of re-arming itself per batch. A caller that awaits
508
+ * its writes one at a time resumes and issues the next one *before* the flusher
509
+ * gets its turn back, so re-arming would cost that write an extra trip through
510
+ * the lock queue — measured as ~2x on sequential writes, which is most of them.
511
+ * Looping picks it up in the same session for free.
512
+ *
513
+ * The exit is safe because the last `pending` check and clearing the flag happen
514
+ * in one synchronous step: a write that arrives before it keeps the loop going,
515
+ * and one that arrives after sees the flag down and starts a new flusher.
516
+ */
517
+ scheduleFlush(db) {
518
+ if (this.flushing)
519
+ return;
520
+ this.flushing = true;
521
+ void (async () => {
522
+ try {
523
+ while (this.pending.length) {
524
+ try {
525
+ await this.withLock(() => this.flush(db));
526
+ }
527
+ catch (err) {
528
+ // flush only throws on a lost write lock, and only after putting its
529
+ // batch back — so reaching here means withLock spent the whole budget
530
+ // and those writes are still queued with nobody else coming for them.
531
+ // Anything that arrived behind them is failed with the same error
532
+ // rather than left hanging: this store cannot write at all right now,
533
+ // which is what a lone write would have been told too.
534
+ const stranded = this.pending;
535
+ this.pending = [];
536
+ for (const w of stranded)
537
+ w.reject(err);
538
+ }
539
+ }
540
+ }
541
+ finally {
542
+ this.flushing = false;
543
+ }
544
+ })();
545
+ }
392
546
  async fetch(name, params) {
393
547
  const db = this.ensure();
394
548
  await this.maybeRefreshStatistics(db);
395
- return this.withLock(() => this.runNow(name, params));
549
+ // Reads keep their own turn on the lock — see isWriteStatement.
550
+ if (!this.writes[name])
551
+ return this.withLock(() => this.runNow(name, params));
552
+ return new Promise((resolve, reject) => {
553
+ this.pending.push({ name, params, resolve, reject });
554
+ this.scheduleFlush(db);
555
+ });
396
556
  }
397
557
  async tx(fn) {
398
558
  const db = this.ensure();