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/src/client.ts
CHANGED
|
@@ -4,7 +4,15 @@ import { TaskCanceled, TaskFailed } from "./errors.js";
|
|
|
4
4
|
import { isFailed, isSucceeded, type Task, type TaskRef, type TaskStatus } from "./models.js";
|
|
5
5
|
import { SQLiteStore } from "./store/sqlite.js";
|
|
6
6
|
import { PostgresStore } from "./store/postgres.js";
|
|
7
|
-
import type {
|
|
7
|
+
import type { PgExecutor } from "./store/pg-executor.js";
|
|
8
|
+
import type {
|
|
9
|
+
ListInput,
|
|
10
|
+
PurgeInput,
|
|
11
|
+
SubmitInput,
|
|
12
|
+
TaskStore,
|
|
13
|
+
WatchOptions,
|
|
14
|
+
WatchSignal,
|
|
15
|
+
} from "./store/base.js";
|
|
8
16
|
import { type TaskDef, taskName } from "./task.js";
|
|
9
17
|
import { DEFAULT_WAIT_TIMEOUT_MS, type PollOptions, pollWait, pollWaitByKey } from "./wait.js";
|
|
10
18
|
|
|
@@ -53,11 +61,16 @@ export class CairnQ {
|
|
|
53
61
|
return new CairnQ(new SQLiteStore(path, { busyTimeoutMs }), client);
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
/** Multi-host backend. `
|
|
57
|
-
* optional `pg` package
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
/** Multi-host backend. `source` is a libpq connection string — which requires
|
|
65
|
+
* the optional `pg` package — or a PgExecutor over a driver the application
|
|
66
|
+
* already runs (an ORM's pool, say), which cairnq then shares instead of
|
|
67
|
+
* opening a second one. */
|
|
68
|
+
static postgres(
|
|
69
|
+
source: string | PgExecutor,
|
|
70
|
+
opts: { max?: number; schema?: string } & ClientOptions = {},
|
|
71
|
+
): CairnQ {
|
|
72
|
+
const { max, schema, ...client } = opts;
|
|
73
|
+
return new CairnQ(new PostgresStore(source, { max, schema }), client);
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
get store(): TaskStore {
|
|
@@ -146,6 +159,20 @@ export class CairnQ {
|
|
|
146
159
|
return this._store.stats();
|
|
147
160
|
}
|
|
148
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Call `onSignal` when the tasks on `queues` may have changed. Returns an
|
|
164
|
+
* unsubscribe.
|
|
165
|
+
*
|
|
166
|
+
* Notify-accelerated polling, not an event log: a signal means "re-read now",
|
|
167
|
+
* and `stats()` / `list()` / `get()` are where the truth is. On Postgres an
|
|
168
|
+
* idle watch costs nothing and signals land in milliseconds; everywhere else
|
|
169
|
+
* the timer alone still delivers, so the same consumer code is correct either
|
|
170
|
+
* way. See TaskStore.watch for the full contract.
|
|
171
|
+
*/
|
|
172
|
+
watch(opts: WatchOptions, onSignal: (signal: WatchSignal) => void): () => void {
|
|
173
|
+
return this._store.watch(opts, onSignal);
|
|
174
|
+
}
|
|
175
|
+
|
|
149
176
|
/** Wait for a task to finish. Resolves with the terminal Task (any status);
|
|
150
177
|
* throws TaskTimeout without stopping the task, so `wait(err.taskId)` picks the
|
|
151
178
|
* same wait back up — from another process, or after a longer deadline. */
|
package/src/context.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { asEnvelope, type FailReason, LostLease } from "./errors.js";
|
|
|
3
3
|
import { cancelRequested, type Task } from "./models.js";
|
|
4
4
|
import type { SubmitOptions } from "./client.js";
|
|
5
5
|
import type { TaskStore } from "./store/base.js";
|
|
6
|
+
import type { PgSession } from "./store/pg-executor.js";
|
|
6
7
|
import { type TaskDef, taskName } from "./task.js";
|
|
7
8
|
import { pollWait } from "./wait.js";
|
|
8
9
|
|
|
@@ -36,7 +37,7 @@ export class TaskContext {
|
|
|
36
37
|
constructor(
|
|
37
38
|
private readonly store: TaskStore,
|
|
38
39
|
private readonly task: Task,
|
|
39
|
-
|
|
40
|
+
private readonly ownerId: string,
|
|
40
41
|
private readonly leaseMs: number,
|
|
41
42
|
opts: TaskContextOptions = {},
|
|
42
43
|
) {
|
|
@@ -47,6 +48,10 @@ export class TaskContext {
|
|
|
47
48
|
get taskId(): string {
|
|
48
49
|
return this.task.id;
|
|
49
50
|
}
|
|
51
|
+
/** The worker running this task — what `worker_id` on the row points at. */
|
|
52
|
+
get workerId(): string {
|
|
53
|
+
return this.ownerId;
|
|
54
|
+
}
|
|
50
55
|
get name(): string {
|
|
51
56
|
return this.task.name;
|
|
52
57
|
}
|
|
@@ -149,7 +154,7 @@ export class TaskContext {
|
|
|
149
154
|
return this.owned(() =>
|
|
150
155
|
this.store.progress({
|
|
151
156
|
taskId: this.task.id,
|
|
152
|
-
workerId: this.
|
|
157
|
+
workerId: this.ownerId,
|
|
153
158
|
progress: value,
|
|
154
159
|
message,
|
|
155
160
|
}),
|
|
@@ -160,7 +165,7 @@ export class TaskContext {
|
|
|
160
165
|
return this.owned(() =>
|
|
161
166
|
this.store.heartbeat({
|
|
162
167
|
taskId: this.task.id,
|
|
163
|
-
workerId: this.
|
|
168
|
+
workerId: this.ownerId,
|
|
164
169
|
leaseMs: this.leaseMs,
|
|
165
170
|
}),
|
|
166
171
|
);
|
|
@@ -195,12 +200,48 @@ export class TaskContext {
|
|
|
195
200
|
async succeed(result: unknown = null): Promise<Task | null> {
|
|
196
201
|
if (this.isSettled) return null;
|
|
197
202
|
const task = await this.owned(() =>
|
|
198
|
-
this.store.complete({ taskId: this.task.id, workerId: this.
|
|
203
|
+
this.store.complete({ taskId: this.task.id, workerId: this.ownerId, result }),
|
|
199
204
|
);
|
|
200
205
|
this.markSettled();
|
|
201
206
|
return task;
|
|
202
207
|
}
|
|
203
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Finalize this task as succeeded, committing the caller's own writes in the
|
|
211
|
+
* SAME transaction as the settlement. Whatever `write` returns becomes the
|
|
212
|
+
* task's result.
|
|
213
|
+
*
|
|
214
|
+
* await ctx.succeedIn(async (session) => {
|
|
215
|
+
* await db.withSession(session).insert(pages).values(rendered)
|
|
216
|
+
* return { pages: rendered.length }
|
|
217
|
+
* })
|
|
218
|
+
*
|
|
219
|
+
* The alternative — write the rows, then settle — has a window between the two
|
|
220
|
+
* commits where the work is durable but the task still reads as running. A
|
|
221
|
+
* crash there re-runs the whole task, which for a render or an ingest means
|
|
222
|
+
* recomputing it, and for non-idempotent work means doing it twice.
|
|
223
|
+
*
|
|
224
|
+
* `session` is the driver's, so this needs a Postgres store built on a
|
|
225
|
+
* PgExecutor the application shares with its own driver; anything else throws.
|
|
226
|
+
* If the settlement finds the lease gone, `write`'s work is rolled back with
|
|
227
|
+
* it and LostLease is raised. Returns null if this task was already settled.
|
|
228
|
+
*
|
|
229
|
+
* `write` may be replayed if the backend retries the transaction — derive
|
|
230
|
+
* nothing inside it that cannot be derived twice.
|
|
231
|
+
*/
|
|
232
|
+
async succeedIn<T>(write: (session: PgSession) => Promise<T>): Promise<Task | null> {
|
|
233
|
+
if (this.isSettled) return null;
|
|
234
|
+
const task = await this.owned(async () => {
|
|
235
|
+
const { task } = await this.store.completeIn<PgSession, T>(
|
|
236
|
+
{ taskId: this.task.id, workerId: this.ownerId },
|
|
237
|
+
write,
|
|
238
|
+
);
|
|
239
|
+
return task;
|
|
240
|
+
});
|
|
241
|
+
this.markSettled();
|
|
242
|
+
return task;
|
|
243
|
+
}
|
|
244
|
+
|
|
204
245
|
/**
|
|
205
246
|
* Finalize this task as failed, now. `error` may be a string reason, an Error,
|
|
206
247
|
* a TaskError (which carries its own retryability), or a ready envelope.
|
|
@@ -216,7 +257,7 @@ export class TaskContext {
|
|
|
216
257
|
const task = await this.owned(() =>
|
|
217
258
|
this.store.fail({
|
|
218
259
|
taskId: this.task.id,
|
|
219
|
-
workerId: this.
|
|
260
|
+
workerId: this.ownerId,
|
|
220
261
|
error: envelope,
|
|
221
262
|
retryable,
|
|
222
263
|
delayMs: failDelayMs(this.task.attempt, retryable, this.backoffMs, this.backoffMaxMs),
|
package/src/errors.ts
CHANGED
|
@@ -208,6 +208,27 @@ export class ProtocolVersionMismatch extends CairnQError {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* This connection is not pointed at the cairnq installation the rest of the
|
|
213
|
+
* deployment is using — raised at connect, before any task is written.
|
|
214
|
+
*
|
|
215
|
+
* The schema a Postgres connection resolves to is out-of-band configuration
|
|
216
|
+
* (`search_path`, a `schema` option, an ORM's pool settings), so two processes
|
|
217
|
+
* given the same DSN can still land in different schemas. Every migration is
|
|
218
|
+
* `create table if not exists`, so the odd one out does not fail: it builds a
|
|
219
|
+
* second, empty installation and its protocol version check passes against the
|
|
220
|
+
* cairnq_meta it just created. Left undetected, an API and a worker then agree
|
|
221
|
+
* about everything except WHERE, and no task ever crosses.
|
|
222
|
+
*
|
|
223
|
+
* The Python SDK raises the same named error.
|
|
224
|
+
*/
|
|
225
|
+
export class SchemaMismatch extends CairnQError {
|
|
226
|
+
constructor(message: string) {
|
|
227
|
+
super(message);
|
|
228
|
+
this.name = "SchemaMismatch";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
211
232
|
/** A value could not be encoded for a protocol JSON column (non-finite number,
|
|
212
233
|
* BigInt, circular structure, …). Raised at the boundary — submit rejects with
|
|
213
234
|
* it, and a worker records a handler result that triggers it as a permanent
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,19 @@ export { defineTask } from "./task.js";
|
|
|
12
12
|
export type { TaskDef } from "./task.js";
|
|
13
13
|
export { SQLiteStore } from "./store/sqlite.js";
|
|
14
14
|
export { PostgresStore } from "./store/postgres.js";
|
|
15
|
+
export { ListenUnavailable } from "./store/pg-executor.js";
|
|
16
|
+
export type { PgExecutor, PgSession, Row } from "./store/pg-executor.js";
|
|
17
|
+
export { createPoolExecutor } from "./store/pg-pool.js";
|
|
15
18
|
export { TaskStore } from "./store/base.js";
|
|
16
|
-
export type {
|
|
19
|
+
export type {
|
|
20
|
+
ListInput,
|
|
21
|
+
PurgeInput,
|
|
22
|
+
SubmitInput,
|
|
23
|
+
Conflict,
|
|
24
|
+
WatchOptions,
|
|
25
|
+
WatchSignal,
|
|
26
|
+
} from "./store/base.js";
|
|
27
|
+
export { DEFAULT_WATCH_POLL_MS } from "./store/base.js";
|
|
17
28
|
export type { Task, TaskRef, TaskStatus, TerminalStatus } from "./models.js";
|
|
18
29
|
export {
|
|
19
30
|
STATUSES,
|
|
@@ -37,6 +48,7 @@ export {
|
|
|
37
48
|
LostLease,
|
|
38
49
|
EventLoopBlocked,
|
|
39
50
|
ProtocolVersionMismatch,
|
|
51
|
+
SchemaMismatch,
|
|
40
52
|
SerializationError,
|
|
41
53
|
} from "./errors.js";
|
|
42
54
|
export type { FailReason } from "./errors.js";
|
package/src/models.ts
CHANGED
|
@@ -39,6 +39,19 @@ export interface TaskRef {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const JSON_COLUMNS = ["payload", "result", "error", "metadata"] as const;
|
|
42
|
+
// The bigint columns. `attempt` / `max_attempts` / `priority` are int4 and
|
|
43
|
+
// `progress` is double precision, so every driver already gives those as numbers;
|
|
44
|
+
// only int8 has a wire form worth normalizing. Nullability differs per column
|
|
45
|
+
// (completed_at_ms may be null, created_at_ms may not), so the coercion has to
|
|
46
|
+
// preserve null rather than turn it into 0.
|
|
47
|
+
const MS_COLUMNS = [
|
|
48
|
+
"lease_until_ms",
|
|
49
|
+
"run_at_ms",
|
|
50
|
+
"cancel_requested_at_ms",
|
|
51
|
+
"created_at_ms",
|
|
52
|
+
"updated_at_ms",
|
|
53
|
+
"completed_at_ms",
|
|
54
|
+
] as const;
|
|
42
55
|
// As a const tuple so TerminalStatus derives from it — the same declare-once
|
|
43
56
|
// pattern as STATUSES/TaskStatus above.
|
|
44
57
|
export const TERMINAL = ["succeeded", "failed", "canceled"] as const;
|
|
@@ -63,6 +76,20 @@ export function rowToTask(row: Record<string, unknown>): Task {
|
|
|
63
76
|
// already-decoded object. Parse only a string — never assume one backend.
|
|
64
77
|
t[col] = typeof v === "string" ? JSON.parse(v) : (v ?? null);
|
|
65
78
|
}
|
|
79
|
+
for (const col of MS_COLUMNS) {
|
|
80
|
+
const v = row[col];
|
|
81
|
+
// Same argument, for the other column type the drivers disagree about.
|
|
82
|
+
// Postgres sends int8 down the wire as text to protect precision it cannot
|
|
83
|
+
// know is unneeded; `pg` surfaces that as a string, postgres.js does too,
|
|
84
|
+
// asyncpg decodes to int. Normalizing here rather than demanding it of every
|
|
85
|
+
// driver is what keeps an INJECTED executor honest: the alternative is
|
|
86
|
+
// asking an application to change its driver's global int8 handling to suit
|
|
87
|
+
// cairnq, which breaks that application's own bigint columns.
|
|
88
|
+
//
|
|
89
|
+
// Lossless: every one of these is an epoch-ms, and a millisecond timestamp
|
|
90
|
+
// does not reach Number.MAX_SAFE_INTEGER until the year 287396.
|
|
91
|
+
t[col] = v == null ? null : Number(v);
|
|
92
|
+
}
|
|
66
93
|
return t as unknown as Task;
|
|
67
94
|
}
|
|
68
95
|
|
package/src/store/base.ts
CHANGED
|
@@ -201,6 +201,35 @@ export function statementParams(sql: string): readonly string[] {
|
|
|
201
201
|
* them in one place is what stops SQLite and Postgres from drifting apart in
|
|
202
202
|
* behavior; the shared SQL already stops them from drifting in wording.
|
|
203
203
|
*/
|
|
204
|
+
/**
|
|
205
|
+
* Why `watch` is calling back.
|
|
206
|
+
*
|
|
207
|
+
* `queued` / `done` come from the store's push channel and name what moved;
|
|
208
|
+
* `poll` is the timer saying the watch cannot rule out a change. None of them
|
|
209
|
+
* carries state — the row is the truth.
|
|
210
|
+
*/
|
|
211
|
+
export interface WatchSignal {
|
|
212
|
+
reason: "queued" | "done" | "poll";
|
|
213
|
+
/** The queue a task was queued on. Only on `queued`. */
|
|
214
|
+
queue?: string;
|
|
215
|
+
/** The task that reached a terminal status. Only on `done`. */
|
|
216
|
+
taskId?: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface WatchOptions {
|
|
220
|
+
/** Restrict `queued` signals to these queues. Unset watches every queue. */
|
|
221
|
+
queues?: string[];
|
|
222
|
+
/**
|
|
223
|
+
* How often to signal in the absence of a push channel — and, where there is
|
|
224
|
+
* one, how long a dropped listener can go unnoticed. The default trades a
|
|
225
|
+
* dashboard's idle query rate against how stale it may look.
|
|
226
|
+
*/
|
|
227
|
+
pollMs?: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** See WatchOptions.pollMs. */
|
|
231
|
+
export const DEFAULT_WATCH_POLL_MS = 2_000;
|
|
232
|
+
|
|
204
233
|
export abstract class TaskStore {
|
|
205
234
|
/** Set by useBackpressure; null means submit is ungated. */
|
|
206
235
|
private gate: QueueDepthGate | null = null;
|
|
@@ -222,6 +251,37 @@ export abstract class TaskStore {
|
|
|
222
251
|
*/
|
|
223
252
|
protected abstract tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T>;
|
|
224
253
|
|
|
254
|
+
/**
|
|
255
|
+
* `tx`, but also handing `fn` the driver's own session so the CALLER can run
|
|
256
|
+
* their statements in the same transaction as the protocol's.
|
|
257
|
+
*
|
|
258
|
+
* This is what lets a task's settlement and the rows that task produced commit
|
|
259
|
+
* together. Without it the two are separate transactions and there is a window
|
|
260
|
+
* where the work is durable but the task still reads as unfinished — a crash
|
|
261
|
+
* there costs a full recomputation on retry, and for non-idempotent work costs
|
|
262
|
+
* more than that.
|
|
263
|
+
*
|
|
264
|
+
* Optional, because the session type is the driver's, not the protocol's: a
|
|
265
|
+
* store that has no session worth handing out simply does not implement it and
|
|
266
|
+
* `completeIn` reports that. Postgres implements it; SQLite does not.
|
|
267
|
+
*/
|
|
268
|
+
protected txWithSession?<T>(fn: (fetch: Fetch, session: unknown) => Promise<T>): Promise<T>;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Register for this store's push channel, if it has one; returns an
|
|
272
|
+
* unsubscribe. A store without a push channel does not implement this, and
|
|
273
|
+
* `watch` degrades to its timer alone.
|
|
274
|
+
*/
|
|
275
|
+
protected subscribePush?(onSignal: (signal: WatchSignal) => void): () => void;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Nudge the push channel back up if it has dropped. Called from `watch`'s
|
|
279
|
+
* timer, which is the only thing keeping a client-side subscriber alive: a
|
|
280
|
+
* process that never claims never calls claimWake, so without this a listener
|
|
281
|
+
* that died once would never come back there.
|
|
282
|
+
*/
|
|
283
|
+
protected warmPush?(): void;
|
|
284
|
+
|
|
225
285
|
/**
|
|
226
286
|
* Whether it is worth opening the claim transaction at all. SQLite gates its
|
|
227
287
|
* single write lock behind a read-only probe; Postgres readers don't block
|
|
@@ -460,6 +520,57 @@ export abstract class TaskStore {
|
|
|
460
520
|
return out;
|
|
461
521
|
}
|
|
462
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Call `onSignal` when the tasks on `queues` may have changed — something was
|
|
525
|
+
* queued, or something finished.
|
|
526
|
+
*
|
|
527
|
+
* This is notify-ACCELERATED POLLING, not an event log, and the difference is
|
|
528
|
+
* the whole contract. Where a push channel is available (Postgres LISTEN) an
|
|
529
|
+
* idle watch costs nothing and a signal arrives within milliseconds of the
|
|
530
|
+
* event. Where it is not — a transaction-mode pooler refuses LISTEN, SQLite has
|
|
531
|
+
* no channel at all — the timer alone still delivers `poll` signals, so a
|
|
532
|
+
* consumer that re-reads on every signal is correct in both cases and merely
|
|
533
|
+
* less prompt in one.
|
|
534
|
+
*
|
|
535
|
+
* What it will NOT do is promise that a signal means something happened, or
|
|
536
|
+
* that every event produces its own signal. Treat a signal as "re-read now"
|
|
537
|
+
* and take the truth from `stats()` / `list()` / `get()`, which is where it
|
|
538
|
+
* lives. `reason` is a hint for reading less: a `done` signal names the task,
|
|
539
|
+
* so a dashboard can refresh that row instead of the list.
|
|
540
|
+
*
|
|
541
|
+
* Returns an unsubscribe. The timer is unref'd — watching does not hold a
|
|
542
|
+
* process open.
|
|
543
|
+
*/
|
|
544
|
+
watch(opts: WatchOptions, onSignal: (signal: WatchSignal) => void): () => void {
|
|
545
|
+
const pollMs = Math.max(1, opts.pollMs ?? DEFAULT_WATCH_POLL_MS);
|
|
546
|
+
const queues = opts.queues ?? null;
|
|
547
|
+
let live = true;
|
|
548
|
+
const emit = (signal: WatchSignal): void => {
|
|
549
|
+
// A signal delivered after unsubscribe would have the consumer re-reading
|
|
550
|
+
// a store it has stopped caring about, possibly a closed one.
|
|
551
|
+
if (live) onSignal(signal);
|
|
552
|
+
};
|
|
553
|
+
const unsubscribe = this.subscribePush?.((signal) => {
|
|
554
|
+
// A queued signal names its queue, so a watch scoped to some queues can
|
|
555
|
+
// drop the rest. A done signal names only the task — which queue it was on
|
|
556
|
+
// is not in the notification, so it is never filtered out.
|
|
557
|
+
if (signal.reason === "queued" && queues && signal.queue && !queues.includes(signal.queue)) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
emit(signal);
|
|
561
|
+
});
|
|
562
|
+
const timer = setInterval(() => {
|
|
563
|
+
this.warmPush?.();
|
|
564
|
+
emit({ reason: "poll" });
|
|
565
|
+
}, pollMs);
|
|
566
|
+
timer.unref?.();
|
|
567
|
+
return () => {
|
|
568
|
+
live = false;
|
|
569
|
+
unsubscribe?.();
|
|
570
|
+
clearInterval(timer);
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
463
574
|
/**
|
|
464
575
|
* How many more tasks fit on `queue` under `maxDepth` — 0 once it is full.
|
|
465
576
|
*
|
|
@@ -636,6 +747,45 @@ export abstract class TaskStore {
|
|
|
636
747
|
});
|
|
637
748
|
}
|
|
638
749
|
|
|
750
|
+
/**
|
|
751
|
+
* `complete`, with the caller's own writes committed in the same transaction.
|
|
752
|
+
*
|
|
753
|
+
* `fn` runs first and whatever it returns becomes the task's result; the
|
|
754
|
+
* settlement is the last statement in the transaction. So a lost lease — the
|
|
755
|
+
* settlement matching no row — rolls the caller's writes back with it, and
|
|
756
|
+
* there is no ordering in which the work is recorded but the task is not.
|
|
757
|
+
*
|
|
758
|
+
* The settlement runs LAST rather than checking ownership up front on purpose:
|
|
759
|
+
* the ownership predicate lives in the protocol's complete.sql, and a
|
|
760
|
+
* fail-fast pre-check here would be a second copy of it, free to drift. The
|
|
761
|
+
* cost of that choice is that a doomed attempt does its work before finding
|
|
762
|
+
* out, which is the rare path.
|
|
763
|
+
*
|
|
764
|
+
* `fn` must be replayable for the same reason `tx`'s callback must be.
|
|
765
|
+
*/
|
|
766
|
+
async completeIn<S, T>(
|
|
767
|
+
input: { taskId: string; workerId: string },
|
|
768
|
+
fn: (session: S) => Promise<T>,
|
|
769
|
+
): Promise<{ task: Task; value: T }> {
|
|
770
|
+
if (!this.txWithSession) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
"this store cannot share a transaction with the caller — " +
|
|
773
|
+
"completeIn requires a Postgres store (see PgExecutor)",
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
return this.txWithSession(async (fetch, session) => {
|
|
777
|
+
const value = await fn(session as S);
|
|
778
|
+
const rows = await fetch("complete", {
|
|
779
|
+
id: input.taskId,
|
|
780
|
+
worker_id: input.workerId,
|
|
781
|
+
result: value == null ? null : dumpJson(value),
|
|
782
|
+
});
|
|
783
|
+
// Rolls back `fn`'s writes along with the settlement that did not land.
|
|
784
|
+
if (!rows.length) throw new LostLease(input.taskId);
|
|
785
|
+
return { task: rowToTask(rows[0]), value };
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
639
789
|
async fail(input: {
|
|
640
790
|
taskId: string;
|
|
641
791
|
workerId: string;
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
/** A row as the driver hands it back: column name -> value. */
|
|
20
|
+
export type Row = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Somewhere statements can run. The same shape whether it is a pool (each call
|
|
24
|
+
* on some connection) or one transaction's dedicated connection — the store's
|
|
25
|
+
* statements do not care, and this is what lets `tx` hand the same interface to
|
|
26
|
+
* its callback.
|
|
27
|
+
*/
|
|
28
|
+
export interface PgSession {
|
|
29
|
+
/**
|
|
30
|
+
* One parameterised statement, `$1`-style. `values` is positional and may
|
|
31
|
+
* legitimately contain nulls — a null parameter is "this filter is off" in
|
|
32
|
+
* several protocol statements, not a missing argument.
|
|
33
|
+
*/
|
|
34
|
+
query(text: string, values: readonly unknown[]): Promise<Row[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Parameterless SQL that may hold several statements, for migration DDL.
|
|
37
|
+
* Separate from `query` because it must go over the simple query protocol:
|
|
38
|
+
* the extended protocol a parameterised call uses accepts only one statement,
|
|
39
|
+
* and every migration is a script.
|
|
40
|
+
*/
|
|
41
|
+
exec(sql: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A session that can also open transactions, listen, and be shut down. */
|
|
45
|
+
export interface PgExecutor extends PgSession {
|
|
46
|
+
/**
|
|
47
|
+
* Run `fn` inside one transaction on one dedicated connection, committing if
|
|
48
|
+
* it returns and rolling back if it throws. The store relies on both halves:
|
|
49
|
+
* a claim that cannot see its own `recover_leases` is a double-dispatch, and a
|
|
50
|
+
* keyed submit that commits half way poisons the key.
|
|
51
|
+
*/
|
|
52
|
+
tx<T>(fn: (session: PgSession) => Promise<T>): Promise<T>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe a dedicated connection to `channels`. Optional: an executor that
|
|
56
|
+
* omits it (or a Postgres that refuses LISTEN) costs latency, never
|
|
57
|
+
* correctness — the store falls back to plain polling, which is the contract
|
|
58
|
+
* PROTOCOL.md gives for push wakeups.
|
|
59
|
+
*
|
|
60
|
+
* Resolves with a function that stops listening. `onClose` reports a
|
|
61
|
+
* connection that dropped on its own, so the store can degrade and retry.
|
|
62
|
+
*
|
|
63
|
+
* Throw `ListenUnavailable` when this Postgres will never accept LISTEN — a
|
|
64
|
+
* transaction-mode pooler, say. Any other rejection is read as transient and
|
|
65
|
+
* retried with backoff, so a permanent condition raised as a plain Error
|
|
66
|
+
* becomes a reconnect loop that cannot succeed.
|
|
67
|
+
*/
|
|
68
|
+
listen?(
|
|
69
|
+
channels: readonly string[],
|
|
70
|
+
onNotify: (channel: string, payload: string | undefined) => void,
|
|
71
|
+
onClose: () => void,
|
|
72
|
+
): Promise<() => void>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Release this executor's resources. Called by PostgresStore.close() ONLY for
|
|
76
|
+
* an executor the store created itself: an injected one belongs to the caller,
|
|
77
|
+
* whose other work would not survive cairnq closing it.
|
|
78
|
+
*/
|
|
79
|
+
close(): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* LISTEN will not work on this connection, and retrying cannot change that.
|
|
84
|
+
* See `PgExecutor.listen`.
|
|
85
|
+
*/
|
|
86
|
+
export class ListenUnavailable extends Error {
|
|
87
|
+
constructor(message = "LISTEN is not available on this connection") {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = "ListenUnavailable";
|
|
90
|
+
}
|
|
91
|
+
}
|