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.
- package/README.md +69 -4
- package/dist/_protocol/migrations/postgres/0007_purge_status_index.sql +11 -0
- package/dist/_protocol/migrations/sqlite/0007_purge_status_index.sql +11 -0
- package/dist/_protocol/sql/postgres/get_status.sql +7 -0
- package/dist/_protocol/sql/postgres/get_status_by_key.sql +7 -0
- package/dist/_protocol/sql/postgres/purge.sql +8 -1
- package/dist/_protocol/sql/sqlite/get_status.sql +6 -0
- package/dist/_protocol/sql/sqlite/get_status_by_key.sql +7 -0
- package/dist/_protocol/sql/sqlite/purge.sql +7 -1
- package/dist/client.d.ts +32 -15
- package/dist/client.js +38 -12
- package/dist/context.d.ts +25 -0
- package/dist/context.js +33 -0
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +7 -4
- package/dist/index.d.ts +9 -5
- package/dist/index.js +4 -1
- package/dist/models.d.ts +14 -2
- package/dist/models.js +12 -1
- package/dist/retention.d.ts +15 -3
- package/dist/retention.js +36 -14
- package/dist/store/base.d.ts +118 -1
- package/dist/store/base.js +122 -20
- package/dist/store/pg-executor.d.ts +77 -0
- package/dist/store/pg-executor.js +26 -0
- package/dist/store/pg-pool.d.ts +16 -0
- package/dist/store/pg-pool.js +147 -0
- package/dist/store/postgres.d.ts +41 -13
- package/dist/store/postgres.js +135 -147
- package/dist/store/sqlite.js +20 -2
- package/dist/wait.d.ts +9 -4
- package/dist/wait.js +27 -13
- package/dist/worker.d.ts +6 -3
- package/dist/worker.js +6 -5
- package/package.json +6 -4
- package/src/client.ts +60 -21
- package/src/context.ts +37 -0
- package/src/errors.ts +7 -4
- package/src/index.ts +16 -4
- package/src/models.ts +24 -3
- package/src/retention.ts +45 -15
- package/src/store/base.ts +204 -9
- package/src/store/pg-executor.ts +90 -0
- package/src/store/pg-pool.ts +156 -0
- package/src/store/postgres.ts +144 -141
- package/src/store/sqlite.ts +23 -2
- package/src/wait.ts +35 -14
- package/src/worker.ts +8 -6
package/src/store/base.ts
CHANGED
|
@@ -6,7 +6,15 @@ import {
|
|
|
6
6
|
ProtocolVersionMismatch,
|
|
7
7
|
SerializationError,
|
|
8
8
|
} from "../errors.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
isTerminalStatus,
|
|
11
|
+
rowToRef,
|
|
12
|
+
rowToTask,
|
|
13
|
+
STATUSES,
|
|
14
|
+
type Task,
|
|
15
|
+
type TaskRef,
|
|
16
|
+
type TaskStatus,
|
|
17
|
+
} from "../models.js";
|
|
10
18
|
import { type BackpressureOptions, QueueDepthGate } from "../backpressure.js";
|
|
11
19
|
|
|
12
20
|
const rejectMangled = function (this: unknown, _key: string, v: unknown): unknown {
|
|
@@ -77,7 +85,7 @@ export type Conflict = (typeof CONFLICTS)[number];
|
|
|
77
85
|
*/
|
|
78
86
|
function reusable(conflict: Conflict, status: TaskStatus): boolean {
|
|
79
87
|
if (conflict === "replace") return false;
|
|
80
|
-
if (!
|
|
88
|
+
if (!isTerminalStatus(status)) return true;
|
|
81
89
|
return conflict === "reuse-succeeded" && status === "succeeded";
|
|
82
90
|
}
|
|
83
91
|
|
|
@@ -110,8 +118,32 @@ export interface ListInput {
|
|
|
110
118
|
offset?: number;
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
/** Validate a purge's inputs. Shared with RetentionSweeper, which fail-fasts at
|
|
122
|
+
* construction on the same rules an hourly sweep would otherwise only surface
|
|
123
|
+
* through its onError hook — one statement of the rules, two callers. */
|
|
124
|
+
export function validatePurgeInput(input: PurgeInput): void {
|
|
125
|
+
if (input.olderThanMs != null && (!Number.isFinite(input.olderThanMs) || input.olderThanMs < 0)) {
|
|
126
|
+
throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
|
|
127
|
+
}
|
|
128
|
+
if (input.limit != null && input.limit < 1) {
|
|
129
|
+
throw new Error(`limit must be >= 1, got ${input.limit}`);
|
|
130
|
+
}
|
|
131
|
+
// Terminal only: purge never deletes live work, so accepting `queued` here
|
|
132
|
+
// would be accepting a filter that silently matches nothing.
|
|
133
|
+
if (input.status != null && !isTerminalStatus(input.status)) {
|
|
134
|
+
throw new Error(`status must be terminal, got ${input.status}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
export interface PurgeInput {
|
|
114
139
|
olderThanMs?: number;
|
|
140
|
+
/** Restrict the sweep to one terminal status. Retention needs are tiered —
|
|
141
|
+
* succeeded rows are spent once their result is consumed, failed ones are
|
|
142
|
+
* worth keeping for diagnosis — and without this the shortest-lived tier
|
|
143
|
+
* sets the retention for every row. Absent means all terminal statuses. */
|
|
144
|
+
status?: TaskStatus;
|
|
145
|
+
/** Restrict the sweep to one task name. Absent means all names. */
|
|
146
|
+
name?: string;
|
|
115
147
|
limit?: number;
|
|
116
148
|
}
|
|
117
149
|
|
|
@@ -169,6 +201,35 @@ export function statementParams(sql: string): readonly string[] {
|
|
|
169
201
|
* them in one place is what stops SQLite and Postgres from drifting apart in
|
|
170
202
|
* behavior; the shared SQL already stops them from drifting in wording.
|
|
171
203
|
*/
|
|
204
|
+
/**
|
|
205
|
+
* Why `watch` is calling back.
|
|
206
|
+
*
|
|
207
|
+
* `queued` / `done` come from the store's push channel and name what moved;
|
|
208
|
+
* `poll` is the timer saying the watch cannot rule out a change. None of them
|
|
209
|
+
* carries state — the row is the truth.
|
|
210
|
+
*/
|
|
211
|
+
export interface WatchSignal {
|
|
212
|
+
reason: "queued" | "done" | "poll";
|
|
213
|
+
/** The queue a task was queued on. Only on `queued`. */
|
|
214
|
+
queue?: string;
|
|
215
|
+
/** The task that reached a terminal status. Only on `done`. */
|
|
216
|
+
taskId?: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface WatchOptions {
|
|
220
|
+
/** Restrict `queued` signals to these queues. Unset watches every queue. */
|
|
221
|
+
queues?: string[];
|
|
222
|
+
/**
|
|
223
|
+
* How often to signal in the absence of a push channel — and, where there is
|
|
224
|
+
* one, how long a dropped listener can go unnoticed. The default trades a
|
|
225
|
+
* dashboard's idle query rate against how stale it may look.
|
|
226
|
+
*/
|
|
227
|
+
pollMs?: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** See WatchOptions.pollMs. */
|
|
231
|
+
export const DEFAULT_WATCH_POLL_MS = 2_000;
|
|
232
|
+
|
|
172
233
|
export abstract class TaskStore {
|
|
173
234
|
/** Set by useBackpressure; null means submit is ungated. */
|
|
174
235
|
private gate: QueueDepthGate | null = null;
|
|
@@ -190,6 +251,37 @@ export abstract class TaskStore {
|
|
|
190
251
|
*/
|
|
191
252
|
protected abstract tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
|
|
192
253
|
|
|
254
|
+
/**
|
|
255
|
+
* `tx`, but also handing `fn` the driver's own session so the CALLER can run
|
|
256
|
+
* their statements in the same transaction as the protocol's.
|
|
257
|
+
*
|
|
258
|
+
* This is what lets a task's settlement and the rows that task produced commit
|
|
259
|
+
* together. Without it the two are separate transactions and there is a window
|
|
260
|
+
* where the work is durable but the task still reads as unfinished — a crash
|
|
261
|
+
* there costs a full recomputation on retry, and for non-idempotent work costs
|
|
262
|
+
* more than that.
|
|
263
|
+
*
|
|
264
|
+
* Optional, because the session type is the driver's, not the protocol's: a
|
|
265
|
+
* store that has no session worth handing out simply does not implement it and
|
|
266
|
+
* `completeIn` reports that. Postgres implements it; SQLite does not.
|
|
267
|
+
*/
|
|
268
|
+
protected txWithSession?<T>(fn: (fetch: Fetch, session: unknown) => Promise<T>): Promise<T>;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Register for this store's push channel, if it has one; returns an
|
|
272
|
+
* unsubscribe. A store without a push channel does not implement this, and
|
|
273
|
+
* `watch` degrades to its timer alone.
|
|
274
|
+
*/
|
|
275
|
+
protected subscribePush?(onSignal: (signal: WatchSignal) => void): () => void;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Nudge the push channel back up if it has dropped. Called from `watch`'s
|
|
279
|
+
* timer, which is the only thing keeping a client-side subscriber alive: a
|
|
280
|
+
* process that never claims never calls claimWake, so without this a listener
|
|
281
|
+
* that died once would never come back there.
|
|
282
|
+
*/
|
|
283
|
+
protected warmPush?(): void;
|
|
284
|
+
|
|
193
285
|
/**
|
|
194
286
|
* Whether it is worth opening the claim transaction at all. SQLite gates its
|
|
195
287
|
* single write lock behind a read-only probe; Postgres readers don't block
|
|
@@ -234,6 +326,10 @@ export abstract class TaskStore {
|
|
|
234
326
|
return rows.length ? rowToTask(rows[0]) : null;
|
|
235
327
|
}
|
|
236
328
|
|
|
329
|
+
private static oneRef(rows: any[]): TaskRef | null {
|
|
330
|
+
return rows.length ? rowToRef(rows[0]) : null;
|
|
331
|
+
}
|
|
332
|
+
|
|
237
333
|
// ------------------------------------------------------------- client side
|
|
238
334
|
/**
|
|
239
335
|
* Bound how deep a queue may get before `submit` blocks. Off unless set.
|
|
@@ -305,7 +401,7 @@ export abstract class TaskStore {
|
|
|
305
401
|
// fresh one inserted below. Cancel only what is still live: a terminal
|
|
306
402
|
// task has nothing to stop, and cancelling it would rewrite a settled
|
|
307
403
|
// row (and hand a `canceled` back to whoever is waiting on it).
|
|
308
|
-
if (!
|
|
404
|
+
if (!isTerminalStatus(current.status as TaskStatus)) {
|
|
309
405
|
await fetch("cancel", { id: existing[0].task_id });
|
|
310
406
|
}
|
|
311
407
|
}
|
|
@@ -324,6 +420,18 @@ export abstract class TaskStore {
|
|
|
324
420
|
return TaskStore.one(await this.fetch("get_by_key", { key }));
|
|
325
421
|
}
|
|
326
422
|
|
|
423
|
+
/** The wait loop's probe: id + status alone, so polling a task with a large
|
|
424
|
+
* payload does not re-read and re-parse that payload on every beat. */
|
|
425
|
+
async getStatus(taskId: string): Promise<TaskRef | null> {
|
|
426
|
+
return TaskStore.oneRef(await this.fetch("get_status", { id: taskId }));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** getStatus, following a key instead of an id — re-resolved per call, so a
|
|
430
|
+
* `replace` moves the probe onto the new task. */
|
|
431
|
+
async getStatusByKey(key: string): Promise<TaskRef | null> {
|
|
432
|
+
return TaskStore.oneRef(await this.fetch("get_status_by_key", { key }));
|
|
433
|
+
}
|
|
434
|
+
|
|
327
435
|
async list(input: ListInput = {}): Promise<Task[]> {
|
|
328
436
|
// Validate up front, like submit's conflict guard: a typo'd status otherwise
|
|
329
437
|
// matches nothing and returns [] indistinguishably from "no such tasks".
|
|
@@ -385,14 +493,11 @@ export abstract class TaskStore {
|
|
|
385
493
|
* call it in a loop until it returns fewer than `limit`.
|
|
386
494
|
*/
|
|
387
495
|
async purge(input: PurgeInput = {}): Promise<string[]> {
|
|
388
|
-
|
|
389
|
-
throw new Error(`olderThanMs must be >= 0, got ${input.olderThanMs}`);
|
|
390
|
-
}
|
|
391
|
-
if (input.limit != null && input.limit < 1) {
|
|
392
|
-
throw new Error(`limit must be >= 1, got ${input.limit}`);
|
|
393
|
-
}
|
|
496
|
+
validatePurgeInput(input);
|
|
394
497
|
const rows = await this.fetch("purge", {
|
|
395
498
|
older_than_ms: input.olderThanMs ?? 0,
|
|
499
|
+
status: input.status ?? null,
|
|
500
|
+
name: input.name ?? null,
|
|
396
501
|
limit: input.limit ?? 1_000,
|
|
397
502
|
});
|
|
398
503
|
return rows.map((r) => r.id as string);
|
|
@@ -415,6 +520,57 @@ export abstract class TaskStore {
|
|
|
415
520
|
return out;
|
|
416
521
|
}
|
|
417
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Call `onSignal` when the tasks on `queues` may have changed — something was
|
|
525
|
+
* queued, or something finished.
|
|
526
|
+
*
|
|
527
|
+
* This is notify-ACCELERATED POLLING, not an event log, and the difference is
|
|
528
|
+
* the whole contract. Where a push channel is available (Postgres LISTEN) an
|
|
529
|
+
* idle watch costs nothing and a signal arrives within milliseconds of the
|
|
530
|
+
* event. Where it is not — a transaction-mode pooler refuses LISTEN, SQLite has
|
|
531
|
+
* no channel at all — the timer alone still delivers `poll` signals, so a
|
|
532
|
+
* consumer that re-reads on every signal is correct in both cases and merely
|
|
533
|
+
* less prompt in one.
|
|
534
|
+
*
|
|
535
|
+
* What it will NOT do is promise that a signal means something happened, or
|
|
536
|
+
* that every event produces its own signal. Treat a signal as "re-read now"
|
|
537
|
+
* and take the truth from `stats()` / `list()` / `get()`, which is where it
|
|
538
|
+
* lives. `reason` is a hint for reading less: a `done` signal names the task,
|
|
539
|
+
* so a dashboard can refresh that row instead of the list.
|
|
540
|
+
*
|
|
541
|
+
* Returns an unsubscribe. The timer is unref'd — watching does not hold a
|
|
542
|
+
* process open.
|
|
543
|
+
*/
|
|
544
|
+
watch(opts: WatchOptions, onSignal: (signal: WatchSignal) => void): () => void {
|
|
545
|
+
const pollMs = Math.max(1, opts.pollMs ?? DEFAULT_WATCH_POLL_MS);
|
|
546
|
+
const queues = opts.queues ?? null;
|
|
547
|
+
let live = true;
|
|
548
|
+
const emit = (signal: WatchSignal): void => {
|
|
549
|
+
// A signal delivered after unsubscribe would have the consumer re-reading
|
|
550
|
+
// a store it has stopped caring about, possibly a closed one.
|
|
551
|
+
if (live) onSignal(signal);
|
|
552
|
+
};
|
|
553
|
+
const unsubscribe = this.subscribePush?.((signal) => {
|
|
554
|
+
// A queued signal names its queue, so a watch scoped to some queues can
|
|
555
|
+
// drop the rest. A done signal names only the task — which queue it was on
|
|
556
|
+
// is not in the notification, so it is never filtered out.
|
|
557
|
+
if (signal.reason === "queued" && queues && signal.queue && !queues.includes(signal.queue)) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
emit(signal);
|
|
561
|
+
});
|
|
562
|
+
const timer = setInterval(() => {
|
|
563
|
+
this.warmPush?.();
|
|
564
|
+
emit({ reason: "poll" });
|
|
565
|
+
}, pollMs);
|
|
566
|
+
timer.unref?.();
|
|
567
|
+
return () => {
|
|
568
|
+
live = false;
|
|
569
|
+
unsubscribe?.();
|
|
570
|
+
clearInterval(timer);
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
418
574
|
/**
|
|
419
575
|
* How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
|
|
420
576
|
*
|
|
@@ -591,6 +747,45 @@ export abstract class TaskStore {
|
|
|
591
747
|
});
|
|
592
748
|
}
|
|
593
749
|
|
|
750
|
+
/**
|
|
751
|
+
* `complete`, with the caller's own writes committed in the same transaction.
|
|
752
|
+
*
|
|
753
|
+
* `fn` runs first and whatever it returns becomes the task's result; the
|
|
754
|
+
* settlement is the last statement in the transaction. So a lost lease — the
|
|
755
|
+
* settlement matching no row — rolls the caller's writes back with it, and
|
|
756
|
+
* there is no ordering in which the work is recorded but the task is not.
|
|
757
|
+
*
|
|
758
|
+
* The settlement runs LAST rather than checking ownership up front on purpose:
|
|
759
|
+
* the ownership predicate lives in the protocol's complete.sql, and a
|
|
760
|
+
* fail-fast pre-check here would be a second copy of it, free to drift. The
|
|
761
|
+
* cost of that choice is that a doomed attempt does its work before finding
|
|
762
|
+
* out, which is the rare path.
|
|
763
|
+
*
|
|
764
|
+
* `fn` must be replayable for the same reason `tx`'s callback must be.
|
|
765
|
+
*/
|
|
766
|
+
async completeIn<S, T>(
|
|
767
|
+
input: { taskId: string; workerId: string },
|
|
768
|
+
fn: (session: S) => Promise<T>,
|
|
769
|
+
): Promise<{ task: Task; value: T }> {
|
|
770
|
+
if (!this.txWithSession) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
"this store cannot share a transaction with the caller — " +
|
|
773
|
+
"completeIn requires a Postgres store (see PgExecutor)",
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
return this.txWithSession(async (fetch, session) => {
|
|
777
|
+
const value = await fn(session as S);
|
|
778
|
+
const rows = await fetch("complete", {
|
|
779
|
+
id: input.taskId,
|
|
780
|
+
worker_id: input.workerId,
|
|
781
|
+
result: value == null ? null : dumpJson(value),
|
|
782
|
+
});
|
|
783
|
+
// Rolls back `fn`'s writes along with the settlement that did not land.
|
|
784
|
+
if (!rows.length) throw new LostLease(input.taskId);
|
|
785
|
+
return { task: rowToTask(rows[0]), value };
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
594
789
|
async fail(input: {
|
|
595
790
|
taskId: string;
|
|
596
791
|
workerId: string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between PostgresStore and whatever actually talks to Postgres.
|
|
3
|
+
*
|
|
4
|
+
* PostgresStore owns the *dialect* — the protocol's named-parameter SQL rewritten
|
|
5
|
+
* to `$n`, the migration ledger, the LISTEN policy. It does not own the
|
|
6
|
+
* *connection*. Applications that already run a Postgres driver (an ORM, a pool
|
|
7
|
+
* they size themselves) pass their own executor and cairnq joins that session
|
|
8
|
+
* instead of opening a second one, which is what makes a task's settlement
|
|
9
|
+
* commit in the same transaction as the rows the task produced.
|
|
10
|
+
*
|
|
11
|
+
* Implementing one is small — see `createPoolExecutor` below for the reference
|
|
12
|
+
* implementation over `pg`, and PROTOCOL.md for the two things an adapter must
|
|
13
|
+
* get right that are easy to miss: int8 must come back as a JS number (every
|
|
14
|
+
* cairnq bigint is an epoch-ms or a counter, all inside the safe range), and
|
|
15
|
+
* jsonb must come back as an object, not a string.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A row as the driver hands it back: column name -> value. */
|
|
19
|
+
export type Row = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Somewhere statements can run. The same shape whether it is a pool (each call
|
|
23
|
+
* on some connection) or one transaction's dedicated connection — the store's
|
|
24
|
+
* statements do not care, and this is what lets `tx` hand the same interface to
|
|
25
|
+
* its callback.
|
|
26
|
+
*/
|
|
27
|
+
export interface PgSession {
|
|
28
|
+
/**
|
|
29
|
+
* One parameterised statement, `$1`-style. `values` is positional and may
|
|
30
|
+
* legitimately contain nulls — a null parameter is "this filter is off" in
|
|
31
|
+
* several protocol statements, not a missing argument.
|
|
32
|
+
*/
|
|
33
|
+
query(text: string, values: readonly unknown[]): Promise<Row[]>;
|
|
34
|
+
/**
|
|
35
|
+
* Parameterless SQL that may hold several statements, for migration DDL.
|
|
36
|
+
* Separate from `query` because it must go over the simple query protocol:
|
|
37
|
+
* the extended protocol a parameterised call uses accepts only one statement,
|
|
38
|
+
* and every migration is a script.
|
|
39
|
+
*/
|
|
40
|
+
exec(sql: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A session that can also open transactions, listen, and be shut down. */
|
|
44
|
+
export interface PgExecutor extends PgSession {
|
|
45
|
+
/**
|
|
46
|
+
* Run `fn` inside one transaction on one dedicated connection, committing if
|
|
47
|
+
* it returns and rolling back if it throws. The store relies on both halves:
|
|
48
|
+
* a claim that cannot see its own `recover_leases` is a double-dispatch, and a
|
|
49
|
+
* keyed submit that commits half way poisons the key.
|
|
50
|
+
*/
|
|
51
|
+
tx<T>(fn: (session: PgSession) => Promise<T>): Promise<T>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Subscribe a dedicated connection to `channels`. Optional: an executor that
|
|
55
|
+
* omits it (or a Postgres that refuses LISTEN) costs latency, never
|
|
56
|
+
* correctness — the store falls back to plain polling, which is the contract
|
|
57
|
+
* PROTOCOL.md gives for push wakeups.
|
|
58
|
+
*
|
|
59
|
+
* Resolves with a function that stops listening. `onClose` reports a
|
|
60
|
+
* connection that dropped on its own, so the store can degrade and retry.
|
|
61
|
+
*
|
|
62
|
+
* Throw `ListenUnavailable` when this Postgres will never accept LISTEN — a
|
|
63
|
+
* transaction-mode pooler, say. Any other rejection is read as transient and
|
|
64
|
+
* retried with backoff, so a permanent condition raised as a plain Error
|
|
65
|
+
* becomes a reconnect loop that cannot succeed.
|
|
66
|
+
*/
|
|
67
|
+
listen?(
|
|
68
|
+
channels: readonly string[],
|
|
69
|
+
onNotify: (channel: string, payload: string | undefined) => void,
|
|
70
|
+
onClose: () => void,
|
|
71
|
+
): Promise<() => void>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Release this executor's resources. Called by PostgresStore.close() ONLY for
|
|
75
|
+
* an executor the store created itself: an injected one belongs to the caller,
|
|
76
|
+
* whose other work would not survive cairnq closing it.
|
|
77
|
+
*/
|
|
78
|
+
close(): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* LISTEN will not work on this connection, and retrying cannot change that.
|
|
83
|
+
* See `PgExecutor.listen`.
|
|
84
|
+
*/
|
|
85
|
+
export class ListenUnavailable extends Error {
|
|
86
|
+
constructor(message = "LISTEN is not available on this connection") {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = "ListenUnavailable";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type * as PG from "pg";
|
|
2
|
+
|
|
3
|
+
import { ListenUnavailable, type PgExecutor, type PgSession, type Row } from "./pg-executor.js";
|
|
4
|
+
|
|
5
|
+
// `pg` is an optional dependency: the SDK is SQLite-first, so it's loaded lazily
|
|
6
|
+
// the first time a pool-backed executor is built. Absent -> a clear install hint.
|
|
7
|
+
// An application that brings its own executor never reaches this.
|
|
8
|
+
let pgModule: typeof import("pg") | null = null;
|
|
9
|
+
async function loadPg(): Promise<typeof import("pg")> {
|
|
10
|
+
if (pgModule) return pgModule;
|
|
11
|
+
let mod: { default?: typeof import("pg") } & typeof import("pg");
|
|
12
|
+
try {
|
|
13
|
+
mod = (await import("pg")) as never;
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error("PostgresStore requires the 'pg' package — install it (e.g. `npm i pg`)");
|
|
16
|
+
}
|
|
17
|
+
const pg = (mod.default ?? mod) as typeof import("pg");
|
|
18
|
+
// Postgres returns bigint (int8, OID 20) as a string to avoid precision loss.
|
|
19
|
+
// Every cairnq bigint is an epoch-ms or a counter, all within Number's safe
|
|
20
|
+
// integer range, so parse to number once (globally) to match the Task model
|
|
21
|
+
// (*_ms typed as number, same as the SQLite SDK). Set before any query runs.
|
|
22
|
+
pg.types.setTypeParser(pg.types.builtins.INT8, (v: string) => (v == null ? null : Number(v)));
|
|
23
|
+
pgModule = pg;
|
|
24
|
+
return pg;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Roll back on the way out of a failed transaction, without letting the rollback
|
|
29
|
+
* become the error the caller sees. A dropped connection fails both the statement
|
|
30
|
+
* and the rollback, and it is the first one that says what went wrong.
|
|
31
|
+
*/
|
|
32
|
+
async function rollbackQuietly(client: PG.PoolClient): Promise<void> {
|
|
33
|
+
try {
|
|
34
|
+
await client.query("rollback");
|
|
35
|
+
} catch {
|
|
36
|
+
// Already rolled back, or the connection is gone. Either way the original
|
|
37
|
+
// error is the one worth propagating.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The PgSession face of one pg client or pool. */
|
|
42
|
+
function session(q: Pick<PG.PoolClient, "query">): PgSession {
|
|
43
|
+
return {
|
|
44
|
+
async query(text: string, values: readonly unknown[]): Promise<Row[]> {
|
|
45
|
+
return (await q.query(text, values as unknown[])).rows;
|
|
46
|
+
},
|
|
47
|
+
async exec(sql: string): Promise<void> {
|
|
48
|
+
// No values: pg sends this over the simple query protocol, which is what
|
|
49
|
+
// makes a multi-statement migration script legal here and not in query().
|
|
50
|
+
await q.query(sql);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A Postgres identifier that is safe to interpolate — cairnq quotes the schema
|
|
57
|
+
* name, and a name that could close that quote could rewrite the statement.
|
|
58
|
+
* Deliberately narrower than what Postgres accepts: a schema cairnq is asked to
|
|
59
|
+
* live in is a deployment decision, not a place to be clever.
|
|
60
|
+
*/
|
|
61
|
+
const PLAIN_IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The built-in executor: a `pg.Pool` over a libpq DSN. What `CairnQ.postgres(dsn)`
|
|
65
|
+
* uses, and the reference for what an adapter over another driver must do.
|
|
66
|
+
*
|
|
67
|
+
* Creating it does not connect — `pg.Pool` is lazy, and the store's first
|
|
68
|
+
* statement (the migration ledger) is what proves the database is reachable.
|
|
69
|
+
*
|
|
70
|
+
* `schema` puts cairnq's tables in a schema of their own rather than in whatever
|
|
71
|
+
* the connection's search_path leads with. The protocol's SQL names no schema, so
|
|
72
|
+
* this is a per-connection `search_path` and not one statement changes.
|
|
73
|
+
*/
|
|
74
|
+
export async function createPoolExecutor(
|
|
75
|
+
dsn: string,
|
|
76
|
+
opts: { max?: number; schema?: string } = {},
|
|
77
|
+
): Promise<PgExecutor> {
|
|
78
|
+
const pg = await loadPg();
|
|
79
|
+
const schema = opts.schema;
|
|
80
|
+
if (schema !== undefined && !PLAIN_IDENT.test(schema)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`schema must be a plain identifier (letters, digits, _ and $, not starting ` +
|
|
83
|
+
`with a digit), got ${JSON.stringify(schema)}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const pool = new pg.Pool({ connectionString: dsn, max: opts.max });
|
|
87
|
+
if (schema) {
|
|
88
|
+
// Queued on the connection before it is handed out, so every statement this
|
|
89
|
+
// pool ever runs — migrations included — resolves in the right schema. Pooled
|
|
90
|
+
// connections come and go, which is why this is per-connection rather than a
|
|
91
|
+
// one-off at startup.
|
|
92
|
+
pool.on("connect", (client) => {
|
|
93
|
+
void client.query(`set search_path to "${schema}"`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (schema) {
|
|
97
|
+
// Created once, here, rather than from the connect handler (which would ask
|
|
98
|
+
// for CREATE privilege on every new connection) or from the migrations
|
|
99
|
+
// (which name no schema, by design). Without it the first `create table`
|
|
100
|
+
// fails with "no schema has been selected to create in", which says nothing
|
|
101
|
+
// about the cause. This is the one thing that makes building the executor
|
|
102
|
+
// connect; without `schema` it stays lazy.
|
|
103
|
+
try {
|
|
104
|
+
await pool.query(`create schema if not exists "${schema}"`);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
await pool.end();
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const poolSession = session(pool);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
query: poolSession.query,
|
|
114
|
+
exec: poolSession.exec,
|
|
115
|
+
|
|
116
|
+
async tx<T>(fn: (s: PgSession) => Promise<T>): Promise<T> {
|
|
117
|
+
const client = await pool.connect();
|
|
118
|
+
try {
|
|
119
|
+
await client.query("begin");
|
|
120
|
+
const out = await fn(session(client));
|
|
121
|
+
await client.query("commit");
|
|
122
|
+
return out;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
await rollbackQuietly(client);
|
|
125
|
+
throw e;
|
|
126
|
+
} finally {
|
|
127
|
+
client.release();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async listen(channels, onNotify, onClose): Promise<() => void> {
|
|
132
|
+
// Built from the raw DSN rather than taken from the pool: a listener holds
|
|
133
|
+
// its connection for its whole life, and a pooled one would be a slot the
|
|
134
|
+
// store never gives back. If `opts` ever grows connection-level settings
|
|
135
|
+
// (ssl, application_name), the listener must receive them too.
|
|
136
|
+
const client = new pg.Client({ connectionString: dsn });
|
|
137
|
+
await client.connect(); // failure here is transient: caller retries with backoff
|
|
138
|
+
client.on("notification", (msg) => onNotify(msg.channel, msg.payload));
|
|
139
|
+
// A dropped listener degrades to polling; the store reconnects on the next wake.
|
|
140
|
+
client.on("error", () => onClose());
|
|
141
|
+
try {
|
|
142
|
+
await client.query(channels.map((c) => `listen ${c}`).join("; "));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
void client.end().catch(() => {});
|
|
145
|
+
// Connected, but LISTEN was refused (e.g. a transaction-mode pooler) —
|
|
146
|
+
// deterministic, so tell the store not to retry.
|
|
147
|
+
throw new ListenUnavailable(e instanceof Error ? e.message : undefined);
|
|
148
|
+
}
|
|
149
|
+
return () => void client.end().catch(() => {});
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async close(): Promise<void> {
|
|
153
|
+
await pool.end();
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|