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
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
// Deliberately NOT setting a global int8 type parser here. It would be the
|
|
19
|
+
// shortest fix for Postgres sending bigint as text, and it is what this file
|
|
20
|
+
// used to do — but pg's type parsers are process-global, so a library that
|
|
21
|
+
// installs one silently changes how the APPLICATION's own bigint columns come
|
|
22
|
+
// back. rowToTask normalizes instead, which costs nothing and leaves the
|
|
23
|
+
// caller's driver as they configured it.
|
|
24
|
+
pgModule = pg;
|
|
25
|
+
return pg;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Roll back on the way out of a failed transaction, without letting the rollback
|
|
30
|
+
* become the error the caller sees. A dropped connection fails both the statement
|
|
31
|
+
* and the rollback, and it is the first one that says what went wrong.
|
|
32
|
+
*/
|
|
33
|
+
async function rollbackQuietly(client: PG.PoolClient): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
await client.query("rollback");
|
|
36
|
+
} catch {
|
|
37
|
+
// Already rolled back, or the connection is gone. Either way the original
|
|
38
|
+
// error is the one worth propagating.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The PgSession face of one pg client or pool. */
|
|
43
|
+
function session(q: Pick<PG.PoolClient, "query">): PgSession {
|
|
44
|
+
return {
|
|
45
|
+
async query(text: string, values: readonly unknown[]): Promise<Row[]> {
|
|
46
|
+
return (await q.query(text, values as unknown[])).rows;
|
|
47
|
+
},
|
|
48
|
+
async exec(sql: string): Promise<void> {
|
|
49
|
+
// No values: pg sends this over the simple query protocol, which is what
|
|
50
|
+
// makes a multi-statement migration script legal here and not in query().
|
|
51
|
+
await q.query(sql);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A Postgres identifier that is safe to interpolate — cairnq quotes the schema
|
|
58
|
+
* name, and a name that could close that quote could rewrite the statement.
|
|
59
|
+
* Deliberately narrower than what Postgres accepts: a schema cairnq is asked to
|
|
60
|
+
* live in is a deployment decision, not a place to be clever.
|
|
61
|
+
*/
|
|
62
|
+
const PLAIN_IDENT = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The built-in executor: a `pg.Pool` over a libpq DSN. What `CairnQ.postgres(dsn)`
|
|
66
|
+
* uses, and the reference for what an adapter over another driver must do.
|
|
67
|
+
*
|
|
68
|
+
* Creating it does not connect — `pg.Pool` is lazy, and the store's first
|
|
69
|
+
* statement (the migration ledger) is what proves the database is reachable.
|
|
70
|
+
*
|
|
71
|
+
* `schema` puts cairnq's tables in a schema of their own rather than in whatever
|
|
72
|
+
* the connection's search_path leads with. The protocol's SQL names no schema, so
|
|
73
|
+
* this is a per-connection `search_path` and not one statement changes.
|
|
74
|
+
*/
|
|
75
|
+
export async function createPoolExecutor(
|
|
76
|
+
dsn: string,
|
|
77
|
+
opts: { max?: number; schema?: string } = {},
|
|
78
|
+
): Promise<PgExecutor> {
|
|
79
|
+
const pg = await loadPg();
|
|
80
|
+
const schema = opts.schema;
|
|
81
|
+
if (schema !== undefined && !PLAIN_IDENT.test(schema)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`schema must be a plain identifier (letters, digits, _ and $, not starting ` +
|
|
84
|
+
`with a digit), got ${JSON.stringify(schema)}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const pool = new pg.Pool({ connectionString: dsn, max: opts.max });
|
|
88
|
+
if (schema) {
|
|
89
|
+
// Queued on the connection before it is handed out, so every statement this
|
|
90
|
+
// pool ever runs — migrations included — resolves in the right schema. Pooled
|
|
91
|
+
// connections come and go, which is why this is per-connection rather than a
|
|
92
|
+
// one-off at startup.
|
|
93
|
+
pool.on("connect", (client) => {
|
|
94
|
+
void client.query(`set search_path to "${schema}"`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (schema) {
|
|
98
|
+
// Created once, here, rather than from the connect handler (which would ask
|
|
99
|
+
// for CREATE privilege on every new connection) or from the migrations
|
|
100
|
+
// (which name no schema, by design). Without it the first `create table`
|
|
101
|
+
// fails with "no schema has been selected to create in", which says nothing
|
|
102
|
+
// about the cause. This is the one thing that makes building the executor
|
|
103
|
+
// connect; without `schema` it stays lazy.
|
|
104
|
+
try {
|
|
105
|
+
await pool.query(`create schema if not exists "${schema}"`);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
await pool.end();
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const poolSession = session(pool);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
query: poolSession.query,
|
|
115
|
+
exec: poolSession.exec,
|
|
116
|
+
|
|
117
|
+
async tx<T>(fn: (s: PgSession) => Promise<T>): Promise<T> {
|
|
118
|
+
const client = await pool.connect();
|
|
119
|
+
try {
|
|
120
|
+
await client.query("begin");
|
|
121
|
+
const out = await fn(session(client));
|
|
122
|
+
await client.query("commit");
|
|
123
|
+
return out;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
await rollbackQuietly(client);
|
|
126
|
+
throw e;
|
|
127
|
+
} finally {
|
|
128
|
+
client.release();
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async listen(channels, onNotify, onClose): Promise<() => void> {
|
|
133
|
+
// Built from the raw DSN rather than taken from the pool: a listener holds
|
|
134
|
+
// its connection for its whole life, and a pooled one would be a slot the
|
|
135
|
+
// store never gives back. If `opts` ever grows connection-level settings
|
|
136
|
+
// (ssl, application_name), the listener must receive them too.
|
|
137
|
+
const client = new pg.Client({ connectionString: dsn });
|
|
138
|
+
await client.connect(); // failure here is transient: caller retries with backoff
|
|
139
|
+
client.on("notification", (msg) => onNotify(msg.channel, msg.payload));
|
|
140
|
+
// A dropped listener degrades to polling; the store reconnects on the next wake.
|
|
141
|
+
client.on("error", () => onClose());
|
|
142
|
+
try {
|
|
143
|
+
await client.query(channels.map((c) => `listen ${c}`).join("; "));
|
|
144
|
+
} catch (e) {
|
|
145
|
+
void client.end().catch(() => {});
|
|
146
|
+
// Connected, but LISTEN was refused (e.g. a transaction-mode pooler) —
|
|
147
|
+
// deterministic, so tell the store not to retry.
|
|
148
|
+
throw new ListenUnavailable(e instanceof Error ? e.message : undefined);
|
|
149
|
+
}
|
|
150
|
+
return () => void client.end().catch(() => {});
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async close(): Promise<void> {
|
|
154
|
+
await pool.end();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
package/src/store/postgres.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { SchemaMismatch } from "../errors.js";
|
|
3
2
|
import { loadMigrations, loadStatements } from "../sql.js";
|
|
4
3
|
import {
|
|
5
4
|
checkProtocolVersion,
|
|
@@ -9,48 +8,16 @@ import {
|
|
|
9
8
|
type Params,
|
|
10
9
|
statementParams,
|
|
11
10
|
TaskStore,
|
|
11
|
+
type WatchSignal,
|
|
12
12
|
} from "./base.js";
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
}
|
|
13
|
+
import { ListenUnavailable, type PgExecutor, type PgSession } from "./pg-executor.js";
|
|
14
|
+
import { createPoolExecutor } from "./pg-pool.js";
|
|
48
15
|
|
|
49
16
|
// Notification channels, emitted by the 0003_notify trigger.
|
|
50
17
|
const QUEUED_CHANNEL = "cairnq_queued";
|
|
51
18
|
const DONE_CHANNEL = "cairnq_done";
|
|
52
19
|
|
|
53
|
-
// Backoff between attempts to (re)
|
|
20
|
+
// Backoff between attempts to (re)establish the LISTEN subscription after a
|
|
54
21
|
// transient failure. Doubles per failure up to the cap; polling covers the gap.
|
|
55
22
|
const LISTENER_RETRY_MS = 1_000;
|
|
56
23
|
const LISTENER_RETRY_MAX_MS = 30_000;
|
|
@@ -100,26 +67,38 @@ export function toPositional(
|
|
|
100
67
|
* PostgresStore — the Postgres dialect of the shared cairnq-protocol SQL.
|
|
101
68
|
*
|
|
102
69
|
* Everything protocol-shaped lives in TaskStore; this file is only what Postgres
|
|
103
|
-
* does differently:
|
|
104
|
-
* the DB clock (`now()`) instead of from the SDK,
|
|
105
|
-
* multi-host — unlike SQLite it coordinates API
|
|
106
|
-
* machines, with no shared clock to agree on. claim
|
|
107
|
-
* and needs no claimable_probe, because PG readers
|
|
108
|
-
* columns are jsonb (bound as JSON text, read back as
|
|
70
|
+
* does differently: `:name` -> `$n` translation, the migration ledger, LISTEN
|
|
71
|
+
* policy, and time taken from the DB clock (`now()`) instead of from the SDK,
|
|
72
|
+
* which is what makes this backend multi-host — unlike SQLite it coordinates API
|
|
73
|
+
* and worker processes across machines, with no shared clock to agree on. claim
|
|
74
|
+
* uses FOR UPDATE SKIP LOCKED and needs no claimable_probe, because PG readers
|
|
75
|
+
* don't block writers. JSON columns are jsonb (bound as JSON text, read back as
|
|
76
|
+
* objects by rowToTask).
|
|
77
|
+
*
|
|
78
|
+
* What it deliberately does NOT own is the connection. Given a DSN it builds a
|
|
79
|
+
* `pg` pool (see pg-pool.ts); given a PgExecutor it runs inside the caller's
|
|
80
|
+
* session instead — no second driver, no second pool, and the caller's writes and
|
|
81
|
+
* cairnq's can share one transaction.
|
|
109
82
|
*/
|
|
110
83
|
export class PostgresStore extends TaskStore {
|
|
111
|
-
private
|
|
84
|
+
private readonly dsn: string | null;
|
|
85
|
+
/** The caller's executor, if one was injected — never closed by this store. */
|
|
86
|
+
private readonly provided: PgExecutor | null;
|
|
87
|
+
/** Set once migrations have run and the protocol version checked out. */
|
|
88
|
+
private executor: PgExecutor | null = null;
|
|
112
89
|
private connecting: Promise<void> | null = null;
|
|
113
90
|
private readonly statements: Record<string, string>;
|
|
114
91
|
|
|
115
92
|
// ------------------------------------------------------- LISTEN/NOTIFY state
|
|
116
|
-
//
|
|
117
|
-
// the claimWake/taskDoneWake contract on TaskStore).
|
|
118
|
-
// keep it silently degrades the store to the base
|
|
119
|
-
|
|
93
|
+
// The executor subscribes one dedicated connection to both channels (see
|
|
94
|
+
// 0003_notify.sql and the claimWake/taskDoneWake contract on TaskStore).
|
|
95
|
+
// Failure to establish or keep it silently degrades the store to the base
|
|
96
|
+
// class's plain polling. Held as the unsubscribe function, not the connection:
|
|
97
|
+
// whose connection it is, is the executor's business.
|
|
98
|
+
private listener: (() => void) | null = null;
|
|
120
99
|
private listenerConnecting: Promise<void> | null = null;
|
|
121
|
-
/** LISTEN is off for good: the store was closed,
|
|
122
|
-
*
|
|
100
|
+
/** LISTEN is off for good: the store was closed, the executor does not support
|
|
101
|
+
* it, or the server refused it (e.g. a transaction-mode pooler) —
|
|
123
102
|
* deterministic, so retrying would fail the same way every time. */
|
|
124
103
|
private listenerUnavailable = false;
|
|
125
104
|
/** A failure to even connect is transient (network blip, server restarting):
|
|
@@ -132,12 +111,22 @@ export class PostgresStore extends TaskStore {
|
|
|
132
111
|
private readonly pendingQueues = new Set<string>();
|
|
133
112
|
/** Wake callbacks by key: "queued" (broadcast) or "done:<task id>". */
|
|
134
113
|
private readonly waiters = new Map<string, Set<() => void>>();
|
|
114
|
+
/** watch() subscribers. Separate from `waiters`: a waiter is one-shot and
|
|
115
|
+
* consumes the notification, a subscriber is standing and only observes. */
|
|
116
|
+
private readonly subscribers = new Set<(signal: WatchSignal) => void>();
|
|
135
117
|
|
|
118
|
+
/**
|
|
119
|
+
* `source` is either a libpq connection string — this store then owns a `pg`
|
|
120
|
+
* pool and requires the optional `pg` package — or a PgExecutor the caller
|
|
121
|
+
* already has, which this store uses and never closes.
|
|
122
|
+
*/
|
|
136
123
|
constructor(
|
|
137
|
-
|
|
138
|
-
private readonly opts: { max?: number } = {},
|
|
124
|
+
source: string | PgExecutor,
|
|
125
|
+
private readonly opts: { max?: number; schema?: string } = {},
|
|
139
126
|
) {
|
|
140
127
|
super();
|
|
128
|
+
this.dsn = typeof source === "string" ? source : null;
|
|
129
|
+
this.provided = typeof source === "string" ? null : source;
|
|
141
130
|
this.statements = loadStatements("postgres");
|
|
142
131
|
}
|
|
143
132
|
|
|
@@ -148,18 +137,19 @@ export class PostgresStore extends TaskStore {
|
|
|
148
137
|
async close(): Promise<void> {
|
|
149
138
|
this.listenerUnavailable = true; // no revival after close
|
|
150
139
|
this.dropListener();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
140
|
+
const executor = this.executor;
|
|
141
|
+
this.executor = null;
|
|
142
|
+
this.connecting = null;
|
|
143
|
+
// An injected executor belongs to the caller, whose other work would not
|
|
144
|
+
// survive cairnq closing it.
|
|
145
|
+
if (executor && !this.provided) await executor.close();
|
|
157
146
|
}
|
|
158
147
|
|
|
159
148
|
private async ensure(): Promise<void> {
|
|
160
|
-
if (this.
|
|
161
|
-
// Cache the in-flight connect so concurrent calls share one
|
|
162
|
-
// clear it so a later call retries instead of re-awaiting a
|
|
149
|
+
if (this.executor) return;
|
|
150
|
+
// Cache the in-flight connect so concurrent calls share one executor. On
|
|
151
|
+
// failure, clear it so a later call retries instead of re-awaiting a
|
|
152
|
+
// rejected promise.
|
|
163
153
|
if (!this.connecting) {
|
|
164
154
|
this.connecting = this.doConnect().catch((e) => {
|
|
165
155
|
this.connecting = null;
|
|
@@ -170,28 +160,85 @@ export class PostgresStore extends TaskStore {
|
|
|
170
160
|
}
|
|
171
161
|
|
|
172
162
|
private async doConnect(): Promise<void> {
|
|
173
|
-
const
|
|
174
|
-
|
|
163
|
+
const executor =
|
|
164
|
+
this.provided ??
|
|
165
|
+
(await createPoolExecutor(this.dsn!, { max: this.opts.max, schema: this.opts.schema }));
|
|
175
166
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
client.release();
|
|
182
|
-
}
|
|
167
|
+
// Before migrations, which would otherwise create the very installation
|
|
168
|
+
// this is trying to warn about.
|
|
169
|
+
await this.checkSchema(executor);
|
|
170
|
+
await this.applyMigrations(executor);
|
|
171
|
+
checkProtocolVersion(await this.readProtocolVersion(executor));
|
|
183
172
|
} catch (e) {
|
|
184
|
-
|
|
173
|
+
// Never leak an executor we created; never close one we were handed.
|
|
174
|
+
if (!this.provided) await executor.close().catch(() => {});
|
|
185
175
|
throw e;
|
|
186
176
|
}
|
|
187
|
-
this.
|
|
188
|
-
// Warm the LISTEN
|
|
177
|
+
this.executor = executor; // publish only a fully-migrated, version-checked executor
|
|
178
|
+
// Warm the LISTEN subscription in the background so the first idle sleep is
|
|
189
179
|
// already wakeable. Fire-and-forget: failure just means polling.
|
|
190
180
|
this.listenerReady();
|
|
191
181
|
}
|
|
192
182
|
|
|
193
|
-
|
|
194
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Refuse a connection pointed somewhere other than the deployment's cairnq.
|
|
185
|
+
*
|
|
186
|
+
* Two shapes, because `schema` means "the schema cairnq's tables live in" and
|
|
187
|
+
* cairnq can either arrange that (it built the connection) or only check it
|
|
188
|
+
* (the caller's executor did):
|
|
189
|
+
*
|
|
190
|
+
* - `schema` configured -> assert the connection actually resolves there. On
|
|
191
|
+
* the DSN path this is a cheap self-check; on an injected executor it is the
|
|
192
|
+
* only way to state the expectation at all.
|
|
193
|
+
* - `schema` not configured -> the dangerous case is being about to create a
|
|
194
|
+
* SECOND installation while one already exists elsewhere in this database,
|
|
195
|
+
* which is exactly what a mismatched pair of SDKs does. Joining an existing
|
|
196
|
+
* installation is fine no matter what else is around, so the check is
|
|
197
|
+
* deliberately narrow: it fires only when this schema has no cairnq and some
|
|
198
|
+
* other schema does.
|
|
199
|
+
*
|
|
200
|
+
* That narrowness is what keeps it from crying wolf. Two applications each
|
|
201
|
+
* running their own cairnq in their own schema are legitimate; the second one
|
|
202
|
+
* to be set up trips this once, and saying `schema` explicitly — which such a
|
|
203
|
+
* deployment should be doing anyway — is both the fix and the confirmation.
|
|
204
|
+
*/
|
|
205
|
+
private async checkSchema(executor: PgExecutor): Promise<void> {
|
|
206
|
+
// One row per installation; the statement's LEFT JOIN guarantees at least
|
|
207
|
+
// one, so current_schema is readable even where cairnq lives nowhere yet.
|
|
208
|
+
const rows = await executor.query(this.statements.installations, []);
|
|
209
|
+
const current = (rows[0]?.current_schema as string | null) ?? null;
|
|
210
|
+
const installations = rows
|
|
211
|
+
.map((r) => r.schema as string | null)
|
|
212
|
+
.filter((s): s is string => s != null);
|
|
213
|
+
const wanted = this.opts.schema;
|
|
214
|
+
|
|
215
|
+
if (wanted != null) {
|
|
216
|
+
if (current !== wanted) {
|
|
217
|
+
throw new SchemaMismatch(
|
|
218
|
+
`cairnq is configured for schema ${JSON.stringify(wanted)} but this ` +
|
|
219
|
+
`connection resolves to ${JSON.stringify(current)} — check the ` +
|
|
220
|
+
`connection's search_path`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// A search_path naming nothing that exists: there is no "here" to compare
|
|
226
|
+
// against, and the migrations are about to fail with a clearer message.
|
|
227
|
+
if (current === null) return;
|
|
228
|
+
if (installations.length === 0 || installations.includes(current)) return;
|
|
229
|
+
|
|
230
|
+
throw new SchemaMismatch(
|
|
231
|
+
`cairnq tables already exist in schema ${installations.map((s) => JSON.stringify(s)).join(", ")} ` +
|
|
232
|
+
`of this database, but this connection resolves to ${JSON.stringify(current)}, where there are ` +
|
|
233
|
+
`none. Connecting would create a second, parallel installation that the other one can never see ` +
|
|
234
|
+
`— an API and a worker split this way agree about everything except where, and no task crosses. ` +
|
|
235
|
+
`Point this process at the same schema (\`schema\` option, or \`options=-c search_path=...\` in the ` +
|
|
236
|
+
`DSN), or pass \`schema\` explicitly to confirm a separate installation is what you meant.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async applyMigrations(executor: PgExecutor): Promise<void> {
|
|
241
|
+
await executor.exec(
|
|
195
242
|
"create table if not exists cairnq_migrations " +
|
|
196
243
|
"(name text primary key, applied_at_ms bigint not null)",
|
|
197
244
|
);
|
|
@@ -199,43 +246,31 @@ export class PostgresStore extends TaskStore {
|
|
|
199
246
|
// Check and apply inside one transaction, with the table lock taken up
|
|
200
247
|
// front: two processes cold-starting together would otherwise both see a
|
|
201
248
|
// migration as unapplied and both run it.
|
|
202
|
-
|
|
203
|
-
await
|
|
204
|
-
await
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (applied.rowCount === 0) {
|
|
209
|
-
await client.query(sql); // multi-statement DDL (simple-query, no params)
|
|
210
|
-
await client.query(
|
|
249
|
+
await executor.tx(async (s) => {
|
|
250
|
+
await s.exec("lock table cairnq_migrations in exclusive mode");
|
|
251
|
+
const applied = await s.query("select 1 from cairnq_migrations where name = $1", [name]);
|
|
252
|
+
if (applied.length === 0) {
|
|
253
|
+
await s.exec(sql); // multi-statement DDL (simple-query, no params)
|
|
254
|
+
await s.query(
|
|
211
255
|
"insert into cairnq_migrations (name, applied_at_ms) values " +
|
|
212
256
|
"($1, (extract(epoch from now()) * 1000)::bigint)",
|
|
213
257
|
[name],
|
|
214
258
|
);
|
|
215
259
|
}
|
|
216
|
-
|
|
217
|
-
} catch (e) {
|
|
218
|
-
await rollbackQuietly(client);
|
|
219
|
-
throw e;
|
|
220
|
-
}
|
|
260
|
+
});
|
|
221
261
|
}
|
|
222
262
|
}
|
|
223
263
|
|
|
224
|
-
// Takes an explicit
|
|
225
|
-
// this cannot go through fetch(). The statement binds nothing
|
|
226
|
-
private async readProtocolVersion(
|
|
227
|
-
const
|
|
228
|
-
return
|
|
264
|
+
// Takes an explicit session: during doConnect the executor is not published
|
|
265
|
+
// yet, so this cannot go through fetch(). The statement binds nothing.
|
|
266
|
+
private async readProtocolVersion(s: PgSession): Promise<number> {
|
|
267
|
+
const rows = await s.query(this.statements.protocol_version, []);
|
|
268
|
+
return rows.length ? Number(rows[0].value) : 0;
|
|
229
269
|
}
|
|
230
270
|
|
|
231
271
|
async protocolVersion(): Promise<number> {
|
|
232
272
|
await this.ensure();
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
return await this.readProtocolVersion(client);
|
|
236
|
-
} finally {
|
|
237
|
-
client.release();
|
|
238
|
-
}
|
|
273
|
+
return this.readProtocolVersion(this.executor!);
|
|
239
274
|
}
|
|
240
275
|
|
|
241
276
|
// ------------------------------------------------------------ wake channel
|
|
@@ -255,6 +290,16 @@ export class PostgresStore extends TaskStore {
|
|
|
255
290
|
}
|
|
256
291
|
}
|
|
257
292
|
|
|
293
|
+
protected subscribePush(onSignal: (signal: WatchSignal) => void): () => void {
|
|
294
|
+
this.subscribers.add(onSignal);
|
|
295
|
+
this.listenerReady(); // an API-side watcher is often the only thing asking
|
|
296
|
+
return () => void this.subscribers.delete(onSignal);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
protected warmPush(): void {
|
|
300
|
+
if (this.subscribers.size > 0) this.listenerReady();
|
|
301
|
+
}
|
|
302
|
+
|
|
258
303
|
taskDoneWake(taskId: string, timeoutMs: number): Promise<void> {
|
|
259
304
|
if (!this.listenerReady()) return super.taskDoneWake(taskId, timeoutMs);
|
|
260
305
|
return this.wakeOn(`done:${taskId}`, timeoutMs);
|
|
@@ -280,7 +325,7 @@ export class PostgresStore extends TaskStore {
|
|
|
280
325
|
});
|
|
281
326
|
}
|
|
282
327
|
|
|
283
|
-
/** True once the LISTEN
|
|
328
|
+
/** True once the LISTEN subscription is up; starts establishing it otherwise
|
|
284
329
|
* (respecting the transient-failure backoff). Callers fall back to plain
|
|
285
330
|
* polling until it is ready (or forever, if it can't be established) —
|
|
286
331
|
* correctness never depends on it. */
|
|
@@ -294,36 +339,39 @@ export class PostgresStore extends TaskStore {
|
|
|
294
339
|
|
|
295
340
|
private async startListener(): Promise<void> {
|
|
296
341
|
try {
|
|
297
|
-
const
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
342
|
+
const executor = this.executor ?? this.provided;
|
|
343
|
+
// Not connected yet: transient by definition — doConnect calls back in.
|
|
344
|
+
if (!executor) return;
|
|
345
|
+
if (!executor.listen) {
|
|
346
|
+
this.listenerUnavailable = true; // this executor will never push
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
let stop: () => void;
|
|
301
350
|
try {
|
|
302
|
-
await
|
|
303
|
-
|
|
304
|
-
|
|
351
|
+
stop = await executor.listen(
|
|
352
|
+
[QUEUED_CHANNEL, DONE_CHANNEL],
|
|
353
|
+
(channel, payload) => this.onNotification(channel, payload),
|
|
354
|
+
// A dropped listener degrades to polling; the next wake reconnects.
|
|
355
|
+
() => this.dropListener(),
|
|
356
|
+
);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
if (e instanceof ListenUnavailable) {
|
|
359
|
+
// Deterministic (e.g. a transaction-mode pooler): off for good rather
|
|
360
|
+
// than a reconnect loop that cannot succeed. Polling covers it.
|
|
361
|
+
this.listenerUnavailable = true;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Could not establish it — transient. Schedule a backed-off retry;
|
|
305
365
|
// polling covers the gap.
|
|
306
366
|
this.listenerRetryAt = Date.now() + this.listenerBackoffMs;
|
|
307
367
|
this.listenerBackoffMs = Math.min(LISTENER_RETRY_MAX_MS, this.listenerBackoffMs * 2);
|
|
308
368
|
return;
|
|
309
369
|
}
|
|
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
370
|
if (this.listenerUnavailable) {
|
|
323
|
-
|
|
371
|
+
stop(); // closed while we were subscribing
|
|
324
372
|
return;
|
|
325
373
|
}
|
|
326
|
-
this.listener =
|
|
374
|
+
this.listener = stop;
|
|
327
375
|
this.listenerBackoffMs = LISTENER_RETRY_MS;
|
|
328
376
|
} catch {
|
|
329
377
|
// Anything unexpected (e.g. `pg` failed to load): off for good rather
|
|
@@ -339,8 +387,10 @@ export class PostgresStore extends TaskStore {
|
|
|
339
387
|
if (channel === QUEUED_CHANNEL && payload) {
|
|
340
388
|
this.pendingQueues.add(payload);
|
|
341
389
|
key = "queued";
|
|
390
|
+
this.publish({ reason: "queued", queue: payload });
|
|
342
391
|
} else if (channel === DONE_CHANNEL && payload) {
|
|
343
392
|
key = `done:${payload}`;
|
|
393
|
+
this.publish({ reason: "done", taskId: payload });
|
|
344
394
|
} else {
|
|
345
395
|
return;
|
|
346
396
|
}
|
|
@@ -349,10 +399,23 @@ export class PostgresStore extends TaskStore {
|
|
|
349
399
|
if (set) for (const w of set) w();
|
|
350
400
|
}
|
|
351
401
|
|
|
402
|
+
/** Hand a notification to every watch() subscriber. A throwing subscriber is
|
|
403
|
+
* its own problem: it must not cost the others their signal, nor take down the
|
|
404
|
+
* listener connection that delivered it. */
|
|
405
|
+
private publish(signal: WatchSignal): void {
|
|
406
|
+
for (const s of [...this.subscribers]) {
|
|
407
|
+
try {
|
|
408
|
+
s(signal);
|
|
409
|
+
} catch {
|
|
410
|
+
// Deliberately swallowed — see above.
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
352
415
|
private dropListener(): void {
|
|
353
|
-
const
|
|
416
|
+
const stop = this.listener;
|
|
354
417
|
this.listener = null;
|
|
355
|
-
|
|
418
|
+
stop?.();
|
|
356
419
|
// Release everyone promptly; their fallback poll takes over.
|
|
357
420
|
for (const set of [...this.waiters.values()]) for (const w of [...set]) w();
|
|
358
421
|
}
|
|
@@ -361,25 +424,26 @@ export class PostgresStore extends TaskStore {
|
|
|
361
424
|
protected async fetch(name: string, params: Params): Promise<any[]> {
|
|
362
425
|
await this.ensure();
|
|
363
426
|
const { text, values } = toPositional(this.statements[name], params);
|
|
364
|
-
return
|
|
427
|
+
return this.executor!.query(text, values);
|
|
365
428
|
}
|
|
366
429
|
|
|
367
430
|
protected async tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T> {
|
|
368
431
|
await this.ensure();
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
432
|
+
return this.executor!.tx((s) => fn(this.boundFetch(s)));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
protected async txWithSession<T>(
|
|
436
|
+
fn: (fetch: Fetch, session: PgSession) => Promise<T>,
|
|
437
|
+
): Promise<T> {
|
|
438
|
+
await this.ensure();
|
|
439
|
+
return this.executor!.tx((s) => fn(this.boundFetch(s), s));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** A Fetch that runs the protocol's statements on one particular session. */
|
|
443
|
+
private boundFetch(s: PgSession): Fetch {
|
|
444
|
+
return async (name, params) => {
|
|
445
|
+
const { text, values } = toPositional(this.statements[name], params);
|
|
446
|
+
return s.query(text, values);
|
|
447
|
+
};
|
|
384
448
|
}
|
|
385
449
|
}
|