@voyantjs/workflows-orchestrator-cloudflare 0.30.3 → 0.30.6
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 +104 -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 +32 -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 +8 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/cloudflare-edge-driver.ts +172 -57
- package/src/concurrency-coordinator.ts +237 -0
- package/src/durable-object.ts +44 -0
- package/src/index.ts +6 -0
- package/src/types.ts +9 -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;
|
|
@@ -111,11 +168,8 @@ export function createCloudflareEdgeDriver(opts) {
|
|
|
111
168
|
priority: triggerOpts?.priority,
|
|
112
169
|
triggeredBy: { kind: "api" },
|
|
113
170
|
};
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
headers: { "content-type": "application/json" },
|
|
117
|
-
body: JSON.stringify(payload),
|
|
118
|
-
}));
|
|
171
|
+
const policy = await resolveConcurrencyPolicy(workflow, workflowId, env);
|
|
172
|
+
const resp = await forwardTrigger(payload, policy);
|
|
119
173
|
if (!resp.ok) {
|
|
120
174
|
const body = await safeText(resp);
|
|
121
175
|
throw new Error(`CloudflareEdgeDriver: trigger DO returned ${resp.status}: ${body.slice(0, 256)}`);
|
|
@@ -181,11 +235,8 @@ export function createCloudflareEdgeDriver(opts) {
|
|
|
181
235
|
},
|
|
182
236
|
};
|
|
183
237
|
try {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
headers: { "content-type": "application/json" },
|
|
187
|
-
body: JSON.stringify(payload),
|
|
188
|
-
}));
|
|
238
|
+
const policy = await resolveConcurrencyPolicy(entry.targetWorkflowId, entry.targetWorkflowId, args.environment, manifest);
|
|
239
|
+
const resp = await forwardTrigger(payload, policy);
|
|
189
240
|
if (resp.ok) {
|
|
190
241
|
matches.push({
|
|
191
242
|
filterId: entry.filterId,
|
|
@@ -237,45 +288,51 @@ export function createCloudflareEdgeDriver(opts) {
|
|
|
237
288
|
// layer; getRun + cancelRun are direct DO RPC; listRuns +
|
|
238
289
|
// streamRun are explicitly unsupported per architecture
|
|
239
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
|
+
}
|
|
240
320
|
const admin = {
|
|
241
321
|
async getRun(runId) {
|
|
242
|
-
|
|
243
|
-
const resp = await forwardToRunDO(runId, new Request("https://do-internal/get", { method: "GET" }));
|
|
244
|
-
if (resp.status === 404)
|
|
245
|
-
return null;
|
|
246
|
-
if (!resp.ok)
|
|
247
|
-
return null;
|
|
248
|
-
const rec = (await resp.json());
|
|
249
|
-
return {
|
|
250
|
-
id: rec.id,
|
|
251
|
-
workflowId: rec.workflowId,
|
|
252
|
-
status: rec.status,
|
|
253
|
-
startedAt: rec.startedAt,
|
|
254
|
-
completedAt: rec.completedAt,
|
|
255
|
-
tags: [...rec.tags],
|
|
256
|
-
environment: rec.environment,
|
|
257
|
-
version: rec.workflowVersion,
|
|
258
|
-
input: rec.input,
|
|
259
|
-
output: rec.output,
|
|
260
|
-
error: rec.error,
|
|
261
|
-
durationMs: rec.completedAt !== undefined
|
|
262
|
-
? Math.max(0, rec.completedAt - rec.startedAt)
|
|
263
|
-
: undefined,
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
catch {
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
322
|
+
return getRunDetail(runId);
|
|
269
323
|
},
|
|
270
324
|
async cancelRun(runId, cancelOpts) {
|
|
271
325
|
// Per architecture doc §21.21, cancel does NOT run compensations
|
|
272
326
|
// by default; the `compensate` flag is accepted but no-op in v1.
|
|
273
327
|
void cancelOpts?.compensate;
|
|
274
|
-
await
|
|
328
|
+
const detail = opts.concurrencyNamespace ? await getRunDetail(runId) : null;
|
|
329
|
+
const resp = await forwardToRunDO(runId, new Request("https://do-internal/cancel", {
|
|
275
330
|
method: "POST",
|
|
276
331
|
headers: { "content-type": "application/json" },
|
|
277
332
|
body: JSON.stringify({ reason: cancelOpts?.reason }),
|
|
278
333
|
}));
|
|
334
|
+
if (resp.ok)
|
|
335
|
+
await releaseConcurrencySlot(detail);
|
|
279
336
|
},
|
|
280
337
|
async listRuns(_listOpts) {
|
|
281
338
|
// Self-host Mode 1 has no native cross-run query layer; voyant-cloud
|
|
@@ -319,3 +376,11 @@ async function safeText(resp) {
|
|
|
319
376
|
return "";
|
|
320
377
|
}
|
|
321
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,6 +46,7 @@ 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,
|
|
@@ -62,6 +67,7 @@ export async function handleDurableObjectRequest(req, deps) {
|
|
|
62
67
|
return json(status, { error: out.status, message: out.message });
|
|
63
68
|
}
|
|
64
69
|
await reconcileAlarm(out.record, store, deps);
|
|
70
|
+
await releaseConcurrencyLeaseIfTerminal(out.record, deps);
|
|
65
71
|
return json(200, out.record);
|
|
66
72
|
}
|
|
67
73
|
if (req.method === "POST" && url.pathname === "/cancel") {
|
|
@@ -76,6 +82,7 @@ export async function handleDurableObjectRequest(req, deps) {
|
|
|
76
82
|
return json(status, { error: out.status, message: out.message });
|
|
77
83
|
}
|
|
78
84
|
await reconcileAlarm(out.record, store, deps);
|
|
85
|
+
await releaseConcurrencyLeaseIfTerminal(out.record, deps);
|
|
79
86
|
return json(200, out.record);
|
|
80
87
|
}
|
|
81
88
|
if (req.method === "GET" && url.pathname === "/get") {
|
|
@@ -133,6 +140,7 @@ export async function handleDurableObjectAlarm(deps) {
|
|
|
133
140
|
await driveUntilPaused(record, { handler, now: deps.now });
|
|
134
141
|
await store.save(record);
|
|
135
142
|
await reconcileAlarm(record, store, deps);
|
|
143
|
+
await releaseConcurrencyLeaseIfTerminal(record, deps);
|
|
136
144
|
}
|
|
137
145
|
/**
|
|
138
146
|
* Look at the record's pending waitpoints; if any are DATETIME,
|
|
@@ -177,6 +185,30 @@ async function getStoredRunId(store) {
|
|
|
177
185
|
const all = await store.list();
|
|
178
186
|
return all[0]?.id;
|
|
179
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
|
+
}
|
|
180
212
|
function json(status, body) {
|
|
181
213
|
return new Response(JSON.stringify(body), {
|
|
182
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.
|
|
@@ -58,6 +58,13 @@ export interface TriggerPayload {
|
|
|
58
58
|
wakeAt: number;
|
|
59
59
|
};
|
|
60
60
|
priority?: number;
|
|
61
|
+
triggeredBy?: RunTrigger;
|
|
62
|
+
concurrencyLease?: ConcurrencyLease;
|
|
63
|
+
}
|
|
64
|
+
export interface ConcurrencyLease {
|
|
65
|
+
environment: EnvironmentName;
|
|
66
|
+
key: string;
|
|
67
|
+
runId: string;
|
|
61
68
|
}
|
|
62
69
|
/** Cancel payload. */
|
|
63
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.6",
|
|
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
|
|
30
|
-
"@voyantjs/workflows": "0.30.
|
|
29
|
+
"@voyantjs/workflows": "0.30.6",
|
|
30
|
+
"@voyantjs/workflows-orchestrator": "0.30.6"
|
|
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>> {
|
|
@@ -190,13 +292,10 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
190
292
|
priority: triggerOpts?.priority,
|
|
191
293
|
triggeredBy: { kind: "api" as const },
|
|
192
294
|
}
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
headers: { "content-type": "application/json" },
|
|
198
|
-
body: JSON.stringify(payload),
|
|
199
|
-
}),
|
|
295
|
+
const policy = await resolveConcurrencyPolicy(workflow, workflowId, env)
|
|
296
|
+
const resp = await forwardTrigger(
|
|
297
|
+
payload as TriggerPayload & { runId: string; environment: EnvironmentName },
|
|
298
|
+
policy,
|
|
200
299
|
)
|
|
201
300
|
if (!resp.ok) {
|
|
202
301
|
const body = await safeText(resp)
|
|
@@ -273,13 +372,15 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
273
372
|
},
|
|
274
373
|
}
|
|
275
374
|
try {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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,
|
|
283
384
|
)
|
|
284
385
|
if (resp.ok) {
|
|
285
386
|
matches.push({
|
|
@@ -334,55 +435,60 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
334
435
|
// streamRun are explicitly unsupported per architecture
|
|
335
436
|
// doc §8.3) ----
|
|
336
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
|
+
|
|
337
481
|
const admin: Partial<WorkflowAdmin> = {
|
|
338
482
|
async getRun(runId: string): Promise<RunDetail | null> {
|
|
339
|
-
|
|
340
|
-
const resp = await forwardToRunDO(
|
|
341
|
-
runId,
|
|
342
|
-
new Request("https://do-internal/get", { method: "GET" }),
|
|
343
|
-
)
|
|
344
|
-
if (resp.status === 404) return null
|
|
345
|
-
if (!resp.ok) return null
|
|
346
|
-
const rec = (await resp.json()) as {
|
|
347
|
-
id: string
|
|
348
|
-
workflowId: string
|
|
349
|
-
workflowVersion: string
|
|
350
|
-
status: RunSummary["status"]
|
|
351
|
-
startedAt: number
|
|
352
|
-
completedAt?: number
|
|
353
|
-
tags: string[]
|
|
354
|
-
environment: EnvironmentName
|
|
355
|
-
input: unknown
|
|
356
|
-
output?: unknown
|
|
357
|
-
error?: unknown
|
|
358
|
-
}
|
|
359
|
-
return {
|
|
360
|
-
id: rec.id,
|
|
361
|
-
workflowId: rec.workflowId,
|
|
362
|
-
status: rec.status,
|
|
363
|
-
startedAt: rec.startedAt,
|
|
364
|
-
completedAt: rec.completedAt,
|
|
365
|
-
tags: [...rec.tags],
|
|
366
|
-
environment: rec.environment,
|
|
367
|
-
version: rec.workflowVersion,
|
|
368
|
-
input: rec.input,
|
|
369
|
-
output: rec.output,
|
|
370
|
-
error: rec.error,
|
|
371
|
-
durationMs:
|
|
372
|
-
rec.completedAt !== undefined
|
|
373
|
-
? Math.max(0, rec.completedAt - rec.startedAt)
|
|
374
|
-
: undefined,
|
|
375
|
-
}
|
|
376
|
-
} catch {
|
|
377
|
-
return null
|
|
378
|
-
}
|
|
483
|
+
return getRunDetail(runId)
|
|
379
484
|
},
|
|
380
485
|
|
|
381
486
|
async cancelRun(runId: string, cancelOpts?: { reason?: string; compensate?: boolean }) {
|
|
382
487
|
// Per architecture doc §21.21, cancel does NOT run compensations
|
|
383
488
|
// by default; the `compensate` flag is accepted but no-op in v1.
|
|
384
489
|
void cancelOpts?.compensate
|
|
385
|
-
await
|
|
490
|
+
const detail = opts.concurrencyNamespace ? await getRunDetail(runId) : null
|
|
491
|
+
const resp = await forwardToRunDO(
|
|
386
492
|
runId,
|
|
387
493
|
new Request("https://do-internal/cancel", {
|
|
388
494
|
method: "POST",
|
|
@@ -390,6 +496,7 @@ export function createCloudflareEdgeDriver(opts: CloudflareEdgeDriverOptions): D
|
|
|
390
496
|
body: JSON.stringify({ reason: cancelOpts?.reason }),
|
|
391
497
|
}),
|
|
392
498
|
)
|
|
499
|
+
if (resp.ok) await releaseConcurrencySlot(detail)
|
|
393
500
|
},
|
|
394
501
|
|
|
395
502
|
async listRuns(_listOpts?: ListRunsOptions) {
|
|
@@ -438,3 +545,11 @@ async function safeText(resp: Response): Promise<string> {
|
|
|
438
545
|
return ""
|
|
439
546
|
}
|
|
440
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,6 +96,7 @@ 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)
|
|
@@ -114,6 +123,7 @@ export async function handleDurableObjectRequest(
|
|
|
114
123
|
return json(status, { error: out.status, message: out.message })
|
|
115
124
|
}
|
|
116
125
|
await reconcileAlarm(out.record, store, deps)
|
|
126
|
+
await releaseConcurrencyLeaseIfTerminal(out.record, deps)
|
|
117
127
|
return json(200, out.record)
|
|
118
128
|
}
|
|
119
129
|
|
|
@@ -131,6 +141,7 @@ export async function handleDurableObjectRequest(
|
|
|
131
141
|
return json(status, { error: out.status, message: out.message })
|
|
132
142
|
}
|
|
133
143
|
await reconcileAlarm(out.record, store, deps)
|
|
144
|
+
await releaseConcurrencyLeaseIfTerminal(out.record, deps)
|
|
134
145
|
return json(200, out.record)
|
|
135
146
|
}
|
|
136
147
|
|
|
@@ -189,6 +200,7 @@ export async function handleDurableObjectAlarm(deps: DurableObjectDeps): Promise
|
|
|
189
200
|
await driveUntilPaused(record, { handler, now: deps.now })
|
|
190
201
|
await store.save(record)
|
|
191
202
|
await reconcileAlarm(record, store, deps)
|
|
203
|
+
await releaseConcurrencyLeaseIfTerminal(record, deps)
|
|
192
204
|
}
|
|
193
205
|
|
|
194
206
|
/**
|
|
@@ -239,6 +251,38 @@ async function getStoredRunId(
|
|
|
239
251
|
return all[0]?.id
|
|
240
252
|
}
|
|
241
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
|
+
|
|
242
286
|
function json(status: number, body: unknown): Response {
|
|
243
287
|
return new Response(JSON.stringify(body), {
|
|
244
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
|
/**
|
|
@@ -61,6 +61,14 @@ export interface TriggerPayload {
|
|
|
61
61
|
idempotencyKey?: string
|
|
62
62
|
delay?: Duration | { wakeAt: number }
|
|
63
63
|
priority?: number
|
|
64
|
+
triggeredBy?: RunTrigger
|
|
65
|
+
concurrencyLease?: ConcurrencyLease
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ConcurrencyLease {
|
|
69
|
+
environment: EnvironmentName
|
|
70
|
+
key: string
|
|
71
|
+
runId: string
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
/** Cancel payload. */
|