cairnq 0.10.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.
- package/dist/_protocol/migrations/postgres/0001_init.sql +2 -0
- package/dist/_protocol/migrations/postgres/0006_claim_name_index.sql +2 -0
- package/dist/_protocol/migrations/postgres/0008_claim_due_index.sql +68 -0
- package/dist/_protocol/migrations/postgres/0009_purge_queue_index.sql +39 -0
- package/dist/_protocol/migrations/sqlite/0001_init.sql +2 -0
- package/dist/_protocol/migrations/sqlite/0006_claim_name_index.sql +2 -0
- package/dist/_protocol/migrations/sqlite/0008_claim_due_index.sql +84 -0
- package/dist/_protocol/migrations/sqlite/0009_purge_queue_index.sql +39 -0
- package/dist/_protocol/sql/postgres/claim.sql +6 -3
- package/dist/_protocol/sql/postgres/claim_one_name.sql +6 -3
- package/dist/_protocol/sql/postgres/claim_one_queue.sql +9 -5
- package/dist/_protocol/sql/postgres/claim_one_queue_one_name.sql +6 -3
- package/dist/_protocol/sql/postgres/claimable_probe.sql +47 -0
- package/dist/_protocol/sql/postgres/purge.sql +12 -6
- package/dist/_protocol/sql/postgres/purge_one_queue.sql +42 -0
- package/dist/_protocol/sql/postgres/purge_one_queue_one_status.sql +42 -0
- package/dist/_protocol/sql/postgres/purge_one_status.sql +42 -0
- package/dist/_protocol/sql/postgres/stats.sql +15 -2
- package/dist/_protocol/sql/postgres/stats_one_queue.sql +21 -0
- package/dist/_protocol/sql/sqlite/claim.sql +6 -3
- package/dist/_protocol/sql/sqlite/claim_one_name.sql +6 -3
- package/dist/_protocol/sql/sqlite/claim_one_queue.sql +9 -6
- package/dist/_protocol/sql/sqlite/claim_one_queue_one_name.sql +9 -4
- package/dist/_protocol/sql/sqlite/purge.sql +12 -5
- package/dist/_protocol/sql/sqlite/purge_one_queue.sql +41 -0
- package/dist/_protocol/sql/sqlite/purge_one_queue_one_status.sql +41 -0
- package/dist/_protocol/sql/sqlite/purge_one_status.sql +41 -0
- package/dist/_protocol/sql/sqlite/stats.sql +15 -2
- package/dist/_protocol/sql/sqlite/stats_one_queue.sql +21 -0
- package/dist/client.d.ts +12 -3
- package/dist/client.js +13 -4
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +14 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/models.d.ts +2 -0
- package/dist/models.js +2 -2
- package/dist/retention.d.ts +49 -7
- package/dist/retention.js +48 -24
- package/dist/store/base.d.ts +48 -8
- package/dist/store/base.js +77 -15
- package/dist/store/pg-executor.d.ts +15 -0
- package/dist/store/pg-executor.js +15 -0
- package/dist/store/pg-pool.js +31 -6
- package/dist/store/postgres.d.ts +45 -2
- package/dist/store/postgres.js +69 -3
- package/dist/store/sqlite.d.ts +0 -1
- package/dist/store/sqlite.js +0 -6
- package/package.json +1 -1
- package/src/client.ts +13 -4
- package/src/errors.ts +15 -0
- package/src/index.ts +2 -1
- package/src/models.ts +2 -2
- package/src/retention.ts +84 -31
- package/src/store/base.ts +92 -16
- package/src/store/pg-executor.ts +15 -0
- package/src/store/pg-pool.ts +32 -6
- package/src/store/postgres.ts +68 -3
- package/src/store/sqlite.ts +0 -6
|
@@ -49,6 +49,8 @@ create table if not exists cairnq_tasks (
|
|
|
49
49
|
-- desc, created_at_ms asc (run_at_ms applied as a residual filter). Only
|
|
50
50
|
-- claim_one_queue.sql can read it in claim order; claim.sql's array-valued queue
|
|
51
51
|
-- filter forces a sort, and past a few thousand queued rows a sequential scan.
|
|
52
|
+
-- The shape as first shipped; migration 0008 rebuilds it around run_at_ms and
|
|
53
|
+
-- says why.
|
|
52
54
|
create index if not exists cairnq_tasks_claim_idx
|
|
53
55
|
on cairnq_tasks (queue, status, priority desc, created_at_ms);
|
|
54
56
|
create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
-- existing large cairnq_tasks this takes a write lock for the build. Deploying
|
|
20
20
|
-- into a busy database is the case to watch; build it by hand with CONCURRENTLY
|
|
21
21
|
-- first if that matters, and this statement then becomes a no-op.
|
|
22
|
+
-- The measurements above belong to the shape defined here; migration 0008
|
|
23
|
+
-- rebuilds it around run_at_ms and carries the current numbers.
|
|
22
24
|
create index if not exists cairnq_tasks_claim_name_idx
|
|
23
25
|
on cairnq_tasks (queue, status, name, priority desc, created_at_ms);
|
|
24
26
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
-- Serve the claim's whole ORDER BY from the index, and order by when a task
|
|
2
|
+
-- became DUE rather than by when it was created.
|
|
3
|
+
--
|
|
4
|
+
-- The two halves are one fix. `run_at_ms <= :now_ms` was a residual on the old
|
|
5
|
+
-- (queue, status, priority desc, created_at_ms) index, and a not-yet-due row —
|
|
6
|
+
-- a retry waiting out its backoff, a task submitted with a delay — sorts by the
|
|
7
|
+
-- created_at it has always had, which puts it AHEAD of the rows that are
|
|
8
|
+
-- actually claimable. So every draw walked the whole backoff pile before
|
|
9
|
+
-- reaching anything it could take, inside the transaction that holds the claim.
|
|
10
|
+
-- The pile is largest exactly when a downstream dependency has just failed and
|
|
11
|
+
-- thousands of tasks are backing off together.
|
|
12
|
+
--
|
|
13
|
+
-- Ordering by run_at_ms instead puts every not-yet-due row AFTER the due ones,
|
|
14
|
+
-- so the scan reaches its rows first, and appending `id` lets the index carry the
|
|
15
|
+
-- tie-break as well, which is what lets the planner satisfy the ORDER BY from the
|
|
16
|
+
-- index alone.
|
|
17
|
+
--
|
|
18
|
+
-- The measurements behind this live in the SQLite twin of this file, and they are
|
|
19
|
+
-- SQLite numbers: no Postgres was available where this was written, so this side
|
|
20
|
+
-- is reasoned from the same index shape rather than benchmarked. What holds
|
|
21
|
+
-- across both is the ordering — the due rows are now at the front of the index
|
|
22
|
+
-- range instead of behind every not-yet-due one.
|
|
23
|
+
--
|
|
24
|
+
-- What this does NOT fix, in either dialect: an empty draw still walks the whole
|
|
25
|
+
-- range looking for rows that are not there, and `claimable_probe` — whose
|
|
26
|
+
-- two-armed EXISTS cannot use the index at all — is untouched. A queue deep in
|
|
27
|
+
-- backoff still costs a worker real time per poll.
|
|
28
|
+
--
|
|
29
|
+
-- For a task that was never delayed or retried the change is invisible in
|
|
30
|
+
-- practice: insert_task sets run_at_ms = now + delay, so the two columns hold the
|
|
31
|
+
-- same millisecond for an ordinary submit. (On Postgres "the same millisecond" is
|
|
32
|
+
-- not quite an identity — insert_task evaluates clock_timestamp() once per column
|
|
33
|
+
-- and it advances within a statement, so a submit landing on a millisecond
|
|
34
|
+
-- boundary can differ by 1ms. That reorders two tasks submitted in the same
|
|
35
|
+
-- millisecond, which claim.sql already calls decided by the id's random half
|
|
36
|
+
-- rather than by submit order.) It changes real ordering only for tasks whose
|
|
37
|
+
-- delivery was deferred, and for those "oldest due first" is the fairer answer —
|
|
38
|
+
-- a task that failed and backed off should not cut ahead of everything submitted
|
|
39
|
+
-- while it waited, which is what its original created_at_ms bought it.
|
|
40
|
+
--
|
|
41
|
+
-- Both indexes are rebuilt inside the migration's transaction, which holds a
|
|
42
|
+
-- lock that blocks writes to cairnq_tasks while they build — seconds on a large
|
|
43
|
+
-- table. There is no CONCURRENTLY here: it cannot run inside a transaction, and
|
|
44
|
+
-- the ledger's check-and-apply is one. Upgrade a large deployment in a window
|
|
45
|
+
-- where that pause is affordable.
|
|
46
|
+
--
|
|
47
|
+
-- Same names, new definitions: these indexes ARE "the ones the claim reads", and
|
|
48
|
+
-- every reference to them in the SQL comments and PROTOCOL.md still points at
|
|
49
|
+
-- the right object.
|
|
50
|
+
--
|
|
51
|
+
-- An SDK older than this migration keeps working: it orders by created_at_ms,
|
|
52
|
+
-- finds no index in that order, and sorts. Slower, exactly where this migration
|
|
53
|
+
-- is faster — but note the sharper consequence while a fleet is mixed. Two SDK
|
|
54
|
+
-- versions against one database then disagree about which task is NEXT for any
|
|
55
|
+
-- delayed or retried work: each takes a valid claimable task, no task is lost or
|
|
56
|
+
-- run twice, but the documented claim order holds only within one version. That
|
|
57
|
+
-- is a difference in fairness, not in correctness, which is why protocol_version
|
|
58
|
+
-- stays at 1 — and it is a reason to keep the mixed window short.
|
|
59
|
+
drop index if exists cairnq_tasks_claim_idx;
|
|
60
|
+
create index cairnq_tasks_claim_idx
|
|
61
|
+
on cairnq_tasks (queue, status, priority desc, run_at_ms, id);
|
|
62
|
+
|
|
63
|
+
-- The per-name twin (see 0006), rebuilt on the same principle.
|
|
64
|
+
drop index if exists cairnq_tasks_claim_name_idx;
|
|
65
|
+
create index cairnq_tasks_claim_name_idx
|
|
66
|
+
on cairnq_tasks (queue, status, name, priority desc, run_at_ms, id);
|
|
67
|
+
|
|
68
|
+
update cairnq_meta set value = '8' 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';
|
|
@@ -48,6 +48,8 @@ create table if not exists cairnq_tasks (
|
|
|
48
48
|
-- queue+status then the ORDER BY columns is what lets claim_one_queue.sql read
|
|
49
49
|
-- rows in claim order; claim.sql's list-valued queue filter has to merge several
|
|
50
50
|
-- ranges of this index, so it sorts instead.
|
|
51
|
+
-- The shape as first shipped; migration 0008 rebuilds it around run_at_ms and
|
|
52
|
+
-- says why.
|
|
51
53
|
create index if not exists cairnq_tasks_claim_idx
|
|
52
54
|
on cairnq_tasks (queue, status, priority desc, created_at_ms);
|
|
53
55
|
create index if not exists cairnq_tasks_status_idx on cairnq_tasks (status);
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
-- bloom filter over the subquery and falls back to cairnq_tasks_claim_idx
|
|
21
21
|
-- (measured 1446us, i.e. no improvement at all). That is why the per-name
|
|
22
22
|
-- statements exist as separate files rather than the shared one being reused.
|
|
23
|
+
-- The measurements above belong to the shape defined here; migration 0008
|
|
24
|
+
-- rebuilds it around run_at_ms and carries the current numbers.
|
|
23
25
|
create index if not exists cairnq_tasks_claim_name_idx
|
|
24
26
|
on cairnq_tasks (queue, status, name, priority desc, created_at_ms);
|
|
25
27
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
-- Serve the claim's whole ORDER BY from the index, and order by when a task
|
|
2
|
+
-- became DUE rather than by when it was created.
|
|
3
|
+
--
|
|
4
|
+
-- The two halves are one fix. `run_at_ms <= :now_ms` was a residual on the old
|
|
5
|
+
-- (queue, status, priority desc, created_at_ms) index, and a not-yet-due row —
|
|
6
|
+
-- a retry waiting out its backoff, a task submitted with a delay — sorts by the
|
|
7
|
+
-- created_at it has always had, which puts it AHEAD of the rows that are
|
|
8
|
+
-- actually claimable. So every draw walked the whole backoff pile before
|
|
9
|
+
-- reaching anything it could take, inside the transaction that holds the claim.
|
|
10
|
+
-- The pile is largest exactly when a downstream dependency has just failed and
|
|
11
|
+
-- thousands of tasks are backing off together.
|
|
12
|
+
--
|
|
13
|
+
-- Ordering by run_at_ms instead puts every not-yet-due row AFTER the due ones,
|
|
14
|
+
-- so the scan reaches its rows first, and appending `id` lets the index carry the
|
|
15
|
+
-- tie-break as well, which is what lets the planner satisfy the ORDER BY from the
|
|
16
|
+
-- index alone. Measured through the real statement (`claim_one_queue`, one queue,
|
|
17
|
+
-- four names, 20k queued rows backing off), one claim of one task, on the three
|
|
18
|
+
-- SQLite builds that happened to be on the authoring machine:
|
|
19
|
+
--
|
|
20
|
+
-- SQLite linked by one due row an empty draw
|
|
21
|
+
-- 3.39.4 a system python3.11 1166us -> 22us 1110us -> 350us
|
|
22
|
+
-- 3.47.1 a python3.13 (this repo's venv) 3378us -> 116us 3128us -> 1006us
|
|
23
|
+
-- 3.53.4 better-sqlite3 13 (bundled) 1100us -> 72us 1060us -> 50us
|
|
24
|
+
--
|
|
25
|
+
-- Read the columns, not the rows: the win holds on every build, and the absolute
|
|
26
|
+
-- cost swings more between SQLite versions than between before and after on some
|
|
27
|
+
-- of them. better-sqlite3 bundles its own SQLite, so the TypeScript SDK's version
|
|
28
|
+
-- is a dependency; the Python SDK links whatever the interpreter was built
|
|
29
|
+
-- against, so its version belongs to the user's Python and cannot be assumed.
|
|
30
|
+
--
|
|
31
|
+
-- The plan differs by SQLite version and the win does not come from one single
|
|
32
|
+
-- mechanism: 3.39 drops the sorter outright, while 3.53 keeps one and instead
|
|
33
|
+
-- reaches the due rows through a skip-scan. What both have in common is the
|
|
34
|
+
-- ordering — the due rows are now at the front of the range instead of behind
|
|
35
|
+
-- every not-yet-due one. Numbers are SQLite; Postgres was not measured (no
|
|
36
|
+
-- server in the authoring environment), so treat the Postgres side as reasoned
|
|
37
|
+
-- rather than benchmarked.
|
|
38
|
+
--
|
|
39
|
+
-- What this does NOT fix: an empty draw still walks the whole range looking for
|
|
40
|
+
-- rows that are not there (350us above), and `claimable_probe` — whose two-armed
|
|
41
|
+
-- EXISTS cannot use the index at all — is unchanged at ~2.2ms on that backlog.
|
|
42
|
+
-- A queue deep in backoff still costs a worker real time per poll.
|
|
43
|
+
--
|
|
44
|
+
-- For a task that was never delayed or retried the change is invisible in
|
|
45
|
+
-- practice: insert_task sets run_at_ms = now + delay, so the two columns hold the
|
|
46
|
+
-- same millisecond for an ordinary submit. (On Postgres "the same millisecond" is
|
|
47
|
+
-- not quite an identity — insert_task evaluates clock_timestamp() once per column
|
|
48
|
+
-- and it advances within a statement, so a submit landing on a millisecond
|
|
49
|
+
-- boundary can differ by 1ms. That reorders two tasks submitted in the same
|
|
50
|
+
-- millisecond, which claim.sql already calls decided by the id's random half
|
|
51
|
+
-- rather than by submit order.) It changes real ordering only for tasks whose
|
|
52
|
+
-- delivery was deferred, and for those "oldest due first" is the fairer answer —
|
|
53
|
+
-- a task that failed and backed off should not cut ahead of everything submitted
|
|
54
|
+
-- while it waited, which is what its original created_at_ms bought it.
|
|
55
|
+
--
|
|
56
|
+
-- Both indexes are rebuilt inside the migration's write transaction, so every
|
|
57
|
+
-- other process's writes wait while they build — and they wait only as long as
|
|
58
|
+
-- their busy budget (`busyTimeoutMs` / `busy_timeout_ms`, 5s by default) before
|
|
59
|
+
-- failing. Measured on a warm local SSD: ~2.5s + ~3.4s for a 1M-row, 500MB
|
|
60
|
+
-- database, i.e. past that default. Small databases do not notice; see
|
|
61
|
+
-- "Upgrading" in the README for what to do about a large one.
|
|
62
|
+
--
|
|
63
|
+
-- Same names, new definitions: these indexes ARE "the ones the claim reads", and
|
|
64
|
+
-- every reference to them in the SQL comments and PROTOCOL.md still points at
|
|
65
|
+
-- the right object.
|
|
66
|
+
--
|
|
67
|
+
-- An SDK older than this migration keeps working: it orders by created_at_ms,
|
|
68
|
+
-- finds no index in that order, and sorts. Slower, exactly where this migration
|
|
69
|
+
-- is faster — but note the sharper consequence while a fleet is mixed. Two SDK
|
|
70
|
+
-- versions against one database then disagree about which task is NEXT for any
|
|
71
|
+
-- delayed or retried work: each takes a valid claimable task, no task is lost or
|
|
72
|
+
-- run twice, but the documented claim order holds only within one version. That
|
|
73
|
+
-- is a difference in fairness, not in correctness, which is why protocol_version
|
|
74
|
+
-- stays at 1 — and it is a reason to keep the mixed window short.
|
|
75
|
+
drop index if exists cairnq_tasks_claim_idx;
|
|
76
|
+
create index cairnq_tasks_claim_idx
|
|
77
|
+
on cairnq_tasks (queue, status, priority desc, run_at_ms, id);
|
|
78
|
+
|
|
79
|
+
-- The per-name twin (see 0006), rebuilt on the same principle.
|
|
80
|
+
drop index if exists cairnq_tasks_claim_name_idx;
|
|
81
|
+
create index cairnq_tasks_claim_name_idx
|
|
82
|
+
on cairnq_tasks (queue, status, name, priority desc, run_at_ms, id);
|
|
83
|
+
|
|
84
|
+
update cairnq_meta set value = '8' 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';
|
|
@@ -29,10 +29,13 @@ from (
|
|
|
29
29
|
and queue = any(:queues::text[])
|
|
30
30
|
and (:names::text[] is null or name = any(:names::text[]))
|
|
31
31
|
and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
|
|
32
|
-
--
|
|
33
|
-
--
|
|
32
|
+
-- Ordered by when a task became DUE, not when it was created: see migration
|
|
33
|
+
-- 0008 for why, for what it costs a mixed-version fleet, and for which of
|
|
34
|
+
-- these four statements the index can serve without a sort.
|
|
35
|
+
-- id breaks run_at_ms ties (same-millisecond submits), so claim order is
|
|
36
|
+
-- deterministic: FIFO at millisecond granularity; within one millisecond
|
|
34
37
|
-- the id's random half decides, stably but not in submit order.
|
|
35
|
-
order by priority desc,
|
|
38
|
+
order by priority desc, run_at_ms asc, id asc
|
|
36
39
|
limit :limit
|
|
37
40
|
for update skip locked
|
|
38
41
|
) sel
|
|
@@ -26,10 +26,13 @@ from (
|
|
|
26
26
|
and queue = any(:queues::text[])
|
|
27
27
|
and name = :name
|
|
28
28
|
and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
|
|
29
|
-
--
|
|
30
|
-
--
|
|
29
|
+
-- Ordered by when a task became DUE, not when it was created: see migration
|
|
30
|
+
-- 0008 for why, for what it costs a mixed-version fleet, and for which of
|
|
31
|
+
-- these four statements the index can serve without a sort.
|
|
32
|
+
-- id breaks run_at_ms ties (same-millisecond submits), so claim order is
|
|
33
|
+
-- deterministic: FIFO at millisecond granularity; within one millisecond
|
|
31
34
|
-- the id's random half decides, stably but not in submit order.
|
|
32
|
-
order by priority desc,
|
|
35
|
+
order by priority desc, run_at_ms asc, id asc
|
|
33
36
|
limit :limit
|
|
34
37
|
for update skip locked
|
|
35
38
|
) sel
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
-- + LIMIT over `= any(...)` the planner falls back to a sequential scan and a
|
|
8
8
|
-- full sort of every claimable row (measured on 20k queued: Seq Scan 20000 rows,
|
|
9
9
|
-- quicksort 1861kB), inside the transaction that holds the claim. The equality
|
|
10
|
-
-- form index-scans cairnq_tasks_claim_idx
|
|
11
|
-
-- tie-break — 33 rows read for the same
|
|
10
|
+
-- form index-scans cairnq_tasks_claim_idx, which since migration 0008 carries the
|
|
11
|
+
-- id tie-break too and so needs no sort node at all — 33 rows read for the same
|
|
12
|
+
-- query.
|
|
12
13
|
--
|
|
13
14
|
-- params: queue, names (text[] or null), worker_id, lease_ms, limit
|
|
14
15
|
update cairnq_tasks t
|
|
@@ -24,10 +25,13 @@ from (
|
|
|
24
25
|
and queue = :queue
|
|
25
26
|
and (:names::text[] is null or name = any(:names::text[]))
|
|
26
27
|
and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
|
|
27
|
-
--
|
|
28
|
-
--
|
|
28
|
+
-- Ordered by when a task became DUE, not when it was created: see migration
|
|
29
|
+
-- 0008 for why, for what it costs a mixed-version fleet, and for which of
|
|
30
|
+
-- these four statements the index can serve without a sort.
|
|
31
|
+
-- id breaks run_at_ms ties (same-millisecond submits), so claim order is
|
|
32
|
+
-- deterministic: FIFO at millisecond granularity; within one millisecond
|
|
29
33
|
-- the id's random half decides, stably but not in submit order.
|
|
30
|
-
order by priority desc,
|
|
34
|
+
order by priority desc, run_at_ms asc, id asc
|
|
31
35
|
limit :limit
|
|
32
36
|
for update skip locked
|
|
33
37
|
) sel
|
|
@@ -23,10 +23,13 @@ from (
|
|
|
23
23
|
and queue = :queue
|
|
24
24
|
and name = :name
|
|
25
25
|
and run_at_ms <= (extract(epoch from clock_timestamp()) * 1000)::bigint
|
|
26
|
-
--
|
|
27
|
-
--
|
|
26
|
+
-- Ordered by when a task became DUE, not when it was created: see migration
|
|
27
|
+
-- 0008 for why, for what it costs a mixed-version fleet, and for which of
|
|
28
|
+
-- these four statements the index can serve without a sort.
|
|
29
|
+
-- id breaks run_at_ms ties (same-millisecond submits), so claim order is
|
|
30
|
+
-- deterministic: FIFO at millisecond granularity; within one millisecond
|
|
28
31
|
-- the id's random half decides, stably but not in submit order.
|
|
29
|
-
order by priority desc,
|
|
32
|
+
order by priority desc, run_at_ms asc, id asc
|
|
30
33
|
limit :limit
|
|
31
34
|
for update skip locked
|
|
32
35
|
) sel
|
|
@@ -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
|
|
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
|
+
-- 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
|
-
--
|
|
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
|
+
-- 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;
|
|
@@ -22,10 +22,13 @@ where id in (
|
|
|
22
22
|
and queue in (select value from json_each(:queues))
|
|
23
23
|
and (:names is null or name in (select value from json_each(:names)))
|
|
24
24
|
and run_at_ms <= :now_ms
|
|
25
|
-
--
|
|
26
|
-
--
|
|
25
|
+
-- Ordered by when a task became DUE, not when it was created: see migration
|
|
26
|
+
-- 0008 for why, for what it costs a mixed-version fleet, and for which of
|
|
27
|
+
-- these four statements the index can serve without a sort.
|
|
28
|
+
-- id breaks run_at_ms ties (same-millisecond submits), so claim order is
|
|
29
|
+
-- deterministic: FIFO at millisecond granularity; within one millisecond
|
|
27
30
|
-- the id's random half decides, stably but not in submit order.
|
|
28
|
-
order by priority desc,
|
|
31
|
+
order by priority desc, run_at_ms asc, id asc
|
|
29
32
|
limit :limit
|
|
30
33
|
)
|
|
31
34
|
returning *;
|