@coji/durably 0.11.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-fppJjkF-.d.ts → index-DWsJlgyh.d.ts} +149 -66
- package/dist/index.d.ts +11 -5
- package/dist/index.js +1000 -460
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +1 -1
- package/docs/llms.md +40 -20
- 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,367 +931,165 @@ function createKyselyStorage(db) {
|
|
|
561
931
|
if (!row) return null;
|
|
562
932
|
return rowToRun({ ...row, step_count: 0 });
|
|
563
933
|
},
|
|
564
|
-
async
|
|
565
|
-
const
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
index: input.index,
|
|
572
|
-
status: input.status,
|
|
573
|
-
output: input.output !== void 0 ? JSON.stringify(input.output) : null,
|
|
574
|
-
error: input.error ?? null,
|
|
575
|
-
started_at: input.startedAt,
|
|
576
|
-
completed_at: completedAt
|
|
577
|
-
};
|
|
578
|
-
await db.insertInto("durably_steps").values(step).execute();
|
|
579
|
-
return rowToStep(step);
|
|
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;
|
|
580
941
|
},
|
|
581
|
-
async
|
|
582
|
-
const
|
|
583
|
-
|
|
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);
|
|
584
950
|
},
|
|
585
|
-
async
|
|
586
|
-
|
|
587
|
-
|
|
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
|
+
});
|
|
588
957
|
},
|
|
589
|
-
async
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
run_id: input.runId,
|
|
595
|
-
step_name: input.stepName,
|
|
596
|
-
level: input.level,
|
|
597
|
-
message: input.message,
|
|
598
|
-
data: input.data !== void 0 ? JSON.stringify(input.data) : null,
|
|
599
|
-
created_at: now
|
|
600
|
-
};
|
|
601
|
-
await db.insertInto("durably_logs").values(log).execute();
|
|
602
|
-
return rowToLog(log);
|
|
958
|
+
async failRun(runId, leaseGeneration, error, completedAt) {
|
|
959
|
+
return terminateRun(runId, leaseGeneration, completedAt, {
|
|
960
|
+
status: "failed",
|
|
961
|
+
error
|
|
962
|
+
});
|
|
603
963
|
},
|
|
604
|
-
async
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
// src/errors.ts
|
|
615
|
-
var CancelledError = class extends Error {
|
|
616
|
-
constructor(runId) {
|
|
617
|
-
super(`Run was cancelled: ${runId}`);
|
|
618
|
-
this.name = "CancelledError";
|
|
619
|
-
}
|
|
620
|
-
};
|
|
621
|
-
function getErrorMessage(error) {
|
|
622
|
-
return error instanceof Error ? error.message : String(error);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// src/context.ts
|
|
626
|
-
function createStepContext(run, jobName, storage, eventEmitter) {
|
|
627
|
-
let stepIndex = run.currentStepIndex;
|
|
628
|
-
let currentStepName = null;
|
|
629
|
-
const controller = new AbortController();
|
|
630
|
-
const unsubscribe = eventEmitter.on("run:cancel", (event) => {
|
|
631
|
-
if (event.runId === run.id) {
|
|
632
|
-
controller.abort();
|
|
633
|
-
}
|
|
634
|
-
});
|
|
635
|
-
const step = {
|
|
636
|
-
get runId() {
|
|
637
|
-
return run.id;
|
|
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;
|
|
638
973
|
},
|
|
639
|
-
async
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
const startTime = Date.now();
|
|
659
|
-
eventEmitter.emit({
|
|
660
|
-
type: "step:start",
|
|
661
|
-
runId: run.id,
|
|
662
|
-
jobName,
|
|
663
|
-
stepName: name,
|
|
664
|
-
stepIndex,
|
|
665
|
-
labels: run.labels
|
|
666
|
-
});
|
|
667
|
-
try {
|
|
668
|
-
const result = await fn(controller.signal);
|
|
669
|
-
await storage.createStep({
|
|
670
|
-
runId: run.id,
|
|
671
|
-
name,
|
|
672
|
-
index: stepIndex,
|
|
673
|
-
status: "completed",
|
|
674
|
-
output: result,
|
|
675
|
-
startedAt
|
|
676
|
-
});
|
|
677
|
-
stepIndex++;
|
|
678
|
-
await storage.updateRun(run.id, { currentStepIndex: stepIndex });
|
|
679
|
-
eventEmitter.emit({
|
|
680
|
-
type: "step:complete",
|
|
681
|
-
runId: run.id,
|
|
682
|
-
jobName,
|
|
683
|
-
stepName: name,
|
|
684
|
-
stepIndex: stepIndex - 1,
|
|
685
|
-
output: result,
|
|
686
|
-
duration: Date.now() - startTime,
|
|
687
|
-
labels: run.labels
|
|
688
|
-
});
|
|
689
|
-
return result;
|
|
690
|
-
} catch (error) {
|
|
691
|
-
const isCancelled = controller.signal.aborted;
|
|
692
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
693
|
-
await storage.createStep({
|
|
694
|
-
runId: run.id,
|
|
695
|
-
name,
|
|
696
|
-
index: stepIndex,
|
|
697
|
-
status: isCancelled ? "cancelled" : "failed",
|
|
698
|
-
error: errorMessage,
|
|
699
|
-
startedAt
|
|
700
|
-
});
|
|
701
|
-
eventEmitter.emit({
|
|
702
|
-
...isCancelled ? { type: "step:cancel" } : { type: "step:fail", error: errorMessage },
|
|
703
|
-
runId: run.id,
|
|
704
|
-
jobName,
|
|
705
|
-
stepName: name,
|
|
706
|
-
stepIndex,
|
|
707
|
-
labels: run.labels
|
|
708
|
-
});
|
|
709
|
-
if (isCancelled) {
|
|
710
|
-
throw new CancelledError(run.id);
|
|
974
|
+
async persistStep(runId, leaseGeneration, input) {
|
|
975
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
976
|
+
const id = ulid();
|
|
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();
|
|
711
993
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
jobName,
|
|
724
|
-
progress: progressData,
|
|
725
|
-
labels: run.labels
|
|
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
|
+
};
|
|
726
1005
|
});
|
|
727
1006
|
},
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
}
|
|
1007
|
+
async deleteSteps(runId) {
|
|
1008
|
+
await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
|
|
1009
|
+
await db.deleteFrom("durably_logs").where("run_id", "=", runId).execute();
|
|
1010
|
+
},
|
|
1011
|
+
async getSteps(runId) {
|
|
1012
|
+
const rows = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).orderBy("index", "asc").execute();
|
|
1013
|
+
return rows.map(rowToStep);
|
|
1014
|
+
},
|
|
1015
|
+
async getCompletedStep(runId, name) {
|
|
1016
|
+
const row = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).where("name", "=", name).where("status", "=", "completed").executeTakeFirst();
|
|
1017
|
+
return row ? rowToStep(row) : null;
|
|
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
|
+
},
|
|
1025
|
+
async createLog(input) {
|
|
1026
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1027
|
+
const id = ulid();
|
|
1028
|
+
const log = {
|
|
1029
|
+
id,
|
|
1030
|
+
run_id: input.runId,
|
|
1031
|
+
step_name: input.stepName,
|
|
1032
|
+
level: input.level,
|
|
1033
|
+
message: input.message,
|
|
1034
|
+
data: input.data !== void 0 ? JSON.stringify(input.data) : null,
|
|
1035
|
+
created_at: now
|
|
1036
|
+
};
|
|
1037
|
+
await db.insertInto("durably_logs").values(log).execute();
|
|
1038
|
+
return rowToLog(log);
|
|
1039
|
+
},
|
|
1040
|
+
async getLogs(runId) {
|
|
1041
|
+
const rows = await db.selectFrom("durably_logs").selectAll().where("run_id", "=", runId).orderBy("created_at", "asc").execute();
|
|
1042
|
+
return rows.map(rowToLog);
|
|
765
1043
|
}
|
|
766
1044
|
};
|
|
767
|
-
|
|
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));
|
|
1065
|
+
}
|
|
1066
|
+
return store;
|
|
768
1067
|
}
|
|
769
1068
|
|
|
770
1069
|
// src/worker.ts
|
|
771
|
-
function createWorker(config,
|
|
1070
|
+
function createWorker(config, processOne) {
|
|
772
1071
|
let running = false;
|
|
773
|
-
let currentRunPromise = null;
|
|
774
1072
|
let pollingTimeout = null;
|
|
1073
|
+
let inFlight = null;
|
|
775
1074
|
let stopResolver = null;
|
|
776
|
-
let
|
|
777
|
-
let currentRunId = null;
|
|
778
|
-
async function recoverStaleRuns() {
|
|
779
|
-
const staleThreshold = new Date(
|
|
780
|
-
Date.now() - config.staleThreshold
|
|
781
|
-
).toISOString();
|
|
782
|
-
const runningRuns = await storage.getRuns({ status: "running" });
|
|
783
|
-
for (const run of runningRuns) {
|
|
784
|
-
if (run.heartbeatAt < staleThreshold) {
|
|
785
|
-
await storage.updateRun(run.id, {
|
|
786
|
-
status: "pending"
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
async function updateHeartbeat() {
|
|
792
|
-
if (currentRunId) {
|
|
793
|
-
await storage.updateRun(currentRunId, {
|
|
794
|
-
heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
async function handleRunSuccess(runId, jobName, output, startTime) {
|
|
799
|
-
const currentRun = await storage.getRun(runId);
|
|
800
|
-
if (!currentRun || currentRun.status === "cancelled") {
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
await storage.updateRun(runId, {
|
|
804
|
-
status: "completed",
|
|
805
|
-
output,
|
|
806
|
-
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
807
|
-
});
|
|
808
|
-
eventEmitter.emit({
|
|
809
|
-
type: "run:complete",
|
|
810
|
-
runId,
|
|
811
|
-
jobName,
|
|
812
|
-
output,
|
|
813
|
-
duration: Date.now() - startTime,
|
|
814
|
-
labels: currentRun.labels
|
|
815
|
-
});
|
|
816
|
-
}
|
|
817
|
-
async function handleRunFailure(runId, jobName, error) {
|
|
818
|
-
if (error instanceof CancelledError) {
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
const currentRun = await storage.getRun(runId);
|
|
822
|
-
if (!currentRun || currentRun.status === "cancelled") {
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
const errorMessage = getErrorMessage(error);
|
|
826
|
-
const steps = await storage.getSteps(runId);
|
|
827
|
-
const failedStep = steps.find((s) => s.status === "failed");
|
|
828
|
-
await storage.updateRun(runId, {
|
|
829
|
-
status: "failed",
|
|
830
|
-
error: errorMessage,
|
|
831
|
-
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
832
|
-
});
|
|
833
|
-
eventEmitter.emit({
|
|
834
|
-
type: "run:fail",
|
|
835
|
-
runId,
|
|
836
|
-
jobName,
|
|
837
|
-
error: errorMessage,
|
|
838
|
-
failedStepName: failedStep?.name ?? "unknown",
|
|
839
|
-
labels: currentRun.labels
|
|
840
|
-
});
|
|
841
|
-
}
|
|
842
|
-
async function executeRun(run, job) {
|
|
843
|
-
currentRunId = run.id;
|
|
844
|
-
heartbeatInterval = setInterval(() => {
|
|
845
|
-
updateHeartbeat().catch((error) => {
|
|
846
|
-
eventEmitter.emit({
|
|
847
|
-
type: "worker:error",
|
|
848
|
-
error: getErrorMessage(error),
|
|
849
|
-
context: "heartbeat",
|
|
850
|
-
runId: run.id
|
|
851
|
-
});
|
|
852
|
-
});
|
|
853
|
-
}, config.heartbeatInterval);
|
|
854
|
-
eventEmitter.emit({
|
|
855
|
-
type: "run:start",
|
|
856
|
-
runId: run.id,
|
|
857
|
-
jobName: run.jobName,
|
|
858
|
-
input: run.input,
|
|
859
|
-
labels: run.labels
|
|
860
|
-
});
|
|
861
|
-
const startTime = Date.now();
|
|
862
|
-
const { step, dispose } = createStepContext(
|
|
863
|
-
run,
|
|
864
|
-
run.jobName,
|
|
865
|
-
storage,
|
|
866
|
-
eventEmitter
|
|
867
|
-
);
|
|
868
|
-
try {
|
|
869
|
-
const output = await job.fn(step, run.input);
|
|
870
|
-
if (job.outputSchema) {
|
|
871
|
-
const parseResult = job.outputSchema.safeParse(output);
|
|
872
|
-
if (!parseResult.success) {
|
|
873
|
-
throw new Error(`Invalid output: ${prettifyError2(parseResult.error)}`);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
await handleRunSuccess(run.id, run.jobName, output, startTime);
|
|
877
|
-
} catch (error) {
|
|
878
|
-
await handleRunFailure(run.id, run.jobName, error);
|
|
879
|
-
} finally {
|
|
880
|
-
dispose();
|
|
881
|
-
if (heartbeatInterval) {
|
|
882
|
-
clearInterval(heartbeatInterval);
|
|
883
|
-
heartbeatInterval = null;
|
|
884
|
-
}
|
|
885
|
-
currentRunId = null;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
async function processNextRun() {
|
|
889
|
-
const runningRuns = await storage.getRuns({ status: "running" });
|
|
890
|
-
const excludeConcurrencyKeys = runningRuns.filter(
|
|
891
|
-
(r) => r.concurrencyKey !== null
|
|
892
|
-
).map((r) => r.concurrencyKey);
|
|
893
|
-
const run = await storage.claimNextPendingRun(excludeConcurrencyKeys);
|
|
894
|
-
if (!run) {
|
|
895
|
-
return false;
|
|
896
|
-
}
|
|
897
|
-
const job = jobRegistry.get(run.jobName);
|
|
898
|
-
if (!job) {
|
|
899
|
-
await storage.updateRun(run.id, {
|
|
900
|
-
status: "failed",
|
|
901
|
-
error: `Unknown job: ${run.jobName}`
|
|
902
|
-
});
|
|
903
|
-
return true;
|
|
904
|
-
}
|
|
905
|
-
await executeRun(run, job);
|
|
906
|
-
return true;
|
|
907
|
-
}
|
|
1075
|
+
let activeWorkerId;
|
|
908
1076
|
async function poll() {
|
|
909
1077
|
if (!running) {
|
|
910
1078
|
return;
|
|
911
1079
|
}
|
|
912
|
-
const doWork = async () => {
|
|
913
|
-
await recoverStaleRuns();
|
|
914
|
-
await processNextRun();
|
|
915
|
-
};
|
|
916
1080
|
try {
|
|
917
|
-
|
|
918
|
-
await
|
|
1081
|
+
inFlight = processOne({ workerId: activeWorkerId }).then(() => void 0);
|
|
1082
|
+
await inFlight;
|
|
919
1083
|
} finally {
|
|
920
|
-
|
|
1084
|
+
inFlight = null;
|
|
921
1085
|
}
|
|
922
1086
|
if (running) {
|
|
923
|
-
pollingTimeout = setTimeout(() =>
|
|
924
|
-
|
|
1087
|
+
pollingTimeout = setTimeout(() => {
|
|
1088
|
+
void poll();
|
|
1089
|
+
}, config.pollingIntervalMs);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (stopResolver) {
|
|
925
1093
|
stopResolver();
|
|
926
1094
|
stopResolver = null;
|
|
927
1095
|
}
|
|
@@ -930,12 +1098,13 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
|
930
1098
|
get isRunning() {
|
|
931
1099
|
return running;
|
|
932
1100
|
},
|
|
933
|
-
start() {
|
|
1101
|
+
start(options) {
|
|
934
1102
|
if (running) {
|
|
935
1103
|
return;
|
|
936
1104
|
}
|
|
1105
|
+
activeWorkerId = options?.workerId;
|
|
937
1106
|
running = true;
|
|
938
|
-
poll();
|
|
1107
|
+
void poll();
|
|
939
1108
|
},
|
|
940
1109
|
async stop() {
|
|
941
1110
|
if (!running) {
|
|
@@ -946,11 +1115,7 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
|
946
1115
|
clearTimeout(pollingTimeout);
|
|
947
1116
|
pollingTimeout = null;
|
|
948
1117
|
}
|
|
949
|
-
if (
|
|
950
|
-
clearInterval(heartbeatInterval);
|
|
951
|
-
heartbeatInterval = null;
|
|
952
|
-
}
|
|
953
|
-
if (currentRunPromise) {
|
|
1118
|
+
if (inFlight) {
|
|
954
1119
|
return new Promise((resolve) => {
|
|
955
1120
|
stopResolver = resolve;
|
|
956
1121
|
});
|
|
@@ -961,12 +1126,249 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
|
961
1126
|
|
|
962
1127
|
// src/durably.ts
|
|
963
1128
|
var DEFAULTS = {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1129
|
+
pollingIntervalMs: 1e3,
|
|
1130
|
+
leaseRenewIntervalMs: 5e3,
|
|
1131
|
+
leaseMs: 3e4,
|
|
1132
|
+
preserveSteps: false
|
|
967
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
|
+
}
|
|
968
1204
|
function createDurablyInstance(state, jobs) {
|
|
969
|
-
const {
|
|
1205
|
+
const {
|
|
1206
|
+
db,
|
|
1207
|
+
storage,
|
|
1208
|
+
eventEmitter,
|
|
1209
|
+
jobRegistry,
|
|
1210
|
+
worker,
|
|
1211
|
+
releaseBrowserSingleton
|
|
1212
|
+
} = state;
|
|
1213
|
+
async function getRunOrThrow(runId) {
|
|
1214
|
+
const run = await storage.getRun(runId);
|
|
1215
|
+
if (!run) {
|
|
1216
|
+
throw new Error(`Run not found: ${runId}`);
|
|
1217
|
+
}
|
|
1218
|
+
return run;
|
|
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
|
+
}
|
|
970
1372
|
const durably = {
|
|
971
1373
|
db,
|
|
972
1374
|
storage,
|
|
@@ -975,7 +1377,10 @@ function createDurablyInstance(state, jobs) {
|
|
|
975
1377
|
emit: eventEmitter.emit,
|
|
976
1378
|
onError: eventEmitter.onError,
|
|
977
1379
|
start: worker.start,
|
|
978
|
-
stop
|
|
1380
|
+
async stop() {
|
|
1381
|
+
releaseBrowserSingleton();
|
|
1382
|
+
await worker.stop();
|
|
1383
|
+
},
|
|
979
1384
|
// biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions
|
|
980
1385
|
register(jobDefs) {
|
|
981
1386
|
const newHandles = {};
|
|
@@ -996,8 +1401,8 @@ function createDurablyInstance(state, jobs) {
|
|
|
996
1401
|
mergedJobs
|
|
997
1402
|
);
|
|
998
1403
|
},
|
|
999
|
-
getRun: storage.getRun,
|
|
1000
|
-
getRuns: storage.getRuns,
|
|
1404
|
+
getRun: storage.getRun.bind(storage),
|
|
1405
|
+
getRuns: storage.getRuns.bind(storage),
|
|
1001
1406
|
use(plugin) {
|
|
1002
1407
|
plugin.install(durably);
|
|
1003
1408
|
},
|
|
@@ -1011,14 +1416,18 @@ function createDurablyInstance(state, jobs) {
|
|
|
1011
1416
|
subscribe(runId) {
|
|
1012
1417
|
let closed = false;
|
|
1013
1418
|
let cleanup = null;
|
|
1014
|
-
const closeEvents = /* @__PURE__ */ new Set([
|
|
1419
|
+
const closeEvents = /* @__PURE__ */ new Set([
|
|
1420
|
+
"run:complete",
|
|
1421
|
+
"run:fail",
|
|
1422
|
+
"run:cancel",
|
|
1423
|
+
"run:delete"
|
|
1424
|
+
]);
|
|
1015
1425
|
const subscribedEvents = [
|
|
1016
|
-
"run:
|
|
1426
|
+
"run:leased",
|
|
1017
1427
|
"run:complete",
|
|
1018
1428
|
"run:fail",
|
|
1019
1429
|
"run:cancel",
|
|
1020
1430
|
"run:delete",
|
|
1021
|
-
"run:retry",
|
|
1022
1431
|
"run:progress",
|
|
1023
1432
|
"step:start",
|
|
1024
1433
|
"step:complete",
|
|
@@ -1041,6 +1450,64 @@ function createDurablyInstance(state, jobs) {
|
|
|
1041
1450
|
cleanup = () => {
|
|
1042
1451
|
for (const unsub of unsubscribes) unsub();
|
|
1043
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
|
+
});
|
|
1044
1511
|
},
|
|
1045
1512
|
cancel: () => {
|
|
1046
1513
|
if (!closed) {
|
|
@@ -1050,36 +1517,40 @@ function createDurablyInstance(state, jobs) {
|
|
|
1050
1517
|
}
|
|
1051
1518
|
});
|
|
1052
1519
|
},
|
|
1053
|
-
async
|
|
1054
|
-
const run = await
|
|
1055
|
-
if (!run) {
|
|
1056
|
-
throw new Error(`Run not found: ${runId}`);
|
|
1057
|
-
}
|
|
1058
|
-
if (run.status === "completed") {
|
|
1059
|
-
throw new Error(`Cannot retry completed run: ${runId}`);
|
|
1060
|
-
}
|
|
1520
|
+
async retrigger(runId) {
|
|
1521
|
+
const run = await getRunOrThrow(runId);
|
|
1061
1522
|
if (run.status === "pending") {
|
|
1062
|
-
throw new Error(`Cannot
|
|
1523
|
+
throw new Error(`Cannot retrigger pending run: ${runId}`);
|
|
1063
1524
|
}
|
|
1064
|
-
if (run.status === "
|
|
1065
|
-
throw new Error(`Cannot
|
|
1525
|
+
if (run.status === "leased") {
|
|
1526
|
+
throw new Error(`Cannot retrigger leased run: ${runId}`);
|
|
1066
1527
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1528
|
+
const job = jobRegistry.get(run.jobName);
|
|
1529
|
+
if (!job) {
|
|
1530
|
+
throw new Error(`Unknown job: ${run.jobName}`);
|
|
1531
|
+
}
|
|
1532
|
+
const validatedInput = validateJobInputOrThrow(
|
|
1533
|
+
job.inputSchema,
|
|
1534
|
+
run.input,
|
|
1535
|
+
`Cannot retrigger run ${runId}`
|
|
1536
|
+
);
|
|
1537
|
+
const nextRun = await storage.enqueue({
|
|
1538
|
+
jobName: run.jobName,
|
|
1539
|
+
input: validatedInput,
|
|
1540
|
+
concurrencyKey: run.concurrencyKey ?? void 0,
|
|
1541
|
+
labels: run.labels
|
|
1070
1542
|
});
|
|
1071
1543
|
eventEmitter.emit({
|
|
1072
|
-
type: "run:
|
|
1073
|
-
runId,
|
|
1544
|
+
type: "run:trigger",
|
|
1545
|
+
runId: nextRun.id,
|
|
1074
1546
|
jobName: run.jobName,
|
|
1547
|
+
input: validatedInput,
|
|
1075
1548
|
labels: run.labels
|
|
1076
1549
|
});
|
|
1550
|
+
return nextRun;
|
|
1077
1551
|
},
|
|
1078
1552
|
async cancel(runId) {
|
|
1079
|
-
const run = await
|
|
1080
|
-
if (!run) {
|
|
1081
|
-
throw new Error(`Run not found: ${runId}`);
|
|
1082
|
-
}
|
|
1553
|
+
const run = await getRunOrThrow(runId);
|
|
1083
1554
|
if (run.status === "completed") {
|
|
1084
1555
|
throw new Error(`Cannot cancel completed run: ${runId}`);
|
|
1085
1556
|
}
|
|
@@ -1089,9 +1560,17 @@ function createDurablyInstance(state, jobs) {
|
|
|
1089
1560
|
if (run.status === "cancelled") {
|
|
1090
1561
|
throw new Error(`Cannot cancel already cancelled run: ${runId}`);
|
|
1091
1562
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1563
|
+
const wasPending = run.status === "pending";
|
|
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) {
|
|
1572
|
+
await storage.deleteSteps(runId);
|
|
1573
|
+
}
|
|
1095
1574
|
eventEmitter.emit({
|
|
1096
1575
|
type: "run:cancel",
|
|
1097
1576
|
runId,
|
|
@@ -1100,15 +1579,12 @@ function createDurablyInstance(state, jobs) {
|
|
|
1100
1579
|
});
|
|
1101
1580
|
},
|
|
1102
1581
|
async deleteRun(runId) {
|
|
1103
|
-
const run = await
|
|
1104
|
-
if (!run) {
|
|
1105
|
-
throw new Error(`Run not found: ${runId}`);
|
|
1106
|
-
}
|
|
1582
|
+
const run = await getRunOrThrow(runId);
|
|
1107
1583
|
if (run.status === "pending") {
|
|
1108
1584
|
throw new Error(`Cannot delete pending run: ${runId}`);
|
|
1109
1585
|
}
|
|
1110
|
-
if (run.status === "
|
|
1111
|
-
throw new Error(`Cannot delete
|
|
1586
|
+
if (run.status === "leased") {
|
|
1587
|
+
throw new Error(`Cannot delete leased run: ${runId}`);
|
|
1112
1588
|
}
|
|
1113
1589
|
await storage.deleteRun(runId);
|
|
1114
1590
|
eventEmitter.emit({
|
|
@@ -1118,6 +1594,48 @@ function createDurablyInstance(state, jobs) {
|
|
|
1118
1594
|
labels: run.labels
|
|
1119
1595
|
});
|
|
1120
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
|
+
},
|
|
1121
1639
|
async migrate() {
|
|
1122
1640
|
if (state.migrated) {
|
|
1123
1641
|
return;
|
|
@@ -1141,15 +1659,38 @@ function createDurablyInstance(state, jobs) {
|
|
|
1141
1659
|
}
|
|
1142
1660
|
function createDurably(options) {
|
|
1143
1661
|
const config = {
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
|
1147
1667
|
};
|
|
1148
1668
|
const db = new Kysely({ dialect: options.dialect });
|
|
1149
|
-
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
|
+
});
|
|
1150
1682
|
const eventEmitter = createEventEmitter();
|
|
1151
1683
|
const jobRegistry = createJobRegistry();
|
|
1152
|
-
|
|
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
|
+
);
|
|
1153
1694
|
const state = {
|
|
1154
1695
|
db,
|
|
1155
1696
|
storage,
|
|
@@ -1157,13 +1698,20 @@ function createDurably(options) {
|
|
|
1157
1698
|
jobRegistry,
|
|
1158
1699
|
worker,
|
|
1159
1700
|
labelsSchema: options.labels,
|
|
1701
|
+
preserveSteps: config.preserveSteps,
|
|
1160
1702
|
migrating: null,
|
|
1161
|
-
migrated: false
|
|
1703
|
+
migrated: false,
|
|
1704
|
+
leaseMs: config.leaseMs,
|
|
1705
|
+
leaseRenewIntervalMs: config.leaseRenewIntervalMs,
|
|
1706
|
+
retainRunsMs: config.retainRunsMs,
|
|
1707
|
+
lastPurgeAt: 0,
|
|
1708
|
+
releaseBrowserSingleton
|
|
1162
1709
|
};
|
|
1163
1710
|
const instance = createDurablyInstance(
|
|
1164
1711
|
state,
|
|
1165
1712
|
{}
|
|
1166
1713
|
);
|
|
1714
|
+
processOneImpl = instance.processOne;
|
|
1167
1715
|
if (options.jobs) {
|
|
1168
1716
|
return instance.register(options.jobs);
|
|
1169
1717
|
}
|
|
@@ -1408,7 +1956,7 @@ function createThrottledSSEController(inner, throttleMs) {
|
|
|
1408
1956
|
// src/server.ts
|
|
1409
1957
|
var VALID_STATUSES = [
|
|
1410
1958
|
"pending",
|
|
1411
|
-
"
|
|
1959
|
+
"leased",
|
|
1412
1960
|
"completed",
|
|
1413
1961
|
"failed",
|
|
1414
1962
|
"cancelled"
|
|
@@ -1564,12 +2112,12 @@ function createDurablyHandler(durably, options) {
|
|
|
1564
2112
|
return jsonResponse(steps);
|
|
1565
2113
|
});
|
|
1566
2114
|
}
|
|
1567
|
-
async function
|
|
2115
|
+
async function handleRetrigger(url, ctx) {
|
|
1568
2116
|
return withErrorHandling(async () => {
|
|
1569
|
-
const result = await requireRunAccess(url, ctx, "
|
|
2117
|
+
const result = await requireRunAccess(url, ctx, "retrigger");
|
|
1570
2118
|
if (result instanceof Response) return result;
|
|
1571
|
-
await durably.
|
|
1572
|
-
return
|
|
2119
|
+
const run = await durably.retrigger(result.runId);
|
|
2120
|
+
return jsonResponse({ success: true, runId: run.id });
|
|
1573
2121
|
});
|
|
1574
2122
|
}
|
|
1575
2123
|
async function handleCancel(url, ctx) {
|
|
@@ -1638,10 +2186,10 @@ function createDurablyHandler(durably, options) {
|
|
|
1638
2186
|
});
|
|
1639
2187
|
}
|
|
1640
2188
|
}),
|
|
1641
|
-
durably.on("run:
|
|
2189
|
+
durably.on("run:leased", (event) => {
|
|
1642
2190
|
if (matchesFilter(event.jobName, event.labels)) {
|
|
1643
2191
|
ctrl.enqueue({
|
|
1644
|
-
type: "run:
|
|
2192
|
+
type: "run:leased",
|
|
1645
2193
|
runId: event.runId,
|
|
1646
2194
|
jobName: event.jobName,
|
|
1647
2195
|
labels: event.labels
|
|
@@ -1688,16 +2236,6 @@ function createDurablyHandler(durably, options) {
|
|
|
1688
2236
|
});
|
|
1689
2237
|
}
|
|
1690
2238
|
}),
|
|
1691
|
-
durably.on("run:retry", (event) => {
|
|
1692
|
-
if (matchesFilter(event.jobName, event.labels)) {
|
|
1693
|
-
ctrl.enqueue({
|
|
1694
|
-
type: "run:retry",
|
|
1695
|
-
runId: event.runId,
|
|
1696
|
-
jobName: event.jobName,
|
|
1697
|
-
labels: event.labels
|
|
1698
|
-
});
|
|
1699
|
-
}
|
|
1700
|
-
}),
|
|
1701
2239
|
durably.on("run:progress", (event) => {
|
|
1702
2240
|
if (matchesFilter(event.jobName, event.labels)) {
|
|
1703
2241
|
ctrl.enqueue({
|
|
@@ -1801,7 +2339,7 @@ function createDurablyHandler(durably, options) {
|
|
|
1801
2339
|
}
|
|
1802
2340
|
if (method === "POST") {
|
|
1803
2341
|
if (path === "/trigger") return await handleTrigger(request, ctx);
|
|
1804
|
-
if (path === "/
|
|
2342
|
+
if (path === "/retrigger") return await handleRetrigger(url, ctx);
|
|
1805
2343
|
if (path === "/cancel") return await handleCancel(url, ctx);
|
|
1806
2344
|
}
|
|
1807
2345
|
if (method === "DELETE") {
|
|
@@ -1817,8 +2355,10 @@ function createDurablyHandler(durably, options) {
|
|
|
1817
2355
|
}
|
|
1818
2356
|
export {
|
|
1819
2357
|
CancelledError,
|
|
2358
|
+
LeaseLostError,
|
|
1820
2359
|
createDurably,
|
|
1821
2360
|
createDurablyHandler,
|
|
2361
|
+
createKyselyStore,
|
|
1822
2362
|
defineJob,
|
|
1823
2363
|
toClientRun,
|
|
1824
2364
|
withLogPersistence
|