cairnq 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/_protocol/migrations/postgres/0009_purge_queue_index.sql +39 -0
  2. package/dist/_protocol/migrations/sqlite/0009_purge_queue_index.sql +39 -0
  3. package/dist/_protocol/sql/postgres/claimable_probe.sql +47 -0
  4. package/dist/_protocol/sql/postgres/purge.sql +12 -6
  5. package/dist/_protocol/sql/postgres/purge_one_queue.sql +42 -0
  6. package/dist/_protocol/sql/postgres/purge_one_queue_one_status.sql +42 -0
  7. package/dist/_protocol/sql/postgres/purge_one_status.sql +42 -0
  8. package/dist/_protocol/sql/postgres/stats.sql +15 -2
  9. package/dist/_protocol/sql/postgres/stats_one_queue.sql +21 -0
  10. package/dist/_protocol/sql/sqlite/purge.sql +12 -5
  11. package/dist/_protocol/sql/sqlite/purge_one_queue.sql +41 -0
  12. package/dist/_protocol/sql/sqlite/purge_one_queue_one_status.sql +41 -0
  13. package/dist/_protocol/sql/sqlite/purge_one_status.sql +41 -0
  14. package/dist/_protocol/sql/sqlite/stats.sql +15 -2
  15. package/dist/_protocol/sql/sqlite/stats_one_queue.sql +21 -0
  16. package/dist/client.d.ts +12 -3
  17. package/dist/client.js +13 -4
  18. package/dist/index.d.ts +1 -1
  19. package/dist/retention.d.ts +36 -6
  20. package/dist/retention.js +22 -13
  21. package/dist/store/base.d.ts +36 -5
  22. package/dist/store/base.js +56 -9
  23. package/dist/store/pg-executor.d.ts +15 -0
  24. package/dist/store/pg-executor.js +15 -0
  25. package/dist/store/postgres.d.ts +4 -2
  26. package/dist/store/postgres.js +4 -2
  27. package/dist/store/sqlite.d.ts +0 -1
  28. package/dist/store/sqlite.js +0 -6
  29. package/package.json +1 -1
  30. package/src/client.ts +13 -4
  31. package/src/index.ts +1 -1
  32. package/src/retention.ts +57 -20
  33. package/src/store/base.ts +65 -11
  34. package/src/store/pg-executor.ts +15 -0
  35. package/src/store/postgres.ts +4 -2
  36. package/src/store/sqlite.ts +0 -6
@@ -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,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,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,22 @@
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 the
15
- -- param's type, as in list.sql): retention needs are tiered — a succeeded row
16
- -- is spent once its result is consumed, while a failed one is worth keeping
17
- -- for diagnosis — and without them the shortest-lived tier sets the retention
18
- -- for every row.
19
- -- params: older_than_ms, status, name, limit
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
+ -- params: older_than_ms, queue, status, name, limit
20
25
  delete from cairnq_tasks
21
26
  where id in (
22
27
  select id from cairnq_tasks
23
28
  where status in ('succeeded', 'failed', 'canceled')
29
+ and (:queue::text is null or queue = :queue)
24
30
  and (:status::text is null or status = :status)
25
31
  and (:name::text is null or name = :name)
26
32
  and completed_at_ms is not null
@@ -0,0 +1,42 @@
1
+ -- purge, for a sweep bounded to ONE queue. Byte-for-byte purge.sql except that
2
+ -- the queue filter is an equality instead of an optional `is null or` — a drift-
3
+ -- guard test asserts precisely that, so treat purge.sql as the source and re-
4
+ -- derive this file when it changes.
5
+ --
6
+ -- It exists because the optional-filter form cannot be indexed. SQLite plans a
7
+ -- statement when it is prepared, before any parameter has a value, so `(:queue
8
+ -- is null or queue = :queue)` has to be planned for BOTH branches and the
9
+ -- planner falls back to cairnq_tasks_completed_idx, walking every row past the
10
+ -- cutoff in completion order and discarding the ones belonging to another queue.
11
+ -- `limit` bounds what comes back, never what is read, so the cost grows with
12
+ -- exactly the rows the filter was meant to skip — and the deployment the filter
13
+ -- exists for (one installation, two workloads on different clocks) is the one
14
+ -- where those rows are most numerous. Measured on 20k rows over two queues and
15
+ -- four statuses: the optional form chooses cairnq_tasks_completed_idx for every
16
+ -- filter combination, the equality form chooses cairnq_tasks_queue_completed_idx
17
+ -- (0009) and reads only its own range. Same reason claim.sql has
18
+ -- specializations, same shape.
19
+ --
20
+ -- Postgres does not have SQLite's problem — it re-plans with the parameter
21
+ -- values for the first executions and folds the null branch away — but it ships
22
+ -- the variant too, because both dialects carry the same statement set and a
23
+ -- caller that had to know which dialect indexes which form would be a worse
24
+ -- contract.
25
+ --
26
+ -- :name stays optional in every variant: nothing indexes it, so it is a residual
27
+ -- predicate either way and specializing it would buy nothing.
28
+ -- params: older_than_ms, queue, status, name, limit
29
+ delete from cairnq_tasks
30
+ where id in (
31
+ select id from cairnq_tasks
32
+ where status in ('succeeded', 'failed', 'canceled')
33
+ and queue = :queue
34
+ and (:status::text is null or status = :status)
35
+ and (:name::text is null or name = :name)
36
+ and completed_at_ms is not null
37
+ and completed_at_ms < (extract(epoch from now()) * 1000)::bigint - :older_than_ms
38
+ order by completed_at_ms asc
39
+ limit :limit
40
+ for update skip locked
41
+ )
42
+ returning id;
@@ -0,0 +1,42 @@
1
+ -- purge, for a sweep bounded to one queue AND one terminal status. Byte-for-byte
2
+ -- purge.sql except that the queue and status filters are an equality instead of
3
+ -- an optional `is null or` — a drift-guard test asserts precisely that, so treat
4
+ -- purge.sql as the source and re-derive this file when it changes.
5
+ --
6
+ -- It exists because the optional-filter form cannot be indexed. SQLite plans a
7
+ -- statement when it is prepared, before any parameter has a value, so `(:queue
8
+ -- is null or queue = :queue)` has to be planned for BOTH branches and the
9
+ -- planner falls back to cairnq_tasks_completed_idx, walking every row past the
10
+ -- cutoff in completion order and discarding the ones belonging to another queue.
11
+ -- `limit` bounds what comes back, never what is read, so the cost grows with
12
+ -- exactly the rows the filter was meant to skip — and the deployment the filter
13
+ -- exists for (one installation, two workloads on different clocks) is the one
14
+ -- where those rows are most numerous. Measured on 20k rows over two queues and
15
+ -- four statuses: the optional form chooses cairnq_tasks_completed_idx for every
16
+ -- filter combination, the equality form chooses cairnq_tasks_queue_completed_idx
17
+ -- (0009) and reads only its own range. Same reason claim.sql has
18
+ -- specializations, same shape.
19
+ --
20
+ -- Postgres does not have SQLite's problem — it re-plans with the parameter
21
+ -- values for the first executions and folds the null branch away — but it ships
22
+ -- the variant too, because both dialects carry the same statement set and a
23
+ -- caller that had to know which dialect indexes which form would be a worse
24
+ -- contract.
25
+ --
26
+ -- :name stays optional in every variant: nothing indexes it, so it is a residual
27
+ -- predicate either way and specializing it would buy nothing.
28
+ -- params: older_than_ms, queue, status, name, limit
29
+ delete from cairnq_tasks
30
+ where id in (
31
+ select id from cairnq_tasks
32
+ where status in ('succeeded', 'failed', 'canceled')
33
+ and queue = :queue
34
+ and status = :status
35
+ and (:name::text is null or name = :name)
36
+ and completed_at_ms is not null
37
+ and completed_at_ms < (extract(epoch from now()) * 1000)::bigint - :older_than_ms
38
+ order by completed_at_ms asc
39
+ limit :limit
40
+ for update skip locked
41
+ )
42
+ returning id;
@@ -0,0 +1,42 @@
1
+ -- purge, for a sweep bounded to ONE terminal status. Byte-for-byte purge.sql
2
+ -- except that the status filter is an equality instead of an optional `is null
3
+ -- or` — a drift-guard test asserts precisely that, so treat purge.sql as the
4
+ -- source and re-derive this file when it changes.
5
+ --
6
+ -- It exists because the optional-filter form cannot be indexed. SQLite plans a
7
+ -- statement when it is prepared, before any parameter has a value, so `(:status
8
+ -- is null or status = :status)` has to be planned for BOTH branches and the
9
+ -- planner falls back to cairnq_tasks_completed_idx, walking every row past the
10
+ -- cutoff in completion order and discarding the ones belonging to another
11
+ -- status. `limit` bounds what comes back, never what is read, so the cost grows
12
+ -- with exactly the rows the filter was meant to skip — and the deployment the
13
+ -- filter exists for (one installation, two workloads on different clocks) is the
14
+ -- one where those rows are most numerous. Measured on 20k rows over two queues
15
+ -- and four statuses: the optional form chooses cairnq_tasks_completed_idx for
16
+ -- every filter combination, the equality form chooses
17
+ -- cairnq_tasks_status_completed_idx (0007) and reads only its own range. Same
18
+ -- reason claim.sql has specializations, same shape.
19
+ --
20
+ -- Postgres does not have SQLite's problem — it re-plans with the parameter
21
+ -- values for the first executions and folds the null branch away — but it ships
22
+ -- the variant too, because both dialects carry the same statement set and a
23
+ -- caller that had to know which dialect indexes which form would be a worse
24
+ -- contract.
25
+ --
26
+ -- :name stays optional in every variant: nothing indexes it, so it is a residual
27
+ -- predicate either way and specializing it would buy nothing.
28
+ -- params: older_than_ms, queue, status, name, limit
29
+ delete from cairnq_tasks
30
+ where id in (
31
+ select id from cairnq_tasks
32
+ where status in ('succeeded', 'failed', 'canceled')
33
+ and (:queue::text is null or queue = :queue)
34
+ and status = :status
35
+ and (:name::text is null or name = :name)
36
+ and completed_at_ms is not null
37
+ and completed_at_ms < (extract(epoch from now()) * 1000)::bigint - :older_than_ms
38
+ order by completed_at_ms asc
39
+ limit :limit
40
+ for update skip locked
41
+ )
42
+ returning id;
@@ -1,8 +1,21 @@
1
- -- Queue depth at a glance: task counts grouped by queue and status. Read-only.
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
- -- params: (none)
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
+ -- params: queue
5
17
  select queue, status, count(*) as count
6
18
  from cairnq_tasks
19
+ where (:queue::text is null or queue = :queue)
7
20
  group by queue, status
8
21
  order by queue asc, status asc;
@@ -0,0 +1,21 @@
1
+ -- stats, for a caller asking about ONE queue. Byte-for-byte stats.sql except
2
+ -- that the queue filter is an equality instead of an optional `is null or` — a
3
+ -- drift-guard test asserts precisely that, so treat stats.sql as the source and
4
+ -- re-derive this file when it changes.
5
+ --
6
+ -- It exists for the same reason purge_one_queue.sql does. SQLite plans a
7
+ -- statement before its parameters have values, so the optional form has to be
8
+ -- planned for both branches: it reads the whole table (as a covering index
9
+ -- scan) and groups it, which is exactly the cost narrowing to one queue was
10
+ -- meant to avoid. The equality form seeks the (queue, status) prefix of
11
+ -- cairnq_tasks_claim_idx and reads only that queue's entries.
12
+ --
13
+ -- This still COUNTS what it reports, so it costs what it counts: one queue's
14
+ -- rows, terminal ones included. Narrower than the unfiltered form, still not a
15
+ -- poll-loop question — queue_depth.sql is the bounded one.
16
+ -- params: queue
17
+ select queue, status, count(*) as count
18
+ from cairnq_tasks
19
+ where queue = :queue
20
+ group by queue, status
21
+ order by queue asc, status asc;
@@ -5,15 +5,22 @@
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 list.sql):
9
- -- retention needs are tiered — a succeeded row is spent once its result is
10
- -- consumed, while a failed one is worth keeping for diagnosis — and without
11
- -- them the shortest-lived tier sets the retention for every row.
12
- -- params: before_ms, status, name, limit
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
+ -- params: before_ms, queue, status, name, limit
13
19
  delete from cairnq_tasks
14
20
  where id in (
15
21
  select id from cairnq_tasks
16
22
  where status in ('succeeded', 'failed', 'canceled')
23
+ and (:queue is null or queue = :queue)
17
24
  and (:status is null or status = :status)
18
25
  and (:name is null or name = :name)
19
26
  and completed_at_ms is not null
@@ -0,0 +1,41 @@
1
+ -- purge, for a sweep bounded to ONE queue. Byte-for-byte purge.sql except that
2
+ -- the queue filter is an equality instead of an optional `is null or` — a drift-
3
+ -- guard test asserts precisely that, so treat purge.sql as the source and re-
4
+ -- derive this file when it changes.
5
+ --
6
+ -- It exists because the optional-filter form cannot be indexed. SQLite plans a
7
+ -- statement when it is prepared, before any parameter has a value, so `(:queue
8
+ -- is null or queue = :queue)` has to be planned for BOTH branches and the
9
+ -- planner falls back to cairnq_tasks_completed_idx, walking every row past the
10
+ -- cutoff in completion order and discarding the ones belonging to another queue.
11
+ -- `limit` bounds what comes back, never what is read, so the cost grows with
12
+ -- exactly the rows the filter was meant to skip — and the deployment the filter
13
+ -- exists for (one installation, two workloads on different clocks) is the one
14
+ -- where those rows are most numerous. Measured on 20k rows over two queues and
15
+ -- four statuses: the optional form chooses cairnq_tasks_completed_idx for every
16
+ -- filter combination, the equality form chooses cairnq_tasks_queue_completed_idx
17
+ -- (0009) and reads only its own range. Same reason claim.sql has
18
+ -- specializations, same shape.
19
+ --
20
+ -- Postgres does not have SQLite's problem — it re-plans with the parameter
21
+ -- values for the first executions and folds the null branch away — but it ships
22
+ -- the variant too, because both dialects carry the same statement set and a
23
+ -- caller that had to know which dialect indexes which form would be a worse
24
+ -- contract.
25
+ --
26
+ -- :name stays optional in every variant: nothing indexes it, so it is a residual
27
+ -- predicate either way and specializing it would buy nothing.
28
+ -- params: before_ms, queue, status, name, limit
29
+ delete from cairnq_tasks
30
+ where id in (
31
+ select id from cairnq_tasks
32
+ where status in ('succeeded', 'failed', 'canceled')
33
+ and queue = :queue
34
+ and (:status is null or status = :status)
35
+ and (:name is null or name = :name)
36
+ and completed_at_ms is not null
37
+ and completed_at_ms < :before_ms
38
+ order by completed_at_ms asc
39
+ limit :limit
40
+ )
41
+ returning id;
@@ -0,0 +1,41 @@
1
+ -- purge, for a sweep bounded to one queue AND one terminal status. Byte-for-byte
2
+ -- purge.sql except that the queue and status filters are an equality instead of
3
+ -- an optional `is null or` — a drift-guard test asserts precisely that, so treat
4
+ -- purge.sql as the source and re-derive this file when it changes.
5
+ --
6
+ -- It exists because the optional-filter form cannot be indexed. SQLite plans a
7
+ -- statement when it is prepared, before any parameter has a value, so `(:queue
8
+ -- is null or queue = :queue)` has to be planned for BOTH branches and the
9
+ -- planner falls back to cairnq_tasks_completed_idx, walking every row past the
10
+ -- cutoff in completion order and discarding the ones belonging to another queue.
11
+ -- `limit` bounds what comes back, never what is read, so the cost grows with
12
+ -- exactly the rows the filter was meant to skip — and the deployment the filter
13
+ -- exists for (one installation, two workloads on different clocks) is the one
14
+ -- where those rows are most numerous. Measured on 20k rows over two queues and
15
+ -- four statuses: the optional form chooses cairnq_tasks_completed_idx for every
16
+ -- filter combination, the equality form chooses cairnq_tasks_queue_completed_idx
17
+ -- (0009) and reads only its own range. Same reason claim.sql has
18
+ -- specializations, same shape.
19
+ --
20
+ -- Postgres does not have SQLite's problem — it re-plans with the parameter
21
+ -- values for the first executions and folds the null branch away — but it ships
22
+ -- the variant too, because both dialects carry the same statement set and a
23
+ -- caller that had to know which dialect indexes which form would be a worse
24
+ -- contract.
25
+ --
26
+ -- :name stays optional in every variant: nothing indexes it, so it is a residual
27
+ -- predicate either way and specializing it would buy nothing.
28
+ -- params: before_ms, queue, status, name, limit
29
+ delete from cairnq_tasks
30
+ where id in (
31
+ select id from cairnq_tasks
32
+ where status in ('succeeded', 'failed', 'canceled')
33
+ and queue = :queue
34
+ and status = :status
35
+ and (:name is null or name = :name)
36
+ and completed_at_ms is not null
37
+ and completed_at_ms < :before_ms
38
+ order by completed_at_ms asc
39
+ limit :limit
40
+ )
41
+ returning id;
@@ -0,0 +1,41 @@
1
+ -- purge, for a sweep bounded to ONE terminal status. Byte-for-byte purge.sql
2
+ -- except that the status filter is an equality instead of an optional `is null
3
+ -- or` — a drift-guard test asserts precisely that, so treat purge.sql as the
4
+ -- source and re-derive this file when it changes.
5
+ --
6
+ -- It exists because the optional-filter form cannot be indexed. SQLite plans a
7
+ -- statement when it is prepared, before any parameter has a value, so `(:status
8
+ -- is null or status = :status)` has to be planned for BOTH branches and the
9
+ -- planner falls back to cairnq_tasks_completed_idx, walking every row past the
10
+ -- cutoff in completion order and discarding the ones belonging to another
11
+ -- status. `limit` bounds what comes back, never what is read, so the cost grows
12
+ -- with exactly the rows the filter was meant to skip — and the deployment the
13
+ -- filter exists for (one installation, two workloads on different clocks) is the
14
+ -- one where those rows are most numerous. Measured on 20k rows over two queues
15
+ -- and four statuses: the optional form chooses cairnq_tasks_completed_idx for
16
+ -- every filter combination, the equality form chooses
17
+ -- cairnq_tasks_status_completed_idx (0007) and reads only its own range. Same
18
+ -- reason claim.sql has specializations, same shape.
19
+ --
20
+ -- Postgres does not have SQLite's problem — it re-plans with the parameter
21
+ -- values for the first executions and folds the null branch away — but it ships
22
+ -- the variant too, because both dialects carry the same statement set and a
23
+ -- caller that had to know which dialect indexes which form would be a worse
24
+ -- contract.
25
+ --
26
+ -- :name stays optional in every variant: nothing indexes it, so it is a residual
27
+ -- predicate either way and specializing it would buy nothing.
28
+ -- params: before_ms, queue, status, name, limit
29
+ delete from cairnq_tasks
30
+ where id in (
31
+ select id from cairnq_tasks
32
+ where status in ('succeeded', 'failed', 'canceled')
33
+ and (:queue is null or queue = :queue)
34
+ and status = :status
35
+ and (:name is null or name = :name)
36
+ and completed_at_ms is not null
37
+ and completed_at_ms < :before_ms
38
+ order by completed_at_ms asc
39
+ limit :limit
40
+ )
41
+ returning id;
@@ -1,8 +1,21 @@
1
- -- Queue depth at a glance: task counts grouped by queue and status. Read-only.
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
- -- params: (none)
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
+ -- params: queue
5
17
  select queue, status, count(*) as count
6
18
  from cairnq_tasks
19
+ where (:queue is null or queue = :queue)
7
20
  group by queue, status
8
21
  order by queue asc, status asc;
@@ -0,0 +1,21 @@
1
+ -- stats, for a caller asking about ONE queue. Byte-for-byte stats.sql except
2
+ -- that the queue filter is an equality instead of an optional `is null or` — a
3
+ -- drift-guard test asserts precisely that, so treat stats.sql as the source and
4
+ -- re-derive this file when it changes.
5
+ --
6
+ -- It exists for the same reason purge_one_queue.sql does. SQLite plans a
7
+ -- statement before its parameters have values, so the optional form has to be
8
+ -- planned for both branches: it reads the whole table (as a covering index
9
+ -- scan) and groups it, which is exactly the cost narrowing to one queue was
10
+ -- meant to avoid. The equality form seeks the (queue, status) prefix of
11
+ -- cairnq_tasks_claim_idx and reads only that queue's entries.
12
+ --
13
+ -- This still COUNTS what it reports, so it costs what it counts: one queue's
14
+ -- rows, terminal ones included. Narrower than the unfiltered form, still not a
15
+ -- poll-loop question — queue_depth.sql is the bounded one.
16
+ -- params: queue
17
+ select queue, status, count(*) as count
18
+ from cairnq_tasks
19
+ where queue = :queue
20
+ group by queue, status
21
+ 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
- stats(): Promise<Record<string, Record<TaskStatus, number>>>;
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
- stats() {
102
- return this._store.stats();
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";