cairnq 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +69 -4
  2. package/dist/_protocol/migrations/postgres/0007_purge_status_index.sql +11 -0
  3. package/dist/_protocol/migrations/sqlite/0007_purge_status_index.sql +11 -0
  4. package/dist/_protocol/sql/postgres/get_status.sql +7 -0
  5. package/dist/_protocol/sql/postgres/get_status_by_key.sql +7 -0
  6. package/dist/_protocol/sql/postgres/purge.sql +8 -1
  7. package/dist/_protocol/sql/sqlite/get_status.sql +6 -0
  8. package/dist/_protocol/sql/sqlite/get_status_by_key.sql +7 -0
  9. package/dist/_protocol/sql/sqlite/purge.sql +7 -1
  10. package/dist/client.d.ts +32 -15
  11. package/dist/client.js +38 -12
  12. package/dist/context.d.ts +25 -0
  13. package/dist/context.js +33 -0
  14. package/dist/errors.d.ts +4 -2
  15. package/dist/errors.js +7 -4
  16. package/dist/index.d.ts +9 -5
  17. package/dist/index.js +4 -1
  18. package/dist/models.d.ts +14 -2
  19. package/dist/models.js +12 -1
  20. package/dist/retention.d.ts +15 -3
  21. package/dist/retention.js +36 -14
  22. package/dist/store/base.d.ts +118 -1
  23. package/dist/store/base.js +122 -20
  24. package/dist/store/pg-executor.d.ts +77 -0
  25. package/dist/store/pg-executor.js +26 -0
  26. package/dist/store/pg-pool.d.ts +16 -0
  27. package/dist/store/pg-pool.js +147 -0
  28. package/dist/store/postgres.d.ts +41 -13
  29. package/dist/store/postgres.js +135 -147
  30. package/dist/store/sqlite.js +20 -2
  31. package/dist/wait.d.ts +9 -4
  32. package/dist/wait.js +27 -13
  33. package/dist/worker.d.ts +6 -3
  34. package/dist/worker.js +6 -5
  35. package/package.json +6 -4
  36. package/src/client.ts +60 -21
  37. package/src/context.ts +37 -0
  38. package/src/errors.ts +7 -4
  39. package/src/index.ts +16 -4
  40. package/src/models.ts +24 -3
  41. package/src/retention.ts +45 -15
  42. package/src/store/base.ts +204 -9
  43. package/src/store/pg-executor.ts +90 -0
  44. package/src/store/pg-pool.ts +156 -0
  45. package/src/store/postgres.ts +144 -141
  46. package/src/store/sqlite.ts +23 -2
  47. package/src/wait.ts +35 -14
  48. package/src/worker.ts +8 -6
@@ -1,5 +1,3 @@
1
- import type * as PG from "pg";
2
-
3
1
  import { loadMigrations, loadStatements } from "../sql.js";
4
2
  import {
5
3
  checkProtocolVersion,
@@ -9,48 +7,16 @@ import {
9
7
  type Params,
10
8
  statementParams,
11
9
  TaskStore,
10
+ type WatchSignal,
12
11
  } from "./base.js";
13
-
14
- // `pg` is an optional dependency: the SDK is SQLite-first, so it's loaded lazily
15
- // the first time a PostgresStore connects. Absent -> a clear install hint.
16
- let pgModule: typeof import("pg") | null = null;
17
- async function loadPg(): Promise<typeof import("pg")> {
18
- if (pgModule) return pgModule;
19
- let mod: { default?: typeof import("pg") } & typeof import("pg");
20
- try {
21
- mod = (await import("pg")) as never;
22
- } catch {
23
- throw new Error("PostgresStore requires the 'pg' package — install it (e.g. `npm i pg`)");
24
- }
25
- const pg = (mod.default ?? mod) as typeof import("pg");
26
- // Postgres returns bigint (int8, OID 20) as a string to avoid precision loss.
27
- // Every cairnq bigint is an epoch-ms or a counter, all within Number's safe
28
- // integer range, so parse to number once (globally) to match the Task model
29
- // (*_ms typed as number, same as the SQLite SDK). Set before any query runs.
30
- pg.types.setTypeParser(pg.types.builtins.INT8, (v: string) => (v == null ? null : Number(v)));
31
- pgModule = pg;
32
- return pg;
33
- }
34
-
35
- /**
36
- * Roll back on the way out of a failed transaction, without letting the rollback
37
- * become the error the caller sees. A dropped connection fails both the statement
38
- * and the rollback, and it is the first one that says what went wrong.
39
- */
40
- async function rollbackQuietly(client: PG.PoolClient): Promise<void> {
41
- try {
42
- await client.query("rollback");
43
- } catch {
44
- // Already rolled back, or the connection is gone. Either way the original
45
- // error is the one worth propagating.
46
- }
47
- }
12
+ import { ListenUnavailable, type PgExecutor, type PgSession } from "./pg-executor.js";
13
+ import { createPoolExecutor } from "./pg-pool.js";
48
14
 
49
15
  // Notification channels, emitted by the 0003_notify trigger.
50
16
  const QUEUED_CHANNEL = "cairnq_queued";
51
17
  const DONE_CHANNEL = "cairnq_done";
52
18
 
53
- // Backoff between attempts to (re)connect the LISTEN connection after a
19
+ // Backoff between attempts to (re)establish the LISTEN subscription after a
54
20
  // transient failure. Doubles per failure up to the cap; polling covers the gap.
55
21
  const LISTENER_RETRY_MS = 1_000;
56
22
  const LISTENER_RETRY_MAX_MS = 30_000;
@@ -100,26 +66,38 @@ export function toPositional(
100
66
  * PostgresStore — the Postgres dialect of the shared cairnq-protocol SQL.
101
67
  *
102
68
  * Everything protocol-shaped lives in TaskStore; this file is only what Postgres
103
- * does differently: a `pg` Pool, `:name` -> `$n` translation, and time taken from
104
- * the DB clock (`now()`) instead of from the SDK, which is what makes this backend
105
- * multi-host — unlike SQLite it coordinates API and worker processes across
106
- * machines, with no shared clock to agree on. claim uses FOR UPDATE SKIP LOCKED
107
- * and needs no claimable_probe, because PG readers don't block writers. JSON
108
- * columns are jsonb (bound as JSON text, read back as objects by rowToTask).
69
+ * does differently: `:name` -> `$n` translation, the migration ledger, LISTEN
70
+ * policy, and time taken from the DB clock (`now()`) instead of from the SDK,
71
+ * which is what makes this backend multi-host — unlike SQLite it coordinates API
72
+ * and worker processes across machines, with no shared clock to agree on. claim
73
+ * uses FOR UPDATE SKIP LOCKED and needs no claimable_probe, because PG readers
74
+ * don't block writers. JSON columns are jsonb (bound as JSON text, read back as
75
+ * objects by rowToTask).
76
+ *
77
+ * What it deliberately does NOT own is the connection. Given a DSN it builds a
78
+ * `pg` pool (see pg-pool.ts); given a PgExecutor it runs inside the caller's
79
+ * session instead — no second driver, no second pool, and the caller's writes and
80
+ * cairnq's can share one transaction.
109
81
  */
110
82
  export class PostgresStore extends TaskStore {
111
- private pool: PG.Pool | null = null;
83
+ private readonly dsn: string | null;
84
+ /** The caller's executor, if one was injected — never closed by this store. */
85
+ private readonly provided: PgExecutor | null;
86
+ /** Set once migrations have run and the protocol version checked out. */
87
+ private executor: PgExecutor | null = null;
112
88
  private connecting: Promise<void> | null = null;
113
89
  private readonly statements: Record<string, string>;
114
90
 
115
91
  // ------------------------------------------------------- LISTEN/NOTIFY state
116
- // One dedicated connection LISTENs on both channels (see 0003_notify.sql and
117
- // the claimWake/taskDoneWake contract on TaskStore). Failure to establish or
118
- // keep it silently degrades the store to the base class's plain polling.
119
- private listener: PG.Client | null = null;
92
+ // The executor subscribes one dedicated connection to both channels (see
93
+ // 0003_notify.sql and the claimWake/taskDoneWake contract on TaskStore).
94
+ // Failure to establish or keep it silently degrades the store to the base
95
+ // class's plain polling. Held as the unsubscribe function, not the connection:
96
+ // whose connection it is, is the executor's business.
97
+ private listener: (() => void) | null = null;
120
98
  private listenerConnecting: Promise<void> | null = null;
121
- /** LISTEN is off for good: the store was closed, or the server accepted a
122
- * connection but refused LISTEN (e.g. a transaction-mode pooler) —
99
+ /** LISTEN is off for good: the store was closed, the executor does not support
100
+ * it, or the server refused it (e.g. a transaction-mode pooler) —
123
101
  * deterministic, so retrying would fail the same way every time. */
124
102
  private listenerUnavailable = false;
125
103
  /** A failure to even connect is transient (network blip, server restarting):
@@ -132,12 +110,22 @@ export class PostgresStore extends TaskStore {
132
110
  private readonly pendingQueues = new Set<string>();
133
111
  /** Wake callbacks by key: "queued" (broadcast) or "done:<task id>". */
134
112
  private readonly waiters = new Map<string, Set<() => void>>();
113
+ /** watch() subscribers. Separate from `waiters`: a waiter is one-shot and
114
+ * consumes the notification, a subscriber is standing and only observes. */
115
+ private readonly subscribers = new Set<(signal: WatchSignal) => void>();
135
116
 
117
+ /**
118
+ * `source` is either a libpq connection string — this store then owns a `pg`
119
+ * pool and requires the optional `pg` package — or a PgExecutor the caller
120
+ * already has, which this store uses and never closes.
121
+ */
136
122
  constructor(
137
- private readonly dsn: string,
138
- private readonly opts: { max?: number } = {},
123
+ source: string | PgExecutor,
124
+ private readonly opts: { max?: number; schema?: string } = {},
139
125
  ) {
140
126
  super();
127
+ this.dsn = typeof source === "string" ? source : null;
128
+ this.provided = typeof source === "string" ? null : source;
141
129
  this.statements = loadStatements("postgres");
142
130
  }
143
131
 
@@ -148,18 +136,19 @@ export class PostgresStore extends TaskStore {
148
136
  async close(): Promise<void> {
149
137
  this.listenerUnavailable = true; // no revival after close
150
138
  this.dropListener();
151
- if (this.pool) {
152
- const p = this.pool;
153
- this.pool = null;
154
- this.connecting = null;
155
- await p.end();
156
- }
139
+ const executor = this.executor;
140
+ this.executor = null;
141
+ this.connecting = null;
142
+ // An injected executor belongs to the caller, whose other work would not
143
+ // survive cairnq closing it.
144
+ if (executor && !this.provided) await executor.close();
157
145
  }
158
146
 
159
147
  private async ensure(): Promise<void> {
160
- if (this.pool) return;
161
- // Cache the in-flight connect so concurrent calls share one pool. On failure,
162
- // clear it so a later call retries instead of re-awaiting a rejected promise.
148
+ if (this.executor) return;
149
+ // Cache the in-flight connect so concurrent calls share one executor. On
150
+ // failure, clear it so a later call retries instead of re-awaiting a
151
+ // rejected promise.
163
152
  if (!this.connecting) {
164
153
  this.connecting = this.doConnect().catch((e) => {
165
154
  this.connecting = null;
@@ -170,28 +159,25 @@ export class PostgresStore extends TaskStore {
170
159
  }
171
160
 
172
161
  private async doConnect(): Promise<void> {
173
- const pg = await loadPg();
174
- const pool = new pg.Pool({ connectionString: this.dsn, max: this.opts.max });
162
+ const executor =
163
+ this.provided ??
164
+ (await createPoolExecutor(this.dsn!, { max: this.opts.max, schema: this.opts.schema }));
175
165
  try {
176
- const client = await pool.connect();
177
- try {
178
- await this.applyMigrations(client);
179
- checkProtocolVersion(await this.readProtocolVersion(client));
180
- } finally {
181
- client.release();
182
- }
166
+ await this.applyMigrations(executor);
167
+ checkProtocolVersion(await this.readProtocolVersion(executor));
183
168
  } catch (e) {
184
- await pool.end(); // never leak a pool when connect fails
169
+ // Never leak an executor we created; never close one we were handed.
170
+ if (!this.provided) await executor.close().catch(() => {});
185
171
  throw e;
186
172
  }
187
- this.pool = pool; // publish only a fully-migrated, version-checked pool
188
- // Warm the LISTEN connection in the background so the first idle sleep is
173
+ this.executor = executor; // publish only a fully-migrated, version-checked executor
174
+ // Warm the LISTEN subscription in the background so the first idle sleep is
189
175
  // already wakeable. Fire-and-forget: failure just means polling.
190
176
  this.listenerReady();
191
177
  }
192
178
 
193
- private async applyMigrations(client: PG.PoolClient): Promise<void> {
194
- await client.query(
179
+ private async applyMigrations(executor: PgExecutor): Promise<void> {
180
+ await executor.exec(
195
181
  "create table if not exists cairnq_migrations " +
196
182
  "(name text primary key, applied_at_ms bigint not null)",
197
183
  );
@@ -199,43 +185,31 @@ export class PostgresStore extends TaskStore {
199
185
  // Check and apply inside one transaction, with the table lock taken up
200
186
  // front: two processes cold-starting together would otherwise both see a
201
187
  // migration as unapplied and both run it.
202
- try {
203
- await client.query("begin");
204
- await client.query("lock table cairnq_migrations in exclusive mode");
205
- const applied = await client.query("select 1 from cairnq_migrations where name = $1", [
206
- name,
207
- ]);
208
- if (applied.rowCount === 0) {
209
- await client.query(sql); // multi-statement DDL (simple-query, no params)
210
- await client.query(
188
+ await executor.tx(async (s) => {
189
+ await s.exec("lock table cairnq_migrations in exclusive mode");
190
+ const applied = await s.query("select 1 from cairnq_migrations where name = $1", [name]);
191
+ if (applied.length === 0) {
192
+ await s.exec(sql); // multi-statement DDL (simple-query, no params)
193
+ await s.query(
211
194
  "insert into cairnq_migrations (name, applied_at_ms) values " +
212
195
  "($1, (extract(epoch from now()) * 1000)::bigint)",
213
196
  [name],
214
197
  );
215
198
  }
216
- await client.query("commit");
217
- } catch (e) {
218
- await rollbackQuietly(client);
219
- throw e;
220
- }
199
+ });
221
200
  }
222
201
  }
223
202
 
224
- // Takes an explicit client: during doConnect the pool is not published yet, so
225
- // this cannot go through fetch(). The statement binds nothing, so it runs as-is.
226
- private async readProtocolVersion(client: PG.PoolClient): Promise<number> {
227
- const res = await client.query(this.statements.protocol_version);
228
- return res.rows.length ? Number(res.rows[0].value) : 0;
203
+ // Takes an explicit session: during doConnect the executor is not published
204
+ // yet, so this cannot go through fetch(). The statement binds nothing.
205
+ private async readProtocolVersion(s: PgSession): Promise<number> {
206
+ const rows = await s.query(this.statements.protocol_version, []);
207
+ return rows.length ? Number(rows[0].value) : 0;
229
208
  }
230
209
 
231
210
  async protocolVersion(): Promise<number> {
232
211
  await this.ensure();
233
- const client = await this.pool!.connect();
234
- try {
235
- return await this.readProtocolVersion(client);
236
- } finally {
237
- client.release();
238
- }
212
+ return this.readProtocolVersion(this.executor!);
239
213
  }
240
214
 
241
215
  // ------------------------------------------------------------ wake channel
@@ -255,6 +229,16 @@ export class PostgresStore extends TaskStore {
255
229
  }
256
230
  }
257
231
 
232
+ protected subscribePush(onSignal: (signal: WatchSignal) => void): () => void {
233
+ this.subscribers.add(onSignal);
234
+ this.listenerReady(); // an API-side watcher is often the only thing asking
235
+ return () => void this.subscribers.delete(onSignal);
236
+ }
237
+
238
+ protected warmPush(): void {
239
+ if (this.subscribers.size > 0) this.listenerReady();
240
+ }
241
+
258
242
  taskDoneWake(taskId: string, timeoutMs: number): Promise<void> {
259
243
  if (!this.listenerReady()) return super.taskDoneWake(taskId, timeoutMs);
260
244
  return this.wakeOn(`done:${taskId}`, timeoutMs);
@@ -280,7 +264,7 @@ export class PostgresStore extends TaskStore {
280
264
  });
281
265
  }
282
266
 
283
- /** True once the LISTEN connection is up; starts connecting it otherwise
267
+ /** True once the LISTEN subscription is up; starts establishing it otherwise
284
268
  * (respecting the transient-failure backoff). Callers fall back to plain
285
269
  * polling until it is ready (or forever, if it can't be established) —
286
270
  * correctness never depends on it. */
@@ -294,36 +278,39 @@ export class PostgresStore extends TaskStore {
294
278
 
295
279
  private async startListener(): Promise<void> {
296
280
  try {
297
- const pg = await loadPg();
298
- // Built from the raw DSN: if `opts` ever grows connection-level settings
299
- // (ssl, application_name), the listener must receive them too.
300
- const client = new pg.Client({ connectionString: this.dsn });
281
+ const executor = this.executor ?? this.provided;
282
+ // Not connected yet: transient by definition — doConnect calls back in.
283
+ if (!executor) return;
284
+ if (!executor.listen) {
285
+ this.listenerUnavailable = true; // this executor will never push
286
+ return;
287
+ }
288
+ let stop: () => void;
301
289
  try {
302
- await client.connect();
303
- } catch {
304
- // Could not even connect — transient. Schedule a backed-off retry;
290
+ stop = await executor.listen(
291
+ [QUEUED_CHANNEL, DONE_CHANNEL],
292
+ (channel, payload) => this.onNotification(channel, payload),
293
+ // A dropped listener degrades to polling; the next wake reconnects.
294
+ () => this.dropListener(),
295
+ );
296
+ } catch (e) {
297
+ if (e instanceof ListenUnavailable) {
298
+ // Deterministic (e.g. a transaction-mode pooler): off for good rather
299
+ // than a reconnect loop that cannot succeed. Polling covers it.
300
+ this.listenerUnavailable = true;
301
+ return;
302
+ }
303
+ // Could not establish it — transient. Schedule a backed-off retry;
305
304
  // polling covers the gap.
306
305
  this.listenerRetryAt = Date.now() + this.listenerBackoffMs;
307
306
  this.listenerBackoffMs = Math.min(LISTENER_RETRY_MAX_MS, this.listenerBackoffMs * 2);
308
307
  return;
309
308
  }
310
- client.on("notification", (msg) => this.onNotification(msg.channel, msg.payload));
311
- // A dropped listener degrades to polling; the next wake call reconnects.
312
- client.on("error", () => this.dropListener());
313
- try {
314
- await client.query(`listen ${QUEUED_CHANNEL}; listen ${DONE_CHANNEL}`);
315
- } catch {
316
- // Connected, but LISTEN was refused (e.g. a transaction-mode pooler) —
317
- // deterministic, so off for good. Polling covers it.
318
- this.listenerUnavailable = true;
319
- void client.end().catch(() => {});
320
- return;
321
- }
322
309
  if (this.listenerUnavailable) {
323
- void client.end().catch(() => {}); // closed while we were connecting
310
+ stop(); // closed while we were subscribing
324
311
  return;
325
312
  }
326
- this.listener = client;
313
+ this.listener = stop;
327
314
  this.listenerBackoffMs = LISTENER_RETRY_MS;
328
315
  } catch {
329
316
  // Anything unexpected (e.g. `pg` failed to load): off for good rather
@@ -339,8 +326,10 @@ export class PostgresStore extends TaskStore {
339
326
  if (channel === QUEUED_CHANNEL && payload) {
340
327
  this.pendingQueues.add(payload);
341
328
  key = "queued";
329
+ this.publish({ reason: "queued", queue: payload });
342
330
  } else if (channel === DONE_CHANNEL && payload) {
343
331
  key = `done:${payload}`;
332
+ this.publish({ reason: "done", taskId: payload });
344
333
  } else {
345
334
  return;
346
335
  }
@@ -349,10 +338,23 @@ export class PostgresStore extends TaskStore {
349
338
  if (set) for (const w of set) w();
350
339
  }
351
340
 
341
+ /** Hand a notification to every watch() subscriber. A throwing subscriber is
342
+ * its own problem: it must not cost the others their signal, nor take down the
343
+ * listener connection that delivered it. */
344
+ private publish(signal: WatchSignal): void {
345
+ for (const s of [...this.subscribers]) {
346
+ try {
347
+ s(signal);
348
+ } catch {
349
+ // Deliberately swallowed — see above.
350
+ }
351
+ }
352
+ }
353
+
352
354
  private dropListener(): void {
353
- const client = this.listener;
355
+ const stop = this.listener;
354
356
  this.listener = null;
355
- if (client) void client.end().catch(() => {});
357
+ stop?.();
356
358
  // Release everyone promptly; their fallback poll takes over.
357
359
  for (const set of [...this.waiters.values()]) for (const w of [...set]) w();
358
360
  }
@@ -361,25 +363,26 @@ export class PostgresStore extends TaskStore {
361
363
  protected async fetch(name: string, params: Params): Promise<any[]> {
362
364
  await this.ensure();
363
365
  const { text, values } = toPositional(this.statements[name], params);
364
- return (await this.pool!.query(text, values)).rows;
366
+ return this.executor!.query(text, values);
365
367
  }
366
368
 
367
369
  protected async tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T> {
368
370
  await this.ensure();
369
- const client = await this.pool!.connect();
370
- try {
371
- await client.query("begin");
372
- const out = await fn(async (name, params) => {
373
- const { text, values } = toPositional(this.statements[name], params);
374
- return (await client.query(text, values)).rows;
375
- });
376
- await client.query("commit");
377
- return out;
378
- } catch (e) {
379
- await rollbackQuietly(client);
380
- throw e;
381
- } finally {
382
- client.release();
383
- }
371
+ return this.executor!.tx((s) => fn(this.boundFetch(s)));
372
+ }
373
+
374
+ protected async txWithSession<T>(
375
+ fn: (fetch: Fetch, session: PgSession) => Promise<T>,
376
+ ): Promise<T> {
377
+ await this.ensure();
378
+ return this.executor!.tx((s) => fn(this.boundFetch(s), s));
379
+ }
380
+
381
+ /** A Fetch that runs the protocol's statements on one particular session. */
382
+ private boundFetch(s: PgSession): Fetch {
383
+ return async (name, params) => {
384
+ const { text, values } = toPositional(this.statements[name], params);
385
+ return s.query(text, values);
386
+ };
384
387
  }
385
388
  }
@@ -1,7 +1,9 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
 
4
- import Database from "better-sqlite3";
4
+ import { createRequire } from "node:module";
5
+
6
+ import type Database from "better-sqlite3";
5
7
 
6
8
  import { nowMs } from "../ids.js";
7
9
  import { loadMigrations, loadStatements } from "../sql.js";
@@ -14,6 +16,25 @@ import {
14
16
  TaskStore,
15
17
  } from "./base.js";
16
18
 
19
+ // `better-sqlite3` is an optional dependency, matching `pg` on the Postgres side:
20
+ // a Postgres-only deployment should not have to build a native module it never
21
+ // loads, and importing this file must not pull one in. Required (not imported)
22
+ // because ensure() is synchronous — the open path applies migrations and cannot
23
+ // await — and createRequire gives a synchronous load from ESM.
24
+ const require = createRequire(import.meta.url);
25
+ let sqliteModule: typeof Database | null = null;
26
+ function loadSqlite(): typeof Database {
27
+ if (sqliteModule) return sqliteModule;
28
+ try {
29
+ sqliteModule = require("better-sqlite3") as typeof Database;
30
+ } catch {
31
+ throw new Error(
32
+ "SQLiteStore requires the 'better-sqlite3' package — install it (e.g. `npm i better-sqlite3`)",
33
+ );
34
+ }
35
+ return sqliteModule;
36
+ }
37
+
17
38
  type DB = Database.Database;
18
39
  type Stmt = Database.Statement;
19
40
 
@@ -261,7 +282,7 @@ export class SQLiteStore extends TaskStore {
261
282
  if (this.db) return this.db;
262
283
  const memory = isMemory(this.path);
263
284
  if (!memory) mkdirSync(dirname(this.path), { recursive: true });
264
- const db = new Database(this.path);
285
+ const db = new (loadSqlite())(this.path);
265
286
  // Only the synchronous part of the open path gets a real busy_timeout: the WAL
266
287
  // switch and the migrations cannot await a retry. See the class comment.
267
288
  db.pragma(`busy_timeout = ${this.busyBudgetMs}`);
package/src/wait.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { TaskTimeout } from "./errors.js";
2
2
  import { nowMs } from "./ids.js";
3
- import { isTerminal, type Task } from "./models.js";
3
+ import { isTerminal, type Task, type TaskRef } from "./models.js";
4
4
  import type { TaskStore } from "./store/base.js";
5
5
 
6
+ export const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
6
7
  export const DEFAULT_POLL_MS = 100;
7
8
  export const MAX_POLL_MS = 500;
8
9
  const GROWTH = 1.5;
@@ -11,7 +12,11 @@ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve,
11
12
 
12
13
  export interface PollOptions {
13
14
  timeoutMs: number;
15
+ /** The first poll interval (default 100). */
14
16
  pollMs?: number;
17
+ /** Ceiling the poll interval backs off to (default 500). Worth raising for a
18
+ * task known to take minutes — fewer reads — or lowering when shaving the
19
+ * average half-interval of completion-detection latency matters. */
15
20
  maxPollMs?: number;
16
21
  }
17
22
 
@@ -28,15 +33,25 @@ export function nextPollMs(current: number, maxMs: number): number {
28
33
  }
29
34
 
30
35
  /**
31
- * Poll `read` until it yields a terminal task, or the timeout elapses.
36
+ * Poll `probe` until it reports a terminal status, then return the full task
37
+ * via `read`; or throw once the timeout elapses.
38
+ *
39
+ * The loop's repeated read is the status-only `probe` (see get_status.sql): a
40
+ * waiting caller asks nothing but "is it finished yet", and re-reading the whole
41
+ * row would drag the payload back — and re-parse it — on every beat for the life
42
+ * of the wait. The full row is read once, when the probe turns terminal or, on
43
+ * the timeout beat, for the error's snapshot. Between the probe and that read
44
+ * the row can vanish (purge) or the key repoint (`replace`); a read that comes
45
+ * back empty or non-terminal is simply not finished, and the loop keeps polling.
32
46
  *
33
47
  * `wake` is what the loop sleeps on between reads: a store with a push channel
34
- * (Postgres) cuts it short when the task goes terminal, but the re-read is the
48
+ * (Postgres) cuts it short when the task goes terminal, but the re-probe is the
35
49
  * source of truth either way, so a plain sleep is always a correct answer.
36
50
  */
37
51
  async function poll(
52
+ probe: () => Promise<TaskRef | null>,
38
53
  read: () => Promise<Task | null>,
39
- wake: (task: Task | null, ms: number) => Promise<void>,
54
+ wake: (ref: TaskRef | null, ms: number) => Promise<void>,
40
55
  subject: string,
41
56
  key: string | null,
42
57
  { timeoutMs, pollMs = DEFAULT_POLL_MS, maxPollMs = MAX_POLL_MS }: PollOptions,
@@ -44,22 +59,27 @@ async function poll(
44
59
  const deadline = nowMs() + timeoutMs;
45
60
  let interval = pollMs;
46
61
  for (;;) {
47
- const task = await read();
48
- if (task && isTerminal(task)) return task;
62
+ const ref = await probe();
49
63
  const remaining = deadline - nowMs();
50
- if (remaining <= 0) throw new TaskTimeout(task?.id ?? subject, { timeoutMs, task, key });
51
- await wake(task, Math.min(interval, remaining));
64
+ // The one full-read site: when the probe says finished, or on the timeout
65
+ // beat for the error's stuck-in-what-state snapshot. No ref means no row,
66
+ // so there is nothing for a read to add to either case.
67
+ const task = ref && (isTerminal(ref) || remaining <= 0) ? await read() : null;
68
+ if (task && isTerminal(task)) return task;
69
+ if (remaining <= 0) throw new TaskTimeout(ref?.id ?? subject, { timeoutMs, task, key });
70
+ await wake(ref, Math.min(interval, remaining));
52
71
  interval = nextPollMs(interval, maxPollMs);
53
72
  }
54
73
  }
55
74
 
56
- /** Poll get() until terminal or timeout. Returns the terminal Task (any status).
57
- * Throws TaskTimeout, leaving the task running. `pollMs` is the *first* interval;
58
- * it backs off towards `maxPollMs`. */
75
+ /** Poll the task's status until terminal or timeout. Returns the terminal Task
76
+ * (any status). Throws TaskTimeout, leaving the task running. `pollMs` is the
77
+ * *first* interval; it backs off towards `maxPollMs`. */
59
78
  export function pollWait(store: TaskStore, taskId: string, opts: PollOptions): Promise<Task> {
60
79
  return poll(
80
+ () => store.getStatus(taskId),
61
81
  () => store.get(taskId),
62
- (_task, ms) => store.taskDoneWake(taskId, ms),
82
+ (_ref, ms) => store.taskDoneWake(taskId, ms),
63
83
  taskId,
64
84
  null,
65
85
  opts,
@@ -69,7 +89,7 @@ export function pollWait(store: TaskStore, taskId: string, opts: PollOptions): P
69
89
  /**
70
90
  * The same wait, following a key instead of an id.
71
91
  *
72
- * The key is re-resolved on every read, because that is what a key means: a
92
+ * The key is re-resolved on every probe, because that is what a key means: a
73
93
  * pointer to the task that is *current* under it. A `replace` landing mid-wait
74
94
  * moves the wait onto the new task rather than reporting the cancellation of the
75
95
  * old one, and a key that points at nothing yet is simply not finished — it
@@ -81,8 +101,9 @@ export function pollWait(store: TaskStore, taskId: string, opts: PollOptions): P
81
101
  */
82
102
  export function pollWaitByKey(store: TaskStore, key: string, opts: PollOptions): Promise<Task> {
83
103
  return poll(
104
+ () => store.getStatusByKey(key),
84
105
  () => store.getByKey(key),
85
- (task, ms) => (task ? store.taskDoneWake(task.id, ms) : sleep(ms)),
106
+ (ref, ms) => (ref ? store.taskDoneWake(ref.id, ms) : sleep(ms)),
86
107
  key,
87
108
  key,
88
109
  opts,
package/src/worker.ts CHANGED
@@ -14,6 +14,7 @@ import { newId } from "./ids.js";
14
14
  import { type Task } from "./models.js";
15
15
  import { SQLiteStore } from "./store/sqlite.js";
16
16
  import { PostgresStore } from "./store/postgres.js";
17
+ import type { PgExecutor } from "./store/pg-executor.js";
17
18
  import type { TaskStore } from "./store/base.js";
18
19
  import { type TaskDef, taskName } from "./task.js";
19
20
 
@@ -291,14 +292,15 @@ export class Worker {
291
292
  return worker;
292
293
  }
293
294
 
294
- /** Multi-host backend. `dsn` is a libpq connection string; requires the
295
- * optional `pg` package. */
295
+ /** Multi-host backend. `source` is a libpq connection string — which requires
296
+ * the optional `pg` package — or a PgExecutor over a driver the application
297
+ * already runs, which cairnq then shares instead of opening a second pool. */
296
298
  static postgres(
297
- dsn: string,
298
- opts: WorkerOptions & { queues?: string[]; max?: number } = {},
299
+ source: string | PgExecutor,
300
+ opts: WorkerOptions & { queues?: string[]; max?: number; schema?: string } = {},
299
301
  ): Worker {
300
- const { queues = ["default"], max, ...rest } = opts;
301
- const worker = new Worker(new PostgresStore(dsn, { max }), queues, rest);
302
+ const { queues = ["default"], max, schema, ...rest } = opts;
303
+ const worker = new Worker(new PostgresStore(source, { max, schema }), queues, rest);
302
304
  worker.ownsStore = true;
303
305
  return worker;
304
306
  }