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.
- package/README.md +3 -2
- package/dist/_protocol/migrations/postgres/0006_claim_name_index.sql +25 -0
- package/dist/_protocol/migrations/sqlite/0006_claim_name_index.sql +26 -0
- package/dist/_protocol/sql/postgres/claim_one_name.sql +37 -0
- package/dist/_protocol/sql/postgres/claim_one_queue_one_name.sql +34 -0
- package/dist/_protocol/sql/postgres/heartbeat_batch.sql +24 -0
- package/dist/_protocol/sql/postgres/queue_depth.sql +22 -0
- package/dist/_protocol/sql/sqlite/claim_one_name.sql +36 -0
- package/dist/_protocol/sql/sqlite/claim_one_queue_one_name.sql +31 -0
- package/dist/_protocol/sql/sqlite/heartbeat_batch.sql +29 -0
- package/dist/_protocol/sql/sqlite/queue_depth.sql +26 -0
- package/dist/backoff.d.ts +31 -0
- package/dist/backoff.js +40 -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/context.d.ts +48 -2
- package/dist/context.js +101 -10
- package/dist/errors.d.ts +37 -0
- package/dist/errors.js +60 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +2 -1
- package/dist/store/base.d.ts +73 -0
- package/dist/store/base.js +124 -14
- package/dist/store/sqlite.d.ts +35 -0
- package/dist/store/sqlite.js +163 -3
- package/dist/worker.d.ts +238 -13
- package/dist/worker.js +512 -120
- package/package.json +2 -1
- package/src/backoff.ts +53 -0
- package/src/backpressure.ts +140 -0
- package/src/client.ts +33 -5
- package/src/context.ts +116 -9
- package/src/errors.ts +66 -0
- package/src/index.ts +7 -2
- package/src/store/base.ts +136 -13
- package/src/store/sqlite.ts +168 -2
- package/src/worker.ts +671 -132
package/README.md
CHANGED
|
@@ -66,8 +66,9 @@ Opt-in: every API still accepts a plain name string (cross-language callers use
|
|
|
66
66
|
|
|
67
67
|
```ts
|
|
68
68
|
const worker = Worker.sqlite("tasks.db", {
|
|
69
|
-
concurrency: 4,
|
|
70
|
-
retryBackoffMs: 1_000, // doubles per attempt, capped
|
|
69
|
+
concurrency: 4, // handler calls at once; use maxInFlightBytes to bound memory
|
|
70
|
+
retryBackoffMs: 1_000, // window doubles per attempt, capped at retryBackoffMaxMs (30s),
|
|
71
|
+
// jittered over its upper half; 0 disables
|
|
71
72
|
onError: (err, info) => log.warn({ err, ...info }), // claims/writes the loop survived
|
|
72
73
|
});
|
|
73
74
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Serves the per-name claim: claim_one_name.sql and claim_one_queue_one_name.sql,
|
|
2
|
+
-- which a worker uses to draw a separate quota for each task name that sizes
|
|
3
|
+
-- itself (a `batch`, or its own concurrency). See "Batch delivery" in PROTOCOL.md.
|
|
4
|
+
--
|
|
5
|
+
-- cairnq_tasks_claim_idx does not cover it: `name` is not in that index, so a
|
|
6
|
+
-- name filter is a residual applied while walking the queue in claim order. The
|
|
7
|
+
-- cost lands hardest on a name with *nothing* queued — the claim walks every
|
|
8
|
+
-- claimable row in the queue looking for `limit` matches and finds none — and a
|
|
9
|
+
-- worker makes one such draw per registered name, per poll, inside the claim's
|
|
10
|
+
-- transaction, holding its FOR UPDATE row locks for the whole of it.
|
|
11
|
+
--
|
|
12
|
+
-- Both indexes are needed. `name` sits before the ORDER BY columns here, so this
|
|
13
|
+
-- one can only be read in claim order when `name` is an equality — exactly the
|
|
14
|
+
-- per-name draws. A claim with no name filter, or an array-valued one, still
|
|
15
|
+
-- reads cairnq_tasks_claim_idx and is unaffected.
|
|
16
|
+
--
|
|
17
|
+
-- NOT built CONCURRENTLY: migrations run inside a transaction (see the runner's
|
|
18
|
+
-- `lock table cairnq_migrations`), and CREATE INDEX CONCURRENTLY cannot. On an
|
|
19
|
+
-- existing large cairnq_tasks this takes a write lock for the build. Deploying
|
|
20
|
+
-- into a busy database is the case to watch; build it by hand with CONCURRENTLY
|
|
21
|
+
-- first if that matters, and this statement then becomes a no-op.
|
|
22
|
+
create index if not exists cairnq_tasks_claim_name_idx
|
|
23
|
+
on cairnq_tasks (queue, status, name, priority desc, created_at_ms);
|
|
24
|
+
|
|
25
|
+
update cairnq_meta set value = '6' where key = 'schema_version';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Serves the per-name claim: claim_one_name.sql and claim_one_queue_one_name.sql,
|
|
2
|
+
-- which a worker uses to draw a separate quota for each task name that sizes
|
|
3
|
+
-- itself (a `batch`, or its own concurrency). See "Batch delivery" in PROTOCOL.md.
|
|
4
|
+
--
|
|
5
|
+
-- cairnq_tasks_claim_idx does not cover it: `name` is not in that index, so a
|
|
6
|
+
-- name filter is a residual applied while walking the queue in claim order. The
|
|
7
|
+
-- cost lands hardest on a name with *nothing* queued — the claim walks every
|
|
8
|
+
-- claimable row in the queue looking for `limit` matches and finds none — and a
|
|
9
|
+
-- worker makes one such draw per registered name, per poll, inside the claim's
|
|
10
|
+
-- write transaction. Measured on a 20k backlog: 1116us for an empty name's draw
|
|
11
|
+
-- against cairnq_tasks_claim_idx, 8.8us against this one.
|
|
12
|
+
--
|
|
13
|
+
-- Both indexes are needed. `name` sits before the ORDER BY columns here, so this
|
|
14
|
+
-- one can only be read in claim order when `name` is an equality — exactly the
|
|
15
|
+
-- per-name draws. A claim with no name filter, or a list-valued one, still reads
|
|
16
|
+
-- cairnq_tasks_claim_idx and is unaffected (measured flat at ~13-16us either way).
|
|
17
|
+
--
|
|
18
|
+
-- The equality is what makes it usable: `name in (select value from json_each(?))`
|
|
19
|
+
-- does NOT reach this index even for a single-element list — SQLite builds a
|
|
20
|
+
-- bloom filter over the subquery and falls back to cairnq_tasks_claim_idx
|
|
21
|
+
-- (measured 1446us, i.e. no improvement at all). That is why the per-name
|
|
22
|
+
-- statements exist as separate files rather than the shared one being reused.
|
|
23
|
+
create index if not exists cairnq_tasks_claim_name_idx
|
|
24
|
+
on cairnq_tasks (queue, status, name, priority desc, created_at_ms);
|
|
25
|
+
|
|
26
|
+
update cairnq_meta set value = '6' where key = 'schema_version';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- claim, for a caller drawing exactly ONE task name (Postgres dialect).
|
|
2
|
+
-- Byte-for-byte claim.sql except that the name filter is an equality on :name
|
|
3
|
+
-- instead of `= any(:names)` — a drift-guard test asserts precisely that, so
|
|
4
|
+
-- treat claim.sql as the source and re-derive this file when it changes.
|
|
5
|
+
--
|
|
6
|
+
-- It exists so the draw can reach cairnq_tasks_claim_name_idx, whose leading
|
|
7
|
+
-- columns are (queue, status, name): an array-valued name filter cannot be read
|
|
8
|
+
-- in claim order against it, so the name falls back to a residual on
|
|
9
|
+
-- cairnq_tasks_claim_idx and a draw for a name with nothing queued scans the
|
|
10
|
+
-- whole claimable backlog — inside the transaction, holding its row locks. See
|
|
11
|
+
-- migration 0006.
|
|
12
|
+
--
|
|
13
|
+
-- Used for the per-name quotas a worker draws for names that size themselves —
|
|
14
|
+
-- a `batch`, or their own concurrency. See "Batch delivery" in PROTOCOL.md.
|
|
15
|
+
-- params: queues (text[]), name, worker_id, lease_ms, limit
|
|
16
|
+
update cairnq_tasks t
|
|
17
|
+
set
|
|
18
|
+
status = 'running',
|
|
19
|
+
worker_id = :worker_id,
|
|
20
|
+
lease_until_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint + :lease_ms,
|
|
21
|
+
attempt = attempt + 1,
|
|
22
|
+
updated_at_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint
|
|
23
|
+
from (
|
|
24
|
+
select id from cairnq_tasks
|
|
25
|
+
where status = 'queued'
|
|
26
|
+
and queue = any(:queues::text[])
|
|
27
|
+
and name = :name
|
|
28
|
+
and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
|
|
29
|
+
-- id breaks created_at_ms ties (same-millisecond submits), so claim order
|
|
30
|
+
-- is deterministic: FIFO at millisecond granularity; within one millisecond
|
|
31
|
+
-- the id's random half decides, stably but not in submit order.
|
|
32
|
+
order by priority desc, created_at_ms asc, id asc
|
|
33
|
+
limit :limit
|
|
34
|
+
for update skip locked
|
|
35
|
+
) sel
|
|
36
|
+
where t.id = sel.id
|
|
37
|
+
returning t.*;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- claim, for a caller watching ONE queue and drawing ONE task name (Postgres
|
|
2
|
+
-- dialect) — the common shape for a batched worker. Byte-for-byte claim.sql
|
|
3
|
+
-- except that both the queue and the name filters are equalities; a drift-guard
|
|
4
|
+
-- test asserts precisely that, so treat claim.sql as the source and re-derive
|
|
5
|
+
-- this file when it changes.
|
|
6
|
+
--
|
|
7
|
+
-- It is the combination of claim_one_queue.sql's queue equality and
|
|
8
|
+
-- claim_one_name.sql's name equality, and each is there for the reason that file
|
|
9
|
+
-- gives. Together they pin both leading columns of cairnq_tasks_claim_name_idx,
|
|
10
|
+
-- so the draw is an index scan in claim order that stops at :limit rows however
|
|
11
|
+
-- deep the backlog is.
|
|
12
|
+
-- params: queue, name, worker_id, lease_ms, limit
|
|
13
|
+
update cairnq_tasks t
|
|
14
|
+
set
|
|
15
|
+
status = 'running',
|
|
16
|
+
worker_id = :worker_id,
|
|
17
|
+
lease_until_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint + :lease_ms,
|
|
18
|
+
attempt = attempt + 1,
|
|
19
|
+
updated_at_ms = (extract(epoch from clock_timestamp()) * 1000)::bigint
|
|
20
|
+
from (
|
|
21
|
+
select id from cairnq_tasks
|
|
22
|
+
where status = 'queued'
|
|
23
|
+
and queue = :queue
|
|
24
|
+
and name = :name
|
|
25
|
+
and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
|
|
26
|
+
-- id breaks created_at_ms ties (same-millisecond submits), so claim order
|
|
27
|
+
-- is deterministic: FIFO at millisecond granularity; within one millisecond
|
|
28
|
+
-- the id's random half decides, stably but not in submit order.
|
|
29
|
+
order by priority desc, created_at_ms asc, id asc
|
|
30
|
+
limit :limit
|
|
31
|
+
for update skip locked
|
|
32
|
+
) sel
|
|
33
|
+
where t.id = sel.id
|
|
34
|
+
returning t.*;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Extend the lease on several tasks at once (Postgres dialect) — the heartbeat
|
|
2
|
+
-- for batch delivery. Ownership-checked per row, exactly as heartbeat.sql: a task
|
|
3
|
+
-- whose lease this worker no longer holds simply does not come back, so the
|
|
4
|
+
-- caller learns which ones it lost by which ids are absent from the result rather
|
|
5
|
+
-- than by an error. One statement per beat replaces one round trip per leased
|
|
6
|
+
-- task, which for a 256-task batch is the difference between one write and 256.
|
|
7
|
+
--
|
|
8
|
+
-- Returns only what the beat needs, unlike heartbeat.sql's `returning *`: the
|
|
9
|
+
-- singular statement hands its row to the caller as `ctx.heartbeat()`'s public
|
|
10
|
+
-- return value, while this one's rows never leave the worker — they answer "still
|
|
11
|
+
-- mine?" (presence) and "cancelled?" (the one column). Whole rows here would pull
|
|
12
|
+
-- every payload back on every beat: 256 tasks * 4KB is a megabyte re-read every
|
|
13
|
+
-- lease/3 for the life of the call, and a JSON parse per row to discard it.
|
|
14
|
+
--
|
|
15
|
+
-- New lease (now + :lease_ms) and time come from the DB clock.
|
|
16
|
+
-- params: ids (text[]), worker_id, lease_ms
|
|
17
|
+
update cairnq_tasks
|
|
18
|
+
set lease_until_ms = (extract(epoch from now()) * 1000)::bigint + :lease_ms,
|
|
19
|
+
updated_at_ms = (extract(epoch from now()) * 1000)::bigint
|
|
20
|
+
where id = any(:ids::text[])
|
|
21
|
+
and status = 'running'
|
|
22
|
+
and worker_id = :worker_id
|
|
23
|
+
and lease_until_ms > (extract(epoch from now()) * 1000)::bigint
|
|
24
|
+
returning id, cancel_requested_at_ms;
|
|
@@ -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,36 @@
|
|
|
1
|
+
-- claim, for a caller drawing exactly ONE task name. Byte-for-byte claim.sql
|
|
2
|
+
-- except that the name filter is an equality on :name instead of an IN over
|
|
3
|
+
-- :names — a drift-guard test asserts precisely that, so treat claim.sql as the
|
|
4
|
+
-- source and re-derive this file when it changes.
|
|
5
|
+
--
|
|
6
|
+
-- It exists because `name in (select value from json_each(:names))` cannot reach
|
|
7
|
+
-- cairnq_tasks_claim_name_idx, even for a single-element list: SQLite builds a
|
|
8
|
+
-- bloom filter over the subquery and falls back to cairnq_tasks_claim_idx, where
|
|
9
|
+
-- the name is a residual and a draw for a name with nothing queued walks the
|
|
10
|
+
-- whole claimable backlog. Measured on a 20k backlog: 1446us for the json_each
|
|
11
|
+
-- form, 8.8us for this one. See migration 0006.
|
|
12
|
+
--
|
|
13
|
+
-- Used for the per-name quotas a worker draws for names that size themselves —
|
|
14
|
+
-- a `batch`, or their own concurrency. See "Batch delivery" in PROTOCOL.md.
|
|
15
|
+
-- params: queues (JSON array text), name, now_ms, worker_id, lease_until_ms,
|
|
16
|
+
-- limit
|
|
17
|
+
update cairnq_tasks
|
|
18
|
+
set
|
|
19
|
+
status = 'running',
|
|
20
|
+
worker_id = :worker_id,
|
|
21
|
+
lease_until_ms = :lease_until_ms,
|
|
22
|
+
attempt = attempt + 1,
|
|
23
|
+
updated_at_ms = :now_ms
|
|
24
|
+
where id in (
|
|
25
|
+
select id from cairnq_tasks
|
|
26
|
+
where status = 'queued'
|
|
27
|
+
and queue in (select value from json_each(:queues))
|
|
28
|
+
and name = :name
|
|
29
|
+
and run_at_ms <= :now_ms
|
|
30
|
+
-- id breaks created_at_ms ties (same-millisecond submits), so claim order
|
|
31
|
+
-- is deterministic: FIFO at millisecond granularity; within one millisecond
|
|
32
|
+
-- the id's random half decides, stably but not in submit order.
|
|
33
|
+
order by priority desc, created_at_ms asc, id asc
|
|
34
|
+
limit :limit
|
|
35
|
+
)
|
|
36
|
+
returning *;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- claim, for a caller watching ONE queue and drawing ONE task name — the common
|
|
2
|
+
-- shape for a batched worker. Byte-for-byte claim.sql except that both the queue
|
|
3
|
+
-- and the name filters are equalities; a drift-guard test asserts precisely that,
|
|
4
|
+
-- so treat claim.sql as the source and re-derive this file when it changes.
|
|
5
|
+
--
|
|
6
|
+
-- It is the combination of claim_one_queue.sql's queue equality and
|
|
7
|
+
-- claim_one_name.sql's name equality, and each is there for the reason that file
|
|
8
|
+
-- gives. Together they let cairnq_tasks_claim_name_idx be read in claim order
|
|
9
|
+
-- with both leading columns pinned, so the draw is a seek that terminates at
|
|
10
|
+
-- :limit rows however deep the backlog is.
|
|
11
|
+
-- params: queue, name, now_ms, worker_id, lease_until_ms, limit
|
|
12
|
+
update cairnq_tasks
|
|
13
|
+
set
|
|
14
|
+
status = 'running',
|
|
15
|
+
worker_id = :worker_id,
|
|
16
|
+
lease_until_ms = :lease_until_ms,
|
|
17
|
+
attempt = attempt + 1,
|
|
18
|
+
updated_at_ms = :now_ms
|
|
19
|
+
where id in (
|
|
20
|
+
select id from cairnq_tasks
|
|
21
|
+
where status = 'queued'
|
|
22
|
+
and queue = :queue
|
|
23
|
+
and name = :name
|
|
24
|
+
and run_at_ms <= :now_ms
|
|
25
|
+
-- id breaks created_at_ms ties (same-millisecond submits), so claim order
|
|
26
|
+
-- is deterministic: FIFO at millisecond granularity; within one millisecond
|
|
27
|
+
-- the id's random half decides, stably but not in submit order.
|
|
28
|
+
order by priority desc, created_at_ms asc, id asc
|
|
29
|
+
limit :limit
|
|
30
|
+
)
|
|
31
|
+
returning *;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- Extend the lease on several tasks at once — the heartbeat for batch delivery.
|
|
2
|
+
-- Ownership-checked per row, exactly as heartbeat.sql: a task whose lease this
|
|
3
|
+
-- worker no longer holds simply does not come back, so the caller learns which
|
|
4
|
+
-- ones it lost by which ids are absent from the result rather than by an error.
|
|
5
|
+
-- One statement per beat is the point: a batch handler holding 256 leases would
|
|
6
|
+
-- otherwise write 256 rows every heartbeat interval, which on SQLite means 256
|
|
7
|
+
-- turns of the single write lock for work nobody is waiting on.
|
|
8
|
+
--
|
|
9
|
+
-- Returns only what the beat needs, unlike heartbeat.sql's `returning *`: the
|
|
10
|
+
-- singular statement hands its row to the caller as `ctx.heartbeat()`'s public
|
|
11
|
+
-- return value, while this one's rows never leave the worker — they answer "still
|
|
12
|
+
-- mine?" (presence) and "cancelled?" (the one column). Whole rows here would pull
|
|
13
|
+
-- every payload back on every beat: 256 tasks * 4KB is a megabyte re-read every
|
|
14
|
+
-- lease/3 for the life of the call, and a JSON parse per row to discard it.
|
|
15
|
+
-- Unlike heartbeat.sql, this one's plan depends on statistics: json_each() hides
|
|
16
|
+
-- the id list's length, so without sqlite_stat1 the planner drives the update off
|
|
17
|
+
-- cairnq_tasks_status_idx and walks every 'running' row in the database per beat
|
|
18
|
+
-- instead of doing primary-key lookups. Both SDKs ANALYZE on open and revisit
|
|
19
|
+
-- once a minute per connection (see "Planner statistics" in PROTOCOL.md), so this
|
|
20
|
+
-- is a warm-up window rather than a standing cost — but the statement it replaced
|
|
21
|
+
-- had no such dependency, which is why it is called out here.
|
|
22
|
+
-- params: ids (JSON array text), worker_id, now_ms, lease_until_ms
|
|
23
|
+
update cairnq_tasks
|
|
24
|
+
set lease_until_ms = :lease_until_ms, updated_at_ms = :now_ms
|
|
25
|
+
where id in (select value from json_each(:ids))
|
|
26
|
+
and status = 'running'
|
|
27
|
+
and worker_id = :worker_id
|
|
28
|
+
and lease_until_ms > :now_ms
|
|
29
|
+
returning id, cancel_requested_at_ms;
|
|
@@ -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,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry backoff, in its own module because two callers need it.
|
|
3
|
+
*
|
|
4
|
+
* The worker computes it when a handler's failure ends an attempt; TaskContext
|
|
5
|
+
* computes it when a handler fails one task of a batch itself. Keeping it in
|
|
6
|
+
* worker.ts would make context.ts import the module that imports it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_RETRY_BACKOFF_MS = 1000;
|
|
9
|
+
export declare const DEFAULT_RETRY_BACKOFF_MAX_MS = 30000;
|
|
10
|
+
/**
|
|
11
|
+
* Exponential backoff with equal jitter: the window doubles per attempt up to
|
|
12
|
+
* `maxMs`, and the delay lands uniformly in its upper half, `[w/2, w)`.
|
|
13
|
+
*
|
|
14
|
+
* The jitter is what keeps a fleet from retrying in lockstep. Failures align
|
|
15
|
+
* when the downstream fails fast enough that a whole concurrency batch raises
|
|
16
|
+
* at once (connection refused, DNS gone), and capped exponential backoff then
|
|
17
|
+
* *preserves* that alignment — once every task sits at `maxMs`, they all retry
|
|
18
|
+
* on the same beat forever. Spreading over half the window breaks it; keeping
|
|
19
|
+
* the lower half as a floor means jitter never shortens the wait to less than
|
|
20
|
+
* half of what plain exponential backoff would have asked for.
|
|
21
|
+
*
|
|
22
|
+
* `rand` is injected so tests can pin an exact delay.
|
|
23
|
+
*/
|
|
24
|
+
export declare function retryDelayMs(attempt: number, baseMs: number, maxMs: number, rand?: () => number): number;
|
|
25
|
+
/**
|
|
26
|
+
* The delay a `fail` write should carry. Not just the backoff: a permanent
|
|
27
|
+
* failure is never re-run, so it always delays 0. Both settlement paths — the
|
|
28
|
+
* worker's and a handler's `ctx.fail` — go through this, so they cannot end up
|
|
29
|
+
* backing off differently.
|
|
30
|
+
*/
|
|
31
|
+
export declare function failDelayMs(attempt: number, retryable: boolean, baseMs: number, maxMs: number, rand?: () => number): number;
|
package/dist/backoff.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry backoff, in its own module because two callers need it.
|
|
3
|
+
*
|
|
4
|
+
* The worker computes it when a handler's failure ends an attempt; TaskContext
|
|
5
|
+
* computes it when a handler fails one task of a batch itself. Keeping it in
|
|
6
|
+
* worker.ts would make context.ts import the module that imports it.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_RETRY_BACKOFF_MS = 1_000;
|
|
9
|
+
export const DEFAULT_RETRY_BACKOFF_MAX_MS = 30_000;
|
|
10
|
+
/**
|
|
11
|
+
* Exponential backoff with equal jitter: the window doubles per attempt up to
|
|
12
|
+
* `maxMs`, and the delay lands uniformly in its upper half, `[w/2, w)`.
|
|
13
|
+
*
|
|
14
|
+
* The jitter is what keeps a fleet from retrying in lockstep. Failures align
|
|
15
|
+
* when the downstream fails fast enough that a whole concurrency batch raises
|
|
16
|
+
* at once (connection refused, DNS gone), and capped exponential backoff then
|
|
17
|
+
* *preserves* that alignment — once every task sits at `maxMs`, they all retry
|
|
18
|
+
* on the same beat forever. Spreading over half the window breaks it; keeping
|
|
19
|
+
* the lower half as a floor means jitter never shortens the wait to less than
|
|
20
|
+
* half of what plain exponential backoff would have asked for.
|
|
21
|
+
*
|
|
22
|
+
* `rand` is injected so tests can pin an exact delay.
|
|
23
|
+
*/
|
|
24
|
+
export function retryDelayMs(attempt, baseMs, maxMs, rand = Math.random) {
|
|
25
|
+
if (baseMs <= 0)
|
|
26
|
+
return 0;
|
|
27
|
+
const exponent = Math.max(0, attempt - 1);
|
|
28
|
+
const window = Math.min(maxMs, baseMs * 2 ** exponent);
|
|
29
|
+
const floor = Math.floor(window / 2);
|
|
30
|
+
return floor + Math.floor(rand() * (window - floor));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The delay a `fail` write should carry. Not just the backoff: a permanent
|
|
34
|
+
* failure is never re-run, so it always delays 0. Both settlement paths — the
|
|
35
|
+
* worker's and a handler's `ctx.fail` — go through this, so they cannot end up
|
|
36
|
+
* backing off differently.
|
|
37
|
+
*/
|
|
38
|
+
export function failDelayMs(attempt, retryable, baseMs, maxMs, rand = Math.random) {
|
|
39
|
+
return retryable ? retryDelayMs(attempt, baseMs, maxMs, rand) : 0;
|
|
40
|
+
}
|
|
@@ -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
|
}
|