cairnq 0.11.0 → 0.13.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/migrations/postgres/0009_purge_queue_index.sql +39 -0
- package/dist/_protocol/migrations/postgres/0010_drop_status_index.sql +29 -0
- package/dist/_protocol/migrations/sqlite/0009_purge_queue_index.sql +39 -0
- package/dist/_protocol/migrations/sqlite/0010_drop_status_index.sql +29 -0
- package/dist/_protocol/sql/postgres/claimable_probe.sql +47 -0
- package/dist/_protocol/sql/postgres/purge.sql +26 -6
- package/dist/_protocol/sql/postgres/stats.sql +17 -2
- package/dist/_protocol/sql/sqlite/purge.sql +26 -5
- package/dist/_protocol/sql/sqlite/stats.sql +17 -2
- package/dist/client.d.ts +12 -3
- package/dist/client.js +13 -4
- package/dist/index.d.ts +1 -1
- package/dist/retention.d.ts +36 -6
- package/dist/retention.js +22 -13
- package/dist/store/base.d.ts +54 -5
- package/dist/store/base.js +84 -8
- package/dist/store/pg-executor.d.ts +30 -0
- package/dist/store/pg-executor.js +30 -0
- package/dist/store/postgres.d.ts +4 -2
- package/dist/store/postgres.js +7 -5
- package/dist/store/sqlite.d.ts +7 -1
- package/dist/store/sqlite.js +21 -13
- package/package.json +1 -1
- package/src/client.ts +13 -4
- package/src/index.ts +1 -1
- package/src/retention.ts +57 -20
- package/src/store/base.ts +94 -10
- package/src/store/pg-executor.ts +30 -0
- package/src/store/postgres.ts +7 -4
- package/src/store/sqlite.ts +21 -12
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
-- Give the retention sweep a queue dimension it can actually read.
|
|
2
|
+
--
|
|
3
|
+
-- purge gained an optional `:queue` filter, and without an index for it queue is
|
|
4
|
+
-- a residual on cairnq_tasks_completed_idx (completed_at_ms) or
|
|
5
|
+
-- cairnq_tasks_status_completed_idx (status, completed_at_ms): the scan walks in
|
|
6
|
+
-- completion order and throws away every row belonging to another queue. `limit`
|
|
7
|
+
-- bounds what comes back, not what is read — and the shape that makes the filter
|
|
8
|
+
-- worth having in the first place is exactly the worst case for it. One store
|
|
9
|
+
-- carrying an RPC queue kept for minutes and a durable queue kept for a week is
|
|
10
|
+
-- the cross-language coordination this project recommends; sweeping the RPC
|
|
11
|
+
-- queue then means walking the week's worth of older rows the other queue is
|
|
12
|
+
-- deliberately holding onto, over and over, once per batch.
|
|
13
|
+
--
|
|
14
|
+
-- Partial, on the terminal statuses, for two reasons. It is the smaller half of
|
|
15
|
+
-- the table — a busy queue's live rows never enter it — and, more to the point,
|
|
16
|
+
-- rows enter it only when a task settles, so the index is not a tax on the claim
|
|
17
|
+
-- path the way a full index on (queue, ...) would be. purge.sql always carries
|
|
18
|
+
-- the literal `status in ('succeeded','failed','canceled')`, whether or not the
|
|
19
|
+
-- caller narrowed to one status, so the predicate matches exactly and the
|
|
20
|
+
-- planner never has to prove anything subtler to use it.
|
|
21
|
+
--
|
|
22
|
+
-- Measured on SQLite 3.39.4, 20k rows over two queues and four statuses, with
|
|
23
|
+
-- 0002's and 0007's indexes present alongside it: every filter combination purge
|
|
24
|
+
-- can issue is served by one index scan with the ORDER BY satisfied from the
|
|
25
|
+
-- index (no temp b-tree), and the two pre-existing shapes — unfiltered, and
|
|
26
|
+
-- status-only — still choose their old indexes, so nothing that worked before
|
|
27
|
+
-- got slower. Postgres is reasoned from the same shape rather than benchmarked;
|
|
28
|
+
-- see 0008 for why that caveat keeps appearing.
|
|
29
|
+
--
|
|
30
|
+
-- Unlike 0008 this only CREATES an index, so an older SDK is unaffected: it
|
|
31
|
+
-- never passes `:queue`, its statements are unchanged, and it pays only the
|
|
32
|
+
-- write-side cost of an index it does not read. The build still holds a lock for
|
|
33
|
+
-- as long as it takes (no CONCURRENTLY — the ledger's check-and-apply is one
|
|
34
|
+
-- transaction), but over terminal rows only, which is the smaller set.
|
|
35
|
+
create index if not exists cairnq_tasks_queue_completed_idx
|
|
36
|
+
on cairnq_tasks (queue, completed_at_ms)
|
|
37
|
+
where status in ('succeeded', 'failed', 'canceled');
|
|
38
|
+
|
|
39
|
+
update cairnq_meta set value = '9' where key = 'schema_version';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- Drop cairnq_tasks_status_idx, which nothing can use.
|
|
2
|
+
--
|
|
3
|
+
-- 0001 shipped it for list's status filter. 0007 later added
|
|
4
|
+
-- (status, completed_at_ms) for purge, and a one-column index on the same
|
|
5
|
+
-- leading column is strictly contained in that: any lookup the narrow one could
|
|
6
|
+
-- serve, the wider one serves from its prefix. Confirmed on the statements as
|
|
7
|
+
-- they run — with the status filter specialized to an equality, SQLite picks
|
|
8
|
+
-- cairnq_tasks_status_completed_idx for list and for purge, and never the
|
|
9
|
+
-- narrow index, on a 20k-row table with statistics.
|
|
10
|
+
--
|
|
11
|
+
-- Until specialization existed neither was reachable at all, so this was
|
|
12
|
+
-- invisible: both indexes looked equally unused, and dropping either looked
|
|
13
|
+
-- equally safe or unsafe. It is only once the filters reach an index that one
|
|
14
|
+
-- of them is demonstrably the one reached.
|
|
15
|
+
--
|
|
16
|
+
-- The write cost is what makes it worth removing rather than leaving: every
|
|
17
|
+
-- insert and every status transition — submit, claim, each settle, each retry —
|
|
18
|
+
-- maintains it, which is the hottest write path there is, for a structure no
|
|
19
|
+
-- read consults. The other three of 0001's filter indexes (name, root_id,
|
|
20
|
+
-- correlation_id) stay: each is the only index that serves its filter, and each
|
|
21
|
+
-- is demonstrably read now.
|
|
22
|
+
--
|
|
23
|
+
-- Dropping an index needs no CONCURRENTLY on either dialect and holds a lock
|
|
24
|
+
-- only long enough to unlink it, so unlike 0008 this is not an upgrade window
|
|
25
|
+
-- to plan around. An older SDK is unaffected: it never named the index, and the
|
|
26
|
+
-- statement that used to want it could not reach it anyway.
|
|
27
|
+
drop index if exists cairnq_tasks_status_idx;
|
|
28
|
+
|
|
29
|
+
update cairnq_meta set value = '10' where key = 'schema_version';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
-- Give the retention sweep a queue dimension it can actually read.
|
|
2
|
+
--
|
|
3
|
+
-- purge gained an optional `:queue` filter, and without an index for it queue is
|
|
4
|
+
-- a residual on cairnq_tasks_completed_idx (completed_at_ms) or
|
|
5
|
+
-- cairnq_tasks_status_completed_idx (status, completed_at_ms): the scan walks in
|
|
6
|
+
-- completion order and throws away every row belonging to another queue. `limit`
|
|
7
|
+
-- bounds what comes back, not what is read — and the shape that makes the filter
|
|
8
|
+
-- worth having in the first place is exactly the worst case for it. One store
|
|
9
|
+
-- carrying an RPC queue kept for minutes and a durable queue kept for a week is
|
|
10
|
+
-- the cross-language coordination this project recommends; sweeping the RPC
|
|
11
|
+
-- queue then means walking the week's worth of older rows the other queue is
|
|
12
|
+
-- deliberately holding onto, over and over, once per batch.
|
|
13
|
+
--
|
|
14
|
+
-- Partial, on the terminal statuses, for two reasons. It is the smaller half of
|
|
15
|
+
-- the table — a busy queue's live rows never enter it — and, more to the point,
|
|
16
|
+
-- rows enter it only when a task settles, so the index is not a tax on the claim
|
|
17
|
+
-- path the way a full index on (queue, ...) would be. purge.sql always carries
|
|
18
|
+
-- the literal `status in ('succeeded','failed','canceled')`, whether or not the
|
|
19
|
+
-- caller narrowed to one status, so the predicate matches exactly and the
|
|
20
|
+
-- planner never has to prove anything subtler to use it.
|
|
21
|
+
--
|
|
22
|
+
-- Measured on SQLite 3.39.4, 20k rows over two queues and four statuses, with
|
|
23
|
+
-- 0002's and 0007's indexes present alongside it: every filter combination purge
|
|
24
|
+
-- can issue is served by one index scan with the ORDER BY satisfied from the
|
|
25
|
+
-- index (no temp b-tree), and the two pre-existing shapes — unfiltered, and
|
|
26
|
+
-- status-only — still choose their old indexes, so nothing that worked before
|
|
27
|
+
-- got slower. Postgres is reasoned from the same shape rather than benchmarked;
|
|
28
|
+
-- see 0008 for why that caveat keeps appearing.
|
|
29
|
+
--
|
|
30
|
+
-- Unlike 0008 this only CREATES an index, so an older SDK is unaffected: it
|
|
31
|
+
-- never passes `:queue`, its statements are unchanged, and it pays only the
|
|
32
|
+
-- write-side cost of an index it does not read. The build still holds a lock for
|
|
33
|
+
-- as long as it takes (no CONCURRENTLY — the ledger's check-and-apply is one
|
|
34
|
+
-- transaction), but over terminal rows only, which is the smaller set.
|
|
35
|
+
create index if not exists cairnq_tasks_queue_completed_idx
|
|
36
|
+
on cairnq_tasks (queue, completed_at_ms)
|
|
37
|
+
where status in ('succeeded', 'failed', 'canceled');
|
|
38
|
+
|
|
39
|
+
update cairnq_meta set value = '9' where key = 'schema_version';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- Drop cairnq_tasks_status_idx, which nothing can use.
|
|
2
|
+
--
|
|
3
|
+
-- 0001 shipped it for list's status filter. 0007 later added
|
|
4
|
+
-- (status, completed_at_ms) for purge, and a one-column index on the same
|
|
5
|
+
-- leading column is strictly contained in that: any lookup the narrow one could
|
|
6
|
+
-- serve, the wider one serves from its prefix. Confirmed on the statements as
|
|
7
|
+
-- they run — with the status filter specialized to an equality, SQLite picks
|
|
8
|
+
-- cairnq_tasks_status_completed_idx for list and for purge, and never the
|
|
9
|
+
-- narrow index, on a 20k-row table with statistics.
|
|
10
|
+
--
|
|
11
|
+
-- Until specialization existed neither was reachable at all, so this was
|
|
12
|
+
-- invisible: both indexes looked equally unused, and dropping either looked
|
|
13
|
+
-- equally safe or unsafe. It is only once the filters reach an index that one
|
|
14
|
+
-- of them is demonstrably the one reached.
|
|
15
|
+
--
|
|
16
|
+
-- The write cost is what makes it worth removing rather than leaving: every
|
|
17
|
+
-- insert and every status transition — submit, claim, each settle, each retry —
|
|
18
|
+
-- maintains it, which is the hottest write path there is, for a structure no
|
|
19
|
+
-- read consults. The other three of 0001's filter indexes (name, root_id,
|
|
20
|
+
-- correlation_id) stay: each is the only index that serves its filter, and each
|
|
21
|
+
-- is demonstrably read now.
|
|
22
|
+
--
|
|
23
|
+
-- Dropping an index needs no CONCURRENTLY on either dialect and holds a lock
|
|
24
|
+
-- only long enough to unlink it, so unlike 0008 this is not an upgrade window
|
|
25
|
+
-- to plan around. An older SDK is unaffected: it never named the index, and the
|
|
26
|
+
-- statement that used to want it could not reach it anyway.
|
|
27
|
+
drop index if exists cairnq_tasks_status_idx;
|
|
28
|
+
|
|
29
|
+
update cairnq_meta set value = '10' where key = 'schema_version';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- Read-only check: is there anything worth opening the claim transaction for?
|
|
2
|
+
-- Run before claim so an idle worker's poll costs one statement instead of a
|
|
3
|
+
-- transaction. Mirrors claim.sql's filters, so the probe never promises work
|
|
4
|
+
-- claim will skip. The expired-lease arm stays unfiltered on purpose: recovering
|
|
5
|
+
-- a dead worker's task is every worker's job, whatever names it happens to
|
|
6
|
+
-- handle.
|
|
7
|
+
--
|
|
8
|
+
-- The SQLite twin exists for a reason that does not apply here — keeping idle
|
|
9
|
+
-- workers off the single write lock — and this dialect went without one on that
|
|
10
|
+
-- basis. What survives the difference is the rest of the poll: without a probe
|
|
11
|
+
-- every empty poll still opens a transaction, runs recover_leases, and then runs
|
|
12
|
+
-- one claim statement per self-limiting name. A worker declaring a dozen such
|
|
13
|
+
-- names pays a dozen statements to learn there is nothing to do, and on this
|
|
14
|
+
-- dialect the empty poll is the COMMON case precisely because LISTEN wakes the
|
|
15
|
+
-- worker on the rare one.
|
|
16
|
+
--
|
|
17
|
+
-- Two separate EXISTS, not one select with an OR: an OR across two different
|
|
18
|
+
-- index shapes gets no index at all (see migration 0008's note on the SQLite
|
|
19
|
+
-- twin), while each EXISTS here chooses its own — cairnq_tasks_claim_idx for the
|
|
20
|
+
-- queued arm, cairnq_tasks_lease_idx (0004, partial on running rows) for the
|
|
21
|
+
-- lease arm — and stops at the first row it finds.
|
|
22
|
+
--
|
|
23
|
+
-- Time is clock_timestamp() wrapped in a scalar subselect, for the reason spelled
|
|
24
|
+
-- out at length in recover_leases.sql: inlined, a VOLATILE function becomes a
|
|
25
|
+
-- per-row filter and the scan degrades to reading every candidate row. Wrapped,
|
|
26
|
+
-- it is an InitPlan evaluated once and usable as an index bound.
|
|
27
|
+
--
|
|
28
|
+
-- What this does NOT make free: the queued arm still walks the (queue, status)
|
|
29
|
+
-- range looking for a due row when every row in it is backing off, the same cost
|
|
30
|
+
-- an empty claim draw pays. The saving is the transaction and the other N-1
|
|
31
|
+
-- statements, not the range scan.
|
|
32
|
+
-- params: queues (text[]), names (text[] or null)
|
|
33
|
+
select (
|
|
34
|
+
exists (
|
|
35
|
+
select 1 from cairnq_tasks
|
|
36
|
+
where status = 'queued'
|
|
37
|
+
and queue = any(:queues::text[])
|
|
38
|
+
and (:names::text[] is null or name = any(:names::text[]))
|
|
39
|
+
and run_at_ms <= (select (extract(epoch from clock_timestamp()) * 1000)::bigint)
|
|
40
|
+
)
|
|
41
|
+
or exists (
|
|
42
|
+
select 1 from cairnq_tasks
|
|
43
|
+
where status = 'running'
|
|
44
|
+
and lease_until_ms is not null
|
|
45
|
+
and lease_until_ms <= (select (extract(epoch from clock_timestamp()) * 1000)::bigint)
|
|
46
|
+
)
|
|
47
|
+
) as has_work;
|
|
@@ -11,16 +11,36 @@
|
|
|
11
11
|
-- live task. Locking the rows in the subselect freezes them terminal until the
|
|
12
12
|
-- delete commits; a concurrent retry then re-evaluates against the deleted row
|
|
13
13
|
-- and correctly finds nothing.
|
|
14
|
-
-- The status/name filters are optional (pass NULL to skip; `::text` pins
|
|
15
|
-
-- param's type, as in list.sql): retention needs are tiered — a succeeded row
|
|
16
|
-
--
|
|
17
|
-
--
|
|
18
|
-
--
|
|
19
|
-
--
|
|
14
|
+
-- The queue/status/name filters are optional (pass NULL to skip; `::text` pins
|
|
15
|
+
-- the param's type, as in list.sql): retention needs are tiered — a succeeded row is spent once its result is
|
|
16
|
+
-- consumed, while a failed one is worth keeping for diagnosis — and without them
|
|
17
|
+
-- the shortest-lived tier sets the retention for every row. `queue` is the same
|
|
18
|
+
-- argument one level up: a single installation is how this project recommends
|
|
19
|
+
-- two languages coordinate, so it routinely carries two workloads whose rows
|
|
20
|
+
-- have nothing to do with each other's lifetimes — an RPC result read once and a
|
|
21
|
+
-- durable job's log kept for a week. Migration 0009 adds the index that makes
|
|
22
|
+
-- the queue filter read only its own queue's rows rather than skipping past
|
|
23
|
+
-- every other queue's.
|
|
24
|
+
-- Three specializations exist for the two filters that DO have indexes —
|
|
25
|
+
-- purge_one_queue.sql, purge_one_status.sql, purge_one_queue_one_status.sql —
|
|
26
|
+
-- and the SDK picks one per call. This file's optional form is the one it uses
|
|
27
|
+
-- when neither filter is set, because `(:p is null or col = :p)` is planned
|
|
28
|
+
-- before the parameter has a value: SQLite must plan both branches, reaches no
|
|
29
|
+
-- index at all, and walks every row past the cutoff in completion order.
|
|
30
|
+
--
|
|
31
|
+
-- Both dialects ship every variant. Postgres does not need them — it re-plans
|
|
32
|
+
-- with the parameter values for a statement's first executions and folds the
|
|
33
|
+
-- null branch away — but a caller that had to know which dialect indexes which
|
|
34
|
+
-- form would be a worse contract than four extra files.
|
|
35
|
+
--
|
|
36
|
+
-- :name has no specialization: no index covers it, so it is a residual predicate
|
|
37
|
+
-- either way and an equality form would buy nothing.
|
|
38
|
+
-- params: older_than_ms, queue, status, name, limit
|
|
20
39
|
delete from cairnq_tasks
|
|
21
40
|
where id in (
|
|
22
41
|
select id from cairnq_tasks
|
|
23
42
|
where status in ('succeeded', 'failed', 'canceled')
|
|
43
|
+
and (:queue::text is null or queue = :queue)
|
|
24
44
|
and (:status::text is null or status = :status)
|
|
25
45
|
and (:name::text is null or name = :name)
|
|
26
46
|
and completed_at_ms is not null
|
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
--
|
|
1
|
+
-- Task counts grouped by queue and status. Read-only.
|
|
2
2
|
-- A queue appears only while it has rows — terminal tasks count until purge
|
|
3
3
|
-- removes them. The SDK zero-fills the statuses a queue has no rows in.
|
|
4
|
-
--
|
|
4
|
+
--
|
|
5
|
+
-- :queue is optional (pass NULL for every queue; `::text` pins the param's type,
|
|
6
|
+
-- as in list.sql). Unfiltered, this reads every row in the table, so its cost
|
|
7
|
+
-- grows with everything the installation has ever run — and one store carrying
|
|
8
|
+
-- two workloads is the coordination cairnq recommends, so a caller asking about
|
|
9
|
+
-- its own queue should not pay for the other's backlog. Filtered to one queue it
|
|
10
|
+
-- can be served from cairnq_tasks_claim_idx's (queue, status) prefix instead.
|
|
11
|
+
--
|
|
12
|
+
-- Filtered or not, this still COUNTS: the cost is proportional to the rows being
|
|
13
|
+
-- counted, which is the whole queue, terminal rows included. That is fine for a
|
|
14
|
+
-- dashboard and wrong for a poll loop — queue_depth.sql is the bounded question,
|
|
15
|
+
-- and the one to ask on an interval.
|
|
16
|
+
-- stats_one_queue.sql is the equality form the SDK uses when a queue is
|
|
17
|
+
-- named; see purge.sql for why the optional filter here cannot be indexed.
|
|
18
|
+
-- params: queue
|
|
5
19
|
select queue, status, count(*) as count
|
|
6
20
|
from cairnq_tasks
|
|
21
|
+
where (:queue::text is null or queue = :queue)
|
|
7
22
|
group by queue, status
|
|
8
23
|
order by queue asc, status asc;
|
|
@@ -5,15 +5,36 @@
|
|
|
5
5
|
-- task goes with it via cairnq_task_keys' ON DELETE CASCADE.
|
|
6
6
|
-- The LIMIT lives in a subquery: plain `delete ... limit` needs a non-default
|
|
7
7
|
-- SQLite build option.
|
|
8
|
-
-- The status/name filters are optional (pass NULL to skip, as in
|
|
9
|
-
-- retention needs are tiered — a succeeded row is spent once its
|
|
10
|
-
-- consumed, while a failed one is worth keeping for diagnosis — and
|
|
11
|
-
-- them the shortest-lived tier sets the retention for every row.
|
|
12
|
-
--
|
|
8
|
+
-- The queue/status/name filters are optional (pass NULL to skip, as in
|
|
9
|
+
-- list.sql): retention needs are tiered — a succeeded row is spent once its
|
|
10
|
+
-- result is consumed, while a failed one is worth keeping for diagnosis — and
|
|
11
|
+
-- without them the shortest-lived tier sets the retention for every row.
|
|
12
|
+
-- `queue` is the same argument one level up: a single installation is how this
|
|
13
|
+
-- project recommends two languages coordinate, so it routinely carries two
|
|
14
|
+
-- workloads whose rows have nothing to do with each other's lifetimes — an RPC
|
|
15
|
+
-- result read once and a durable job's log kept for a week. Migration 0009 adds
|
|
16
|
+
-- the index that makes the queue filter read only its own queue's rows rather
|
|
17
|
+
-- than skipping past every other queue's.
|
|
18
|
+
-- Three specializations exist for the two filters that DO have indexes —
|
|
19
|
+
-- purge_one_queue.sql, purge_one_status.sql, purge_one_queue_one_status.sql —
|
|
20
|
+
-- and the SDK picks one per call. This file's optional form is the one it uses
|
|
21
|
+
-- when neither filter is set, because `(:p is null or col = :p)` is planned
|
|
22
|
+
-- before the parameter has a value: SQLite must plan both branches, reaches no
|
|
23
|
+
-- index at all, and walks every row past the cutoff in completion order.
|
|
24
|
+
--
|
|
25
|
+
-- Both dialects ship every variant. Postgres does not need them — it re-plans
|
|
26
|
+
-- with the parameter values for a statement's first executions and folds the
|
|
27
|
+
-- null branch away — but a caller that had to know which dialect indexes which
|
|
28
|
+
-- form would be a worse contract than four extra files.
|
|
29
|
+
--
|
|
30
|
+
-- :name has no specialization: no index covers it, so it is a residual predicate
|
|
31
|
+
-- either way and an equality form would buy nothing.
|
|
32
|
+
-- params: before_ms, queue, status, name, limit
|
|
13
33
|
delete from cairnq_tasks
|
|
14
34
|
where id in (
|
|
15
35
|
select id from cairnq_tasks
|
|
16
36
|
where status in ('succeeded', 'failed', 'canceled')
|
|
37
|
+
and (:queue is null or queue = :queue)
|
|
17
38
|
and (:status is null or status = :status)
|
|
18
39
|
and (:name is null or name = :name)
|
|
19
40
|
and completed_at_ms is not null
|
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
--
|
|
1
|
+
-- Task counts grouped by queue and status. Read-only.
|
|
2
2
|
-- A queue appears only while it has rows — terminal tasks count until purge
|
|
3
3
|
-- removes them. The SDK zero-fills the statuses a queue has no rows in.
|
|
4
|
-
--
|
|
4
|
+
--
|
|
5
|
+
-- :queue is optional (pass NULL for every queue). Unfiltered, this reads every
|
|
6
|
+
-- row in the table, so its cost grows with everything the installation has ever
|
|
7
|
+
-- run — and one store carrying two workloads is the coordination cairnq
|
|
8
|
+
-- recommends, so a caller asking about its own queue should not pay for the
|
|
9
|
+
-- other's backlog. Filtered to one queue it can be served from
|
|
10
|
+
-- cairnq_tasks_claim_idx's (queue, status) prefix instead.
|
|
11
|
+
--
|
|
12
|
+
-- Filtered or not, this still COUNTS: the cost is proportional to the rows being
|
|
13
|
+
-- counted, which is the whole queue, terminal rows included. That is fine for a
|
|
14
|
+
-- dashboard and wrong for a poll loop — queue_depth.sql is the bounded question,
|
|
15
|
+
-- and the one to ask on an interval.
|
|
16
|
+
-- stats_one_queue.sql is the equality form the SDK uses when a queue is
|
|
17
|
+
-- named; see purge.sql for why the optional filter here cannot be indexed.
|
|
18
|
+
-- params: queue
|
|
5
19
|
select queue, status, count(*) as count
|
|
6
20
|
from cairnq_tasks
|
|
21
|
+
where (:queue is null or queue = :queue)
|
|
7
22
|
group by queue, status
|
|
8
23
|
order by queue asc, status asc;
|
package/dist/client.d.ts
CHANGED
|
@@ -72,11 +72,20 @@ export declare class CairnQ {
|
|
|
72
72
|
/** Delete terminal tasks that finished more than `olderThanMs` ago and return
|
|
73
73
|
* their ids. Nothing else in CairnQ removes rows, so a long-lived database
|
|
74
74
|
* needs this on a schedule. Each call is bounded by `limit` to keep the write
|
|
75
|
-
* short; loop until it returns fewer than `limit`.
|
|
75
|
+
* short; loop until it returns fewer than `limit`.
|
|
76
|
+
*
|
|
77
|
+
* `queue` / `status` / `name` narrow the sweep — one installation carrying two
|
|
78
|
+
* workloads needs a retention per workload, not one for the whole database. */
|
|
76
79
|
purge(input?: PurgeInput): Promise<string[]>;
|
|
77
80
|
/** Task counts per queue, keyed by status and zero-filled across all statuses
|
|
78
|
-
* — `(await stats()).default.queued` is the backlog of a queue.
|
|
79
|
-
|
|
81
|
+
* — `(await stats()).default.queued` is the backlog of a queue. `queue` narrows
|
|
82
|
+
* the aggregate to one queue, which is also what keeps a caller from paying for
|
|
83
|
+
* the other workloads sharing the installation; a named queue is always
|
|
84
|
+
* present, zero-filled if it has no rows.
|
|
85
|
+
*
|
|
86
|
+
* This counts rows, so it costs what it counts — use it for a dashboard, and
|
|
87
|
+
* poll `queueDepth()` instead, which is bounded. */
|
|
88
|
+
stats(queue?: string): Promise<Record<string, Record<TaskStatus, number>>>;
|
|
80
89
|
/**
|
|
81
90
|
* Call `onSignal` when the tasks on `queues` may have changed. Returns an
|
|
82
91
|
* unsubscribe.
|
package/dist/client.js
CHANGED
|
@@ -92,14 +92,23 @@ export class CairnQ {
|
|
|
92
92
|
/** Delete terminal tasks that finished more than `olderThanMs` ago and return
|
|
93
93
|
* their ids. Nothing else in CairnQ removes rows, so a long-lived database
|
|
94
94
|
* needs this on a schedule. Each call is bounded by `limit` to keep the write
|
|
95
|
-
* short; loop until it returns fewer than `limit`.
|
|
95
|
+
* short; loop until it returns fewer than `limit`.
|
|
96
|
+
*
|
|
97
|
+
* `queue` / `status` / `name` narrow the sweep — one installation carrying two
|
|
98
|
+
* workloads needs a retention per workload, not one for the whole database. */
|
|
96
99
|
purge(input) {
|
|
97
100
|
return this._store.purge(input);
|
|
98
101
|
}
|
|
99
102
|
/** Task counts per queue, keyed by status and zero-filled across all statuses
|
|
100
|
-
* — `(await stats()).default.queued` is the backlog of a queue.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
* — `(await stats()).default.queued` is the backlog of a queue. `queue` narrows
|
|
104
|
+
* the aggregate to one queue, which is also what keeps a caller from paying for
|
|
105
|
+
* the other workloads sharing the installation; a named queue is always
|
|
106
|
+
* present, zero-filled if it has no rows.
|
|
107
|
+
*
|
|
108
|
+
* This counts rows, so it costs what it counts — use it for a dashboard, and
|
|
109
|
+
* poll `queueDepth()` instead, which is bounded. */
|
|
110
|
+
stats(queue) {
|
|
111
|
+
return this._store.stats(queue);
|
|
103
112
|
}
|
|
104
113
|
/**
|
|
105
114
|
* Call `onSignal` when the tasks on `queues` may have changed. Returns an
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type { CallOptions, ClientOptions, SubmitOptions, WaitOptions } from "./c
|
|
|
3
3
|
export { QueueDepthGate } from "./backpressure.js";
|
|
4
4
|
export type { BackpressureOptions, QueueDepthLimit } from "./backpressure.js";
|
|
5
5
|
export { RetentionSweeper } from "./retention.js";
|
|
6
|
-
export type { RetentionCutoffs, RetentionOptions } from "./retention.js";
|
|
6
|
+
export type { RetentionCutoffs, RetentionOptions, RetentionRule } from "./retention.js";
|
|
7
7
|
export { Worker } from "./worker.js";
|
|
8
8
|
export type { BatchHandler, Handler, TypedHandler, WorkerOptions } from "./worker.js";
|
|
9
9
|
export { TaskContext } from "./context.js";
|
package/dist/retention.d.ts
CHANGED
|
@@ -3,17 +3,46 @@ import { type TaskStore } from "./store/base.js";
|
|
|
3
3
|
/** Per-status cutoffs. A status left out is never swept — granular retention is
|
|
4
4
|
* an explicit statement of what may go, not a default for what wasn't named. */
|
|
5
5
|
export type RetentionCutoffs = Partial<Record<TerminalStatus, number>>;
|
|
6
|
+
/**
|
|
7
|
+
* One "these rows may go after this long" statement. Each field left out widens
|
|
8
|
+
* what the rule covers; `olderThanMs` is the only required one.
|
|
9
|
+
*
|
|
10
|
+
* A rule is one `purge` call's filters, so the fields are exactly `PurgeInput`'s
|
|
11
|
+
* — deliberately, since a rule the sweeper can express but the store cannot
|
|
12
|
+
* enforce would be a lie about what is being deleted.
|
|
13
|
+
*/
|
|
14
|
+
export interface RetentionRule {
|
|
15
|
+
/** Only this queue. Absent means every queue. */
|
|
16
|
+
queue?: string;
|
|
17
|
+
/** Only this terminal status. Absent means all three. */
|
|
18
|
+
status?: TerminalStatus;
|
|
19
|
+
/** Only this task name. Absent means every name. */
|
|
20
|
+
name?: string;
|
|
21
|
+
/** How long a row matching this rule is kept after it finished. */
|
|
22
|
+
olderThanMs: number;
|
|
23
|
+
}
|
|
6
24
|
export interface RetentionOptions {
|
|
7
25
|
/**
|
|
8
26
|
* How long a terminal task is kept after it finished. Required: there is no
|
|
9
27
|
* safe default for how long someone else's results stay readable.
|
|
10
28
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
29
|
+
* Three forms, widening as the deployment does:
|
|
30
|
+
*
|
|
31
|
+
* - A number keeps every terminal row the same time.
|
|
32
|
+
* - A per-status map tiers by outcome — a succeeded row is spent once its
|
|
33
|
+
* result is consumed, a failed one is worth keeping for diagnosis:
|
|
34
|
+
* `{ succeeded: 300_000, failed: 86_400_000 }`. A status left out is never
|
|
35
|
+
* swept.
|
|
36
|
+
* - An array of rules tiers by anything `purge` can filter on, which is what a
|
|
37
|
+
* store shared by two workloads needs — the recommended way for two
|
|
38
|
+
* languages to coordinate is one installation, and an RPC queue read once
|
|
39
|
+
* has nothing in common with a durable queue kept for a week:
|
|
40
|
+
* `[{ queue: "rpc", olderThanMs: 300_000 },
|
|
41
|
+
* { queue: "jobs", status: "failed", olderThanMs: 604_800_000 }]`.
|
|
42
|
+
* Rules are independent, each its own sweep — nothing a rule does not match
|
|
43
|
+
* is swept, and rules that overlap simply delete the same row once.
|
|
15
44
|
*/
|
|
16
|
-
olderThanMs: number | RetentionCutoffs;
|
|
45
|
+
olderThanMs: number | RetentionCutoffs | RetentionRule[];
|
|
17
46
|
/** Time between sweeps. Default 3_600_000 (one hour). */
|
|
18
47
|
intervalMs?: number;
|
|
19
48
|
/** Rows deleted per statement while draining. Default 1_000. */
|
|
@@ -63,7 +92,8 @@ export declare class RetentionSweeper {
|
|
|
63
92
|
private readonly intervalMs;
|
|
64
93
|
/** Rows per purge statement while draining — see DEFAULT_LIMIT. */
|
|
65
94
|
private readonly limit;
|
|
66
|
-
/** One purge per
|
|
95
|
+
/** One purge per rule: a lone entry for a number, one per status for a map,
|
|
96
|
+
* one per element for an array. */
|
|
67
97
|
private readonly purgeInputs;
|
|
68
98
|
constructor(store: TaskStore, opts: RetentionOptions);
|
|
69
99
|
start(): void;
|
package/dist/retention.js
CHANGED
|
@@ -6,6 +6,19 @@ const DEFAULT_INTERVAL_MS = 3_600_000;
|
|
|
6
6
|
/** Rows per purge statement. The same bound `purge` defaults to: big enough that
|
|
7
7
|
* a backlog drains in few statements, small enough that each is a short write. */
|
|
8
8
|
const DEFAULT_LIMIT = 1_000;
|
|
9
|
+
/** The three `olderThanMs` forms as the one form the sweep runs on. The number
|
|
10
|
+
* and the per-status map are the rule array's common cases spelled shorter, so
|
|
11
|
+
* they are widened here rather than handled separately downstream. */
|
|
12
|
+
function toRules(spec) {
|
|
13
|
+
if (typeof spec === "number")
|
|
14
|
+
return [{ olderThanMs: spec }];
|
|
15
|
+
if (Array.isArray(spec))
|
|
16
|
+
return spec;
|
|
17
|
+
return Object.entries(spec).map(([status, olderThanMs]) => ({
|
|
18
|
+
status,
|
|
19
|
+
olderThanMs,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
9
22
|
/**
|
|
10
23
|
* Deletes terminal tasks on a schedule, for as long as the handle is open.
|
|
11
24
|
*
|
|
@@ -44,7 +57,8 @@ export class RetentionSweeper {
|
|
|
44
57
|
intervalMs;
|
|
45
58
|
/** Rows per purge statement while draining — see DEFAULT_LIMIT. */
|
|
46
59
|
limit;
|
|
47
|
-
/** One purge per
|
|
60
|
+
/** One purge per rule: a lone entry for a number, one per status for a map,
|
|
61
|
+
* one per element for an array. */
|
|
48
62
|
purgeInputs;
|
|
49
63
|
constructor(store, opts) {
|
|
50
64
|
this.store = store;
|
|
@@ -54,20 +68,15 @@ export class RetentionSweeper {
|
|
|
54
68
|
throw new Error(`retention.intervalMs must be >= 1, got ${this.intervalMs}`);
|
|
55
69
|
}
|
|
56
70
|
this.limit = opts.limit ?? DEFAULT_LIMIT;
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
throw new Error("retention.olderThanMs must name at least one status");
|
|
71
|
+
const rules = toRules(opts.olderThanMs);
|
|
72
|
+
// An empty map or array retains nothing and sweeps nothing — almost
|
|
73
|
+
// certainly a bug upstream of this call, so refuse it rather than silently
|
|
74
|
+
// never purging.
|
|
75
|
+
if (!rules.length) {
|
|
76
|
+
throw new Error("retention.olderThanMs must name at least one rule");
|
|
64
77
|
}
|
|
65
78
|
this.arm();
|
|
66
|
-
this.purgeInputs =
|
|
67
|
-
olderThanMs: ms,
|
|
68
|
-
status,
|
|
69
|
-
limit: this.limit,
|
|
70
|
-
}));
|
|
79
|
+
this.purgeInputs = rules.map((rule) => ({ ...rule, limit: this.limit }));
|
|
71
80
|
// Fail fast on the store's own purge rules (terminal status, cutoff >= 0):
|
|
72
81
|
// the sweep runs an hour from now, and its errors only surface via onError.
|
|
73
82
|
for (const input of this.purgeInputs)
|
package/dist/store/base.d.ts
CHANGED
|
@@ -45,6 +45,14 @@ export interface ListInput {
|
|
|
45
45
|
export declare function validatePurgeInput(input: PurgeInput): void;
|
|
46
46
|
export interface PurgeInput {
|
|
47
47
|
olderThanMs?: number;
|
|
48
|
+
/** Restrict the sweep to one queue. Absent means every queue.
|
|
49
|
+
*
|
|
50
|
+
* The same tiering argument as `status`, one level up: a single installation
|
|
51
|
+
* is how this project recommends two languages coordinate, so it routinely
|
|
52
|
+
* carries two workloads whose rows have nothing to do with each other's
|
|
53
|
+
* lifetimes — an RPC result read once, a durable job's log kept for a week.
|
|
54
|
+
* Without this the shorter-lived queue sets the retention for both. */
|
|
55
|
+
queue?: string;
|
|
48
56
|
/** Restrict the sweep to one terminal status. Retention needs are tiered —
|
|
49
57
|
* succeeded rows are spent once their result is consumed, failed ones are
|
|
50
58
|
* worth keeping for diagnosis — and without this the shortest-lived tier
|
|
@@ -62,6 +70,24 @@ export declare const LEASE_EXPIRED_ERROR_JSON: string;
|
|
|
62
70
|
export declare const COMMENT: RegExp;
|
|
63
71
|
/** A `:name` placeholder. The lookbehind spares Postgres `::type` casts. */
|
|
64
72
|
export declare const NAMED: RegExp;
|
|
73
|
+
/**
|
|
74
|
+
* The statement as it should run for these arguments: every optional filter the
|
|
75
|
+
* caller actually supplied rewritten to an equality.
|
|
76
|
+
*
|
|
77
|
+
* `(:p is null or col = :p)` cannot use an index. SQLite plans a statement when
|
|
78
|
+
* it is prepared, before any parameter has a value, so it must plan for both
|
|
79
|
+
* branches and settles for a scan; that is not a tuning detail but the whole
|
|
80
|
+
* difference between `list(root_id=…)` seeking cairnq_tasks_root_idx and reading
|
|
81
|
+
* the table. Postgres re-plans with the values for a statement's first
|
|
82
|
+
* executions and folds the branch away on its own, so this is a no-op there —
|
|
83
|
+
* but it costs nothing, and one behaviour is easier to reason about than two.
|
|
84
|
+
*
|
|
85
|
+
* Filters the caller did NOT supply are left alone rather than removed: a
|
|
86
|
+
* constant-true term costs a per-row evaluation the planner mostly discards, and
|
|
87
|
+
* leaving them keeps the parameter set identical to the file's, so the binding
|
|
88
|
+
* path below needs to know nothing about any of this.
|
|
89
|
+
*/
|
|
90
|
+
export declare function specialize(sql: string, params: Params): string;
|
|
65
91
|
/**
|
|
66
92
|
* The parameter names a statement binds, in first-appearance order.
|
|
67
93
|
*
|
|
@@ -161,11 +187,19 @@ export declare abstract class TaskStore {
|
|
|
161
187
|
*/
|
|
162
188
|
protected warmPush?(): void;
|
|
163
189
|
/**
|
|
164
|
-
* Whether it is worth opening the claim transaction at all
|
|
165
|
-
*
|
|
166
|
-
*
|
|
190
|
+
* Whether it is worth opening the claim transaction at all — the read-only
|
|
191
|
+
* `claimable_probe`, which every dialect ships.
|
|
192
|
+
*
|
|
193
|
+
* Both dialects want it, for different reasons: SQLite so an idle worker never
|
|
194
|
+
* takes its single write lock and idle workers stop serializing against each
|
|
195
|
+
* other, Postgres so an empty poll costs one statement instead of a
|
|
196
|
+
* transaction plus `recover_leases` plus one claim statement per
|
|
197
|
+
* self-limiting name. Neither reason is dialect-specific enough to live in a
|
|
198
|
+
* dialect: what differs is the SQL, which is where the protocol keeps dialect
|
|
199
|
+
* differences already. A backend whose probe would cost more than the claim it
|
|
200
|
+
* guards overrides this with `return true`.
|
|
167
201
|
*/
|
|
168
|
-
protected hasClaimableWork(
|
|
202
|
+
protected hasClaimableWork(params: Params): Promise<boolean>;
|
|
169
203
|
/** Resolves when a task may have become claimable on one of `queues`. The
|
|
170
204
|
* timer is unref'd: the worker races this against its own stop-aware, ref'd
|
|
171
205
|
* sleep, so it must neither hold the process open nor need clearing. */
|
|
@@ -220,6 +254,9 @@ export declare abstract class TaskStore {
|
|
|
220
254
|
* their ids. Nothing else removes rows, so a long-lived database needs this
|
|
221
255
|
* called periodically. Bounded by `limit` to keep each sweep a short write;
|
|
222
256
|
* call it in a loop until it returns fewer than `limit`.
|
|
257
|
+
*
|
|
258
|
+
* `queue` / `status` / `name` narrow the sweep, which is what makes tiered
|
|
259
|
+
* retention expressible at all — see PurgeInput.
|
|
223
260
|
*/
|
|
224
261
|
purge(input?: PurgeInput): Promise<string[]>;
|
|
225
262
|
/**
|
|
@@ -227,8 +264,20 @@ export declare abstract class TaskStore {
|
|
|
227
264
|
* `(await stats()).default.queued` is the backlog of a queue. A queue appears
|
|
228
265
|
* only while it has rows; terminal tasks keep counting until `purge` removes
|
|
229
266
|
* them.
|
|
267
|
+
*
|
|
268
|
+
* `queue` restricts the aggregate to one queue, which is also what stops the
|
|
269
|
+
* caller paying for every other queue's rows: one installation carrying two
|
|
270
|
+
* workloads is the coordination this project recommends, and the unfiltered
|
|
271
|
+
* form reads the whole table. A named queue is always present in the result,
|
|
272
|
+
* zero-filled if it has no rows at all — asking about a specific queue and
|
|
273
|
+
* getting `undefined` back would make every caller write the same fallback.
|
|
274
|
+
*
|
|
275
|
+
* Filtered or not, this COUNTS, so it costs what it counts: a whole queue,
|
|
276
|
+
* terminal rows included. Right for a dashboard, wrong on an interval — poll
|
|
277
|
+
* `queueDepth`, which is bounded, and keep this for when the real numbers are
|
|
278
|
+
* the point.
|
|
230
279
|
*/
|
|
231
|
-
stats(): Promise<Record<string, Record<TaskStatus, number>>>;
|
|
280
|
+
stats(queue?: string): Promise<Record<string, Record<TaskStatus, number>>>;
|
|
232
281
|
/**
|
|
233
282
|
* Call `onSignal` when the tasks on `queues` may have changed — something was
|
|
234
283
|
* queued, or something finished.
|