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.
@@ -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 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
+ -- 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
- -- 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
+ -- 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 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
+ -- 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
- -- 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
+ -- 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
- 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";
@@ -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
- * A number keeps every terminal status the same time. Retention needs are
12
- * often tiered — a succeeded row is spent once its result is consumed, while
13
- * a failed one is worth keeping for diagnosis so a per-status map sets a
14
- * cutoff per status instead: `{ succeeded: 300_000, failed: 86_400_000 }`.
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 cutoff: a lone entry for a number, one per status for a map. */
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 cutoff: a lone entry for a number, one per status for a map. */
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 cutoffs = typeof opts.olderThanMs === "number"
58
- ? [[undefined, opts.olderThanMs]]
59
- : Object.entries(opts.olderThanMs);
60
- // An empty map retains nothing and sweeps nothing — almost certainly a bug
61
- // upstream of this call, so refuse it rather than silently never purging.
62
- if (!cutoffs.length) {
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 = cutoffs.map(([status, ms]) => ({
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)
@@ -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. SQLite gates its
165
- * single write lock behind a read-only probe; Postgres readers don't block
166
- * writers, so it just says yes.
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(_params: Params): Promise<boolean>;
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.