@hunterzhu/pulse-runtime 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/dist/context/builder.d.ts +68 -0
- package/dist/context/builder.js +127 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/merger.d.ts +25 -0
- package/dist/context/merger.js +125 -0
- package/dist/core/actions.d.ts +1 -0
- package/dist/core/actions.js +1 -0
- package/dist/core/errors.d.ts +8 -0
- package/dist/core/errors.js +36 -0
- package/dist/core/events.d.ts +10 -0
- package/dist/core/events.js +24 -0
- package/dist/core/factory.d.ts +35 -0
- package/dist/core/factory.js +27 -0
- package/dist/core/inbox.d.ts +119 -0
- package/dist/core/inbox.js +217 -0
- package/dist/core/mutations.d.ts +80 -0
- package/dist/core/mutations.js +127 -0
- package/dist/core/records.d.ts +1 -0
- package/dist/core/records.js +1 -0
- package/dist/core/types.d.ts +615 -0
- package/dist/core/types.js +109 -0
- package/dist/dependencies/graph.d.ts +25 -0
- package/dist/dependencies/graph.js +92 -0
- package/dist/dependencies/index.d.ts +1 -0
- package/dist/dependencies/index.js +1 -0
- package/dist/dsl/context-proxy.d.ts +20 -0
- package/dist/dsl/context-proxy.js +64 -0
- package/dist/dsl/index.d.ts +4 -0
- package/dist/dsl/index.js +4 -0
- package/dist/dsl/program.d.ts +314 -0
- package/dist/dsl/program.js +756 -0
- package/dist/dsl/session.d.ts +45 -0
- package/dist/dsl/session.js +93 -0
- package/dist/dsl/templates-index.d.ts +1 -0
- package/dist/dsl/templates-index.js +1 -0
- package/dist/dsl/templates.d.ts +85 -0
- package/dist/dsl/templates.js +110 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/lifecycle/scopes.d.ts +38 -0
- package/dist/lifecycle/scopes.js +50 -0
- package/dist/lifecycle/watchdog.d.ts +16 -0
- package/dist/lifecycle/watchdog.js +66 -0
- package/dist/models/actions.d.ts +10 -0
- package/dist/models/actions.js +68 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/models/index.js +2 -0
- package/dist/models/router.d.ts +187 -0
- package/dist/models/router.js +353 -0
- package/dist/scheduler/clock.d.ts +45 -0
- package/dist/scheduler/clock.js +92 -0
- package/dist/scheduler/decision.d.ts +72 -0
- package/dist/scheduler/decision.js +63 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.js +6 -0
- package/dist/scheduler/locks.d.ts +18 -0
- package/dist/scheduler/locks.js +106 -0
- package/dist/scheduler/ready-queue.d.ts +32 -0
- package/dist/scheduler/ready-queue.js +40 -0
- package/dist/scheduler/runtime.d.ts +486 -0
- package/dist/scheduler/runtime.js +3445 -0
- package/dist/scheduler/telemetry.d.ts +111 -0
- package/dist/scheduler/telemetry.js +177 -0
- package/dist/scheduler/worker.d.ts +158 -0
- package/dist/scheduler/worker.js +744 -0
- package/dist/storage/artifacts.d.ts +17 -0
- package/dist/storage/artifacts.js +90 -0
- package/dist/storage/findings.d.ts +12 -0
- package/dist/storage/findings.js +70 -0
- package/dist/storage/index.d.ts +8 -0
- package/dist/storage/index.js +8 -0
- package/dist/storage/memory.d.ts +11 -0
- package/dist/storage/memory.js +21 -0
- package/dist/storage/mutation-log.d.ts +41 -0
- package/dist/storage/mutation-log.js +140 -0
- package/dist/storage/outbox.d.ts +30 -0
- package/dist/storage/outbox.js +59 -0
- package/dist/storage/persistence.d.ts +183 -0
- package/dist/storage/persistence.js +999 -0
- package/dist/storage/policy.d.ts +80 -0
- package/dist/storage/policy.js +268 -0
- package/dist/storage/session.d.ts +140 -0
- package/dist/storage/session.js +447 -0
- package/dist/tools/registry.d.ts +125 -0
- package/dist/tools/registry.js +308 -0
- package/dist/transitions/index.d.ts +2 -0
- package/dist/transitions/index.js +1 -0
- package/dist/transitions/validate.d.ts +4 -0
- package/dist/transitions/validate.js +1118 -0
- package/package.json +21 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
export class FileWorkerPersistenceBackend {
|
|
6
|
+
filePath;
|
|
7
|
+
pending = Promise.resolve();
|
|
8
|
+
constructor(filePath) {
|
|
9
|
+
this.filePath = filePath;
|
|
10
|
+
}
|
|
11
|
+
async load() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
catch (cause) {
|
|
16
|
+
if (cause.code === 'ENOENT')
|
|
17
|
+
return undefined;
|
|
18
|
+
throw cause;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async save(snapshot, expectedDigest) {
|
|
22
|
+
const operation = this.pending.then(async () => {
|
|
23
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
24
|
+
const lockPath = `${this.filePath}.lock`;
|
|
25
|
+
let lock;
|
|
26
|
+
const lockDeadline = Date.now() + 30_000;
|
|
27
|
+
while (lock === undefined) {
|
|
28
|
+
try {
|
|
29
|
+
lock = await open(lockPath, 'wx', 0o600);
|
|
30
|
+
}
|
|
31
|
+
catch (cause) {
|
|
32
|
+
if (cause.code !== 'EEXIST')
|
|
33
|
+
throw cause;
|
|
34
|
+
const lockStat = await stat(lockPath).catch(() => undefined);
|
|
35
|
+
if (lockStat && Date.now() - lockStat.mtimeMs > 30_000) {
|
|
36
|
+
await rm(lockPath, { force: true });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (Date.now() >= lockDeadline)
|
|
40
|
+
throw new Error('WORKER_PERSISTENCE_LOCK_TIMEOUT');
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const current = await this.load();
|
|
46
|
+
if (expectedDigest !== undefined && (current === undefined || current.integrity?.digest !== expectedDigest))
|
|
47
|
+
throw new Error('WORKER_PERSISTENCE_CONFLICT');
|
|
48
|
+
const temporaryPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${process.hrtime.bigint().toString()}`;
|
|
49
|
+
let handle;
|
|
50
|
+
try {
|
|
51
|
+
handle = await open(temporaryPath, 'wx', 0o600);
|
|
52
|
+
await handle.writeFile(JSON.stringify(snapshot), 'utf8');
|
|
53
|
+
await handle.sync();
|
|
54
|
+
await handle.close();
|
|
55
|
+
handle = undefined;
|
|
56
|
+
await rename(temporaryPath, this.filePath);
|
|
57
|
+
try {
|
|
58
|
+
const directory = await open(dirname(this.filePath), 'r');
|
|
59
|
+
try {
|
|
60
|
+
await directory.sync();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await directory.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Some filesystems do not expose directory fsync; the rename remains atomic.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
if (handle)
|
|
72
|
+
await handle.close().catch(() => undefined);
|
|
73
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await lock.close().catch(() => undefined);
|
|
78
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
this.pending = operation.catch(() => undefined);
|
|
82
|
+
await operation;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Durable SQLite snapshot backend for sharing WorkerCoordinator state between processes. */
|
|
86
|
+
export class SqliteWorkerPersistenceBackend {
|
|
87
|
+
filePath;
|
|
88
|
+
database;
|
|
89
|
+
tail = Promise.resolve();
|
|
90
|
+
constructor(filePath) {
|
|
91
|
+
this.filePath = filePath;
|
|
92
|
+
}
|
|
93
|
+
async load() {
|
|
94
|
+
return this.enqueue(async () => {
|
|
95
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
96
|
+
const row = this.open().prepare('SELECT payload FROM worker_snapshot WHERE id = 1').get();
|
|
97
|
+
if (!row)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (typeof row.payload !== 'string')
|
|
100
|
+
throw new Error('INVALID_WORKER_SNAPSHOT');
|
|
101
|
+
return JSON.parse(row.payload);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async save(snapshot, expectedDigest) {
|
|
105
|
+
await this.enqueue(async () => {
|
|
106
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
107
|
+
const database = this.open();
|
|
108
|
+
database.exec('BEGIN IMMEDIATE');
|
|
109
|
+
try {
|
|
110
|
+
const current = database.prepare('SELECT digest FROM worker_snapshot WHERE id = 1').get();
|
|
111
|
+
const currentDigest = current && typeof current.digest === 'string' ? current.digest : undefined;
|
|
112
|
+
if (expectedDigest !== undefined && currentDigest !== expectedDigest)
|
|
113
|
+
throw new Error('WORKER_PERSISTENCE_CONFLICT');
|
|
114
|
+
database.prepare('INSERT INTO worker_snapshot (id, payload, digest) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, digest = excluded.digest').run(JSON.stringify(snapshot), snapshot.integrity?.digest ?? null);
|
|
115
|
+
database.exec('COMMIT');
|
|
116
|
+
}
|
|
117
|
+
catch (cause) {
|
|
118
|
+
try {
|
|
119
|
+
database.exec('ROLLBACK');
|
|
120
|
+
}
|
|
121
|
+
catch { /* transaction already closed */ }
|
|
122
|
+
throw cause;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async close() {
|
|
127
|
+
await this.enqueue(async () => {
|
|
128
|
+
this.database?.close();
|
|
129
|
+
this.database = undefined;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
open() {
|
|
133
|
+
if (this.database)
|
|
134
|
+
return this.database;
|
|
135
|
+
const require = createRequire(import.meta.url);
|
|
136
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
137
|
+
this.database = new DatabaseSync(this.filePath);
|
|
138
|
+
this.database.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 30000; CREATE TABLE IF NOT EXISTS worker_snapshot (id INTEGER PRIMARY KEY CHECK (id = 1), payload TEXT NOT NULL, digest TEXT)');
|
|
139
|
+
return this.database;
|
|
140
|
+
}
|
|
141
|
+
enqueue(work) {
|
|
142
|
+
const operation = this.tail.then(work, work);
|
|
143
|
+
this.tail = operation.then(() => undefined, () => undefined);
|
|
144
|
+
return operation;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function deferred() {
|
|
148
|
+
let resolve;
|
|
149
|
+
let reject;
|
|
150
|
+
const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; });
|
|
151
|
+
return { promise, resolve, reject };
|
|
152
|
+
}
|
|
153
|
+
function runtimeError(cause) {
|
|
154
|
+
if (typeof cause === 'object' && cause !== null) {
|
|
155
|
+
const candidate = cause;
|
|
156
|
+
return { code: typeof candidate.code === 'string' ? candidate.code : 'WORKER_FAILED', message: typeof candidate.message === 'string' ? candidate.message : String(cause), ...(typeof candidate.retryable === 'boolean' ? { retryable: candidate.retryable } : {}), ...(candidate.details === undefined ? {} : { details: candidate.details }) };
|
|
157
|
+
}
|
|
158
|
+
return { code: 'WORKER_FAILED', message: cause instanceof Error ? cause.message : String(cause) };
|
|
159
|
+
}
|
|
160
|
+
function workerSnapshotDigest(snapshot) {
|
|
161
|
+
const copy = structuredClone(snapshot);
|
|
162
|
+
delete copy.integrity;
|
|
163
|
+
return createHash('sha256').update(JSON.stringify(copy)).digest('hex');
|
|
164
|
+
}
|
|
165
|
+
/** A lease-based worker coordinator. A network transport can implement the same claim/complete contract. */
|
|
166
|
+
export class WorkerCoordinator {
|
|
167
|
+
tasks = new Map();
|
|
168
|
+
deferreds = new Map();
|
|
169
|
+
idempotency = new Map();
|
|
170
|
+
handlers = new Map();
|
|
171
|
+
remoteWorkers = new Set();
|
|
172
|
+
activeLeases = new Map();
|
|
173
|
+
persistenceBackend;
|
|
174
|
+
persistenceDigest;
|
|
175
|
+
persistencePending = Promise.resolve();
|
|
176
|
+
sequence = 1;
|
|
177
|
+
constructor(options = {}) { this.persistenceBackend = options.persistenceBackend; }
|
|
178
|
+
static restore(snapshot, options = {}) {
|
|
179
|
+
if (snapshot.schemaVersion !== 1 || !Number.isInteger(snapshot.sequence) || snapshot.sequence < 1 || !Array.isArray(snapshot.tasks))
|
|
180
|
+
throw new Error('INVALID_WORKER_SNAPSHOT');
|
|
181
|
+
const coordinator = new WorkerCoordinator(options);
|
|
182
|
+
coordinator.persistenceDigest = snapshot.integrity?.digest ?? workerSnapshotDigest(snapshot);
|
|
183
|
+
coordinator.sequence = snapshot.sequence;
|
|
184
|
+
for (const input of snapshot.tasks) {
|
|
185
|
+
if (!input || typeof input.id !== 'string' || typeof input.payload !== 'object' && input.payload !== null && typeof input.payload !== 'string' && typeof input.payload !== 'number' && typeof input.payload !== 'boolean' || !['queued', 'leased', 'succeeded', 'failed', 'cancelled'].includes(input.state))
|
|
186
|
+
throw new Error('INVALID_WORKER_SNAPSHOT');
|
|
187
|
+
const task = structuredClone(input);
|
|
188
|
+
if (coordinator.tasks.has(task.id))
|
|
189
|
+
throw new Error('DUPLICATE_WORKER_TASK');
|
|
190
|
+
if (task.state === 'leased') {
|
|
191
|
+
task.state = 'queued';
|
|
192
|
+
delete task.leaseId;
|
|
193
|
+
delete task.workerId;
|
|
194
|
+
delete task.leaseExpiresAt;
|
|
195
|
+
}
|
|
196
|
+
coordinator.tasks.set(task.id, task);
|
|
197
|
+
}
|
|
198
|
+
if (snapshot.integrity !== undefined && (snapshot.integrity.algorithm !== 'sha256' || !/^[a-f0-9]{64}$/.test(snapshot.integrity.digest) || snapshot.integrity.digest !== workerSnapshotDigest(snapshot)))
|
|
199
|
+
throw new Error('INVALID_WORKER_INTEGRITY');
|
|
200
|
+
for (const [key, taskId] of Object.entries(snapshot.idempotency ?? {}))
|
|
201
|
+
if (coordinator.tasks.has(taskId))
|
|
202
|
+
coordinator.idempotency.set(key, taskId);
|
|
203
|
+
return coordinator;
|
|
204
|
+
}
|
|
205
|
+
static async fromPersistence(backend) {
|
|
206
|
+
const snapshot = await backend.load();
|
|
207
|
+
return snapshot === undefined ? new WorkerCoordinator({ persistenceBackend: backend }) : WorkerCoordinator.restore(snapshot, { persistenceBackend: backend });
|
|
208
|
+
}
|
|
209
|
+
async flushPersistence() { await this.persistencePending; }
|
|
210
|
+
register(workerId, handler) {
|
|
211
|
+
if (!workerId || this.handlers.has(workerId) || this.remoteWorkers.has(workerId))
|
|
212
|
+
throw new Error(`WORKER_ALREADY_REGISTERED:${workerId}`);
|
|
213
|
+
this.handlers.set(workerId, handler);
|
|
214
|
+
this.pump();
|
|
215
|
+
this.schedulePersistence();
|
|
216
|
+
return () => this.unregister(workerId);
|
|
217
|
+
}
|
|
218
|
+
registerRemote(workerId) {
|
|
219
|
+
if (!workerId || this.handlers.has(workerId) || this.remoteWorkers.has(workerId))
|
|
220
|
+
throw new Error(`WORKER_ALREADY_REGISTERED:${workerId}`);
|
|
221
|
+
this.remoteWorkers.add(workerId);
|
|
222
|
+
this.pump();
|
|
223
|
+
this.schedulePersistence();
|
|
224
|
+
return () => this.unregister(workerId);
|
|
225
|
+
}
|
|
226
|
+
unregister(workerId) {
|
|
227
|
+
this.handlers.delete(workerId);
|
|
228
|
+
this.remoteWorkers.delete(workerId);
|
|
229
|
+
const active = this.activeLeases.get(workerId);
|
|
230
|
+
if (active) {
|
|
231
|
+
active.controller.abort();
|
|
232
|
+
this.activeLeases.delete(workerId);
|
|
233
|
+
}
|
|
234
|
+
for (const task of this.tasks.values())
|
|
235
|
+
if (task.state === 'leased' && task.workerId === workerId)
|
|
236
|
+
this.requeue(task);
|
|
237
|
+
this.pump();
|
|
238
|
+
this.schedulePersistence();
|
|
239
|
+
}
|
|
240
|
+
submit(payload, options = {}) {
|
|
241
|
+
const existingId = options.idempotencyKey === undefined ? undefined : this.idempotency.get(options.idempotencyKey);
|
|
242
|
+
if (existingId !== undefined)
|
|
243
|
+
return this.promiseFor(this.tasks.get(existingId)).promise;
|
|
244
|
+
const taskId = options.taskId ?? `worker-task-${this.sequence++}`;
|
|
245
|
+
if (this.tasks.has(taskId))
|
|
246
|
+
throw new Error(`WORKER_TASK_ALREADY_EXISTS:${taskId}`);
|
|
247
|
+
const leaseMs = options.leaseMs ?? 30_000;
|
|
248
|
+
if (!Number.isFinite(leaseMs) || leaseMs <= 0)
|
|
249
|
+
throw new Error('INVALID_WORKER_LEASE');
|
|
250
|
+
const task = { id: taskId, payload: structuredClone(payload), state: 'queued', attempt: 0, ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }) };
|
|
251
|
+
const result = this.ensureDeferred(taskId);
|
|
252
|
+
this.tasks.set(taskId, task);
|
|
253
|
+
this.deferreds.set(taskId, result);
|
|
254
|
+
if (options.idempotencyKey !== undefined)
|
|
255
|
+
this.idempotency.set(options.idempotencyKey, taskId);
|
|
256
|
+
if (options.signal) {
|
|
257
|
+
if (options.signal.aborted)
|
|
258
|
+
this.cancel(taskId, 'WORKER_CANCELLED');
|
|
259
|
+
else
|
|
260
|
+
options.signal.addEventListener('abort', () => this.cancel(taskId, 'WORKER_CANCELLED'), { once: true });
|
|
261
|
+
}
|
|
262
|
+
task.leaseMs = leaseMs;
|
|
263
|
+
this.pump();
|
|
264
|
+
this.schedulePersistence();
|
|
265
|
+
return result.promise;
|
|
266
|
+
}
|
|
267
|
+
recoverExpired(now = Date.now()) {
|
|
268
|
+
const recovered = [];
|
|
269
|
+
for (const task of this.tasks.values())
|
|
270
|
+
if (task.state === 'leased' && task.leaseExpiresAt !== undefined && task.leaseExpiresAt <= now) {
|
|
271
|
+
const active = task.workerId === undefined ? undefined : this.activeLeases.get(task.workerId);
|
|
272
|
+
if (active !== undefined && active.leaseId === task.leaseId) {
|
|
273
|
+
active.controller.abort();
|
|
274
|
+
this.activeLeases.delete(task.workerId);
|
|
275
|
+
}
|
|
276
|
+
this.requeue(task);
|
|
277
|
+
recovered.push(task.id);
|
|
278
|
+
}
|
|
279
|
+
this.pump();
|
|
280
|
+
this.schedulePersistence();
|
|
281
|
+
return recovered;
|
|
282
|
+
}
|
|
283
|
+
claim(workerId, now = Date.now()) {
|
|
284
|
+
if (!this.handlers.has(workerId) && !this.remoteWorkers.has(workerId))
|
|
285
|
+
throw new Error(`UNKNOWN_WORKER:${workerId}`);
|
|
286
|
+
const active = this.activeLeases.get(workerId);
|
|
287
|
+
if (active !== undefined) {
|
|
288
|
+
const activeTask = [...this.tasks.values()].find((task) => task.state === 'leased' && task.workerId === workerId && task.leaseId === active.leaseId);
|
|
289
|
+
if (activeTask?.leaseExpiresAt !== undefined && activeTask.leaseExpiresAt <= now) {
|
|
290
|
+
active.controller.abort();
|
|
291
|
+
this.activeLeases.delete(workerId);
|
|
292
|
+
this.requeue(activeTask);
|
|
293
|
+
}
|
|
294
|
+
else
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
const task = [...this.tasks.values()].find((candidate) => candidate.state === 'queued');
|
|
298
|
+
if (!task)
|
|
299
|
+
return undefined;
|
|
300
|
+
const lease = this.assign(workerId, task, now);
|
|
301
|
+
this.schedulePersistence();
|
|
302
|
+
return { workerId, leaseId: lease.leaseId, task: structuredClone(task) };
|
|
303
|
+
}
|
|
304
|
+
renewLease(workerId, leaseId, now = Date.now(), leaseMs) {
|
|
305
|
+
const active = this.activeLeases.get(workerId);
|
|
306
|
+
const task = [...this.tasks.values()].find((candidate) => candidate.state === 'leased' && candidate.workerId === workerId && candidate.leaseId === leaseId);
|
|
307
|
+
if (active?.leaseId !== leaseId || !task)
|
|
308
|
+
return undefined;
|
|
309
|
+
const duration = leaseMs ?? task.leaseMs ?? 30_000;
|
|
310
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
311
|
+
throw new Error('INVALID_WORKER_LEASE');
|
|
312
|
+
task.leaseExpiresAt = now + duration;
|
|
313
|
+
this.schedulePersistence();
|
|
314
|
+
return task.leaseExpiresAt;
|
|
315
|
+
}
|
|
316
|
+
completeRemote(workerId, leaseId, value, _now) {
|
|
317
|
+
return this.settleRemote(workerId, leaseId, () => this.completeTask(this.taskForLease(workerId, leaseId).id, leaseId, value));
|
|
318
|
+
}
|
|
319
|
+
failRemote(workerId, leaseId, error, _now) {
|
|
320
|
+
return this.settleRemote(workerId, leaseId, () => this.failTask(this.taskForLease(workerId, leaseId).id, leaseId, error));
|
|
321
|
+
}
|
|
322
|
+
get(taskId) {
|
|
323
|
+
const task = this.tasks.get(taskId);
|
|
324
|
+
return task === undefined ? undefined : structuredClone(task);
|
|
325
|
+
}
|
|
326
|
+
snapshot() {
|
|
327
|
+
const snapshot = { schemaVersion: 1, sequence: this.sequence, tasks: this.inspect(), idempotency: Object.fromEntries(this.idempotency) };
|
|
328
|
+
return { ...snapshot, integrity: { algorithm: 'sha256', digest: workerSnapshotDigest(snapshot) } };
|
|
329
|
+
}
|
|
330
|
+
cancel(taskId, reason = 'WORKER_CANCELLED') {
|
|
331
|
+
const task = this.tasks.get(taskId);
|
|
332
|
+
if (!task || ['succeeded', 'failed', 'cancelled'].includes(task.state))
|
|
333
|
+
return false;
|
|
334
|
+
const active = task.workerId === undefined ? undefined : this.activeLeases.get(task.workerId);
|
|
335
|
+
if (active !== undefined && active.leaseId === task.leaseId) {
|
|
336
|
+
active.controller.abort();
|
|
337
|
+
this.activeLeases.delete(task.workerId);
|
|
338
|
+
}
|
|
339
|
+
task.state = 'cancelled';
|
|
340
|
+
delete task.leaseId;
|
|
341
|
+
delete task.workerId;
|
|
342
|
+
delete task.leaseExpiresAt;
|
|
343
|
+
this.ensureDeferred(task.id).reject(new Error(reason));
|
|
344
|
+
this.pump();
|
|
345
|
+
this.schedulePersistence();
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
inspect() { return [...this.tasks.values()].map((task) => structuredClone(task)); }
|
|
349
|
+
schedulePersistence() {
|
|
350
|
+
if (!this.persistenceBackend)
|
|
351
|
+
return;
|
|
352
|
+
const operation = this.persistencePending.catch(() => undefined).then(async () => {
|
|
353
|
+
const snapshot = this.snapshot();
|
|
354
|
+
await this.persistenceBackend.save(snapshot, this.persistenceDigest);
|
|
355
|
+
this.persistenceDigest = snapshot.integrity?.digest;
|
|
356
|
+
});
|
|
357
|
+
this.persistencePending = operation;
|
|
358
|
+
}
|
|
359
|
+
requeue(task) {
|
|
360
|
+
task.state = 'queued';
|
|
361
|
+
delete task.leaseId;
|
|
362
|
+
delete task.workerId;
|
|
363
|
+
delete task.leaseExpiresAt;
|
|
364
|
+
}
|
|
365
|
+
ensureDeferred(taskId) {
|
|
366
|
+
const existing = this.deferreds.get(taskId);
|
|
367
|
+
if (existing)
|
|
368
|
+
return existing;
|
|
369
|
+
const result = deferred();
|
|
370
|
+
this.deferreds.set(taskId, result);
|
|
371
|
+
const task = this.tasks.get(taskId);
|
|
372
|
+
if (task?.state === 'succeeded')
|
|
373
|
+
result.resolve(task.result ?? null);
|
|
374
|
+
else if (task?.state === 'failed')
|
|
375
|
+
result.reject(task.error ?? { code: 'WORKER_FAILED', message: 'WORKER_FAILED' });
|
|
376
|
+
else if (task?.state === 'cancelled')
|
|
377
|
+
result.reject(new Error('WORKER_CANCELLED'));
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
promiseFor(task) {
|
|
381
|
+
if (!task)
|
|
382
|
+
throw new Error('WORKER_IDEMPOTENCY_TARGET_MISSING');
|
|
383
|
+
return this.ensureDeferred(task.id);
|
|
384
|
+
}
|
|
385
|
+
pump() {
|
|
386
|
+
for (const [workerId, handler] of this.handlers) {
|
|
387
|
+
const lease = this.claim(workerId);
|
|
388
|
+
if (!lease)
|
|
389
|
+
continue;
|
|
390
|
+
const controller = this.activeLeases.get(workerId).controller;
|
|
391
|
+
void handler(lease.task.payload, controller.signal).then((value) => this.completeTask(lease.task.id, lease.leaseId, value)).catch((cause) => this.failTask(lease.task.id, lease.leaseId, runtimeError(cause))).finally(() => {
|
|
392
|
+
const active = this.activeLeases.get(workerId);
|
|
393
|
+
if (active?.leaseId === lease.leaseId)
|
|
394
|
+
this.activeLeases.delete(workerId);
|
|
395
|
+
this.pump();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
assign(workerId, task, now) {
|
|
400
|
+
const leaseId = `worker-lease-${this.sequence++}`;
|
|
401
|
+
const controller = new AbortController();
|
|
402
|
+
const leaseMs = task.leaseMs ?? 30_000;
|
|
403
|
+
task.state = 'leased';
|
|
404
|
+
task.attempt++;
|
|
405
|
+
task.leaseId = leaseId;
|
|
406
|
+
task.workerId = workerId;
|
|
407
|
+
task.leaseExpiresAt = now + leaseMs;
|
|
408
|
+
this.activeLeases.set(workerId, { leaseId, controller });
|
|
409
|
+
return { leaseId, controller };
|
|
410
|
+
}
|
|
411
|
+
taskForLease(workerId, leaseId) {
|
|
412
|
+
const active = this.activeLeases.get(workerId);
|
|
413
|
+
if (active?.leaseId !== leaseId)
|
|
414
|
+
return undefined;
|
|
415
|
+
return [...this.tasks.values()].find((task) => task.state === 'leased' && task.workerId === workerId && task.leaseId === leaseId);
|
|
416
|
+
}
|
|
417
|
+
settleRemote(workerId, leaseId, settle) {
|
|
418
|
+
if (this.taskForLease(workerId, leaseId) === undefined)
|
|
419
|
+
return false;
|
|
420
|
+
const settled = settle();
|
|
421
|
+
if (settled)
|
|
422
|
+
this.activeLeases.delete(workerId);
|
|
423
|
+
this.pump();
|
|
424
|
+
return settled;
|
|
425
|
+
}
|
|
426
|
+
completeTask(taskId, leaseId, value) {
|
|
427
|
+
const task = this.tasks.get(taskId);
|
|
428
|
+
if (!task || task.state !== 'leased' || task.leaseId !== leaseId)
|
|
429
|
+
return false;
|
|
430
|
+
task.state = 'succeeded';
|
|
431
|
+
task.result = structuredClone(value);
|
|
432
|
+
delete task.leaseId;
|
|
433
|
+
delete task.workerId;
|
|
434
|
+
delete task.leaseExpiresAt;
|
|
435
|
+
this.ensureDeferred(taskId).resolve(value);
|
|
436
|
+
this.schedulePersistence();
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
failTask(taskId, leaseId, error) {
|
|
440
|
+
const task = this.tasks.get(taskId);
|
|
441
|
+
if (!task || task.state !== 'leased' || task.leaseId !== leaseId)
|
|
442
|
+
return false;
|
|
443
|
+
task.state = 'failed';
|
|
444
|
+
task.error = error;
|
|
445
|
+
delete task.leaseId;
|
|
446
|
+
delete task.workerId;
|
|
447
|
+
delete task.leaseExpiresAt;
|
|
448
|
+
this.ensureDeferred(taskId).reject(error);
|
|
449
|
+
this.schedulePersistence();
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* A WorkerCoordinator whose lease transitions are owned by SQLite transactions.
|
|
455
|
+
* Separate processes may open the same database and race safely on the same queue.
|
|
456
|
+
*/
|
|
457
|
+
export class SqliteDistributedWorkerCoordinator {
|
|
458
|
+
filePath;
|
|
459
|
+
database;
|
|
460
|
+
deferreds = new Map();
|
|
461
|
+
watchers = new Map();
|
|
462
|
+
handlers = new Map();
|
|
463
|
+
remoteWorkers = new Set();
|
|
464
|
+
activeLeases = new Map();
|
|
465
|
+
constructor(filePath) {
|
|
466
|
+
this.filePath = filePath;
|
|
467
|
+
}
|
|
468
|
+
register(workerId, handler) {
|
|
469
|
+
if (!workerId || this.handlers.has(workerId) || this.remoteWorkers.has(workerId))
|
|
470
|
+
throw new Error(`WORKER_ALREADY_REGISTERED:${workerId}`);
|
|
471
|
+
this.handlers.set(workerId, handler);
|
|
472
|
+
this.pump();
|
|
473
|
+
return () => this.unregister(workerId);
|
|
474
|
+
}
|
|
475
|
+
registerRemote(workerId) {
|
|
476
|
+
if (!workerId || this.handlers.has(workerId) || this.remoteWorkers.has(workerId))
|
|
477
|
+
throw new Error(`WORKER_ALREADY_REGISTERED:${workerId}`);
|
|
478
|
+
this.remoteWorkers.add(workerId);
|
|
479
|
+
return () => this.unregister(workerId);
|
|
480
|
+
}
|
|
481
|
+
unregister(workerId) {
|
|
482
|
+
this.handlers.delete(workerId);
|
|
483
|
+
this.remoteWorkers.delete(workerId);
|
|
484
|
+
const active = this.activeLeases.get(workerId);
|
|
485
|
+
if (active) {
|
|
486
|
+
active.controller.abort();
|
|
487
|
+
this.activeLeases.delete(workerId);
|
|
488
|
+
}
|
|
489
|
+
this.requeueWorker(workerId);
|
|
490
|
+
this.pump();
|
|
491
|
+
}
|
|
492
|
+
submit(payload, options = {}) {
|
|
493
|
+
const leaseMs = options.leaseMs ?? 30_000;
|
|
494
|
+
if (!Number.isFinite(leaseMs) || leaseMs <= 0)
|
|
495
|
+
throw new Error('INVALID_WORKER_LEASE');
|
|
496
|
+
const taskId = this.withTransaction((database) => {
|
|
497
|
+
if (options.idempotencyKey !== undefined) {
|
|
498
|
+
const existing = database.prepare('SELECT id FROM worker_tasks WHERE idempotency_key = ?').get(options.idempotencyKey);
|
|
499
|
+
if (existing && typeof existing.id === 'string')
|
|
500
|
+
return existing.id;
|
|
501
|
+
}
|
|
502
|
+
const id = options.taskId ?? this.nextId(database, 'worker-task');
|
|
503
|
+
if (database.prepare('SELECT id FROM worker_tasks WHERE id = ?').get(id))
|
|
504
|
+
throw new Error(`WORKER_TASK_ALREADY_EXISTS:${id}`);
|
|
505
|
+
database.prepare('INSERT INTO worker_tasks (id, payload, state, attempt, lease_ms, idempotency_key) VALUES (?, ?, \'queued\', 0, ?, ?)').run(id, JSON.stringify(payload), leaseMs, options.idempotencyKey ?? null);
|
|
506
|
+
return id;
|
|
507
|
+
});
|
|
508
|
+
const result = this.ensureDeferred(taskId);
|
|
509
|
+
if (options.signal) {
|
|
510
|
+
if (options.signal.aborted)
|
|
511
|
+
this.cancel(taskId);
|
|
512
|
+
else
|
|
513
|
+
options.signal.addEventListener('abort', () => this.cancel(taskId), { once: true });
|
|
514
|
+
}
|
|
515
|
+
this.pump();
|
|
516
|
+
return result.promise;
|
|
517
|
+
}
|
|
518
|
+
claim(workerId, now = Date.now()) {
|
|
519
|
+
if (!this.handlers.has(workerId) && !this.remoteWorkers.has(workerId))
|
|
520
|
+
throw new Error(`UNKNOWN_WORKER:${workerId}`);
|
|
521
|
+
const active = this.activeLeases.get(workerId);
|
|
522
|
+
if (active) {
|
|
523
|
+
const activeTask = this.getByLease(workerId, active.leaseId);
|
|
524
|
+
if (activeTask?.leaseExpiresAt !== undefined && activeTask.leaseExpiresAt > now)
|
|
525
|
+
return undefined;
|
|
526
|
+
active.controller.abort();
|
|
527
|
+
this.activeLeases.delete(workerId);
|
|
528
|
+
}
|
|
529
|
+
const lease = this.withTransaction((database) => {
|
|
530
|
+
const row = database.prepare('SELECT id FROM worker_tasks WHERE state = \'queued\' OR (state = \'leased\' AND lease_expires_at <= ?) ORDER BY CASE WHEN state = \'queued\' THEN 0 ELSE 1 END, id LIMIT 1').get(now);
|
|
531
|
+
if (!row || typeof row.id !== 'string')
|
|
532
|
+
return undefined;
|
|
533
|
+
const leaseId = this.nextId(database, 'worker-lease');
|
|
534
|
+
const changed = database.prepare('UPDATE worker_tasks SET state = \'leased\', attempt = attempt + 1, lease_id = ?, worker_id = ?, lease_expires_at = ? WHERE id = ? AND (state = \'queued\' OR (state = \'leased\' AND lease_expires_at <= ?))').run(leaseId, workerId, now + this.leaseMs(database, row.id), row.id, now);
|
|
535
|
+
if (this.changedRows(changed) !== 1)
|
|
536
|
+
return undefined;
|
|
537
|
+
return { id: row.id, leaseId };
|
|
538
|
+
});
|
|
539
|
+
if (!lease)
|
|
540
|
+
return undefined;
|
|
541
|
+
const controller = new AbortController();
|
|
542
|
+
this.activeLeases.set(workerId, { leaseId: lease.leaseId, controller });
|
|
543
|
+
const task = this.get(lease.id);
|
|
544
|
+
if (!task)
|
|
545
|
+
return undefined;
|
|
546
|
+
return { workerId, leaseId: lease.leaseId, task };
|
|
547
|
+
}
|
|
548
|
+
renewLease(workerId, leaseId, now = Date.now(), leaseMs) {
|
|
549
|
+
const expiresAt = this.withTransaction((database) => {
|
|
550
|
+
const task = database.prepare('SELECT lease_ms FROM worker_tasks WHERE id IN (SELECT id FROM worker_tasks WHERE worker_id = ? AND lease_id = ? AND state = \'leased\' AND lease_expires_at > ?)').get(workerId, leaseId, now);
|
|
551
|
+
if (!task)
|
|
552
|
+
return undefined;
|
|
553
|
+
const duration = leaseMs ?? (typeof task.lease_ms === 'number' ? task.lease_ms : 30_000);
|
|
554
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
555
|
+
throw new Error('INVALID_WORKER_LEASE');
|
|
556
|
+
const changed = database.prepare('UPDATE worker_tasks SET lease_expires_at = ?, lease_ms = ? WHERE worker_id = ? AND lease_id = ? AND state = \'leased\' AND lease_expires_at > ?').run(now + duration, duration, workerId, leaseId, now);
|
|
557
|
+
return this.changedRows(changed) === 1 ? now + duration : undefined;
|
|
558
|
+
});
|
|
559
|
+
return expiresAt;
|
|
560
|
+
}
|
|
561
|
+
completeRemote(workerId, leaseId, value, now = Date.now()) {
|
|
562
|
+
const taskId = this.taskIdForLease(workerId, leaseId);
|
|
563
|
+
const changed = this.withTransaction((database) => database.prepare('UPDATE worker_tasks SET state = \'succeeded\', result = ?, lease_id = NULL, worker_id = NULL, lease_expires_at = NULL WHERE worker_id = ? AND lease_id = ? AND state = \'leased\' AND lease_expires_at > ?').run(JSON.stringify(value), workerId, leaseId, now));
|
|
564
|
+
if (this.changedRows(changed) !== 1)
|
|
565
|
+
return false;
|
|
566
|
+
this.activeLeases.get(workerId)?.controller.abort();
|
|
567
|
+
this.activeLeases.delete(workerId);
|
|
568
|
+
this.deferreds.get(taskId ?? '')?.resolve(value);
|
|
569
|
+
this.pump();
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
failRemote(workerId, leaseId, error, now = Date.now()) {
|
|
573
|
+
const taskId = this.taskIdForLease(workerId, leaseId);
|
|
574
|
+
const changed = this.withTransaction((database) => database.prepare('UPDATE worker_tasks SET state = \'failed\', error = ?, lease_id = NULL, worker_id = NULL, lease_expires_at = NULL WHERE worker_id = ? AND lease_id = ? AND state = \'leased\' AND lease_expires_at > ?').run(JSON.stringify(error), workerId, leaseId, now));
|
|
575
|
+
if (this.changedRows(changed) !== 1)
|
|
576
|
+
return false;
|
|
577
|
+
this.activeLeases.get(workerId)?.controller.abort();
|
|
578
|
+
this.activeLeases.delete(workerId);
|
|
579
|
+
if (taskId)
|
|
580
|
+
this.deferreds.get(taskId)?.reject(error);
|
|
581
|
+
this.pump();
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
get(taskId) {
|
|
585
|
+
const row = this.open().prepare('SELECT * FROM worker_tasks WHERE id = ?').get(taskId);
|
|
586
|
+
return row ? this.rowToTask(row) : undefined;
|
|
587
|
+
}
|
|
588
|
+
inspect() { return this.open().prepare('SELECT * FROM worker_tasks ORDER BY id').all().map((row) => this.rowToTask(row)); }
|
|
589
|
+
cancel(taskId, reason = 'WORKER_CANCELLED') {
|
|
590
|
+
const task = this.get(taskId);
|
|
591
|
+
if (!task || ['succeeded', 'failed', 'cancelled'].includes(task.state))
|
|
592
|
+
return false;
|
|
593
|
+
const changed = this.withTransaction((database) => database.prepare('UPDATE worker_tasks SET state = \'cancelled\', lease_id = NULL, worker_id = NULL, lease_expires_at = NULL WHERE id = ? AND state NOT IN (\'succeeded\', \'failed\', \'cancelled\')').run(taskId));
|
|
594
|
+
if (this.changedRows(changed) !== 1)
|
|
595
|
+
return false;
|
|
596
|
+
if (task.workerId) {
|
|
597
|
+
this.activeLeases.get(task.workerId)?.controller.abort();
|
|
598
|
+
this.activeLeases.delete(task.workerId);
|
|
599
|
+
}
|
|
600
|
+
this.deferreds.get(taskId)?.reject(new Error(reason));
|
|
601
|
+
this.pump();
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
recoverExpired(now = Date.now()) {
|
|
605
|
+
const ids = this.withTransaction((database) => {
|
|
606
|
+
const rows = database.prepare('SELECT id, worker_id FROM worker_tasks WHERE state = \'leased\' AND lease_expires_at <= ?').all(now);
|
|
607
|
+
database.prepare('UPDATE worker_tasks SET state = \'queued\', lease_id = NULL, worker_id = NULL, lease_expires_at = NULL WHERE state = \'leased\' AND lease_expires_at <= ?').run(now);
|
|
608
|
+
return rows.map((row) => {
|
|
609
|
+
if (typeof row.worker_id === 'string') {
|
|
610
|
+
this.activeLeases.get(row.worker_id)?.controller.abort();
|
|
611
|
+
this.activeLeases.delete(row.worker_id);
|
|
612
|
+
}
|
|
613
|
+
return row.id;
|
|
614
|
+
}).filter((id) => typeof id === 'string');
|
|
615
|
+
});
|
|
616
|
+
this.pump();
|
|
617
|
+
return ids;
|
|
618
|
+
}
|
|
619
|
+
close() {
|
|
620
|
+
for (const watcher of this.watchers.values())
|
|
621
|
+
clearInterval(watcher);
|
|
622
|
+
this.watchers.clear();
|
|
623
|
+
this.database?.close();
|
|
624
|
+
this.database = undefined;
|
|
625
|
+
}
|
|
626
|
+
pump() {
|
|
627
|
+
for (const [workerId, handler] of this.handlers) {
|
|
628
|
+
const lease = this.claim(workerId);
|
|
629
|
+
if (!lease)
|
|
630
|
+
continue;
|
|
631
|
+
const controller = this.activeLeases.get(workerId)?.controller;
|
|
632
|
+
if (!controller)
|
|
633
|
+
continue;
|
|
634
|
+
void handler(lease.task.payload, controller.signal).then((value) => this.completeRemote(workerId, lease.leaseId, value)).catch((cause) => this.failRemote(workerId, lease.leaseId, runtimeError(cause)));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
ensureDeferred(taskId) {
|
|
638
|
+
const existing = this.deferreds.get(taskId);
|
|
639
|
+
if (existing)
|
|
640
|
+
return existing;
|
|
641
|
+
const result = deferred();
|
|
642
|
+
this.deferreds.set(taskId, result);
|
|
643
|
+
const task = taskId ? this.get(taskId) : undefined;
|
|
644
|
+
if (task?.state === 'succeeded')
|
|
645
|
+
result.resolve(task.result ?? null);
|
|
646
|
+
else if (task?.state === 'failed')
|
|
647
|
+
result.reject(task.error ?? { code: 'WORKER_FAILED', message: 'WORKER_FAILED' });
|
|
648
|
+
else if (task?.state === 'cancelled')
|
|
649
|
+
result.reject(new Error('WORKER_CANCELLED'));
|
|
650
|
+
else
|
|
651
|
+
this.watch(taskId, result);
|
|
652
|
+
return result;
|
|
653
|
+
}
|
|
654
|
+
watch(taskId, result) {
|
|
655
|
+
if (this.watchers.has(taskId))
|
|
656
|
+
return;
|
|
657
|
+
const watcher = setInterval(() => {
|
|
658
|
+
const task = this.get(taskId);
|
|
659
|
+
if (!task || (task.state !== 'succeeded' && task.state !== 'failed' && task.state !== 'cancelled'))
|
|
660
|
+
return;
|
|
661
|
+
const timer = this.watchers.get(taskId);
|
|
662
|
+
if (timer)
|
|
663
|
+
clearInterval(timer);
|
|
664
|
+
this.watchers.delete(taskId);
|
|
665
|
+
if (task.state === 'succeeded')
|
|
666
|
+
result.resolve(task.result ?? null);
|
|
667
|
+
else if (task.state === 'failed')
|
|
668
|
+
result.reject(task.error ?? { code: 'WORKER_FAILED', message: 'WORKER_FAILED' });
|
|
669
|
+
else
|
|
670
|
+
result.reject(new Error('WORKER_CANCELLED'));
|
|
671
|
+
}, 10);
|
|
672
|
+
watcher.unref();
|
|
673
|
+
this.watchers.set(taskId, watcher);
|
|
674
|
+
}
|
|
675
|
+
taskIdForLease(workerId, leaseId) {
|
|
676
|
+
const row = this.open().prepare('SELECT id FROM worker_tasks WHERE worker_id = ? AND lease_id = ?').get(workerId, leaseId);
|
|
677
|
+
return row && typeof row.id === 'string' ? row.id : undefined;
|
|
678
|
+
}
|
|
679
|
+
getByLease(workerId, leaseId) {
|
|
680
|
+
const row = this.open().prepare('SELECT * FROM worker_tasks WHERE worker_id = ? AND lease_id = ? AND state = \'leased\'').get(workerId, leaseId);
|
|
681
|
+
return row ? this.rowToTask(row) : undefined;
|
|
682
|
+
}
|
|
683
|
+
requeueWorker(workerId) {
|
|
684
|
+
this.withTransaction((database) => { database.prepare('UPDATE worker_tasks SET state = \'queued\', lease_id = NULL, worker_id = NULL, lease_expires_at = NULL WHERE worker_id = ? AND state = \'leased\'').run(workerId); return undefined; });
|
|
685
|
+
}
|
|
686
|
+
nextId(database, prefix) {
|
|
687
|
+
const row = database.prepare('SELECT value FROM worker_sequence WHERE id = 1').get();
|
|
688
|
+
const sequence = typeof row?.value === 'number' ? row.value : 1;
|
|
689
|
+
database.prepare('UPDATE worker_sequence SET value = ? WHERE id = 1').run(sequence + 1);
|
|
690
|
+
return `${prefix}-${sequence}`;
|
|
691
|
+
}
|
|
692
|
+
leaseMs(database, taskId) {
|
|
693
|
+
const row = database.prepare('SELECT lease_ms FROM worker_tasks WHERE id = ?').get(taskId);
|
|
694
|
+
return typeof row?.lease_ms === 'number' ? row.lease_ms : 30_000;
|
|
695
|
+
}
|
|
696
|
+
rowToTask(row) {
|
|
697
|
+
const task = { id: String(row.id), payload: JSON.parse(String(row.payload)), state: String(row.state), attempt: Number(row.attempt), leaseMs: Number(row.lease_ms) };
|
|
698
|
+
if (typeof row.lease_id === 'string')
|
|
699
|
+
task.leaseId = row.lease_id;
|
|
700
|
+
if (typeof row.worker_id === 'string')
|
|
701
|
+
task.workerId = row.worker_id;
|
|
702
|
+
if (typeof row.lease_expires_at === 'number')
|
|
703
|
+
task.leaseExpiresAt = row.lease_expires_at;
|
|
704
|
+
if (typeof row.idempotency_key === 'string')
|
|
705
|
+
task.idempotencyKey = row.idempotency_key;
|
|
706
|
+
if (typeof row.result === 'string')
|
|
707
|
+
task.result = JSON.parse(row.result);
|
|
708
|
+
if (typeof row.error === 'string')
|
|
709
|
+
task.error = JSON.parse(row.error);
|
|
710
|
+
return task;
|
|
711
|
+
}
|
|
712
|
+
changedRows(result) { return typeof result === 'object' && result !== null && typeof result.changes === 'number' ? result.changes : 0; }
|
|
713
|
+
open() {
|
|
714
|
+
if (this.database)
|
|
715
|
+
return this.database;
|
|
716
|
+
const require = createRequire(import.meta.url);
|
|
717
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
718
|
+
this.database = new DatabaseSync(this.filePath);
|
|
719
|
+
this.database.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 30000; CREATE TABLE IF NOT EXISTS worker_sequence (id INTEGER PRIMARY KEY CHECK (id = 1), value INTEGER NOT NULL); INSERT OR IGNORE INTO worker_sequence (id, value) VALUES (1, 1); CREATE TABLE IF NOT EXISTS worker_tasks (id TEXT PRIMARY KEY, payload TEXT NOT NULL, state TEXT NOT NULL, attempt INTEGER NOT NULL, lease_id TEXT, worker_id TEXT, lease_expires_at INTEGER, lease_ms INTEGER NOT NULL, idempotency_key TEXT UNIQUE, result TEXT, error TEXT)');
|
|
720
|
+
return this.database;
|
|
721
|
+
}
|
|
722
|
+
withTransaction(work) {
|
|
723
|
+
const database = this.open();
|
|
724
|
+
database.exec('BEGIN IMMEDIATE');
|
|
725
|
+
try {
|
|
726
|
+
const result = work(database);
|
|
727
|
+
database.exec('COMMIT');
|
|
728
|
+
return result;
|
|
729
|
+
}
|
|
730
|
+
catch (cause) {
|
|
731
|
+
try {
|
|
732
|
+
database.exec('ROLLBACK');
|
|
733
|
+
}
|
|
734
|
+
catch { /* transaction already closed */ }
|
|
735
|
+
throw cause;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
export function createWorkerEffectExecutor(coordinator, options = {}) {
|
|
740
|
+
return async (effect, signal) => {
|
|
741
|
+
const value = await coordinator.submit({ effectId: effect.id, attemptId: effect.attemptId, kind: effect.kind, input: effect.input }, { taskId: `${effect.id}:${effect.attemptId}`, idempotencyKey: effect.idempotencyKey ?? `${effect.id}:${effect.attemptId}`, ...(options.leaseMs === undefined ? {} : { leaseMs: options.leaseMs }), signal });
|
|
742
|
+
return { value, executionState: 'succeeded', sideEffectState: 'none' };
|
|
743
|
+
};
|
|
744
|
+
}
|