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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- How many more tasks fit in one queue under :max_depth. Read-only.
|
|
2
|
+
--
|
|
3
|
+
-- Returns headroom, not depth: a producer needs "may I enqueue, and how many
|
|
4
|
+
-- more" — and bounding the scan at :max_depth is what keeps this affordable.
|
|
5
|
+
-- COUNT over the whole backlog would read every queued row, so the cost of
|
|
6
|
+
-- asking would grow with exactly the pile-up the caller is trying to stop.
|
|
7
|
+
-- Wrapped as a LIMIT subquery it reads at most :max_depth index entries off
|
|
8
|
+
-- cairnq_tasks_claim_idx (queue, status leading), and headroom saturates at 0
|
|
9
|
+
-- once the queue is full — which is all a gate needs to know.
|
|
10
|
+
--
|
|
11
|
+
-- Counts 'queued' only. A running task already has a worker and is bounded by
|
|
12
|
+
-- that worker's concurrency; the backlog worth pushing back on is the work
|
|
13
|
+
-- nobody has picked up. Delayed tasks (run_at_ms in the future) count: they are
|
|
14
|
+
-- queued work that will run, and excluding them would let an unbounded pile of
|
|
15
|
+
-- them through the gate.
|
|
16
|
+
-- params: queue, max_depth
|
|
17
|
+
select :max_depth - count(*) as headroom
|
|
18
|
+
from (
|
|
19
|
+
select 1 from cairnq_tasks
|
|
20
|
+
where queue = :queue and status = 'queued'
|
|
21
|
+
limit :max_depth
|
|
22
|
+
) probe;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- How many more tasks fit in one queue under :max_depth. Read-only.
|
|
2
|
+
--
|
|
3
|
+
-- Returns headroom, not depth: a producer needs "may I enqueue, and how many
|
|
4
|
+
-- more" — and bounding the scan at :max_depth is what keeps this affordable.
|
|
5
|
+
-- COUNT over the whole backlog would read every queued row, so the cost of
|
|
6
|
+
-- asking would grow with exactly the pile-up the caller is trying to stop.
|
|
7
|
+
-- Wrapped as a LIMIT subquery it reads at most :max_depth index entries off
|
|
8
|
+
-- cairnq_tasks_claim_idx (queue, status leading), and headroom saturates at 0
|
|
9
|
+
-- once the queue is full — which is all a gate needs to know.
|
|
10
|
+
--
|
|
11
|
+
-- Counts 'queued' only. A running task already has a worker and is bounded by
|
|
12
|
+
-- that worker's concurrency; the backlog worth pushing back on is the work
|
|
13
|
+
-- nobody has picked up. Delayed tasks (run_at_ms in the future) count: they are
|
|
14
|
+
-- queued work that will run, and excluding them would let an unbounded pile of
|
|
15
|
+
-- them through the gate.
|
|
16
|
+
--
|
|
17
|
+
-- Read-only, and it must stay that way: isWriteStatement/_is_write_statement
|
|
18
|
+
-- route a non-select into the group commit, which would put a gate probe behind
|
|
19
|
+
-- SQLite's write lock — the opposite of what a backpressure check is for.
|
|
20
|
+
-- params: queue, max_depth
|
|
21
|
+
select :max_depth - count(*) as headroom
|
|
22
|
+
from (
|
|
23
|
+
select 1 from cairnq_tasks
|
|
24
|
+
where queue = :queue and status = 'queued'
|
|
25
|
+
limit :max_depth
|
|
26
|
+
) probe;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { TaskStore } from "./store/base.js";
|
|
2
|
+
/** Per-queue depth limits. A number applies one limit to every queue; a record
|
|
3
|
+
* gates only the queues it names and leaves the rest unbounded. */
|
|
4
|
+
export type QueueDepthLimit = number | Record<string, number>;
|
|
5
|
+
export interface BackpressureOptions {
|
|
6
|
+
/** Queued tasks a queue may hold before `submit` blocks. */
|
|
7
|
+
maxQueueDepth: QueueDepthLimit;
|
|
8
|
+
/** How long a blocked submit waits before raising QueueFull. Default 600_000. */
|
|
9
|
+
maxQueueWaitMs?: number;
|
|
10
|
+
/** First backoff between depth probes; doubles to a 5s ceiling. Default 250. */
|
|
11
|
+
queuePollIntervalMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Blocks `submit` while a queue is at its depth limit.
|
|
15
|
+
*
|
|
16
|
+
* Without one of these a producer that outruns its workers is only bounded by
|
|
17
|
+
* disk: the backlog grows, every task's queue wait grows with it, and the
|
|
18
|
+
* failure is a database that filled up rather than a producer that slowed down.
|
|
19
|
+
* A queue is the wrong place to buffer an overload — pushing back on the
|
|
20
|
+
* producer is the point.
|
|
21
|
+
*
|
|
22
|
+
* **A soft limit under several producers.** The check is a read followed by a
|
|
23
|
+
* write that other producers can interleave with, and each holds its own grant,
|
|
24
|
+
* so N producers can overshoot the limit by up to (N-1) * MAX_GRANT tasks. Made
|
|
25
|
+
* exact it would need the depth check inside insert_task's transaction, which
|
|
26
|
+
* puts an unbounded-scan predicate on the hot path of every submit and turns
|
|
27
|
+
* concurrent submits into lock contention — a steep price for a bound whose
|
|
28
|
+
* whole purpose is approximate. Size the limit for the pushback you want, not as
|
|
29
|
+
* a capacity assertion.
|
|
30
|
+
*/
|
|
31
|
+
export declare class QueueDepthGate {
|
|
32
|
+
private readonly store;
|
|
33
|
+
/** Remaining grant per queue: submits allowed before the next probe. */
|
|
34
|
+
private readonly headroom;
|
|
35
|
+
/** In-flight probe per queue, so concurrent submits share one read rather
|
|
36
|
+
* than each issuing their own against a queue that is already known full. */
|
|
37
|
+
private readonly probing;
|
|
38
|
+
private readonly limits;
|
|
39
|
+
private readonly maxWaitMs;
|
|
40
|
+
private readonly initialProbeMs;
|
|
41
|
+
constructor(store: TaskStore, opts: BackpressureOptions);
|
|
42
|
+
private validate;
|
|
43
|
+
/** The limit for `queue`, or null when it is not gated. */
|
|
44
|
+
limitFor(queue: string): number | null;
|
|
45
|
+
/**
|
|
46
|
+
* Consume one unit of headroom for `queue`, waiting for room if it is full.
|
|
47
|
+
* Returns immediately for an ungated queue. Raises QueueFull on timeout,
|
|
48
|
+
* having enqueued nothing.
|
|
49
|
+
*/
|
|
50
|
+
acquire(queue: string): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Refresh `queue`'s grant from the store, at most one probe in flight.
|
|
53
|
+
*
|
|
54
|
+
* Callers re-read `headroom` afterwards rather than using a returned value:
|
|
55
|
+
* only the caller that started the probe writes the grant, so waiters that
|
|
56
|
+
* joined it cannot overwrite the units already handed out.
|
|
57
|
+
*/
|
|
58
|
+
private probe;
|
|
59
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { QueueFull } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Most tasks a producer may enqueue on one probe's word.
|
|
5
|
+
*
|
|
6
|
+
* The gate probes only when its headroom runs out, so this is what the check
|
|
7
|
+
* costs amortized: one bounded index read per MAX_GRANT submits. It also bounds
|
|
8
|
+
* how far the limit can be overshot — see the class docstring on why several
|
|
9
|
+
* producers make this a soft limit, and why that overshoot is (N-1) * MAX_GRANT
|
|
10
|
+
* rather than unbounded.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_GRANT = 64;
|
|
13
|
+
// Named for probing, not polling: wait.ts exports DEFAULT_POLL_MS / MAX_POLL_MS
|
|
14
|
+
// for the get() loop behind wait(), an order of magnitude tighter and answering
|
|
15
|
+
// a different question. Two constants of the same name in one SDK would be read
|
|
16
|
+
// as one policy.
|
|
17
|
+
const INITIAL_PROBE_INTERVAL_MS = 250;
|
|
18
|
+
const MAX_PROBE_INTERVAL_MS = 5_000;
|
|
19
|
+
const DEFAULT_MAX_WAIT_MS = 600_000;
|
|
20
|
+
/**
|
|
21
|
+
* Blocks `submit` while a queue is at its depth limit.
|
|
22
|
+
*
|
|
23
|
+
* Without one of these a producer that outruns its workers is only bounded by
|
|
24
|
+
* disk: the backlog grows, every task's queue wait grows with it, and the
|
|
25
|
+
* failure is a database that filled up rather than a producer that slowed down.
|
|
26
|
+
* A queue is the wrong place to buffer an overload — pushing back on the
|
|
27
|
+
* producer is the point.
|
|
28
|
+
*
|
|
29
|
+
* **A soft limit under several producers.** The check is a read followed by a
|
|
30
|
+
* write that other producers can interleave with, and each holds its own grant,
|
|
31
|
+
* so N producers can overshoot the limit by up to (N-1) * MAX_GRANT tasks. Made
|
|
32
|
+
* exact it would need the depth check inside insert_task's transaction, which
|
|
33
|
+
* puts an unbounded-scan predicate on the hot path of every submit and turns
|
|
34
|
+
* concurrent submits into lock contention — a steep price for a bound whose
|
|
35
|
+
* whole purpose is approximate. Size the limit for the pushback you want, not as
|
|
36
|
+
* a capacity assertion.
|
|
37
|
+
*/
|
|
38
|
+
export class QueueDepthGate {
|
|
39
|
+
store;
|
|
40
|
+
/** Remaining grant per queue: submits allowed before the next probe. */
|
|
41
|
+
headroom = new Map();
|
|
42
|
+
/** In-flight probe per queue, so concurrent submits share one read rather
|
|
43
|
+
* than each issuing their own against a queue that is already known full. */
|
|
44
|
+
probing = new Map();
|
|
45
|
+
limits;
|
|
46
|
+
maxWaitMs;
|
|
47
|
+
initialProbeMs;
|
|
48
|
+
constructor(store, opts) {
|
|
49
|
+
this.store = store;
|
|
50
|
+
this.limits = opts.maxQueueDepth;
|
|
51
|
+
this.maxWaitMs = opts.maxQueueWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
52
|
+
this.initialProbeMs = opts.queuePollIntervalMs ?? INITIAL_PROBE_INTERVAL_MS;
|
|
53
|
+
if (typeof this.limits === "number")
|
|
54
|
+
this.validate("*", this.limits);
|
|
55
|
+
else
|
|
56
|
+
for (const [q, v] of Object.entries(this.limits))
|
|
57
|
+
this.validate(q, v);
|
|
58
|
+
}
|
|
59
|
+
validate(queue, limit) {
|
|
60
|
+
// A limit of 0 would block every submit forever, which is never what a
|
|
61
|
+
// caller means; catching it here beats a first submit that hangs for
|
|
62
|
+
// maxQueueWaitMs and then raises.
|
|
63
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
64
|
+
throw new Error(`maxQueueDepth for ${queue} must be an integer >= 1, got ${limit}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The limit for `queue`, or null when it is not gated. */
|
|
68
|
+
limitFor(queue) {
|
|
69
|
+
if (typeof this.limits === "number")
|
|
70
|
+
return this.limits;
|
|
71
|
+
return this.limits[queue] ?? null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Consume one unit of headroom for `queue`, waiting for room if it is full.
|
|
75
|
+
* Returns immediately for an ungated queue. Raises QueueFull on timeout,
|
|
76
|
+
* having enqueued nothing.
|
|
77
|
+
*/
|
|
78
|
+
async acquire(queue) {
|
|
79
|
+
const limit = this.limitFor(queue);
|
|
80
|
+
if (limit == null)
|
|
81
|
+
return;
|
|
82
|
+
const startedAt = Date.now();
|
|
83
|
+
let waitMs = this.initialProbeMs;
|
|
84
|
+
for (;;) {
|
|
85
|
+
const left = this.headroom.get(queue) ?? 0;
|
|
86
|
+
if (left > 0) {
|
|
87
|
+
this.headroom.set(queue, left - 1);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await this.probe(queue, limit);
|
|
91
|
+
if ((this.headroom.get(queue) ?? 0) > 0)
|
|
92
|
+
continue;
|
|
93
|
+
const waited = Date.now() - startedAt;
|
|
94
|
+
if (waited >= this.maxWaitMs)
|
|
95
|
+
throw new QueueFull(queue, limit, waited);
|
|
96
|
+
// Back off: a queue at its limit will not drain within one poll interval,
|
|
97
|
+
// and re-probing tightly adds read load to a database already behind.
|
|
98
|
+
await delay(Math.min(waitMs, this.maxWaitMs - waited));
|
|
99
|
+
waitMs = Math.min(waitMs * 2, MAX_PROBE_INTERVAL_MS);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Refresh `queue`'s grant from the store, at most one probe in flight.
|
|
104
|
+
*
|
|
105
|
+
* Callers re-read `headroom` afterwards rather than using a returned value:
|
|
106
|
+
* only the caller that started the probe writes the grant, so waiters that
|
|
107
|
+
* joined it cannot overwrite the units already handed out.
|
|
108
|
+
*/
|
|
109
|
+
probe(queue, limit) {
|
|
110
|
+
let p = this.probing.get(queue);
|
|
111
|
+
if (!p) {
|
|
112
|
+
p = this.store
|
|
113
|
+
.queueDepth(queue, limit)
|
|
114
|
+
.then((headroom) => {
|
|
115
|
+
this.headroom.set(queue, Math.min(headroom, MAX_GRANT));
|
|
116
|
+
})
|
|
117
|
+
.finally(() => this.probing.delete(queue));
|
|
118
|
+
this.probing.set(queue, p);
|
|
119
|
+
}
|
|
120
|
+
return p;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BackpressureOptions } from "./backpressure.js";
|
|
1
2
|
import { type Task, type TaskStatus } from "./models.js";
|
|
2
3
|
import type { ListInput, PurgeInput, SubmitInput, TaskStore } from "./store/base.js";
|
|
3
4
|
import { type TaskDef } from "./task.js";
|
|
@@ -6,23 +7,35 @@ export interface CallOptions extends SubmitOptions {
|
|
|
6
7
|
waitTimeoutMs?: number;
|
|
7
8
|
pollMs?: number;
|
|
8
9
|
}
|
|
10
|
+
/** Options this handle configures on the store it wraps, rather than the
|
|
11
|
+
* store's own constructor arguments. */
|
|
12
|
+
export type ClientOptions = Partial<BackpressureOptions>;
|
|
9
13
|
/** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
|
|
10
14
|
export declare class CairnQ {
|
|
11
15
|
private readonly _store;
|
|
12
|
-
constructor(_store: TaskStore);
|
|
16
|
+
constructor(_store: TaskStore, opts?: ClientOptions);
|
|
13
17
|
static sqlite(path: string, opts?: {
|
|
14
18
|
busyTimeoutMs?: number;
|
|
15
|
-
}): CairnQ;
|
|
19
|
+
} & ClientOptions): CairnQ;
|
|
16
20
|
/** Multi-host backend. `dsn` is a libpq connection string; requires the
|
|
17
21
|
* optional `pg` package. */
|
|
18
22
|
static postgres(dsn: string, opts?: {
|
|
19
23
|
max?: number;
|
|
20
|
-
}): CairnQ;
|
|
24
|
+
} & ClientOptions): CairnQ;
|
|
21
25
|
get store(): TaskStore;
|
|
22
26
|
connect(): Promise<void>;
|
|
23
27
|
close(): Promise<void>;
|
|
28
|
+
/** Enqueue a task. With `maxQueueDepth` configured this blocks while the
|
|
29
|
+
* target queue is at its limit, and raises QueueFull if it stays there for
|
|
30
|
+
* `maxQueueWaitMs` — see QueueDepthGate for why that bound is approximate
|
|
31
|
+
* across several producers. */
|
|
24
32
|
submit(name: string, payload?: unknown, opts?: SubmitOptions): Promise<Task>;
|
|
25
33
|
submit<P, R>(task: TaskDef<P, R>, payload?: P, opts?: SubmitOptions): Promise<Task>;
|
|
34
|
+
/** How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
|
|
35
|
+
* The non-blocking read behind `maxQueueDepth`, for a producer that would
|
|
36
|
+
* rather shed load or pick another queue than wait. Cheaper than `stats()`:
|
|
37
|
+
* bounded at `maxDepth` index entries instead of aggregating the table. */
|
|
38
|
+
queueDepth(queue: string, maxDepth: number): Promise<number>;
|
|
26
39
|
get(taskId: string): Promise<Task | null>;
|
|
27
40
|
getByKey(key: string): Promise<Task | null>;
|
|
28
41
|
list(input?: ListInput): Promise<Task[]>;
|
package/dist/client.js
CHANGED
|
@@ -7,16 +7,23 @@ import { pollWait } from "./wait.js";
|
|
|
7
7
|
/** API-side handle. Thin wrapper over a TaskStore + SDK-orchestrated wait/call. */
|
|
8
8
|
export class CairnQ {
|
|
9
9
|
_store;
|
|
10
|
-
constructor(_store) {
|
|
10
|
+
constructor(_store, opts = {}) {
|
|
11
11
|
this._store = _store;
|
|
12
|
+
// Installed on the store, not held here: every submit path goes through the
|
|
13
|
+
// store, including TaskContext.submit, which this handle never sees.
|
|
14
|
+
if (opts.maxQueueDepth != null) {
|
|
15
|
+
_store.useBackpressure(opts);
|
|
16
|
+
}
|
|
12
17
|
}
|
|
13
|
-
static sqlite(path, opts) {
|
|
14
|
-
|
|
18
|
+
static sqlite(path, opts = {}) {
|
|
19
|
+
const { busyTimeoutMs, ...client } = opts;
|
|
20
|
+
return new CairnQ(new SQLiteStore(path, { busyTimeoutMs }), client);
|
|
15
21
|
}
|
|
16
22
|
/** Multi-host backend. `dsn` is a libpq connection string; requires the
|
|
17
23
|
* optional `pg` package. */
|
|
18
|
-
static postgres(dsn, opts) {
|
|
19
|
-
|
|
24
|
+
static postgres(dsn, opts = {}) {
|
|
25
|
+
const { max, ...client } = opts;
|
|
26
|
+
return new CairnQ(new PostgresStore(dsn, { max }), client);
|
|
20
27
|
}
|
|
21
28
|
get store() {
|
|
22
29
|
return this._store;
|
|
@@ -30,6 +37,13 @@ export class CairnQ {
|
|
|
30
37
|
submit(task, payload, opts = {}) {
|
|
31
38
|
return this._store.submit({ name: taskName(task), payload, ...opts });
|
|
32
39
|
}
|
|
40
|
+
/** How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
|
|
41
|
+
* The non-blocking read behind `maxQueueDepth`, for a producer that would
|
|
42
|
+
* rather shed load or pick another queue than wait. Cheaper than `stats()`:
|
|
43
|
+
* bounded at `maxDepth` index entries instead of aggregating the table. */
|
|
44
|
+
queueDepth(queue, maxDepth) {
|
|
45
|
+
return this._store.queueDepth(queue, maxDepth);
|
|
46
|
+
}
|
|
33
47
|
get(taskId) {
|
|
34
48
|
return this._store.get(taskId);
|
|
35
49
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -16,6 +16,16 @@ export declare class AlreadyExists extends CairnQError {
|
|
|
16
16
|
key: string;
|
|
17
17
|
constructor(key: string);
|
|
18
18
|
}
|
|
19
|
+
/** A gated submit waited out `maxWaitMs` without the queue draining below its
|
|
20
|
+
* depth limit. Nothing was enqueued. Distinct from a slow submit on purpose: a
|
|
21
|
+
* queue this far behind is a capacity problem, and a caller that silently
|
|
22
|
+
* retries forever converts it into an invisible one. */
|
|
23
|
+
export declare class QueueFull extends CairnQError {
|
|
24
|
+
queue: string;
|
|
25
|
+
maxDepth: number;
|
|
26
|
+
waitedMs: number;
|
|
27
|
+
constructor(queue: string, maxDepth: number, waitedMs: number);
|
|
28
|
+
}
|
|
19
29
|
/** wait/call did not reach a terminal status in time. The task keeps running.
|
|
20
30
|
* `task` is the last snapshot wait() observed (null if get() found nothing), and
|
|
21
31
|
* the message says what state it was stuck in — a queued-never-claimed task is
|
package/dist/errors.js
CHANGED
|
@@ -29,6 +29,23 @@ export class AlreadyExists extends CairnQError {
|
|
|
29
29
|
this.name = "AlreadyExists";
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/** A gated submit waited out `maxWaitMs` without the queue draining below its
|
|
33
|
+
* depth limit. Nothing was enqueued. Distinct from a slow submit on purpose: a
|
|
34
|
+
* queue this far behind is a capacity problem, and a caller that silently
|
|
35
|
+
* retries forever converts it into an invisible one. */
|
|
36
|
+
export class QueueFull extends CairnQError {
|
|
37
|
+
queue;
|
|
38
|
+
maxDepth;
|
|
39
|
+
waitedMs;
|
|
40
|
+
constructor(queue, maxDepth, waitedMs) {
|
|
41
|
+
super(`queue ${queue} still holds ${maxDepth} or more queued tasks after ` +
|
|
42
|
+
`${waitedMs}ms; refusing to enqueue more`);
|
|
43
|
+
this.queue = queue;
|
|
44
|
+
this.maxDepth = maxDepth;
|
|
45
|
+
this.waitedMs = waitedMs;
|
|
46
|
+
this.name = "QueueFull";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
32
49
|
/** One line of "why hasn't this finished" from the last snapshot wait()
|
|
33
50
|
* observed. No worker running, no handler for the name, wrong queue, and two
|
|
34
51
|
* processes on different database files all look identical from the API side —
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { CairnQ } from "./client.js";
|
|
2
|
-
export type { CallOptions, SubmitOptions } from "./client.js";
|
|
2
|
+
export type { CallOptions, ClientOptions, SubmitOptions } from "./client.js";
|
|
3
|
+
export { QueueDepthGate } from "./backpressure.js";
|
|
4
|
+
export type { BackpressureOptions, QueueDepthLimit } from "./backpressure.js";
|
|
3
5
|
export { Worker } from "./worker.js";
|
|
4
6
|
export type { Handler, TypedHandler, WorkerOptions } from "./worker.js";
|
|
5
7
|
export { TaskContext } from "./context.js";
|
|
@@ -11,4 +13,4 @@ export { TaskStore } from "./store/base.js";
|
|
|
11
13
|
export type { ListInput, PurgeInput, SubmitInput, Conflict } from "./store/base.js";
|
|
12
14
|
export type { Task, TaskStatus } from "./models.js";
|
|
13
15
|
export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
|
|
14
|
-
export { CairnQError, AlreadyExists, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
|
|
16
|
+
export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { CairnQ } from "./client.js";
|
|
2
|
+
export { QueueDepthGate } from "./backpressure.js";
|
|
2
3
|
export { Worker } from "./worker.js";
|
|
3
4
|
export { TaskContext } from "./context.js";
|
|
4
5
|
export { defineTask } from "./task.js";
|
|
@@ -6,4 +7,4 @@ export { SQLiteStore } from "./store/sqlite.js";
|
|
|
6
7
|
export { PostgresStore } from "./store/postgres.js";
|
|
7
8
|
export { TaskStore } from "./store/base.js";
|
|
8
9
|
export { STATUSES, isTerminal, cancelRequested, isQueued, isRunning, isSucceeded, isFailed, isCanceled, } from "./models.js";
|
|
9
|
-
export { CairnQError, AlreadyExists, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
|
|
10
|
+
export { CairnQError, AlreadyExists, QueueFull, TaskTimeout, TaskFailed, TaskCanceled, TaskError, LostLease, ProtocolVersionMismatch, SerializationError, } from "./errors.js";
|
package/dist/store/base.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Task, type TaskStatus } from "../models.js";
|
|
2
|
+
import { type BackpressureOptions } from "../backpressure.js";
|
|
2
3
|
/** Encode a value for a protocol JSON column, raising SerializationError on
|
|
3
4
|
* anything JSON cannot represent. Refuses what JSON.stringify would silently
|
|
4
5
|
* mangle into `null`: NaN/Infinity anywhere, undefined/function/symbol inside an
|
|
@@ -12,6 +13,9 @@ export declare function dumpJson(value: unknown): string;
|
|
|
12
13
|
export declare function checkProtocolVersion(version: number): void;
|
|
13
14
|
declare const CONFLICTS: readonly ["reuse", "reject", "replace"];
|
|
14
15
|
export type Conflict = (typeof CONFLICTS)[number];
|
|
16
|
+
/** The queue a submit lands on when it names none. Owned here, where the
|
|
17
|
+
* default is applied, so nothing above has to re-derive it. */
|
|
18
|
+
export declare const DEFAULT_QUEUE = "default";
|
|
15
19
|
export interface SubmitInput {
|
|
16
20
|
name: string;
|
|
17
21
|
payload: unknown;
|
|
@@ -69,6 +73,8 @@ export declare function statementParams(sql: string): readonly string[];
|
|
|
69
73
|
* behavior; the shared SQL already stops them from drifting in wording.
|
|
70
74
|
*/
|
|
71
75
|
export declare abstract class TaskStore {
|
|
76
|
+
/** Set by useBackpressure; null means submit is ungated. */
|
|
77
|
+
private gate;
|
|
72
78
|
abstract connect(): Promise<void>;
|
|
73
79
|
abstract close(): Promise<void>;
|
|
74
80
|
abstract protocolVersion(): Promise<number>;
|
|
@@ -103,6 +109,15 @@ export declare abstract class TaskStore {
|
|
|
103
109
|
*/
|
|
104
110
|
private ownedWrite;
|
|
105
111
|
private static one;
|
|
112
|
+
/**
|
|
113
|
+
* Bound how deep a queue may get before `submit` blocks. Off unless set.
|
|
114
|
+
*
|
|
115
|
+
* It hangs here rather than on `CairnQ` because the store is the one choke
|
|
116
|
+
* point every submit passes through — a handler spawning children via
|
|
117
|
+
* `TaskContext.submit` is the shape most likely to outrun its workers, and
|
|
118
|
+
* gating only the client would leave exactly that path unbounded.
|
|
119
|
+
*/
|
|
120
|
+
useBackpressure(opts: BackpressureOptions): void;
|
|
106
121
|
submit(input: SubmitInput): Promise<Task>;
|
|
107
122
|
get(taskId: string): Promise<Task | null>;
|
|
108
123
|
getByKey(key: string): Promise<Task | null>;
|
|
@@ -136,6 +151,15 @@ export declare abstract class TaskStore {
|
|
|
136
151
|
* them.
|
|
137
152
|
*/
|
|
138
153
|
stats(): Promise<Record<string, Record<TaskStatus, number>>>;
|
|
154
|
+
/**
|
|
155
|
+
* How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
|
|
156
|
+
*
|
|
157
|
+
* The cheap half of backpressure: bounded at `maxDepth` index entries, unlike
|
|
158
|
+
* `stats()`, which aggregates the whole table (terminal rows included) and so
|
|
159
|
+
* costs more the longer a database has been running. Use it directly to shed
|
|
160
|
+
* load or shape a producer; `QueueDepthGate` builds the blocking form on top.
|
|
161
|
+
*/
|
|
162
|
+
queueDepth(queue: string, maxDepth: number): Promise<number>;
|
|
139
163
|
/**
|
|
140
164
|
* Take up to `limit` claimable tasks. `names` restricts the claim to task names
|
|
141
165
|
* this caller can actually run — a worker passes its registered handlers.
|
package/dist/store/base.js
CHANGED
|
@@ -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 ??
|
|
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
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -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>;
|