cairnq 0.8.0 → 0.10.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 +73 -2
- package/dist/_protocol/sql/postgres/installations.sql +33 -0
- package/dist/client.d.ts +19 -4
- package/dist/client.js +20 -5
- package/dist/context.d.ts +29 -2
- package/dist/context.js +44 -7
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +20 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/models.js +27 -0
- package/dist/store/base.d.ts +99 -0
- package/dist/store/base.js +87 -11
- package/dist/store/pg-executor.d.ts +78 -0
- package/dist/store/pg-executor.js +27 -0
- package/dist/store/pg-pool.d.ts +16 -0
- package/dist/store/pg-pool.js +148 -0
- package/dist/store/postgres.d.ts +64 -13
- package/dist/store/postgres.js +191 -147
- package/dist/store/sqlite.js +20 -2
- package/dist/worker.d.ts +10 -5
- package/dist/worker.js +16 -13
- package/package.json +6 -4
- package/src/client.ts +33 -6
- package/src/context.ts +46 -5
- package/src/errors.ts +21 -0
- package/src/index.ts +13 -1
- package/src/models.ts +27 -0
- package/src/store/base.ts +150 -0
- package/src/store/pg-executor.ts +91 -0
- package/src/store/pg-pool.ts +157 -0
- package/src/store/postgres.ts +205 -141
- package/src/store/sqlite.ts +23 -2
- package/src/worker.ts +18 -14
package/dist/store/base.js
CHANGED
|
@@ -122,17 +122,8 @@ export function statementParams(sql) {
|
|
|
122
122
|
}
|
|
123
123
|
return names;
|
|
124
124
|
}
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
*
|
|
128
|
-
* A backend supplies three things: how to run one protocol statement, how to run
|
|
129
|
-
* several inside a transaction, and how its dialect binds parameters. Everything
|
|
130
|
-
* above that — the submit conflict branches, the *_by_key lookups, the
|
|
131
|
-
* recover-then-claim sequence, the ownership-checked writes — lives here once,
|
|
132
|
-
* because those are protocol decisions rather than storage decisions. Keeping
|
|
133
|
-
* them in one place is what stops SQLite and Postgres from drifting apart in
|
|
134
|
-
* behavior; the shared SQL already stops them from drifting in wording.
|
|
135
|
-
*/
|
|
125
|
+
/** See WatchOptions.pollMs. */
|
|
126
|
+
export const DEFAULT_WATCH_POLL_MS = 2_000;
|
|
136
127
|
export class TaskStore {
|
|
137
128
|
/** Set by useBackpressure; null means submit is ungated. */
|
|
138
129
|
gate = null;
|
|
@@ -354,6 +345,57 @@ export class TaskStore {
|
|
|
354
345
|
}
|
|
355
346
|
return out;
|
|
356
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* Call `onSignal` when the tasks on `queues` may have changed — something was
|
|
350
|
+
* queued, or something finished.
|
|
351
|
+
*
|
|
352
|
+
* This is notify-ACCELERATED POLLING, not an event log, and the difference is
|
|
353
|
+
* the whole contract. Where a push channel is available (Postgres LISTEN) an
|
|
354
|
+
* idle watch costs nothing and a signal arrives within milliseconds of the
|
|
355
|
+
* event. Where it is not — a transaction-mode pooler refuses LISTEN, SQLite has
|
|
356
|
+
* no channel at all — the timer alone still delivers `poll` signals, so a
|
|
357
|
+
* consumer that re-reads on every signal is correct in both cases and merely
|
|
358
|
+
* less prompt in one.
|
|
359
|
+
*
|
|
360
|
+
* What it will NOT do is promise that a signal means something happened, or
|
|
361
|
+
* that every event produces its own signal. Treat a signal as "re-read now"
|
|
362
|
+
* and take the truth from `stats()` / `list()` / `get()`, which is where it
|
|
363
|
+
* lives. `reason` is a hint for reading less: a `done` signal names the task,
|
|
364
|
+
* so a dashboard can refresh that row instead of the list.
|
|
365
|
+
*
|
|
366
|
+
* Returns an unsubscribe. The timer is unref'd — watching does not hold a
|
|
367
|
+
* process open.
|
|
368
|
+
*/
|
|
369
|
+
watch(opts, onSignal) {
|
|
370
|
+
const pollMs = Math.max(1, opts.pollMs ?? DEFAULT_WATCH_POLL_MS);
|
|
371
|
+
const queues = opts.queues ?? null;
|
|
372
|
+
let live = true;
|
|
373
|
+
const emit = (signal) => {
|
|
374
|
+
// A signal delivered after unsubscribe would have the consumer re-reading
|
|
375
|
+
// a store it has stopped caring about, possibly a closed one.
|
|
376
|
+
if (live)
|
|
377
|
+
onSignal(signal);
|
|
378
|
+
};
|
|
379
|
+
const unsubscribe = this.subscribePush?.((signal) => {
|
|
380
|
+
// A queued signal names its queue, so a watch scoped to some queues can
|
|
381
|
+
// drop the rest. A done signal names only the task — which queue it was on
|
|
382
|
+
// is not in the notification, so it is never filtered out.
|
|
383
|
+
if (signal.reason === "queued" && queues && signal.queue && !queues.includes(signal.queue)) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
emit(signal);
|
|
387
|
+
});
|
|
388
|
+
const timer = setInterval(() => {
|
|
389
|
+
this.warmPush?.();
|
|
390
|
+
emit({ reason: "poll" });
|
|
391
|
+
}, pollMs);
|
|
392
|
+
timer.unref?.();
|
|
393
|
+
return () => {
|
|
394
|
+
live = false;
|
|
395
|
+
unsubscribe?.();
|
|
396
|
+
clearInterval(timer);
|
|
397
|
+
};
|
|
398
|
+
}
|
|
357
399
|
/**
|
|
358
400
|
* How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
|
|
359
401
|
*
|
|
@@ -504,6 +546,40 @@ export class TaskStore {
|
|
|
504
546
|
result: input.result == null ? null : dumpJson(input.result),
|
|
505
547
|
});
|
|
506
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* `complete`, with the caller's own writes committed in the same transaction.
|
|
551
|
+
*
|
|
552
|
+
* `fn` runs first and whatever it returns becomes the task's result; the
|
|
553
|
+
* settlement is the last statement in the transaction. So a lost lease — the
|
|
554
|
+
* settlement matching no row — rolls the caller's writes back with it, and
|
|
555
|
+
* there is no ordering in which the work is recorded but the task is not.
|
|
556
|
+
*
|
|
557
|
+
* The settlement runs LAST rather than checking ownership up front on purpose:
|
|
558
|
+
* the ownership predicate lives in the protocol's complete.sql, and a
|
|
559
|
+
* fail-fast pre-check here would be a second copy of it, free to drift. The
|
|
560
|
+
* cost of that choice is that a doomed attempt does its work before finding
|
|
561
|
+
* out, which is the rare path.
|
|
562
|
+
*
|
|
563
|
+
* `fn` must be replayable for the same reason `tx`'s callback must be.
|
|
564
|
+
*/
|
|
565
|
+
async completeIn(input, fn) {
|
|
566
|
+
if (!this.txWithSession) {
|
|
567
|
+
throw new Error("this store cannot share a transaction with the caller — " +
|
|
568
|
+
"completeIn requires a Postgres store (see PgExecutor)");
|
|
569
|
+
}
|
|
570
|
+
return this.txWithSession(async (fetch, session) => {
|
|
571
|
+
const value = await fn(session);
|
|
572
|
+
const rows = await fetch("complete", {
|
|
573
|
+
id: input.taskId,
|
|
574
|
+
worker_id: input.workerId,
|
|
575
|
+
result: value == null ? null : dumpJson(value),
|
|
576
|
+
});
|
|
577
|
+
// Rolls back `fn`'s writes along with the settlement that did not land.
|
|
578
|
+
if (!rows.length)
|
|
579
|
+
throw new LostLease(input.taskId);
|
|
580
|
+
return { task: rowToTask(rows[0]), value };
|
|
581
|
+
});
|
|
582
|
+
}
|
|
507
583
|
async fail(input) {
|
|
508
584
|
let error;
|
|
509
585
|
try {
|
|
@@ -0,0 +1,78 @@
|
|
|
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` in pg-pool.ts for the
|
|
12
|
+
* reference implementation over `pg`. An adapter passes rows through as its
|
|
13
|
+
* driver produced them: cairnq normalizes both column types the drivers disagree
|
|
14
|
+
* about (jsonb decoded or not, int8 as text or number) in `rowToTask`, so no
|
|
15
|
+
* adapter has to reconfigure its driver — and none has to change how the
|
|
16
|
+
* application's OWN columns come back in order to satisfy cairnq.
|
|
17
|
+
*/
|
|
18
|
+
/** A row as the driver hands it back: column name -> value. */
|
|
19
|
+
export type Row = Record<string, unknown>;
|
|
20
|
+
/**
|
|
21
|
+
* Somewhere statements can run. The same shape whether it is a pool (each call
|
|
22
|
+
* on some connection) or one transaction's dedicated connection — the store's
|
|
23
|
+
* statements do not care, and this is what lets `tx` hand the same interface to
|
|
24
|
+
* its callback.
|
|
25
|
+
*/
|
|
26
|
+
export interface PgSession {
|
|
27
|
+
/**
|
|
28
|
+
* One parameterised statement, `$1`-style. `values` is positional and may
|
|
29
|
+
* legitimately contain nulls — a null parameter is "this filter is off" in
|
|
30
|
+
* several protocol statements, not a missing argument.
|
|
31
|
+
*/
|
|
32
|
+
query(text: string, values: readonly unknown[]): Promise<Row[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Parameterless SQL that may hold several statements, for migration DDL.
|
|
35
|
+
* Separate from `query` because it must go over the simple query protocol:
|
|
36
|
+
* the extended protocol a parameterised call uses accepts only one statement,
|
|
37
|
+
* and every migration is a script.
|
|
38
|
+
*/
|
|
39
|
+
exec(sql: string): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
/** A session that can also open transactions, listen, and be shut down. */
|
|
42
|
+
export interface PgExecutor extends PgSession {
|
|
43
|
+
/**
|
|
44
|
+
* Run `fn` inside one transaction on one dedicated connection, committing if
|
|
45
|
+
* it returns and rolling back if it throws. The store relies on both halves:
|
|
46
|
+
* a claim that cannot see its own `recover_leases` is a double-dispatch, and a
|
|
47
|
+
* keyed submit that commits half way poisons the key.
|
|
48
|
+
*/
|
|
49
|
+
tx<T>(fn: (session: PgSession) => Promise<T>): Promise<T>;
|
|
50
|
+
/**
|
|
51
|
+
* Subscribe a dedicated connection to `channels`. Optional: an executor that
|
|
52
|
+
* omits it (or a Postgres that refuses LISTEN) costs latency, never
|
|
53
|
+
* correctness — the store falls back to plain polling, which is the contract
|
|
54
|
+
* PROTOCOL.md gives for push wakeups.
|
|
55
|
+
*
|
|
56
|
+
* Resolves with a function that stops listening. `onClose` reports a
|
|
57
|
+
* connection that dropped on its own, so the store can degrade and retry.
|
|
58
|
+
*
|
|
59
|
+
* Throw `ListenUnavailable` when this Postgres will never accept LISTEN — a
|
|
60
|
+
* transaction-mode pooler, say. Any other rejection is read as transient and
|
|
61
|
+
* retried with backoff, so a permanent condition raised as a plain Error
|
|
62
|
+
* becomes a reconnect loop that cannot succeed.
|
|
63
|
+
*/
|
|
64
|
+
listen?(channels: readonly string[], onNotify: (channel: string, payload: string | undefined) => void, onClose: () => void): Promise<() => void>;
|
|
65
|
+
/**
|
|
66
|
+
* Release this executor's resources. Called by PostgresStore.close() ONLY for
|
|
67
|
+
* an executor the store created itself: an injected one belongs to the caller,
|
|
68
|
+
* whose other work would not survive cairnq closing it.
|
|
69
|
+
*/
|
|
70
|
+
close(): Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* LISTEN will not work on this connection, and retrying cannot change that.
|
|
74
|
+
* See `PgExecutor.listen`.
|
|
75
|
+
*/
|
|
76
|
+
export declare class ListenUnavailable extends Error {
|
|
77
|
+
constructor(message?: string);
|
|
78
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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` in pg-pool.ts for the
|
|
12
|
+
* reference implementation over `pg`. An adapter passes rows through as its
|
|
13
|
+
* driver produced them: cairnq normalizes both column types the drivers disagree
|
|
14
|
+
* about (jsonb decoded or not, int8 as text or number) in `rowToTask`, so no
|
|
15
|
+
* adapter has to reconfigure its driver — and none has to change how the
|
|
16
|
+
* application's OWN columns come back in order to satisfy cairnq.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* LISTEN will not work on this connection, and retrying cannot change that.
|
|
20
|
+
* See `PgExecutor.listen`.
|
|
21
|
+
*/
|
|
22
|
+
export class ListenUnavailable extends Error {
|
|
23
|
+
constructor(message = "LISTEN is not available on this connection") {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "ListenUnavailable";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type PgExecutor } from "./pg-executor.js";
|
|
2
|
+
/**
|
|
3
|
+
* The built-in executor: a `pg.Pool` over a libpq DSN. What `CairnQ.postgres(dsn)`
|
|
4
|
+
* uses, and the reference for what an adapter over another driver must do.
|
|
5
|
+
*
|
|
6
|
+
* Creating it does not connect — `pg.Pool` is lazy, and the store's first
|
|
7
|
+
* statement (the migration ledger) is what proves the database is reachable.
|
|
8
|
+
*
|
|
9
|
+
* `schema` puts cairnq's tables in a schema of their own rather than in whatever
|
|
10
|
+
* the connection's search_path leads with. The protocol's SQL names no schema, so
|
|
11
|
+
* this is a per-connection `search_path` and not one statement changes.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createPoolExecutor(dsn: string, opts?: {
|
|
14
|
+
max?: number;
|
|
15
|
+
schema?: string;
|
|
16
|
+
}): Promise<PgExecutor>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ListenUnavailable } from "./pg-executor.js";
|
|
2
|
+
// `pg` is an optional dependency: the SDK is SQLite-first, so it's loaded lazily
|
|
3
|
+
// the first time a pool-backed executor is built. Absent -> a clear install hint.
|
|
4
|
+
// An application that brings its own executor never reaches this.
|
|
5
|
+
let pgModule = null;
|
|
6
|
+
async function loadPg() {
|
|
7
|
+
if (pgModule)
|
|
8
|
+
return pgModule;
|
|
9
|
+
let mod;
|
|
10
|
+
try {
|
|
11
|
+
mod = (await import("pg"));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error("PostgresStore requires the 'pg' package — install it (e.g. `npm i pg`)");
|
|
15
|
+
}
|
|
16
|
+
const pg = (mod.default ?? mod);
|
|
17
|
+
// Deliberately NOT setting a global int8 type parser here. It would be the
|
|
18
|
+
// shortest fix for Postgres sending bigint as text, and it is what this file
|
|
19
|
+
// used to do — but pg's type parsers are process-global, so a library that
|
|
20
|
+
// installs one silently changes how the APPLICATION's own bigint columns come
|
|
21
|
+
// back. rowToTask normalizes instead, which costs nothing and leaves the
|
|
22
|
+
// caller's driver as they configured it.
|
|
23
|
+
pgModule = pg;
|
|
24
|
+
return pg;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Roll back on the way out of a failed transaction, without letting the rollback
|
|
28
|
+
* become the error the caller sees. A dropped connection fails both the statement
|
|
29
|
+
* and the rollback, and it is the first one that says what went wrong.
|
|
30
|
+
*/
|
|
31
|
+
async function rollbackQuietly(client) {
|
|
32
|
+
try {
|
|
33
|
+
await client.query("rollback");
|
|
34
|
+
}
|
|
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
|
+
/** The PgSession face of one pg client or pool. */
|
|
41
|
+
function session(q) {
|
|
42
|
+
return {
|
|
43
|
+
async query(text, values) {
|
|
44
|
+
return (await q.query(text, values)).rows;
|
|
45
|
+
},
|
|
46
|
+
async exec(sql) {
|
|
47
|
+
// No values: pg sends this over the simple query protocol, which is what
|
|
48
|
+
// makes a multi-statement migration script legal here and not in query().
|
|
49
|
+
await q.query(sql);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A Postgres identifier that is safe to interpolate — cairnq quotes the schema
|
|
55
|
+
* name, and a name that could close that quote could rewrite the statement.
|
|
56
|
+
* Deliberately narrower than what Postgres accepts: a schema cairnq is asked to
|
|
57
|
+
* live in is a deployment decision, not a place to be clever.
|
|
58
|
+
*/
|
|
59
|
+
const PLAIN_IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
60
|
+
/**
|
|
61
|
+
* The built-in executor: a `pg.Pool` over a libpq DSN. What `CairnQ.postgres(dsn)`
|
|
62
|
+
* uses, and the reference for what an adapter over another driver must do.
|
|
63
|
+
*
|
|
64
|
+
* Creating it does not connect — `pg.Pool` is lazy, and the store's first
|
|
65
|
+
* statement (the migration ledger) is what proves the database is reachable.
|
|
66
|
+
*
|
|
67
|
+
* `schema` puts cairnq's tables in a schema of their own rather than in whatever
|
|
68
|
+
* the connection's search_path leads with. The protocol's SQL names no schema, so
|
|
69
|
+
* this is a per-connection `search_path` and not one statement changes.
|
|
70
|
+
*/
|
|
71
|
+
export async function createPoolExecutor(dsn, opts = {}) {
|
|
72
|
+
const pg = await loadPg();
|
|
73
|
+
const schema = opts.schema;
|
|
74
|
+
if (schema !== undefined && !PLAIN_IDENT.test(schema)) {
|
|
75
|
+
throw new Error(`schema must be a plain identifier (letters, digits, _ and $, not starting ` +
|
|
76
|
+
`with a digit), got ${JSON.stringify(schema)}`);
|
|
77
|
+
}
|
|
78
|
+
const pool = new pg.Pool({ connectionString: dsn, max: opts.max });
|
|
79
|
+
if (schema) {
|
|
80
|
+
// Queued on the connection before it is handed out, so every statement this
|
|
81
|
+
// pool ever runs — migrations included — resolves in the right schema. Pooled
|
|
82
|
+
// connections come and go, which is why this is per-connection rather than a
|
|
83
|
+
// one-off at startup.
|
|
84
|
+
pool.on("connect", (client) => {
|
|
85
|
+
void client.query(`set search_path to "${schema}"`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (schema) {
|
|
89
|
+
// Created once, here, rather than from the connect handler (which would ask
|
|
90
|
+
// for CREATE privilege on every new connection) or from the migrations
|
|
91
|
+
// (which name no schema, by design). Without it the first `create table`
|
|
92
|
+
// fails with "no schema has been selected to create in", which says nothing
|
|
93
|
+
// about the cause. This is the one thing that makes building the executor
|
|
94
|
+
// connect; without `schema` it stays lazy.
|
|
95
|
+
try {
|
|
96
|
+
await pool.query(`create schema if not exists "${schema}"`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
await pool.end();
|
|
100
|
+
throw e;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const poolSession = session(pool);
|
|
104
|
+
return {
|
|
105
|
+
query: poolSession.query,
|
|
106
|
+
exec: poolSession.exec,
|
|
107
|
+
async tx(fn) {
|
|
108
|
+
const client = await pool.connect();
|
|
109
|
+
try {
|
|
110
|
+
await client.query("begin");
|
|
111
|
+
const out = await fn(session(client));
|
|
112
|
+
await client.query("commit");
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
await rollbackQuietly(client);
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
client.release();
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
async listen(channels, onNotify, onClose) {
|
|
124
|
+
// Built from the raw DSN rather than taken from the pool: a listener holds
|
|
125
|
+
// its connection for its whole life, and a pooled one would be a slot the
|
|
126
|
+
// store never gives back. If `opts` ever grows connection-level settings
|
|
127
|
+
// (ssl, application_name), the listener must receive them too.
|
|
128
|
+
const client = new pg.Client({ connectionString: dsn });
|
|
129
|
+
await client.connect(); // failure here is transient: caller retries with backoff
|
|
130
|
+
client.on("notification", (msg) => onNotify(msg.channel, msg.payload));
|
|
131
|
+
// A dropped listener degrades to polling; the store reconnects on the next wake.
|
|
132
|
+
client.on("error", () => onClose());
|
|
133
|
+
try {
|
|
134
|
+
await client.query(channels.map((c) => `listen ${c}`).join("; "));
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
void client.end().catch(() => { });
|
|
138
|
+
// Connected, but LISTEN was refused (e.g. a transaction-mode pooler) —
|
|
139
|
+
// deterministic, so tell the store not to retry.
|
|
140
|
+
throw new ListenUnavailable(e instanceof Error ? e.message : undefined);
|
|
141
|
+
}
|
|
142
|
+
return () => void client.end().catch(() => { });
|
|
143
|
+
},
|
|
144
|
+
async close() {
|
|
145
|
+
await pool.end();
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type Fetch, type Params, TaskStore } from "./base.js";
|
|
1
|
+
import { type Fetch, type Params, TaskStore, type WatchSignal } from "./base.js";
|
|
2
|
+
import { type PgExecutor, type PgSession } from "./pg-executor.js";
|
|
2
3
|
/**
|
|
3
4
|
* The rewritten SQL and the order its `$n` slots must be filled in.
|
|
4
5
|
*
|
|
@@ -28,23 +29,32 @@ export declare function toPositional(sql: string, params: Params): {
|
|
|
28
29
|
* PostgresStore — the Postgres dialect of the shared cairnq-protocol SQL.
|
|
29
30
|
*
|
|
30
31
|
* Everything protocol-shaped lives in TaskStore; this file is only what Postgres
|
|
31
|
-
* does differently:
|
|
32
|
-
* the DB clock (`now()`) instead of from the SDK,
|
|
33
|
-
* multi-host — unlike SQLite it coordinates API
|
|
34
|
-
* machines, with no shared clock to agree on. claim
|
|
35
|
-
* and needs no claimable_probe, because PG readers
|
|
36
|
-
* columns are jsonb (bound as JSON text, read back as
|
|
32
|
+
* does differently: `:name` -> `$n` translation, the migration ledger, LISTEN
|
|
33
|
+
* policy, and time taken from the DB clock (`now()`) instead of from the SDK,
|
|
34
|
+
* which is what makes this backend multi-host — unlike SQLite it coordinates API
|
|
35
|
+
* and worker processes across machines, with no shared clock to agree on. claim
|
|
36
|
+
* uses FOR UPDATE SKIP LOCKED and needs no claimable_probe, because PG readers
|
|
37
|
+
* don't block writers. JSON columns are jsonb (bound as JSON text, read back as
|
|
38
|
+
* objects by rowToTask).
|
|
39
|
+
*
|
|
40
|
+
* What it deliberately does NOT own is the connection. Given a DSN it builds a
|
|
41
|
+
* `pg` pool (see pg-pool.ts); given a PgExecutor it runs inside the caller's
|
|
42
|
+
* session instead — no second driver, no second pool, and the caller's writes and
|
|
43
|
+
* cairnq's can share one transaction.
|
|
37
44
|
*/
|
|
38
45
|
export declare class PostgresStore extends TaskStore {
|
|
39
|
-
private readonly dsn;
|
|
40
46
|
private readonly opts;
|
|
41
|
-
private
|
|
47
|
+
private readonly dsn;
|
|
48
|
+
/** The caller's executor, if one was injected — never closed by this store. */
|
|
49
|
+
private readonly provided;
|
|
50
|
+
/** Set once migrations have run and the protocol version checked out. */
|
|
51
|
+
private executor;
|
|
42
52
|
private connecting;
|
|
43
53
|
private readonly statements;
|
|
44
54
|
private listener;
|
|
45
55
|
private listenerConnecting;
|
|
46
|
-
/** LISTEN is off for good: the store was closed,
|
|
47
|
-
*
|
|
56
|
+
/** LISTEN is off for good: the store was closed, the executor does not support
|
|
57
|
+
* it, or the server refused it (e.g. a transaction-mode pooler) —
|
|
48
58
|
* deterministic, so retrying would fail the same way every time. */
|
|
49
59
|
private listenerUnavailable;
|
|
50
60
|
/** A failure to even connect is transient (network blip, server restarting):
|
|
@@ -57,30 +67,71 @@ export declare class PostgresStore extends TaskStore {
|
|
|
57
67
|
private readonly pendingQueues;
|
|
58
68
|
/** Wake callbacks by key: "queued" (broadcast) or "done:<task id>". */
|
|
59
69
|
private readonly waiters;
|
|
60
|
-
|
|
70
|
+
/** watch() subscribers. Separate from `waiters`: a waiter is one-shot and
|
|
71
|
+
* consumes the notification, a subscriber is standing and only observes. */
|
|
72
|
+
private readonly subscribers;
|
|
73
|
+
/**
|
|
74
|
+
* `source` is either a libpq connection string — this store then owns a `pg`
|
|
75
|
+
* pool and requires the optional `pg` package — or a PgExecutor the caller
|
|
76
|
+
* already has, which this store uses and never closes.
|
|
77
|
+
*/
|
|
78
|
+
constructor(source: string | PgExecutor, opts?: {
|
|
61
79
|
max?: number;
|
|
80
|
+
schema?: string;
|
|
62
81
|
});
|
|
63
82
|
connect(): Promise<void>;
|
|
64
83
|
close(): Promise<void>;
|
|
65
84
|
private ensure;
|
|
66
85
|
private doConnect;
|
|
86
|
+
/**
|
|
87
|
+
* Refuse a connection pointed somewhere other than the deployment's cairnq.
|
|
88
|
+
*
|
|
89
|
+
* Two shapes, because `schema` means "the schema cairnq's tables live in" and
|
|
90
|
+
* cairnq can either arrange that (it built the connection) or only check it
|
|
91
|
+
* (the caller's executor did):
|
|
92
|
+
*
|
|
93
|
+
* - `schema` configured -> assert the connection actually resolves there. On
|
|
94
|
+
* the DSN path this is a cheap self-check; on an injected executor it is the
|
|
95
|
+
* only way to state the expectation at all.
|
|
96
|
+
* - `schema` not configured -> the dangerous case is being about to create a
|
|
97
|
+
* SECOND installation while one already exists elsewhere in this database,
|
|
98
|
+
* which is exactly what a mismatched pair of SDKs does. Joining an existing
|
|
99
|
+
* installation is fine no matter what else is around, so the check is
|
|
100
|
+
* deliberately narrow: it fires only when this schema has no cairnq and some
|
|
101
|
+
* other schema does.
|
|
102
|
+
*
|
|
103
|
+
* That narrowness is what keeps it from crying wolf. Two applications each
|
|
104
|
+
* running their own cairnq in their own schema are legitimate; the second one
|
|
105
|
+
* to be set up trips this once, and saying `schema` explicitly — which such a
|
|
106
|
+
* deployment should be doing anyway — is both the fix and the confirmation.
|
|
107
|
+
*/
|
|
108
|
+
private checkSchema;
|
|
67
109
|
private applyMigrations;
|
|
68
110
|
private readProtocolVersion;
|
|
69
111
|
protocolVersion(): Promise<number>;
|
|
70
112
|
claimWake(queues: string[], timeoutMs: number): Promise<void>;
|
|
113
|
+
protected subscribePush(onSignal: (signal: WatchSignal) => void): () => void;
|
|
114
|
+
protected warmPush(): void;
|
|
71
115
|
taskDoneWake(taskId: string, timeoutMs: number): Promise<void>;
|
|
72
116
|
/** A promise resolving on notification-or-timeout, deregistering either way.
|
|
73
117
|
* The timer is unref'd: while a listener exists its socket keeps the process
|
|
74
118
|
* alive, and dropListener wakes every waiter the moment it goes away. */
|
|
75
119
|
private wakeOn;
|
|
76
|
-
/** True once the LISTEN
|
|
120
|
+
/** True once the LISTEN subscription is up; starts establishing it otherwise
|
|
77
121
|
* (respecting the transient-failure backoff). Callers fall back to plain
|
|
78
122
|
* polling until it is ready (or forever, if it can't be established) —
|
|
79
123
|
* correctness never depends on it. */
|
|
80
124
|
private listenerReady;
|
|
81
125
|
private startListener;
|
|
82
126
|
private onNotification;
|
|
127
|
+
/** Hand a notification to every watch() subscriber. A throwing subscriber is
|
|
128
|
+
* its own problem: it must not cost the others their signal, nor take down the
|
|
129
|
+
* listener connection that delivered it. */
|
|
130
|
+
private publish;
|
|
83
131
|
private dropListener;
|
|
84
132
|
protected fetch(name: string, params: Params): Promise<any[]>;
|
|
85
133
|
protected tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
|
|
134
|
+
protected txWithSession<T>(fn: (fetch: Fetch, session: PgSession) => Promise<T>): Promise<T>;
|
|
135
|
+
/** A Fetch that runs the protocol's statements on one particular session. */
|
|
136
|
+
private boundFetch;
|
|
86
137
|
}
|