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/dist/_protocol/sql/postgres/queue_depth.sql +22 -0
- package/dist/_protocol/sql/sqlite/queue_depth.sql +26 -0
- package/dist/backpressure.d.ts +59 -0
- package/dist/backpressure.js +122 -0
- package/dist/client.d.ts +16 -3
- package/dist/client.js +19 -5
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/store/base.d.ts +24 -0
- package/dist/store/base.js +38 -1
- package/dist/store/sqlite.d.ts +35 -0
- package/dist/store/sqlite.js +160 -3
- package/dist/worker.d.ts +28 -1
- package/dist/worker.js +50 -2
- package/package.json +6 -5
- package/src/backpressure.ts +140 -0
- package/src/client.ts +33 -5
- package/src/errors.ts +18 -0
- package/src/index.ts +4 -1
- package/src/store/base.ts +41 -1
- package/src/store/sqlite.ts +165 -2
- package/src/worker.ts +76 -3
package/dist/store/sqlite.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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": ">=
|
|
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": "^
|
|
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": "^
|
|
59
|
-
"vitest": "^
|
|
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(
|
|
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
|
|
20
|
-
|
|
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
|
|
26
|
-
|
|
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,
|