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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type Task } from "../models.js";
|
|
2
|
+
import type { ListInput, SubmitInput, TaskStore } from "./base.js";
|
|
3
|
+
/**
|
|
4
|
+
* Translate the protocol's named-parameter SQL (`:name`) into Postgres positional
|
|
5
|
+
* placeholders (`$1`), collapsing each DISTINCT name to ONE slot — statements reuse
|
|
6
|
+
* a name across CASE branches / IS NULL guards (e.g. list.sql). SQL comments are
|
|
7
|
+
* stripped first so a `:name` mentioned in a header comment (e.g. "now + :lease_ms")
|
|
8
|
+
* never leaks into the parameter list. Exported for unit testing.
|
|
9
|
+
*/
|
|
10
|
+
export declare function toPositional(sql: string, params: Record<string, unknown>): {
|
|
11
|
+
text: string;
|
|
12
|
+
values: unknown[];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* PostgresStore — asyncpg's TS counterpart: a `pg` Pool executing the shared
|
|
16
|
+
* cairnq-protocol SQL (postgres dialect). Multi-host capable — unlike SQLite this
|
|
17
|
+
* coordinates API and worker processes across machines through one database. Time
|
|
18
|
+
* comes from the DB clock (now()), claim uses FOR UPDATE SKIP LOCKED, JSON columns
|
|
19
|
+
* are jsonb (bound as JSON text, read back as objects by rowToTask's adaptive parse).
|
|
20
|
+
*/
|
|
21
|
+
export declare class PostgresStore implements TaskStore {
|
|
22
|
+
private readonly dsn;
|
|
23
|
+
private readonly opts;
|
|
24
|
+
private pool;
|
|
25
|
+
private connecting;
|
|
26
|
+
private readonly statements;
|
|
27
|
+
constructor(dsn: string, opts?: {
|
|
28
|
+
max?: number;
|
|
29
|
+
});
|
|
30
|
+
connect(): Promise<void>;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
private ensure;
|
|
33
|
+
private doConnect;
|
|
34
|
+
private applyMigrations;
|
|
35
|
+
private readProtocolVersion;
|
|
36
|
+
protocolVersion(): Promise<number>;
|
|
37
|
+
private run;
|
|
38
|
+
private runOn;
|
|
39
|
+
private tx;
|
|
40
|
+
private ownedWrite;
|
|
41
|
+
submit(input: SubmitInput): Promise<Task>;
|
|
42
|
+
get(taskId: string): Promise<Task | null>;
|
|
43
|
+
getByKey(key: string): Promise<Task | null>;
|
|
44
|
+
list(input?: ListInput): Promise<Task[]>;
|
|
45
|
+
cancel(taskId: string): Promise<Task | null>;
|
|
46
|
+
cancelByKey(key: string): Promise<Task | null>;
|
|
47
|
+
retry(taskId: string, opts?: {
|
|
48
|
+
resetAttempt?: boolean;
|
|
49
|
+
}): Promise<Task | null>;
|
|
50
|
+
retryByKey(key: string, opts?: {
|
|
51
|
+
resetAttempt?: boolean;
|
|
52
|
+
}): Promise<Task | null>;
|
|
53
|
+
claim(input: {
|
|
54
|
+
queues: string[];
|
|
55
|
+
workerId: string;
|
|
56
|
+
leaseMs?: number;
|
|
57
|
+
limit?: number;
|
|
58
|
+
}): Promise<Task[]>;
|
|
59
|
+
heartbeat(input: {
|
|
60
|
+
taskId: string;
|
|
61
|
+
workerId: string;
|
|
62
|
+
leaseMs?: number;
|
|
63
|
+
}): Promise<Task>;
|
|
64
|
+
progress(input: {
|
|
65
|
+
taskId: string;
|
|
66
|
+
workerId: string;
|
|
67
|
+
progress: number | null;
|
|
68
|
+
message: string | null;
|
|
69
|
+
}): Promise<Task>;
|
|
70
|
+
succeed(input: {
|
|
71
|
+
taskId: string;
|
|
72
|
+
workerId: string;
|
|
73
|
+
result: unknown;
|
|
74
|
+
}): Promise<Task>;
|
|
75
|
+
complete(input: {
|
|
76
|
+
taskId: string;
|
|
77
|
+
workerId: string;
|
|
78
|
+
result: unknown;
|
|
79
|
+
}): Promise<Task>;
|
|
80
|
+
fail(input: {
|
|
81
|
+
taskId: string;
|
|
82
|
+
workerId: string;
|
|
83
|
+
error: unknown;
|
|
84
|
+
retryable?: boolean;
|
|
85
|
+
delayMs?: number;
|
|
86
|
+
}): Promise<Task>;
|
|
87
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { newId } from "../ids.js";
|
|
2
|
+
import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch } from "../errors.js";
|
|
3
|
+
import { rowToTask } from "../models.js";
|
|
4
|
+
import { loadMigrations, loadStatements } from "../sql.js";
|
|
5
|
+
const SUPPORTED_PROTOCOL_MAJOR = 1;
|
|
6
|
+
const LEASE_EXPIRED_ERROR_JSON = JSON.stringify(errorEnvelope({
|
|
7
|
+
type: "LeaseExpired",
|
|
8
|
+
code: "lease_expired",
|
|
9
|
+
message: "task lease expired and max attempts reached",
|
|
10
|
+
retryable: false,
|
|
11
|
+
}));
|
|
12
|
+
// `pg` is an optional dependency: the SDK is SQLite-first, so it's loaded lazily
|
|
13
|
+
// the first time a PostgresStore connects. Absent -> a clear install hint.
|
|
14
|
+
let pgModule = null;
|
|
15
|
+
async function loadPg() {
|
|
16
|
+
if (pgModule)
|
|
17
|
+
return pgModule;
|
|
18
|
+
let mod;
|
|
19
|
+
try {
|
|
20
|
+
mod = (await import("pg"));
|
|
21
|
+
}
|
|
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);
|
|
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) => (v == null ? null : Number(v)));
|
|
31
|
+
pgModule = pg;
|
|
32
|
+
return pg;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Translate the protocol's named-parameter SQL (`:name`) into Postgres positional
|
|
36
|
+
* placeholders (`$1`), collapsing each DISTINCT name to ONE slot — statements reuse
|
|
37
|
+
* a name across CASE branches / IS NULL guards (e.g. list.sql). SQL comments are
|
|
38
|
+
* stripped first so a `:name` mentioned in a header comment (e.g. "now + :lease_ms")
|
|
39
|
+
* never leaks into the parameter list. Exported for unit testing.
|
|
40
|
+
*/
|
|
41
|
+
export function toPositional(sql, params) {
|
|
42
|
+
const body = sql.replace(/--[^\n]*/g, "");
|
|
43
|
+
const order = [];
|
|
44
|
+
const slot = new Map();
|
|
45
|
+
const text = body.replace(/(?<!:):(\w+)/g, (_m, name) => {
|
|
46
|
+
let i = slot.get(name);
|
|
47
|
+
if (i === undefined) {
|
|
48
|
+
order.push(name);
|
|
49
|
+
i = order.length; // 1-based $n
|
|
50
|
+
slot.set(name, i);
|
|
51
|
+
}
|
|
52
|
+
return `$${i}`;
|
|
53
|
+
});
|
|
54
|
+
return { text, values: order.map((n) => params[n]) };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* PostgresStore — asyncpg's TS counterpart: a `pg` Pool executing the shared
|
|
58
|
+
* cairnq-protocol SQL (postgres dialect). Multi-host capable — unlike SQLite this
|
|
59
|
+
* coordinates API and worker processes across machines through one database. Time
|
|
60
|
+
* comes from the DB clock (now()), claim uses FOR UPDATE SKIP LOCKED, JSON columns
|
|
61
|
+
* are jsonb (bound as JSON text, read back as objects by rowToTask's adaptive parse).
|
|
62
|
+
*/
|
|
63
|
+
export class PostgresStore {
|
|
64
|
+
dsn;
|
|
65
|
+
opts;
|
|
66
|
+
pool = null;
|
|
67
|
+
connecting = null;
|
|
68
|
+
statements;
|
|
69
|
+
constructor(dsn, opts = {}) {
|
|
70
|
+
this.dsn = dsn;
|
|
71
|
+
this.opts = opts;
|
|
72
|
+
this.statements = loadStatements("postgres");
|
|
73
|
+
}
|
|
74
|
+
async connect() {
|
|
75
|
+
await this.ensure();
|
|
76
|
+
}
|
|
77
|
+
async close() {
|
|
78
|
+
if (this.pool) {
|
|
79
|
+
const p = this.pool;
|
|
80
|
+
this.pool = null;
|
|
81
|
+
this.connecting = null;
|
|
82
|
+
await p.end();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async ensure() {
|
|
86
|
+
if (this.pool)
|
|
87
|
+
return;
|
|
88
|
+
// Cache the in-flight connect so concurrent calls share one pool. On failure,
|
|
89
|
+
// clear it so a later call retries instead of re-awaiting a rejected promise.
|
|
90
|
+
if (!this.connecting) {
|
|
91
|
+
this.connecting = this.doConnect().catch((e) => {
|
|
92
|
+
this.connecting = null;
|
|
93
|
+
throw e;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
await this.connecting;
|
|
97
|
+
}
|
|
98
|
+
async doConnect() {
|
|
99
|
+
const pg = await loadPg();
|
|
100
|
+
const pool = new pg.Pool({ connectionString: this.dsn, max: this.opts.max });
|
|
101
|
+
try {
|
|
102
|
+
const client = await pool.connect();
|
|
103
|
+
try {
|
|
104
|
+
await this.applyMigrations(client);
|
|
105
|
+
const version = await this.readProtocolVersion(client);
|
|
106
|
+
if (version !== SUPPORTED_PROTOCOL_MAJOR) {
|
|
107
|
+
throw new ProtocolVersionMismatch(`storage protocol_version=${version}, SDK supports ${SUPPORTED_PROTOCOL_MAJOR}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
client.release();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
await pool.end(); // never leak a pool when connect fails
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
this.pool = pool; // publish only a fully-migrated, version-checked pool
|
|
119
|
+
}
|
|
120
|
+
async applyMigrations(client) {
|
|
121
|
+
await client.query("create table if not exists cairnq_migrations " +
|
|
122
|
+
"(name text primary key, applied_at_ms bigint not null)");
|
|
123
|
+
const applied = new Set((await client.query("select name from cairnq_migrations")).rows.map((r) => r.name));
|
|
124
|
+
for (const { name, sql } of loadMigrations("postgres")) {
|
|
125
|
+
if (applied.has(name))
|
|
126
|
+
continue;
|
|
127
|
+
try {
|
|
128
|
+
await client.query("begin");
|
|
129
|
+
await client.query(sql); // multi-statement DDL (simple-query, no params)
|
|
130
|
+
await client.query("insert into cairnq_migrations (name, applied_at_ms) values " +
|
|
131
|
+
"($1, (extract(epoch from now()) * 1000)::bigint) on conflict (name) do nothing", [name]);
|
|
132
|
+
await client.query("commit");
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
await client.query("rollback");
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async readProtocolVersion(client) {
|
|
141
|
+
const res = await client.query("select value from cairnq_meta where key = 'protocol_version'");
|
|
142
|
+
return res.rows.length ? Number(res.rows[0].value) : 0;
|
|
143
|
+
}
|
|
144
|
+
async protocolVersion() {
|
|
145
|
+
await this.ensure();
|
|
146
|
+
const client = await this.pool.connect();
|
|
147
|
+
try {
|
|
148
|
+
return await this.readProtocolVersion(client);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
client.release();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// ------------------------------------------------------------------ helpers
|
|
155
|
+
async run(name, params) {
|
|
156
|
+
const { text, values } = toPositional(this.statements[name], params);
|
|
157
|
+
return (await this.pool.query(text, values)).rows;
|
|
158
|
+
}
|
|
159
|
+
async runOn(client, name, params) {
|
|
160
|
+
const { text, values } = toPositional(this.statements[name], params);
|
|
161
|
+
return (await client.query(text, values)).rows;
|
|
162
|
+
}
|
|
163
|
+
// BEGIN … COMMIT on a dedicated pool client, rolling back on any error.
|
|
164
|
+
async tx(fn) {
|
|
165
|
+
const client = await this.pool.connect();
|
|
166
|
+
try {
|
|
167
|
+
await client.query("begin");
|
|
168
|
+
const out = await fn(client);
|
|
169
|
+
await client.query("commit");
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
await client.query("rollback");
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
client.release();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// An ownership-checked worker write: each statement's WHERE pins worker_id + a
|
|
181
|
+
// live lease, so 0 rows back means the lease was lost.
|
|
182
|
+
async ownedWrite(name, taskId, params) {
|
|
183
|
+
const rows = await this.run(name, params);
|
|
184
|
+
if (!rows.length)
|
|
185
|
+
throw new LostLease(taskId);
|
|
186
|
+
return rowToTask(rows[0]);
|
|
187
|
+
}
|
|
188
|
+
// ------------------------------------------------------------- client side
|
|
189
|
+
async submit(input) {
|
|
190
|
+
await this.ensure();
|
|
191
|
+
const id = newId("task");
|
|
192
|
+
// No now_ms / run_at_ms: the DB clock supplies time, so submit passes a
|
|
193
|
+
// relative :delay_ms and the SQL computes run_at = now + delay.
|
|
194
|
+
const ins = {
|
|
195
|
+
id,
|
|
196
|
+
name: input.name,
|
|
197
|
+
queue: input.queue ?? "default",
|
|
198
|
+
payload: JSON.stringify(input.payload ?? {}),
|
|
199
|
+
metadata: JSON.stringify(input.metadata ?? {}),
|
|
200
|
+
max_attempts: input.maxAttempts ?? 3,
|
|
201
|
+
priority: input.priority ?? 0,
|
|
202
|
+
delay_ms: input.runAtDelayMs ?? 0,
|
|
203
|
+
parent_id: input.parentId ?? null,
|
|
204
|
+
root_id: input.rootId ?? id,
|
|
205
|
+
correlation_id: input.correlationId ?? null,
|
|
206
|
+
};
|
|
207
|
+
const key = input.key ?? null;
|
|
208
|
+
const conflict = input.conflict ?? "reuse";
|
|
209
|
+
if (key === null)
|
|
210
|
+
return rowToTask((await this.run("insert_task", ins))[0]);
|
|
211
|
+
return this.tx(async (client) => {
|
|
212
|
+
const existing = (await this.runOn(client, "get_key", { key }));
|
|
213
|
+
if (existing.length) {
|
|
214
|
+
const exId = existing[0].task_id;
|
|
215
|
+
if (conflict === "reuse")
|
|
216
|
+
return rowToTask((await this.runOn(client, "get", { id: exId }))[0]);
|
|
217
|
+
if (conflict === "reject")
|
|
218
|
+
throw new AlreadyExists(key);
|
|
219
|
+
if (conflict === "replace") {
|
|
220
|
+
await this.runOn(client, "cancel", { id: exId });
|
|
221
|
+
const row = (await this.runOn(client, "insert_task", ins))[0];
|
|
222
|
+
await this.runOn(client, "upsert_key", { key, task_id: id });
|
|
223
|
+
return rowToTask(row);
|
|
224
|
+
}
|
|
225
|
+
throw new Error(`unknown conflict strategy: ${conflict}`);
|
|
226
|
+
}
|
|
227
|
+
const row = (await this.runOn(client, "insert_task", ins))[0];
|
|
228
|
+
await this.runOn(client, "upsert_key", { key, task_id: id });
|
|
229
|
+
return rowToTask(row);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async get(taskId) {
|
|
233
|
+
await this.ensure();
|
|
234
|
+
const rows = await this.run("get", { id: taskId });
|
|
235
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
236
|
+
}
|
|
237
|
+
async getByKey(key) {
|
|
238
|
+
await this.ensure();
|
|
239
|
+
const rows = await this.run("get_by_key", { key });
|
|
240
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
241
|
+
}
|
|
242
|
+
async list(input = {}) {
|
|
243
|
+
await this.ensure();
|
|
244
|
+
const rows = await this.run("list", {
|
|
245
|
+
status: input.status ?? null,
|
|
246
|
+
queue: input.queue ?? null,
|
|
247
|
+
name: input.name ?? null,
|
|
248
|
+
root_id: input.rootId ?? null,
|
|
249
|
+
correlation_id: input.correlationId ?? null,
|
|
250
|
+
limit: input.limit ?? 100,
|
|
251
|
+
offset: input.offset ?? 0,
|
|
252
|
+
});
|
|
253
|
+
return rows.map(rowToTask);
|
|
254
|
+
}
|
|
255
|
+
async cancel(taskId) {
|
|
256
|
+
await this.ensure();
|
|
257
|
+
const rows = await this.run("cancel", { id: taskId });
|
|
258
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
259
|
+
}
|
|
260
|
+
async cancelByKey(key) {
|
|
261
|
+
await this.ensure();
|
|
262
|
+
return this.tx(async (client) => {
|
|
263
|
+
const existing = (await this.runOn(client, "get_key", { key }));
|
|
264
|
+
if (!existing.length)
|
|
265
|
+
return null;
|
|
266
|
+
const rows = await this.runOn(client, "cancel", { id: existing[0].task_id });
|
|
267
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async retry(taskId, opts = {}) {
|
|
271
|
+
await this.ensure();
|
|
272
|
+
const rows = await this.run("retry", { id: taskId, reset_attempt: opts.resetAttempt ?? false });
|
|
273
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
274
|
+
}
|
|
275
|
+
async retryByKey(key, opts = {}) {
|
|
276
|
+
await this.ensure();
|
|
277
|
+
return this.tx(async (client) => {
|
|
278
|
+
const existing = (await this.runOn(client, "get_key", { key }));
|
|
279
|
+
if (!existing.length)
|
|
280
|
+
return null;
|
|
281
|
+
const rows = await this.runOn(client, "retry", {
|
|
282
|
+
id: existing[0].task_id,
|
|
283
|
+
reset_attempt: opts.resetAttempt ?? false,
|
|
284
|
+
});
|
|
285
|
+
return rows.length ? rowToTask(rows[0]) : null;
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
// ------------------------------------------------------------- worker side
|
|
289
|
+
async claim(input) {
|
|
290
|
+
await this.ensure();
|
|
291
|
+
// No claimable_probe: PG readers don't block writers, so a plain transaction
|
|
292
|
+
// (recover then claim) is cheap even when idle. FOR UPDATE SKIP LOCKED in
|
|
293
|
+
// claim.sql gives true concurrent, non-contending dispatch.
|
|
294
|
+
return this.tx(async (client) => {
|
|
295
|
+
await this.runOn(client, "recover_leases", { lease_expired_error: LEASE_EXPIRED_ERROR_JSON });
|
|
296
|
+
const rows = await this.runOn(client, "claim", {
|
|
297
|
+
queues: input.queues,
|
|
298
|
+
worker_id: input.workerId,
|
|
299
|
+
lease_ms: input.leaseMs ?? 30_000,
|
|
300
|
+
limit: input.limit ?? 1,
|
|
301
|
+
});
|
|
302
|
+
return rows.map(rowToTask);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async heartbeat(input) {
|
|
306
|
+
await this.ensure();
|
|
307
|
+
return this.ownedWrite("heartbeat", input.taskId, {
|
|
308
|
+
id: input.taskId,
|
|
309
|
+
worker_id: input.workerId,
|
|
310
|
+
lease_ms: input.leaseMs ?? 30_000,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async progress(input) {
|
|
314
|
+
await this.ensure();
|
|
315
|
+
return this.ownedWrite("progress", input.taskId, {
|
|
316
|
+
id: input.taskId,
|
|
317
|
+
worker_id: input.workerId,
|
|
318
|
+
progress: input.progress,
|
|
319
|
+
message: input.message,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
async succeed(input) {
|
|
323
|
+
await this.ensure();
|
|
324
|
+
return this.ownedWrite("succeed", input.taskId, {
|
|
325
|
+
id: input.taskId,
|
|
326
|
+
worker_id: input.workerId,
|
|
327
|
+
result: input.result == null ? null : JSON.stringify(input.result),
|
|
328
|
+
message: null,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
async complete(input) {
|
|
332
|
+
await this.ensure();
|
|
333
|
+
return this.ownedWrite("complete", input.taskId, {
|
|
334
|
+
id: input.taskId,
|
|
335
|
+
worker_id: input.workerId,
|
|
336
|
+
result: input.result == null ? null : JSON.stringify(input.result),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
async fail(input) {
|
|
340
|
+
await this.ensure();
|
|
341
|
+
return this.ownedWrite("fail", input.taskId, {
|
|
342
|
+
id: input.taskId,
|
|
343
|
+
worker_id: input.workerId,
|
|
344
|
+
error: JSON.stringify(input.error ?? {}),
|
|
345
|
+
retryable: input.retryable !== false,
|
|
346
|
+
delay_ms: input.delayMs ?? 0,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type Task } from "../models.js";
|
|
2
|
+
import type { ListInput, SubmitInput, TaskStore } from "./base.js";
|
|
3
|
+
/**
|
|
4
|
+
* SQLiteStore — better-sqlite3 backend executing the shared cairnq-protocol SQL.
|
|
5
|
+
*
|
|
6
|
+
* The driver is synchronous, which suits SQLite's single writer: claim is one
|
|
7
|
+
* short transaction, the handler runs outside any transaction, and
|
|
8
|
+
* progress/heartbeat/succeed/fail are each their own short write. JS being
|
|
9
|
+
* single-threaded means sync DB calls never interleave. Cross-process contention
|
|
10
|
+
* (deployment mode B) is absorbed by busy_timeout.
|
|
11
|
+
*/
|
|
12
|
+
export declare class SQLiteStore implements TaskStore {
|
|
13
|
+
private readonly path;
|
|
14
|
+
private readonly opts;
|
|
15
|
+
private db;
|
|
16
|
+
private stmts;
|
|
17
|
+
private readonly statements;
|
|
18
|
+
constructor(path: string, opts?: {
|
|
19
|
+
busyTimeoutMs?: number;
|
|
20
|
+
});
|
|
21
|
+
connect(): Promise<void>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
private ensure;
|
|
24
|
+
private applyMigrations;
|
|
25
|
+
private checkVersion;
|
|
26
|
+
private readProtocolVersion;
|
|
27
|
+
protocolVersion(): Promise<number>;
|
|
28
|
+
private all;
|
|
29
|
+
private run;
|
|
30
|
+
private ownedWrite;
|
|
31
|
+
submit(input: SubmitInput): Promise<Task>;
|
|
32
|
+
get(taskId: string): Promise<Task | null>;
|
|
33
|
+
getByKey(key: string): Promise<Task | null>;
|
|
34
|
+
list(input?: ListInput): Promise<Task[]>;
|
|
35
|
+
cancel(taskId: string): Promise<Task | null>;
|
|
36
|
+
cancelByKey(key: string): Promise<Task | null>;
|
|
37
|
+
retry(taskId: string, opts?: {
|
|
38
|
+
resetAttempt?: boolean;
|
|
39
|
+
}): Promise<Task | null>;
|
|
40
|
+
retryByKey(key: string, opts?: {
|
|
41
|
+
resetAttempt?: boolean;
|
|
42
|
+
}): Promise<Task | null>;
|
|
43
|
+
claim(input: {
|
|
44
|
+
queues: string[];
|
|
45
|
+
workerId: string;
|
|
46
|
+
leaseMs?: number;
|
|
47
|
+
limit?: number;
|
|
48
|
+
}): Promise<Task[]>;
|
|
49
|
+
heartbeat(input: {
|
|
50
|
+
taskId: string;
|
|
51
|
+
workerId: string;
|
|
52
|
+
leaseMs?: number;
|
|
53
|
+
}): Promise<Task>;
|
|
54
|
+
progress(input: {
|
|
55
|
+
taskId: string;
|
|
56
|
+
workerId: string;
|
|
57
|
+
progress: number | null;
|
|
58
|
+
message: string | null;
|
|
59
|
+
}): Promise<Task>;
|
|
60
|
+
succeed(input: {
|
|
61
|
+
taskId: string;
|
|
62
|
+
workerId: string;
|
|
63
|
+
result: unknown;
|
|
64
|
+
}): Promise<Task>;
|
|
65
|
+
complete(input: {
|
|
66
|
+
taskId: string;
|
|
67
|
+
workerId: string;
|
|
68
|
+
result: unknown;
|
|
69
|
+
}): Promise<Task>;
|
|
70
|
+
fail(input: {
|
|
71
|
+
taskId: string;
|
|
72
|
+
workerId: string;
|
|
73
|
+
error: unknown;
|
|
74
|
+
retryable?: boolean;
|
|
75
|
+
delayMs?: number;
|
|
76
|
+
}): Promise<Task>;
|
|
77
|
+
}
|