cairnq 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/_protocol/migrations/postgres/0002_purge_index.sql +6 -0
- package/dist/_protocol/migrations/postgres/0003_notify.sql +38 -0
- package/dist/_protocol/migrations/sqlite/0002_purge_index.sql +6 -0
- package/dist/_protocol/sql/postgres/claim.sql +18 -5
- package/dist/_protocol/sql/postgres/fail.sql +28 -8
- package/dist/_protocol/sql/postgres/insert_task.sql +6 -3
- package/dist/_protocol/sql/postgres/list.sql +3 -1
- package/dist/_protocol/sql/postgres/lock_key.sql +9 -0
- package/dist/_protocol/sql/postgres/progress.sql +4 -3
- package/dist/_protocol/sql/postgres/protocol_version.sql +4 -0
- package/dist/_protocol/sql/postgres/purge.sql +25 -0
- package/dist/_protocol/sql/postgres/recover_leases.sql +38 -14
- package/dist/_protocol/sql/postgres/retry.sql +3 -0
- package/dist/_protocol/sql/postgres/stats.sql +8 -0
- package/dist/_protocol/sql/sqlite/claim.sql +13 -2
- package/dist/_protocol/sql/sqlite/claimable_probe.sql +6 -2
- package/dist/_protocol/sql/sqlite/fail.sql +30 -8
- package/dist/_protocol/sql/sqlite/list.sql +3 -1
- package/dist/_protocol/sql/sqlite/lock_key.sql +5 -0
- package/dist/_protocol/sql/sqlite/progress.sql +6 -2
- package/dist/_protocol/sql/sqlite/protocol_version.sql +4 -0
- package/dist/_protocol/sql/sqlite/purge.sql +18 -0
- package/dist/_protocol/sql/sqlite/recover_leases.sql +25 -7
- package/dist/_protocol/sql/sqlite/retry.sql +3 -0
- package/dist/_protocol/sql/sqlite/stats.sql +8 -0
- package/dist/client.d.ts +10 -2
- package/dist/client.js +12 -0
- package/dist/context.d.ts +17 -1
- package/dist/context.js +60 -6
- package/dist/errors.d.ts +18 -2
- package/dist/errors.js +49 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/sql.js +16 -9
- package/dist/store/base.d.ts +107 -9
- package/dist/store/base.js +370 -1
- package/dist/store/postgres.d.ts +62 -63
- package/dist/store/postgres.js +245 -222
- package/dist/store/sqlite.d.ts +34 -59
- package/dist/store/sqlite.js +200 -232
- package/dist/wait.d.ts +15 -2
- package/dist/wait.js +23 -5
- package/dist/worker.d.ts +53 -1
- package/dist/worker.js +202 -42
- package/package.json +9 -2
- package/src/client.ts +16 -2
- package/src/context.ts +70 -13
- package/src/errors.ts +59 -4
- package/src/index.ts +3 -1
- package/src/sql.ts +15 -8
- package/src/store/base.ts +430 -27
- package/src/store/postgres.ts +243 -267
- package/src/store/sqlite.ts +211 -265
- package/src/wait.ts +28 -5
- package/src/worker.ts +242 -42
package/src/store/sqlite.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
19
|
+
const WAL_RETRY_DELAY_MS = 50;
|
|
20
|
+
const WAL_RETRY_BUDGET_MS = 5_000;
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
*
|
|
33
|
+
* Serializes every SQLiteStore on one database file, process-wide.
|
|
28
34
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
const memory = isMemory(this.path);
|
|
136
|
+
if (!memory) mkdirSync(dirname(this.path), { recursive: true });
|
|
62
137
|
const db = new Database(this.path);
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
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
|
|
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
|
|
161
|
+
"insert into cairnq_migrations (name, applied_at_ms) values (?, ?)",
|
|
89
162
|
);
|
|
90
163
|
for (const { name, sql } of loadMigrations("sqlite")) {
|
|
91
|
-
|
|
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
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
264
|
+
protected async fetch(name: string, params: Params): Promise<any[]> {
|
|
314
265
|
this.ensure();
|
|
315
|
-
return this.
|
|
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
|
|
325
|
-
this.ensure();
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
}
|