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