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.
@@ -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
@@ -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
  *
@@ -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
- // Each optional filter has an equality form, picked here — the same trade
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
- // Equality form when a queue was named see stats_one_queue.sql: the
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 — every one is loaded from a file at
21
- * startup and the `:name` -> `$n` rewrite is memoized on it, so the same handful
22
- * of strings is submitted for the life of the process, and nothing here ever
23
- * interpolates a value into SQL. That is exactly the shape a server-side
24
- * prepared statement is for, and the worker's poll loop reruns those texts
25
- * forever. A driver that instead describes each statement before binding pays an
26
- * extra round trip and a re-parse on every call: measured on one such adapter
27
- * (postgres.js, whose `unsafe()` defaults to `prepare: false`), the same hot
28
- * path went 137ms -> 17ms per round trip once preparation was turned on. The
29
- * reference implementation happens not to be affected `pg` uses the extended
30
- * protocol by default which is why this is stated here rather than left to be
31
- * discovered.
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 — every one is loaded from a file at
21
- * startup and the `:name` -> `$n` rewrite is memoized on it, so the same handful
22
- * of strings is submitted for the life of the process, and nothing here ever
23
- * interpolates a value into SQL. That is exactly the shape a server-side
24
- * prepared statement is for, and the worker's poll loop reruns those texts
25
- * forever. A driver that instead describes each statement before binding pays an
26
- * extra round trip and a re-parse on every call: measured on one such adapter
27
- * (postgres.js, whose `unsafe()` defaults to `prepare: false`), the same hot
28
- * path went 137ms -> 17ms per round trip once preparation was turned on. The
29
- * reference implementation happens not to be affected `pg` uses the extended
30
- * protocol by default which is why this is stated here rather than left to be
31
- * discovered.
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.
@@ -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
  }
@@ -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. */
@@ -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
- stmts = {};
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
- for (const [name, sql] of Object.entries(this.statements)) {
268
- this.stmts[name] = db.prepare(sql);
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 stmt = this.stmts[name];
356
- const bound = this.bind(this.statements[name], params);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairnq",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "SQLite-first, cross-language, storage-centered durable task runtime",
5
5
  "license": "MIT",
6
6
  "author": "Jannchie <jannchie@gmail.com>",
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
- // Each optional filter has an equality form, picked here — the same trade
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
- // Equality form when a queue was named see stats_one_queue.sql: the
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
  }
@@ -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 — every one is loaded from a file at
21
- * startup and the `:name` -> `$n` rewrite is memoized on it, so the same handful
22
- * of strings is submitted for the life of the process, and nothing here ever
23
- * interpolates a value into SQL. That is exactly the shape a server-side
24
- * prepared statement is for, and the worker's poll loop reruns those texts
25
- * forever. A driver that instead describes each statement before binding pays an
26
- * extra round trip and a re-parse on every call: measured on one such adapter
27
- * (postgres.js, whose `unsafe()` defaults to `prepare: false`), the same hot
28
- * path went 137ms -> 17ms per round trip once preparation was turned on. The
29
- * reference implementation happens not to be affected `pg` uses the extended
30
- * protocol by default which is why this is stated here rather than left to be
31
- * discovered.
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. */
@@ -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
  }
@@ -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
- private stmts: Record<string, Stmt> = {};
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
- for (const [name, sql] of Object.entries(this.statements)) {
308
- this.stmts[name] = db.prepare(sql);
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 stmt = this.stmts[name];
404
- const bound = this.bind(this.statements[name], params);
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;