@coji/durably 0.12.0 → 0.13.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/{index-hM7-oiyj.d.ts → index-DWsJlgyh.d.ts} +146 -56
- package/dist/index.d.ts +9 -3
- package/dist/index.js +953 -421
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +1 -1
- package/docs/llms.md +35 -17
- package/package.json +27 -21
- package/LICENSE +0 -21
package/dist/index.js
CHANGED
|
@@ -4,6 +4,210 @@ import {
|
|
|
4
4
|
|
|
5
5
|
// src/durably.ts
|
|
6
6
|
import { Kysely } from "kysely";
|
|
7
|
+
import { monotonicFactory as monotonicFactory2 } from "ulidx";
|
|
8
|
+
|
|
9
|
+
// src/errors.ts
|
|
10
|
+
var CancelledError = class extends Error {
|
|
11
|
+
constructor(runId) {
|
|
12
|
+
super(`Run was cancelled: ${runId}`);
|
|
13
|
+
this.name = "CancelledError";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var LeaseLostError = class extends Error {
|
|
17
|
+
constructor(runId) {
|
|
18
|
+
super(`Lease ownership was lost: ${runId}`);
|
|
19
|
+
this.name = "LeaseLostError";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function getErrorMessage(error) {
|
|
23
|
+
return error instanceof Error ? error.message : String(error);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/context.ts
|
|
27
|
+
var LEASE_LOST = "lease-lost";
|
|
28
|
+
function createStepContext(run, jobName, leaseGeneration, storage, eventEmitter) {
|
|
29
|
+
let stepIndex = run.currentStepIndex;
|
|
30
|
+
let currentStepName = null;
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
function abortForLeaseLoss() {
|
|
33
|
+
if (!controller.signal.aborted) {
|
|
34
|
+
controller.abort(LEASE_LOST);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function throwIfAborted() {
|
|
38
|
+
if (!controller.signal.aborted) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (controller.signal.reason === LEASE_LOST) {
|
|
42
|
+
throw new LeaseLostError(run.id);
|
|
43
|
+
}
|
|
44
|
+
throw new CancelledError(run.id);
|
|
45
|
+
}
|
|
46
|
+
const unsubscribe = eventEmitter.on("run:cancel", (event) => {
|
|
47
|
+
if (event.runId === run.id) {
|
|
48
|
+
controller.abort();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
const step = {
|
|
52
|
+
get runId() {
|
|
53
|
+
return run.id;
|
|
54
|
+
},
|
|
55
|
+
get signal() {
|
|
56
|
+
return controller.signal;
|
|
57
|
+
},
|
|
58
|
+
isAborted() {
|
|
59
|
+
return controller.signal.aborted;
|
|
60
|
+
},
|
|
61
|
+
throwIfAborted() {
|
|
62
|
+
throwIfAborted();
|
|
63
|
+
},
|
|
64
|
+
async run(name, fn) {
|
|
65
|
+
throwIfAborted();
|
|
66
|
+
const currentRun = await storage.getRun(run.id);
|
|
67
|
+
if (currentRun?.status === "cancelled") {
|
|
68
|
+
controller.abort();
|
|
69
|
+
throwIfAborted();
|
|
70
|
+
}
|
|
71
|
+
if (currentRun && (currentRun.status === "leased" && currentRun.leaseGeneration !== leaseGeneration || currentRun.status === "completed" || currentRun.status === "failed")) {
|
|
72
|
+
abortForLeaseLoss();
|
|
73
|
+
throwIfAborted();
|
|
74
|
+
}
|
|
75
|
+
throwIfAborted();
|
|
76
|
+
const existingStep = await storage.getCompletedStep(run.id, name);
|
|
77
|
+
if (existingStep) {
|
|
78
|
+
stepIndex++;
|
|
79
|
+
return existingStep.output;
|
|
80
|
+
}
|
|
81
|
+
currentStepName = name;
|
|
82
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
83
|
+
const startTime = Date.now();
|
|
84
|
+
eventEmitter.emit({
|
|
85
|
+
type: "step:start",
|
|
86
|
+
runId: run.id,
|
|
87
|
+
jobName,
|
|
88
|
+
stepName: name,
|
|
89
|
+
stepIndex,
|
|
90
|
+
labels: run.labels
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
const result = await fn(controller.signal);
|
|
94
|
+
throwIfAborted();
|
|
95
|
+
const savedStep = await storage.persistStep(run.id, leaseGeneration, {
|
|
96
|
+
name,
|
|
97
|
+
index: stepIndex,
|
|
98
|
+
status: "completed",
|
|
99
|
+
output: result,
|
|
100
|
+
startedAt
|
|
101
|
+
});
|
|
102
|
+
if (!savedStep) {
|
|
103
|
+
abortForLeaseLoss();
|
|
104
|
+
throwIfAborted();
|
|
105
|
+
}
|
|
106
|
+
stepIndex++;
|
|
107
|
+
eventEmitter.emit({
|
|
108
|
+
type: "step:complete",
|
|
109
|
+
runId: run.id,
|
|
110
|
+
jobName,
|
|
111
|
+
stepName: name,
|
|
112
|
+
stepIndex: stepIndex - 1,
|
|
113
|
+
output: result,
|
|
114
|
+
duration: Date.now() - startTime,
|
|
115
|
+
labels: run.labels
|
|
116
|
+
});
|
|
117
|
+
return result;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof LeaseLostError) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
const isLeaseLost = controller.signal.aborted && controller.signal.reason === LEASE_LOST;
|
|
123
|
+
if (isLeaseLost) {
|
|
124
|
+
throw new LeaseLostError(run.id);
|
|
125
|
+
}
|
|
126
|
+
const isCancelled = controller.signal.aborted;
|
|
127
|
+
const errorMessage = getErrorMessage(error);
|
|
128
|
+
const savedStep = await storage.persistStep(run.id, leaseGeneration, {
|
|
129
|
+
name,
|
|
130
|
+
index: stepIndex,
|
|
131
|
+
status: isCancelled ? "cancelled" : "failed",
|
|
132
|
+
error: errorMessage,
|
|
133
|
+
startedAt
|
|
134
|
+
});
|
|
135
|
+
if (!savedStep) {
|
|
136
|
+
abortForLeaseLoss();
|
|
137
|
+
throw new LeaseLostError(run.id);
|
|
138
|
+
}
|
|
139
|
+
eventEmitter.emit({
|
|
140
|
+
...isCancelled ? { type: "step:cancel" } : { type: "step:fail", error: errorMessage },
|
|
141
|
+
runId: run.id,
|
|
142
|
+
jobName,
|
|
143
|
+
stepName: name,
|
|
144
|
+
stepIndex,
|
|
145
|
+
labels: run.labels
|
|
146
|
+
});
|
|
147
|
+
if (isCancelled) {
|
|
148
|
+
throwIfAborted();
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
} finally {
|
|
152
|
+
currentStepName = null;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
progress(current, total, message) {
|
|
156
|
+
const progressData = { current, total, message };
|
|
157
|
+
storage.updateProgress(run.id, leaseGeneration, progressData);
|
|
158
|
+
eventEmitter.emit({
|
|
159
|
+
type: "run:progress",
|
|
160
|
+
runId: run.id,
|
|
161
|
+
jobName,
|
|
162
|
+
progress: progressData,
|
|
163
|
+
labels: run.labels
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
log: {
|
|
167
|
+
info(message, data) {
|
|
168
|
+
eventEmitter.emit({
|
|
169
|
+
type: "log:write",
|
|
170
|
+
runId: run.id,
|
|
171
|
+
jobName,
|
|
172
|
+
labels: run.labels,
|
|
173
|
+
stepName: currentStepName,
|
|
174
|
+
level: "info",
|
|
175
|
+
message,
|
|
176
|
+
data
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
warn(message, data) {
|
|
180
|
+
eventEmitter.emit({
|
|
181
|
+
type: "log:write",
|
|
182
|
+
runId: run.id,
|
|
183
|
+
jobName,
|
|
184
|
+
labels: run.labels,
|
|
185
|
+
stepName: currentStepName,
|
|
186
|
+
level: "warn",
|
|
187
|
+
message,
|
|
188
|
+
data
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
error(message, data) {
|
|
192
|
+
eventEmitter.emit({
|
|
193
|
+
type: "log:write",
|
|
194
|
+
runId: run.id,
|
|
195
|
+
jobName,
|
|
196
|
+
labels: run.labels,
|
|
197
|
+
stepName: currentStepName,
|
|
198
|
+
level: "error",
|
|
199
|
+
message,
|
|
200
|
+
data
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
return {
|
|
206
|
+
step,
|
|
207
|
+
abortLeaseOwnership: abortForLeaseLoss,
|
|
208
|
+
dispose: unsubscribe
|
|
209
|
+
};
|
|
210
|
+
}
|
|
7
211
|
|
|
8
212
|
// src/events.ts
|
|
9
213
|
function createEventEmitter() {
|
|
@@ -96,7 +300,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
|
|
|
96
300
|
if (labelsSchema && options?.labels) {
|
|
97
301
|
validateJobInputOrThrow(labelsSchema, options.labels, "labels");
|
|
98
302
|
}
|
|
99
|
-
const run = await storage.
|
|
303
|
+
const run = await storage.enqueue({
|
|
100
304
|
jobName: jobDef.name,
|
|
101
305
|
input: validatedInput,
|
|
102
306
|
idempotencyKey: options?.idempotencyKey,
|
|
@@ -226,7 +430,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
|
|
|
226
430
|
options: normalized[i].options
|
|
227
431
|
});
|
|
228
432
|
}
|
|
229
|
-
const runs = await storage.
|
|
433
|
+
const runs = await storage.enqueueMany(
|
|
230
434
|
validated.map((v) => ({
|
|
231
435
|
jobName: jobDef.name,
|
|
232
436
|
input: v.input,
|
|
@@ -274,6 +478,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
|
|
|
274
478
|
}
|
|
275
479
|
|
|
276
480
|
// src/migrations.ts
|
|
481
|
+
import { sql } from "kysely";
|
|
277
482
|
var migrations = [
|
|
278
483
|
{
|
|
279
484
|
version: 1,
|
|
@@ -282,12 +487,26 @@ var migrations = [
|
|
|
282
487
|
"current_step_index",
|
|
283
488
|
"integer",
|
|
284
489
|
(col) => col.notNull().defaultTo(0)
|
|
285
|
-
).addColumn("progress", "text").addColumn("output", "text").addColumn("error", "text").addColumn("
|
|
490
|
+
).addColumn("progress", "text").addColumn("output", "text").addColumn("error", "text").addColumn("lease_owner", "text").addColumn("lease_expires_at", "text").addColumn(
|
|
491
|
+
"lease_generation",
|
|
492
|
+
"integer",
|
|
493
|
+
(col) => col.notNull().defaultTo(0)
|
|
494
|
+
).addColumn("started_at", "text").addColumn("completed_at", "text").addColumn("created_at", "text", (col) => col.notNull()).addColumn("updated_at", "text", (col) => col.notNull()).execute();
|
|
286
495
|
await db.schema.createIndex("idx_durably_runs_job_idempotency").ifNotExists().on("durably_runs").columns(["job_name", "idempotency_key"]).unique().execute();
|
|
287
496
|
await db.schema.createIndex("idx_durably_runs_status_concurrency").ifNotExists().on("durably_runs").columns(["status", "concurrency_key"]).execute();
|
|
288
497
|
await db.schema.createIndex("idx_durably_runs_status_created").ifNotExists().on("durably_runs").columns(["status", "created_at"]).execute();
|
|
498
|
+
await db.schema.createIndex("idx_durably_runs_status_lease_expires").ifNotExists().on("durably_runs").columns(["status", "lease_expires_at"]).execute();
|
|
499
|
+
await db.schema.createIndex("idx_durably_runs_job_created").ifNotExists().on("durably_runs").columns(["job_name", "created_at"]).execute();
|
|
500
|
+
await db.schema.createIndex("idx_durably_runs_status_completed").ifNotExists().on("durably_runs").columns(["status", "completed_at"]).execute();
|
|
501
|
+
await db.schema.createTable("durably_run_labels").ifNotExists().addColumn("run_id", "text", (col) => col.notNull()).addColumn("key", "text", (col) => col.notNull()).addColumn("value", "text", (col) => col.notNull()).execute();
|
|
502
|
+
await db.schema.createIndex("idx_durably_run_labels_pk").ifNotExists().on("durably_run_labels").columns(["run_id", "key"]).unique().execute();
|
|
503
|
+
await db.schema.createIndex("idx_durably_run_labels_key_value").ifNotExists().on("durably_run_labels").columns(["key", "value"]).execute();
|
|
289
504
|
await db.schema.createTable("durably_steps").ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("run_id", "text", (col) => col.notNull()).addColumn("name", "text", (col) => col.notNull()).addColumn("index", "integer", (col) => col.notNull()).addColumn("status", "text", (col) => col.notNull()).addColumn("output", "text").addColumn("error", "text").addColumn("started_at", "text", (col) => col.notNull()).addColumn("completed_at", "text").execute();
|
|
290
505
|
await db.schema.createIndex("idx_durably_steps_run_index").ifNotExists().on("durably_steps").columns(["run_id", "index"]).execute();
|
|
506
|
+
await sql`
|
|
507
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_durably_steps_completed_unique
|
|
508
|
+
ON durably_steps(run_id, name) WHERE status = 'completed'
|
|
509
|
+
`.execute(db);
|
|
291
510
|
await db.schema.createTable("durably_logs").ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("run_id", "text", (col) => col.notNull()).addColumn("step_name", "text").addColumn("level", "text", (col) => col.notNull()).addColumn("message", "text", (col) => col.notNull()).addColumn("data", "text").addColumn("created_at", "text", (col) => col.notNull()).execute();
|
|
292
511
|
await db.schema.createIndex("idx_durably_logs_run_created").ifNotExists().on("durably_logs").columns(["run_id", "created_at"]).execute();
|
|
293
512
|
await db.schema.createTable("durably_schema_versions").ifNotExists().addColumn("version", "integer", (col) => col.primaryKey()).addColumn("applied_at", "text", (col) => col.notNull()).execute();
|
|
@@ -318,14 +537,17 @@ async function runMigrations(db) {
|
|
|
318
537
|
}
|
|
319
538
|
|
|
320
539
|
// src/storage.ts
|
|
321
|
-
import { sql } from "kysely";
|
|
540
|
+
import { sql as sql2 } from "kysely";
|
|
322
541
|
import { monotonicFactory } from "ulidx";
|
|
323
542
|
var ulid = monotonicFactory();
|
|
543
|
+
var TERMINAL_STATUSES = ["completed", "failed", "cancelled"];
|
|
324
544
|
function toClientRun(run) {
|
|
325
545
|
const {
|
|
326
546
|
idempotencyKey,
|
|
327
547
|
concurrencyKey,
|
|
328
|
-
|
|
548
|
+
leaseOwner,
|
|
549
|
+
leaseExpiresAt,
|
|
550
|
+
leaseGeneration,
|
|
329
551
|
updatedAt,
|
|
330
552
|
...clientRun
|
|
331
553
|
} = run;
|
|
@@ -356,7 +578,9 @@ function rowToRun(row) {
|
|
|
356
578
|
output: row.output ? JSON.parse(row.output) : null,
|
|
357
579
|
error: row.error,
|
|
358
580
|
labels: JSON.parse(row.labels),
|
|
359
|
-
|
|
581
|
+
leaseOwner: row.lease_owner,
|
|
582
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
583
|
+
leaseGeneration: row.lease_generation,
|
|
360
584
|
startedAt: row.started_at,
|
|
361
585
|
completedAt: row.completed_at,
|
|
362
586
|
createdAt: row.created_at,
|
|
@@ -387,9 +611,50 @@ function rowToLog(row) {
|
|
|
387
611
|
createdAt: row.created_at
|
|
388
612
|
};
|
|
389
613
|
}
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
|
|
614
|
+
function createWriteMutex() {
|
|
615
|
+
let queue = Promise.resolve();
|
|
616
|
+
return async function withWriteLock(fn) {
|
|
617
|
+
let release;
|
|
618
|
+
const next = new Promise((resolve) => {
|
|
619
|
+
release = resolve;
|
|
620
|
+
});
|
|
621
|
+
const prev = queue;
|
|
622
|
+
queue = next;
|
|
623
|
+
await prev;
|
|
624
|
+
try {
|
|
625
|
+
return await fn();
|
|
626
|
+
} finally {
|
|
627
|
+
release();
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function createKyselyStore(db, backend = "generic") {
|
|
632
|
+
const withWriteLock = createWriteMutex();
|
|
633
|
+
async function cascadeDeleteRuns(trx, ids) {
|
|
634
|
+
if (ids.length === 0) return;
|
|
635
|
+
await trx.deleteFrom("durably_steps").where("run_id", "in", ids).execute();
|
|
636
|
+
await trx.deleteFrom("durably_logs").where("run_id", "in", ids).execute();
|
|
637
|
+
await trx.deleteFrom("durably_run_labels").where("run_id", "in", ids).execute();
|
|
638
|
+
await trx.deleteFrom("durably_runs").where("id", "in", ids).execute();
|
|
639
|
+
}
|
|
640
|
+
async function insertLabelRows(executor, runId, labels) {
|
|
641
|
+
const entries = Object.entries(labels ?? {});
|
|
642
|
+
if (entries.length > 0) {
|
|
643
|
+
await executor.insertInto("durably_run_labels").values(entries.map(([key, value]) => ({ run_id: runId, key, value }))).execute();
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
async function terminateRun(runId, leaseGeneration, completedAt, fields) {
|
|
647
|
+
const result = await db.updateTable("durably_runs").set({
|
|
648
|
+
...fields,
|
|
649
|
+
lease_owner: null,
|
|
650
|
+
lease_expires_at: null,
|
|
651
|
+
completed_at: completedAt,
|
|
652
|
+
updated_at: completedAt
|
|
653
|
+
}).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).executeTakeFirst();
|
|
654
|
+
return Number(result.numUpdatedRows) > 0;
|
|
655
|
+
}
|
|
656
|
+
const store = {
|
|
657
|
+
async enqueue(input) {
|
|
393
658
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
394
659
|
if (input.idempotencyKey) {
|
|
395
660
|
const existing = await db.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
|
|
@@ -411,16 +676,21 @@ function createKyselyStorage(db) {
|
|
|
411
676
|
output: null,
|
|
412
677
|
error: null,
|
|
413
678
|
labels: JSON.stringify(input.labels ?? {}),
|
|
414
|
-
|
|
679
|
+
lease_owner: null,
|
|
680
|
+
lease_expires_at: null,
|
|
681
|
+
lease_generation: 0,
|
|
415
682
|
started_at: null,
|
|
416
683
|
completed_at: null,
|
|
417
684
|
created_at: now,
|
|
418
685
|
updated_at: now
|
|
419
686
|
};
|
|
420
|
-
await db.
|
|
687
|
+
await db.transaction().execute(async (trx) => {
|
|
688
|
+
await trx.insertInto("durably_runs").values(run).execute();
|
|
689
|
+
await insertLabelRows(trx, id, input.labels);
|
|
690
|
+
});
|
|
421
691
|
return rowToRun(run);
|
|
422
692
|
},
|
|
423
|
-
async
|
|
693
|
+
async enqueueMany(inputs) {
|
|
424
694
|
if (inputs.length === 0) {
|
|
425
695
|
return [];
|
|
426
696
|
}
|
|
@@ -430,6 +700,7 @@ function createKyselyStorage(db) {
|
|
|
430
700
|
for (const input of inputs) {
|
|
431
701
|
validateLabels(input.labels);
|
|
432
702
|
}
|
|
703
|
+
const allLabelRows = [];
|
|
433
704
|
for (const input of inputs) {
|
|
434
705
|
if (input.idempotencyKey) {
|
|
435
706
|
const existing = await trx.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
|
|
@@ -439,6 +710,11 @@ function createKyselyStorage(db) {
|
|
|
439
710
|
}
|
|
440
711
|
}
|
|
441
712
|
const id = ulid();
|
|
713
|
+
if (input.labels) {
|
|
714
|
+
for (const [key, value] of Object.entries(input.labels)) {
|
|
715
|
+
allLabelRows.push({ run_id: id, key, value });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
442
718
|
runs.push({
|
|
443
719
|
id,
|
|
444
720
|
job_name: input.jobName,
|
|
@@ -451,7 +727,9 @@ function createKyselyStorage(db) {
|
|
|
451
727
|
output: null,
|
|
452
728
|
error: null,
|
|
453
729
|
labels: JSON.stringify(input.labels ?? {}),
|
|
454
|
-
|
|
730
|
+
lease_owner: null,
|
|
731
|
+
lease_expires_at: null,
|
|
732
|
+
lease_generation: 0,
|
|
455
733
|
started_at: null,
|
|
456
734
|
completed_at: null,
|
|
457
735
|
created_at: now,
|
|
@@ -461,35 +739,13 @@ function createKyselyStorage(db) {
|
|
|
461
739
|
const newRuns = runs.filter((r) => r.created_at === now);
|
|
462
740
|
if (newRuns.length > 0) {
|
|
463
741
|
await trx.insertInto("durably_runs").values(newRuns).execute();
|
|
742
|
+
if (allLabelRows.length > 0) {
|
|
743
|
+
await trx.insertInto("durably_run_labels").values(allLabelRows).execute();
|
|
744
|
+
}
|
|
464
745
|
}
|
|
465
746
|
return runs.map(rowToRun);
|
|
466
747
|
});
|
|
467
748
|
},
|
|
468
|
-
async updateRun(runId, data) {
|
|
469
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
470
|
-
const updates = {
|
|
471
|
-
updated_at: now
|
|
472
|
-
};
|
|
473
|
-
if (data.status !== void 0) updates.status = data.status;
|
|
474
|
-
if (data.currentStepIndex !== void 0)
|
|
475
|
-
updates.current_step_index = data.currentStepIndex;
|
|
476
|
-
if (data.progress !== void 0)
|
|
477
|
-
updates.progress = data.progress ? JSON.stringify(data.progress) : null;
|
|
478
|
-
if (data.output !== void 0)
|
|
479
|
-
updates.output = JSON.stringify(data.output);
|
|
480
|
-
if (data.error !== void 0) updates.error = data.error;
|
|
481
|
-
if (data.heartbeatAt !== void 0)
|
|
482
|
-
updates.heartbeat_at = data.heartbeatAt;
|
|
483
|
-
if (data.startedAt !== void 0) updates.started_at = data.startedAt;
|
|
484
|
-
if (data.completedAt !== void 0)
|
|
485
|
-
updates.completed_at = data.completedAt;
|
|
486
|
-
await db.updateTable("durably_runs").set(updates).where("id", "=", runId).execute();
|
|
487
|
-
},
|
|
488
|
-
async deleteRun(runId) {
|
|
489
|
-
await db.deleteFrom("durably_logs").where("run_id", "=", runId).execute();
|
|
490
|
-
await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
|
|
491
|
-
await db.deleteFrom("durably_runs").where("id", "=", runId).execute();
|
|
492
|
-
},
|
|
493
749
|
async getRun(runId) {
|
|
494
750
|
const row = await db.selectFrom("durably_runs").leftJoin("durably_steps", "durably_runs.id", "durably_steps.run_id").selectAll("durably_runs").select(
|
|
495
751
|
(eb) => eb.fn.count("durably_steps.id").as("step_count")
|
|
@@ -517,10 +773,14 @@ function createKyselyStorage(db) {
|
|
|
517
773
|
validateLabels(labels);
|
|
518
774
|
for (const [key, value] of Object.entries(labels)) {
|
|
519
775
|
if (value === void 0) continue;
|
|
776
|
+
const jsonFallback = backend === "postgres" ? sql2`durably_runs.labels ->> ${key} = ${value}` : sql2`json_extract(durably_runs.labels, ${`$.${key}`}) = ${value}`;
|
|
520
777
|
query = query.where(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
778
|
+
(eb) => eb.or([
|
|
779
|
+
eb.exists(
|
|
780
|
+
eb.selectFrom("durably_run_labels").select(sql2.lit(1).as("one")).whereRef("durably_run_labels.run_id", "=", "durably_runs.id").where("durably_run_labels.key", "=", key).where("durably_run_labels.value", "=", value)
|
|
781
|
+
),
|
|
782
|
+
jsonFallback
|
|
783
|
+
])
|
|
524
784
|
);
|
|
525
785
|
}
|
|
526
786
|
}
|
|
@@ -537,21 +797,131 @@ function createKyselyStorage(db) {
|
|
|
537
797
|
const rows = await query.execute();
|
|
538
798
|
return rows.map(rowToRun);
|
|
539
799
|
},
|
|
540
|
-
async
|
|
800
|
+
async updateRun(runId, data) {
|
|
541
801
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
802
|
+
const status = data.status;
|
|
803
|
+
await db.updateTable("durably_runs").set({
|
|
804
|
+
status,
|
|
805
|
+
current_step_index: data.currentStepIndex,
|
|
806
|
+
progress: data.progress !== void 0 ? data.progress ? JSON.stringify(data.progress) : null : void 0,
|
|
807
|
+
output: data.output !== void 0 ? JSON.stringify(data.output) : void 0,
|
|
808
|
+
error: data.error,
|
|
809
|
+
lease_owner: data.leaseOwner !== void 0 ? data.leaseOwner : void 0,
|
|
810
|
+
lease_expires_at: data.leaseExpiresAt !== void 0 ? data.leaseExpiresAt : void 0,
|
|
811
|
+
started_at: data.startedAt,
|
|
812
|
+
completed_at: data.completedAt,
|
|
813
|
+
updated_at: now
|
|
814
|
+
}).where("id", "=", runId).execute();
|
|
815
|
+
},
|
|
816
|
+
async deleteRun(runId) {
|
|
817
|
+
await db.transaction().execute(async (trx) => {
|
|
818
|
+
await cascadeDeleteRuns(trx, [runId]);
|
|
819
|
+
});
|
|
820
|
+
},
|
|
821
|
+
async purgeRuns(options) {
|
|
822
|
+
const limit = options.limit ?? 500;
|
|
823
|
+
return await db.transaction().execute(async (trx) => {
|
|
824
|
+
const rows = await trx.selectFrom("durably_runs").select("id").where("status", "in", TERMINAL_STATUSES).where("completed_at", "<", options.olderThan).orderBy("completed_at", "asc").limit(limit).execute();
|
|
825
|
+
if (rows.length === 0) return 0;
|
|
826
|
+
const ids = rows.map((r) => r.id);
|
|
827
|
+
await cascadeDeleteRuns(trx, ids);
|
|
828
|
+
return ids.length;
|
|
829
|
+
});
|
|
830
|
+
},
|
|
831
|
+
async claimNext(workerId, now, leaseMs) {
|
|
832
|
+
const leaseExpiresAt = new Date(Date.parse(now) + leaseMs).toISOString();
|
|
833
|
+
const activeLeaseGuard = sql2`
|
|
834
|
+
(
|
|
835
|
+
concurrency_key IS NULL
|
|
836
|
+
OR NOT EXISTS (
|
|
837
|
+
SELECT 1
|
|
838
|
+
FROM durably_runs AS active
|
|
839
|
+
WHERE active.concurrency_key = durably_runs.concurrency_key
|
|
840
|
+
AND active.id <> durably_runs.id
|
|
841
|
+
AND active.status = 'leased'
|
|
842
|
+
AND active.lease_expires_at IS NOT NULL
|
|
843
|
+
AND active.lease_expires_at > ${now}
|
|
844
|
+
)
|
|
845
|
+
)
|
|
846
|
+
`;
|
|
847
|
+
if (backend === "postgres") {
|
|
848
|
+
return await db.transaction().execute(async (trx) => {
|
|
849
|
+
const skipKeys = [];
|
|
850
|
+
for (; ; ) {
|
|
851
|
+
const concurrencyCondition = skipKeys.length > 0 ? sql2`
|
|
852
|
+
AND (
|
|
853
|
+
concurrency_key IS NULL
|
|
854
|
+
OR concurrency_key NOT IN (${sql2.join(skipKeys)})
|
|
855
|
+
)
|
|
856
|
+
` : sql2``;
|
|
857
|
+
const candidateResult = await sql2`
|
|
858
|
+
SELECT id, concurrency_key
|
|
859
|
+
FROM durably_runs
|
|
860
|
+
WHERE
|
|
861
|
+
(
|
|
862
|
+
status = 'pending'
|
|
863
|
+
OR (status = 'leased' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ${now})
|
|
864
|
+
)
|
|
865
|
+
AND ${activeLeaseGuard}
|
|
866
|
+
${concurrencyCondition}
|
|
867
|
+
ORDER BY created_at ASC, id ASC
|
|
868
|
+
FOR UPDATE SKIP LOCKED
|
|
869
|
+
LIMIT 1
|
|
870
|
+
`.execute(trx);
|
|
871
|
+
const candidate = candidateResult.rows[0];
|
|
872
|
+
if (!candidate) return null;
|
|
873
|
+
if (candidate.concurrency_key) {
|
|
874
|
+
await sql2`SELECT pg_advisory_xact_lock(hashtext(${candidate.concurrency_key}))`.execute(
|
|
875
|
+
trx
|
|
876
|
+
);
|
|
877
|
+
const conflict = await sql2`
|
|
878
|
+
SELECT 1 FROM durably_runs
|
|
879
|
+
WHERE concurrency_key = ${candidate.concurrency_key}
|
|
880
|
+
AND id <> ${candidate.id}
|
|
881
|
+
AND status = 'leased'
|
|
882
|
+
AND lease_expires_at IS NOT NULL
|
|
883
|
+
AND lease_expires_at > ${now}
|
|
884
|
+
LIMIT 1
|
|
885
|
+
`.execute(trx);
|
|
886
|
+
if (conflict.rows.length > 0) {
|
|
887
|
+
skipKeys.push(candidate.concurrency_key);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const result = await sql2`
|
|
892
|
+
UPDATE durably_runs
|
|
893
|
+
SET
|
|
894
|
+
status = 'leased',
|
|
895
|
+
lease_owner = ${workerId},
|
|
896
|
+
lease_expires_at = ${leaseExpiresAt},
|
|
897
|
+
lease_generation = lease_generation + 1,
|
|
898
|
+
started_at = COALESCE(started_at, ${now}),
|
|
899
|
+
updated_at = ${now}
|
|
900
|
+
WHERE id = ${candidate.id}
|
|
901
|
+
RETURNING *
|
|
902
|
+
`.execute(trx);
|
|
903
|
+
const row2 = result.rows[0];
|
|
904
|
+
if (!row2) return null;
|
|
905
|
+
return rowToRun({ ...row2, step_count: 0 });
|
|
906
|
+
}
|
|
907
|
+
});
|
|
550
908
|
}
|
|
909
|
+
let subquery = db.selectFrom("durably_runs").select("durably_runs.id").where(
|
|
910
|
+
(eb) => eb.or([
|
|
911
|
+
eb("status", "=", "pending"),
|
|
912
|
+
eb.and([
|
|
913
|
+
eb("status", "=", "leased"),
|
|
914
|
+
eb("lease_expires_at", "is not", null),
|
|
915
|
+
eb("lease_expires_at", "<=", now)
|
|
916
|
+
])
|
|
917
|
+
])
|
|
918
|
+
).where(activeLeaseGuard).orderBy("created_at", "asc").orderBy("id", "asc").limit(1);
|
|
551
919
|
const row = await db.updateTable("durably_runs").set({
|
|
552
|
-
status: "
|
|
553
|
-
|
|
554
|
-
|
|
920
|
+
status: "leased",
|
|
921
|
+
lease_owner: workerId,
|
|
922
|
+
lease_expires_at: leaseExpiresAt,
|
|
923
|
+
lease_generation: sql2`lease_generation + 1`,
|
|
924
|
+
started_at: sql2`COALESCE(started_at, ${now})`,
|
|
555
925
|
updated_at: now
|
|
556
926
|
}).where(
|
|
557
927
|
"id",
|
|
@@ -561,25 +931,82 @@ function createKyselyStorage(db) {
|
|
|
561
931
|
if (!row) return null;
|
|
562
932
|
return rowToRun({ ...row, step_count: 0 });
|
|
563
933
|
},
|
|
564
|
-
async
|
|
934
|
+
async renewLease(runId, leaseGeneration, now, leaseMs) {
|
|
935
|
+
const leaseExpiresAt = new Date(Date.parse(now) + leaseMs).toISOString();
|
|
936
|
+
const result = await db.updateTable("durably_runs").set({
|
|
937
|
+
lease_expires_at: leaseExpiresAt,
|
|
938
|
+
updated_at: now
|
|
939
|
+
}).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).where("lease_expires_at", ">", now).executeTakeFirst();
|
|
940
|
+
return Number(result.numUpdatedRows) > 0;
|
|
941
|
+
},
|
|
942
|
+
async releaseExpiredLeases(now) {
|
|
943
|
+
const result = await db.updateTable("durably_runs").set({
|
|
944
|
+
status: "pending",
|
|
945
|
+
lease_owner: null,
|
|
946
|
+
lease_expires_at: null,
|
|
947
|
+
updated_at: now
|
|
948
|
+
}).where("status", "=", "leased").where("lease_expires_at", "is not", null).where("lease_expires_at", "<=", now).executeTakeFirst();
|
|
949
|
+
return Number(result.numUpdatedRows);
|
|
950
|
+
},
|
|
951
|
+
async completeRun(runId, leaseGeneration, output, completedAt) {
|
|
952
|
+
return terminateRun(runId, leaseGeneration, completedAt, {
|
|
953
|
+
status: "completed",
|
|
954
|
+
output: JSON.stringify(output),
|
|
955
|
+
error: null
|
|
956
|
+
});
|
|
957
|
+
},
|
|
958
|
+
async failRun(runId, leaseGeneration, error, completedAt) {
|
|
959
|
+
return terminateRun(runId, leaseGeneration, completedAt, {
|
|
960
|
+
status: "failed",
|
|
961
|
+
error
|
|
962
|
+
});
|
|
963
|
+
},
|
|
964
|
+
async cancelRun(runId, now) {
|
|
965
|
+
const result = await db.updateTable("durably_runs").set({
|
|
966
|
+
status: "cancelled",
|
|
967
|
+
lease_owner: null,
|
|
968
|
+
lease_expires_at: null,
|
|
969
|
+
completed_at: now,
|
|
970
|
+
updated_at: now
|
|
971
|
+
}).where("id", "=", runId).where("status", "in", ["pending", "leased"]).executeTakeFirst();
|
|
972
|
+
return Number(result.numUpdatedRows) > 0;
|
|
973
|
+
},
|
|
974
|
+
async persistStep(runId, leaseGeneration, input) {
|
|
565
975
|
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
566
976
|
const id = ulid();
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
977
|
+
const outputJson = input.output !== void 0 ? JSON.stringify(input.output) : null;
|
|
978
|
+
const errorValue = input.error ?? null;
|
|
979
|
+
return await db.transaction().execute(async (trx) => {
|
|
980
|
+
const insertResult = await sql2`
|
|
981
|
+
INSERT INTO durably_steps (id, run_id, name, "index", status, output, error, started_at, completed_at)
|
|
982
|
+
SELECT ${id}, ${runId}, ${input.name}, ${input.index}, ${input.status},
|
|
983
|
+
${outputJson}, ${errorValue}, ${input.startedAt}, ${completedAt}
|
|
984
|
+
FROM durably_runs
|
|
985
|
+
WHERE id = ${runId} AND lease_generation = ${leaseGeneration}
|
|
986
|
+
`.execute(trx);
|
|
987
|
+
if (Number(insertResult.numAffectedRows) === 0) return null;
|
|
988
|
+
if (input.status === "completed") {
|
|
989
|
+
await trx.updateTable("durably_runs").set({
|
|
990
|
+
current_step_index: input.index + 1,
|
|
991
|
+
updated_at: completedAt
|
|
992
|
+
}).where("id", "=", runId).where("lease_generation", "=", leaseGeneration).execute();
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
id,
|
|
996
|
+
runId,
|
|
997
|
+
name: input.name,
|
|
998
|
+
index: input.index,
|
|
999
|
+
status: input.status,
|
|
1000
|
+
output: input.output !== void 0 ? input.output : null,
|
|
1001
|
+
error: errorValue,
|
|
1002
|
+
startedAt: input.startedAt,
|
|
1003
|
+
completedAt
|
|
1004
|
+
};
|
|
1005
|
+
});
|
|
580
1006
|
},
|
|
581
1007
|
async deleteSteps(runId) {
|
|
582
1008
|
await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
|
|
1009
|
+
await db.deleteFrom("durably_logs").where("run_id", "=", runId).execute();
|
|
583
1010
|
},
|
|
584
1011
|
async getSteps(runId) {
|
|
585
1012
|
const rows = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).orderBy("index", "asc").execute();
|
|
@@ -589,6 +1016,12 @@ function createKyselyStorage(db) {
|
|
|
589
1016
|
const row = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).where("name", "=", name).where("status", "=", "completed").executeTakeFirst();
|
|
590
1017
|
return row ? rowToStep(row) : null;
|
|
591
1018
|
},
|
|
1019
|
+
async updateProgress(runId, leaseGeneration, progress) {
|
|
1020
|
+
await db.updateTable("durably_runs").set({
|
|
1021
|
+
progress: progress ? JSON.stringify(progress) : null,
|
|
1022
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1023
|
+
}).where("id", "=", runId).where("lease_generation", "=", leaseGeneration).execute();
|
|
1024
|
+
},
|
|
592
1025
|
async createLog(input) {
|
|
593
1026
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
594
1027
|
const id = ulid();
|
|
@@ -609,328 +1042,54 @@ function createKyselyStorage(db) {
|
|
|
609
1042
|
return rows.map(rowToLog);
|
|
610
1043
|
}
|
|
611
1044
|
};
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1045
|
+
const mutatingKeys = [
|
|
1046
|
+
"enqueue",
|
|
1047
|
+
"enqueueMany",
|
|
1048
|
+
"updateRun",
|
|
1049
|
+
"deleteRun",
|
|
1050
|
+
"purgeRuns",
|
|
1051
|
+
"claimNext",
|
|
1052
|
+
"renewLease",
|
|
1053
|
+
"releaseExpiredLeases",
|
|
1054
|
+
"completeRun",
|
|
1055
|
+
"failRun",
|
|
1056
|
+
"cancelRun",
|
|
1057
|
+
"persistStep",
|
|
1058
|
+
"deleteSteps",
|
|
1059
|
+
"updateProgress",
|
|
1060
|
+
"createLog"
|
|
1061
|
+
];
|
|
1062
|
+
for (const key of mutatingKeys) {
|
|
1063
|
+
const original = store[key];
|
|
1064
|
+
store[key] = (...args) => withWriteLock(() => original.apply(store, args));
|
|
622
1065
|
}
|
|
623
|
-
|
|
624
|
-
function getErrorMessage(error) {
|
|
625
|
-
return error instanceof Error ? error.message : String(error);
|
|
1066
|
+
return store;
|
|
626
1067
|
}
|
|
627
1068
|
|
|
628
|
-
// src/
|
|
629
|
-
function
|
|
630
|
-
let
|
|
631
|
-
let
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1069
|
+
// src/worker.ts
|
|
1070
|
+
function createWorker(config, processOne) {
|
|
1071
|
+
let running = false;
|
|
1072
|
+
let pollingTimeout = null;
|
|
1073
|
+
let inFlight = null;
|
|
1074
|
+
let stopResolver = null;
|
|
1075
|
+
let activeWorkerId;
|
|
1076
|
+
async function poll() {
|
|
1077
|
+
if (!running) {
|
|
1078
|
+
return;
|
|
636
1079
|
}
|
|
637
|
-
});
|
|
638
|
-
const step = {
|
|
639
|
-
get runId() {
|
|
640
|
-
return run.id;
|
|
641
|
-
},
|
|
642
|
-
async run(name, fn) {
|
|
643
|
-
if (controller.signal.aborted) {
|
|
644
|
-
throw new CancelledError(run.id);
|
|
645
|
-
}
|
|
646
|
-
const currentRun = await storage.getRun(run.id);
|
|
647
|
-
if (currentRun?.status === "cancelled") {
|
|
648
|
-
controller.abort();
|
|
649
|
-
throw new CancelledError(run.id);
|
|
650
|
-
}
|
|
651
|
-
if (controller.signal.aborted) {
|
|
652
|
-
throw new CancelledError(run.id);
|
|
653
|
-
}
|
|
654
|
-
const existingStep = await storage.getCompletedStep(run.id, name);
|
|
655
|
-
if (existingStep) {
|
|
656
|
-
stepIndex++;
|
|
657
|
-
return existingStep.output;
|
|
658
|
-
}
|
|
659
|
-
currentStepName = name;
|
|
660
|
-
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
661
|
-
const startTime = Date.now();
|
|
662
|
-
eventEmitter.emit({
|
|
663
|
-
type: "step:start",
|
|
664
|
-
runId: run.id,
|
|
665
|
-
jobName,
|
|
666
|
-
stepName: name,
|
|
667
|
-
stepIndex,
|
|
668
|
-
labels: run.labels
|
|
669
|
-
});
|
|
670
|
-
try {
|
|
671
|
-
const result = await fn(controller.signal);
|
|
672
|
-
await storage.createStep({
|
|
673
|
-
runId: run.id,
|
|
674
|
-
name,
|
|
675
|
-
index: stepIndex,
|
|
676
|
-
status: "completed",
|
|
677
|
-
output: result,
|
|
678
|
-
startedAt
|
|
679
|
-
});
|
|
680
|
-
stepIndex++;
|
|
681
|
-
await storage.updateRun(run.id, { currentStepIndex: stepIndex });
|
|
682
|
-
eventEmitter.emit({
|
|
683
|
-
type: "step:complete",
|
|
684
|
-
runId: run.id,
|
|
685
|
-
jobName,
|
|
686
|
-
stepName: name,
|
|
687
|
-
stepIndex: stepIndex - 1,
|
|
688
|
-
output: result,
|
|
689
|
-
duration: Date.now() - startTime,
|
|
690
|
-
labels: run.labels
|
|
691
|
-
});
|
|
692
|
-
return result;
|
|
693
|
-
} catch (error) {
|
|
694
|
-
const isCancelled = controller.signal.aborted;
|
|
695
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
696
|
-
await storage.createStep({
|
|
697
|
-
runId: run.id,
|
|
698
|
-
name,
|
|
699
|
-
index: stepIndex,
|
|
700
|
-
status: isCancelled ? "cancelled" : "failed",
|
|
701
|
-
error: errorMessage,
|
|
702
|
-
startedAt
|
|
703
|
-
});
|
|
704
|
-
eventEmitter.emit({
|
|
705
|
-
...isCancelled ? { type: "step:cancel" } : { type: "step:fail", error: errorMessage },
|
|
706
|
-
runId: run.id,
|
|
707
|
-
jobName,
|
|
708
|
-
stepName: name,
|
|
709
|
-
stepIndex,
|
|
710
|
-
labels: run.labels
|
|
711
|
-
});
|
|
712
|
-
if (isCancelled) {
|
|
713
|
-
throw new CancelledError(run.id);
|
|
714
|
-
}
|
|
715
|
-
throw error;
|
|
716
|
-
} finally {
|
|
717
|
-
currentStepName = null;
|
|
718
|
-
}
|
|
719
|
-
},
|
|
720
|
-
progress(current, total, message) {
|
|
721
|
-
const progressData = { current, total, message };
|
|
722
|
-
storage.updateRun(run.id, { progress: progressData });
|
|
723
|
-
eventEmitter.emit({
|
|
724
|
-
type: "run:progress",
|
|
725
|
-
runId: run.id,
|
|
726
|
-
jobName,
|
|
727
|
-
progress: progressData,
|
|
728
|
-
labels: run.labels
|
|
729
|
-
});
|
|
730
|
-
},
|
|
731
|
-
log: {
|
|
732
|
-
info(message, data) {
|
|
733
|
-
eventEmitter.emit({
|
|
734
|
-
type: "log:write",
|
|
735
|
-
runId: run.id,
|
|
736
|
-
jobName,
|
|
737
|
-
labels: run.labels,
|
|
738
|
-
stepName: currentStepName,
|
|
739
|
-
level: "info",
|
|
740
|
-
message,
|
|
741
|
-
data
|
|
742
|
-
});
|
|
743
|
-
},
|
|
744
|
-
warn(message, data) {
|
|
745
|
-
eventEmitter.emit({
|
|
746
|
-
type: "log:write",
|
|
747
|
-
runId: run.id,
|
|
748
|
-
jobName,
|
|
749
|
-
labels: run.labels,
|
|
750
|
-
stepName: currentStepName,
|
|
751
|
-
level: "warn",
|
|
752
|
-
message,
|
|
753
|
-
data
|
|
754
|
-
});
|
|
755
|
-
},
|
|
756
|
-
error(message, data) {
|
|
757
|
-
eventEmitter.emit({
|
|
758
|
-
type: "log:write",
|
|
759
|
-
runId: run.id,
|
|
760
|
-
jobName,
|
|
761
|
-
labels: run.labels,
|
|
762
|
-
stepName: currentStepName,
|
|
763
|
-
level: "error",
|
|
764
|
-
message,
|
|
765
|
-
data
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
};
|
|
770
|
-
return { step, dispose: unsubscribe };
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// src/worker.ts
|
|
774
|
-
function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
775
|
-
let running = false;
|
|
776
|
-
let currentRunPromise = null;
|
|
777
|
-
let pollingTimeout = null;
|
|
778
|
-
let stopResolver = null;
|
|
779
|
-
let heartbeatInterval = null;
|
|
780
|
-
let currentRunId = null;
|
|
781
|
-
async function recoverStaleRuns() {
|
|
782
|
-
const staleThreshold = new Date(
|
|
783
|
-
Date.now() - config.staleThreshold
|
|
784
|
-
).toISOString();
|
|
785
|
-
const runningRuns = await storage.getRuns({ status: "running" });
|
|
786
|
-
for (const run of runningRuns) {
|
|
787
|
-
if (run.heartbeatAt < staleThreshold) {
|
|
788
|
-
await storage.updateRun(run.id, {
|
|
789
|
-
status: "pending"
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
async function updateHeartbeat() {
|
|
795
|
-
if (currentRunId) {
|
|
796
|
-
await storage.updateRun(currentRunId, {
|
|
797
|
-
heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
798
|
-
});
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
async function handleRunSuccess(runId, jobName, output, startTime) {
|
|
802
|
-
const currentRun = await storage.getRun(runId);
|
|
803
|
-
if (!currentRun || currentRun.status === "cancelled") {
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
|
-
await storage.updateRun(runId, {
|
|
807
|
-
status: "completed",
|
|
808
|
-
output,
|
|
809
|
-
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
810
|
-
});
|
|
811
|
-
eventEmitter.emit({
|
|
812
|
-
type: "run:complete",
|
|
813
|
-
runId,
|
|
814
|
-
jobName,
|
|
815
|
-
output,
|
|
816
|
-
duration: Date.now() - startTime,
|
|
817
|
-
labels: currentRun.labels
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
async function handleRunFailure(runId, jobName, error) {
|
|
821
|
-
if (error instanceof CancelledError) {
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
const currentRun = await storage.getRun(runId);
|
|
825
|
-
if (!currentRun || currentRun.status === "cancelled") {
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
const errorMessage = getErrorMessage(error);
|
|
829
|
-
const steps = await storage.getSteps(runId);
|
|
830
|
-
const failedStep = steps.find((s) => s.status === "failed");
|
|
831
|
-
await storage.updateRun(runId, {
|
|
832
|
-
status: "failed",
|
|
833
|
-
error: errorMessage,
|
|
834
|
-
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
835
|
-
});
|
|
836
|
-
eventEmitter.emit({
|
|
837
|
-
type: "run:fail",
|
|
838
|
-
runId,
|
|
839
|
-
jobName,
|
|
840
|
-
error: errorMessage,
|
|
841
|
-
failedStepName: failedStep?.name ?? "unknown",
|
|
842
|
-
labels: currentRun.labels
|
|
843
|
-
});
|
|
844
|
-
}
|
|
845
|
-
async function executeRun(run, job) {
|
|
846
|
-
currentRunId = run.id;
|
|
847
|
-
heartbeatInterval = setInterval(() => {
|
|
848
|
-
updateHeartbeat().catch((error) => {
|
|
849
|
-
eventEmitter.emit({
|
|
850
|
-
type: "worker:error",
|
|
851
|
-
error: getErrorMessage(error),
|
|
852
|
-
context: "heartbeat",
|
|
853
|
-
runId: run.id
|
|
854
|
-
});
|
|
855
|
-
});
|
|
856
|
-
}, config.heartbeatInterval);
|
|
857
|
-
eventEmitter.emit({
|
|
858
|
-
type: "run:start",
|
|
859
|
-
runId: run.id,
|
|
860
|
-
jobName: run.jobName,
|
|
861
|
-
input: run.input,
|
|
862
|
-
labels: run.labels
|
|
863
|
-
});
|
|
864
|
-
const startTime = Date.now();
|
|
865
|
-
const { step, dispose } = createStepContext(
|
|
866
|
-
run,
|
|
867
|
-
run.jobName,
|
|
868
|
-
storage,
|
|
869
|
-
eventEmitter
|
|
870
|
-
);
|
|
871
1080
|
try {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const parseResult = job.outputSchema.safeParse(output);
|
|
875
|
-
if (!parseResult.success) {
|
|
876
|
-
throw new Error(`Invalid output: ${prettifyError2(parseResult.error)}`);
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
await handleRunSuccess(run.id, run.jobName, output, startTime);
|
|
880
|
-
} catch (error) {
|
|
881
|
-
await handleRunFailure(run.id, run.jobName, error);
|
|
1081
|
+
inFlight = processOne({ workerId: activeWorkerId }).then(() => void 0);
|
|
1082
|
+
await inFlight;
|
|
882
1083
|
} finally {
|
|
883
|
-
|
|
884
|
-
try {
|
|
885
|
-
await storage.deleteSteps(run.id);
|
|
886
|
-
} catch {
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
dispose();
|
|
890
|
-
if (heartbeatInterval) {
|
|
891
|
-
clearInterval(heartbeatInterval);
|
|
892
|
-
heartbeatInterval = null;
|
|
893
|
-
}
|
|
894
|
-
currentRunId = null;
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
async function processNextRun() {
|
|
898
|
-
const runningRuns = await storage.getRuns({ status: "running" });
|
|
899
|
-
const excludeConcurrencyKeys = runningRuns.filter(
|
|
900
|
-
(r) => r.concurrencyKey !== null
|
|
901
|
-
).map((r) => r.concurrencyKey);
|
|
902
|
-
const run = await storage.claimNextPendingRun(excludeConcurrencyKeys);
|
|
903
|
-
if (!run) {
|
|
904
|
-
return false;
|
|
905
|
-
}
|
|
906
|
-
const job = jobRegistry.get(run.jobName);
|
|
907
|
-
if (!job) {
|
|
908
|
-
await storage.updateRun(run.id, {
|
|
909
|
-
status: "failed",
|
|
910
|
-
error: `Unknown job: ${run.jobName}`
|
|
911
|
-
});
|
|
912
|
-
return true;
|
|
1084
|
+
inFlight = null;
|
|
913
1085
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
if (!running) {
|
|
1086
|
+
if (running) {
|
|
1087
|
+
pollingTimeout = setTimeout(() => {
|
|
1088
|
+
void poll();
|
|
1089
|
+
}, config.pollingIntervalMs);
|
|
919
1090
|
return;
|
|
920
1091
|
}
|
|
921
|
-
|
|
922
|
-
await recoverStaleRuns();
|
|
923
|
-
await processNextRun();
|
|
924
|
-
};
|
|
925
|
-
try {
|
|
926
|
-
currentRunPromise = doWork();
|
|
927
|
-
await currentRunPromise;
|
|
928
|
-
} finally {
|
|
929
|
-
currentRunPromise = null;
|
|
930
|
-
}
|
|
931
|
-
if (running) {
|
|
932
|
-
pollingTimeout = setTimeout(() => poll(), config.pollingInterval);
|
|
933
|
-
} else if (stopResolver) {
|
|
1092
|
+
if (stopResolver) {
|
|
934
1093
|
stopResolver();
|
|
935
1094
|
stopResolver = null;
|
|
936
1095
|
}
|
|
@@ -939,12 +1098,13 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
|
939
1098
|
get isRunning() {
|
|
940
1099
|
return running;
|
|
941
1100
|
},
|
|
942
|
-
start() {
|
|
1101
|
+
start(options) {
|
|
943
1102
|
if (running) {
|
|
944
1103
|
return;
|
|
945
1104
|
}
|
|
1105
|
+
activeWorkerId = options?.workerId;
|
|
946
1106
|
running = true;
|
|
947
|
-
poll();
|
|
1107
|
+
void poll();
|
|
948
1108
|
},
|
|
949
1109
|
async stop() {
|
|
950
1110
|
if (!running) {
|
|
@@ -955,11 +1115,7 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
|
955
1115
|
clearTimeout(pollingTimeout);
|
|
956
1116
|
pollingTimeout = null;
|
|
957
1117
|
}
|
|
958
|
-
if (
|
|
959
|
-
clearInterval(heartbeatInterval);
|
|
960
|
-
heartbeatInterval = null;
|
|
961
|
-
}
|
|
962
|
-
if (currentRunPromise) {
|
|
1118
|
+
if (inFlight) {
|
|
963
1119
|
return new Promise((resolve) => {
|
|
964
1120
|
stopResolver = resolve;
|
|
965
1121
|
});
|
|
@@ -970,13 +1126,90 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
|
970
1126
|
|
|
971
1127
|
// src/durably.ts
|
|
972
1128
|
var DEFAULTS = {
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1129
|
+
pollingIntervalMs: 1e3,
|
|
1130
|
+
leaseRenewIntervalMs: 5e3,
|
|
1131
|
+
leaseMs: 3e4,
|
|
1132
|
+
preserveSteps: false
|
|
977
1133
|
};
|
|
1134
|
+
function parseDuration(value) {
|
|
1135
|
+
const match = value.match(/^(\d+)(d|h|m)$/);
|
|
1136
|
+
if (!match) {
|
|
1137
|
+
throw new Error(
|
|
1138
|
+
`Invalid duration format: "${value}". Use e.g. '30d', '24h', '60m'`
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
const num = Number.parseInt(match[1], 10);
|
|
1142
|
+
const unit = match[2];
|
|
1143
|
+
const multipliers = {
|
|
1144
|
+
d: 864e5,
|
|
1145
|
+
h: 36e5,
|
|
1146
|
+
m: 6e4
|
|
1147
|
+
};
|
|
1148
|
+
return num * multipliers[unit];
|
|
1149
|
+
}
|
|
1150
|
+
var PURGE_INTERVAL_MS = 6e4;
|
|
1151
|
+
var ulid2 = monotonicFactory2();
|
|
1152
|
+
var BROWSER_SINGLETON_REGISTRY_KEY = "__durablyBrowserSingletonRegistry";
|
|
1153
|
+
var BROWSER_LOCAL_DIALECT_KEY = "__durablyBrowserLocalKey";
|
|
1154
|
+
function defaultWorkerId() {
|
|
1155
|
+
return `worker_${ulid2()}`;
|
|
1156
|
+
}
|
|
1157
|
+
function detectBackend(dialect) {
|
|
1158
|
+
return dialect.constructor.name === "PostgresDialect" ? "postgres" : "generic";
|
|
1159
|
+
}
|
|
1160
|
+
function isBrowserLikeEnvironment() {
|
|
1161
|
+
return typeof globalThis.window !== "undefined" || typeof globalThis.document !== "undefined";
|
|
1162
|
+
}
|
|
1163
|
+
function getBrowserSingletonKey(dialect, explicitKey) {
|
|
1164
|
+
if (!isBrowserLikeEnvironment()) {
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
if (explicitKey) {
|
|
1168
|
+
return explicitKey;
|
|
1169
|
+
}
|
|
1170
|
+
const taggedDialect = dialect;
|
|
1171
|
+
const taggedKey = taggedDialect[BROWSER_LOCAL_DIALECT_KEY];
|
|
1172
|
+
return typeof taggedKey === "string" ? taggedKey : null;
|
|
1173
|
+
}
|
|
1174
|
+
function registerBrowserSingletonWarning(singletonKey) {
|
|
1175
|
+
const globalRegistry = globalThis;
|
|
1176
|
+
const registry = globalRegistry[BROWSER_SINGLETON_REGISTRY_KEY] ?? /* @__PURE__ */ new Map();
|
|
1177
|
+
globalRegistry[BROWSER_SINGLETON_REGISTRY_KEY] = registry;
|
|
1178
|
+
const instanceId = ulid2();
|
|
1179
|
+
const instances = registry.get(singletonKey) ?? /* @__PURE__ */ new Set();
|
|
1180
|
+
const hadExistingInstance = instances.size > 0;
|
|
1181
|
+
instances.add(instanceId);
|
|
1182
|
+
registry.set(singletonKey, instances);
|
|
1183
|
+
if (hadExistingInstance && (typeof process === "undefined" || process.env.NODE_ENV !== "production")) {
|
|
1184
|
+
console.warn(
|
|
1185
|
+
`[durably] Multiple runtimes were created for browser-local store "${singletonKey}" in one tab. Prefer a single shared instance per tab.`
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
let released = false;
|
|
1189
|
+
return () => {
|
|
1190
|
+
if (released) {
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
released = true;
|
|
1194
|
+
const activeInstances = registry.get(singletonKey);
|
|
1195
|
+
if (!activeInstances) {
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
activeInstances.delete(instanceId);
|
|
1199
|
+
if (activeInstances.size === 0) {
|
|
1200
|
+
registry.delete(singletonKey);
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
978
1204
|
function createDurablyInstance(state, jobs) {
|
|
979
|
-
const {
|
|
1205
|
+
const {
|
|
1206
|
+
db,
|
|
1207
|
+
storage,
|
|
1208
|
+
eventEmitter,
|
|
1209
|
+
jobRegistry,
|
|
1210
|
+
worker,
|
|
1211
|
+
releaseBrowserSingleton
|
|
1212
|
+
} = state;
|
|
980
1213
|
async function getRunOrThrow(runId) {
|
|
981
1214
|
const run = await storage.getRun(runId);
|
|
982
1215
|
if (!run) {
|
|
@@ -984,6 +1217,158 @@ function createDurablyInstance(state, jobs) {
|
|
|
984
1217
|
}
|
|
985
1218
|
return run;
|
|
986
1219
|
}
|
|
1220
|
+
async function executeRun(run, workerId) {
|
|
1221
|
+
const job = jobRegistry.get(run.jobName);
|
|
1222
|
+
if (!job) {
|
|
1223
|
+
await storage.failRun(
|
|
1224
|
+
run.id,
|
|
1225
|
+
run.leaseGeneration,
|
|
1226
|
+
`Unknown job: ${run.jobName}`,
|
|
1227
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
1228
|
+
);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const { step, abortLeaseOwnership, dispose } = createStepContext(
|
|
1232
|
+
run,
|
|
1233
|
+
run.jobName,
|
|
1234
|
+
run.leaseGeneration,
|
|
1235
|
+
storage,
|
|
1236
|
+
eventEmitter
|
|
1237
|
+
);
|
|
1238
|
+
let leaseDeadlineTimer = null;
|
|
1239
|
+
const scheduleLeaseDeadline = (leaseExpiresAt) => {
|
|
1240
|
+
if (leaseDeadlineTimer) {
|
|
1241
|
+
clearTimeout(leaseDeadlineTimer);
|
|
1242
|
+
leaseDeadlineTimer = null;
|
|
1243
|
+
}
|
|
1244
|
+
if (!leaseExpiresAt) {
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const delay = Math.max(0, Date.parse(leaseExpiresAt) - Date.now());
|
|
1248
|
+
leaseDeadlineTimer = setTimeout(() => {
|
|
1249
|
+
abortLeaseOwnership();
|
|
1250
|
+
}, delay);
|
|
1251
|
+
};
|
|
1252
|
+
scheduleLeaseDeadline(run.leaseExpiresAt);
|
|
1253
|
+
const leaseTimer = setInterval(() => {
|
|
1254
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1255
|
+
storage.renewLease(run.id, run.leaseGeneration, now, state.leaseMs).then((renewed) => {
|
|
1256
|
+
if (!renewed) {
|
|
1257
|
+
abortLeaseOwnership();
|
|
1258
|
+
eventEmitter.emit({
|
|
1259
|
+
type: "worker:error",
|
|
1260
|
+
error: `Lease renewal lost ownership for run ${run.id}`,
|
|
1261
|
+
context: "lease-renewal",
|
|
1262
|
+
runId: run.id
|
|
1263
|
+
});
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
const renewedLeaseExpiresAt = new Date(
|
|
1267
|
+
Date.parse(now) + state.leaseMs
|
|
1268
|
+
).toISOString();
|
|
1269
|
+
scheduleLeaseDeadline(renewedLeaseExpiresAt);
|
|
1270
|
+
eventEmitter.emit({
|
|
1271
|
+
type: "run:lease-renewed",
|
|
1272
|
+
runId: run.id,
|
|
1273
|
+
jobName: run.jobName,
|
|
1274
|
+
leaseOwner: workerId,
|
|
1275
|
+
leaseExpiresAt: renewedLeaseExpiresAt,
|
|
1276
|
+
labels: run.labels
|
|
1277
|
+
});
|
|
1278
|
+
}).catch((error) => {
|
|
1279
|
+
eventEmitter.emit({
|
|
1280
|
+
type: "worker:error",
|
|
1281
|
+
error: getErrorMessage(error),
|
|
1282
|
+
context: "lease-renewal",
|
|
1283
|
+
runId: run.id
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
}, state.leaseRenewIntervalMs);
|
|
1287
|
+
const started = Date.now();
|
|
1288
|
+
let reachedTerminalState = false;
|
|
1289
|
+
try {
|
|
1290
|
+
eventEmitter.emit({
|
|
1291
|
+
type: "run:leased",
|
|
1292
|
+
runId: run.id,
|
|
1293
|
+
jobName: run.jobName,
|
|
1294
|
+
input: run.input,
|
|
1295
|
+
leaseOwner: workerId,
|
|
1296
|
+
leaseExpiresAt: run.leaseExpiresAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1297
|
+
labels: run.labels
|
|
1298
|
+
});
|
|
1299
|
+
const output = await job.fn(step, run.input);
|
|
1300
|
+
if (job.outputSchema) {
|
|
1301
|
+
const parseResult = job.outputSchema.safeParse(output);
|
|
1302
|
+
if (!parseResult.success) {
|
|
1303
|
+
throw new Error(`Invalid output: ${parseResult.error.message}`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1307
|
+
const completed = await storage.completeRun(
|
|
1308
|
+
run.id,
|
|
1309
|
+
run.leaseGeneration,
|
|
1310
|
+
output,
|
|
1311
|
+
completedAt
|
|
1312
|
+
);
|
|
1313
|
+
if (completed) {
|
|
1314
|
+
reachedTerminalState = true;
|
|
1315
|
+
eventEmitter.emit({
|
|
1316
|
+
type: "run:complete",
|
|
1317
|
+
runId: run.id,
|
|
1318
|
+
jobName: run.jobName,
|
|
1319
|
+
output,
|
|
1320
|
+
duration: Date.now() - started,
|
|
1321
|
+
labels: run.labels
|
|
1322
|
+
});
|
|
1323
|
+
} else {
|
|
1324
|
+
eventEmitter.emit({
|
|
1325
|
+
type: "worker:error",
|
|
1326
|
+
error: `Lease lost before completing run ${run.id}`,
|
|
1327
|
+
context: "run-completion"
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
if (error instanceof LeaseLostError || error instanceof CancelledError) {
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
const errorMessage = getErrorMessage(error);
|
|
1335
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1336
|
+
const failed = await storage.failRun(
|
|
1337
|
+
run.id,
|
|
1338
|
+
run.leaseGeneration,
|
|
1339
|
+
errorMessage,
|
|
1340
|
+
completedAt
|
|
1341
|
+
);
|
|
1342
|
+
if (failed) {
|
|
1343
|
+
reachedTerminalState = true;
|
|
1344
|
+
const steps = await storage.getSteps(run.id);
|
|
1345
|
+
const failedStep = steps.find((entry) => entry.status === "failed");
|
|
1346
|
+
eventEmitter.emit({
|
|
1347
|
+
type: "run:fail",
|
|
1348
|
+
runId: run.id,
|
|
1349
|
+
jobName: run.jobName,
|
|
1350
|
+
error: errorMessage,
|
|
1351
|
+
failedStepName: failedStep?.name ?? "unknown",
|
|
1352
|
+
labels: run.labels
|
|
1353
|
+
});
|
|
1354
|
+
} else {
|
|
1355
|
+
eventEmitter.emit({
|
|
1356
|
+
type: "worker:error",
|
|
1357
|
+
error: `Lease lost before recording failure for run ${run.id}`,
|
|
1358
|
+
context: "run-failure"
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
} finally {
|
|
1362
|
+
clearInterval(leaseTimer);
|
|
1363
|
+
if (leaseDeadlineTimer) {
|
|
1364
|
+
clearTimeout(leaseDeadlineTimer);
|
|
1365
|
+
}
|
|
1366
|
+
dispose();
|
|
1367
|
+
if (!state.preserveSteps && reachedTerminalState) {
|
|
1368
|
+
await storage.deleteSteps(run.id);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
987
1372
|
const durably = {
|
|
988
1373
|
db,
|
|
989
1374
|
storage,
|
|
@@ -992,7 +1377,10 @@ function createDurablyInstance(state, jobs) {
|
|
|
992
1377
|
emit: eventEmitter.emit,
|
|
993
1378
|
onError: eventEmitter.onError,
|
|
994
1379
|
start: worker.start,
|
|
995
|
-
stop
|
|
1380
|
+
async stop() {
|
|
1381
|
+
releaseBrowserSingleton();
|
|
1382
|
+
await worker.stop();
|
|
1383
|
+
},
|
|
996
1384
|
// biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions
|
|
997
1385
|
register(jobDefs) {
|
|
998
1386
|
const newHandles = {};
|
|
@@ -1013,8 +1401,8 @@ function createDurablyInstance(state, jobs) {
|
|
|
1013
1401
|
mergedJobs
|
|
1014
1402
|
);
|
|
1015
1403
|
},
|
|
1016
|
-
getRun: storage.getRun,
|
|
1017
|
-
getRuns: storage.getRuns,
|
|
1404
|
+
getRun: storage.getRun.bind(storage),
|
|
1405
|
+
getRuns: storage.getRuns.bind(storage),
|
|
1018
1406
|
use(plugin) {
|
|
1019
1407
|
plugin.install(durably);
|
|
1020
1408
|
},
|
|
@@ -1028,9 +1416,14 @@ function createDurablyInstance(state, jobs) {
|
|
|
1028
1416
|
subscribe(runId) {
|
|
1029
1417
|
let closed = false;
|
|
1030
1418
|
let cleanup = null;
|
|
1031
|
-
const closeEvents = /* @__PURE__ */ new Set([
|
|
1419
|
+
const closeEvents = /* @__PURE__ */ new Set([
|
|
1420
|
+
"run:complete",
|
|
1421
|
+
"run:fail",
|
|
1422
|
+
"run:cancel",
|
|
1423
|
+
"run:delete"
|
|
1424
|
+
]);
|
|
1032
1425
|
const subscribedEvents = [
|
|
1033
|
-
"run:
|
|
1426
|
+
"run:leased",
|
|
1034
1427
|
"run:complete",
|
|
1035
1428
|
"run:fail",
|
|
1036
1429
|
"run:cancel",
|
|
@@ -1057,6 +1450,64 @@ function createDurablyInstance(state, jobs) {
|
|
|
1057
1450
|
cleanup = () => {
|
|
1058
1451
|
for (const unsub of unsubscribes) unsub();
|
|
1059
1452
|
};
|
|
1453
|
+
const closeStream = () => {
|
|
1454
|
+
closed = true;
|
|
1455
|
+
cleanup?.();
|
|
1456
|
+
controller.close();
|
|
1457
|
+
};
|
|
1458
|
+
storage.getRun(runId).then((run) => {
|
|
1459
|
+
if (closed || !run) return;
|
|
1460
|
+
const base = {
|
|
1461
|
+
runId,
|
|
1462
|
+
jobName: run.jobName,
|
|
1463
|
+
labels: run.labels,
|
|
1464
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1465
|
+
sequence: 0
|
|
1466
|
+
};
|
|
1467
|
+
if (run.status === "leased") {
|
|
1468
|
+
controller.enqueue({
|
|
1469
|
+
...base,
|
|
1470
|
+
type: "run:leased",
|
|
1471
|
+
input: run.input,
|
|
1472
|
+
leaseOwner: run.leaseOwner ?? "",
|
|
1473
|
+
leaseExpiresAt: run.leaseExpiresAt ?? ""
|
|
1474
|
+
});
|
|
1475
|
+
if (run.progress != null) {
|
|
1476
|
+
controller.enqueue({
|
|
1477
|
+
...base,
|
|
1478
|
+
type: "run:progress",
|
|
1479
|
+
progress: run.progress
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
} else if (run.status === "completed") {
|
|
1483
|
+
controller.enqueue({
|
|
1484
|
+
...base,
|
|
1485
|
+
type: "run:complete",
|
|
1486
|
+
output: run.output,
|
|
1487
|
+
duration: 0
|
|
1488
|
+
});
|
|
1489
|
+
closeStream();
|
|
1490
|
+
} else if (run.status === "failed") {
|
|
1491
|
+
controller.enqueue({
|
|
1492
|
+
...base,
|
|
1493
|
+
type: "run:fail",
|
|
1494
|
+
error: run.error ?? "Unknown error",
|
|
1495
|
+
failedStepName: ""
|
|
1496
|
+
});
|
|
1497
|
+
closeStream();
|
|
1498
|
+
} else if (run.status === "cancelled") {
|
|
1499
|
+
controller.enqueue({
|
|
1500
|
+
...base,
|
|
1501
|
+
type: "run:cancel"
|
|
1502
|
+
});
|
|
1503
|
+
closeStream();
|
|
1504
|
+
}
|
|
1505
|
+
}).catch((error) => {
|
|
1506
|
+
if (closed) return;
|
|
1507
|
+
closed = true;
|
|
1508
|
+
cleanup?.();
|
|
1509
|
+
controller.error(error);
|
|
1510
|
+
});
|
|
1060
1511
|
},
|
|
1061
1512
|
cancel: () => {
|
|
1062
1513
|
if (!closed) {
|
|
@@ -1071,15 +1522,21 @@ function createDurablyInstance(state, jobs) {
|
|
|
1071
1522
|
if (run.status === "pending") {
|
|
1072
1523
|
throw new Error(`Cannot retrigger pending run: ${runId}`);
|
|
1073
1524
|
}
|
|
1074
|
-
if (run.status === "
|
|
1075
|
-
throw new Error(`Cannot retrigger
|
|
1525
|
+
if (run.status === "leased") {
|
|
1526
|
+
throw new Error(`Cannot retrigger leased run: ${runId}`);
|
|
1076
1527
|
}
|
|
1077
|
-
|
|
1528
|
+
const job = jobRegistry.get(run.jobName);
|
|
1529
|
+
if (!job) {
|
|
1078
1530
|
throw new Error(`Unknown job: ${run.jobName}`);
|
|
1079
1531
|
}
|
|
1080
|
-
const
|
|
1532
|
+
const validatedInput = validateJobInputOrThrow(
|
|
1533
|
+
job.inputSchema,
|
|
1534
|
+
run.input,
|
|
1535
|
+
`Cannot retrigger run ${runId}`
|
|
1536
|
+
);
|
|
1537
|
+
const nextRun = await storage.enqueue({
|
|
1081
1538
|
jobName: run.jobName,
|
|
1082
|
-
input:
|
|
1539
|
+
input: validatedInput,
|
|
1083
1540
|
concurrencyKey: run.concurrencyKey ?? void 0,
|
|
1084
1541
|
labels: run.labels
|
|
1085
1542
|
});
|
|
@@ -1087,7 +1544,7 @@ function createDurablyInstance(state, jobs) {
|
|
|
1087
1544
|
type: "run:trigger",
|
|
1088
1545
|
runId: nextRun.id,
|
|
1089
1546
|
jobName: run.jobName,
|
|
1090
|
-
input:
|
|
1547
|
+
input: validatedInput,
|
|
1091
1548
|
labels: run.labels
|
|
1092
1549
|
});
|
|
1093
1550
|
return nextRun;
|
|
@@ -1104,11 +1561,14 @@ function createDurablyInstance(state, jobs) {
|
|
|
1104
1561
|
throw new Error(`Cannot cancel already cancelled run: ${runId}`);
|
|
1105
1562
|
}
|
|
1106
1563
|
const wasPending = run.status === "pending";
|
|
1107
|
-
await storage.
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1564
|
+
const cancelled = await storage.cancelRun(runId, (/* @__PURE__ */ new Date()).toISOString());
|
|
1565
|
+
if (!cancelled) {
|
|
1566
|
+
const current = await getRunOrThrow(runId);
|
|
1567
|
+
throw new Error(
|
|
1568
|
+
`Cannot cancel run ${runId}: status changed to ${current.status}`
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
if (wasPending && !state.preserveSteps) {
|
|
1112
1572
|
await storage.deleteSteps(runId);
|
|
1113
1573
|
}
|
|
1114
1574
|
eventEmitter.emit({
|
|
@@ -1123,8 +1583,8 @@ function createDurablyInstance(state, jobs) {
|
|
|
1123
1583
|
if (run.status === "pending") {
|
|
1124
1584
|
throw new Error(`Cannot delete pending run: ${runId}`);
|
|
1125
1585
|
}
|
|
1126
|
-
if (run.status === "
|
|
1127
|
-
throw new Error(`Cannot delete
|
|
1586
|
+
if (run.status === "leased") {
|
|
1587
|
+
throw new Error(`Cannot delete leased run: ${runId}`);
|
|
1128
1588
|
}
|
|
1129
1589
|
await storage.deleteRun(runId);
|
|
1130
1590
|
eventEmitter.emit({
|
|
@@ -1134,6 +1594,48 @@ function createDurablyInstance(state, jobs) {
|
|
|
1134
1594
|
labels: run.labels
|
|
1135
1595
|
});
|
|
1136
1596
|
},
|
|
1597
|
+
async purgeRuns(options) {
|
|
1598
|
+
return storage.purgeRuns({
|
|
1599
|
+
olderThan: options.olderThan.toISOString(),
|
|
1600
|
+
limit: options.limit
|
|
1601
|
+
});
|
|
1602
|
+
},
|
|
1603
|
+
async processOne(options) {
|
|
1604
|
+
const workerId = options?.workerId ?? defaultWorkerId();
|
|
1605
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1606
|
+
await storage.releaseExpiredLeases(now);
|
|
1607
|
+
const run = await storage.claimNext(workerId, now, state.leaseMs);
|
|
1608
|
+
if (!run) {
|
|
1609
|
+
if (state.retainRunsMs !== null && Date.now() - state.lastPurgeAt >= PURGE_INTERVAL_MS) {
|
|
1610
|
+
const purgeNow = Date.now();
|
|
1611
|
+
state.lastPurgeAt = purgeNow;
|
|
1612
|
+
const cutoff = new Date(purgeNow - state.retainRunsMs).toISOString();
|
|
1613
|
+
storage.purgeRuns({ olderThan: cutoff, limit: 100 }).catch((error) => {
|
|
1614
|
+
eventEmitter.emit({
|
|
1615
|
+
type: "worker:error",
|
|
1616
|
+
error: getErrorMessage(error),
|
|
1617
|
+
context: "auto-purge"
|
|
1618
|
+
});
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
1623
|
+
await executeRun(run, workerId);
|
|
1624
|
+
return true;
|
|
1625
|
+
},
|
|
1626
|
+
async processUntilIdle(options) {
|
|
1627
|
+
const workerId = options?.workerId ?? defaultWorkerId();
|
|
1628
|
+
const maxRuns = options?.maxRuns ?? Number.POSITIVE_INFINITY;
|
|
1629
|
+
let processed = 0;
|
|
1630
|
+
while (processed < maxRuns) {
|
|
1631
|
+
const didProcess = await this.processOne({ workerId });
|
|
1632
|
+
if (!didProcess) {
|
|
1633
|
+
break;
|
|
1634
|
+
}
|
|
1635
|
+
processed++;
|
|
1636
|
+
}
|
|
1637
|
+
return processed;
|
|
1638
|
+
},
|
|
1137
1639
|
async migrate() {
|
|
1138
1640
|
if (state.migrated) {
|
|
1139
1641
|
return;
|
|
@@ -1157,16 +1659,38 @@ function createDurablyInstance(state, jobs) {
|
|
|
1157
1659
|
}
|
|
1158
1660
|
function createDurably(options) {
|
|
1159
1661
|
const config = {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1662
|
+
pollingIntervalMs: options.pollingIntervalMs ?? DEFAULTS.pollingIntervalMs,
|
|
1663
|
+
leaseRenewIntervalMs: options.leaseRenewIntervalMs ?? DEFAULTS.leaseRenewIntervalMs,
|
|
1664
|
+
leaseMs: options.leaseMs ?? DEFAULTS.leaseMs,
|
|
1665
|
+
preserveSteps: options.preserveSteps ?? DEFAULTS.preserveSteps,
|
|
1666
|
+
retainRunsMs: options.retainRuns ? parseDuration(options.retainRuns) : null
|
|
1164
1667
|
};
|
|
1165
1668
|
const db = new Kysely({ dialect: options.dialect });
|
|
1166
|
-
const
|
|
1669
|
+
const singletonKey = getBrowserSingletonKey(
|
|
1670
|
+
options.dialect,
|
|
1671
|
+
options.singletonKey
|
|
1672
|
+
);
|
|
1673
|
+
const releaseBrowserSingleton = singletonKey !== null ? registerBrowserSingletonWarning(singletonKey) : () => {
|
|
1674
|
+
};
|
|
1675
|
+
const backend = detectBackend(options.dialect);
|
|
1676
|
+
const storage = createKyselyStore(db, backend);
|
|
1677
|
+
const originalDestroy = db.destroy.bind(db);
|
|
1678
|
+
db.destroy = (async () => {
|
|
1679
|
+
releaseBrowserSingleton();
|
|
1680
|
+
return originalDestroy();
|
|
1681
|
+
});
|
|
1167
1682
|
const eventEmitter = createEventEmitter();
|
|
1168
1683
|
const jobRegistry = createJobRegistry();
|
|
1169
|
-
|
|
1684
|
+
let processOneImpl = null;
|
|
1685
|
+
const worker = createWorker(
|
|
1686
|
+
{ pollingIntervalMs: config.pollingIntervalMs },
|
|
1687
|
+
(runtimeOptions) => {
|
|
1688
|
+
if (!processOneImpl) {
|
|
1689
|
+
throw new Error("Durably runtime is not initialized");
|
|
1690
|
+
}
|
|
1691
|
+
return processOneImpl(runtimeOptions);
|
|
1692
|
+
}
|
|
1693
|
+
);
|
|
1170
1694
|
const state = {
|
|
1171
1695
|
db,
|
|
1172
1696
|
storage,
|
|
@@ -1174,14 +1698,20 @@ function createDurably(options) {
|
|
|
1174
1698
|
jobRegistry,
|
|
1175
1699
|
worker,
|
|
1176
1700
|
labelsSchema: options.labels,
|
|
1177
|
-
|
|
1701
|
+
preserveSteps: config.preserveSteps,
|
|
1178
1702
|
migrating: null,
|
|
1179
|
-
migrated: false
|
|
1703
|
+
migrated: false,
|
|
1704
|
+
leaseMs: config.leaseMs,
|
|
1705
|
+
leaseRenewIntervalMs: config.leaseRenewIntervalMs,
|
|
1706
|
+
retainRunsMs: config.retainRunsMs,
|
|
1707
|
+
lastPurgeAt: 0,
|
|
1708
|
+
releaseBrowserSingleton
|
|
1180
1709
|
};
|
|
1181
1710
|
const instance = createDurablyInstance(
|
|
1182
1711
|
state,
|
|
1183
1712
|
{}
|
|
1184
1713
|
);
|
|
1714
|
+
processOneImpl = instance.processOne;
|
|
1185
1715
|
if (options.jobs) {
|
|
1186
1716
|
return instance.register(options.jobs);
|
|
1187
1717
|
}
|
|
@@ -1426,7 +1956,7 @@ function createThrottledSSEController(inner, throttleMs) {
|
|
|
1426
1956
|
// src/server.ts
|
|
1427
1957
|
var VALID_STATUSES = [
|
|
1428
1958
|
"pending",
|
|
1429
|
-
"
|
|
1959
|
+
"leased",
|
|
1430
1960
|
"completed",
|
|
1431
1961
|
"failed",
|
|
1432
1962
|
"cancelled"
|
|
@@ -1656,10 +2186,10 @@ function createDurablyHandler(durably, options) {
|
|
|
1656
2186
|
});
|
|
1657
2187
|
}
|
|
1658
2188
|
}),
|
|
1659
|
-
durably.on("run:
|
|
2189
|
+
durably.on("run:leased", (event) => {
|
|
1660
2190
|
if (matchesFilter(event.jobName, event.labels)) {
|
|
1661
2191
|
ctrl.enqueue({
|
|
1662
|
-
type: "run:
|
|
2192
|
+
type: "run:leased",
|
|
1663
2193
|
runId: event.runId,
|
|
1664
2194
|
jobName: event.jobName,
|
|
1665
2195
|
labels: event.labels
|
|
@@ -1825,8 +2355,10 @@ function createDurablyHandler(durably, options) {
|
|
|
1825
2355
|
}
|
|
1826
2356
|
export {
|
|
1827
2357
|
CancelledError,
|
|
2358
|
+
LeaseLostError,
|
|
1828
2359
|
createDurably,
|
|
1829
2360
|
createDurablyHandler,
|
|
2361
|
+
createKyselyStore,
|
|
1830
2362
|
defineJob,
|
|
1831
2363
|
toClientRun,
|
|
1832
2364
|
withLogPersistence
|