cairnq 0.1.0 → 0.2.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 (56) hide show
  1. package/README.md +28 -0
  2. package/dist/_protocol/migrations/postgres/0002_purge_index.sql +6 -0
  3. package/dist/_protocol/migrations/postgres/0003_notify.sql +38 -0
  4. package/dist/_protocol/migrations/sqlite/0002_purge_index.sql +6 -0
  5. package/dist/_protocol/sql/postgres/claim.sql +18 -5
  6. package/dist/_protocol/sql/postgres/fail.sql +28 -8
  7. package/dist/_protocol/sql/postgres/insert_task.sql +6 -3
  8. package/dist/_protocol/sql/postgres/list.sql +3 -1
  9. package/dist/_protocol/sql/postgres/lock_key.sql +9 -0
  10. package/dist/_protocol/sql/postgres/progress.sql +4 -3
  11. package/dist/_protocol/sql/postgres/protocol_version.sql +4 -0
  12. package/dist/_protocol/sql/postgres/purge.sql +25 -0
  13. package/dist/_protocol/sql/postgres/recover_leases.sql +38 -14
  14. package/dist/_protocol/sql/postgres/retry.sql +3 -0
  15. package/dist/_protocol/sql/postgres/stats.sql +8 -0
  16. package/dist/_protocol/sql/sqlite/claim.sql +13 -2
  17. package/dist/_protocol/sql/sqlite/claimable_probe.sql +6 -2
  18. package/dist/_protocol/sql/sqlite/fail.sql +30 -8
  19. package/dist/_protocol/sql/sqlite/list.sql +3 -1
  20. package/dist/_protocol/sql/sqlite/lock_key.sql +5 -0
  21. package/dist/_protocol/sql/sqlite/progress.sql +6 -2
  22. package/dist/_protocol/sql/sqlite/protocol_version.sql +4 -0
  23. package/dist/_protocol/sql/sqlite/purge.sql +18 -0
  24. package/dist/_protocol/sql/sqlite/recover_leases.sql +25 -7
  25. package/dist/_protocol/sql/sqlite/retry.sql +3 -0
  26. package/dist/_protocol/sql/sqlite/stats.sql +8 -0
  27. package/dist/client.d.ts +10 -2
  28. package/dist/client.js +12 -0
  29. package/dist/context.d.ts +17 -1
  30. package/dist/context.js +60 -6
  31. package/dist/errors.d.ts +18 -2
  32. package/dist/errors.js +49 -3
  33. package/dist/index.d.ts +3 -2
  34. package/dist/index.js +2 -1
  35. package/dist/sql.js +16 -9
  36. package/dist/store/base.d.ts +107 -9
  37. package/dist/store/base.js +370 -1
  38. package/dist/store/postgres.d.ts +62 -63
  39. package/dist/store/postgres.js +245 -222
  40. package/dist/store/sqlite.d.ts +34 -59
  41. package/dist/store/sqlite.js +200 -232
  42. package/dist/wait.d.ts +15 -2
  43. package/dist/wait.js +23 -5
  44. package/dist/worker.d.ts +53 -1
  45. package/dist/worker.js +202 -42
  46. package/package.json +9 -2
  47. package/src/client.ts +16 -2
  48. package/src/context.ts +70 -13
  49. package/src/errors.ts +59 -4
  50. package/src/index.ts +3 -1
  51. package/src/sql.ts +15 -8
  52. package/src/store/base.ts +430 -27
  53. package/src/store/postgres.ts +243 -267
  54. package/src/store/sqlite.ts +211 -265
  55. package/src/wait.ts +28 -5
  56. package/src/worker.ts +242 -42
@@ -1,21 +1,15 @@
1
1
  import type * as PG from "pg";
2
2
 
3
- import { newId } from "../ids.js";
4
- import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch } from "../errors.js";
5
- import { rowToTask, type Task } from "../models.js";
6
3
  import { loadMigrations, loadStatements } from "../sql.js";
7
- import type { ListInput, SubmitInput, TaskStore } from "./base.js";
8
-
9
- const SUPPORTED_PROTOCOL_MAJOR = 1;
10
-
11
- const LEASE_EXPIRED_ERROR_JSON = JSON.stringify(
12
- errorEnvelope({
13
- type: "LeaseExpired",
14
- code: "lease_expired",
15
- message: "task lease expired and max attempts reached",
16
- retryable: false,
17
- }),
18
- );
4
+ import {
5
+ checkProtocolVersion,
6
+ COMMENT,
7
+ type Fetch,
8
+ NAMED,
9
+ type Params,
10
+ statementParams,
11
+ TaskStore,
12
+ } from "./base.js";
19
13
 
20
14
  // `pg` is an optional dependency: the SDK is SQLite-first, so it's loaded lazily
21
15
  // the first time a PostgresStore connects. Absent -> a clear install hint.
@@ -39,47 +33,111 @@ async function loadPg(): Promise<typeof import("pg")> {
39
33
  }
40
34
 
41
35
  /**
42
- * Translate the protocol's named-parameter SQL (`:name`) into Postgres positional
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
+ }
48
+
49
+ // Notification channels, emitted by the 0003_notify trigger.
50
+ const QUEUED_CHANNEL = "cairnq_queued";
51
+ const DONE_CHANNEL = "cairnq_done";
52
+
53
+ // Backoff between attempts to (re)connect the LISTEN connection after a
54
+ // transient failure. Doubles per failure up to the cap; polling covers the gap.
55
+ const LISTENER_RETRY_MS = 1_000;
56
+ const LISTENER_RETRY_MAX_MS = 30_000;
57
+
58
+ const translated = new Map<string, { text: string; order: readonly string[] }>();
59
+
60
+ /**
61
+ * The rewritten SQL and the order its `$n` slots must be filled in.
62
+ *
63
+ * Translates the protocol's named-parameter SQL (`:name`) into Postgres positional
43
64
  * placeholders (`$1`), collapsing each DISTINCT name to ONE slot — statements reuse
44
- * a name across CASE branches / IS NULL guards (e.g. list.sql). SQL comments are
45
- * stripped first so a `:name` mentioned in a header comment (e.g. "now + :lease_ms")
46
- * never leaks into the parameter list. Exported for unit testing.
65
+ * a name across CASE branches / IS NULL guards (e.g. list.sql). Which names count
66
+ * as parameters is `statementParams`' decision, shared with the SQLite binding path
67
+ * so the two can't disagree about, say, a `::type` cast.
68
+ *
69
+ * Memoized on the statement text, which is loaded once and never varies: this runs
70
+ * on every query, including the worker's poll loop, for a result that cannot have
71
+ * changed. Exported for unit testing.
72
+ */
73
+ export function positionalStatement(sql: string): { text: string; order: readonly string[] } {
74
+ let entry = translated.get(sql);
75
+ if (!entry) {
76
+ const order = statementParams(sql);
77
+ const slot = new Map(order.map((name, i) => [name, i + 1])); // 1-based $n
78
+ const text = sql
79
+ .replace(COMMENT, "")
80
+ .replace(NAMED, (_m, name: string) => `$${slot.get(name)}`);
81
+ entry = { text, order };
82
+ translated.set(sql, entry);
83
+ }
84
+ return entry;
85
+ }
86
+
87
+ /**
88
+ * The statement's rewritten text plus this call's values, in slot order. Names the
89
+ * statement does not use are simply not bound, so callers may pass a superset.
47
90
  */
48
91
  export function toPositional(
49
92
  sql: string,
50
- params: Record<string, unknown>,
93
+ params: Params,
51
94
  ): { text: string; values: unknown[] } {
52
- const body = sql.replace(/--[^\n]*/g, "");
53
- const order: string[] = [];
54
- const slot = new Map<string, number>();
55
- const text = body.replace(/(?<!:):(\w+)/g, (_m, name: string) => {
56
- let i = slot.get(name);
57
- if (i === undefined) {
58
- order.push(name);
59
- i = order.length; // 1-based $n
60
- slot.set(name, i);
61
- }
62
- return `$${i}`;
63
- });
95
+ const { text, order } = positionalStatement(sql);
64
96
  return { text, values: order.map((n) => params[n]) };
65
97
  }
66
98
 
67
99
  /**
68
- * PostgresStore — asyncpg's TS counterpart: a `pg` Pool executing the shared
69
- * cairnq-protocol SQL (postgres dialect). Multi-host capable — unlike SQLite this
70
- * coordinates API and worker processes across machines through one database. Time
71
- * comes from the DB clock (now()), claim uses FOR UPDATE SKIP LOCKED, JSON columns
72
- * are jsonb (bound as JSON text, read back as objects by rowToTask's adaptive parse).
100
+ * PostgresStore — the Postgres dialect of the shared cairnq-protocol SQL.
101
+ *
102
+ * 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).
73
109
  */
74
- export class PostgresStore implements TaskStore {
110
+ export class PostgresStore extends TaskStore {
75
111
  private pool: PG.Pool | null = null;
76
112
  private connecting: Promise<void> | null = null;
77
113
  private readonly statements: Record<string, string>;
78
114
 
115
+ // ------------------------------------------------------- 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;
120
+ 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) —
123
+ * deterministic, so retrying would fail the same way every time. */
124
+ private listenerUnavailable = false;
125
+ /** A failure to even connect is transient (network blip, server restarting):
126
+ * retry, but not before this time, backing off so a down server is not
127
+ * hammered from the poll loop. */
128
+ private listenerRetryAt = 0;
129
+ private listenerBackoffMs = LISTENER_RETRY_MS;
130
+ /** Queues notified while nobody was waiting; consumed by the next claimWake
131
+ * so a wake that lands between polls is not lost. */
132
+ private readonly pendingQueues = new Set<string>();
133
+ /** Wake callbacks by key: "queued" (broadcast) or "done:<task id>". */
134
+ private readonly waiters = new Map<string, Set<() => void>>();
135
+
79
136
  constructor(
80
137
  private readonly dsn: string,
81
138
  private readonly opts: { max?: number } = {},
82
139
  ) {
140
+ super();
83
141
  this.statements = loadStatements("postgres");
84
142
  }
85
143
 
@@ -88,6 +146,8 @@ export class PostgresStore implements TaskStore {
88
146
  }
89
147
 
90
148
  async close(): Promise<void> {
149
+ this.listenerUnavailable = true; // no revival after close
150
+ this.dropListener();
91
151
  if (this.pool) {
92
152
  const p = this.pool;
93
153
  this.pool = null;
@@ -116,12 +176,7 @@ export class PostgresStore implements TaskStore {
116
176
  const client = await pool.connect();
117
177
  try {
118
178
  await this.applyMigrations(client);
119
- const version = await this.readProtocolVersion(client);
120
- if (version !== SUPPORTED_PROTOCOL_MAJOR) {
121
- throw new ProtocolVersionMismatch(
122
- `storage protocol_version=${version}, SDK supports ${SUPPORTED_PROTOCOL_MAJOR}`,
123
- );
124
- }
179
+ checkProtocolVersion(await this.readProtocolVersion(client));
125
180
  } finally {
126
181
  client.release();
127
182
  }
@@ -130,6 +185,9 @@ export class PostgresStore implements TaskStore {
130
185
  throw e;
131
186
  }
132
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
189
+ // already wakeable. Fire-and-forget: failure just means polling.
190
+ this.listenerReady();
133
191
  }
134
192
 
135
193
  private async applyMigrations(client: PG.PoolClient): Promise<void> {
@@ -137,33 +195,36 @@ export class PostgresStore implements TaskStore {
137
195
  "create table if not exists cairnq_migrations " +
138
196
  "(name text primary key, applied_at_ms bigint not null)",
139
197
  );
140
- const applied = new Set(
141
- (await client.query("select name from cairnq_migrations")).rows.map(
142
- (r: { name: string }) => r.name,
143
- ),
144
- );
145
198
  for (const { name, sql } of loadMigrations("postgres")) {
146
- if (applied.has(name)) continue;
199
+ // Check and apply inside one transaction, with the table lock taken up
200
+ // front: two processes cold-starting together would otherwise both see a
201
+ // migration as unapplied and both run it.
147
202
  try {
148
203
  await client.query("begin");
149
- await client.query(sql); // multi-statement DDL (simple-query, no params)
150
- await client.query(
151
- "insert into cairnq_migrations (name, applied_at_ms) values " +
152
- "($1, (extract(epoch from now()) * 1000)::bigint) on conflict (name) do nothing",
153
- [name],
154
- );
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(
211
+ "insert into cairnq_migrations (name, applied_at_ms) values " +
212
+ "($1, (extract(epoch from now()) * 1000)::bigint)",
213
+ [name],
214
+ );
215
+ }
155
216
  await client.query("commit");
156
217
  } catch (e) {
157
- await client.query("rollback");
218
+ await rollbackQuietly(client);
158
219
  throw e;
159
220
  }
160
221
  }
161
222
  }
162
223
 
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.
163
226
  private async readProtocolVersion(client: PG.PoolClient): Promise<number> {
164
- const res = await client.query(
165
- "select value from cairnq_meta where key = 'protocol_version'",
166
- );
227
+ const res = await client.query(this.statements.protocol_version);
167
228
  return res.rows.length ? Number(res.rows[0].value) : 0;
168
229
  }
169
230
 
@@ -177,233 +238,148 @@ export class PostgresStore implements TaskStore {
177
238
  }
178
239
  }
179
240
 
180
- // ------------------------------------------------------------------ helpers
181
- private async run(name: string, params: Record<string, unknown>): Promise<any[]> {
182
- const { text, values } = toPositional(this.statements[name], params);
183
- return (await this.pool!.query(text, values)).rows;
184
- }
185
-
186
- private async runOn(
187
- client: PG.PoolClient,
188
- name: string,
189
- params: Record<string, unknown>,
190
- ): Promise<any[]> {
191
- const { text, values } = toPositional(this.statements[name], params);
192
- return (await client.query(text, values)).rows;
193
- }
194
-
195
- // BEGIN … COMMIT on a dedicated pool client, rolling back on any error.
196
- private async tx<T>(fn: (client: PG.PoolClient) => Promise<T>): Promise<T> {
197
- const client = await this.pool!.connect();
198
- try {
199
- await client.query("begin");
200
- const out = await fn(client);
201
- await client.query("commit");
202
- return out;
203
- } catch (e) {
204
- await client.query("rollback");
205
- throw e;
206
- } finally {
207
- client.release();
241
+ // ------------------------------------------------------------ wake channel
242
+ async claimWake(queues: string[], timeoutMs: number): Promise<void> {
243
+ if (!this.listenerReady()) return super.claimWake(queues, timeoutMs);
244
+ const deadline = Date.now() + timeoutMs;
245
+ for (;;) {
246
+ // Consume every pending notification for our queues; any hit wakes us.
247
+ let hit = false;
248
+ for (const q of queues) if (this.pendingQueues.delete(q)) hit = true;
249
+ if (hit) return;
250
+ const remaining = deadline - Date.now();
251
+ if (remaining <= 0) return;
252
+ // Broadcast wake: another queue's notification loops us back to waiting
253
+ // with the remaining budget instead of waking the worker for nothing.
254
+ await this.wakeOn("queued", remaining);
208
255
  }
209
256
  }
210
257
 
211
- // An ownership-checked worker write: each statement's WHERE pins worker_id + a
212
- // live lease, so 0 rows back means the lease was lost.
213
- private async ownedWrite(
214
- name: string,
215
- taskId: string,
216
- params: Record<string, unknown>,
217
- ): Promise<Task> {
218
- const rows = await this.run(name, params);
219
- if (!rows.length) throw new LostLease(taskId);
220
- return rowToTask(rows[0]);
258
+ taskDoneWake(taskId: string, timeoutMs: number): Promise<void> {
259
+ if (!this.listenerReady()) return super.taskDoneWake(taskId, timeoutMs);
260
+ return this.wakeOn(`done:${taskId}`, timeoutMs);
221
261
  }
222
262
 
223
- // ------------------------------------------------------------- client side
224
- async submit(input: SubmitInput): Promise<Task> {
225
- await this.ensure();
226
- const id = newId("task");
227
- // No now_ms / run_at_ms: the DB clock supplies time, so submit passes a
228
- // relative :delay_ms and the SQL computes run_at = now + delay.
229
- const ins = {
230
- id,
231
- name: input.name,
232
- queue: input.queue ?? "default",
233
- payload: JSON.stringify(input.payload ?? {}),
234
- metadata: JSON.stringify(input.metadata ?? {}),
235
- max_attempts: input.maxAttempts ?? 3,
236
- priority: input.priority ?? 0,
237
- delay_ms: input.runAtDelayMs ?? 0,
238
- parent_id: input.parentId ?? null,
239
- root_id: input.rootId ?? id,
240
- correlation_id: input.correlationId ?? null,
241
- };
242
- const key = input.key ?? null;
243
- const conflict = input.conflict ?? "reuse";
244
- if (key === null) return rowToTask((await this.run("insert_task", ins))[0]);
245
-
246
- return this.tx(async (client) => {
247
- const existing = (await this.runOn(client, "get_key", { key })) as { task_id: string }[];
248
- if (existing.length) {
249
- const exId = existing[0].task_id;
250
- if (conflict === "reuse") return rowToTask((await this.runOn(client, "get", { id: exId }))[0]);
251
- if (conflict === "reject") throw new AlreadyExists(key);
252
- if (conflict === "replace") {
253
- await this.runOn(client, "cancel", { id: exId });
254
- const row = (await this.runOn(client, "insert_task", ins))[0];
255
- await this.runOn(client, "upsert_key", { key, task_id: id });
256
- return rowToTask(row);
257
- }
258
- throw new Error(`unknown conflict strategy: ${conflict}`);
259
- }
260
- const row = (await this.runOn(client, "insert_task", ins))[0];
261
- await this.runOn(client, "upsert_key", { key, task_id: id });
262
- return rowToTask(row);
263
+ /** A promise resolving on notification-or-timeout, deregistering either way.
264
+ * The timer is unref'd: while a listener exists its socket keeps the process
265
+ * alive, and dropListener wakes every waiter the moment it goes away. */
266
+ private wakeOn(key: string, timeoutMs: number): Promise<void> {
267
+ return new Promise((resolve) => {
268
+ let set = this.waiters.get(key);
269
+ if (!set) this.waiters.set(key, (set = new Set()));
270
+ const peers = set;
271
+ const waiter = () => {
272
+ clearTimeout(timer);
273
+ peers.delete(waiter);
274
+ if (peers.size === 0) this.waiters.delete(key);
275
+ resolve();
276
+ };
277
+ const timer = setTimeout(waiter, timeoutMs);
278
+ timer.unref?.();
279
+ peers.add(waiter);
263
280
  });
264
281
  }
265
282
 
266
- async get(taskId: string): Promise<Task | null> {
267
- await this.ensure();
268
- const rows = await this.run("get", { id: taskId });
269
- return rows.length ? rowToTask(rows[0]) : null;
270
- }
271
-
272
- async getByKey(key: string): Promise<Task | null> {
273
- await this.ensure();
274
- const rows = await this.run("get_by_key", { key });
275
- return rows.length ? rowToTask(rows[0]) : null;
276
- }
277
-
278
- async list(input: ListInput = {}): Promise<Task[]> {
279
- await this.ensure();
280
- const rows = await this.run("list", {
281
- status: input.status ?? null,
282
- queue: input.queue ?? null,
283
- name: input.name ?? null,
284
- root_id: input.rootId ?? null,
285
- correlation_id: input.correlationId ?? null,
286
- limit: input.limit ?? 100,
287
- offset: input.offset ?? 0,
288
- });
289
- return rows.map(rowToTask);
283
+ /** True once the LISTEN connection is up; starts connecting it otherwise
284
+ * (respecting the transient-failure backoff). Callers fall back to plain
285
+ * polling until it is ready (or forever, if it can't be established) —
286
+ * correctness never depends on it. */
287
+ private listenerReady(): boolean {
288
+ if (this.listener) return true;
289
+ if (!this.listenerUnavailable && !this.listenerConnecting && Date.now() >= this.listenerRetryAt) {
290
+ this.listenerConnecting = this.startListener();
291
+ }
292
+ return false;
290
293
  }
291
294
 
292
- async cancel(taskId: string): Promise<Task | null> {
293
- await this.ensure();
294
- const rows = await this.run("cancel", { id: taskId });
295
- return rows.length ? rowToTask(rows[0]) : null;
295
+ private async startListener(): Promise<void> {
296
+ 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 });
301
+ try {
302
+ await client.connect();
303
+ } catch {
304
+ // Could not even connect — transient. Schedule a backed-off retry;
305
+ // polling covers the gap.
306
+ this.listenerRetryAt = Date.now() + this.listenerBackoffMs;
307
+ this.listenerBackoffMs = Math.min(LISTENER_RETRY_MAX_MS, this.listenerBackoffMs * 2);
308
+ return;
309
+ }
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
+ if (this.listenerUnavailable) {
323
+ void client.end().catch(() => {}); // closed while we were connecting
324
+ return;
325
+ }
326
+ this.listener = client;
327
+ this.listenerBackoffMs = LISTENER_RETRY_MS;
328
+ } catch {
329
+ // Anything unexpected (e.g. `pg` failed to load): off for good rather
330
+ // than a retry loop that cannot succeed.
331
+ this.listenerUnavailable = true;
332
+ } finally {
333
+ this.listenerConnecting = null;
334
+ }
296
335
  }
297
336
 
298
- async cancelByKey(key: string): Promise<Task | null> {
299
- await this.ensure();
300
- return this.tx(async (client) => {
301
- const existing = (await this.runOn(client, "get_key", { key })) as { task_id: string }[];
302
- if (!existing.length) return null;
303
- const rows = await this.runOn(client, "cancel", { id: existing[0].task_id });
304
- return rows.length ? rowToTask(rows[0]) : null;
305
- });
337
+ private onNotification(channel: string, payload: string | undefined): void {
338
+ let key: string;
339
+ if (channel === QUEUED_CHANNEL && payload) {
340
+ this.pendingQueues.add(payload);
341
+ key = "queued";
342
+ } else if (channel === DONE_CHANNEL && payload) {
343
+ key = `done:${payload}`;
344
+ } else {
345
+ return;
346
+ }
347
+ const set = this.waiters.get(key);
348
+ // A waiter only deletes itself, which Set iteration tolerates — no copy.
349
+ if (set) for (const w of set) w();
306
350
  }
307
351
 
308
- async retry(taskId: string, opts: { resetAttempt?: boolean } = {}): Promise<Task | null> {
309
- await this.ensure();
310
- const rows = await this.run("retry", { id: taskId, reset_attempt: opts.resetAttempt ?? false });
311
- return rows.length ? rowToTask(rows[0]) : null;
352
+ private dropListener(): void {
353
+ const client = this.listener;
354
+ this.listener = null;
355
+ if (client) void client.end().catch(() => {});
356
+ // Release everyone promptly; their fallback poll takes over.
357
+ for (const set of [...this.waiters.values()]) for (const w of [...set]) w();
312
358
  }
313
359
 
314
- async retryByKey(key: string, opts: { resetAttempt?: boolean } = {}): Promise<Task | null> {
360
+ // ------------------------------------------------------------ dialect seam
361
+ protected async fetch(name: string, params: Params): Promise<any[]> {
315
362
  await this.ensure();
316
- return this.tx(async (client) => {
317
- const existing = (await this.runOn(client, "get_key", { key })) as { task_id: string }[];
318
- if (!existing.length) return null;
319
- const rows = await this.runOn(client, "retry", {
320
- id: existing[0].task_id,
321
- reset_attempt: opts.resetAttempt ?? false,
322
- });
323
- return rows.length ? rowToTask(rows[0]) : null;
324
- });
363
+ const { text, values } = toPositional(this.statements[name], params);
364
+ return (await this.pool!.query(text, values)).rows;
325
365
  }
326
366
 
327
- // ------------------------------------------------------------- worker side
328
- async claim(input: {
329
- queues: string[];
330
- workerId: string;
331
- leaseMs?: number;
332
- limit?: number;
333
- }): Promise<Task[]> {
367
+ protected async tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T> {
334
368
  await this.ensure();
335
- // No claimable_probe: PG readers don't block writers, so a plain transaction
336
- // (recover then claim) is cheap even when idle. FOR UPDATE SKIP LOCKED in
337
- // claim.sql gives true concurrent, non-contending dispatch.
338
- return this.tx(async (client) => {
339
- await this.runOn(client, "recover_leases", { lease_expired_error: LEASE_EXPIRED_ERROR_JSON });
340
- const rows = await this.runOn(client, "claim", {
341
- queues: input.queues,
342
- worker_id: input.workerId,
343
- lease_ms: input.leaseMs ?? 30_000,
344
- limit: input.limit ?? 1,
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;
345
375
  });
346
- return rows.map(rowToTask);
347
- });
348
- }
349
-
350
- async heartbeat(input: { taskId: string; workerId: string; leaseMs?: number }): Promise<Task> {
351
- await this.ensure();
352
- return this.ownedWrite("heartbeat", input.taskId, {
353
- id: input.taskId,
354
- worker_id: input.workerId,
355
- lease_ms: input.leaseMs ?? 30_000,
356
- });
357
- }
358
-
359
- async progress(input: {
360
- taskId: string;
361
- workerId: string;
362
- progress: number | null;
363
- message: string | null;
364
- }): Promise<Task> {
365
- await this.ensure();
366
- return this.ownedWrite("progress", input.taskId, {
367
- id: input.taskId,
368
- worker_id: input.workerId,
369
- progress: input.progress,
370
- message: input.message,
371
- });
372
- }
373
-
374
- async succeed(input: { taskId: string; workerId: string; result: unknown }): Promise<Task> {
375
- await this.ensure();
376
- return this.ownedWrite("succeed", input.taskId, {
377
- id: input.taskId,
378
- worker_id: input.workerId,
379
- result: input.result == null ? null : JSON.stringify(input.result),
380
- message: null,
381
- });
382
- }
383
-
384
- async complete(input: { taskId: string; workerId: string; result: unknown }): Promise<Task> {
385
- await this.ensure();
386
- return this.ownedWrite("complete", input.taskId, {
387
- id: input.taskId,
388
- worker_id: input.workerId,
389
- result: input.result == null ? null : JSON.stringify(input.result),
390
- });
391
- }
392
-
393
- async fail(input: {
394
- taskId: string;
395
- workerId: string;
396
- error: unknown;
397
- retryable?: boolean;
398
- delayMs?: number;
399
- }): Promise<Task> {
400
- await this.ensure();
401
- return this.ownedWrite("fail", input.taskId, {
402
- id: input.taskId,
403
- worker_id: input.workerId,
404
- error: JSON.stringify(input.error ?? {}),
405
- retryable: input.retryable !== false,
406
- delay_ms: input.delayMs ?? 0,
407
- });
376
+ await client.query("commit");
377
+ return out;
378
+ } catch (e) {
379
+ await rollbackQuietly(client);
380
+ throw e;
381
+ } finally {
382
+ client.release();
383
+ }
408
384
  }
409
385
  }