cairnq 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_protocol/migrations/postgres/0010_drop_status_index.sql +29 -0
- package/dist/_protocol/migrations/sqlite/0010_drop_status_index.sql +29 -0
- package/dist/_protocol/sql/postgres/purge.sql +14 -0
- package/dist/_protocol/sql/postgres/stats.sql +2 -0
- package/dist/_protocol/sql/sqlite/purge.sql +14 -0
- package/dist/_protocol/sql/sqlite/stats.sql +2 -0
- package/dist/store/base.d.ts +18 -0
- package/dist/store/base.js +48 -19
- package/dist/store/pg-executor.d.ts +27 -12
- package/dist/store/pg-executor.js +27 -12
- package/dist/store/postgres.js +3 -3
- package/dist/store/sqlite.d.ts +7 -0
- package/dist/store/sqlite.js +21 -7
- package/package.json +1 -1
- package/src/store/base.ts +50 -20
- package/src/store/pg-executor.ts +27 -12
- package/src/store/postgres.ts +3 -2
- package/src/store/sqlite.ts +21 -6
- package/dist/_protocol/sql/postgres/purge_one_queue.sql +0 -42
- package/dist/_protocol/sql/postgres/purge_one_queue_one_status.sql +0 -42
- package/dist/_protocol/sql/postgres/purge_one_status.sql +0 -42
- package/dist/_protocol/sql/postgres/stats_one_queue.sql +0 -21
- package/dist/_protocol/sql/sqlite/purge_one_queue.sql +0 -41
- package/dist/_protocol/sql/sqlite/purge_one_queue_one_status.sql +0 -41
- package/dist/_protocol/sql/sqlite/purge_one_status.sql +0 -41
- package/dist/_protocol/sql/sqlite/stats_one_queue.sql +0 -21
|
@@ -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,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';
|
|
@@ -21,6 +21,20 @@
|
|
|
21
21
|
-- durable job's log kept for a week. Migration 0009 adds the index that makes
|
|
22
22
|
-- the queue filter read only its own queue's rows rather than skipping past
|
|
23
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.
|
|
24
38
|
-- params: older_than_ms, queue, status, name, limit
|
|
25
39
|
delete from cairnq_tasks
|
|
26
40
|
where id in (
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
-- counted, which is the whole queue, terminal rows included. That is fine for a
|
|
14
14
|
-- dashboard and wrong for a poll loop — queue_depth.sql is the bounded question,
|
|
15
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.
|
|
16
18
|
-- params: queue
|
|
17
19
|
select queue, status, count(*) as count
|
|
18
20
|
from cairnq_tasks
|
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
-- result read once and a durable job's log kept for a week. Migration 0009 adds
|
|
16
16
|
-- the index that makes the queue filter read only its own queue's rows rather
|
|
17
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.
|
|
18
32
|
-- params: before_ms, queue, status, name, limit
|
|
19
33
|
delete from cairnq_tasks
|
|
20
34
|
where id in (
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
-- counted, which is the whole queue, terminal rows included. That is fine for a
|
|
14
14
|
-- dashboard and wrong for a poll loop — queue_depth.sql is the bounded question,
|
|
15
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.
|
|
16
18
|
-- params: queue
|
|
17
19
|
select queue, status, count(*) as count
|
|
18
20
|
from cairnq_tasks
|
package/dist/store/base.d.ts
CHANGED
|
@@ -70,6 +70,24 @@ export declare const LEASE_EXPIRED_ERROR_JSON: string;
|
|
|
70
70
|
export declare const COMMENT: RegExp;
|
|
71
71
|
/** A `:name` placeholder. The lookbehind spares Postgres `::type` casts. */
|
|
72
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;
|
|
73
91
|
/**
|
|
74
92
|
* The parameter names a statement binds, in first-appearance order.
|
|
75
93
|
*
|
package/dist/store/base.js
CHANGED
|
@@ -98,10 +98,56 @@ export const LEASE_EXPIRED_ERROR_JSON = dumpJson(errorEnvelope({
|
|
|
98
98
|
export const COMMENT = /--[^\n]*/g;
|
|
99
99
|
/** A `:name` placeholder. The lookbehind spares Postgres `::type` casts. */
|
|
100
100
|
export const NAMED = /(?<!:):(\w+)/g;
|
|
101
|
+
/**
|
|
102
|
+
* An optional filter: `(:p is null or col = :p)`, with the `::type` cast the
|
|
103
|
+
* Postgres dialect adds to pin the parameter's type. The back-reference is what
|
|
104
|
+
* keeps it from matching anything else — both halves must name the same
|
|
105
|
+
* parameter. claim's `(:names is null or name in (…))` deliberately does not
|
|
106
|
+
* match: a list-valued filter is a different problem, and claim_one_name.sql is
|
|
107
|
+
* its answer.
|
|
108
|
+
*/
|
|
109
|
+
const OPTIONAL_FILTER = /\(:(\w+)(?:::[\w[\]]+)? is null or (\S+) = :\1\)/g;
|
|
101
110
|
// Statement text is loaded once at construction and never varies, so the parse is
|
|
102
111
|
// memoized on it: every dialect's binding path runs on each query, and re-scanning
|
|
103
112
|
// the SQL each time would put a regex sweep on the worker's poll loop.
|
|
104
113
|
const paramCache = new Map();
|
|
114
|
+
// Same argument for the specialized texts, which vary only by WHICH filters a
|
|
115
|
+
// caller supplied — a small, bounded set per statement, reached within the first
|
|
116
|
+
// few calls and constant thereafter.
|
|
117
|
+
const specialCache = new Map();
|
|
118
|
+
/**
|
|
119
|
+
* The statement as it should run for these arguments: every optional filter the
|
|
120
|
+
* caller actually supplied rewritten to an equality.
|
|
121
|
+
*
|
|
122
|
+
* `(:p is null or col = :p)` cannot use an index. SQLite plans a statement when
|
|
123
|
+
* it is prepared, before any parameter has a value, so it must plan for both
|
|
124
|
+
* branches and settles for a scan; that is not a tuning detail but the whole
|
|
125
|
+
* difference between `list(root_id=…)` seeking cairnq_tasks_root_idx and reading
|
|
126
|
+
* the table. Postgres re-plans with the values for a statement's first
|
|
127
|
+
* executions and folds the branch away on its own, so this is a no-op there —
|
|
128
|
+
* but it costs nothing, and one behaviour is easier to reason about than two.
|
|
129
|
+
*
|
|
130
|
+
* Filters the caller did NOT supply are left alone rather than removed: a
|
|
131
|
+
* constant-true term costs a per-row evaluation the planner mostly discards, and
|
|
132
|
+
* leaving them keeps the parameter set identical to the file's, so the binding
|
|
133
|
+
* path below needs to know nothing about any of this.
|
|
134
|
+
*/
|
|
135
|
+
export function specialize(sql, params) {
|
|
136
|
+
let active = "";
|
|
137
|
+
for (const [, name] of sql.matchAll(OPTIONAL_FILTER)) {
|
|
138
|
+
if (params[name] != null)
|
|
139
|
+
active += name + ",";
|
|
140
|
+
}
|
|
141
|
+
if (!active)
|
|
142
|
+
return sql;
|
|
143
|
+
const key = active + sql;
|
|
144
|
+
let out = specialCache.get(key);
|
|
145
|
+
if (out === undefined) {
|
|
146
|
+
out = sql.replace(OPTIONAL_FILTER, (whole, name, column) => params[name] != null ? `${column} = :${name}` : whole);
|
|
147
|
+
specialCache.set(key, out);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
105
151
|
/**
|
|
106
152
|
* The parameter names a statement binds, in first-appearance order.
|
|
107
153
|
*
|
|
@@ -335,20 +381,7 @@ export class TaskStore {
|
|
|
335
381
|
*/
|
|
336
382
|
async purge(input = {}) {
|
|
337
383
|
validatePurgeInput(input);
|
|
338
|
-
|
|
339
|
-
// claimSession makes between claim and its specializations, for the same
|
|
340
|
-
// reason. `(:queue is null or queue = :queue)` is planned before any
|
|
341
|
-
// parameter has a value, so on SQLite it can use no index at all and the
|
|
342
|
-
// sweep walks every row past the cutoff, whichever queue it belongs to.
|
|
343
|
-
// See purge_one_queue.sql. `name` is not specialized: nothing indexes it.
|
|
344
|
-
const statement = input.queue != null
|
|
345
|
-
? input.status != null
|
|
346
|
-
? "purge_one_queue_one_status"
|
|
347
|
-
: "purge_one_queue"
|
|
348
|
-
: input.status != null
|
|
349
|
-
? "purge_one_status"
|
|
350
|
-
: "purge";
|
|
351
|
-
const rows = await this.fetch(statement, {
|
|
384
|
+
const rows = await this.fetch("purge", {
|
|
352
385
|
older_than_ms: input.olderThanMs ?? 0,
|
|
353
386
|
queue: input.queue ?? null,
|
|
354
387
|
status: input.status ?? null,
|
|
@@ -382,11 +415,7 @@ export class TaskStore {
|
|
|
382
415
|
// rows to seed from, and that is exactly the case the promise is about.
|
|
383
416
|
if (queue != null)
|
|
384
417
|
out[queue] = zeros();
|
|
385
|
-
|
|
386
|
-
// optional filter cannot be indexed, so the "narrowed" form would read the
|
|
387
|
-
// whole table anyway and narrowing would buy nothing.
|
|
388
|
-
const statement = queue != null ? "stats_one_queue" : "stats";
|
|
389
|
-
for (const row of await this.fetch(statement, { queue: queue ?? null })) {
|
|
418
|
+
for (const row of await this.fetch("stats", { queue: queue ?? null })) {
|
|
390
419
|
const per = (out[row.queue] ??= zeros());
|
|
391
420
|
per[row.status] = Number(row.count);
|
|
392
421
|
}
|
|
@@ -17,18 +17,33 @@
|
|
|
17
17
|
*
|
|
18
18
|
* ONE thing an adapter does have to get right: enable the driver's
|
|
19
19
|
* prepared-statement path if it is not already the default. The store issues a
|
|
20
|
-
* small, FIXED set of statement texts —
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
20
|
+
* small, FIXED set of statement texts — each is loaded from a file at startup,
|
|
21
|
+
* `specialize` may return one variant of it per set of optional filters a caller
|
|
22
|
+
* supplies, and the `:name` -> `$n` rewrite is memoized on the result. So the
|
|
23
|
+
* same handful of strings is submitted for the life of the process, reached
|
|
24
|
+
* within the first few calls, and nothing here ever interpolates a value into
|
|
25
|
+
* SQL. That is exactly the shape a server-side
|
|
26
|
+
* prepared statement is for. A driver that instead describes each statement
|
|
27
|
+
* before binding pays an extra round trip and a re-parse every time.
|
|
28
|
+
*
|
|
29
|
+
* The cost is **per statement**, and that is the number to reason with: measured
|
|
30
|
+
* on postgres.js, whose `unsafe()` defaults to `prepare: false`, one statement
|
|
31
|
+
* run 2000 times went 0.52ms -> 0.19ms once preparation was on — about 2.8x, or
|
|
32
|
+
* ~0.33ms a statement. Per task it disappears: the same measurement through a
|
|
33
|
+
* whole `call` round trip moved 104.9ms -> 102.9ms, and against a real handler
|
|
34
|
+
* 17ms -> 16ms. A task costs what its handler costs, and a couple of statements
|
|
35
|
+
* either way is noise next to that.
|
|
36
|
+
*
|
|
37
|
+
* Where it does land is the bookkeeping that runs whether or not there is any
|
|
38
|
+
* work — the claim loop, `recover_leases`, heartbeats, `wait`'s polling. Those
|
|
39
|
+
* are pure statement cost with no handler to hide behind, which is also why this
|
|
40
|
+
* multiplies with `claimable_probe` rather than being independent of it: the
|
|
41
|
+
* probe cuts how many statements an idle poll issues, this cuts what each one
|
|
42
|
+
* costs. A fleet that is mostly idle pays almost nothing else.
|
|
43
|
+
*
|
|
44
|
+
* The reference implementation happens not to be affected — `pg` uses the
|
|
45
|
+
* extended protocol by default — which is why this is stated here rather than
|
|
46
|
+
* left to be discovered.
|
|
32
47
|
*/
|
|
33
48
|
/** A row as the driver hands it back: column name -> value. */
|
|
34
49
|
export type Row = Record<string, unknown>;
|
|
@@ -17,18 +17,33 @@
|
|
|
17
17
|
*
|
|
18
18
|
* ONE thing an adapter does have to get right: enable the driver's
|
|
19
19
|
* prepared-statement path if it is not already the default. The store issues a
|
|
20
|
-
* small, FIXED set of statement texts —
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
20
|
+
* small, FIXED set of statement texts — each is loaded from a file at startup,
|
|
21
|
+
* `specialize` may return one variant of it per set of optional filters a caller
|
|
22
|
+
* supplies, and the `:name` -> `$n` rewrite is memoized on the result. So the
|
|
23
|
+
* same handful of strings is submitted for the life of the process, reached
|
|
24
|
+
* within the first few calls, and nothing here ever interpolates a value into
|
|
25
|
+
* SQL. That is exactly the shape a server-side
|
|
26
|
+
* prepared statement is for. A driver that instead describes each statement
|
|
27
|
+
* before binding pays an extra round trip and a re-parse every time.
|
|
28
|
+
*
|
|
29
|
+
* The cost is **per statement**, and that is the number to reason with: measured
|
|
30
|
+
* on postgres.js, whose `unsafe()` defaults to `prepare: false`, one statement
|
|
31
|
+
* run 2000 times went 0.52ms -> 0.19ms once preparation was on — about 2.8x, or
|
|
32
|
+
* ~0.33ms a statement. Per task it disappears: the same measurement through a
|
|
33
|
+
* whole `call` round trip moved 104.9ms -> 102.9ms, and against a real handler
|
|
34
|
+
* 17ms -> 16ms. A task costs what its handler costs, and a couple of statements
|
|
35
|
+
* either way is noise next to that.
|
|
36
|
+
*
|
|
37
|
+
* Where it does land is the bookkeeping that runs whether or not there is any
|
|
38
|
+
* work — the claim loop, `recover_leases`, heartbeats, `wait`'s polling. Those
|
|
39
|
+
* are pure statement cost with no handler to hide behind, which is also why this
|
|
40
|
+
* multiplies with `claimable_probe` rather than being independent of it: the
|
|
41
|
+
* probe cuts how many statements an idle poll issues, this cuts what each one
|
|
42
|
+
* costs. A fleet that is mostly idle pays almost nothing else.
|
|
43
|
+
*
|
|
44
|
+
* The reference implementation happens not to be affected — `pg` uses the
|
|
45
|
+
* extended protocol by default — which is why this is stated here rather than
|
|
46
|
+
* left to be discovered.
|
|
32
47
|
*/
|
|
33
48
|
/**
|
|
34
49
|
* LISTEN will not work on this connection, and retrying cannot change that.
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SchemaMismatch } from "../errors.js";
|
|
2
2
|
import { loadMigrations, loadStatements } from "../sql.js";
|
|
3
|
-
import { checkProtocolVersion, COMMENT, NAMED, statementParams, TaskStore, } from "./base.js";
|
|
3
|
+
import { checkProtocolVersion, COMMENT, NAMED, specialize, statementParams, TaskStore, } from "./base.js";
|
|
4
4
|
import { ListenUnavailable } from "./pg-executor.js";
|
|
5
5
|
import { createPoolExecutor } from "./pg-pool.js";
|
|
6
6
|
// Notification channels, emitted by the 0003_notify trigger.
|
|
@@ -461,7 +461,7 @@ export class PostgresStore extends TaskStore {
|
|
|
461
461
|
// ------------------------------------------------------------ dialect seam
|
|
462
462
|
async fetch(name, params) {
|
|
463
463
|
await this.ensure();
|
|
464
|
-
const { text, values } = toPositional(this.statements[name], params);
|
|
464
|
+
const { text, values } = toPositional(specialize(this.statements[name], params), params);
|
|
465
465
|
return this.executor.query(text, values);
|
|
466
466
|
}
|
|
467
467
|
async tx(fn) {
|
|
@@ -475,7 +475,7 @@ export class PostgresStore extends TaskStore {
|
|
|
475
475
|
/** A Fetch that runs the protocol's statements on one particular session. */
|
|
476
476
|
boundFetch(s) {
|
|
477
477
|
return async (name, params) => {
|
|
478
|
-
const { text, values } = toPositional(this.statements[name], params);
|
|
478
|
+
const { text, values } = toPositional(specialize(this.statements[name], params), params);
|
|
479
479
|
return s.query(text, values);
|
|
480
480
|
};
|
|
481
481
|
}
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ import { type Fetch, type Params, TaskStore } from "./base.js";
|
|
|
27
27
|
export declare class SQLiteStore extends TaskStore {
|
|
28
28
|
private readonly path;
|
|
29
29
|
private db;
|
|
30
|
+
/**
|
|
31
|
+
* Prepared statements, keyed by the SQL they were prepared from rather than by
|
|
32
|
+
* statement name: `specialize` gives a statement one text per set of optional
|
|
33
|
+
* filters a caller supplies, and each of those is its own prepared plan — which
|
|
34
|
+
* is the entire point, since the plan is what the specialization changes.
|
|
35
|
+
* Bounded by the statement set times the filter combinations actually used.
|
|
36
|
+
*/
|
|
30
37
|
private stmts;
|
|
31
38
|
private readonly statements;
|
|
32
39
|
/** This store's entry in `fileLocks` — see there for why it is per-database. */
|
package/dist/store/sqlite.js
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { nowMs } from "../ids.js";
|
|
5
5
|
import { loadMigrations, loadStatements } from "../sql.js";
|
|
6
|
-
import { checkProtocolVersion, COMMENT, statementParams, TaskStore, } from "./base.js";
|
|
6
|
+
import { checkProtocolVersion, COMMENT, specialize, statementParams, TaskStore, } from "./base.js";
|
|
7
7
|
// `better-sqlite3` is an optional dependency, matching `pg` on the Postgres side:
|
|
8
8
|
// a Postgres-only deployment should not have to build a native module it never
|
|
9
9
|
// loads, and importing this file must not pull one in. Required (not imported)
|
|
@@ -194,7 +194,14 @@ function enableWal(db) {
|
|
|
194
194
|
export class SQLiteStore extends TaskStore {
|
|
195
195
|
path;
|
|
196
196
|
db = null;
|
|
197
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Prepared statements, keyed by the SQL they were prepared from rather than by
|
|
199
|
+
* statement name: `specialize` gives a statement one text per set of optional
|
|
200
|
+
* filters a caller supplies, and each of those is its own prepared plan — which
|
|
201
|
+
* is the entire point, since the plan is what the specialization changes.
|
|
202
|
+
* Bounded by the statement set times the filter combinations actually used.
|
|
203
|
+
*/
|
|
204
|
+
stmts = new Map();
|
|
198
205
|
statements;
|
|
199
206
|
/** This store's entry in `fileLocks` — see there for why it is per-database. */
|
|
200
207
|
lockKey;
|
|
@@ -230,7 +237,7 @@ export class SQLiteStore extends TaskStore {
|
|
|
230
237
|
if (this.db) {
|
|
231
238
|
this.db.close();
|
|
232
239
|
this.db = null;
|
|
233
|
-
this.stmts
|
|
240
|
+
this.stmts.clear();
|
|
234
241
|
}
|
|
235
242
|
}
|
|
236
243
|
ensure() {
|
|
@@ -264,8 +271,10 @@ export class SQLiteStore extends TaskStore {
|
|
|
264
271
|
throw err;
|
|
265
272
|
}
|
|
266
273
|
this.nextStatsRefreshAt = Date.now() + STATS_REFRESH_INTERVAL_MS;
|
|
267
|
-
|
|
268
|
-
|
|
274
|
+
// Warm the unfiltered texts, which every statement has and most callers use.
|
|
275
|
+
// The specialized ones prepare on first use; see `stmts`.
|
|
276
|
+
for (const sql of Object.values(this.statements)) {
|
|
277
|
+
this.stmts.set(sql, db.prepare(sql));
|
|
269
278
|
}
|
|
270
279
|
this.db = db;
|
|
271
280
|
checkProtocolVersion(this.readProtocolVersion());
|
|
@@ -352,8 +361,13 @@ export class SQLiteStore extends TaskStore {
|
|
|
352
361
|
return bound;
|
|
353
362
|
}
|
|
354
363
|
runNow(name, params) {
|
|
355
|
-
const
|
|
356
|
-
|
|
364
|
+
const sql = specialize(this.statements[name], params);
|
|
365
|
+
let stmt = this.stmts.get(sql);
|
|
366
|
+
if (!stmt) {
|
|
367
|
+
stmt = this.db.prepare(sql);
|
|
368
|
+
this.stmts.set(sql, stmt);
|
|
369
|
+
}
|
|
370
|
+
const bound = this.bind(sql, params);
|
|
357
371
|
// Nearly every protocol statement ends in RETURNING; upsert_key does not, and
|
|
358
372
|
// better-sqlite3 refuses .all() on a statement that yields no rows.
|
|
359
373
|
if (!stmt.reader) {
|
package/package.json
CHANGED
package/src/store/base.ts
CHANGED
|
@@ -174,10 +174,58 @@ export const COMMENT = /--[^\n]*/g;
|
|
|
174
174
|
/** A `:name` placeholder. The lookbehind spares Postgres `::type` casts. */
|
|
175
175
|
export const NAMED = /(?<!:):(\w+)/g;
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* An optional filter: `(:p is null or col = :p)`, with the `::type` cast the
|
|
179
|
+
* Postgres dialect adds to pin the parameter's type. The back-reference is what
|
|
180
|
+
* keeps it from matching anything else — both halves must name the same
|
|
181
|
+
* parameter. claim's `(:names is null or name in (…))` deliberately does not
|
|
182
|
+
* match: a list-valued filter is a different problem, and claim_one_name.sql is
|
|
183
|
+
* its answer.
|
|
184
|
+
*/
|
|
185
|
+
const OPTIONAL_FILTER = /\(:(\w+)(?:::[\w[\]]+)? is null or (\S+) = :\1\)/g;
|
|
186
|
+
|
|
177
187
|
// Statement text is loaded once at construction and never varies, so the parse is
|
|
178
188
|
// memoized on it: every dialect's binding path runs on each query, and re-scanning
|
|
179
189
|
// the SQL each time would put a regex sweep on the worker's poll loop.
|
|
180
190
|
const paramCache = new Map<string, readonly string[]>();
|
|
191
|
+
// Same argument for the specialized texts, which vary only by WHICH filters a
|
|
192
|
+
// caller supplied — a small, bounded set per statement, reached within the first
|
|
193
|
+
// few calls and constant thereafter.
|
|
194
|
+
const specialCache = new Map<string, string>();
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The statement as it should run for these arguments: every optional filter the
|
|
198
|
+
* caller actually supplied rewritten to an equality.
|
|
199
|
+
*
|
|
200
|
+
* `(:p is null or col = :p)` cannot use an index. SQLite plans a statement when
|
|
201
|
+
* it is prepared, before any parameter has a value, so it must plan for both
|
|
202
|
+
* branches and settles for a scan; that is not a tuning detail but the whole
|
|
203
|
+
* difference between `list(root_id=…)` seeking cairnq_tasks_root_idx and reading
|
|
204
|
+
* the table. Postgres re-plans with the values for a statement's first
|
|
205
|
+
* executions and folds the branch away on its own, so this is a no-op there —
|
|
206
|
+
* but it costs nothing, and one behaviour is easier to reason about than two.
|
|
207
|
+
*
|
|
208
|
+
* Filters the caller did NOT supply are left alone rather than removed: a
|
|
209
|
+
* constant-true term costs a per-row evaluation the planner mostly discards, and
|
|
210
|
+
* leaving them keeps the parameter set identical to the file's, so the binding
|
|
211
|
+
* path below needs to know nothing about any of this.
|
|
212
|
+
*/
|
|
213
|
+
export function specialize(sql: string, params: Params): string {
|
|
214
|
+
let active = "";
|
|
215
|
+
for (const [, name] of sql.matchAll(OPTIONAL_FILTER)) {
|
|
216
|
+
if (params[name] != null) active += name + ",";
|
|
217
|
+
}
|
|
218
|
+
if (!active) return sql;
|
|
219
|
+
const key = active + sql;
|
|
220
|
+
let out = specialCache.get(key);
|
|
221
|
+
if (out === undefined) {
|
|
222
|
+
out = sql.replace(OPTIONAL_FILTER, (whole, name: string, column: string) =>
|
|
223
|
+
params[name] != null ? `${column} = :${name}` : whole,
|
|
224
|
+
);
|
|
225
|
+
specialCache.set(key, out);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
181
229
|
|
|
182
230
|
/**
|
|
183
231
|
* The parameter names a statement binds, in first-appearance order.
|
|
@@ -522,21 +570,7 @@ export abstract class TaskStore {
|
|
|
522
570
|
*/
|
|
523
571
|
async purge(input: PurgeInput = {}): Promise<string[]> {
|
|
524
572
|
validatePurgeInput(input);
|
|
525
|
-
|
|
526
|
-
// claimSession makes between claim and its specializations, for the same
|
|
527
|
-
// reason. `(:queue is null or queue = :queue)` is planned before any
|
|
528
|
-
// parameter has a value, so on SQLite it can use no index at all and the
|
|
529
|
-
// sweep walks every row past the cutoff, whichever queue it belongs to.
|
|
530
|
-
// See purge_one_queue.sql. `name` is not specialized: nothing indexes it.
|
|
531
|
-
const statement =
|
|
532
|
-
input.queue != null
|
|
533
|
-
? input.status != null
|
|
534
|
-
? "purge_one_queue_one_status"
|
|
535
|
-
: "purge_one_queue"
|
|
536
|
-
: input.status != null
|
|
537
|
-
? "purge_one_status"
|
|
538
|
-
: "purge";
|
|
539
|
-
const rows = await this.fetch(statement, {
|
|
573
|
+
const rows = await this.fetch("purge", {
|
|
540
574
|
older_than_ms: input.olderThanMs ?? 0,
|
|
541
575
|
queue: input.queue ?? null,
|
|
542
576
|
status: input.status ?? null,
|
|
@@ -571,11 +605,7 @@ export abstract class TaskStore {
|
|
|
571
605
|
// Seed before the query, not after: a named queue with no rows returns no
|
|
572
606
|
// rows to seed from, and that is exactly the case the promise is about.
|
|
573
607
|
if (queue != null) out[queue] = zeros();
|
|
574
|
-
|
|
575
|
-
// optional filter cannot be indexed, so the "narrowed" form would read the
|
|
576
|
-
// whole table anyway and narrowing would buy nothing.
|
|
577
|
-
const statement = queue != null ? "stats_one_queue" : "stats";
|
|
578
|
-
for (const row of await this.fetch(statement, { queue: queue ?? null })) {
|
|
608
|
+
for (const row of await this.fetch("stats", { queue: queue ?? null })) {
|
|
579
609
|
const per = (out[row.queue] ??= zeros());
|
|
580
610
|
per[row.status as TaskStatus] = Number(row.count);
|
|
581
611
|
}
|
package/src/store/pg-executor.ts
CHANGED
|
@@ -17,18 +17,33 @@
|
|
|
17
17
|
*
|
|
18
18
|
* ONE thing an adapter does have to get right: enable the driver's
|
|
19
19
|
* prepared-statement path if it is not already the default. The store issues a
|
|
20
|
-
* small, FIXED set of statement texts —
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
20
|
+
* small, FIXED set of statement texts — each is loaded from a file at startup,
|
|
21
|
+
* `specialize` may return one variant of it per set of optional filters a caller
|
|
22
|
+
* supplies, and the `:name` -> `$n` rewrite is memoized on the result. So the
|
|
23
|
+
* same handful of strings is submitted for the life of the process, reached
|
|
24
|
+
* within the first few calls, and nothing here ever interpolates a value into
|
|
25
|
+
* SQL. That is exactly the shape a server-side
|
|
26
|
+
* prepared statement is for. A driver that instead describes each statement
|
|
27
|
+
* before binding pays an extra round trip and a re-parse every time.
|
|
28
|
+
*
|
|
29
|
+
* The cost is **per statement**, and that is the number to reason with: measured
|
|
30
|
+
* on postgres.js, whose `unsafe()` defaults to `prepare: false`, one statement
|
|
31
|
+
* run 2000 times went 0.52ms -> 0.19ms once preparation was on — about 2.8x, or
|
|
32
|
+
* ~0.33ms a statement. Per task it disappears: the same measurement through a
|
|
33
|
+
* whole `call` round trip moved 104.9ms -> 102.9ms, and against a real handler
|
|
34
|
+
* 17ms -> 16ms. A task costs what its handler costs, and a couple of statements
|
|
35
|
+
* either way is noise next to that.
|
|
36
|
+
*
|
|
37
|
+
* Where it does land is the bookkeeping that runs whether or not there is any
|
|
38
|
+
* work — the claim loop, `recover_leases`, heartbeats, `wait`'s polling. Those
|
|
39
|
+
* are pure statement cost with no handler to hide behind, which is also why this
|
|
40
|
+
* multiplies with `claimable_probe` rather than being independent of it: the
|
|
41
|
+
* probe cuts how many statements an idle poll issues, this cuts what each one
|
|
42
|
+
* costs. A fleet that is mostly idle pays almost nothing else.
|
|
43
|
+
*
|
|
44
|
+
* The reference implementation happens not to be affected — `pg` uses the
|
|
45
|
+
* extended protocol by default — which is why this is stated here rather than
|
|
46
|
+
* left to be discovered.
|
|
32
47
|
*/
|
|
33
48
|
|
|
34
49
|
/** A row as the driver hands it back: column name -> value. */
|
package/src/store/postgres.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type Fetch,
|
|
7
7
|
NAMED,
|
|
8
8
|
type Params,
|
|
9
|
+
specialize,
|
|
9
10
|
statementParams,
|
|
10
11
|
TaskStore,
|
|
11
12
|
type WatchSignal,
|
|
@@ -488,7 +489,7 @@ export class PostgresStore extends TaskStore {
|
|
|
488
489
|
// ------------------------------------------------------------ dialect seam
|
|
489
490
|
protected async fetch(name: string, params: Params): Promise<any[]> {
|
|
490
491
|
await this.ensure();
|
|
491
|
-
const { text, values } = toPositional(this.statements[name], params);
|
|
492
|
+
const { text, values } = toPositional(specialize(this.statements[name], params), params);
|
|
492
493
|
return this.executor!.query(text, values);
|
|
493
494
|
}
|
|
494
495
|
|
|
@@ -507,7 +508,7 @@ export class PostgresStore extends TaskStore {
|
|
|
507
508
|
/** A Fetch that runs the protocol's statements on one particular session. */
|
|
508
509
|
private boundFetch(s: PgSession): Fetch {
|
|
509
510
|
return async (name, params) => {
|
|
510
|
-
const { text, values } = toPositional(this.statements[name], params);
|
|
511
|
+
const { text, values } = toPositional(specialize(this.statements[name], params), params);
|
|
511
512
|
return s.query(text, values);
|
|
512
513
|
};
|
|
513
514
|
}
|
package/src/store/sqlite.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
COMMENT,
|
|
13
13
|
type Fetch,
|
|
14
14
|
type Params,
|
|
15
|
+
specialize,
|
|
15
16
|
statementParams,
|
|
16
17
|
TaskStore,
|
|
17
18
|
} from "./base.js";
|
|
@@ -231,7 +232,14 @@ function enableWal(db: DB): void {
|
|
|
231
232
|
*/
|
|
232
233
|
export class SQLiteStore extends TaskStore {
|
|
233
234
|
private db: DB | null = null;
|
|
234
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Prepared statements, keyed by the SQL they were prepared from rather than by
|
|
237
|
+
* statement name: `specialize` gives a statement one text per set of optional
|
|
238
|
+
* filters a caller supplies, and each of those is its own prepared plan — which
|
|
239
|
+
* is the entire point, since the plan is what the specialization changes.
|
|
240
|
+
* Bounded by the statement set times the filter combinations actually used.
|
|
241
|
+
*/
|
|
242
|
+
private stmts = new Map<string, Stmt>();
|
|
235
243
|
private readonly statements: Record<string, string>;
|
|
236
244
|
/** This store's entry in `fileLocks` — see there for why it is per-database. */
|
|
237
245
|
private readonly lockKey: string;
|
|
@@ -274,7 +282,7 @@ export class SQLiteStore extends TaskStore {
|
|
|
274
282
|
if (this.db) {
|
|
275
283
|
this.db.close();
|
|
276
284
|
this.db = null;
|
|
277
|
-
this.stmts
|
|
285
|
+
this.stmts.clear();
|
|
278
286
|
}
|
|
279
287
|
}
|
|
280
288
|
|
|
@@ -304,8 +312,10 @@ export class SQLiteStore extends TaskStore {
|
|
|
304
312
|
if (!isBusy(err)) throw err;
|
|
305
313
|
}
|
|
306
314
|
this.nextStatsRefreshAt = Date.now() + STATS_REFRESH_INTERVAL_MS;
|
|
307
|
-
|
|
308
|
-
|
|
315
|
+
// Warm the unfiltered texts, which every statement has and most callers use.
|
|
316
|
+
// The specialized ones prepare on first use; see `stmts`.
|
|
317
|
+
for (const sql of Object.values(this.statements)) {
|
|
318
|
+
this.stmts.set(sql, db.prepare(sql));
|
|
309
319
|
}
|
|
310
320
|
this.db = db;
|
|
311
321
|
checkProtocolVersion(this.readProtocolVersion());
|
|
@@ -400,8 +410,13 @@ export class SQLiteStore extends TaskStore {
|
|
|
400
410
|
}
|
|
401
411
|
|
|
402
412
|
private runNow(name: string, params: Params): any[] {
|
|
403
|
-
const
|
|
404
|
-
|
|
413
|
+
const sql = specialize(this.statements[name], params);
|
|
414
|
+
let stmt = this.stmts.get(sql);
|
|
415
|
+
if (!stmt) {
|
|
416
|
+
stmt = this.db!.prepare(sql);
|
|
417
|
+
this.stmts.set(sql, stmt);
|
|
418
|
+
}
|
|
419
|
+
const bound = this.bind(sql, params);
|
|
405
420
|
// Nearly every protocol statement ends in RETURNING; upsert_key does not, and
|
|
406
421
|
// better-sqlite3 refuses .all() on a statement that yields no rows.
|
|
407
422
|
if (!stmt.reader) {
|
|
@@ -1,42 +0,0 @@
|
|
|
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;
|
|
@@ -1,42 +0,0 @@
|
|
|
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;
|
|
@@ -1,42 +0,0 @@
|
|
|
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,21 +0,0 @@
|
|
|
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;
|
|
@@ -1,41 +0,0 @@
|
|
|
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;
|
|
@@ -1,41 +0,0 @@
|
|
|
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;
|
|
@@ -1,41 +0,0 @@
|
|
|
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,21 +0,0 @@
|
|
|
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;
|