@coji/durably 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/{chunk-UCUP6NMJ.js → chunk-L42OCQEV.js} +3 -3
- package/dist/chunk-L42OCQEV.js.map +1 -0
- package/dist/{index-hM7-oiyj.d.ts → index-CDCdrLgw.d.ts} +150 -58
- package/dist/index.d.ts +29 -3
- package/dist/index.js +1120 -503
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.d.ts +1 -1
- package/dist/plugins/index.js +1 -1
- package/docs/llms.md +85 -22
- package/package.json +27 -21
- package/LICENSE +0 -21
- package/dist/chunk-UCUP6NMJ.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,9 +1,254 @@
|
|
|
1
1
|
import {
|
|
2
2
|
withLogPersistence
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-L42OCQEV.js";
|
|
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
|
+
var DurablyError = class extends Error {
|
|
23
|
+
statusCode;
|
|
24
|
+
constructor(message, statusCode) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "DurablyError";
|
|
27
|
+
this.statusCode = statusCode;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
var NotFoundError = class extends DurablyError {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message, 404);
|
|
33
|
+
this.name = "NotFoundError";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var ValidationError = class extends DurablyError {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message, 400);
|
|
39
|
+
this.name = "ValidationError";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var ConflictError = class extends DurablyError {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message, 409);
|
|
45
|
+
this.name = "ConflictError";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
function getErrorMessage(error) {
|
|
49
|
+
return error instanceof Error ? error.message : String(error);
|
|
50
|
+
}
|
|
51
|
+
function toError(error) {
|
|
52
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/context.ts
|
|
56
|
+
var LEASE_LOST = "lease-lost";
|
|
57
|
+
function createStepContext(run, jobName, leaseGeneration, storage, eventEmitter) {
|
|
58
|
+
let stepIndex = run.currentStepIndex;
|
|
59
|
+
let currentStepName = null;
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
function abortForLeaseLoss() {
|
|
62
|
+
if (!controller.signal.aborted) {
|
|
63
|
+
controller.abort(LEASE_LOST);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function throwIfAborted() {
|
|
67
|
+
if (!controller.signal.aborted) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (controller.signal.reason === LEASE_LOST) {
|
|
71
|
+
throw new LeaseLostError(run.id);
|
|
72
|
+
}
|
|
73
|
+
throw new CancelledError(run.id);
|
|
74
|
+
}
|
|
75
|
+
async function throwForRefusedStep(stepName, stepIndex2) {
|
|
76
|
+
const latestRun = await storage.getRun(run.id);
|
|
77
|
+
if (latestRun?.status === "cancelled") {
|
|
78
|
+
eventEmitter.emit({
|
|
79
|
+
type: "step:cancel",
|
|
80
|
+
runId: run.id,
|
|
81
|
+
jobName,
|
|
82
|
+
stepName,
|
|
83
|
+
stepIndex: stepIndex2,
|
|
84
|
+
labels: run.labels
|
|
85
|
+
});
|
|
86
|
+
throw new CancelledError(run.id);
|
|
87
|
+
}
|
|
88
|
+
abortForLeaseLoss();
|
|
89
|
+
throw new LeaseLostError(run.id);
|
|
90
|
+
}
|
|
91
|
+
const unsubscribe = eventEmitter.on("run:cancel", (event) => {
|
|
92
|
+
if (event.runId === run.id) {
|
|
93
|
+
controller.abort();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
const step = {
|
|
97
|
+
get runId() {
|
|
98
|
+
return run.id;
|
|
99
|
+
},
|
|
100
|
+
get signal() {
|
|
101
|
+
return controller.signal;
|
|
102
|
+
},
|
|
103
|
+
isAborted() {
|
|
104
|
+
return controller.signal.aborted;
|
|
105
|
+
},
|
|
106
|
+
throwIfAborted() {
|
|
107
|
+
throwIfAborted();
|
|
108
|
+
},
|
|
109
|
+
async run(name, fn) {
|
|
110
|
+
throwIfAborted();
|
|
111
|
+
const currentRun = await storage.getRun(run.id);
|
|
112
|
+
if (currentRun?.status === "cancelled") {
|
|
113
|
+
controller.abort();
|
|
114
|
+
throwIfAborted();
|
|
115
|
+
}
|
|
116
|
+
if (currentRun && (currentRun.status === "leased" && currentRun.leaseGeneration !== leaseGeneration || currentRun.status === "completed" || currentRun.status === "failed")) {
|
|
117
|
+
abortForLeaseLoss();
|
|
118
|
+
throwIfAborted();
|
|
119
|
+
}
|
|
120
|
+
throwIfAborted();
|
|
121
|
+
const existingStep = await storage.getCompletedStep(run.id, name);
|
|
122
|
+
if (existingStep) {
|
|
123
|
+
stepIndex++;
|
|
124
|
+
return existingStep.output;
|
|
125
|
+
}
|
|
126
|
+
currentStepName = name;
|
|
127
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
128
|
+
const startTime = Date.now();
|
|
129
|
+
eventEmitter.emit({
|
|
130
|
+
type: "step:start",
|
|
131
|
+
runId: run.id,
|
|
132
|
+
jobName,
|
|
133
|
+
stepName: name,
|
|
134
|
+
stepIndex,
|
|
135
|
+
labels: run.labels
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
const result = await fn(controller.signal);
|
|
139
|
+
throwIfAborted();
|
|
140
|
+
const savedStep = await storage.persistStep(run.id, leaseGeneration, {
|
|
141
|
+
name,
|
|
142
|
+
index: stepIndex,
|
|
143
|
+
status: "completed",
|
|
144
|
+
output: result,
|
|
145
|
+
startedAt
|
|
146
|
+
});
|
|
147
|
+
if (!savedStep) {
|
|
148
|
+
await throwForRefusedStep(name, stepIndex);
|
|
149
|
+
}
|
|
150
|
+
stepIndex++;
|
|
151
|
+
eventEmitter.emit({
|
|
152
|
+
type: "step:complete",
|
|
153
|
+
runId: run.id,
|
|
154
|
+
jobName,
|
|
155
|
+
stepName: name,
|
|
156
|
+
stepIndex: stepIndex - 1,
|
|
157
|
+
output: result,
|
|
158
|
+
duration: Date.now() - startTime,
|
|
159
|
+
labels: run.labels
|
|
160
|
+
});
|
|
161
|
+
return result;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error instanceof LeaseLostError) {
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
const isLeaseLost = controller.signal.aborted && controller.signal.reason === LEASE_LOST;
|
|
167
|
+
if (isLeaseLost) {
|
|
168
|
+
throw new LeaseLostError(run.id);
|
|
169
|
+
}
|
|
170
|
+
const isCancelled = controller.signal.aborted;
|
|
171
|
+
const errorMessage = getErrorMessage(error);
|
|
172
|
+
const savedStep = await storage.persistStep(run.id, leaseGeneration, {
|
|
173
|
+
name,
|
|
174
|
+
index: stepIndex,
|
|
175
|
+
status: isCancelled ? "cancelled" : "failed",
|
|
176
|
+
error: errorMessage,
|
|
177
|
+
startedAt
|
|
178
|
+
});
|
|
179
|
+
if (!savedStep) {
|
|
180
|
+
await throwForRefusedStep(name, stepIndex);
|
|
181
|
+
}
|
|
182
|
+
eventEmitter.emit({
|
|
183
|
+
type: "step:fail",
|
|
184
|
+
error: errorMessage,
|
|
185
|
+
runId: run.id,
|
|
186
|
+
jobName,
|
|
187
|
+
stepName: name,
|
|
188
|
+
stepIndex,
|
|
189
|
+
labels: run.labels
|
|
190
|
+
});
|
|
191
|
+
throw error;
|
|
192
|
+
} finally {
|
|
193
|
+
currentStepName = null;
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
progress(current, total, message) {
|
|
197
|
+
const progressData = { current, total, message };
|
|
198
|
+
storage.updateProgress(run.id, leaseGeneration, progressData);
|
|
199
|
+
eventEmitter.emit({
|
|
200
|
+
type: "run:progress",
|
|
201
|
+
runId: run.id,
|
|
202
|
+
jobName,
|
|
203
|
+
progress: progressData,
|
|
204
|
+
labels: run.labels
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
log: {
|
|
208
|
+
info(message, data) {
|
|
209
|
+
eventEmitter.emit({
|
|
210
|
+
type: "log:write",
|
|
211
|
+
runId: run.id,
|
|
212
|
+
jobName,
|
|
213
|
+
labels: run.labels,
|
|
214
|
+
stepName: currentStepName,
|
|
215
|
+
level: "info",
|
|
216
|
+
message,
|
|
217
|
+
data
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
warn(message, data) {
|
|
221
|
+
eventEmitter.emit({
|
|
222
|
+
type: "log:write",
|
|
223
|
+
runId: run.id,
|
|
224
|
+
jobName,
|
|
225
|
+
labels: run.labels,
|
|
226
|
+
stepName: currentStepName,
|
|
227
|
+
level: "warn",
|
|
228
|
+
message,
|
|
229
|
+
data
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
error(message, data) {
|
|
233
|
+
eventEmitter.emit({
|
|
234
|
+
type: "log:write",
|
|
235
|
+
runId: run.id,
|
|
236
|
+
jobName,
|
|
237
|
+
labels: run.labels,
|
|
238
|
+
stepName: currentStepName,
|
|
239
|
+
level: "error",
|
|
240
|
+
message,
|
|
241
|
+
data
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
step,
|
|
248
|
+
abortLeaseOwnership: abortForLeaseLoss,
|
|
249
|
+
dispose: unsubscribe
|
|
250
|
+
};
|
|
251
|
+
}
|
|
7
252
|
|
|
8
253
|
// src/events.ts
|
|
9
254
|
function createEventEmitter() {
|
|
@@ -35,16 +280,16 @@ function createEventEmitter() {
|
|
|
35
280
|
if (!typeListeners) {
|
|
36
281
|
return;
|
|
37
282
|
}
|
|
283
|
+
const reportError = (error) => errorHandler?.(toError(error), fullEvent);
|
|
38
284
|
for (const listener of typeListeners) {
|
|
39
285
|
try {
|
|
40
|
-
listener(fullEvent);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
error instanceof Error ? error : new Error(String(error)),
|
|
45
|
-
fullEvent
|
|
46
|
-
);
|
|
286
|
+
const result = listener(fullEvent);
|
|
287
|
+
if (result != null && typeof result.then === "function") {
|
|
288
|
+
;
|
|
289
|
+
result.catch(reportError);
|
|
47
290
|
}
|
|
291
|
+
} catch (error) {
|
|
292
|
+
reportError(error);
|
|
48
293
|
}
|
|
49
294
|
}
|
|
50
295
|
}
|
|
@@ -59,7 +304,9 @@ function validateJobInputOrThrow(schema, input, context) {
|
|
|
59
304
|
const result = schema.safeParse(input);
|
|
60
305
|
if (!result.success) {
|
|
61
306
|
const prefix = context ? `${context}: ` : "";
|
|
62
|
-
throw new
|
|
307
|
+
throw new ValidationError(
|
|
308
|
+
`${prefix}Invalid input: ${prettifyError(result.error)}`
|
|
309
|
+
);
|
|
63
310
|
}
|
|
64
311
|
return result.data;
|
|
65
312
|
}
|
|
@@ -96,7 +343,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
|
|
|
96
343
|
if (labelsSchema && options?.labels) {
|
|
97
344
|
validateJobInputOrThrow(labelsSchema, options.labels, "labels");
|
|
98
345
|
}
|
|
99
|
-
const run = await storage.
|
|
346
|
+
const run = await storage.enqueue({
|
|
100
347
|
jobName: jobDef.name,
|
|
101
348
|
input: validatedInput,
|
|
102
349
|
idempotencyKey: options?.idempotencyKey,
|
|
@@ -183,7 +430,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
|
|
|
183
430
|
}).catch((error) => {
|
|
184
431
|
if (resolved) return;
|
|
185
432
|
cleanup();
|
|
186
|
-
reject(
|
|
433
|
+
reject(toError(error));
|
|
187
434
|
});
|
|
188
435
|
if (options?.timeout !== void 0) {
|
|
189
436
|
timeoutId = setTimeout(() => {
|
|
@@ -226,7 +473,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
|
|
|
226
473
|
options: normalized[i].options
|
|
227
474
|
});
|
|
228
475
|
}
|
|
229
|
-
const runs = await storage.
|
|
476
|
+
const runs = await storage.enqueueMany(
|
|
230
477
|
validated.map((v) => ({
|
|
231
478
|
jobName: jobDef.name,
|
|
232
479
|
input: v.input,
|
|
@@ -274,6 +521,7 @@ function createJobHandle(jobDef, storage, eventEmitter, registry, labelsSchema)
|
|
|
274
521
|
}
|
|
275
522
|
|
|
276
523
|
// src/migrations.ts
|
|
524
|
+
import { sql } from "kysely";
|
|
277
525
|
var migrations = [
|
|
278
526
|
{
|
|
279
527
|
version: 1,
|
|
@@ -282,12 +530,30 @@ var migrations = [
|
|
|
282
530
|
"current_step_index",
|
|
283
531
|
"integer",
|
|
284
532
|
(col) => col.notNull().defaultTo(0)
|
|
285
|
-
).addColumn(
|
|
533
|
+
).addColumn(
|
|
534
|
+
"completed_step_count",
|
|
535
|
+
"integer",
|
|
536
|
+
(col) => col.notNull().defaultTo(0)
|
|
537
|
+
).addColumn("progress", "text").addColumn("output", "text").addColumn("error", "text").addColumn("lease_owner", "text").addColumn("lease_expires_at", "text").addColumn(
|
|
538
|
+
"lease_generation",
|
|
539
|
+
"integer",
|
|
540
|
+
(col) => col.notNull().defaultTo(0)
|
|
541
|
+
).addColumn("started_at", "text").addColumn("completed_at", "text").addColumn("created_at", "text", (col) => col.notNull()).addColumn("updated_at", "text", (col) => col.notNull()).execute();
|
|
286
542
|
await db.schema.createIndex("idx_durably_runs_job_idempotency").ifNotExists().on("durably_runs").columns(["job_name", "idempotency_key"]).unique().execute();
|
|
287
543
|
await db.schema.createIndex("idx_durably_runs_status_concurrency").ifNotExists().on("durably_runs").columns(["status", "concurrency_key"]).execute();
|
|
288
544
|
await db.schema.createIndex("idx_durably_runs_status_created").ifNotExists().on("durably_runs").columns(["status", "created_at"]).execute();
|
|
545
|
+
await db.schema.createIndex("idx_durably_runs_status_lease_expires").ifNotExists().on("durably_runs").columns(["status", "lease_expires_at"]).execute();
|
|
546
|
+
await db.schema.createIndex("idx_durably_runs_job_created").ifNotExists().on("durably_runs").columns(["job_name", "created_at"]).execute();
|
|
547
|
+
await db.schema.createIndex("idx_durably_runs_status_completed").ifNotExists().on("durably_runs").columns(["status", "completed_at"]).execute();
|
|
548
|
+
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();
|
|
549
|
+
await db.schema.createIndex("idx_durably_run_labels_pk").ifNotExists().on("durably_run_labels").columns(["run_id", "key"]).unique().execute();
|
|
550
|
+
await db.schema.createIndex("idx_durably_run_labels_key_value").ifNotExists().on("durably_run_labels").columns(["key", "value"]).execute();
|
|
289
551
|
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
552
|
await db.schema.createIndex("idx_durably_steps_run_index").ifNotExists().on("durably_steps").columns(["run_id", "index"]).execute();
|
|
553
|
+
await sql`
|
|
554
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_durably_steps_completed_unique
|
|
555
|
+
ON durably_steps(run_id, name) WHERE status = 'completed'
|
|
556
|
+
`.execute(db);
|
|
291
557
|
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
558
|
await db.schema.createIndex("idx_durably_logs_run_created").ifNotExists().on("durably_logs").columns(["run_id", "created_at"]).execute();
|
|
293
559
|
await db.schema.createTable("durably_schema_versions").ifNotExists().addColumn("version", "integer", (col) => col.primaryKey()).addColumn("applied_at", "text", (col) => col.notNull()).execute();
|
|
@@ -318,30 +584,13 @@ async function runMigrations(db) {
|
|
|
318
584
|
}
|
|
319
585
|
|
|
320
586
|
// src/storage.ts
|
|
321
|
-
import { sql } from "kysely";
|
|
587
|
+
import { sql as sql4 } from "kysely";
|
|
322
588
|
import { monotonicFactory } from "ulidx";
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
heartbeatAt,
|
|
329
|
-
updatedAt,
|
|
330
|
-
...clientRun
|
|
331
|
-
} = run;
|
|
332
|
-
return clientRun;
|
|
333
|
-
}
|
|
334
|
-
var LABEL_KEY_PATTERN = /^[a-zA-Z0-9\-_./]+$/;
|
|
335
|
-
function validateLabels(labels) {
|
|
336
|
-
if (!labels) return;
|
|
337
|
-
for (const key of Object.keys(labels)) {
|
|
338
|
-
if (!LABEL_KEY_PATTERN.test(key)) {
|
|
339
|
-
throw new Error(
|
|
340
|
-
`Invalid label key "${key}": must contain only alphanumeric characters, dashes, underscores, dots, and slashes`
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
589
|
+
|
|
590
|
+
// src/claim-postgres.ts
|
|
591
|
+
import { sql as sql2 } from "kysely";
|
|
592
|
+
|
|
593
|
+
// src/transformers.ts
|
|
345
594
|
function rowToRun(row) {
|
|
346
595
|
return {
|
|
347
596
|
id: row.id,
|
|
@@ -351,12 +600,14 @@ function rowToRun(row) {
|
|
|
351
600
|
idempotencyKey: row.idempotency_key,
|
|
352
601
|
concurrencyKey: row.concurrency_key,
|
|
353
602
|
currentStepIndex: row.current_step_index,
|
|
354
|
-
|
|
603
|
+
completedStepCount: row.completed_step_count,
|
|
355
604
|
progress: row.progress ? JSON.parse(row.progress) : null,
|
|
356
605
|
output: row.output ? JSON.parse(row.output) : null,
|
|
357
606
|
error: row.error,
|
|
358
607
|
labels: JSON.parse(row.labels),
|
|
359
|
-
|
|
608
|
+
leaseOwner: row.lease_owner,
|
|
609
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
610
|
+
leaseGeneration: row.lease_generation,
|
|
360
611
|
startedAt: row.started_at,
|
|
361
612
|
completedAt: row.completed_at,
|
|
362
613
|
createdAt: row.created_at,
|
|
@@ -387,9 +638,166 @@ function rowToLog(row) {
|
|
|
387
638
|
createdAt: row.created_at
|
|
388
639
|
};
|
|
389
640
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
641
|
+
var LABEL_KEY_PATTERN = /^[a-zA-Z0-9\-_./]+$/;
|
|
642
|
+
function validateLabels(labels) {
|
|
643
|
+
if (!labels) return;
|
|
644
|
+
for (const key of Object.keys(labels)) {
|
|
645
|
+
if (!LABEL_KEY_PATTERN.test(key)) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`Invalid label key "${key}": must contain only alphanumeric characters, dashes, underscores, dots, and slashes`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/claim-postgres.ts
|
|
654
|
+
async function claimNextPostgres(db, workerId, now, leaseExpiresAt, activeLeaseGuard) {
|
|
655
|
+
return await db.transaction().execute(async (trx) => {
|
|
656
|
+
const skipKeys = [];
|
|
657
|
+
for (; ; ) {
|
|
658
|
+
const concurrencyCondition = skipKeys.length > 0 ? sql2`
|
|
659
|
+
AND (
|
|
660
|
+
concurrency_key IS NULL
|
|
661
|
+
OR concurrency_key NOT IN (${sql2.join(skipKeys)})
|
|
662
|
+
)
|
|
663
|
+
` : sql2``;
|
|
664
|
+
const candidateResult = await sql2`
|
|
665
|
+
SELECT id, concurrency_key
|
|
666
|
+
FROM durably_runs
|
|
667
|
+
WHERE
|
|
668
|
+
(
|
|
669
|
+
status = 'pending'
|
|
670
|
+
OR (status = 'leased' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ${now})
|
|
671
|
+
)
|
|
672
|
+
AND ${activeLeaseGuard}
|
|
673
|
+
${concurrencyCondition}
|
|
674
|
+
ORDER BY created_at ASC, id ASC
|
|
675
|
+
FOR UPDATE SKIP LOCKED
|
|
676
|
+
LIMIT 1
|
|
677
|
+
`.execute(trx);
|
|
678
|
+
const candidate = candidateResult.rows[0];
|
|
679
|
+
if (!candidate) return null;
|
|
680
|
+
if (candidate.concurrency_key) {
|
|
681
|
+
await sql2`SELECT pg_advisory_xact_lock(hashtext(${candidate.concurrency_key}))`.execute(
|
|
682
|
+
trx
|
|
683
|
+
);
|
|
684
|
+
const conflict = await sql2`
|
|
685
|
+
SELECT 1 FROM durably_runs
|
|
686
|
+
WHERE concurrency_key = ${candidate.concurrency_key}
|
|
687
|
+
AND id <> ${candidate.id}
|
|
688
|
+
AND status = 'leased'
|
|
689
|
+
AND lease_expires_at IS NOT NULL
|
|
690
|
+
AND lease_expires_at > ${now}
|
|
691
|
+
LIMIT 1
|
|
692
|
+
`.execute(trx);
|
|
693
|
+
if (conflict.rows.length > 0) {
|
|
694
|
+
skipKeys.push(candidate.concurrency_key);
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const result = await sql2`
|
|
699
|
+
UPDATE durably_runs
|
|
700
|
+
SET
|
|
701
|
+
status = 'leased',
|
|
702
|
+
lease_owner = ${workerId},
|
|
703
|
+
lease_expires_at = ${leaseExpiresAt},
|
|
704
|
+
lease_generation = lease_generation + 1,
|
|
705
|
+
started_at = COALESCE(started_at, ${now}),
|
|
706
|
+
updated_at = ${now}
|
|
707
|
+
WHERE id = ${candidate.id}
|
|
708
|
+
RETURNING *
|
|
709
|
+
`.execute(trx);
|
|
710
|
+
const row = result.rows[0];
|
|
711
|
+
if (!row) return null;
|
|
712
|
+
return rowToRun(row);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// src/claim-sqlite.ts
|
|
718
|
+
import { sql as sql3 } from "kysely";
|
|
719
|
+
async function claimNextSqlite(db, workerId, now, leaseExpiresAt, activeLeaseGuard) {
|
|
720
|
+
const subquery = db.selectFrom("durably_runs").select("durably_runs.id").where(
|
|
721
|
+
(eb) => eb.or([
|
|
722
|
+
eb("status", "=", "pending"),
|
|
723
|
+
eb.and([
|
|
724
|
+
eb("status", "=", "leased"),
|
|
725
|
+
eb("lease_expires_at", "is not", null),
|
|
726
|
+
eb("lease_expires_at", "<=", now)
|
|
727
|
+
])
|
|
728
|
+
])
|
|
729
|
+
).where(activeLeaseGuard).orderBy("created_at", "asc").orderBy("id", "asc").limit(1);
|
|
730
|
+
const row = await db.updateTable("durably_runs").set({
|
|
731
|
+
status: "leased",
|
|
732
|
+
lease_owner: workerId,
|
|
733
|
+
lease_expires_at: leaseExpiresAt,
|
|
734
|
+
lease_generation: sql3`lease_generation + 1`,
|
|
735
|
+
started_at: sql3`COALESCE(started_at, ${now})`,
|
|
736
|
+
updated_at: now
|
|
737
|
+
}).where("id", "=", (eb) => eb.selectFrom(subquery.as("sub")).select("id")).returningAll().executeTakeFirst();
|
|
738
|
+
if (!row) return null;
|
|
739
|
+
return rowToRun(row);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/storage.ts
|
|
743
|
+
var ulid = monotonicFactory();
|
|
744
|
+
var TERMINAL_STATUSES = ["completed", "failed", "cancelled"];
|
|
745
|
+
function toClientRun(run) {
|
|
746
|
+
const {
|
|
747
|
+
idempotencyKey,
|
|
748
|
+
concurrencyKey,
|
|
749
|
+
leaseOwner,
|
|
750
|
+
leaseExpiresAt,
|
|
751
|
+
leaseGeneration,
|
|
752
|
+
updatedAt,
|
|
753
|
+
...clientRun
|
|
754
|
+
} = run;
|
|
755
|
+
return clientRun;
|
|
756
|
+
}
|
|
757
|
+
function createWriteMutex() {
|
|
758
|
+
let queue = Promise.resolve();
|
|
759
|
+
return async function withWriteLock(fn) {
|
|
760
|
+
let release;
|
|
761
|
+
const next = new Promise((resolve) => {
|
|
762
|
+
release = resolve;
|
|
763
|
+
});
|
|
764
|
+
const prev = queue;
|
|
765
|
+
queue = next;
|
|
766
|
+
await prev;
|
|
767
|
+
try {
|
|
768
|
+
return await fn();
|
|
769
|
+
} finally {
|
|
770
|
+
release();
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
function createKyselyStore(db, backend = "generic") {
|
|
775
|
+
const withWriteLock = createWriteMutex();
|
|
776
|
+
async function cascadeDeleteRuns(trx, ids) {
|
|
777
|
+
if (ids.length === 0) return;
|
|
778
|
+
await trx.deleteFrom("durably_steps").where("run_id", "in", ids).execute();
|
|
779
|
+
await trx.deleteFrom("durably_logs").where("run_id", "in", ids).execute();
|
|
780
|
+
await trx.deleteFrom("durably_run_labels").where("run_id", "in", ids).execute();
|
|
781
|
+
await trx.deleteFrom("durably_runs").where("id", "in", ids).execute();
|
|
782
|
+
}
|
|
783
|
+
async function insertLabelRows(executor, runId, labels) {
|
|
784
|
+
const entries = Object.entries(labels ?? {});
|
|
785
|
+
if (entries.length > 0) {
|
|
786
|
+
await executor.insertInto("durably_run_labels").values(entries.map(([key, value]) => ({ run_id: runId, key, value }))).execute();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async function terminateRun(runId, leaseGeneration, completedAt, fields) {
|
|
790
|
+
const result = await db.updateTable("durably_runs").set({
|
|
791
|
+
...fields,
|
|
792
|
+
lease_owner: null,
|
|
793
|
+
lease_expires_at: null,
|
|
794
|
+
completed_at: completedAt,
|
|
795
|
+
updated_at: completedAt
|
|
796
|
+
}).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).executeTakeFirst();
|
|
797
|
+
return Number(result.numUpdatedRows) > 0;
|
|
798
|
+
}
|
|
799
|
+
const store = {
|
|
800
|
+
async enqueue(input) {
|
|
393
801
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
394
802
|
if (input.idempotencyKey) {
|
|
395
803
|
const existing = await db.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
|
|
@@ -407,20 +815,26 @@ function createKyselyStorage(db) {
|
|
|
407
815
|
idempotency_key: input.idempotencyKey ?? null,
|
|
408
816
|
concurrency_key: input.concurrencyKey ?? null,
|
|
409
817
|
current_step_index: 0,
|
|
818
|
+
completed_step_count: 0,
|
|
410
819
|
progress: null,
|
|
411
820
|
output: null,
|
|
412
821
|
error: null,
|
|
413
822
|
labels: JSON.stringify(input.labels ?? {}),
|
|
414
|
-
|
|
823
|
+
lease_owner: null,
|
|
824
|
+
lease_expires_at: null,
|
|
825
|
+
lease_generation: 0,
|
|
415
826
|
started_at: null,
|
|
416
827
|
completed_at: null,
|
|
417
828
|
created_at: now,
|
|
418
829
|
updated_at: now
|
|
419
830
|
};
|
|
420
|
-
await db.
|
|
831
|
+
await db.transaction().execute(async (trx) => {
|
|
832
|
+
await trx.insertInto("durably_runs").values(run).execute();
|
|
833
|
+
await insertLabelRows(trx, id, input.labels);
|
|
834
|
+
});
|
|
421
835
|
return rowToRun(run);
|
|
422
836
|
},
|
|
423
|
-
async
|
|
837
|
+
async enqueueMany(inputs) {
|
|
424
838
|
if (inputs.length === 0) {
|
|
425
839
|
return [];
|
|
426
840
|
}
|
|
@@ -430,6 +844,7 @@ function createKyselyStorage(db) {
|
|
|
430
844
|
for (const input of inputs) {
|
|
431
845
|
validateLabels(input.labels);
|
|
432
846
|
}
|
|
847
|
+
const allLabelRows = [];
|
|
433
848
|
for (const input of inputs) {
|
|
434
849
|
if (input.idempotencyKey) {
|
|
435
850
|
const existing = await trx.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
|
|
@@ -439,6 +854,11 @@ function createKyselyStorage(db) {
|
|
|
439
854
|
}
|
|
440
855
|
}
|
|
441
856
|
const id = ulid();
|
|
857
|
+
if (input.labels) {
|
|
858
|
+
for (const [key, value] of Object.entries(input.labels)) {
|
|
859
|
+
allLabelRows.push({ run_id: id, key, value });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
442
862
|
runs.push({
|
|
443
863
|
id,
|
|
444
864
|
job_name: input.jobName,
|
|
@@ -447,11 +867,14 @@ function createKyselyStorage(db) {
|
|
|
447
867
|
idempotency_key: input.idempotencyKey ?? null,
|
|
448
868
|
concurrency_key: input.concurrencyKey ?? null,
|
|
449
869
|
current_step_index: 0,
|
|
870
|
+
completed_step_count: 0,
|
|
450
871
|
progress: null,
|
|
451
872
|
output: null,
|
|
452
873
|
error: null,
|
|
453
874
|
labels: JSON.stringify(input.labels ?? {}),
|
|
454
|
-
|
|
875
|
+
lease_owner: null,
|
|
876
|
+
lease_expires_at: null,
|
|
877
|
+
lease_generation: 0,
|
|
455
878
|
started_at: null,
|
|
456
879
|
completed_at: null,
|
|
457
880
|
created_at: now,
|
|
@@ -461,55 +884,29 @@ function createKyselyStorage(db) {
|
|
|
461
884
|
const newRuns = runs.filter((r) => r.created_at === now);
|
|
462
885
|
if (newRuns.length > 0) {
|
|
463
886
|
await trx.insertInto("durably_runs").values(newRuns).execute();
|
|
887
|
+
if (allLabelRows.length > 0) {
|
|
888
|
+
await trx.insertInto("durably_run_labels").values(allLabelRows).execute();
|
|
889
|
+
}
|
|
464
890
|
}
|
|
465
891
|
return runs.map(rowToRun);
|
|
466
892
|
});
|
|
467
893
|
},
|
|
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
894
|
async getRun(runId) {
|
|
494
|
-
const row = await db.selectFrom("durably_runs").
|
|
495
|
-
(eb) => eb.fn.count("durably_steps.id").as("step_count")
|
|
496
|
-
).where("durably_runs.id", "=", runId).groupBy("durably_runs.id").executeTakeFirst();
|
|
895
|
+
const row = await db.selectFrom("durably_runs").selectAll().where("id", "=", runId).executeTakeFirst();
|
|
497
896
|
return row ? rowToRun(row) : null;
|
|
498
897
|
},
|
|
499
898
|
async getRuns(filter) {
|
|
500
|
-
let query = db.selectFrom("durably_runs").
|
|
501
|
-
(eb) => eb.fn.count("durably_steps.id").as("step_count")
|
|
502
|
-
).groupBy("durably_runs.id");
|
|
899
|
+
let query = db.selectFrom("durably_runs").selectAll();
|
|
503
900
|
if (filter?.status) {
|
|
504
|
-
query = query.where("
|
|
901
|
+
query = query.where("status", "=", filter.status);
|
|
505
902
|
}
|
|
506
903
|
if (filter?.jobName) {
|
|
507
904
|
if (Array.isArray(filter.jobName)) {
|
|
508
905
|
if (filter.jobName.length > 0) {
|
|
509
|
-
query = query.where("
|
|
906
|
+
query = query.where("job_name", "in", filter.jobName);
|
|
510
907
|
}
|
|
511
908
|
} else {
|
|
512
|
-
query = query.where("
|
|
909
|
+
query = query.where("job_name", "=", filter.jobName);
|
|
513
910
|
}
|
|
514
911
|
}
|
|
515
912
|
if (filter?.labels) {
|
|
@@ -518,13 +915,13 @@ function createKyselyStorage(db) {
|
|
|
518
915
|
for (const [key, value] of Object.entries(labels)) {
|
|
519
916
|
if (value === void 0) continue;
|
|
520
917
|
query = query.where(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
918
|
+
(eb) => eb.exists(
|
|
919
|
+
eb.selectFrom("durably_run_labels").select(sql4.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)
|
|
920
|
+
)
|
|
524
921
|
);
|
|
525
922
|
}
|
|
526
923
|
}
|
|
527
|
-
query = query.orderBy("
|
|
924
|
+
query = query.orderBy("created_at", "desc");
|
|
528
925
|
if (filter?.limit !== void 0) {
|
|
529
926
|
query = query.limit(filter.limit);
|
|
530
927
|
}
|
|
@@ -537,400 +934,226 @@ function createKyselyStorage(db) {
|
|
|
537
934
|
const rows = await query.execute();
|
|
538
935
|
return rows.map(rowToRun);
|
|
539
936
|
},
|
|
540
|
-
async
|
|
937
|
+
async updateRun(runId, data) {
|
|
541
938
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
heartbeat_at: now,
|
|
554
|
-
started_at: sql`COALESCE(started_at, ${now})`,
|
|
939
|
+
const status = data.status;
|
|
940
|
+
await db.updateTable("durably_runs").set({
|
|
941
|
+
status,
|
|
942
|
+
current_step_index: data.currentStepIndex,
|
|
943
|
+
progress: data.progress !== void 0 ? data.progress ? JSON.stringify(data.progress) : null : void 0,
|
|
944
|
+
output: data.output !== void 0 ? JSON.stringify(data.output) : void 0,
|
|
945
|
+
error: data.error,
|
|
946
|
+
lease_owner: data.leaseOwner !== void 0 ? data.leaseOwner : void 0,
|
|
947
|
+
lease_expires_at: data.leaseExpiresAt !== void 0 ? data.leaseExpiresAt : void 0,
|
|
948
|
+
started_at: data.startedAt,
|
|
949
|
+
completed_at: data.completedAt,
|
|
555
950
|
updated_at: now
|
|
556
|
-
}).where(
|
|
557
|
-
"id",
|
|
558
|
-
"=",
|
|
559
|
-
(eb) => eb.selectFrom(subquery.as("sub")).select("id")
|
|
560
|
-
).returningAll().executeTakeFirst();
|
|
561
|
-
if (!row) return null;
|
|
562
|
-
return rowToRun({ ...row, step_count: 0 });
|
|
951
|
+
}).where("id", "=", runId).execute();
|
|
563
952
|
},
|
|
564
|
-
async
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
id,
|
|
569
|
-
run_id: input.runId,
|
|
570
|
-
name: input.name,
|
|
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);
|
|
953
|
+
async deleteRun(runId) {
|
|
954
|
+
await db.transaction().execute(async (trx) => {
|
|
955
|
+
await cascadeDeleteRuns(trx, [runId]);
|
|
956
|
+
});
|
|
580
957
|
},
|
|
581
|
-
async
|
|
582
|
-
|
|
958
|
+
async purgeRuns(options) {
|
|
959
|
+
const limit = options.limit ?? 500;
|
|
960
|
+
return await db.transaction().execute(async (trx) => {
|
|
961
|
+
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();
|
|
962
|
+
if (rows.length === 0) return 0;
|
|
963
|
+
const ids = rows.map((r) => r.id);
|
|
964
|
+
await cascadeDeleteRuns(trx, ids);
|
|
965
|
+
return ids.length;
|
|
966
|
+
});
|
|
583
967
|
},
|
|
584
|
-
async
|
|
585
|
-
const
|
|
586
|
-
|
|
968
|
+
async claimNext(workerId, now, leaseMs) {
|
|
969
|
+
const leaseExpiresAt = new Date(Date.parse(now) + leaseMs).toISOString();
|
|
970
|
+
const activeLeaseGuard = sql4`
|
|
971
|
+
(
|
|
972
|
+
concurrency_key IS NULL
|
|
973
|
+
OR NOT EXISTS (
|
|
974
|
+
SELECT 1
|
|
975
|
+
FROM durably_runs AS active
|
|
976
|
+
WHERE active.concurrency_key = durably_runs.concurrency_key
|
|
977
|
+
AND active.id <> durably_runs.id
|
|
978
|
+
AND active.status = 'leased'
|
|
979
|
+
AND active.lease_expires_at IS NOT NULL
|
|
980
|
+
AND active.lease_expires_at > ${now}
|
|
981
|
+
)
|
|
982
|
+
)
|
|
983
|
+
`;
|
|
984
|
+
return backend === "postgres" ? claimNextPostgres(db, workerId, now, leaseExpiresAt, activeLeaseGuard) : claimNextSqlite(db, workerId, now, leaseExpiresAt, activeLeaseGuard);
|
|
587
985
|
},
|
|
588
|
-
async
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const log = {
|
|
596
|
-
id,
|
|
597
|
-
run_id: input.runId,
|
|
598
|
-
step_name: input.stepName,
|
|
599
|
-
level: input.level,
|
|
600
|
-
message: input.message,
|
|
601
|
-
data: input.data !== void 0 ? JSON.stringify(input.data) : null,
|
|
602
|
-
created_at: now
|
|
603
|
-
};
|
|
604
|
-
await db.insertInto("durably_logs").values(log).execute();
|
|
605
|
-
return rowToLog(log);
|
|
986
|
+
async renewLease(runId, leaseGeneration, now, leaseMs) {
|
|
987
|
+
const leaseExpiresAt = new Date(Date.parse(now) + leaseMs).toISOString();
|
|
988
|
+
const result = await db.updateTable("durably_runs").set({
|
|
989
|
+
lease_expires_at: leaseExpiresAt,
|
|
990
|
+
updated_at: now
|
|
991
|
+
}).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).where("lease_expires_at", ">", now).executeTakeFirst();
|
|
992
|
+
return Number(result.numUpdatedRows) > 0;
|
|
606
993
|
},
|
|
607
|
-
async
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
import { prettifyError as prettifyError2 } from "zod";
|
|
616
|
-
|
|
617
|
-
// src/errors.ts
|
|
618
|
-
var CancelledError = class extends Error {
|
|
619
|
-
constructor(runId) {
|
|
620
|
-
super(`Run was cancelled: ${runId}`);
|
|
621
|
-
this.name = "CancelledError";
|
|
622
|
-
}
|
|
623
|
-
};
|
|
624
|
-
function getErrorMessage(error) {
|
|
625
|
-
return error instanceof Error ? error.message : String(error);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// src/context.ts
|
|
629
|
-
function createStepContext(run, jobName, storage, eventEmitter) {
|
|
630
|
-
let stepIndex = run.currentStepIndex;
|
|
631
|
-
let currentStepName = null;
|
|
632
|
-
const controller = new AbortController();
|
|
633
|
-
const unsubscribe = eventEmitter.on("run:cancel", (event) => {
|
|
634
|
-
if (event.runId === run.id) {
|
|
635
|
-
controller.abort();
|
|
636
|
-
}
|
|
637
|
-
});
|
|
638
|
-
const step = {
|
|
639
|
-
get runId() {
|
|
640
|
-
return run.id;
|
|
994
|
+
async releaseExpiredLeases(now) {
|
|
995
|
+
const result = await db.updateTable("durably_runs").set({
|
|
996
|
+
status: "pending",
|
|
997
|
+
lease_owner: null,
|
|
998
|
+
lease_expires_at: null,
|
|
999
|
+
updated_at: now
|
|
1000
|
+
}).where("status", "=", "leased").where("lease_expires_at", "is not", null).where("lease_expires_at", "<=", now).executeTakeFirst();
|
|
1001
|
+
return Number(result.numUpdatedRows);
|
|
641
1002
|
},
|
|
642
|
-
async
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
1003
|
+
async completeRun(runId, leaseGeneration, output, completedAt) {
|
|
1004
|
+
return terminateRun(runId, leaseGeneration, completedAt, {
|
|
1005
|
+
status: "completed",
|
|
1006
|
+
output: JSON.stringify(output),
|
|
1007
|
+
error: null
|
|
669
1008
|
});
|
|
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
1009
|
},
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
type: "run:progress",
|
|
725
|
-
runId: run.id,
|
|
726
|
-
jobName,
|
|
727
|
-
progress: progressData,
|
|
728
|
-
labels: run.labels
|
|
1010
|
+
async failRun(runId, leaseGeneration, error, completedAt) {
|
|
1011
|
+
return terminateRun(runId, leaseGeneration, completedAt, {
|
|
1012
|
+
status: "failed",
|
|
1013
|
+
error
|
|
729
1014
|
});
|
|
730
1015
|
},
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1016
|
+
async cancelRun(runId, now) {
|
|
1017
|
+
const result = await db.updateTable("durably_runs").set({
|
|
1018
|
+
status: "cancelled",
|
|
1019
|
+
lease_owner: null,
|
|
1020
|
+
lease_expires_at: null,
|
|
1021
|
+
completed_at: now,
|
|
1022
|
+
updated_at: now
|
|
1023
|
+
}).where("id", "=", runId).where("status", "in", ["pending", "leased"]).executeTakeFirst();
|
|
1024
|
+
return Number(result.numUpdatedRows) > 0;
|
|
1025
|
+
},
|
|
1026
|
+
async persistStep(runId, leaseGeneration, input) {
|
|
1027
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1028
|
+
const id = ulid();
|
|
1029
|
+
const outputJson = input.output !== void 0 ? JSON.stringify(input.output) : null;
|
|
1030
|
+
const errorValue = input.error ?? null;
|
|
1031
|
+
return await db.transaction().execute(async (trx) => {
|
|
1032
|
+
const insertResult = await sql4`
|
|
1033
|
+
INSERT INTO durably_steps (id, run_id, name, "index", status, output, error, started_at, completed_at)
|
|
1034
|
+
SELECT ${id}, ${runId}, ${input.name}, ${input.index}, ${input.status},
|
|
1035
|
+
${outputJson}, ${errorValue}, ${input.startedAt}, ${completedAt}
|
|
1036
|
+
FROM durably_runs
|
|
1037
|
+
WHERE id = ${runId} AND status = 'leased' AND lease_generation = ${leaseGeneration}
|
|
1038
|
+
`.execute(trx);
|
|
1039
|
+
if (Number(insertResult.numAffectedRows) === 0) return null;
|
|
1040
|
+
if (input.status === "completed") {
|
|
1041
|
+
await trx.updateTable("durably_runs").set({
|
|
1042
|
+
current_step_index: input.index + 1,
|
|
1043
|
+
completed_step_count: sql4`completed_step_count + 1`,
|
|
1044
|
+
updated_at: completedAt
|
|
1045
|
+
}).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).execute();
|
|
1046
|
+
}
|
|
1047
|
+
return {
|
|
1048
|
+
id,
|
|
1049
|
+
runId,
|
|
1050
|
+
name: input.name,
|
|
1051
|
+
index: input.index,
|
|
1052
|
+
status: input.status,
|
|
1053
|
+
output: input.output !== void 0 ? input.output : null,
|
|
1054
|
+
error: errorValue,
|
|
1055
|
+
startedAt: input.startedAt,
|
|
1056
|
+
completedAt
|
|
1057
|
+
};
|
|
1058
|
+
});
|
|
1059
|
+
},
|
|
1060
|
+
async deleteSteps(runId) {
|
|
1061
|
+
await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
|
|
1062
|
+
await db.deleteFrom("durably_logs").where("run_id", "=", runId).execute();
|
|
1063
|
+
},
|
|
1064
|
+
async getSteps(runId) {
|
|
1065
|
+
const rows = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).orderBy("index", "asc").execute();
|
|
1066
|
+
return rows.map(rowToStep);
|
|
1067
|
+
},
|
|
1068
|
+
async getCompletedStep(runId, name) {
|
|
1069
|
+
const row = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).where("name", "=", name).where("status", "=", "completed").executeTakeFirst();
|
|
1070
|
+
return row ? rowToStep(row) : null;
|
|
1071
|
+
},
|
|
1072
|
+
async updateProgress(runId, leaseGeneration, progress) {
|
|
1073
|
+
await db.updateTable("durably_runs").set({
|
|
1074
|
+
progress: progress ? JSON.stringify(progress) : null,
|
|
1075
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1076
|
+
}).where("id", "=", runId).where("status", "=", "leased").where("lease_generation", "=", leaseGeneration).execute();
|
|
1077
|
+
},
|
|
1078
|
+
async createLog(input) {
|
|
1079
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1080
|
+
const id = ulid();
|
|
1081
|
+
const log = {
|
|
1082
|
+
id,
|
|
1083
|
+
run_id: input.runId,
|
|
1084
|
+
step_name: input.stepName,
|
|
1085
|
+
level: input.level,
|
|
1086
|
+
message: input.message,
|
|
1087
|
+
data: input.data !== void 0 ? JSON.stringify(input.data) : null,
|
|
1088
|
+
created_at: now
|
|
1089
|
+
};
|
|
1090
|
+
await db.insertInto("durably_logs").values(log).execute();
|
|
1091
|
+
return rowToLog(log);
|
|
1092
|
+
},
|
|
1093
|
+
async getLogs(runId) {
|
|
1094
|
+
const rows = await db.selectFrom("durably_logs").selectAll().where("run_id", "=", runId).orderBy("created_at", "asc").execute();
|
|
1095
|
+
return rows.map(rowToLog);
|
|
768
1096
|
}
|
|
769
1097
|
};
|
|
770
|
-
|
|
1098
|
+
if (backend !== "postgres") {
|
|
1099
|
+
const mutatingKeys = [
|
|
1100
|
+
"enqueue",
|
|
1101
|
+
"enqueueMany",
|
|
1102
|
+
"updateRun",
|
|
1103
|
+
"deleteRun",
|
|
1104
|
+
"purgeRuns",
|
|
1105
|
+
"claimNext",
|
|
1106
|
+
"renewLease",
|
|
1107
|
+
"releaseExpiredLeases",
|
|
1108
|
+
"completeRun",
|
|
1109
|
+
"failRun",
|
|
1110
|
+
"cancelRun",
|
|
1111
|
+
"persistStep",
|
|
1112
|
+
"deleteSteps",
|
|
1113
|
+
"updateProgress",
|
|
1114
|
+
"createLog"
|
|
1115
|
+
];
|
|
1116
|
+
for (const key of mutatingKeys) {
|
|
1117
|
+
const original = store[key];
|
|
1118
|
+
store[key] = (...args) => withWriteLock(() => original.apply(store, args));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return store;
|
|
771
1122
|
}
|
|
772
1123
|
|
|
773
1124
|
// src/worker.ts
|
|
774
|
-
function createWorker(config,
|
|
1125
|
+
function createWorker(config, processOne, onIdle) {
|
|
775
1126
|
let running = false;
|
|
776
|
-
let currentRunPromise = null;
|
|
777
1127
|
let pollingTimeout = null;
|
|
1128
|
+
let inFlight = null;
|
|
778
1129
|
let stopResolver = null;
|
|
779
|
-
let
|
|
780
|
-
|
|
781
|
-
|
|
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") {
|
|
1130
|
+
let activeWorkerId;
|
|
1131
|
+
async function poll() {
|
|
1132
|
+
if (!running) {
|
|
826
1133
|
return;
|
|
827
1134
|
}
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
try {
|
|
872
|
-
const output = await job.fn(step, run.input);
|
|
873
|
-
if (job.outputSchema) {
|
|
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);
|
|
882
|
-
} finally {
|
|
883
|
-
if (config.cleanupSteps) {
|
|
1135
|
+
const cycle = (async () => {
|
|
1136
|
+
const didProcess = await processOne({ workerId: activeWorkerId });
|
|
1137
|
+
if (!didProcess && onIdle && running) {
|
|
884
1138
|
try {
|
|
885
|
-
await
|
|
1139
|
+
await onIdle();
|
|
886
1140
|
} catch {
|
|
887
1141
|
}
|
|
888
1142
|
}
|
|
889
|
-
|
|
890
|
-
|
|
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;
|
|
913
|
-
}
|
|
914
|
-
await executeRun(run, job);
|
|
915
|
-
return true;
|
|
916
|
-
}
|
|
917
|
-
async function poll() {
|
|
918
|
-
if (!running) {
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
const doWork = async () => {
|
|
922
|
-
await recoverStaleRuns();
|
|
923
|
-
await processNextRun();
|
|
924
|
-
};
|
|
1143
|
+
})();
|
|
1144
|
+
inFlight = cycle;
|
|
925
1145
|
try {
|
|
926
|
-
|
|
927
|
-
await currentRunPromise;
|
|
1146
|
+
await cycle;
|
|
928
1147
|
} finally {
|
|
929
|
-
|
|
1148
|
+
inFlight = null;
|
|
930
1149
|
}
|
|
931
1150
|
if (running) {
|
|
932
|
-
pollingTimeout = setTimeout(() =>
|
|
933
|
-
|
|
1151
|
+
pollingTimeout = setTimeout(() => {
|
|
1152
|
+
void poll();
|
|
1153
|
+
}, config.pollingIntervalMs);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
if (stopResolver) {
|
|
934
1157
|
stopResolver();
|
|
935
1158
|
stopResolver = null;
|
|
936
1159
|
}
|
|
@@ -939,12 +1162,13 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
|
939
1162
|
get isRunning() {
|
|
940
1163
|
return running;
|
|
941
1164
|
},
|
|
942
|
-
start() {
|
|
1165
|
+
start(options) {
|
|
943
1166
|
if (running) {
|
|
944
1167
|
return;
|
|
945
1168
|
}
|
|
1169
|
+
activeWorkerId = options?.workerId;
|
|
946
1170
|
running = true;
|
|
947
|
-
poll();
|
|
1171
|
+
void poll();
|
|
948
1172
|
},
|
|
949
1173
|
async stop() {
|
|
950
1174
|
if (!running) {
|
|
@@ -955,11 +1179,7 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
|
955
1179
|
clearTimeout(pollingTimeout);
|
|
956
1180
|
pollingTimeout = null;
|
|
957
1181
|
}
|
|
958
|
-
if (
|
|
959
|
-
clearInterval(heartbeatInterval);
|
|
960
|
-
heartbeatInterval = null;
|
|
961
|
-
}
|
|
962
|
-
if (currentRunPromise) {
|
|
1182
|
+
if (inFlight) {
|
|
963
1183
|
return new Promise((resolve) => {
|
|
964
1184
|
stopResolver = resolve;
|
|
965
1185
|
});
|
|
@@ -970,20 +1190,249 @@ function createWorker(config, storage, eventEmitter, jobRegistry) {
|
|
|
970
1190
|
|
|
971
1191
|
// src/durably.ts
|
|
972
1192
|
var DEFAULTS = {
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1193
|
+
pollingIntervalMs: 1e3,
|
|
1194
|
+
leaseRenewIntervalMs: 5e3,
|
|
1195
|
+
leaseMs: 3e4,
|
|
1196
|
+
preserveSteps: false
|
|
977
1197
|
};
|
|
1198
|
+
function parseDuration(value) {
|
|
1199
|
+
const match = value.match(/^(\d+)(d|h|m)$/);
|
|
1200
|
+
if (!match) {
|
|
1201
|
+
throw new Error(
|
|
1202
|
+
`Invalid duration format: "${value}". Use e.g. '30d', '24h', '60m'`
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
const num = Number.parseInt(match[1], 10);
|
|
1206
|
+
const unit = match[2];
|
|
1207
|
+
const multipliers = {
|
|
1208
|
+
d: 864e5,
|
|
1209
|
+
h: 36e5,
|
|
1210
|
+
m: 6e4
|
|
1211
|
+
};
|
|
1212
|
+
return num * multipliers[unit];
|
|
1213
|
+
}
|
|
1214
|
+
var PURGE_INTERVAL_MS = 6e4;
|
|
1215
|
+
var ulid2 = monotonicFactory2();
|
|
1216
|
+
var BROWSER_SINGLETON_REGISTRY_KEY = "__durablyBrowserSingletonRegistry";
|
|
1217
|
+
var BROWSER_LOCAL_DIALECT_KEY = "__durablyBrowserLocalKey";
|
|
1218
|
+
function defaultWorkerId() {
|
|
1219
|
+
return `worker_${ulid2()}`;
|
|
1220
|
+
}
|
|
1221
|
+
function detectBackend(dialect) {
|
|
1222
|
+
return dialect.constructor.name === "PostgresDialect" ? "postgres" : "generic";
|
|
1223
|
+
}
|
|
1224
|
+
function isBrowserLikeEnvironment() {
|
|
1225
|
+
return typeof globalThis.window !== "undefined" || typeof globalThis.document !== "undefined";
|
|
1226
|
+
}
|
|
1227
|
+
function getBrowserSingletonKey(dialect, explicitKey) {
|
|
1228
|
+
if (!isBrowserLikeEnvironment()) {
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
if (explicitKey) {
|
|
1232
|
+
return explicitKey;
|
|
1233
|
+
}
|
|
1234
|
+
const taggedDialect = dialect;
|
|
1235
|
+
const taggedKey = taggedDialect[BROWSER_LOCAL_DIALECT_KEY];
|
|
1236
|
+
return typeof taggedKey === "string" ? taggedKey : null;
|
|
1237
|
+
}
|
|
1238
|
+
function registerBrowserSingletonWarning(singletonKey) {
|
|
1239
|
+
const globalRegistry = globalThis;
|
|
1240
|
+
const registry = globalRegistry[BROWSER_SINGLETON_REGISTRY_KEY] ?? /* @__PURE__ */ new Map();
|
|
1241
|
+
globalRegistry[BROWSER_SINGLETON_REGISTRY_KEY] = registry;
|
|
1242
|
+
const instanceId = ulid2();
|
|
1243
|
+
const instances = registry.get(singletonKey) ?? /* @__PURE__ */ new Set();
|
|
1244
|
+
const hadExistingInstance = instances.size > 0;
|
|
1245
|
+
instances.add(instanceId);
|
|
1246
|
+
registry.set(singletonKey, instances);
|
|
1247
|
+
if (hadExistingInstance && (typeof process === "undefined" || process.env.NODE_ENV !== "production")) {
|
|
1248
|
+
console.warn(
|
|
1249
|
+
`[durably] Multiple runtimes were created for browser-local store "${singletonKey}" in one tab. Prefer a single shared instance per tab.`
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
let released = false;
|
|
1253
|
+
return () => {
|
|
1254
|
+
if (released) {
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
released = true;
|
|
1258
|
+
const activeInstances = registry.get(singletonKey);
|
|
1259
|
+
if (!activeInstances) {
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
activeInstances.delete(instanceId);
|
|
1263
|
+
if (activeInstances.size === 0) {
|
|
1264
|
+
registry.delete(singletonKey);
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
978
1268
|
function createDurablyInstance(state, jobs) {
|
|
979
|
-
const {
|
|
1269
|
+
const {
|
|
1270
|
+
db,
|
|
1271
|
+
storage,
|
|
1272
|
+
eventEmitter,
|
|
1273
|
+
jobRegistry,
|
|
1274
|
+
worker,
|
|
1275
|
+
releaseBrowserSingleton
|
|
1276
|
+
} = state;
|
|
980
1277
|
async function getRunOrThrow(runId) {
|
|
981
1278
|
const run = await storage.getRun(runId);
|
|
982
1279
|
if (!run) {
|
|
983
|
-
throw new
|
|
1280
|
+
throw new NotFoundError(`Run not found: ${runId}`);
|
|
984
1281
|
}
|
|
985
1282
|
return run;
|
|
986
1283
|
}
|
|
1284
|
+
async function executeRun(run, workerId) {
|
|
1285
|
+
const job = jobRegistry.get(run.jobName);
|
|
1286
|
+
if (!job) {
|
|
1287
|
+
await storage.failRun(
|
|
1288
|
+
run.id,
|
|
1289
|
+
run.leaseGeneration,
|
|
1290
|
+
`Unknown job: ${run.jobName}`,
|
|
1291
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
1292
|
+
);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
const { step, abortLeaseOwnership, dispose } = createStepContext(
|
|
1296
|
+
run,
|
|
1297
|
+
run.jobName,
|
|
1298
|
+
run.leaseGeneration,
|
|
1299
|
+
storage,
|
|
1300
|
+
eventEmitter
|
|
1301
|
+
);
|
|
1302
|
+
let leaseDeadlineTimer = null;
|
|
1303
|
+
const scheduleLeaseDeadline = (leaseExpiresAt) => {
|
|
1304
|
+
if (leaseDeadlineTimer) {
|
|
1305
|
+
clearTimeout(leaseDeadlineTimer);
|
|
1306
|
+
leaseDeadlineTimer = null;
|
|
1307
|
+
}
|
|
1308
|
+
if (!leaseExpiresAt) {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const delay = Math.max(0, Date.parse(leaseExpiresAt) - Date.now());
|
|
1312
|
+
leaseDeadlineTimer = setTimeout(() => {
|
|
1313
|
+
abortLeaseOwnership();
|
|
1314
|
+
}, delay);
|
|
1315
|
+
};
|
|
1316
|
+
scheduleLeaseDeadline(run.leaseExpiresAt);
|
|
1317
|
+
const leaseTimer = setInterval(() => {
|
|
1318
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1319
|
+
storage.renewLease(run.id, run.leaseGeneration, now, state.leaseMs).then((renewed) => {
|
|
1320
|
+
if (!renewed) {
|
|
1321
|
+
abortLeaseOwnership();
|
|
1322
|
+
eventEmitter.emit({
|
|
1323
|
+
type: "worker:error",
|
|
1324
|
+
error: `Lease renewal lost ownership for run ${run.id}`,
|
|
1325
|
+
context: "lease-renewal",
|
|
1326
|
+
runId: run.id
|
|
1327
|
+
});
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
const renewedLeaseExpiresAt = new Date(
|
|
1331
|
+
Date.parse(now) + state.leaseMs
|
|
1332
|
+
).toISOString();
|
|
1333
|
+
scheduleLeaseDeadline(renewedLeaseExpiresAt);
|
|
1334
|
+
eventEmitter.emit({
|
|
1335
|
+
type: "run:lease-renewed",
|
|
1336
|
+
runId: run.id,
|
|
1337
|
+
jobName: run.jobName,
|
|
1338
|
+
leaseOwner: workerId,
|
|
1339
|
+
leaseExpiresAt: renewedLeaseExpiresAt,
|
|
1340
|
+
labels: run.labels
|
|
1341
|
+
});
|
|
1342
|
+
}).catch((error) => {
|
|
1343
|
+
eventEmitter.emit({
|
|
1344
|
+
type: "worker:error",
|
|
1345
|
+
error: getErrorMessage(error),
|
|
1346
|
+
context: "lease-renewal",
|
|
1347
|
+
runId: run.id
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
}, state.leaseRenewIntervalMs);
|
|
1351
|
+
const started = Date.now();
|
|
1352
|
+
let reachedTerminalState = false;
|
|
1353
|
+
try {
|
|
1354
|
+
eventEmitter.emit({
|
|
1355
|
+
type: "run:leased",
|
|
1356
|
+
runId: run.id,
|
|
1357
|
+
jobName: run.jobName,
|
|
1358
|
+
input: run.input,
|
|
1359
|
+
leaseOwner: workerId,
|
|
1360
|
+
leaseExpiresAt: run.leaseExpiresAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1361
|
+
labels: run.labels
|
|
1362
|
+
});
|
|
1363
|
+
const output = await job.fn(step, run.input);
|
|
1364
|
+
if (job.outputSchema) {
|
|
1365
|
+
const parseResult = job.outputSchema.safeParse(output);
|
|
1366
|
+
if (!parseResult.success) {
|
|
1367
|
+
throw new Error(`Invalid output: ${parseResult.error.message}`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1371
|
+
const completed = await storage.completeRun(
|
|
1372
|
+
run.id,
|
|
1373
|
+
run.leaseGeneration,
|
|
1374
|
+
output,
|
|
1375
|
+
completedAt
|
|
1376
|
+
);
|
|
1377
|
+
if (completed) {
|
|
1378
|
+
reachedTerminalState = true;
|
|
1379
|
+
eventEmitter.emit({
|
|
1380
|
+
type: "run:complete",
|
|
1381
|
+
runId: run.id,
|
|
1382
|
+
jobName: run.jobName,
|
|
1383
|
+
output,
|
|
1384
|
+
duration: Date.now() - started,
|
|
1385
|
+
labels: run.labels
|
|
1386
|
+
});
|
|
1387
|
+
} else {
|
|
1388
|
+
eventEmitter.emit({
|
|
1389
|
+
type: "worker:error",
|
|
1390
|
+
error: `Lease lost before completing run ${run.id}`,
|
|
1391
|
+
context: "run-completion"
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
if (error instanceof LeaseLostError || error instanceof CancelledError) {
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const errorMessage = getErrorMessage(error);
|
|
1399
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1400
|
+
const failed = await storage.failRun(
|
|
1401
|
+
run.id,
|
|
1402
|
+
run.leaseGeneration,
|
|
1403
|
+
errorMessage,
|
|
1404
|
+
completedAt
|
|
1405
|
+
);
|
|
1406
|
+
if (failed) {
|
|
1407
|
+
reachedTerminalState = true;
|
|
1408
|
+
const steps = await storage.getSteps(run.id);
|
|
1409
|
+
const failedStep = steps.find((entry) => entry.status === "failed");
|
|
1410
|
+
eventEmitter.emit({
|
|
1411
|
+
type: "run:fail",
|
|
1412
|
+
runId: run.id,
|
|
1413
|
+
jobName: run.jobName,
|
|
1414
|
+
error: errorMessage,
|
|
1415
|
+
failedStepName: failedStep?.name ?? "unknown",
|
|
1416
|
+
labels: run.labels
|
|
1417
|
+
});
|
|
1418
|
+
} else {
|
|
1419
|
+
eventEmitter.emit({
|
|
1420
|
+
type: "worker:error",
|
|
1421
|
+
error: `Lease lost before recording failure for run ${run.id}`,
|
|
1422
|
+
context: "run-failure"
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
} finally {
|
|
1426
|
+
clearInterval(leaseTimer);
|
|
1427
|
+
if (leaseDeadlineTimer) {
|
|
1428
|
+
clearTimeout(leaseDeadlineTimer);
|
|
1429
|
+
}
|
|
1430
|
+
dispose();
|
|
1431
|
+
if (!state.preserveSteps && reachedTerminalState) {
|
|
1432
|
+
await storage.deleteSteps(run.id);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
987
1436
|
const durably = {
|
|
988
1437
|
db,
|
|
989
1438
|
storage,
|
|
@@ -992,7 +1441,10 @@ function createDurablyInstance(state, jobs) {
|
|
|
992
1441
|
emit: eventEmitter.emit,
|
|
993
1442
|
onError: eventEmitter.onError,
|
|
994
1443
|
start: worker.start,
|
|
995
|
-
stop
|
|
1444
|
+
async stop() {
|
|
1445
|
+
releaseBrowserSingleton();
|
|
1446
|
+
await worker.stop();
|
|
1447
|
+
},
|
|
996
1448
|
// biome-ignore lint/suspicious/noExplicitAny: flexible type constraint for job definitions
|
|
997
1449
|
register(jobDefs) {
|
|
998
1450
|
const newHandles = {};
|
|
@@ -1013,8 +1465,8 @@ function createDurablyInstance(state, jobs) {
|
|
|
1013
1465
|
mergedJobs
|
|
1014
1466
|
);
|
|
1015
1467
|
},
|
|
1016
|
-
getRun: storage.getRun,
|
|
1017
|
-
getRuns: storage.getRuns,
|
|
1468
|
+
getRun: storage.getRun.bind(storage),
|
|
1469
|
+
getRuns: storage.getRuns.bind(storage),
|
|
1018
1470
|
use(plugin) {
|
|
1019
1471
|
plugin.install(durably);
|
|
1020
1472
|
},
|
|
@@ -1028,9 +1480,14 @@ function createDurablyInstance(state, jobs) {
|
|
|
1028
1480
|
subscribe(runId) {
|
|
1029
1481
|
let closed = false;
|
|
1030
1482
|
let cleanup = null;
|
|
1031
|
-
const closeEvents = /* @__PURE__ */ new Set([
|
|
1483
|
+
const closeEvents = /* @__PURE__ */ new Set([
|
|
1484
|
+
"run:complete",
|
|
1485
|
+
"run:fail",
|
|
1486
|
+
"run:cancel",
|
|
1487
|
+
"run:delete"
|
|
1488
|
+
]);
|
|
1032
1489
|
const subscribedEvents = [
|
|
1033
|
-
"run:
|
|
1490
|
+
"run:leased",
|
|
1034
1491
|
"run:complete",
|
|
1035
1492
|
"run:fail",
|
|
1036
1493
|
"run:cancel",
|
|
@@ -1057,6 +1514,64 @@ function createDurablyInstance(state, jobs) {
|
|
|
1057
1514
|
cleanup = () => {
|
|
1058
1515
|
for (const unsub of unsubscribes) unsub();
|
|
1059
1516
|
};
|
|
1517
|
+
const closeStream = () => {
|
|
1518
|
+
closed = true;
|
|
1519
|
+
cleanup?.();
|
|
1520
|
+
controller.close();
|
|
1521
|
+
};
|
|
1522
|
+
storage.getRun(runId).then((run) => {
|
|
1523
|
+
if (closed || !run) return;
|
|
1524
|
+
const base = {
|
|
1525
|
+
runId,
|
|
1526
|
+
jobName: run.jobName,
|
|
1527
|
+
labels: run.labels,
|
|
1528
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1529
|
+
sequence: 0
|
|
1530
|
+
};
|
|
1531
|
+
if (run.status === "leased") {
|
|
1532
|
+
controller.enqueue({
|
|
1533
|
+
...base,
|
|
1534
|
+
type: "run:leased",
|
|
1535
|
+
input: run.input,
|
|
1536
|
+
leaseOwner: run.leaseOwner ?? "",
|
|
1537
|
+
leaseExpiresAt: run.leaseExpiresAt ?? ""
|
|
1538
|
+
});
|
|
1539
|
+
if (run.progress != null) {
|
|
1540
|
+
controller.enqueue({
|
|
1541
|
+
...base,
|
|
1542
|
+
type: "run:progress",
|
|
1543
|
+
progress: run.progress
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
} else if (run.status === "completed") {
|
|
1547
|
+
controller.enqueue({
|
|
1548
|
+
...base,
|
|
1549
|
+
type: "run:complete",
|
|
1550
|
+
output: run.output,
|
|
1551
|
+
duration: 0
|
|
1552
|
+
});
|
|
1553
|
+
closeStream();
|
|
1554
|
+
} else if (run.status === "failed") {
|
|
1555
|
+
controller.enqueue({
|
|
1556
|
+
...base,
|
|
1557
|
+
type: "run:fail",
|
|
1558
|
+
error: run.error ?? "Unknown error",
|
|
1559
|
+
failedStepName: ""
|
|
1560
|
+
});
|
|
1561
|
+
closeStream();
|
|
1562
|
+
} else if (run.status === "cancelled") {
|
|
1563
|
+
controller.enqueue({
|
|
1564
|
+
...base,
|
|
1565
|
+
type: "run:cancel"
|
|
1566
|
+
});
|
|
1567
|
+
closeStream();
|
|
1568
|
+
}
|
|
1569
|
+
}).catch((error) => {
|
|
1570
|
+
if (closed) return;
|
|
1571
|
+
closed = true;
|
|
1572
|
+
cleanup?.();
|
|
1573
|
+
controller.error(error);
|
|
1574
|
+
});
|
|
1060
1575
|
},
|
|
1061
1576
|
cancel: () => {
|
|
1062
1577
|
if (!closed) {
|
|
@@ -1069,17 +1584,23 @@ function createDurablyInstance(state, jobs) {
|
|
|
1069
1584
|
async retrigger(runId) {
|
|
1070
1585
|
const run = await getRunOrThrow(runId);
|
|
1071
1586
|
if (run.status === "pending") {
|
|
1072
|
-
throw new
|
|
1587
|
+
throw new ConflictError(`Cannot retrigger pending run: ${runId}`);
|
|
1073
1588
|
}
|
|
1074
|
-
if (run.status === "
|
|
1075
|
-
throw new
|
|
1589
|
+
if (run.status === "leased") {
|
|
1590
|
+
throw new ConflictError(`Cannot retrigger leased run: ${runId}`);
|
|
1076
1591
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1592
|
+
const job = jobRegistry.get(run.jobName);
|
|
1593
|
+
if (!job) {
|
|
1594
|
+
throw new NotFoundError(`Unknown job: ${run.jobName}`);
|
|
1079
1595
|
}
|
|
1080
|
-
const
|
|
1596
|
+
const validatedInput = validateJobInputOrThrow(
|
|
1597
|
+
job.inputSchema,
|
|
1598
|
+
run.input,
|
|
1599
|
+
`Cannot retrigger run ${runId}`
|
|
1600
|
+
);
|
|
1601
|
+
const nextRun = await storage.enqueue({
|
|
1081
1602
|
jobName: run.jobName,
|
|
1082
|
-
input:
|
|
1603
|
+
input: validatedInput,
|
|
1083
1604
|
concurrencyKey: run.concurrencyKey ?? void 0,
|
|
1084
1605
|
labels: run.labels
|
|
1085
1606
|
});
|
|
@@ -1087,7 +1608,7 @@ function createDurablyInstance(state, jobs) {
|
|
|
1087
1608
|
type: "run:trigger",
|
|
1088
1609
|
runId: nextRun.id,
|
|
1089
1610
|
jobName: run.jobName,
|
|
1090
|
-
input:
|
|
1611
|
+
input: validatedInput,
|
|
1091
1612
|
labels: run.labels
|
|
1092
1613
|
});
|
|
1093
1614
|
return nextRun;
|
|
@@ -1095,20 +1616,23 @@ function createDurablyInstance(state, jobs) {
|
|
|
1095
1616
|
async cancel(runId) {
|
|
1096
1617
|
const run = await getRunOrThrow(runId);
|
|
1097
1618
|
if (run.status === "completed") {
|
|
1098
|
-
throw new
|
|
1619
|
+
throw new ConflictError(`Cannot cancel completed run: ${runId}`);
|
|
1099
1620
|
}
|
|
1100
1621
|
if (run.status === "failed") {
|
|
1101
|
-
throw new
|
|
1622
|
+
throw new ConflictError(`Cannot cancel failed run: ${runId}`);
|
|
1102
1623
|
}
|
|
1103
1624
|
if (run.status === "cancelled") {
|
|
1104
|
-
throw new
|
|
1625
|
+
throw new ConflictError(`Cannot cancel already cancelled run: ${runId}`);
|
|
1105
1626
|
}
|
|
1106
1627
|
const wasPending = run.status === "pending";
|
|
1107
|
-
await storage.
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1628
|
+
const cancelled = await storage.cancelRun(runId, (/* @__PURE__ */ new Date()).toISOString());
|
|
1629
|
+
if (!cancelled) {
|
|
1630
|
+
const current = await getRunOrThrow(runId);
|
|
1631
|
+
throw new ConflictError(
|
|
1632
|
+
`Cannot cancel run ${runId}: status changed to ${current.status}`
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
if (wasPending && !state.preserveSteps) {
|
|
1112
1636
|
await storage.deleteSteps(runId);
|
|
1113
1637
|
}
|
|
1114
1638
|
eventEmitter.emit({
|
|
@@ -1121,10 +1645,10 @@ function createDurablyInstance(state, jobs) {
|
|
|
1121
1645
|
async deleteRun(runId) {
|
|
1122
1646
|
const run = await getRunOrThrow(runId);
|
|
1123
1647
|
if (run.status === "pending") {
|
|
1124
|
-
throw new
|
|
1648
|
+
throw new ConflictError(`Cannot delete pending run: ${runId}`);
|
|
1125
1649
|
}
|
|
1126
|
-
if (run.status === "
|
|
1127
|
-
throw new
|
|
1650
|
+
if (run.status === "leased") {
|
|
1651
|
+
throw new ConflictError(`Cannot delete leased run: ${runId}`);
|
|
1128
1652
|
}
|
|
1129
1653
|
await storage.deleteRun(runId);
|
|
1130
1654
|
eventEmitter.emit({
|
|
@@ -1134,6 +1658,40 @@ function createDurablyInstance(state, jobs) {
|
|
|
1134
1658
|
labels: run.labels
|
|
1135
1659
|
});
|
|
1136
1660
|
},
|
|
1661
|
+
async purgeRuns(options) {
|
|
1662
|
+
return storage.purgeRuns({
|
|
1663
|
+
olderThan: options.olderThan.toISOString(),
|
|
1664
|
+
limit: options.limit
|
|
1665
|
+
});
|
|
1666
|
+
},
|
|
1667
|
+
async processOne(options) {
|
|
1668
|
+
const workerId = options?.workerId ?? defaultWorkerId();
|
|
1669
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1670
|
+
const run = await storage.claimNext(workerId, now, state.leaseMs);
|
|
1671
|
+
if (!run) {
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
await executeRun(run, workerId);
|
|
1675
|
+
return true;
|
|
1676
|
+
},
|
|
1677
|
+
async processUntilIdle(options) {
|
|
1678
|
+
const workerId = options?.workerId ?? defaultWorkerId();
|
|
1679
|
+
const maxRuns = options?.maxRuns ?? Number.POSITIVE_INFINITY;
|
|
1680
|
+
let processed = 0;
|
|
1681
|
+
let reachedIdle = false;
|
|
1682
|
+
while (processed < maxRuns) {
|
|
1683
|
+
const didProcess = await this.processOne({ workerId });
|
|
1684
|
+
if (!didProcess) {
|
|
1685
|
+
reachedIdle = true;
|
|
1686
|
+
break;
|
|
1687
|
+
}
|
|
1688
|
+
processed++;
|
|
1689
|
+
}
|
|
1690
|
+
if (reachedIdle) {
|
|
1691
|
+
await state.runIdleMaintenance();
|
|
1692
|
+
}
|
|
1693
|
+
return processed;
|
|
1694
|
+
},
|
|
1137
1695
|
async migrate() {
|
|
1138
1696
|
if (state.migrated) {
|
|
1139
1697
|
return;
|
|
@@ -1157,16 +1715,60 @@ function createDurablyInstance(state, jobs) {
|
|
|
1157
1715
|
}
|
|
1158
1716
|
function createDurably(options) {
|
|
1159
1717
|
const config = {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1718
|
+
pollingIntervalMs: options.pollingIntervalMs ?? DEFAULTS.pollingIntervalMs,
|
|
1719
|
+
leaseRenewIntervalMs: options.leaseRenewIntervalMs ?? DEFAULTS.leaseRenewIntervalMs,
|
|
1720
|
+
leaseMs: options.leaseMs ?? DEFAULTS.leaseMs,
|
|
1721
|
+
preserveSteps: options.preserveSteps ?? DEFAULTS.preserveSteps,
|
|
1722
|
+
retainRunsMs: options.retainRuns ? parseDuration(options.retainRuns) : null
|
|
1164
1723
|
};
|
|
1165
1724
|
const db = new Kysely({ dialect: options.dialect });
|
|
1166
|
-
const
|
|
1725
|
+
const singletonKey = getBrowserSingletonKey(
|
|
1726
|
+
options.dialect,
|
|
1727
|
+
options.singletonKey
|
|
1728
|
+
);
|
|
1729
|
+
const releaseBrowserSingleton = singletonKey !== null ? registerBrowserSingletonWarning(singletonKey) : () => {
|
|
1730
|
+
};
|
|
1731
|
+
const backend = detectBackend(options.dialect);
|
|
1732
|
+
const storage = createKyselyStore(db, backend);
|
|
1733
|
+
const originalDestroy = db.destroy.bind(db);
|
|
1734
|
+
db.destroy = (async () => {
|
|
1735
|
+
releaseBrowserSingleton();
|
|
1736
|
+
return originalDestroy();
|
|
1737
|
+
});
|
|
1167
1738
|
const eventEmitter = createEventEmitter();
|
|
1168
1739
|
const jobRegistry = createJobRegistry();
|
|
1169
|
-
|
|
1740
|
+
let lastPurgeAt = 0;
|
|
1741
|
+
const runIdleMaintenance = async () => {
|
|
1742
|
+
try {
|
|
1743
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1744
|
+
await storage.releaseExpiredLeases(now);
|
|
1745
|
+
if (config.retainRunsMs !== null) {
|
|
1746
|
+
const purgeNow = Date.now();
|
|
1747
|
+
if (purgeNow - lastPurgeAt >= PURGE_INTERVAL_MS) {
|
|
1748
|
+
lastPurgeAt = purgeNow;
|
|
1749
|
+
const cutoff = new Date(purgeNow - config.retainRunsMs).toISOString();
|
|
1750
|
+
await storage.purgeRuns({ olderThan: cutoff, limit: 100 });
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
} catch (error) {
|
|
1754
|
+
eventEmitter.emit({
|
|
1755
|
+
type: "worker:error",
|
|
1756
|
+
error: getErrorMessage(error),
|
|
1757
|
+
context: "idle-maintenance"
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
let processOneImpl = null;
|
|
1762
|
+
const worker = createWorker(
|
|
1763
|
+
{ pollingIntervalMs: config.pollingIntervalMs },
|
|
1764
|
+
(runtimeOptions) => {
|
|
1765
|
+
if (!processOneImpl) {
|
|
1766
|
+
throw new Error("Durably runtime is not initialized");
|
|
1767
|
+
}
|
|
1768
|
+
return processOneImpl(runtimeOptions);
|
|
1769
|
+
},
|
|
1770
|
+
runIdleMaintenance
|
|
1771
|
+
);
|
|
1170
1772
|
const state = {
|
|
1171
1773
|
db,
|
|
1172
1774
|
storage,
|
|
@@ -1174,14 +1776,20 @@ function createDurably(options) {
|
|
|
1174
1776
|
jobRegistry,
|
|
1175
1777
|
worker,
|
|
1176
1778
|
labelsSchema: options.labels,
|
|
1177
|
-
|
|
1779
|
+
preserveSteps: config.preserveSteps,
|
|
1178
1780
|
migrating: null,
|
|
1179
|
-
migrated: false
|
|
1781
|
+
migrated: false,
|
|
1782
|
+
leaseMs: config.leaseMs,
|
|
1783
|
+
leaseRenewIntervalMs: config.leaseRenewIntervalMs,
|
|
1784
|
+
retainRunsMs: config.retainRunsMs,
|
|
1785
|
+
releaseBrowserSingleton,
|
|
1786
|
+
runIdleMaintenance
|
|
1180
1787
|
};
|
|
1181
1788
|
const instance = createDurablyInstance(
|
|
1182
1789
|
state,
|
|
1183
1790
|
{}
|
|
1184
1791
|
);
|
|
1792
|
+
processOneImpl = instance.processOne;
|
|
1185
1793
|
if (options.jobs) {
|
|
1186
1794
|
return instance.register(options.jobs);
|
|
1187
1795
|
}
|
|
@@ -1426,7 +2034,7 @@ function createThrottledSSEController(inner, throttleMs) {
|
|
|
1426
2034
|
// src/server.ts
|
|
1427
2035
|
var VALID_STATUSES = [
|
|
1428
2036
|
"pending",
|
|
1429
|
-
"
|
|
2037
|
+
"leased",
|
|
1430
2038
|
"completed",
|
|
1431
2039
|
"failed",
|
|
1432
2040
|
"cancelled"
|
|
@@ -1505,6 +2113,12 @@ function createDurablyHandler(durably, options) {
|
|
|
1505
2113
|
return await fn();
|
|
1506
2114
|
} catch (error) {
|
|
1507
2115
|
if (error instanceof Response) throw error;
|
|
2116
|
+
if (error instanceof DurablyError) {
|
|
2117
|
+
return errorResponse(
|
|
2118
|
+
error.message,
|
|
2119
|
+
error.statusCode
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
1508
2122
|
return errorResponse(getErrorMessage(error), 500);
|
|
1509
2123
|
}
|
|
1510
2124
|
}
|
|
@@ -1533,14 +2147,11 @@ function createDurablyHandler(durably, options) {
|
|
|
1533
2147
|
if (auth?.onTrigger && ctx !== void 0) {
|
|
1534
2148
|
await auth.onTrigger(ctx, body);
|
|
1535
2149
|
}
|
|
1536
|
-
const run = await job.trigger(
|
|
1537
|
-
body.
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
labels: body.labels
|
|
1542
|
-
}
|
|
1543
|
-
);
|
|
2150
|
+
const run = await job.trigger(body.input, {
|
|
2151
|
+
idempotencyKey: body.idempotencyKey,
|
|
2152
|
+
concurrencyKey: body.concurrencyKey,
|
|
2153
|
+
labels: body.labels
|
|
2154
|
+
});
|
|
1544
2155
|
const response = { runId: run.id };
|
|
1545
2156
|
return jsonResponse(response);
|
|
1546
2157
|
});
|
|
@@ -1656,10 +2267,10 @@ function createDurablyHandler(durably, options) {
|
|
|
1656
2267
|
});
|
|
1657
2268
|
}
|
|
1658
2269
|
}),
|
|
1659
|
-
durably.on("run:
|
|
2270
|
+
durably.on("run:leased", (event) => {
|
|
1660
2271
|
if (matchesFilter(event.jobName, event.labels)) {
|
|
1661
2272
|
ctrl.enqueue({
|
|
1662
|
-
type: "run:
|
|
2273
|
+
type: "run:leased",
|
|
1663
2274
|
runId: event.runId,
|
|
1664
2275
|
jobName: event.jobName,
|
|
1665
2276
|
labels: event.labels
|
|
@@ -1825,8 +2436,14 @@ function createDurablyHandler(durably, options) {
|
|
|
1825
2436
|
}
|
|
1826
2437
|
export {
|
|
1827
2438
|
CancelledError,
|
|
2439
|
+
ConflictError,
|
|
2440
|
+
DurablyError,
|
|
2441
|
+
LeaseLostError,
|
|
2442
|
+
NotFoundError,
|
|
2443
|
+
ValidationError,
|
|
1828
2444
|
createDurably,
|
|
1829
2445
|
createDurablyHandler,
|
|
2446
|
+
createKyselyStore,
|
|
1830
2447
|
defineJob,
|
|
1831
2448
|
toClientRun,
|
|
1832
2449
|
withLogPersistence
|