@voyant-travel/workflows-orchestrator 0.107.10
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/LICENSE +201 -0
- package/NOTICE +52 -0
- package/README.md +76 -0
- package/dist/abort-registry.d.ts +6 -0
- package/dist/abort-registry.d.ts.map +1 -0
- package/dist/abort-registry.js +37 -0
- package/dist/concurrency.d.ts +31 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +145 -0
- package/dist/drive.d.ts +67 -0
- package/dist/drive.d.ts.map +1 -0
- package/dist/drive.js +373 -0
- package/dist/driver-inmemory.d.ts +30 -0
- package/dist/driver-inmemory.d.ts.map +1 -0
- package/dist/driver-inmemory.js +394 -0
- package/dist/event-router.d.ts +51 -0
- package/dist/event-router.d.ts.map +1 -0
- package/dist/event-router.js +68 -0
- package/dist/http-step-handler.d.ts +25 -0
- package/dist/http-step-handler.d.ts.map +1 -0
- package/dist/http-step-handler.js +78 -0
- package/dist/in-memory-store.d.ts +5 -0
- package/dist/in-memory-store.d.ts.map +1 -0
- package/dist/in-memory-store.js +41 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/journal-helpers.d.ts +3 -0
- package/dist/journal-helpers.d.ts.map +1 -0
- package/dist/journal-helpers.js +9 -0
- package/dist/orchestrator.d.ts +116 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +411 -0
- package/dist/resume-run.d.ts +40 -0
- package/dist/resume-run.d.ts.map +1 -0
- package/dist/resume-run.js +119 -0
- package/dist/schedule.d.ts +51 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +243 -0
- package/dist/testing/driver-compliance.d.ts +58 -0
- package/dist/testing/driver-compliance.d.ts.map +1 -0
- package/dist/testing/driver-compliance.js +667 -0
- package/dist/types.d.ts +182 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +51 -0
- package/src/__tests__/orchestrator-test-support.ts +18 -0
- package/src/abort-registry.ts +41 -0
- package/src/concurrency.ts +217 -0
- package/src/drive.ts +477 -0
- package/src/driver-inmemory.ts +511 -0
- package/src/event-router.ts +120 -0
- package/src/http-step-handler.ts +112 -0
- package/src/in-memory-store.ts +44 -0
- package/src/index.ts +73 -0
- package/src/journal-helpers.ts +11 -0
- package/src/orchestrator.ts +527 -0
- package/src/resume-run.ts +162 -0
- package/src/schedule.ts +310 -0
- package/src/testing/driver-compliance.ts +800 -0
- package/src/types.ts +201 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { RunTrigger, WaitpointKind } from "@voyant-travel/workflows";
|
|
2
|
+
import type { StepHandlerError, WorkflowStepRequest, WorkflowStepResponse } from "@voyant-travel/workflows/handler";
|
|
3
|
+
import type { CompensationJournalEntry, JournalSlice, StepJournalEntry, WaitpointResolutionEntry } from "@voyant-travel/workflows/protocol";
|
|
4
|
+
export type { CompensationJournalEntry, JournalSlice, RunTrigger, StepJournalEntry, WaitpointKind, WaitpointResolutionEntry, WorkflowStepRequest, WorkflowStepResponse, };
|
|
5
|
+
/**
|
|
6
|
+
* Terminal and non-terminal run statuses. Mirrors the wire statuses
|
|
7
|
+
* from docs/runtime-protocol.md §5 but only the states the orchestrator
|
|
8
|
+
* itself cares about.
|
|
9
|
+
*/
|
|
10
|
+
export type OrchestratorRunStatus = "running" | "waiting" | "completed" | "failed" | "cancelled" | "compensated" | "compensation_failed";
|
|
11
|
+
export interface PendingWaitpoint {
|
|
12
|
+
clientWaitpointId: string;
|
|
13
|
+
kind: WaitpointKind;
|
|
14
|
+
meta: Record<string, unknown>;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface StreamChunk {
|
|
18
|
+
streamId: string;
|
|
19
|
+
seq: number;
|
|
20
|
+
encoding: "text" | "json" | "base64";
|
|
21
|
+
chunk: unknown;
|
|
22
|
+
final: boolean;
|
|
23
|
+
at: number;
|
|
24
|
+
}
|
|
25
|
+
export interface RunRecord {
|
|
26
|
+
id: string;
|
|
27
|
+
workflowId: string;
|
|
28
|
+
workflowVersion: string;
|
|
29
|
+
status: OrchestratorRunStatus;
|
|
30
|
+
input: unknown;
|
|
31
|
+
/** Output on "completed" runs. */
|
|
32
|
+
output?: unknown;
|
|
33
|
+
/** SerializedError for failed / compensation_failed. */
|
|
34
|
+
error?: {
|
|
35
|
+
category: string;
|
|
36
|
+
code: string;
|
|
37
|
+
message: string;
|
|
38
|
+
};
|
|
39
|
+
/** Journal accumulated across every tenant invocation. */
|
|
40
|
+
journal: JournalSlice;
|
|
41
|
+
/** Number of tenant invocations performed so far. */
|
|
42
|
+
invocationCount: number;
|
|
43
|
+
/**
|
|
44
|
+
* Positional dedup cursor for `metadataUpdates`. Each executor
|
|
45
|
+
* response re-emits every metadata mutation the body made (including
|
|
46
|
+
* those from prior invocations, since the body replays from the
|
|
47
|
+
* start). The orchestrator applies only the delta beyond what it's
|
|
48
|
+
* seen to avoid double-counting increments / duplicate appends.
|
|
49
|
+
*/
|
|
50
|
+
metadataAppliedCount: number;
|
|
51
|
+
/**
|
|
52
|
+
* Cumulative ms spent inside tenant invocations, excluding parked
|
|
53
|
+
* time. Used to enforce workflow-level timeouts — a run that
|
|
54
|
+
* parks for a week on a waitpoint should not count that week
|
|
55
|
+
* against its compute budget.
|
|
56
|
+
*/
|
|
57
|
+
computeTimeMs: number;
|
|
58
|
+
/** ms budget from WorkflowConfig.timeout. Zero / undefined = no limit. */
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Trigger-time scheduling priority. Higher numbers are claimed first
|
|
62
|
+
* by store-backed time wheels when multiple runs are due.
|
|
63
|
+
*/
|
|
64
|
+
priority?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Cross-run lineage. Present on child runs created by
|
|
67
|
+
* `ctx.invoke`; set by the orchestrator when the child parks so
|
|
68
|
+
* its eventual terminal transition can cascade-resume the parent.
|
|
69
|
+
*/
|
|
70
|
+
parent?: {
|
|
71
|
+
runId: string;
|
|
72
|
+
waitpointId: string;
|
|
73
|
+
};
|
|
74
|
+
/** Pending waitpoints, only populated when status === "waiting". */
|
|
75
|
+
pendingWaitpoints: PendingWaitpoint[];
|
|
76
|
+
/** Stream chunks accumulated across every tenant invocation, keyed by streamId. */
|
|
77
|
+
streams: Record<string, StreamChunk[]>;
|
|
78
|
+
/** Wall-clock fields, all ms-since-epoch. */
|
|
79
|
+
startedAt: number;
|
|
80
|
+
completedAt?: number;
|
|
81
|
+
/** Trigger metadata. */
|
|
82
|
+
triggeredBy: RunTrigger;
|
|
83
|
+
tags: string[];
|
|
84
|
+
/** Optional environment metadata. */
|
|
85
|
+
environment: "production" | "preview" | "development";
|
|
86
|
+
/** Tenant identity flows through every step request. */
|
|
87
|
+
tenantMeta: {
|
|
88
|
+
tenantId: string;
|
|
89
|
+
projectId: string;
|
|
90
|
+
organizationId: string;
|
|
91
|
+
projectSlug?: string;
|
|
92
|
+
organizationSlug?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Identifier the runtime adapter uses to locate the tenant's
|
|
95
|
+
* code. On Cloudflare this is the dispatch-namespace script name;
|
|
96
|
+
* on other targets it may be a container image tag or Node script
|
|
97
|
+
* path. Optional so local/in-process drivers aren't forced to set it.
|
|
98
|
+
*/
|
|
99
|
+
tenantScript?: string;
|
|
100
|
+
};
|
|
101
|
+
runMeta: {
|
|
102
|
+
number: number;
|
|
103
|
+
attempt: number;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Caller-supplied idempotency token, mirrored from `TriggerArgs.idempotencyKey`.
|
|
107
|
+
* Persistent stores use this column to enforce dedup natively (e.g. unique
|
|
108
|
+
* partial index in `voyant_snapshot_runs`); in-process stores ignore it.
|
|
109
|
+
*/
|
|
110
|
+
idempotencyKey?: string;
|
|
111
|
+
}
|
|
112
|
+
export interface RunRecordStore {
|
|
113
|
+
get(id: string): Promise<RunRecord | undefined>;
|
|
114
|
+
save(record: RunRecord): Promise<RunRecord>;
|
|
115
|
+
/**
|
|
116
|
+
* Atomically insert a new run record, OR return the existing one if
|
|
117
|
+
* a record with the same `id` already exists. Used by `trigger()` to
|
|
118
|
+
* close the get-then-save race window when an idempotency-derived
|
|
119
|
+
* runId could collide across concurrent callers.
|
|
120
|
+
*
|
|
121
|
+
* Stores must implement this with a single atomic operation:
|
|
122
|
+
* - Postgres: `INSERT … ON CONFLICT (id) DO NOTHING RETURNING …`,
|
|
123
|
+
* with a fallback SELECT when no row is returned.
|
|
124
|
+
* - InMemory / FS: check-then-set in a single microtask.
|
|
125
|
+
* - DO storage: get-then-save is naturally atomic per DO instance.
|
|
126
|
+
*
|
|
127
|
+
* The returned `created: true` means this caller's record was the
|
|
128
|
+
* winner — drive it. `created: false` means another caller raced in
|
|
129
|
+
* first; return the existing record without re-driving (per
|
|
130
|
+
* architecture doc §15.2).
|
|
131
|
+
*/
|
|
132
|
+
tryInsert(record: RunRecord): Promise<{
|
|
133
|
+
record: RunRecord;
|
|
134
|
+
created: boolean;
|
|
135
|
+
}>;
|
|
136
|
+
list(filter?: {
|
|
137
|
+
workflowId?: string;
|
|
138
|
+
status?: OrchestratorRunStatus;
|
|
139
|
+
limit?: number;
|
|
140
|
+
}): Promise<RunRecord[]>;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The tenant-side step handler. In-process for tests / local dev via
|
|
144
|
+
* `handleStepRequest` from @voyant-travel/workflows/handler; HTTP in
|
|
145
|
+
* production (via a fetch to the tenant Worker). The optional
|
|
146
|
+
* per-invocation `signal` aborts in-flight step bodies when the run
|
|
147
|
+
* is cancelled mid-execution.
|
|
148
|
+
*/
|
|
149
|
+
export type StepHandler = (req: WorkflowStepRequest, opts?: {
|
|
150
|
+
signal?: AbortSignal;
|
|
151
|
+
/**
|
|
152
|
+
* Fires synchronously from `ctx.stream.*` as each chunk is
|
|
153
|
+
* produced. In-process only; HTTP transport drops it (chunks
|
|
154
|
+
* still arrive in the response body). Used by orchestrators to
|
|
155
|
+
* broadcast chunks to dashboards before the invocation returns.
|
|
156
|
+
*/
|
|
157
|
+
onStreamChunk?: (chunk: StreamChunk) => void;
|
|
158
|
+
}) => Promise<{
|
|
159
|
+
status: number;
|
|
160
|
+
body: WorkflowStepResponse;
|
|
161
|
+
} | {
|
|
162
|
+
status: number;
|
|
163
|
+
body: StepHandlerError;
|
|
164
|
+
}>;
|
|
165
|
+
/**
|
|
166
|
+
* Injection delivered by an external signal (dashboard, API, inbox).
|
|
167
|
+
* Same shape used by the dashboard inject endpoints.
|
|
168
|
+
*/
|
|
169
|
+
export type WaitpointInjection = {
|
|
170
|
+
kind: "EVENT";
|
|
171
|
+
eventType: string;
|
|
172
|
+
payload?: unknown;
|
|
173
|
+
} | {
|
|
174
|
+
kind: "SIGNAL";
|
|
175
|
+
name: string;
|
|
176
|
+
payload?: unknown;
|
|
177
|
+
} | {
|
|
178
|
+
kind: "MANUAL";
|
|
179
|
+
tokenId: string;
|
|
180
|
+
payload?: unknown;
|
|
181
|
+
};
|
|
182
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voyant-travel/workflows-orchestrator",
|
|
3
|
+
"version": "0.107.10",
|
|
4
|
+
"description": "Reference orchestrator core for Voyant Workflows — drives runs through the tenant step handler over the v1 wire protocol.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/voyant-travel/voyant.git",
|
|
9
|
+
"directory": "packages/workflows-orchestrator"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://voyant.cloud/workflows",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./testing": {
|
|
19
|
+
"types": "./dist/testing/driver-compliance.d.ts",
|
|
20
|
+
"import": "./dist/testing/driver-compliance.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"!**/*.test.*",
|
|
29
|
+
"!**/*.spec.*",
|
|
30
|
+
"NOTICE"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@voyant-travel/workflows": "^0.107.10"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^20.12.0",
|
|
37
|
+
"typescript": "^5.9.2",
|
|
38
|
+
"vitest": "^3.2.6",
|
|
39
|
+
"@voyant-travel/voyant-typescript-config": "^0.1.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"check-types": "tsc --noEmit",
|
|
47
|
+
"dev": "tsc -w -p tsconfig.json",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { __resetRegistry } from "@voyant-travel/workflows"
|
|
2
|
+
import { handleStepRequest, type WorkflowStepRequest } from "@voyant-travel/workflows/handler"
|
|
3
|
+
import { beforeEach } from "vitest"
|
|
4
|
+
import type { StepHandler } from "../index.js"
|
|
5
|
+
|
|
6
|
+
export const handler: StepHandler = async (req: WorkflowStepRequest, opts) => {
|
|
7
|
+
return await handleStepRequest(req, {}, opts)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const tenantMeta = {
|
|
11
|
+
tenantId: "tnt_test",
|
|
12
|
+
projectId: "prj_test",
|
|
13
|
+
organizationId: "org_test",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
__resetRegistry()
|
|
18
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Process-local map of runId → AbortController. The orchestrator
|
|
2
|
+
// registers a controller when it starts driving a run and aborts it
|
|
3
|
+
// when `cancel(runId)` lands mid-flight. Step bodies that honor
|
|
4
|
+
// `ctx.signal` (fetches, delays, custom logic) observe the abort and
|
|
5
|
+
// can clean up deterministically.
|
|
6
|
+
//
|
|
7
|
+
// Single-process scope by design: each Durable Object / Node serve
|
|
8
|
+
// has its own registry. Cross-process cancellation rides through
|
|
9
|
+
// the run store (see `beforeInvocation` in orchestrator.ts) and
|
|
10
|
+
// catches up at the next invocation boundary.
|
|
11
|
+
|
|
12
|
+
const controllers = new Map<string, AbortController>()
|
|
13
|
+
|
|
14
|
+
export function registerRunAbort(runId: string): AbortController {
|
|
15
|
+
// If a controller already exists, reuse it (e.g., resume after
|
|
16
|
+
// park — the same run id).
|
|
17
|
+
let ctrl = controllers.get(runId)
|
|
18
|
+
if (!ctrl) {
|
|
19
|
+
ctrl = new AbortController()
|
|
20
|
+
controllers.set(runId, ctrl)
|
|
21
|
+
}
|
|
22
|
+
return ctrl
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function signalRunAbort(runId: string, reason?: string): boolean {
|
|
26
|
+
const ctrl = controllers.get(runId)
|
|
27
|
+
if (!ctrl) return false
|
|
28
|
+
// `AbortController.abort(reason)` is standard across modern runtimes;
|
|
29
|
+
// the reason surfaces to observers via `signal.reason`.
|
|
30
|
+
ctrl.abort(reason ?? "cancelled")
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function unregisterRunAbort(runId: string): void {
|
|
35
|
+
controllers.delete(runId)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** For tests. */
|
|
39
|
+
export function __clearAbortRegistry(): void {
|
|
40
|
+
controllers.clear()
|
|
41
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { ConcurrencyPolicy } from "@voyant-travel/workflows"
|
|
2
|
+
|
|
3
|
+
import type { RunRecord } from "./types.js"
|
|
4
|
+
|
|
5
|
+
export type RuntimeConcurrencyPolicy =
|
|
6
|
+
| ConcurrencyPolicy<unknown>
|
|
7
|
+
| {
|
|
8
|
+
key?: string
|
|
9
|
+
limit?: number
|
|
10
|
+
strategy?: "queue" | "cancel-in-progress" | "cancel-newest" | "round-robin"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ConcurrencyRunHooks {
|
|
14
|
+
onRunRecordCreated(record: RunRecord): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class WorkflowConcurrencyRejectedError extends Error {
|
|
18
|
+
readonly code = "WORKFLOW_CONCURRENCY_REJECTED"
|
|
19
|
+
|
|
20
|
+
constructor(readonly concurrencyKey: string) {
|
|
21
|
+
super(`workflow concurrency limit reached for key "${concurrencyKey}"`)
|
|
22
|
+
this.name = "WorkflowConcurrencyRejectedError"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface InProcessConcurrencyCoordinator {
|
|
27
|
+
run(
|
|
28
|
+
args: {
|
|
29
|
+
workflowId: string
|
|
30
|
+
input: unknown
|
|
31
|
+
policy?: RuntimeConcurrencyPolicy
|
|
32
|
+
holderId?: string
|
|
33
|
+
},
|
|
34
|
+
operation: (hooks: ConcurrencyRunHooks) => Promise<RunRecord>,
|
|
35
|
+
): Promise<RunRecord>
|
|
36
|
+
releaseRun(recordOrRunId: RunRecord | string): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Group {
|
|
40
|
+
active: Set<string>
|
|
41
|
+
waiters: Waiter[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Slot {
|
|
45
|
+
key: string
|
|
46
|
+
holderId: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Waiter {
|
|
50
|
+
holderId: string
|
|
51
|
+
resolve(slot: Slot): void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface CreateInProcessConcurrencyCoordinatorOptions {
|
|
55
|
+
cancelRun?: (runId: string, reason: string) => Promise<unknown>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TOKEN_PREFIX = "concurrency-slot:"
|
|
59
|
+
|
|
60
|
+
export function createInProcessConcurrencyCoordinator(
|
|
61
|
+
opts: CreateInProcessConcurrencyCoordinatorOptions = {},
|
|
62
|
+
): InProcessConcurrencyCoordinator {
|
|
63
|
+
const groups = new Map<string, Group>()
|
|
64
|
+
const runKeys = new Map<string, string>()
|
|
65
|
+
let nextToken = 0
|
|
66
|
+
|
|
67
|
+
function getGroup(key: string): Group {
|
|
68
|
+
let group = groups.get(key)
|
|
69
|
+
if (!group) {
|
|
70
|
+
group = { active: new Set(), waiters: [] }
|
|
71
|
+
groups.set(key, group)
|
|
72
|
+
}
|
|
73
|
+
return group
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createToken(): string {
|
|
77
|
+
nextToken += 1
|
|
78
|
+
return `${TOKEN_PREFIX}${nextToken}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function acquire(
|
|
82
|
+
key: string,
|
|
83
|
+
limit: number,
|
|
84
|
+
strategy: NonNullable<RuntimeConcurrencyPolicy["strategy"]>,
|
|
85
|
+
preferredHolderId?: string,
|
|
86
|
+
): Promise<Slot> {
|
|
87
|
+
const group = getGroup(key)
|
|
88
|
+
if (preferredHolderId && group.active.has(preferredHolderId)) {
|
|
89
|
+
return { key, holderId: preferredHolderId }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const holderId = preferredHolderId ?? createToken()
|
|
93
|
+
if (group.active.size < limit) {
|
|
94
|
+
group.active.add(holderId)
|
|
95
|
+
return { key, holderId }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (strategy === "cancel-newest") {
|
|
99
|
+
throw new WorkflowConcurrencyRejectedError(key)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (strategy === "cancel-in-progress") {
|
|
103
|
+
const holders = [...group.active]
|
|
104
|
+
for (const holder of holders) {
|
|
105
|
+
group.active.delete(holder)
|
|
106
|
+
runKeys.delete(holder)
|
|
107
|
+
if (!holder.startsWith(TOKEN_PREFIX)) {
|
|
108
|
+
await opts.cancelRun?.(holder, "cancelled by workflow concurrency policy")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
group.active.add(holderId)
|
|
112
|
+
return { key, holderId }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
group.waiters.push({
|
|
117
|
+
holderId,
|
|
118
|
+
resolve(slot) {
|
|
119
|
+
resolve(slot)
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function assignRunId(slot: Slot, record: RunRecord): Slot {
|
|
126
|
+
const group = getGroup(slot.key)
|
|
127
|
+
if (group.active.delete(slot.holderId)) {
|
|
128
|
+
group.active.add(record.id)
|
|
129
|
+
}
|
|
130
|
+
runKeys.set(record.id, slot.key)
|
|
131
|
+
return { key: slot.key, holderId: record.id }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function releaseSlot(slot: Slot): void {
|
|
135
|
+
const group = groups.get(slot.key)
|
|
136
|
+
if (!group) return
|
|
137
|
+
group.active.delete(slot.holderId)
|
|
138
|
+
runKeys.delete(slot.holderId)
|
|
139
|
+
drain(slot.key, group)
|
|
140
|
+
if (group.active.size === 0 && group.waiters.length === 0) {
|
|
141
|
+
groups.delete(slot.key)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function drain(key: string, group: Group): void {
|
|
146
|
+
while (group.waiters.length > 0) {
|
|
147
|
+
const waiter = group.waiters.shift()
|
|
148
|
+
if (!waiter) return
|
|
149
|
+
group.active.add(waiter.holderId)
|
|
150
|
+
waiter.resolve({ key, holderId: waiter.holderId })
|
|
151
|
+
if (group.active.size > 0) return
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
async run(args, operation) {
|
|
157
|
+
const policy = args.policy
|
|
158
|
+
if (!policy) {
|
|
159
|
+
return operation({ onRunRecordCreated() {} })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const key = resolveConcurrencyKey(args.workflowId, args.input, policy)
|
|
163
|
+
const limit = normalizeLimit(policy.limit)
|
|
164
|
+
const strategy = policy.strategy ?? "queue"
|
|
165
|
+
let slot = await acquire(key, limit, strategy, args.holderId)
|
|
166
|
+
let record: RunRecord | undefined
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
record = await operation({
|
|
170
|
+
onRunRecordCreated(created) {
|
|
171
|
+
slot = assignRunId(slot, created)
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
if (!runKeys.has(record.id)) {
|
|
175
|
+
slot = assignRunId(slot, record)
|
|
176
|
+
}
|
|
177
|
+
return record
|
|
178
|
+
} finally {
|
|
179
|
+
if (!record || isTerminal(record.status)) {
|
|
180
|
+
releaseSlot(record ? { key: slot.key, holderId: record.id } : slot)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
releaseRun(recordOrRunId) {
|
|
186
|
+
const runId = typeof recordOrRunId === "string" ? recordOrRunId : recordOrRunId.id
|
|
187
|
+
const key = runKeys.get(runId)
|
|
188
|
+
if (!key) return
|
|
189
|
+
releaseSlot({ key, holderId: runId })
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function resolveConcurrencyKey(
|
|
195
|
+
workflowId: string,
|
|
196
|
+
input: unknown,
|
|
197
|
+
policy: RuntimeConcurrencyPolicy,
|
|
198
|
+
): string {
|
|
199
|
+
const rawKey = typeof policy.key === "function" ? policy.key(input) : policy.key
|
|
200
|
+
return `${workflowId}:${rawKey ?? "default"}`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeLimit(limit: number | undefined): number {
|
|
204
|
+
if (limit === undefined) return 1
|
|
205
|
+
if (!Number.isFinite(limit)) return 1
|
|
206
|
+
return Math.max(1, Math.floor(limit))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isTerminal(status: RunRecord["status"]): boolean {
|
|
210
|
+
return (
|
|
211
|
+
status === "completed" ||
|
|
212
|
+
status === "failed" ||
|
|
213
|
+
status === "cancelled" ||
|
|
214
|
+
status === "compensated" ||
|
|
215
|
+
status === "compensation_failed"
|
|
216
|
+
)
|
|
217
|
+
}
|