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