@voyantjs/workflows-orchestrator-cloudflare 0.30.1 → 0.30.5
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/cloudflare-edge-driver.d.ts +2 -0
- package/dist/cloudflare-edge-driver.d.ts.map +1 -1
- package/dist/cloudflare-edge-driver.js +105 -39
- package/dist/concurrency-coordinator.d.ts +14 -0
- package/dist/concurrency-coordinator.d.ts.map +1 -0
- package/dist/concurrency-coordinator.js +163 -0
- package/dist/durable-object.d.ts +2 -0
- package/dist/durable-object.d.ts.map +1 -1
- package/dist/durable-object.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/types.d.ts +9 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/cloudflare-edge-driver.ts +173 -57
- package/src/concurrency-coordinator.ts +237 -0
- package/src/durable-object.ts +45 -0
- package/src/index.ts +6 -0
- package/src/types.ts +10 -1
|
@@ -5,6 +5,8 @@ import type { DurableObjectNamespaceLike } from "./worker.js";
|
|
|
5
5
|
export interface CloudflareEdgeDriverOptions {
|
|
6
6
|
/** Durable Object namespace holding one DO per run. */
|
|
7
7
|
orchestratorNamespace: DurableObjectNamespaceLike;
|
|
8
|
+
/** Optional Durable Object namespace coordinating workflow concurrency across runs. */
|
|
9
|
+
concurrencyNamespace?: DurableObjectNamespaceLike;
|
|
8
10
|
/** KV namespace storing serialized manifests. */
|
|
9
11
|
manifestKv: KvNamespaceLike;
|
|
10
12
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cloudflare-edge-driver.d.ts","sourceRoot":"","sources":["../src/cloudflare-edge-driver.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EACV,eAAe,
|
|
1
|
+
{"version":3,"file":"cloudflare-edge-driver.d.ts","sourceRoot":"","sources":["../src/cloudflare-edge-driver.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EACV,eAAe,EAOhB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EACV,aAAa,EAOd,MAAM,4BAA4B,CAAA;AAUnC,OAAO,EAGL,KAAK,eAAe,EACrB,MAAM,wBAAwB,CAAA;AAE/B,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AAI7D,MAAM,WAAW,2BAA2B;IAC1C,uDAAuD;IACvD,qBAAqB,EAAE,0BAA0B,CAAA;IACjD,uFAAuF;IACvF,oBAAoB,CAAC,EAAE,0BAA0B,CAAA;IACjD,iDAAiD;IACjD,UAAU,EAAE,eAAe,CAAA;IAC3B;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,yEAAyE;IACzE,kBAAkB,CAAC,EAAE,eAAe,CAAA;IACpC,uFAAuF;IACvF,UAAU,CAAC,EAAE;QACX,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,MAAM,CAAA;KACvB,CAAA;IACD,8CAA8C;IAC9C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,MAAM,CAAA;IAC1B,sEAAsE;IACtE,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAChF;AAUD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,2BAA2B,GAAG,aAAa,CAqa3F"}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// See architecture doc §8.
|
|
19
19
|
import { deriveStableEventId } from "@voyantjs/workflows/events";
|
|
20
|
-
import { routeEvent } from "@voyantjs/workflows-orchestrator";
|
|
20
|
+
import { resolveConcurrencyKey, routeEvent, WorkflowConcurrencyRejectedError, } from "@voyantjs/workflows-orchestrator";
|
|
21
21
|
import { createKvManifestStore, } from "./manifest-kv-store.js";
|
|
22
22
|
const DEFAULT_TENANT_META = {
|
|
23
23
|
tenantId: "default",
|
|
@@ -63,6 +63,63 @@ export function createCloudflareEdgeDriver(opts) {
|
|
|
63
63
|
const stub = opts.orchestratorNamespace.get(id);
|
|
64
64
|
return stub.fetch(request);
|
|
65
65
|
}
|
|
66
|
+
async function forwardToConcurrencyDO(environment, concurrencyKey, request) {
|
|
67
|
+
if (!opts.concurrencyNamespace) {
|
|
68
|
+
throw new Error("CloudflareEdgeDriver: concurrency namespace is not configured");
|
|
69
|
+
}
|
|
70
|
+
const id = opts.concurrencyNamespace.idFromName(`workflow-concurrency:${environment}:${concurrencyKey}`);
|
|
71
|
+
const stub = opts.concurrencyNamespace.get(id);
|
|
72
|
+
return stub.fetch(request);
|
|
73
|
+
}
|
|
74
|
+
function findManifestPolicy(manifest, workflowId) {
|
|
75
|
+
return manifest?.workflows.find((entry) => entry.id === workflowId)?.concurrency;
|
|
76
|
+
}
|
|
77
|
+
async function resolveConcurrencyPolicy(workflow, workflowId, environment, manifest) {
|
|
78
|
+
if (typeof workflow !== "string" && workflow.config?.concurrency) {
|
|
79
|
+
return workflow.config.concurrency;
|
|
80
|
+
}
|
|
81
|
+
return findManifestPolicy(manifest ?? (await getManifest({ environment })), workflowId);
|
|
82
|
+
}
|
|
83
|
+
async function forwardTrigger(payload, policy) {
|
|
84
|
+
if (!policy || !opts.concurrencyNamespace) {
|
|
85
|
+
return forwardToRunDO(payload.runId, new Request("https://do-internal/trigger", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json" },
|
|
88
|
+
body: JSON.stringify(payload),
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
const concurrencyKey = resolveConcurrencyKey(payload.workflowId, payload.input, policy);
|
|
92
|
+
const resp = await forwardToConcurrencyDO(payload.environment, concurrencyKey, new Request("https://do-internal/trigger", {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "content-type": "application/json" },
|
|
95
|
+
body: JSON.stringify({
|
|
96
|
+
concurrency: {
|
|
97
|
+
key: concurrencyKey,
|
|
98
|
+
limit: policy.limit,
|
|
99
|
+
strategy: policy.strategy,
|
|
100
|
+
},
|
|
101
|
+
trigger: payload,
|
|
102
|
+
}),
|
|
103
|
+
}));
|
|
104
|
+
if (resp.status === 409) {
|
|
105
|
+
const body = await safeJson(resp);
|
|
106
|
+
throw new WorkflowConcurrencyRejectedError(body?.concurrencyKey ?? concurrencyKey);
|
|
107
|
+
}
|
|
108
|
+
return resp;
|
|
109
|
+
}
|
|
110
|
+
async function releaseConcurrencySlot(detail) {
|
|
111
|
+
if (!detail || !opts.concurrencyNamespace)
|
|
112
|
+
return;
|
|
113
|
+
const policy = await resolveConcurrencyPolicy(detail.workflowId, detail.workflowId, detail.environment);
|
|
114
|
+
if (!policy)
|
|
115
|
+
return;
|
|
116
|
+
const concurrencyKey = resolveConcurrencyKey(detail.workflowId, detail.input, policy);
|
|
117
|
+
await forwardToConcurrencyDO(detail.environment, concurrencyKey, new Request("https://do-internal/release", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "content-type": "application/json" },
|
|
120
|
+
body: JSON.stringify({ runId: detail.id }),
|
|
121
|
+
})).catch(() => undefined);
|
|
122
|
+
}
|
|
66
123
|
function genRunId(seed) {
|
|
67
124
|
if (seed !== undefined)
|
|
68
125
|
return seed;
|
|
@@ -108,13 +165,11 @@ export function createCloudflareEdgeDriver(opts) {
|
|
|
108
165
|
delay: triggerOpts?.delay instanceof Date
|
|
109
166
|
? { wakeAt: triggerOpts.delay.getTime() }
|
|
110
167
|
: triggerOpts?.delay,
|
|
168
|
+
priority: triggerOpts?.priority,
|
|
111
169
|
triggeredBy: { kind: "api" },
|
|
112
170
|
};
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
headers: { "content-type": "application/json" },
|
|
116
|
-
body: JSON.stringify(payload),
|
|
117
|
-
}));
|
|
171
|
+
const policy = await resolveConcurrencyPolicy(workflow, workflowId, env);
|
|
172
|
+
const resp = await forwardTrigger(payload, policy);
|
|
118
173
|
if (!resp.ok) {
|
|
119
174
|
const body = await safeText(resp);
|
|
120
175
|
throw new Error(`CloudflareEdgeDriver: trigger DO returned ${resp.status}: ${body.slice(0, 256)}`);
|
|
@@ -180,11 +235,8 @@ export function createCloudflareEdgeDriver(opts) {
|
|
|
180
235
|
},
|
|
181
236
|
};
|
|
182
237
|
try {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
headers: { "content-type": "application/json" },
|
|
186
|
-
body: JSON.stringify(payload),
|
|
187
|
-
}));
|
|
238
|
+
const policy = await resolveConcurrencyPolicy(entry.targetWorkflowId, entry.targetWorkflowId, args.environment, manifest);
|
|
239
|
+
const resp = await forwardTrigger(payload, policy);
|
|
188
240
|
if (resp.ok) {
|
|
189
241
|
matches.push({
|
|
190
242
|
filterId: entry.filterId,
|
|
@@ -236,45 +288,51 @@ export function createCloudflareEdgeDriver(opts) {
|
|
|
236
288
|
// layer; getRun + cancelRun are direct DO RPC; listRuns +
|
|
237
289
|
// streamRun are explicitly unsupported per architecture
|
|
238
290
|
// doc §8.3) ----
|
|
291
|
+
async function getRunDetail(runId) {
|
|
292
|
+
try {
|
|
293
|
+
const resp = await forwardToRunDO(runId, new Request("https://do-internal/get", { method: "GET" }));
|
|
294
|
+
if (resp.status === 404)
|
|
295
|
+
return null;
|
|
296
|
+
if (!resp.ok)
|
|
297
|
+
return null;
|
|
298
|
+
const rec = (await resp.json());
|
|
299
|
+
return {
|
|
300
|
+
id: rec.id,
|
|
301
|
+
workflowId: rec.workflowId,
|
|
302
|
+
status: rec.status,
|
|
303
|
+
startedAt: rec.startedAt,
|
|
304
|
+
completedAt: rec.completedAt,
|
|
305
|
+
tags: [...rec.tags],
|
|
306
|
+
environment: rec.environment,
|
|
307
|
+
version: rec.workflowVersion,
|
|
308
|
+
input: rec.input,
|
|
309
|
+
output: rec.output,
|
|
310
|
+
error: rec.error,
|
|
311
|
+
durationMs: rec.completedAt !== undefined
|
|
312
|
+
? Math.max(0, rec.completedAt - rec.startedAt)
|
|
313
|
+
: undefined,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
239
320
|
const admin = {
|
|
240
321
|
async getRun(runId) {
|
|
241
|
-
|
|
242
|
-
const resp = await forwardToRunDO(runId, new Request("https://do-internal/get", { method: "GET" }));
|
|
243
|
-
if (resp.status === 404)
|
|
244
|
-
return null;
|
|
245
|
-
if (!resp.ok)
|
|
246
|
-
return null;
|
|
247
|
-
const rec = (await resp.json());
|
|
248
|
-
return {
|
|
249
|
-
id: rec.id,
|
|
250
|
-
workflowId: rec.workflowId,
|
|
251
|
-
status: rec.status,
|
|
252
|
-
startedAt: rec.startedAt,
|
|
253
|
-
completedAt: rec.completedAt,
|
|
254
|
-
tags: [...rec.tags],
|
|
255
|
-
environment: rec.environment,
|
|
256
|
-
version: rec.workflowVersion,
|
|
257
|
-
input: rec.input,
|
|
258
|
-
output: rec.output,
|
|
259
|
-
error: rec.error,
|
|
260
|
-
durationMs: rec.completedAt !== undefined
|
|
261
|
-
? Math.max(0, rec.completedAt - rec.startedAt)
|
|
262
|
-
: undefined,
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
322
|
+
return getRunDetail(runId);
|
|
268
323
|
},
|
|
269
324
|
async cancelRun(runId, cancelOpts) {
|
|
270
325
|
// Per architecture doc §21.21, cancel does NOT run compensations
|
|
271
326
|
// by default; the `compensate` flag is accepted but no-op in v1.
|
|
272
327
|
void cancelOpts?.compensate;
|
|
273
|
-
await
|
|
328
|
+
const detail = opts.concurrencyNamespace ? await getRunDetail(runId) : null;
|
|
329
|
+
const resp = await forwardToRunDO(runId, new Request("https://do-internal/cancel", {
|
|
274
330
|
method: "POST",
|
|
275
331
|
headers: { "content-type": "application/json" },
|
|
276
332
|
body: JSON.stringify({ reason: cancelOpts?.reason }),
|
|
277
333
|
}));
|
|
334
|
+
if (resp.ok)
|
|
335
|
+
await releaseConcurrencySlot(detail);
|
|
278
336
|
},
|
|
279
337
|
async listRuns(_listOpts) {
|
|
280
338
|
// Self-host Mode 1 has no native cross-run query layer; voyant-cloud
|
|
@@ -318,3 +376,11 @@ async function safeText(resp) {
|
|
|
318
376
|
return "";
|
|
319
377
|
}
|
|
320
378
|
}
|
|
379
|
+
async function safeJson(resp) {
|
|
380
|
+
try {
|
|
381
|
+
return (await resp.json());
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DurableObjectStorageLike } from "./types.js";
|
|
2
|
+
import type { DurableObjectNamespaceLike } from "./worker.js";
|
|
3
|
+
export interface ConcurrencyCoordinatorDeps<Id = unknown> {
|
|
4
|
+
storage: DurableObjectStorageLike;
|
|
5
|
+
runDO: DurableObjectNamespaceLike<Id>;
|
|
6
|
+
}
|
|
7
|
+
export interface ConcurrencyCoordinator {
|
|
8
|
+
fetch(req: Request): Promise<Response>;
|
|
9
|
+
}
|
|
10
|
+
export declare function createConcurrencyCoordinator<Id>(deps: ConcurrencyCoordinatorDeps<Id>): ConcurrencyCoordinator;
|
|
11
|
+
export declare function handleConcurrencyCoordinatorRequest<Id>(req: Request, deps: ConcurrencyCoordinatorDeps<Id> & {
|
|
12
|
+
coordinator?: ConcurrencyCoordinator;
|
|
13
|
+
}): Promise<Response>;
|
|
14
|
+
//# sourceMappingURL=concurrency-coordinator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"concurrency-coordinator.d.ts","sourceRoot":"","sources":["../src/concurrency-coordinator.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,wBAAwB,EAAkB,MAAM,YAAY,CAAA;AAC1E,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AAE7D,MAAM,WAAW,0BAA0B,CAAC,EAAE,GAAG,OAAO;IACtD,OAAO,EAAE,wBAAwB,CAAA;IACjC,KAAK,EAAE,0BAA0B,CAAC,EAAE,CAAC,CAAA;CACtC;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CACvC;AA0BD,wBAAgB,4BAA4B,CAAC,EAAE,EAC7C,IAAI,EAAE,0BAA0B,CAAC,EAAE,CAAC,GACnC,sBAAsB,CAgJxB;AAED,wBAAsB,mCAAmC,CAAC,EAAE,EAC1D,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,0BAA0B,CAAC,EAAE,CAAC,GAAG;IAAE,WAAW,CAAC,EAAE,sBAAsB,CAAA;CAAE,GAC9E,OAAO,CAAC,QAAQ,CAAC,CAGnB"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { WorkflowConcurrencyRejectedError, } from "@voyantjs/workflows-orchestrator";
|
|
2
|
+
const STATE_KEY = "state";
|
|
3
|
+
export function createConcurrencyCoordinator(deps) {
|
|
4
|
+
const waiters = [];
|
|
5
|
+
let stateLock = Promise.resolve();
|
|
6
|
+
async function withStateLock(fn) {
|
|
7
|
+
const previous = stateLock;
|
|
8
|
+
let release;
|
|
9
|
+
stateLock = new Promise((resolve) => {
|
|
10
|
+
release = resolve;
|
|
11
|
+
});
|
|
12
|
+
await previous;
|
|
13
|
+
try {
|
|
14
|
+
return await fn();
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
release();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function loadState() {
|
|
21
|
+
return (await deps.storage.get(STATE_KEY)) ?? { active: [] };
|
|
22
|
+
}
|
|
23
|
+
async function saveState(state) {
|
|
24
|
+
await deps.storage.put(STATE_KEY, { active: [...state.active] });
|
|
25
|
+
}
|
|
26
|
+
async function acquire(payload) {
|
|
27
|
+
const runId = payload.trigger.runId;
|
|
28
|
+
let holdersToCancel;
|
|
29
|
+
let waitForSlot;
|
|
30
|
+
await withStateLock(async () => {
|
|
31
|
+
const state = await loadState();
|
|
32
|
+
if (state.active.includes(runId))
|
|
33
|
+
return;
|
|
34
|
+
const limit = normalizeLimit(payload.concurrency.limit);
|
|
35
|
+
if (state.active.length < limit) {
|
|
36
|
+
state.active.push(runId);
|
|
37
|
+
await saveState(state);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const strategy = payload.concurrency.strategy ?? "queue";
|
|
41
|
+
if (strategy === "cancel-newest") {
|
|
42
|
+
throw new WorkflowConcurrencyRejectedError(payload.concurrency.key);
|
|
43
|
+
}
|
|
44
|
+
if (strategy === "cancel-in-progress") {
|
|
45
|
+
holdersToCancel = [...state.active];
|
|
46
|
+
state.active = [runId];
|
|
47
|
+
await saveState(state);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
waitForSlot = new Promise((resolve) => {
|
|
51
|
+
waiters.push({ runId, resolve });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
if (holdersToCancel) {
|
|
55
|
+
await Promise.all(holdersToCancel.map((holder) => forwardToRunDO(deps.runDO, holder, "/cancel", {
|
|
56
|
+
reason: "cancelled by workflow concurrency policy",
|
|
57
|
+
}).catch(() => undefined)));
|
|
58
|
+
}
|
|
59
|
+
await waitForSlot;
|
|
60
|
+
}
|
|
61
|
+
async function release(runId) {
|
|
62
|
+
let next;
|
|
63
|
+
await withStateLock(async () => {
|
|
64
|
+
const state = await loadState();
|
|
65
|
+
const wasActive = state.active.includes(runId);
|
|
66
|
+
state.active = state.active.filter((holder) => holder !== runId);
|
|
67
|
+
next = wasActive ? waiters.shift() : undefined;
|
|
68
|
+
if (wasActive && next) {
|
|
69
|
+
state.active.push(next.runId);
|
|
70
|
+
}
|
|
71
|
+
await saveState(state);
|
|
72
|
+
});
|
|
73
|
+
next?.resolve();
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
async fetch(req) {
|
|
77
|
+
const url = new URL(req.url);
|
|
78
|
+
if (req.method === "POST" && url.pathname === "/trigger") {
|
|
79
|
+
const payload = (await req.json());
|
|
80
|
+
try {
|
|
81
|
+
await acquire(payload);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err instanceof WorkflowConcurrencyRejectedError) {
|
|
85
|
+
return json(409, {
|
|
86
|
+
error: err.code,
|
|
87
|
+
message: err.message,
|
|
88
|
+
concurrencyKey: err.concurrencyKey,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
let resp;
|
|
94
|
+
try {
|
|
95
|
+
resp = await forwardToRunDO(deps.runDO, payload.trigger.runId, "/trigger", {
|
|
96
|
+
...payload.trigger,
|
|
97
|
+
concurrencyLease: {
|
|
98
|
+
environment: payload.trigger.environment ?? "development",
|
|
99
|
+
key: payload.concurrency.key,
|
|
100
|
+
runId: payload.trigger.runId,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
if (resp.ok) {
|
|
104
|
+
const record = (await resp.clone().json());
|
|
105
|
+
if (isTerminal(record.status)) {
|
|
106
|
+
await release(record.id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
await release(payload.trigger.runId);
|
|
111
|
+
}
|
|
112
|
+
return resp;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
await release(payload.trigger.runId);
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (req.method === "POST" && url.pathname === "/release") {
|
|
120
|
+
const payload = (await req.json());
|
|
121
|
+
await release(payload.runId);
|
|
122
|
+
return json(200, { ok: true });
|
|
123
|
+
}
|
|
124
|
+
if (req.method === "GET" && url.pathname === "/state") {
|
|
125
|
+
return json(200, await loadState());
|
|
126
|
+
}
|
|
127
|
+
return json(404, { error: "route_not_found", path: url.pathname });
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export async function handleConcurrencyCoordinatorRequest(req, deps) {
|
|
132
|
+
const coordinator = deps.coordinator ?? createConcurrencyCoordinator(deps);
|
|
133
|
+
return coordinator.fetch(req);
|
|
134
|
+
}
|
|
135
|
+
async function forwardToRunDO(runDO, runId, path, body) {
|
|
136
|
+
const id = runDO.idFromName(runId);
|
|
137
|
+
const stub = runDO.get(id);
|
|
138
|
+
return stub.fetch(new Request(`https://do-internal${path}`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "content-type": "application/json" },
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
function normalizeLimit(limit) {
|
|
145
|
+
if (limit === undefined)
|
|
146
|
+
return 1;
|
|
147
|
+
if (!Number.isFinite(limit))
|
|
148
|
+
return 1;
|
|
149
|
+
return Math.max(1, Math.floor(limit));
|
|
150
|
+
}
|
|
151
|
+
function isTerminal(status) {
|
|
152
|
+
return (status === "completed" ||
|
|
153
|
+
status === "failed" ||
|
|
154
|
+
status === "cancelled" ||
|
|
155
|
+
status === "compensated" ||
|
|
156
|
+
status === "compensation_failed");
|
|
157
|
+
}
|
|
158
|
+
function json(status, body) {
|
|
159
|
+
return new Response(JSON.stringify(body), {
|
|
160
|
+
status,
|
|
161
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
162
|
+
});
|
|
163
|
+
}
|
package/dist/durable-object.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { applyWaitpointInjection, driveUntilPaused, type RunRecord } from "@voyantjs/workflows-orchestrator";
|
|
2
2
|
import type { StepDispatcher } from "./dispatchers.js";
|
|
3
3
|
import type { DurableObjectStorageLike } from "./types.js";
|
|
4
|
+
import type { DurableObjectNamespaceLike } from "./worker.js";
|
|
4
5
|
export interface DurableObjectDeps {
|
|
5
6
|
storage: DurableObjectStorageLike;
|
|
6
7
|
/**
|
|
@@ -16,6 +17,7 @@ export interface DurableObjectDeps {
|
|
|
16
17
|
* - `createHttpDispatcher` — arbitrary HTTP endpoint
|
|
17
18
|
*/
|
|
18
19
|
dispatcher: StepDispatcher;
|
|
20
|
+
concurrencyDO?: DurableObjectNamespaceLike;
|
|
19
21
|
now?: () => number;
|
|
20
22
|
}
|
|
21
23
|
export declare function handleDurableObjectRequest(req: Request, deps: DurableObjectDeps): Promise<Response>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"durable-object.d.ts","sourceRoot":"","sources":["../src/durable-object.ts"],"names":[],"mappings":"AAmBA,OAAO,EACL,uBAAuB,EACvB,gBAAgB,EAKhB,KAAK,SAAS,EAEf,MAAM,kCAAkC,CAAA;AAEzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEtD,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"durable-object.d.ts","sourceRoot":"","sources":["../src/durable-object.ts"],"names":[],"mappings":"AAmBA,OAAO,EACL,uBAAuB,EACvB,gBAAgB,EAKhB,KAAK,SAAS,EAEf,MAAM,kCAAkC,CAAA;AAEzC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEtD,OAAO,KAAK,EAGV,wBAAwB,EAGzB,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAA;AAE7D,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,wBAAwB,CAAA;IACjC;;;;;;;;;;;OAWG;IACH,UAAU,EAAE,cAAc,CAAA;IAC1B,aAAa,CAAC,EAAE,0BAA0B,CAAA;IAC1C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAcD,wBAAsB,0BAA0B,CAC9C,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,QAAQ,CAAC,CA+EnB;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyCrF;AAyFD,YAAY,EAAE,SAAS,EAAE,CAAA;AAEzB,OAAO,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,CAAA"}
|
package/dist/durable-object.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// the public surface is the outer Worker's /api/* routes.
|
|
19
19
|
import { applyWaitpointInjection, driveUntilPaused, cancel as orchestratorCancel, resume as orchestratorResume, trigger as orchestratorTrigger, } from "@voyantjs/workflows-orchestrator";
|
|
20
20
|
import { createDurableObjectRunStore } from "./do-store.js";
|
|
21
|
+
const CONCURRENCY_LEASE_KEY = "concurrencyLease";
|
|
21
22
|
function resolve(deps, record) {
|
|
22
23
|
return deps.dispatcher({
|
|
23
24
|
tenantScript: record.tenantMeta.tenantScript,
|
|
@@ -29,6 +30,9 @@ export async function handleDurableObjectRequest(req, deps) {
|
|
|
29
30
|
const store = createDurableObjectRunStore(deps.storage);
|
|
30
31
|
if (req.method === "POST" && url.pathname === "/trigger") {
|
|
31
32
|
const payload = (await req.json());
|
|
33
|
+
if (payload.concurrencyLease) {
|
|
34
|
+
await deps.storage.put(CONCURRENCY_LEASE_KEY, payload.concurrencyLease);
|
|
35
|
+
}
|
|
32
36
|
const handler = resolve(deps, {
|
|
33
37
|
tenantMeta: payload.tenantMeta,
|
|
34
38
|
workflowId: payload.workflowId,
|
|
@@ -42,9 +46,11 @@ export async function handleDurableObjectRequest(req, deps) {
|
|
|
42
46
|
tags: payload.tags,
|
|
43
47
|
runId: payload.runId,
|
|
44
48
|
idempotencyKey: payload.idempotencyKey,
|
|
49
|
+
triggeredBy: payload.triggeredBy,
|
|
45
50
|
delay: typeof payload.delay === "object" && payload.delay !== null && "wakeAt" in payload.delay
|
|
46
51
|
? new Date(payload.delay.wakeAt)
|
|
47
52
|
: payload.delay,
|
|
53
|
+
priority: payload.priority,
|
|
48
54
|
}, { store, handler, now: deps.now });
|
|
49
55
|
await reconcileAlarm(record, store, deps);
|
|
50
56
|
return json(200, record);
|
|
@@ -61,6 +67,7 @@ export async function handleDurableObjectRequest(req, deps) {
|
|
|
61
67
|
return json(status, { error: out.status, message: out.message });
|
|
62
68
|
}
|
|
63
69
|
await reconcileAlarm(out.record, store, deps);
|
|
70
|
+
await releaseConcurrencyLeaseIfTerminal(out.record, deps);
|
|
64
71
|
return json(200, out.record);
|
|
65
72
|
}
|
|
66
73
|
if (req.method === "POST" && url.pathname === "/cancel") {
|
|
@@ -75,6 +82,7 @@ export async function handleDurableObjectRequest(req, deps) {
|
|
|
75
82
|
return json(status, { error: out.status, message: out.message });
|
|
76
83
|
}
|
|
77
84
|
await reconcileAlarm(out.record, store, deps);
|
|
85
|
+
await releaseConcurrencyLeaseIfTerminal(out.record, deps);
|
|
78
86
|
return json(200, out.record);
|
|
79
87
|
}
|
|
80
88
|
if (req.method === "GET" && url.pathname === "/get") {
|
|
@@ -132,6 +140,7 @@ export async function handleDurableObjectAlarm(deps) {
|
|
|
132
140
|
await driveUntilPaused(record, { handler, now: deps.now });
|
|
133
141
|
await store.save(record);
|
|
134
142
|
await reconcileAlarm(record, store, deps);
|
|
143
|
+
await releaseConcurrencyLeaseIfTerminal(record, deps);
|
|
135
144
|
}
|
|
136
145
|
/**
|
|
137
146
|
* Look at the record's pending waitpoints; if any are DATETIME,
|
|
@@ -176,6 +185,30 @@ async function getStoredRunId(store) {
|
|
|
176
185
|
const all = await store.list();
|
|
177
186
|
return all[0]?.id;
|
|
178
187
|
}
|
|
188
|
+
async function releaseConcurrencyLeaseIfTerminal(record, deps) {
|
|
189
|
+
if (!deps.concurrencyDO || !isTerminal(record.status))
|
|
190
|
+
return;
|
|
191
|
+
const lease = await deps.storage.get(CONCURRENCY_LEASE_KEY);
|
|
192
|
+
if (!lease)
|
|
193
|
+
return;
|
|
194
|
+
const id = deps.concurrencyDO.idFromName(`workflow-concurrency:${lease.environment}:${lease.key}`);
|
|
195
|
+
const stub = deps.concurrencyDO.get(id);
|
|
196
|
+
await stub
|
|
197
|
+
.fetch(new Request("https://do-internal/release", {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { "content-type": "application/json" },
|
|
200
|
+
body: JSON.stringify({ runId: lease.runId }),
|
|
201
|
+
}))
|
|
202
|
+
.catch(() => undefined);
|
|
203
|
+
await deps.storage.delete(CONCURRENCY_LEASE_KEY);
|
|
204
|
+
}
|
|
205
|
+
function isTerminal(status) {
|
|
206
|
+
return (status === "completed" ||
|
|
207
|
+
status === "failed" ||
|
|
208
|
+
status === "cancelled" ||
|
|
209
|
+
status === "compensated" ||
|
|
210
|
+
status === "compensation_failed");
|
|
211
|
+
}
|
|
179
212
|
function json(status, body) {
|
|
180
213
|
return new Response(JSON.stringify(body), {
|
|
181
214
|
status,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { type BundleLocation, type CfContainerRunnerDeps, type ContainerNamespaceLike, createCfContainerStepRunner, } from "./cf-container-runner.js";
|
|
2
2
|
export { type CloudflareEdgeDriverOptions, createCloudflareEdgeDriver, } from "./cloudflare-edge-driver.js";
|
|
3
|
+
export { type ConcurrencyCoordinator, type ConcurrencyCoordinatorDeps, createConcurrencyCoordinator, handleConcurrencyCoordinatorRequest, } from "./concurrency-coordinator.js";
|
|
3
4
|
export { createHttpDispatcher, createInlineDispatcher, createServiceBindingDispatcher, type HttpDispatcherOptions, type ServiceBindingDispatcherOptions, type ServiceBindingLike, type StepDispatcher, type StepDispatcherContext, } from "./dispatchers.js";
|
|
4
5
|
export { createDurableObjectRunStore } from "./do-store.js";
|
|
5
6
|
export { type DurableObjectDeps, handleDurableObjectAlarm, handleDurableObjectRequest, } from "./durable-object.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkCA,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,2BAA2B,GAC5B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,KAAK,2BAA2B,EAChC,0BAA0B,GAC3B,MAAM,6BAA6B,CAAA;AACpC,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,8BAA8B,EAC9B,KAAK,qBAAqB,EAC1B,KAAK,+BAA+B,EACpC,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,2BAA2B,EAAE,MAAM,eAAe,CAAA;AAC3D,OAAO,EACL,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,0BAA0B,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,4BAA4B,EACjC,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACL,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAA;AACrB,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,KAAK,0BAA0B,EAC/B,mBAAmB,EACnB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkCA,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,2BAA2B,GAC5B,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,KAAK,2BAA2B,EAChC,0BAA0B,GAC3B,MAAM,6BAA6B,CAAA;AACpC,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,0BAA0B,EAC/B,4BAA4B,EAC5B,mCAAmC,GACpC,MAAM,8BAA8B,CAAA;AACrC,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,8BAA8B,EAC9B,KAAK,qBAAqB,EAC1B,KAAK,+BAA+B,EACpC,KAAK,kBAAkB,EACvB,KAAK,cAAc,EACnB,KAAK,qBAAqB,GAC3B,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,2BAA2B,EAAE,MAAM,eAAe,CAAA;AAC3D,OAAO,EACL,KAAK,iBAAiB,EACtB,wBAAwB,EACxB,0BAA0B,GAC3B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,4BAA4B,EACjC,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACL,iBAAiB,EACjB,KAAK,WAAW,EAChB,KAAK,kBAAkB,GACxB,MAAM,cAAc,CAAA;AACrB,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,KAAK,0BAA0B,EAC/B,mBAAmB,EACnB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
// design this adapter implements.
|
|
34
34
|
export { createCfContainerStepRunner, } from "./cf-container-runner.js";
|
|
35
35
|
export { createCloudflareEdgeDriver, } from "./cloudflare-edge-driver.js";
|
|
36
|
+
export { createConcurrencyCoordinator, handleConcurrencyCoordinatorRequest, } from "./concurrency-coordinator.js";
|
|
36
37
|
export { createHttpDispatcher, createInlineDispatcher, createServiceBindingDispatcher, } from "./dispatchers.js";
|
|
37
38
|
export { createDurableObjectRunStore } from "./do-store.js";
|
|
38
39
|
export { handleDurableObjectAlarm, handleDurableObjectRequest, } from "./durable-object.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Duration } from "@voyantjs/workflows";
|
|
1
|
+
import type { Duration, EnvironmentName, RunTrigger } from "@voyantjs/workflows";
|
|
2
2
|
import type { WaitpointInjection } from "@voyantjs/workflows-orchestrator";
|
|
3
3
|
/**
|
|
4
4
|
* Subset of Cloudflare's `DurableObjectStorage` we actually use.
|
|
@@ -57,6 +57,14 @@ export interface TriggerPayload {
|
|
|
57
57
|
delay?: Duration | {
|
|
58
58
|
wakeAt: number;
|
|
59
59
|
};
|
|
60
|
+
priority?: number;
|
|
61
|
+
triggeredBy?: RunTrigger;
|
|
62
|
+
concurrencyLease?: ConcurrencyLease;
|
|
63
|
+
}
|
|
64
|
+
export interface ConcurrencyLease {
|
|
65
|
+
environment: EnvironmentName;
|
|
66
|
+
key: string;
|
|
67
|
+
runId: string;
|
|
60
68
|
}
|
|
61
69
|
/** Cancel payload. */
|
|
62
70
|
export interface CancelPayload {
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAChF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAA;AAE1E;;;;;;;;;GASG;AACH,MAAM,WAAW,wBAAwB;IACvC,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC,CAAA;IAC3C,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACrC,IAAI,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAA;IAC/E,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxC,gCAAgC;IAChC,WAAW,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9B;AAED,uDAAuD;AACvD,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,KAAK,CAAA;IAC3C,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,uCAAuC;AACvC,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,kBAAkB,CAAA;CAC9B;AAED,uBAAuB;AACvB,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,CAAA;IACvB,KAAK,EAAE,OAAO,CAAA;IACd,UAAU,EAAE;QACV,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,MAAM,CAAA;QACtB;;;;WAIG;QACH,YAAY,CAAC,EAAE,MAAM,CAAA;KACtB,CAAA;IACD,WAAW,CAAC,EAAE,YAAY,GAAG,SAAS,GAAG,aAAa,CAAA;IACtD,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,KAAK,CAAC,EAAE,QAAQ,GAAG;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,UAAU,CAAA;IACxB,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,WAAW,EAAE,eAAe,CAAA;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,sBAAsB;AACtB,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU,CAAC,WAAW,GAAG,OAAO;IAC/C,8FAA8F;IAC9F,eAAe,EAAE,WAAW,CAAA;CAC7B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyantjs/workflows-orchestrator-cloudflare",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.5",
|
|
4
4
|
"description": "Cloudflare Worker + Durable Object adapter for @voyantjs/workflows-orchestrator. Dispatches workflow-step requests to tenant Workers via a Workers-for-Platforms dispatch namespace.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"NOTICE"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@voyantjs/workflows-orchestrator": "0.30.
|
|
30
|
-
"@voyantjs/workflows": "0.30.
|
|
29
|
+
"@voyantjs/workflows-orchestrator": "0.30.5",
|
|
30
|
+
"@voyantjs/workflows": "0.30.5"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@cloudflare/vitest-pool-workers": "^0.15.1",
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
RunDetail,
|
|
25
25
|
RunSummary,
|
|
26
26
|
TriggerOptions,
|
|
27
|
+
WorkflowDefinition,
|
|
27
28
|
} from "@voyantjs/workflows"
|
|
28
29
|
import type {
|
|
29
30
|
DriverFactory,
|
|
@@ -36,13 +37,19 @@ import type {
|
|
|
36
37
|
} from "@voyantjs/workflows/driver"
|
|
37
38
|
import { deriveStableEventId } from "@voyantjs/workflows/events"
|
|
38
39
|
import type { WorkflowManifest } from "@voyantjs/workflows/protocol"
|
|
39
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
type RuntimeConcurrencyPolicy,
|
|
42
|
+
resolveConcurrencyKey,
|
|
43
|
+
routeEvent,
|
|
44
|
+
WorkflowConcurrencyRejectedError,
|
|
45
|
+
} from "@voyantjs/workflows-orchestrator"
|
|
40
46
|
|
|
41
47
|
import {
|
|
42
48
|
type CfManifestStore,
|
|
43
49
|
createKvManifestStore,
|
|
44
50
|
type KvNamespaceLike,
|
|
45
51
|
} from "./manifest-kv-store.js"
|
|
52
|
+
import type { TriggerPayload } from "./types.js"
|
|
46
53
|
import type { DurableObjectNamespaceLike } from "./worker.js"
|
|
47
54
|
|
|
48
55
|
// ---- Public factory options ----
|
|
@@ -50,6 +57,8 @@ import type { DurableObjectNamespaceLike } from "./worker.js"
|
|
|
50
57
|
export interface CloudflareEdgeDriverOptions {
|
|
51
58
|
/** Durable Object namespace holding one DO per run. */
|
|
52
59
|
orchestratorNamespace: DurableObjectNamespaceLike
|
|
60
|
+
/** Optional Durable Object namespace coordinating workflow concurrency across runs. */
|
|
61
|
+
concurrencyNamespace?: DurableObjectNamespaceLike
|
|
53
62
|
/** KV namespace storing serialized manifests. */
|
|
54
63
|
manifestKv: KvNamespaceLike
|
|
55
64
|
/**
|
|
@@ -129,6 +138,99 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
129
138
|
return stub.fetch(request)
|
|
130
139
|
}
|
|
131
140
|
|
|
141
|
+
async function forwardToConcurrencyDO(
|
|
142
|
+
environment: EnvironmentName,
|
|
143
|
+
concurrencyKey: string,
|
|
144
|
+
request: Request,
|
|
145
|
+
): Promise<Response> {
|
|
146
|
+
if (!opts.concurrencyNamespace) {
|
|
147
|
+
throw new Error("CloudflareEdgeDriver: concurrency namespace is not configured")
|
|
148
|
+
}
|
|
149
|
+
const id = opts.concurrencyNamespace.idFromName(
|
|
150
|
+
`workflow-concurrency:${environment}:${concurrencyKey}`,
|
|
151
|
+
)
|
|
152
|
+
const stub = opts.concurrencyNamespace.get(id)
|
|
153
|
+
return stub.fetch(request)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function findManifestPolicy(
|
|
157
|
+
manifest: WorkflowManifest | null,
|
|
158
|
+
workflowId: string,
|
|
159
|
+
): RuntimeConcurrencyPolicy | undefined {
|
|
160
|
+
return manifest?.workflows.find((entry) => entry.id === workflowId)?.concurrency
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function resolveConcurrencyPolicy(
|
|
164
|
+
workflow: { config?: { concurrency?: unknown } } | string,
|
|
165
|
+
workflowId: string,
|
|
166
|
+
environment: EnvironmentName,
|
|
167
|
+
manifest?: WorkflowManifest | null,
|
|
168
|
+
): Promise<RuntimeConcurrencyPolicy | undefined> {
|
|
169
|
+
if (typeof workflow !== "string" && workflow.config?.concurrency) {
|
|
170
|
+
return workflow.config.concurrency as RuntimeConcurrencyPolicy
|
|
171
|
+
}
|
|
172
|
+
return findManifestPolicy(manifest ?? (await getManifest({ environment })), workflowId)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function forwardTrigger(
|
|
176
|
+
payload: TriggerPayload & { runId: string; environment: EnvironmentName },
|
|
177
|
+
policy?: RuntimeConcurrencyPolicy,
|
|
178
|
+
): Promise<Response> {
|
|
179
|
+
if (!policy || !opts.concurrencyNamespace) {
|
|
180
|
+
return forwardToRunDO(
|
|
181
|
+
payload.runId,
|
|
182
|
+
new Request("https://do-internal/trigger", {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { "content-type": "application/json" },
|
|
185
|
+
body: JSON.stringify(payload),
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const concurrencyKey = resolveConcurrencyKey(payload.workflowId, payload.input, policy)
|
|
191
|
+
const resp = await forwardToConcurrencyDO(
|
|
192
|
+
payload.environment,
|
|
193
|
+
concurrencyKey,
|
|
194
|
+
new Request("https://do-internal/trigger", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "content-type": "application/json" },
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
concurrency: {
|
|
199
|
+
key: concurrencyKey,
|
|
200
|
+
limit: policy.limit,
|
|
201
|
+
strategy: policy.strategy,
|
|
202
|
+
},
|
|
203
|
+
trigger: payload,
|
|
204
|
+
}),
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
if (resp.status === 409) {
|
|
208
|
+
const body = await safeJson<{ concurrencyKey?: string }>(resp)
|
|
209
|
+
throw new WorkflowConcurrencyRejectedError(body?.concurrencyKey ?? concurrencyKey)
|
|
210
|
+
}
|
|
211
|
+
return resp
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function releaseConcurrencySlot(detail: RunDetail | null): Promise<void> {
|
|
215
|
+
if (!detail || !opts.concurrencyNamespace) return
|
|
216
|
+
const policy = await resolveConcurrencyPolicy(
|
|
217
|
+
detail.workflowId,
|
|
218
|
+
detail.workflowId,
|
|
219
|
+
detail.environment,
|
|
220
|
+
)
|
|
221
|
+
if (!policy) return
|
|
222
|
+
const concurrencyKey = resolveConcurrencyKey(detail.workflowId, detail.input, policy)
|
|
223
|
+
await forwardToConcurrencyDO(
|
|
224
|
+
detail.environment,
|
|
225
|
+
concurrencyKey,
|
|
226
|
+
new Request("https://do-internal/release", {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "content-type": "application/json" },
|
|
229
|
+
body: JSON.stringify({ runId: detail.id }),
|
|
230
|
+
}),
|
|
231
|
+
).catch(() => undefined)
|
|
232
|
+
}
|
|
233
|
+
|
|
132
234
|
function genRunId(seed?: string): string {
|
|
133
235
|
if (seed !== undefined) return seed
|
|
134
236
|
if (opts.idGenerator) return opts.idGenerator()
|
|
@@ -162,7 +264,7 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
162
264
|
}
|
|
163
265
|
|
|
164
266
|
async function trigger<TIn, TOut>(
|
|
165
|
-
workflow:
|
|
267
|
+
workflow: WorkflowDefinition<TIn, TOut> | string,
|
|
166
268
|
input: TIn,
|
|
167
269
|
triggerOpts?: TriggerOptions,
|
|
168
270
|
): Promise<Run<TOut>> {
|
|
@@ -187,15 +289,13 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
187
289
|
triggerOpts?.delay instanceof Date
|
|
188
290
|
? { wakeAt: triggerOpts.delay.getTime() }
|
|
189
291
|
: triggerOpts?.delay,
|
|
292
|
+
priority: triggerOpts?.priority,
|
|
190
293
|
triggeredBy: { kind: "api" as const },
|
|
191
294
|
}
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
headers: { "content-type": "application/json" },
|
|
197
|
-
body: JSON.stringify(payload),
|
|
198
|
-
}),
|
|
295
|
+
const policy = await resolveConcurrencyPolicy(workflow, workflowId, env)
|
|
296
|
+
const resp = await forwardTrigger(
|
|
297
|
+
payload as TriggerPayload & { runId: string; environment: EnvironmentName },
|
|
298
|
+
policy,
|
|
199
299
|
)
|
|
200
300
|
if (!resp.ok) {
|
|
201
301
|
const body = await safeText(resp)
|
|
@@ -272,13 +372,15 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
272
372
|
},
|
|
273
373
|
}
|
|
274
374
|
try {
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
375
|
+
const policy = await resolveConcurrencyPolicy(
|
|
376
|
+
entry.targetWorkflowId,
|
|
377
|
+
entry.targetWorkflowId,
|
|
378
|
+
args.environment,
|
|
379
|
+
manifest,
|
|
380
|
+
)
|
|
381
|
+
const resp = await forwardTrigger(
|
|
382
|
+
payload as TriggerPayload & { runId: string; environment: EnvironmentName },
|
|
383
|
+
policy,
|
|
282
384
|
)
|
|
283
385
|
if (resp.ok) {
|
|
284
386
|
matches.push({
|
|
@@ -333,55 +435,60 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
333
435
|
// streamRun are explicitly unsupported per architecture
|
|
334
436
|
// doc §8.3) ----
|
|
335
437
|
|
|
438
|
+
async function getRunDetail(runId: string): Promise<RunDetail | null> {
|
|
439
|
+
try {
|
|
440
|
+
const resp = await forwardToRunDO(
|
|
441
|
+
runId,
|
|
442
|
+
new Request("https://do-internal/get", { method: "GET" }),
|
|
443
|
+
)
|
|
444
|
+
if (resp.status === 404) return null
|
|
445
|
+
if (!resp.ok) return null
|
|
446
|
+
const rec = (await resp.json()) as {
|
|
447
|
+
id: string
|
|
448
|
+
workflowId: string
|
|
449
|
+
workflowVersion: string
|
|
450
|
+
status: RunSummary["status"]
|
|
451
|
+
startedAt: number
|
|
452
|
+
completedAt?: number
|
|
453
|
+
tags: string[]
|
|
454
|
+
environment: EnvironmentName
|
|
455
|
+
input: unknown
|
|
456
|
+
output?: unknown
|
|
457
|
+
error?: unknown
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
id: rec.id,
|
|
461
|
+
workflowId: rec.workflowId,
|
|
462
|
+
status: rec.status,
|
|
463
|
+
startedAt: rec.startedAt,
|
|
464
|
+
completedAt: rec.completedAt,
|
|
465
|
+
tags: [...rec.tags],
|
|
466
|
+
environment: rec.environment,
|
|
467
|
+
version: rec.workflowVersion,
|
|
468
|
+
input: rec.input,
|
|
469
|
+
output: rec.output,
|
|
470
|
+
error: rec.error,
|
|
471
|
+
durationMs:
|
|
472
|
+
rec.completedAt !== undefined
|
|
473
|
+
? Math.max(0, rec.completedAt - rec.startedAt)
|
|
474
|
+
: undefined,
|
|
475
|
+
}
|
|
476
|
+
} catch {
|
|
477
|
+
return null
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
336
481
|
const admin: Partial<WorkflowAdmin> = {
|
|
337
482
|
async getRun(runId: string): Promise<RunDetail | null> {
|
|
338
|
-
|
|
339
|
-
const resp = await forwardToRunDO(
|
|
340
|
-
runId,
|
|
341
|
-
new Request("https://do-internal/get", { method: "GET" }),
|
|
342
|
-
)
|
|
343
|
-
if (resp.status === 404) return null
|
|
344
|
-
if (!resp.ok) return null
|
|
345
|
-
const rec = (await resp.json()) as {
|
|
346
|
-
id: string
|
|
347
|
-
workflowId: string
|
|
348
|
-
workflowVersion: string
|
|
349
|
-
status: RunSummary["status"]
|
|
350
|
-
startedAt: number
|
|
351
|
-
completedAt?: number
|
|
352
|
-
tags: string[]
|
|
353
|
-
environment: EnvironmentName
|
|
354
|
-
input: unknown
|
|
355
|
-
output?: unknown
|
|
356
|
-
error?: unknown
|
|
357
|
-
}
|
|
358
|
-
return {
|
|
359
|
-
id: rec.id,
|
|
360
|
-
workflowId: rec.workflowId,
|
|
361
|
-
status: rec.status,
|
|
362
|
-
startedAt: rec.startedAt,
|
|
363
|
-
completedAt: rec.completedAt,
|
|
364
|
-
tags: [...rec.tags],
|
|
365
|
-
environment: rec.environment,
|
|
366
|
-
version: rec.workflowVersion,
|
|
367
|
-
input: rec.input,
|
|
368
|
-
output: rec.output,
|
|
369
|
-
error: rec.error,
|
|
370
|
-
durationMs:
|
|
371
|
-
rec.completedAt !== undefined
|
|
372
|
-
? Math.max(0, rec.completedAt - rec.startedAt)
|
|
373
|
-
: undefined,
|
|
374
|
-
}
|
|
375
|
-
} catch {
|
|
376
|
-
return null
|
|
377
|
-
}
|
|
483
|
+
return getRunDetail(runId)
|
|
378
484
|
},
|
|
379
485
|
|
|
380
486
|
async cancelRun(runId: string, cancelOpts?: { reason?: string; compensate?: boolean }) {
|
|
381
487
|
// Per architecture doc §21.21, cancel does NOT run compensations
|
|
382
488
|
// by default; the `compensate` flag is accepted but no-op in v1.
|
|
383
489
|
void cancelOpts?.compensate
|
|
384
|
-
await
|
|
490
|
+
const detail = opts.concurrencyNamespace ? await getRunDetail(runId) : null
|
|
491
|
+
const resp = await forwardToRunDO(
|
|
385
492
|
runId,
|
|
386
493
|
new Request("https://do-internal/cancel", {
|
|
387
494
|
method: "POST",
|
|
@@ -389,6 +496,7 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
389
496
|
body: JSON.stringify({ reason: cancelOpts?.reason }),
|
|
390
497
|
}),
|
|
391
498
|
)
|
|
499
|
+
if (resp.ok) await releaseConcurrencySlot(detail)
|
|
392
500
|
},
|
|
393
501
|
|
|
394
502
|
async listRuns(_listOpts?: ListRunsOptions) {
|
|
@@ -437,3 +545,11 @@ async function safeText(resp: Response): Promise<string> {
|
|
|
437
545
|
return ""
|
|
438
546
|
}
|
|
439
547
|
}
|
|
548
|
+
|
|
549
|
+
async function safeJson<T>(resp: Response): Promise<T | undefined> {
|
|
550
|
+
try {
|
|
551
|
+
return (await resp.json()) as T
|
|
552
|
+
} catch {
|
|
553
|
+
return undefined
|
|
554
|
+
}
|
|
555
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type RunRecord,
|
|
3
|
+
type RuntimeConcurrencyPolicy,
|
|
4
|
+
WorkflowConcurrencyRejectedError,
|
|
5
|
+
} from "@voyantjs/workflows-orchestrator"
|
|
6
|
+
|
|
7
|
+
import type { DurableObjectStorageLike, TriggerPayload } from "./types.js"
|
|
8
|
+
import type { DurableObjectNamespaceLike } from "./worker.js"
|
|
9
|
+
|
|
10
|
+
export interface ConcurrencyCoordinatorDeps<Id = unknown> {
|
|
11
|
+
storage: DurableObjectStorageLike
|
|
12
|
+
runDO: DurableObjectNamespaceLike<Id>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ConcurrencyCoordinator {
|
|
16
|
+
fetch(req: Request): Promise<Response>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CoordinatorState {
|
|
20
|
+
active: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ConcurrencyTriggerPayload {
|
|
24
|
+
concurrency: {
|
|
25
|
+
key: string
|
|
26
|
+
limit?: number
|
|
27
|
+
strategy?: RuntimeConcurrencyPolicy["strategy"]
|
|
28
|
+
}
|
|
29
|
+
trigger: TriggerPayload & { runId: string }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ReleasePayload {
|
|
33
|
+
runId: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Waiter {
|
|
37
|
+
runId: string
|
|
38
|
+
resolve(): void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STATE_KEY = "state"
|
|
42
|
+
|
|
43
|
+
export function createConcurrencyCoordinator<Id>(
|
|
44
|
+
deps: ConcurrencyCoordinatorDeps<Id>,
|
|
45
|
+
): ConcurrencyCoordinator {
|
|
46
|
+
const waiters: Waiter[] = []
|
|
47
|
+
let stateLock: Promise<void> = Promise.resolve()
|
|
48
|
+
|
|
49
|
+
async function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
50
|
+
const previous = stateLock
|
|
51
|
+
let release!: () => void
|
|
52
|
+
stateLock = new Promise<void>((resolve) => {
|
|
53
|
+
release = resolve
|
|
54
|
+
})
|
|
55
|
+
await previous
|
|
56
|
+
try {
|
|
57
|
+
return await fn()
|
|
58
|
+
} finally {
|
|
59
|
+
release()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadState(): Promise<CoordinatorState> {
|
|
64
|
+
return (await deps.storage.get<CoordinatorState>(STATE_KEY)) ?? { active: [] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function saveState(state: CoordinatorState): Promise<void> {
|
|
68
|
+
await deps.storage.put(STATE_KEY, { active: [...state.active] })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function acquire(payload: ConcurrencyTriggerPayload): Promise<void> {
|
|
72
|
+
const runId = payload.trigger.runId
|
|
73
|
+
let holdersToCancel: string[] | undefined
|
|
74
|
+
let waitForSlot: Promise<void> | undefined
|
|
75
|
+
|
|
76
|
+
await withStateLock(async () => {
|
|
77
|
+
const state = await loadState()
|
|
78
|
+
if (state.active.includes(runId)) return
|
|
79
|
+
|
|
80
|
+
const limit = normalizeLimit(payload.concurrency.limit)
|
|
81
|
+
if (state.active.length < limit) {
|
|
82
|
+
state.active.push(runId)
|
|
83
|
+
await saveState(state)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const strategy = payload.concurrency.strategy ?? "queue"
|
|
88
|
+
if (strategy === "cancel-newest") {
|
|
89
|
+
throw new WorkflowConcurrencyRejectedError(payload.concurrency.key)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (strategy === "cancel-in-progress") {
|
|
93
|
+
holdersToCancel = [...state.active]
|
|
94
|
+
state.active = [runId]
|
|
95
|
+
await saveState(state)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
waitForSlot = new Promise<void>((resolve) => {
|
|
100
|
+
waiters.push({ runId, resolve })
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (holdersToCancel) {
|
|
105
|
+
await Promise.all(
|
|
106
|
+
holdersToCancel.map((holder) =>
|
|
107
|
+
forwardToRunDO(deps.runDO, holder, "/cancel", {
|
|
108
|
+
reason: "cancelled by workflow concurrency policy",
|
|
109
|
+
}).catch(() => undefined),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await waitForSlot
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function release(runId: string): Promise<void> {
|
|
118
|
+
let next: Waiter | undefined
|
|
119
|
+
await withStateLock(async () => {
|
|
120
|
+
const state = await loadState()
|
|
121
|
+
const wasActive = state.active.includes(runId)
|
|
122
|
+
state.active = state.active.filter((holder) => holder !== runId)
|
|
123
|
+
next = wasActive ? waiters.shift() : undefined
|
|
124
|
+
if (wasActive && next) {
|
|
125
|
+
state.active.push(next.runId)
|
|
126
|
+
}
|
|
127
|
+
await saveState(state)
|
|
128
|
+
})
|
|
129
|
+
next?.resolve()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
async fetch(req) {
|
|
134
|
+
const url = new URL(req.url)
|
|
135
|
+
|
|
136
|
+
if (req.method === "POST" && url.pathname === "/trigger") {
|
|
137
|
+
const payload = (await req.json()) as ConcurrencyTriggerPayload
|
|
138
|
+
try {
|
|
139
|
+
await acquire(payload)
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err instanceof WorkflowConcurrencyRejectedError) {
|
|
142
|
+
return json(409, {
|
|
143
|
+
error: err.code,
|
|
144
|
+
message: err.message,
|
|
145
|
+
concurrencyKey: err.concurrencyKey,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
throw err
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let resp: Response | undefined
|
|
152
|
+
try {
|
|
153
|
+
resp = await forwardToRunDO(deps.runDO, payload.trigger.runId, "/trigger", {
|
|
154
|
+
...payload.trigger,
|
|
155
|
+
concurrencyLease: {
|
|
156
|
+
environment: payload.trigger.environment ?? "development",
|
|
157
|
+
key: payload.concurrency.key,
|
|
158
|
+
runId: payload.trigger.runId,
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
if (resp.ok) {
|
|
162
|
+
const record = (await resp.clone().json()) as RunRecord
|
|
163
|
+
if (isTerminal(record.status)) {
|
|
164
|
+
await release(record.id)
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
await release(payload.trigger.runId)
|
|
168
|
+
}
|
|
169
|
+
return resp
|
|
170
|
+
} catch (err) {
|
|
171
|
+
await release(payload.trigger.runId)
|
|
172
|
+
throw err
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (req.method === "POST" && url.pathname === "/release") {
|
|
177
|
+
const payload = (await req.json()) as ReleasePayload
|
|
178
|
+
await release(payload.runId)
|
|
179
|
+
return json(200, { ok: true })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (req.method === "GET" && url.pathname === "/state") {
|
|
183
|
+
return json(200, await loadState())
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return json(404, { error: "route_not_found", path: url.pathname })
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function handleConcurrencyCoordinatorRequest<Id>(
|
|
192
|
+
req: Request,
|
|
193
|
+
deps: ConcurrencyCoordinatorDeps<Id> & { coordinator?: ConcurrencyCoordinator },
|
|
194
|
+
): Promise<Response> {
|
|
195
|
+
const coordinator = deps.coordinator ?? createConcurrencyCoordinator(deps)
|
|
196
|
+
return coordinator.fetch(req)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function forwardToRunDO<Id>(
|
|
200
|
+
runDO: DurableObjectNamespaceLike<Id>,
|
|
201
|
+
runId: string,
|
|
202
|
+
path: "/trigger" | "/cancel",
|
|
203
|
+
body: unknown,
|
|
204
|
+
): Promise<Response> {
|
|
205
|
+
const id = runDO.idFromName(runId)
|
|
206
|
+
const stub = runDO.get(id)
|
|
207
|
+
return stub.fetch(
|
|
208
|
+
new Request(`https://do-internal${path}`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: { "content-type": "application/json" },
|
|
211
|
+
body: JSON.stringify(body),
|
|
212
|
+
}),
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeLimit(limit: number | undefined): number {
|
|
217
|
+
if (limit === undefined) return 1
|
|
218
|
+
if (!Number.isFinite(limit)) return 1
|
|
219
|
+
return Math.max(1, Math.floor(limit))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isTerminal(status: RunRecord["status"]): boolean {
|
|
223
|
+
return (
|
|
224
|
+
status === "completed" ||
|
|
225
|
+
status === "failed" ||
|
|
226
|
+
status === "cancelled" ||
|
|
227
|
+
status === "compensated" ||
|
|
228
|
+
status === "compensation_failed"
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function json(status: number, body: unknown): Response {
|
|
233
|
+
return new Response(JSON.stringify(body), {
|
|
234
|
+
status,
|
|
235
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
236
|
+
})
|
|
237
|
+
}
|
package/src/durable-object.ts
CHANGED
|
@@ -32,10 +32,12 @@ import type { StepDispatcher } from "./dispatchers.js"
|
|
|
32
32
|
import { createDurableObjectRunStore } from "./do-store.js"
|
|
33
33
|
import type {
|
|
34
34
|
CancelPayload,
|
|
35
|
+
ConcurrencyLease,
|
|
35
36
|
DurableObjectStorageLike,
|
|
36
37
|
ResumePayload,
|
|
37
38
|
TriggerPayload,
|
|
38
39
|
} from "./types.js"
|
|
40
|
+
import type { DurableObjectNamespaceLike } from "./worker.js"
|
|
39
41
|
|
|
40
42
|
export interface DurableObjectDeps {
|
|
41
43
|
storage: DurableObjectStorageLike
|
|
@@ -52,9 +54,12 @@ export interface DurableObjectDeps {
|
|
|
52
54
|
* - `createHttpDispatcher` — arbitrary HTTP endpoint
|
|
53
55
|
*/
|
|
54
56
|
dispatcher: StepDispatcher
|
|
57
|
+
concurrencyDO?: DurableObjectNamespaceLike
|
|
55
58
|
now?: () => number
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
const CONCURRENCY_LEASE_KEY = "concurrencyLease"
|
|
62
|
+
|
|
58
63
|
function resolve(
|
|
59
64
|
deps: DurableObjectDeps,
|
|
60
65
|
record: { tenantMeta: { tenantScript?: string }; workflowId: string },
|
|
@@ -74,6 +79,9 @@ export async function handleDurableObjectRequest(
|
|
|
74
79
|
|
|
75
80
|
if (req.method === "POST" && url.pathname === "/trigger") {
|
|
76
81
|
const payload = (await req.json()) as TriggerPayload
|
|
82
|
+
if (payload.concurrencyLease) {
|
|
83
|
+
await deps.storage.put(CONCURRENCY_LEASE_KEY, payload.concurrencyLease)
|
|
84
|
+
}
|
|
77
85
|
const handler = resolve(deps, {
|
|
78
86
|
tenantMeta: payload.tenantMeta,
|
|
79
87
|
workflowId: payload.workflowId,
|
|
@@ -88,10 +96,12 @@ export async function handleDurableObjectRequest(
|
|
|
88
96
|
tags: payload.tags,
|
|
89
97
|
runId: payload.runId,
|
|
90
98
|
idempotencyKey: payload.idempotencyKey,
|
|
99
|
+
triggeredBy: payload.triggeredBy,
|
|
91
100
|
delay:
|
|
92
101
|
typeof payload.delay === "object" && payload.delay !== null && "wakeAt" in payload.delay
|
|
93
102
|
? new Date(payload.delay.wakeAt)
|
|
94
103
|
: payload.delay,
|
|
104
|
+
priority: payload.priority,
|
|
95
105
|
},
|
|
96
106
|
{ store, handler, now: deps.now },
|
|
97
107
|
)
|
|
@@ -113,6 +123,7 @@ export async function handleDurableObjectRequest(
|
|
|
113
123
|
return json(status, { error: out.status, message: out.message })
|
|
114
124
|
}
|
|
115
125
|
await reconcileAlarm(out.record, store, deps)
|
|
126
|
+
await releaseConcurrencyLeaseIfTerminal(out.record, deps)
|
|
116
127
|
return json(200, out.record)
|
|
117
128
|
}
|
|
118
129
|
|
|
@@ -130,6 +141,7 @@ export async function handleDurableObjectRequest(
|
|
|
130
141
|
return json(status, { error: out.status, message: out.message })
|
|
131
142
|
}
|
|
132
143
|
await reconcileAlarm(out.record, store, deps)
|
|
144
|
+
await releaseConcurrencyLeaseIfTerminal(out.record, deps)
|
|
133
145
|
return json(200, out.record)
|
|
134
146
|
}
|
|
135
147
|
|
|
@@ -188,6 +200,7 @@ export async function handleDurableObjectAlarm(deps: DurableObjectDeps): Promise
|
|
|
188
200
|
await driveUntilPaused(record, { handler, now: deps.now })
|
|
189
201
|
await store.save(record)
|
|
190
202
|
await reconcileAlarm(record, store, deps)
|
|
203
|
+
await releaseConcurrencyLeaseIfTerminal(record, deps)
|
|
191
204
|
}
|
|
192
205
|
|
|
193
206
|
/**
|
|
@@ -238,6 +251,38 @@ async function getStoredRunId(
|
|
|
238
251
|
return all[0]?.id
|
|
239
252
|
}
|
|
240
253
|
|
|
254
|
+
async function releaseConcurrencyLeaseIfTerminal(
|
|
255
|
+
record: RunRecord,
|
|
256
|
+
deps: DurableObjectDeps,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
if (!deps.concurrencyDO || !isTerminal(record.status)) return
|
|
259
|
+
const lease = await deps.storage.get<ConcurrencyLease>(CONCURRENCY_LEASE_KEY)
|
|
260
|
+
if (!lease) return
|
|
261
|
+
|
|
262
|
+
const id = deps.concurrencyDO.idFromName(`workflow-concurrency:${lease.environment}:${lease.key}`)
|
|
263
|
+
const stub = deps.concurrencyDO.get(id)
|
|
264
|
+
await stub
|
|
265
|
+
.fetch(
|
|
266
|
+
new Request("https://do-internal/release", {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { "content-type": "application/json" },
|
|
269
|
+
body: JSON.stringify({ runId: lease.runId }),
|
|
270
|
+
}),
|
|
271
|
+
)
|
|
272
|
+
.catch(() => undefined)
|
|
273
|
+
await deps.storage.delete(CONCURRENCY_LEASE_KEY)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isTerminal(status: RunRecord["status"]): boolean {
|
|
277
|
+
return (
|
|
278
|
+
status === "completed" ||
|
|
279
|
+
status === "failed" ||
|
|
280
|
+
status === "cancelled" ||
|
|
281
|
+
status === "compensated" ||
|
|
282
|
+
status === "compensation_failed"
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
241
286
|
function json(status: number, body: unknown): Response {
|
|
242
287
|
return new Response(JSON.stringify(body), {
|
|
243
288
|
status,
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,12 @@ export {
|
|
|
42
42
|
type CloudflareEdgeDriverOptions,
|
|
43
43
|
createCloudflareEdgeDriver,
|
|
44
44
|
} from "./cloudflare-edge-driver.js"
|
|
45
|
+
export {
|
|
46
|
+
type ConcurrencyCoordinator,
|
|
47
|
+
type ConcurrencyCoordinatorDeps,
|
|
48
|
+
createConcurrencyCoordinator,
|
|
49
|
+
handleConcurrencyCoordinatorRequest,
|
|
50
|
+
} from "./concurrency-coordinator.js"
|
|
45
51
|
export {
|
|
46
52
|
createHttpDispatcher,
|
|
47
53
|
createInlineDispatcher,
|
package/src/types.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// a hard dependency on `@cloudflare/workers-types` — matching the
|
|
3
3
|
// shape is enough, and tests can pass plain objects.
|
|
4
4
|
|
|
5
|
-
import type { Duration } from "@voyantjs/workflows"
|
|
5
|
+
import type { Duration, EnvironmentName, RunTrigger } from "@voyantjs/workflows"
|
|
6
6
|
import type { WaitpointInjection } from "@voyantjs/workflows-orchestrator"
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -60,6 +60,15 @@ export interface TriggerPayload {
|
|
|
60
60
|
runId?: string
|
|
61
61
|
idempotencyKey?: string
|
|
62
62
|
delay?: Duration | { wakeAt: number }
|
|
63
|
+
priority?: number
|
|
64
|
+
triggeredBy?: RunTrigger
|
|
65
|
+
concurrencyLease?: ConcurrencyLease
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ConcurrencyLease {
|
|
69
|
+
environment: EnvironmentName
|
|
70
|
+
key: string
|
|
71
|
+
runId: string
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
/** Cancel payload. */
|