cairnq 0.1.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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/_protocol/migrations/postgres/0001_init.sql +75 -0
- package/dist/_protocol/migrations/sqlite/0001_init.sql +74 -0
- package/dist/_protocol/sql/postgres/cancel.sql +16 -0
- package/dist/_protocol/sql/postgres/claim.sql +27 -0
- package/dist/_protocol/sql/postgres/complete.sql +17 -0
- package/dist/_protocol/sql/postgres/fail.sql +21 -0
- package/dist/_protocol/sql/postgres/get.sql +2 -0
- package/dist/_protocol/sql/postgres/get_by_key.sql +4 -0
- package/dist/_protocol/sql/postgres/get_key.sql +3 -0
- package/dist/_protocol/sql/postgres/heartbeat.sql +12 -0
- package/dist/_protocol/sql/postgres/insert_task.sql +20 -0
- package/dist/_protocol/sql/postgres/list.sql +13 -0
- package/dist/_protocol/sql/postgres/progress.sql +13 -0
- package/dist/_protocol/sql/postgres/recover_leases.sql +20 -0
- package/dist/_protocol/sql/postgres/retry.sql +17 -0
- package/dist/_protocol/sql/postgres/succeed.sql +16 -0
- package/dist/_protocol/sql/postgres/upsert_key.sql +12 -0
- package/dist/_protocol/sql/sqlite/cancel.sql +12 -0
- package/dist/_protocol/sql/sqlite/claim.sql +20 -0
- package/dist/_protocol/sql/sqlite/claimable_probe.sql +14 -0
- package/dist/_protocol/sql/sqlite/complete.sql +18 -0
- package/dist/_protocol/sql/sqlite/fail.sql +19 -0
- package/dist/_protocol/sql/sqlite/get.sql +2 -0
- package/dist/_protocol/sql/sqlite/get_by_key.sql +4 -0
- package/dist/_protocol/sql/sqlite/get_key.sql +3 -0
- package/dist/_protocol/sql/sqlite/heartbeat.sql +10 -0
- package/dist/_protocol/sql/sqlite/insert_task.sql +16 -0
- package/dist/_protocol/sql/sqlite/list.sql +11 -0
- package/dist/_protocol/sql/sqlite/progress.sql +10 -0
- package/dist/_protocol/sql/sqlite/recover_leases.sql +17 -0
- package/dist/_protocol/sql/sqlite/retry.sql +16 -0
- package/dist/_protocol/sql/sqlite/succeed.sql +15 -0
- package/dist/_protocol/sql/sqlite/upsert_key.sql +7 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.js +70 -0
- package/dist/context.d.ts +31 -0
- package/dist/context.js +78 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.js +100 -0
- package/dist/ids.d.ts +3 -0
- package/dist/ids.js +19 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/models.d.ts +36 -0
- package/dist/models.js +31 -0
- package/dist/sql.d.ts +6 -0
- package/dist/sql.js +44 -0
- package/dist/store/base.d.ts +77 -0
- package/dist/store/base.js +1 -0
- package/dist/store/postgres.d.ts +87 -0
- package/dist/store/postgres.js +349 -0
- package/dist/store/sqlite.d.ts +77 -0
- package/dist/store/sqlite.js +297 -0
- package/dist/task.d.ts +21 -0
- package/dist/task.js +7 -0
- package/dist/wait.d.ts +8 -0
- package/dist/wait.js +18 -0
- package/dist/worker.d.ts +60 -0
- package/dist/worker.js +252 -0
- package/package.json +57 -0
- package/src/client.ts +98 -0
- package/src/context.ts +85 -0
- package/src/errors.ts +112 -0
- package/src/ids.ts +23 -0
- package/src/index.ts +31 -0
- package/src/models.ts +63 -0
- package/src/sql.ts +49 -0
- package/src/store/base.ts +67 -0
- package/src/store/postgres.ts +409 -0
- package/src/store/sqlite.ts +351 -0
- package/src/task.ts +27 -0
- package/src/wait.ts +23 -0
- package/src/worker.ts +284 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { newId, nowMs } from "../ids.js";
|
|
5
|
+
import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch } from "../errors.js";
|
|
6
|
+
import { rowToTask } from "../models.js";
|
|
7
|
+
import { loadMigrations, loadStatements } from "../sql.js";
|
|
8
|
+
const SUPPORTED_PROTOCOL_MAJOR = 1;
|
|
9
|
+
const LEASE_EXPIRED_ERROR = errorEnvelope({
|
|
10
|
+
type: "LeaseExpired",
|
|
11
|
+
code: "lease_expired",
|
|
12
|
+
message: "task lease expired and max attempts reached",
|
|
13
|
+
retryable: false,
|
|
14
|
+
});
|
|
15
|
+
// Serialized once: it's an immutable constant bound on every claim that finds work.
|
|
16
|
+
const LEASE_EXPIRED_ERROR_JSON = JSON.stringify(LEASE_EXPIRED_ERROR);
|
|
17
|
+
/**
|
|
18
|
+
* SQLiteStore — better-sqlite3 backend executing the shared cairnq-protocol SQL.
|
|
19
|
+
*
|
|
20
|
+
* The driver is synchronous, which suits SQLite's single writer: claim is one
|
|
21
|
+
* short transaction, the handler runs outside any transaction, and
|
|
22
|
+
* progress/heartbeat/succeed/fail are each their own short write. JS being
|
|
23
|
+
* single-threaded means sync DB calls never interleave. Cross-process contention
|
|
24
|
+
* (deployment mode B) is absorbed by busy_timeout.
|
|
25
|
+
*/
|
|
26
|
+
export class SQLiteStore {
|
|
27
|
+
path;
|
|
28
|
+
opts;
|
|
29
|
+
db = null;
|
|
30
|
+
stmts = {};
|
|
31
|
+
statements;
|
|
32
|
+
constructor(path, opts = {}) {
|
|
33
|
+
this.path = path;
|
|
34
|
+
this.opts = opts;
|
|
35
|
+
this.statements = loadStatements("sqlite");
|
|
36
|
+
}
|
|
37
|
+
async connect() {
|
|
38
|
+
this.ensure();
|
|
39
|
+
}
|
|
40
|
+
async close() {
|
|
41
|
+
if (this.db) {
|
|
42
|
+
this.db.close();
|
|
43
|
+
this.db = null;
|
|
44
|
+
this.stmts = {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
ensure() {
|
|
48
|
+
if (this.db)
|
|
49
|
+
return this.db;
|
|
50
|
+
if (this.path !== ":memory:")
|
|
51
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
52
|
+
const db = new Database(this.path);
|
|
53
|
+
db.pragma("journal_mode = WAL");
|
|
54
|
+
db.pragma("foreign_keys = ON");
|
|
55
|
+
db.pragma(`busy_timeout = ${this.opts.busyTimeoutMs ?? 5000}`);
|
|
56
|
+
this.applyMigrations(db);
|
|
57
|
+
for (const [name, sql] of Object.entries(this.statements)) {
|
|
58
|
+
this.stmts[name] = db.prepare(sql);
|
|
59
|
+
}
|
|
60
|
+
this.db = db;
|
|
61
|
+
this.checkVersion();
|
|
62
|
+
return db;
|
|
63
|
+
}
|
|
64
|
+
applyMigrations(db) {
|
|
65
|
+
db.exec("create table if not exists cairnq_migrations " +
|
|
66
|
+
"(name text primary key, applied_at_ms integer not null)");
|
|
67
|
+
const applied = new Set(db.prepare("select name from cairnq_migrations").all().map((r) => r.name));
|
|
68
|
+
// `or ignore`: another process may apply the same migration concurrently on a
|
|
69
|
+
// fresh shared db (mode B cold start). Migrations are idempotent.
|
|
70
|
+
const insert = db.prepare("insert or ignore into cairnq_migrations (name, applied_at_ms) values (?, ?)");
|
|
71
|
+
for (const { name, sql } of loadMigrations("sqlite")) {
|
|
72
|
+
if (applied.has(name))
|
|
73
|
+
continue;
|
|
74
|
+
db.transaction(() => {
|
|
75
|
+
db.exec(sql);
|
|
76
|
+
insert.run(name, nowMs());
|
|
77
|
+
})();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
checkVersion() {
|
|
81
|
+
const version = this.readProtocolVersion();
|
|
82
|
+
if (version !== SUPPORTED_PROTOCOL_MAJOR) {
|
|
83
|
+
throw new ProtocolVersionMismatch(`storage protocol_version=${version}, SDK supports ${SUPPORTED_PROTOCOL_MAJOR}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
readProtocolVersion() {
|
|
87
|
+
const row = this.db
|
|
88
|
+
.prepare("select value from cairnq_meta where key = 'protocol_version'")
|
|
89
|
+
.get();
|
|
90
|
+
return row ? Number(row.value) : 0;
|
|
91
|
+
}
|
|
92
|
+
async protocolVersion() {
|
|
93
|
+
this.ensure();
|
|
94
|
+
return this.readProtocolVersion();
|
|
95
|
+
}
|
|
96
|
+
all(name, params) {
|
|
97
|
+
return this.stmts[name].all(params);
|
|
98
|
+
}
|
|
99
|
+
run(name, params) {
|
|
100
|
+
this.stmts[name].run(params);
|
|
101
|
+
}
|
|
102
|
+
// An ownership-checked worker write (heartbeat/progress/succeed/complete/fail).
|
|
103
|
+
// Each statement's WHERE pins worker_id + a live lease, so 0 rows back means the
|
|
104
|
+
// lease was lost — every such write reports it the same way.
|
|
105
|
+
ownedWrite(name, taskId, params) {
|
|
106
|
+
const rows = this.all(name, params);
|
|
107
|
+
if (!rows.length)
|
|
108
|
+
throw new LostLease(taskId);
|
|
109
|
+
return rowToTask(rows[0]);
|
|
110
|
+
}
|
|
111
|
+
// ------------------------------------------------------------- client side
|
|
112
|
+
async submit(input) {
|
|
113
|
+
this.ensure();
|
|
114
|
+
const now = nowMs();
|
|
115
|
+
const id = newId("task");
|
|
116
|
+
const ins = {
|
|
117
|
+
id,
|
|
118
|
+
name: input.name,
|
|
119
|
+
queue: input.queue ?? "default",
|
|
120
|
+
payload: JSON.stringify(input.payload ?? {}),
|
|
121
|
+
metadata: JSON.stringify(input.metadata ?? {}),
|
|
122
|
+
max_attempts: input.maxAttempts ?? 3,
|
|
123
|
+
priority: input.priority ?? 0,
|
|
124
|
+
run_at_ms: now + (input.runAtDelayMs ?? 0),
|
|
125
|
+
parent_id: input.parentId ?? null,
|
|
126
|
+
root_id: input.rootId ?? id,
|
|
127
|
+
correlation_id: input.correlationId ?? null,
|
|
128
|
+
now_ms: now,
|
|
129
|
+
};
|
|
130
|
+
const key = input.key ?? null;
|
|
131
|
+
const conflict = input.conflict ?? "reuse";
|
|
132
|
+
const txn = this.db.transaction(() => {
|
|
133
|
+
if (key === null)
|
|
134
|
+
return this.all("insert_task", ins)[0];
|
|
135
|
+
const existing = this.all("get_key", { key });
|
|
136
|
+
if (existing.length) {
|
|
137
|
+
const exId = existing[0].task_id;
|
|
138
|
+
if (conflict === "reuse")
|
|
139
|
+
return this.all("get", { id: exId })[0];
|
|
140
|
+
if (conflict === "reject")
|
|
141
|
+
throw new AlreadyExists(key);
|
|
142
|
+
if (conflict === "replace") {
|
|
143
|
+
this.all("cancel", { id: exId, now_ms: now });
|
|
144
|
+
const row = this.all("insert_task", ins)[0];
|
|
145
|
+
this.run("upsert_key", { key, task_id: id, now_ms: now });
|
|
146
|
+
return row;
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`unknown conflict strategy: ${conflict}`);
|
|
149
|
+
}
|
|
150
|
+
const row = this.all("insert_task", ins)[0];
|
|
151
|
+
this.run("upsert_key", { key, task_id: id, now_ms: now });
|
|
152
|
+
return row;
|
|
153
|
+
});
|
|
154
|
+
return rowToTask(txn.immediate());
|
|
155
|
+
}
|
|
156
|
+
async get(taskId) {
|
|
157
|
+
this.ensure();
|
|
158
|
+
const rows = this.all("get", { id: taskId });
|
|
159
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
160
|
+
}
|
|
161
|
+
async getByKey(key) {
|
|
162
|
+
this.ensure();
|
|
163
|
+
const rows = this.all("get_by_key", { key });
|
|
164
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
165
|
+
}
|
|
166
|
+
async list(input = {}) {
|
|
167
|
+
this.ensure();
|
|
168
|
+
const rows = this.all("list", {
|
|
169
|
+
status: input.status ?? null,
|
|
170
|
+
queue: input.queue ?? null,
|
|
171
|
+
name: input.name ?? null,
|
|
172
|
+
root_id: input.rootId ?? null,
|
|
173
|
+
correlation_id: input.correlationId ?? null,
|
|
174
|
+
limit: input.limit ?? 100,
|
|
175
|
+
offset: input.offset ?? 0,
|
|
176
|
+
});
|
|
177
|
+
return rows.map(rowToTask);
|
|
178
|
+
}
|
|
179
|
+
async cancel(taskId) {
|
|
180
|
+
this.ensure();
|
|
181
|
+
const rows = this.all("cancel", { id: taskId, now_ms: nowMs() });
|
|
182
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
183
|
+
}
|
|
184
|
+
async cancelByKey(key) {
|
|
185
|
+
this.ensure();
|
|
186
|
+
const txn = this.db.transaction(() => {
|
|
187
|
+
const existing = this.all("get_key", { key });
|
|
188
|
+
if (!existing.length)
|
|
189
|
+
return null;
|
|
190
|
+
const rows = this.all("cancel", { id: existing[0].task_id, now_ms: nowMs() });
|
|
191
|
+
return rows.length ? rows[0] : null;
|
|
192
|
+
});
|
|
193
|
+
const row = txn.immediate();
|
|
194
|
+
return row ? rowToTask(row) : null;
|
|
195
|
+
}
|
|
196
|
+
async retry(taskId, opts = {}) {
|
|
197
|
+
this.ensure();
|
|
198
|
+
const rows = this.all("retry", {
|
|
199
|
+
id: taskId,
|
|
200
|
+
now_ms: nowMs(),
|
|
201
|
+
reset_attempt: opts.resetAttempt ? 1 : 0,
|
|
202
|
+
});
|
|
203
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
204
|
+
}
|
|
205
|
+
async retryByKey(key, opts = {}) {
|
|
206
|
+
this.ensure();
|
|
207
|
+
const txn = this.db.transaction(() => {
|
|
208
|
+
const existing = this.all("get_key", { key });
|
|
209
|
+
if (!existing.length)
|
|
210
|
+
return null;
|
|
211
|
+
const rows = this.all("retry", {
|
|
212
|
+
id: existing[0].task_id,
|
|
213
|
+
now_ms: nowMs(),
|
|
214
|
+
reset_attempt: opts.resetAttempt ? 1 : 0,
|
|
215
|
+
});
|
|
216
|
+
return rows.length ? rows[0] : null;
|
|
217
|
+
});
|
|
218
|
+
const row = txn.immediate();
|
|
219
|
+
return row ? rowToTask(row) : null;
|
|
220
|
+
}
|
|
221
|
+
// ------------------------------------------------------------- worker side
|
|
222
|
+
async claim(input) {
|
|
223
|
+
this.ensure();
|
|
224
|
+
const now = nowMs();
|
|
225
|
+
const queues = JSON.stringify(input.queues);
|
|
226
|
+
const leaseMs = input.leaseMs ?? 30_000;
|
|
227
|
+
const limit = input.limit ?? 1;
|
|
228
|
+
// Read-only probe first: skip the write lock entirely when idle.
|
|
229
|
+
const probe = this.all("claimable_probe", { queues, now_ms: now })[0];
|
|
230
|
+
if (!probe || !probe.has_work)
|
|
231
|
+
return [];
|
|
232
|
+
const txn = this.db.transaction(() => {
|
|
233
|
+
this.all("recover_leases", {
|
|
234
|
+
now_ms: now,
|
|
235
|
+
lease_expired_error: LEASE_EXPIRED_ERROR_JSON,
|
|
236
|
+
});
|
|
237
|
+
return this.all("claim", {
|
|
238
|
+
queues,
|
|
239
|
+
now_ms: now,
|
|
240
|
+
worker_id: input.workerId,
|
|
241
|
+
lease_until_ms: now + leaseMs,
|
|
242
|
+
limit,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
return txn.immediate().map(rowToTask);
|
|
246
|
+
}
|
|
247
|
+
async heartbeat(input) {
|
|
248
|
+
this.ensure();
|
|
249
|
+
const now = nowMs();
|
|
250
|
+
return this.ownedWrite("heartbeat", input.taskId, {
|
|
251
|
+
id: input.taskId,
|
|
252
|
+
worker_id: input.workerId,
|
|
253
|
+
now_ms: now,
|
|
254
|
+
lease_until_ms: now + (input.leaseMs ?? 30_000),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
async progress(input) {
|
|
258
|
+
this.ensure();
|
|
259
|
+
return this.ownedWrite("progress", input.taskId, {
|
|
260
|
+
id: input.taskId,
|
|
261
|
+
worker_id: input.workerId,
|
|
262
|
+
now_ms: nowMs(),
|
|
263
|
+
progress: input.progress,
|
|
264
|
+
message: input.message,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
async succeed(input) {
|
|
268
|
+
this.ensure();
|
|
269
|
+
return this.ownedWrite("succeed", input.taskId, {
|
|
270
|
+
id: input.taskId,
|
|
271
|
+
worker_id: input.workerId,
|
|
272
|
+
now_ms: nowMs(),
|
|
273
|
+
result: input.result == null ? null : JSON.stringify(input.result),
|
|
274
|
+
message: null,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async complete(input) {
|
|
278
|
+
this.ensure();
|
|
279
|
+
return this.ownedWrite("complete", input.taskId, {
|
|
280
|
+
id: input.taskId,
|
|
281
|
+
worker_id: input.workerId,
|
|
282
|
+
now_ms: nowMs(),
|
|
283
|
+
result: input.result == null ? null : JSON.stringify(input.result),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async fail(input) {
|
|
287
|
+
this.ensure();
|
|
288
|
+
return this.ownedWrite("fail", input.taskId, {
|
|
289
|
+
id: input.taskId,
|
|
290
|
+
worker_id: input.workerId,
|
|
291
|
+
now_ms: nowMs(),
|
|
292
|
+
error: JSON.stringify(input.error ?? {}),
|
|
293
|
+
retryable: input.retryable === false ? 0 : 1,
|
|
294
|
+
delay_ms: input.delayMs ?? 0,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
package/dist/task.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A typed task handle. `defineTask<Payload, Result>("name")` gives one symbol that
|
|
3
|
+
* the worker (`worker.task(def, handler)`) and the client (`tasks.submit/call(def, …)`)
|
|
4
|
+
* both reference — so the name lives in exactly one place (no string drift), editors
|
|
5
|
+
* autocomplete it and find every caller, and `call(def, …)` infers its `Result` type.
|
|
6
|
+
*
|
|
7
|
+
* It's purely opt-in: every API still accepts a plain name string, and cross-language
|
|
8
|
+
* callers keep using the string (only the name crosses the DB boundary). The `__payload`
|
|
9
|
+
* / `__result` fields are phantom type carriers — they let TypeScript infer `P`/`R` from
|
|
10
|
+
* a `TaskDef<P, R>` argument and are never set or read at runtime.
|
|
11
|
+
*/
|
|
12
|
+
export interface TaskDef<P = unknown, R = unknown> {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
/** @internal phantom — type inference only, never present at runtime. */
|
|
15
|
+
readonly __payload?: P;
|
|
16
|
+
/** @internal phantom — type inference only, never present at runtime. */
|
|
17
|
+
readonly __result?: R;
|
|
18
|
+
}
|
|
19
|
+
export declare function defineTask<P = unknown, R = unknown>(name: string): TaskDef<P, R>;
|
|
20
|
+
/** Resolve a name from either a plain string or a TaskDef. */
|
|
21
|
+
export declare function taskName(task: string | TaskDef): string;
|
package/dist/task.js
ADDED
package/dist/wait.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Task } from "./models.js";
|
|
2
|
+
import type { TaskStore } from "./store/base.js";
|
|
3
|
+
/** Poll get() until terminal or timeout. Returns the terminal Task (any status).
|
|
4
|
+
* Throws TaskTimeout, leaving the task running. */
|
|
5
|
+
export declare function pollWait(store: TaskStore, taskId: string, { timeoutMs, pollMs }: {
|
|
6
|
+
timeoutMs: number;
|
|
7
|
+
pollMs?: number;
|
|
8
|
+
}): Promise<Task>;
|
package/dist/wait.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TaskTimeout } from "./errors.js";
|
|
2
|
+
import { nowMs } from "./ids.js";
|
|
3
|
+
import { isTerminal } from "./models.js";
|
|
4
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
/** Poll get() until terminal or timeout. Returns the terminal Task (any status).
|
|
6
|
+
* Throws TaskTimeout, leaving the task running. */
|
|
7
|
+
export async function pollWait(store, taskId, { timeoutMs, pollMs = 150 }) {
|
|
8
|
+
const deadline = nowMs() + timeoutMs;
|
|
9
|
+
for (;;) {
|
|
10
|
+
const task = await store.get(taskId);
|
|
11
|
+
if (task && isTerminal(task))
|
|
12
|
+
return task;
|
|
13
|
+
const remaining = deadline - nowMs();
|
|
14
|
+
if (remaining <= 0)
|
|
15
|
+
throw new TaskTimeout(taskId);
|
|
16
|
+
await sleep(Math.min(pollMs, remaining));
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { TaskContext } from "./context.js";
|
|
2
|
+
import type { TaskStore } from "./store/base.js";
|
|
3
|
+
import { type TaskDef } from "./task.js";
|
|
4
|
+
export type Handler = (ctx: TaskContext, payload: any) => unknown | Promise<unknown>;
|
|
5
|
+
/** Handler typed against a TaskDef<P, R>: payload is P, the return is R. */
|
|
6
|
+
export type TypedHandler<P, R> = (ctx: TaskContext, payload: P) => R | Promise<R>;
|
|
7
|
+
export interface WorkerOptions {
|
|
8
|
+
concurrency?: number;
|
|
9
|
+
leaseMs?: number;
|
|
10
|
+
heartbeatIntervalMs?: number;
|
|
11
|
+
pollIntervalMs?: number;
|
|
12
|
+
claimBatch?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare class Worker {
|
|
15
|
+
private readonly store;
|
|
16
|
+
private readonly queues;
|
|
17
|
+
private readonly opts;
|
|
18
|
+
private readonly handlers;
|
|
19
|
+
private readonly workerId;
|
|
20
|
+
private stopped;
|
|
21
|
+
private stopResolvers;
|
|
22
|
+
private ownsStore;
|
|
23
|
+
constructor(store: TaskStore, queues: string[], opts?: WorkerOptions);
|
|
24
|
+
static sqlite(path: string, opts?: WorkerOptions & {
|
|
25
|
+
queues?: string[];
|
|
26
|
+
busyTimeoutMs?: number;
|
|
27
|
+
}): Worker;
|
|
28
|
+
/** Multi-host backend. `dsn` is a libpq connection string; requires the
|
|
29
|
+
* optional `pg` package. */
|
|
30
|
+
static postgres(dsn: string, opts?: WorkerOptions & {
|
|
31
|
+
queues?: string[];
|
|
32
|
+
max?: number;
|
|
33
|
+
}): Worker;
|
|
34
|
+
get id(): string;
|
|
35
|
+
task(handler: Handler): this;
|
|
36
|
+
task(name: string, handler: Handler): this;
|
|
37
|
+
task<P, R>(def: TaskDef<P, R>, handler: TypedHandler<P, R>): this;
|
|
38
|
+
stop(): void;
|
|
39
|
+
/** Close the underlying store connection. Call after run() returns. */
|
|
40
|
+
close(): Promise<void>;
|
|
41
|
+
private closeIfOwned;
|
|
42
|
+
run(opts?: {
|
|
43
|
+
concurrency?: number;
|
|
44
|
+
}): Promise<void>;
|
|
45
|
+
/** Blocking-style entry point for a standalone worker process: run until
|
|
46
|
+
* SIGINT/SIGTERM, then close the store. Use this at a script's top level;
|
|
47
|
+
* use run() / background() when you manage the event loop yourself. */
|
|
48
|
+
serve(opts?: {
|
|
49
|
+
concurrency?: number;
|
|
50
|
+
}): Promise<void>;
|
|
51
|
+
/** Run the worker in the same process for the duration of fn (deployment mode A). */
|
|
52
|
+
background<T>(fn: () => Promise<T>, opts?: {
|
|
53
|
+
concurrency?: number;
|
|
54
|
+
}): Promise<T>;
|
|
55
|
+
private execute;
|
|
56
|
+
private startHeartbeat;
|
|
57
|
+
private safeFail;
|
|
58
|
+
private sleepOrStop;
|
|
59
|
+
private installSignals;
|
|
60
|
+
}
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { TaskContext } from "./context.js";
|
|
2
|
+
import { errorEnvelope, LostLease, TaskError } from "./errors.js";
|
|
3
|
+
import { newId } from "./ids.js";
|
|
4
|
+
import { SQLiteStore } from "./store/sqlite.js";
|
|
5
|
+
import { PostgresStore } from "./store/postgres.js";
|
|
6
|
+
import { taskName } from "./task.js";
|
|
7
|
+
function exceptionEnvelope(err) {
|
|
8
|
+
const e = err;
|
|
9
|
+
return errorEnvelope({
|
|
10
|
+
type: e?.name ?? "Error",
|
|
11
|
+
code: "handler_error",
|
|
12
|
+
message: String(e?.message ?? err),
|
|
13
|
+
retryable: true,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export class Worker {
|
|
17
|
+
store;
|
|
18
|
+
queues;
|
|
19
|
+
opts;
|
|
20
|
+
handlers = new Map();
|
|
21
|
+
workerId = newId("worker");
|
|
22
|
+
stopped = false;
|
|
23
|
+
stopResolvers = [];
|
|
24
|
+
// True only when this worker created its own store (via Worker.sqlite); an
|
|
25
|
+
// injected store may be shared, so serve()/background() must not close it.
|
|
26
|
+
ownsStore = false;
|
|
27
|
+
constructor(store, queues, opts = {}) {
|
|
28
|
+
this.store = store;
|
|
29
|
+
this.queues = queues;
|
|
30
|
+
this.opts = opts;
|
|
31
|
+
}
|
|
32
|
+
static sqlite(path, opts = {}) {
|
|
33
|
+
const { queues = ["default"], busyTimeoutMs, ...rest } = opts;
|
|
34
|
+
const worker = new Worker(new SQLiteStore(path, { busyTimeoutMs }), queues, rest);
|
|
35
|
+
worker.ownsStore = true;
|
|
36
|
+
return worker;
|
|
37
|
+
}
|
|
38
|
+
/** Multi-host backend. `dsn` is a libpq connection string; requires the
|
|
39
|
+
* optional `pg` package. */
|
|
40
|
+
static postgres(dsn, opts = {}) {
|
|
41
|
+
const { queues = ["default"], max, ...rest } = opts;
|
|
42
|
+
const worker = new Worker(new PostgresStore(dsn, { max }), queues, rest);
|
|
43
|
+
worker.ownsStore = true;
|
|
44
|
+
return worker;
|
|
45
|
+
}
|
|
46
|
+
get id() {
|
|
47
|
+
return this.workerId;
|
|
48
|
+
}
|
|
49
|
+
task(arg, handler) {
|
|
50
|
+
let name;
|
|
51
|
+
let fn;
|
|
52
|
+
if (typeof arg === "function") {
|
|
53
|
+
// Bare form: worker.task(fn) — registered under the function's name.
|
|
54
|
+
fn = arg;
|
|
55
|
+
name = fn.name;
|
|
56
|
+
if (!name) {
|
|
57
|
+
throw new Error("worker.task(fn): the handler is anonymous; pass a name explicitly, " +
|
|
58
|
+
"e.g. worker.task('summary.create', fn)");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Named string or a TaskDef — resolve the name the one way everything does.
|
|
63
|
+
name = taskName(arg);
|
|
64
|
+
fn = handler;
|
|
65
|
+
}
|
|
66
|
+
this.handlers.set(name, fn);
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
stop() {
|
|
70
|
+
this.stopped = true;
|
|
71
|
+
const resolvers = this.stopResolvers;
|
|
72
|
+
this.stopResolvers = [];
|
|
73
|
+
for (const r of resolvers)
|
|
74
|
+
r();
|
|
75
|
+
}
|
|
76
|
+
/** Close the underlying store connection. Call after run() returns. */
|
|
77
|
+
async close() {
|
|
78
|
+
await this.store.close();
|
|
79
|
+
}
|
|
80
|
+
// serve()/background() only close a store the worker created itself (via
|
|
81
|
+
// Worker.sqlite). An injected store may be shared with a CairnQ client, so
|
|
82
|
+
// closing it here would pull the connection out from under it.
|
|
83
|
+
async closeIfOwned() {
|
|
84
|
+
if (this.ownsStore)
|
|
85
|
+
await this.close();
|
|
86
|
+
}
|
|
87
|
+
async run(opts = {}) {
|
|
88
|
+
const concurrency = opts.concurrency ?? this.opts.concurrency ?? 1;
|
|
89
|
+
const leaseMs = this.opts.leaseMs ?? 30_000;
|
|
90
|
+
const pollMs = this.opts.pollIntervalMs ?? 500;
|
|
91
|
+
const batch = this.opts.claimBatch ?? concurrency;
|
|
92
|
+
await this.store.connect();
|
|
93
|
+
this.installSignals();
|
|
94
|
+
const running = new Set();
|
|
95
|
+
while (!this.stopped) {
|
|
96
|
+
const free = concurrency - running.size;
|
|
97
|
+
if (free <= 0) {
|
|
98
|
+
await this.sleepOrStop(5);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const claimed = await this.store.claim({
|
|
102
|
+
queues: this.queues,
|
|
103
|
+
workerId: this.workerId,
|
|
104
|
+
leaseMs,
|
|
105
|
+
limit: Math.min(batch, free),
|
|
106
|
+
});
|
|
107
|
+
if (claimed.length === 0) {
|
|
108
|
+
await this.sleepOrStop(pollMs);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
for (const task of claimed) {
|
|
112
|
+
const p = this.execute(task, leaseMs).finally(() => running.delete(p));
|
|
113
|
+
running.add(p);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
await Promise.all([...running]);
|
|
117
|
+
}
|
|
118
|
+
/** Blocking-style entry point for a standalone worker process: run until
|
|
119
|
+
* SIGINT/SIGTERM, then close the store. Use this at a script's top level;
|
|
120
|
+
* use run() / background() when you manage the event loop yourself. */
|
|
121
|
+
async serve(opts = {}) {
|
|
122
|
+
try {
|
|
123
|
+
await this.run(opts);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await this.closeIfOwned();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Run the worker in the same process for the duration of fn (deployment mode A). */
|
|
130
|
+
async background(fn, opts = {}) {
|
|
131
|
+
const runner = this.run(opts);
|
|
132
|
+
try {
|
|
133
|
+
return await fn();
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
this.stop();
|
|
137
|
+
await runner;
|
|
138
|
+
await this.closeIfOwned();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async execute(task, leaseMs) {
|
|
142
|
+
const ctx = new TaskContext(this.store, task, this.workerId, leaseMs);
|
|
143
|
+
const hb = this.startHeartbeat(ctx, leaseMs);
|
|
144
|
+
try {
|
|
145
|
+
const handler = this.handlers.get(task.name);
|
|
146
|
+
if (!handler) {
|
|
147
|
+
await this.safeFail(task.id, errorEnvelope({
|
|
148
|
+
type: "NoHandler",
|
|
149
|
+
code: "no_handler",
|
|
150
|
+
message: `no handler registered for ${task.name}`,
|
|
151
|
+
retryable: false,
|
|
152
|
+
}), false);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
let result;
|
|
156
|
+
try {
|
|
157
|
+
result = await handler(ctx, task.payload);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (err instanceof LostLease)
|
|
161
|
+
return;
|
|
162
|
+
if (err instanceof TaskError) {
|
|
163
|
+
await this.safeFail(task.id, err.envelope(), err.retryable);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
await this.safeFail(task.id, exceptionEnvelope(err), true);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
// complete (not succeed): finalizes as canceled if a cancel was
|
|
172
|
+
// requested while the handler ran, else succeeded.
|
|
173
|
+
await this.store.complete({ taskId: task.id, workerId: this.workerId, result });
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
if (err instanceof LostLease)
|
|
177
|
+
return;
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
hb.cancel();
|
|
183
|
+
await hb.done;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
startHeartbeat(ctx, leaseMs) {
|
|
187
|
+
let active = true;
|
|
188
|
+
let wake = null;
|
|
189
|
+
const interval = this.opts.heartbeatIntervalMs ?? Math.max(1_000, Math.floor(leaseMs / 3));
|
|
190
|
+
const done = (async () => {
|
|
191
|
+
while (active) {
|
|
192
|
+
// Cancellable sleep: cancel() resolves this immediately and clears the
|
|
193
|
+
// timer, so done never hangs on a pending timeout.
|
|
194
|
+
await new Promise((resolve) => {
|
|
195
|
+
const timer = setTimeout(resolve, interval);
|
|
196
|
+
wake = () => {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
resolve();
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
wake = null;
|
|
202
|
+
if (!active)
|
|
203
|
+
break;
|
|
204
|
+
try {
|
|
205
|
+
await ctx.heartbeat();
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (err instanceof LostLease)
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
return {
|
|
214
|
+
cancel: () => {
|
|
215
|
+
active = false;
|
|
216
|
+
if (wake)
|
|
217
|
+
wake();
|
|
218
|
+
},
|
|
219
|
+
done,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
async safeFail(taskId, envelope, retryable) {
|
|
223
|
+
try {
|
|
224
|
+
await this.store.fail({ taskId, workerId: this.workerId, error: envelope, retryable });
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (!(err instanceof LostLease))
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
sleepOrStop(ms) {
|
|
232
|
+
if (this.stopped)
|
|
233
|
+
return Promise.resolve();
|
|
234
|
+
return new Promise((resolve) => {
|
|
235
|
+
let done = false;
|
|
236
|
+
const finish = () => {
|
|
237
|
+
if (done)
|
|
238
|
+
return;
|
|
239
|
+
done = true;
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
resolve();
|
|
242
|
+
};
|
|
243
|
+
const timer = setTimeout(finish, ms);
|
|
244
|
+
this.stopResolvers.push(finish);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
installSignals() {
|
|
248
|
+
const handler = () => this.stop();
|
|
249
|
+
process.once("SIGINT", handler);
|
|
250
|
+
process.once("SIGTERM", handler);
|
|
251
|
+
}
|
|
252
|
+
}
|