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.
Files changed (56) hide show
  1. package/README.md +28 -0
  2. package/dist/_protocol/migrations/postgres/0002_purge_index.sql +6 -0
  3. package/dist/_protocol/migrations/postgres/0003_notify.sql +38 -0
  4. package/dist/_protocol/migrations/sqlite/0002_purge_index.sql +6 -0
  5. package/dist/_protocol/sql/postgres/claim.sql +18 -5
  6. package/dist/_protocol/sql/postgres/fail.sql +28 -8
  7. package/dist/_protocol/sql/postgres/insert_task.sql +6 -3
  8. package/dist/_protocol/sql/postgres/list.sql +3 -1
  9. package/dist/_protocol/sql/postgres/lock_key.sql +9 -0
  10. package/dist/_protocol/sql/postgres/progress.sql +4 -3
  11. package/dist/_protocol/sql/postgres/protocol_version.sql +4 -0
  12. package/dist/_protocol/sql/postgres/purge.sql +25 -0
  13. package/dist/_protocol/sql/postgres/recover_leases.sql +38 -14
  14. package/dist/_protocol/sql/postgres/retry.sql +3 -0
  15. package/dist/_protocol/sql/postgres/stats.sql +8 -0
  16. package/dist/_protocol/sql/sqlite/claim.sql +13 -2
  17. package/dist/_protocol/sql/sqlite/claimable_probe.sql +6 -2
  18. package/dist/_protocol/sql/sqlite/fail.sql +30 -8
  19. package/dist/_protocol/sql/sqlite/list.sql +3 -1
  20. package/dist/_protocol/sql/sqlite/lock_key.sql +5 -0
  21. package/dist/_protocol/sql/sqlite/progress.sql +6 -2
  22. package/dist/_protocol/sql/sqlite/protocol_version.sql +4 -0
  23. package/dist/_protocol/sql/sqlite/purge.sql +18 -0
  24. package/dist/_protocol/sql/sqlite/recover_leases.sql +25 -7
  25. package/dist/_protocol/sql/sqlite/retry.sql +3 -0
  26. package/dist/_protocol/sql/sqlite/stats.sql +8 -0
  27. package/dist/client.d.ts +10 -2
  28. package/dist/client.js +12 -0
  29. package/dist/context.d.ts +17 -1
  30. package/dist/context.js +60 -6
  31. package/dist/errors.d.ts +18 -2
  32. package/dist/errors.js +49 -3
  33. package/dist/index.d.ts +3 -2
  34. package/dist/index.js +2 -1
  35. package/dist/sql.js +16 -9
  36. package/dist/store/base.d.ts +107 -9
  37. package/dist/store/base.js +370 -1
  38. package/dist/store/postgres.d.ts +62 -63
  39. package/dist/store/postgres.js +245 -222
  40. package/dist/store/sqlite.d.ts +34 -59
  41. package/dist/store/sqlite.js +200 -232
  42. package/dist/wait.d.ts +15 -2
  43. package/dist/wait.js +23 -5
  44. package/dist/worker.d.ts +53 -1
  45. package/dist/worker.js +202 -42
  46. package/package.json +9 -2
  47. package/src/client.ts +16 -2
  48. package/src/context.ts +70 -13
  49. package/src/errors.ts +59 -4
  50. package/src/index.ts +3 -1
  51. package/src/sql.ts +15 -8
  52. package/src/store/base.ts +430 -27
  53. package/src/store/postgres.ts +243 -267
  54. package/src/store/sqlite.ts +211 -265
  55. package/src/wait.ts +28 -5
  56. package/src/worker.ts +242 -42
@@ -1,47 +1,121 @@
1
1
  import { mkdirSync } from "node:fs";
2
- import { dirname } from "node:path";
2
+ import { dirname, resolve } from "node:path";
3
3
 
4
4
  import Database from "better-sqlite3";
5
5
 
6
- import { newId, nowMs } from "../ids.js";
7
- import { AlreadyExists, errorEnvelope, LostLease, ProtocolVersionMismatch } from "../errors.js";
8
- import { rowToTask, type Task } from "../models.js";
6
+ import { nowMs } from "../ids.js";
9
7
  import { loadMigrations, loadStatements } from "../sql.js";
10
- import type { ListInput, SubmitInput, TaskStore } from "./base.js";
8
+ import {
9
+ checkProtocolVersion,
10
+ type Fetch,
11
+ type Params,
12
+ statementParams,
13
+ TaskStore,
14
+ } from "./base.js";
11
15
 
12
16
  type DB = Database.Database;
13
17
  type Stmt = Database.Statement;
14
18
 
15
- const SUPPORTED_PROTOCOL_MAJOR = 1;
19
+ const WAL_RETRY_DELAY_MS = 50;
20
+ const WAL_RETRY_BUDGET_MS = 5_000;
16
21
 
17
- const LEASE_EXPIRED_ERROR = errorEnvelope({
18
- type: "LeaseExpired",
19
- code: "lease_expired",
20
- message: "task lease expired and max attempts reached",
21
- retryable: false,
22
- });
23
- // Serialized once: it's an immutable constant bound on every claim that finds work.
24
- const LEASE_EXPIRED_ERROR_JSON = JSON.stringify(LEASE_EXPIRED_ERROR);
22
+ /** Sleep without yielding — the whole open path is synchronous already. */
23
+ function sleepSync(ms: number): void {
24
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
25
+ }
26
+
27
+ /** Whether this path names an in-memory database rather than a file. */
28
+ function isMemory(path: string): boolean {
29
+ return path === ":memory:" || path.includes("mode=memory");
30
+ }
25
31
 
26
32
  /**
27
- * SQLiteStore — better-sqlite3 backend executing the shared cairnq-protocol SQL.
33
+ * Serializes every SQLiteStore on one database file, process-wide.
28
34
  *
29
- * The driver is synchronous, which suits SQLite's single writer: claim is one
30
- * short transaction, the handler runs outside any transaction, and
31
- * progress/heartbeat/succeed/fail are each their own short write. JS being
32
- * single-threaded means sync DB calls never interleave. Cross-process contention
33
- * (deployment mode B) is absorbed by busy_timeout.
35
+ * better-sqlite3 is synchronous, and a transaction holds SQLite's write lock
36
+ * across `await`s (the callback seam is shared with Postgres, so it is async). A
37
+ * second connection in this process then blocks the only thread waiting for that
38
+ * lock, and the holder can never reach COMMIT — reaching it needs the thread the
39
+ * waiter is sitting on. busy_timeout cannot break that inversion, being one
40
+ * thread; the wait just burns the timeout and throws "database is locked". So the
41
+ * two must not overlap at all.
42
+ *
43
+ * Keyed by database, not by store: what the lock protects is the file. Across
44
+ * processes there is no inversion (the holder keeps its own thread) and
45
+ * busy_timeout still applies. An in-memory database is private to one connection
46
+ * and gets a key of its own.
34
47
  */
35
- export class SQLiteStore implements TaskStore {
48
+ const fileLocks = new Map<string, Promise<unknown>>();
49
+ let memoryDbSeq = 0;
50
+
51
+ /**
52
+ * Put the database in WAL mode, waiting out a concurrent cold start.
53
+ *
54
+ * journal_mode is a persistent property of the file, so only the first connection
55
+ * to a new database actually switches it — and that switch needs an exclusive
56
+ * lock. `busy_timeout` does not cover it: SQLite returns SQLITE_BUSY for a
57
+ * journal_mode change rather than invoking the busy handler, so several processes
58
+ * opening the same new database at once would otherwise get an instant "database
59
+ * is locked". Retry briefly instead; the window is only as long as one other
60
+ * opener's switch.
61
+ *
62
+ * Callers must skip in-memory databases: those report journal_mode = "memory" and
63
+ * can never be WAL, so waiting for one is waiting for something that will not
64
+ * happen.
65
+ */
66
+ function enableWal(db: DB): void {
67
+ const deadline = Date.now() + WAL_RETRY_BUDGET_MS;
68
+ for (;;) {
69
+ try {
70
+ const rows = db.pragma("journal_mode = WAL") as { journal_mode?: string }[];
71
+ if (rows[0]?.journal_mode?.toLowerCase() === "wal") return;
72
+ } catch (err) {
73
+ const message = String((err as Error).message ?? err);
74
+ if (!/locked|busy/i.test(message)) throw err;
75
+ }
76
+ if (Date.now() >= deadline) {
77
+ throw new Error(
78
+ "could not switch the database to WAL mode: it stayed locked by another connection",
79
+ );
80
+ }
81
+ sleepSync(WAL_RETRY_DELAY_MS);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * SQLiteStore — the SQLite dialect of the shared cairnq-protocol SQL.
87
+ *
88
+ * Everything protocol-shaped lives in TaskStore; this file is only what SQLite
89
+ * does differently: better-sqlite3's synchronous driver, BEGIN IMMEDIATE
90
+ * transactions, a read-only probe in front of the write lock, and time supplied
91
+ * by the SDK (`:now_ms`) rather than by the database.
92
+ *
93
+ * The driver being synchronous suits SQLite's single writer: claim is one short
94
+ * transaction, the handler runs outside any transaction, and
95
+ * progress/heartbeat/succeed/fail are each their own short write. Cross-process
96
+ * contention is absorbed by busy_timeout.
97
+ */
98
+ export class SQLiteStore extends TaskStore {
36
99
  private db: DB | null = null;
37
100
  private stmts: Record<string, Stmt> = {};
38
101
  private readonly statements: Record<string, string>;
102
+ /** This store's entry in `fileLocks` — see there for why it is per-database. */
103
+ private readonly lockKey: string;
39
104
 
40
105
  constructor(
41
106
  private readonly path: string,
42
107
  private readonly opts: { busyTimeoutMs?: number } = {},
43
108
  ) {
109
+ super();
44
110
  this.statements = loadStatements("sqlite");
111
+ // Only a bare ":memory:" is guaranteed private to its connection, so only
112
+ // it gets a lock of its own. A "mode=memory" URI stays path-keyed: with
113
+ // cache=shared it names ONE shared database, and on a build without URI
114
+ // filenames it is a literal file — in both cases two stores on that string
115
+ // must share a lock. Over-serializing a private URI-memory database is
116
+ // harmless; skipping the lock on a shared one is the deadlock this map
117
+ // exists to prevent.
118
+ this.lockKey = path === ":memory:" ? `memory#${memoryDbSeq++}` : resolve(path);
45
119
  }
46
120
 
47
121
  async connect(): Promise<void> {
@@ -58,17 +132,22 @@ export class SQLiteStore implements TaskStore {
58
132
 
59
133
  private ensure(): DB {
60
134
  if (this.db) return this.db;
61
- if (this.path !== ":memory:") mkdirSync(dirname(this.path), { recursive: true });
135
+ const memory = isMemory(this.path);
136
+ if (!memory) mkdirSync(dirname(this.path), { recursive: true });
62
137
  const db = new Database(this.path);
63
- db.pragma("journal_mode = WAL");
64
- db.pragma("foreign_keys = ON");
138
+ // busy_timeout first, so every later statement waits out contention instead
139
+ // of failing instantly.
65
140
  db.pragma(`busy_timeout = ${this.opts.busyTimeoutMs ?? 5000}`);
141
+ // WAL exists so several processes can share one file. An in-memory database
142
+ // is private to this connection, so there is nothing to share or wait for.
143
+ if (!memory) enableWal(db);
144
+ db.pragma("foreign_keys = ON");
66
145
  this.applyMigrations(db);
67
146
  for (const [name, sql] of Object.entries(this.statements)) {
68
147
  this.stmts[name] = db.prepare(sql);
69
148
  }
70
149
  this.db = db;
71
- this.checkVersion();
150
+ checkProtocolVersion(this.readProtocolVersion());
72
151
  return db;
73
152
  }
74
153
 
@@ -77,275 +156,142 @@ export class SQLiteStore implements TaskStore {
77
156
  "create table if not exists cairnq_migrations " +
78
157
  "(name text primary key, applied_at_ms integer not null)",
79
158
  );
80
- const applied = new Set(
81
- (db.prepare("select name from cairnq_migrations").all() as { name: string }[]).map(
82
- (r) => r.name,
83
- ),
84
- );
85
- // `or ignore`: another process may apply the same migration concurrently on a
86
- // fresh shared db (mode B cold start). Migrations are idempotent.
159
+ const isApplied = db.prepare("select 1 from cairnq_migrations where name = ?");
87
160
  const insert = db.prepare(
88
- "insert or ignore into cairnq_migrations (name, applied_at_ms) values (?, ?)",
161
+ "insert into cairnq_migrations (name, applied_at_ms) values (?, ?)",
89
162
  );
90
163
  for (const { name, sql } of loadMigrations("sqlite")) {
91
- if (applied.has(name)) continue;
164
+ // Check and apply under one write lock. Two processes cold-starting on a
165
+ // shared database would otherwise both see a migration as unapplied and
166
+ // both run it — harmless for the idempotent ones, not for a future ALTER.
167
+ // `immediate` takes the write lock up front; the loser sees it applied.
92
168
  db.transaction(() => {
169
+ if (isApplied.get(name)) return;
93
170
  db.exec(sql);
94
171
  insert.run(name, nowMs());
95
- })();
96
- }
97
- }
98
-
99
- private checkVersion(): void {
100
- const version = this.readProtocolVersion();
101
- if (version !== SUPPORTED_PROTOCOL_MAJOR) {
102
- throw new ProtocolVersionMismatch(
103
- `storage protocol_version=${version}, SDK supports ${SUPPORTED_PROTOCOL_MAJOR}`,
104
- );
172
+ }).immediate();
105
173
  }
106
174
  }
107
175
 
108
176
  private readProtocolVersion(): number {
109
- const row = this.db!
110
- .prepare("select value from cairnq_meta where key = 'protocol_version'")
111
- .get() as { value: string } | undefined;
112
- return row ? Number(row.value) : 0;
177
+ const rows = this.runNow("protocol_version", {});
178
+ return rows.length ? Number(rows[0].value) : 0;
113
179
  }
114
180
 
115
181
  async protocolVersion(): Promise<number> {
116
182
  this.ensure();
117
- return this.readProtocolVersion();
118
- }
119
-
120
- private all(name: string, params: Record<string, unknown>): any[] {
121
- return this.stmts[name].all(params) as any[];
122
- }
123
-
124
- private run(name: string, params: Record<string, unknown>): void {
125
- this.stmts[name].run(params);
183
+ // Under the store lock: this public read must not slip a statement into
184
+ // another operation's open transaction on the shared connection.
185
+ return this.withLock(() => this.readProtocolVersion());
126
186
  }
127
187
 
128
- // An ownership-checked worker write (heartbeat/progress/succeed/complete/fail).
129
- // Each statement's WHERE pins worker_id + a live lease, so 0 rows back means the
130
- // lease was lost — every such write reports it the same way.
131
- private ownedWrite(name: string, taskId: string, params: Record<string, unknown>): Task {
132
- const rows = this.all(name, params);
133
- if (!rows.length) throw new LostLease(taskId);
134
- return rowToTask(rows[0]);
135
- }
136
-
137
- // ------------------------------------------------------------- client side
138
- async submit(input: SubmitInput): Promise<Task> {
139
- this.ensure();
188
+ // ------------------------------------------------------------ dialect seam
189
+ /**
190
+ * Adapt the dialect-neutral parameters to what this statement binds.
191
+ *
192
+ * SQLite statements carry no DB clock, so every absolute `*_ms` is derived here
193
+ * from one `now`, and booleans cross as 0/1. The result is narrowed to the
194
+ * names the SQL actually uses, which is what makes it safe for a caller to pass
195
+ * one superset of parameters for both dialects.
196
+ *
197
+ * Each derivation writes a name Postgres does not use (`lease_until_ms` from
198
+ * `lease_ms`, and so on), so a statement binds one or the other, never both —
199
+ * which is why the derived values can be computed unconditionally and left for
200
+ * the narrowing step to discard.
201
+ */
202
+ private bind(sql: string, params: Params): Params {
140
203
  const now = nowMs();
141
- const id = newId("task");
142
- const ins = {
143
- id,
144
- name: input.name,
145
- queue: input.queue ?? "default",
146
- payload: JSON.stringify(input.payload ?? {}),
147
- metadata: JSON.stringify(input.metadata ?? {}),
148
- max_attempts: input.maxAttempts ?? 3,
149
- priority: input.priority ?? 0,
150
- run_at_ms: now + (input.runAtDelayMs ?? 0),
151
- parent_id: input.parentId ?? null,
152
- root_id: input.rootId ?? id,
153
- correlation_id: input.correlationId ?? null,
154
- now_ms: now,
155
- };
156
- const key = input.key ?? null;
157
- const conflict = input.conflict ?? "reuse";
158
-
159
- const txn = this.db!.transaction(() => {
160
- if (key === null) return this.all("insert_task", ins)[0];
161
- const existing = this.all("get_key", { key }) as { task_id: string }[];
162
- if (existing.length) {
163
- const exId = existing[0].task_id;
164
- if (conflict === "reuse") return this.all("get", { id: exId })[0];
165
- if (conflict === "reject") throw new AlreadyExists(key);
166
- if (conflict === "replace") {
167
- this.all("cancel", { id: exId, now_ms: now });
168
- const row = this.all("insert_task", ins)[0];
169
- this.run("upsert_key", { key, task_id: id, now_ms: now });
170
- return row;
171
- }
172
- throw new Error(`unknown conflict strategy: ${conflict}`);
204
+ const bound: Params = {};
205
+ for (const name of statementParams(sql)) {
206
+ switch (name) {
207
+ case "now_ms":
208
+ bound[name] = now;
209
+ break;
210
+ case "lease_until_ms":
211
+ bound[name] = now + (params.lease_ms as number);
212
+ break;
213
+ case "run_at_ms":
214
+ bound[name] = now + (params.delay_ms as number);
215
+ break;
216
+ case "before_ms":
217
+ bound[name] = now - (params.older_than_ms as number);
218
+ break;
219
+ case "queues":
220
+ bound[name] = JSON.stringify(params.queues);
221
+ break;
222
+ case "names":
223
+ // json_each needs a JSON array; null stays null so the SQL's
224
+ // `:names is null` arm means "no filter".
225
+ bound[name] = params.names == null ? null : JSON.stringify(params.names);
226
+ break;
227
+ case "retryable":
228
+ case "reset_attempt":
229
+ bound[name] = params[name] ? 1 : 0;
230
+ break;
231
+ default:
232
+ bound[name] = params[name];
173
233
  }
174
- const row = this.all("insert_task", ins)[0];
175
- this.run("upsert_key", { key, task_id: id, now_ms: now });
176
- return row;
177
- });
178
- return rowToTask(txn.immediate());
179
- }
180
-
181
- async get(taskId: string): Promise<Task | null> {
182
- this.ensure();
183
- const rows = this.all("get", { id: taskId });
184
- return rows.length ? rowToTask(rows[0]) : null;
185
- }
186
-
187
- async getByKey(key: string): Promise<Task | null> {
188
- this.ensure();
189
- const rows = this.all("get_by_key", { key });
190
- return rows.length ? rowToTask(rows[0]) : null;
191
- }
192
-
193
- async list(input: ListInput = {}): Promise<Task[]> {
194
- this.ensure();
195
- const rows = this.all("list", {
196
- status: input.status ?? null,
197
- queue: input.queue ?? null,
198
- name: input.name ?? null,
199
- root_id: input.rootId ?? null,
200
- correlation_id: input.correlationId ?? null,
201
- limit: input.limit ?? 100,
202
- offset: input.offset ?? 0,
203
- });
204
- return rows.map(rowToTask);
205
- }
206
-
207
- async cancel(taskId: string): Promise<Task | null> {
208
- this.ensure();
209
- const rows = this.all("cancel", { id: taskId, now_ms: nowMs() });
210
- return rows.length ? rowToTask(rows[0]) : null;
211
- }
212
-
213
- async cancelByKey(key: string): Promise<Task | null> {
214
- this.ensure();
215
- const txn = this.db!.transaction(() => {
216
- const existing = this.all("get_key", { key }) as { task_id: string }[];
217
- if (!existing.length) return null;
218
- const rows = this.all("cancel", { id: existing[0].task_id, now_ms: nowMs() });
219
- return rows.length ? rows[0] : null;
220
- });
221
- const row = txn.immediate();
222
- return row ? rowToTask(row) : null;
223
- }
224
-
225
- async retry(taskId: string, opts: { resetAttempt?: boolean } = {}): Promise<Task | null> {
226
- this.ensure();
227
- const rows = this.all("retry", {
228
- id: taskId,
229
- now_ms: nowMs(),
230
- reset_attempt: opts.resetAttempt ? 1 : 0,
231
- });
232
- return rows.length ? rowToTask(rows[0]) : null;
233
- }
234
-
235
- async retryByKey(key: string, opts: { resetAttempt?: boolean } = {}): Promise<Task | null> {
236
- this.ensure();
237
- const txn = this.db!.transaction(() => {
238
- const existing = this.all("get_key", { key }) as { task_id: string }[];
239
- if (!existing.length) return null;
240
- const rows = this.all("retry", {
241
- id: existing[0].task_id,
242
- now_ms: nowMs(),
243
- reset_attempt: opts.resetAttempt ? 1 : 0,
244
- });
245
- return rows.length ? rows[0] : null;
246
- });
247
- const row = txn.immediate();
248
- return row ? rowToTask(row) : null;
249
- }
250
-
251
- // ------------------------------------------------------------- worker side
252
- async claim(input: {
253
- queues: string[];
254
- workerId: string;
255
- leaseMs?: number;
256
- limit?: number;
257
- }): Promise<Task[]> {
258
- this.ensure();
259
- const now = nowMs();
260
- const queues = JSON.stringify(input.queues);
261
- const leaseMs = input.leaseMs ?? 30_000;
262
- const limit = input.limit ?? 1;
263
- // Read-only probe first: skip the write lock entirely when idle.
264
- const probe = this.all("claimable_probe", { queues, now_ms: now })[0] as { has_work: number };
265
- if (!probe || !probe.has_work) return [];
266
- const txn = this.db!.transaction(() => {
267
- this.all("recover_leases", {
268
- now_ms: now,
269
- lease_expired_error: LEASE_EXPIRED_ERROR_JSON,
270
- });
271
- return this.all("claim", {
272
- queues,
273
- now_ms: now,
274
- worker_id: input.workerId,
275
- lease_until_ms: now + leaseMs,
276
- limit,
277
- });
278
- });
279
- return (txn.immediate() as any[]).map(rowToTask);
234
+ }
235
+ return bound;
280
236
  }
281
237
 
282
- async heartbeat(input: {
283
- taskId: string;
284
- workerId: string;
285
- leaseMs?: number;
286
- }): Promise<Task> {
287
- this.ensure();
288
- const now = nowMs();
289
- return this.ownedWrite("heartbeat", input.taskId, {
290
- id: input.taskId,
291
- worker_id: input.workerId,
292
- now_ms: now,
293
- lease_until_ms: now + (input.leaseMs ?? 30_000),
294
- });
238
+ private runNow(name: string, params: Params): any[] {
239
+ const stmt = this.stmts[name];
240
+ const bound = this.bind(this.statements[name], params);
241
+ // Nearly every protocol statement ends in RETURNING; upsert_key does not, and
242
+ // better-sqlite3 refuses .all() on a statement that yields no rows.
243
+ if (!stmt.reader) {
244
+ stmt.run(bound);
245
+ return [];
246
+ }
247
+ return stmt.all(bound) as any[];
295
248
  }
296
249
 
297
- async progress(input: {
298
- taskId: string;
299
- workerId: string;
300
- progress: number | null;
301
- message: string | null;
302
- }): Promise<Task> {
303
- this.ensure();
304
- return this.ownedWrite("progress", input.taskId, {
305
- id: input.taskId,
306
- worker_id: input.workerId,
307
- now_ms: nowMs(),
308
- progress: input.progress,
309
- message: input.message,
310
- });
250
+ /** Serialize an operation against every other operation on this database. */
251
+ private withLock<T>(fn: () => T | Promise<T>): Promise<T> {
252
+ const previous = fileLocks.get(this.lockKey) ?? Promise.resolve();
253
+ const run = previous.then(fn, fn) as Promise<T>;
254
+ fileLocks.set(
255
+ this.lockKey,
256
+ run.then(
257
+ () => undefined,
258
+ () => undefined,
259
+ ),
260
+ );
261
+ return run;
311
262
  }
312
263
 
313
- async succeed(input: { taskId: string; workerId: string; result: unknown }): Promise<Task> {
264
+ protected async fetch(name: string, params: Params): Promise<any[]> {
314
265
  this.ensure();
315
- return this.ownedWrite("succeed", input.taskId, {
316
- id: input.taskId,
317
- worker_id: input.workerId,
318
- now_ms: nowMs(),
319
- result: input.result == null ? null : JSON.stringify(input.result),
320
- message: null,
321
- });
266
+ return this.withLock(() => this.runNow(name, params));
322
267
  }
323
268
 
324
- async complete(input: { taskId: string; workerId: string; result: unknown }): Promise<Task> {
325
- this.ensure();
326
- return this.ownedWrite("complete", input.taskId, {
327
- id: input.taskId,
328
- worker_id: input.workerId,
329
- now_ms: nowMs(),
330
- result: input.result == null ? null : JSON.stringify(input.result),
269
+ protected async tx<T>(fn: (fetch: Fetch) => Promise<T>): Promise<T> {
270
+ const db = this.ensure();
271
+ // BEGIN IMMEDIATE by hand rather than db.transaction(): the callback is async
272
+ // (the seam is shared with Postgres), and better-sqlite3's wrapper only takes
273
+ // a synchronous one. The lock above makes the manual version safe.
274
+ return this.withLock(async () => {
275
+ db.exec("BEGIN IMMEDIATE");
276
+ try {
277
+ const out = await fn(async (name, params) => this.runNow(name, params));
278
+ db.exec("COMMIT");
279
+ return out;
280
+ } catch (err) {
281
+ try {
282
+ db.exec("ROLLBACK");
283
+ } catch {
284
+ // Already rolled back by SQLite (e.g. a constraint abort).
285
+ }
286
+ throw err;
287
+ }
331
288
  });
332
289
  }
333
290
 
334
- async fail(input: {
335
- taskId: string;
336
- workerId: string;
337
- error: unknown;
338
- retryable?: boolean;
339
- delayMs?: number;
340
- }): Promise<Task> {
341
- this.ensure();
342
- return this.ownedWrite("fail", input.taskId, {
343
- id: input.taskId,
344
- worker_id: input.workerId,
345
- now_ms: nowMs(),
346
- error: JSON.stringify(input.error ?? {}),
347
- retryable: input.retryable === false ? 0 : 1,
348
- delay_ms: input.delayMs ?? 0,
349
- });
291
+ protected async hasClaimableWork(params: Params): Promise<boolean> {
292
+ // Read-only probe first: an idle worker never takes SQLite's single write
293
+ // lock, so idle workers don't serialize against each other.
294
+ const rows = await this.fetch("claimable_probe", params);
295
+ return Boolean(rows[0]?.has_work);
350
296
  }
351
297
  }
package/src/wait.ts CHANGED
@@ -3,21 +3,44 @@ import { nowMs } from "./ids.js";
3
3
  import { isTerminal, type Task } from "./models.js";
4
4
  import type { TaskStore } from "./store/base.js";
5
5
 
6
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
6
+ export const DEFAULT_POLL_MS = 100;
7
+ export const MAX_POLL_MS = 500;
8
+ const GROWTH = 1.5;
9
+
10
+ /**
11
+ * Grow the polling interval towards the ceiling.
12
+ *
13
+ * wait() has no idea whether the task takes 50ms or an hour. Starting tight keeps
14
+ * short tasks snappy; growing keeps a long wait from costing a read every 100ms
15
+ * for its whole duration. The +1 keeps truncation from pinning tiny intervals:
16
+ * Math.floor(1 * 1.5) === 1 would otherwise never grow past 1.
17
+ */
18
+ export function nextPollMs(current: number, maxMs: number): number {
19
+ return Math.min(maxMs, Math.max(current + 1, Math.floor(current * GROWTH)));
20
+ }
7
21
 
8
22
  /** Poll get() until terminal or timeout. Returns the terminal Task (any status).
9
- * Throws TaskTimeout, leaving the task running. */
23
+ * Throws TaskTimeout, leaving the task running. `pollMs` is the *first* interval;
24
+ * it backs off towards `maxPollMs`. */
10
25
  export async function pollWait(
11
26
  store: TaskStore,
12
27
  taskId: string,
13
- { timeoutMs, pollMs = 150 }: { timeoutMs: number; pollMs?: number },
28
+ {
29
+ timeoutMs,
30
+ pollMs = DEFAULT_POLL_MS,
31
+ maxPollMs = MAX_POLL_MS,
32
+ }: { timeoutMs: number; pollMs?: number; maxPollMs?: number },
14
33
  ): Promise<Task> {
15
34
  const deadline = nowMs() + timeoutMs;
35
+ let interval = pollMs;
16
36
  for (;;) {
17
37
  const task = await store.get(taskId);
18
38
  if (task && isTerminal(task)) return task;
19
39
  const remaining = deadline - nowMs();
20
- if (remaining <= 0) throw new TaskTimeout(taskId);
21
- await sleep(Math.min(pollMs, remaining));
40
+ if (remaining <= 0) throw new TaskTimeout(taskId, { timeoutMs, task });
41
+ // A store with a push channel (Postgres) cuts the sleep short when the task
42
+ // goes terminal; the re-get above stays the source of truth either way.
43
+ await store.taskDoneWake(taskId, Math.min(interval, remaining));
44
+ interval = nextPollMs(interval, maxPollMs);
22
45
  }
23
46
  }