deepline 0.1.243 → 0.1.244
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +5 -25
- package/dist/bundling-sources/sdk/src/release.ts +23 -21
- package/dist/bundling-sources/sdk/src/types.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/backend.ts +2 -21
- package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +5 -6
- package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +6 -13
- package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +9 -26
- package/dist/bundling-sources/shared_libs/play-runtime/internal-step-ids.ts +1 -2
- package/dist/bundling-sources/shared_libs/play-runtime/map-row-identity.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +191 -29
- package/dist/bundling-sources/shared_libs/play-runtime/play-latency-trace.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/providers.ts +1 -36
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +2 -3
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/index.ts +0 -7
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +1 -12
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver-pg.ts +2 -3
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver.ts +5 -15
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +6 -21
- package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +3 -4
- package/dist/bundling-sources/shared_libs/plays/artifact-kind-guard.ts +16 -29
- package/dist/bundling-sources/shared_libs/plays/artifact-types.ts +4 -8
- package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +21 -712
- package/dist/bundling-sources/shared_libs/plays/bundling/limits.ts +1 -18
- package/dist/cli/index.js +29 -71
- package/dist/cli/index.mjs +29 -71
- package/dist/{compiler-manifest-CYcwzSOJ.d.ts → compiler-manifest-DlE7dnRm.d.mts} +2 -8
- package/dist/{compiler-manifest-CYcwzSOJ.d.mts → compiler-manifest-DlE7dnRm.d.ts} +2 -8
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +24 -22
- package/dist/index.mjs +24 -22
- package/dist/plays/bundle-play-file.d.mts +7 -22
- package/dist/plays/bundle-play-file.d.ts +7 -22
- package/dist/plays/bundle-play-file.mjs +26 -614
- package/package.json +2 -2
- package/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +0 -4504
- package/dist/bundling-sources/apps/play-runner-workers/src/dedup-do.ts +0 -1386
- package/dist/bundling-sources/apps/play-runner-workers/src/durable-object-deploy-handoff.ts +0 -30
- package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +0 -8385
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/README.md +0 -21
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/batching.ts +0 -251
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/coordinator-progress.ts +0 -289
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/csv-rows.ts +0 -85
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/customer-console.ts +0 -23
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/dataset-handles.ts +0 -528
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/harness-receipt-store.ts +0 -229
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/ledger-event-batches.ts +0 -81
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/live-progress.ts +0 -22
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-chunk-plan.ts +0 -920
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-latency-profile.ts +0 -90
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/output-datasets.ts +0 -475
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +0 -570
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/result-dataset-persistence.ts +0 -50
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/row-isolation.ts +0 -5
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/run-work-dispatcher.ts +0 -519
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-batch.ts +0 -107
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +0 -2541
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-http-errors.ts +0 -7
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +0 -227
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/work-budget.ts +0 -130
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/worker-platform-budget.ts +0 -39
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/workflow-preview.ts +0 -26
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime-api-retry.ts +0 -23
- package/dist/bundling-sources/apps/play-runner-workers/src/workflow-instance-create.ts +0 -47
- package/dist/bundling-sources/apps/play-runner-workers/src/workflow-retry-state.ts +0 -167
- package/dist/bundling-sources/apps/play-runner-workers/src/workflow-retry.ts +0 -91
- package/dist/bundling-sources/sdk/src/plays/harness-stub.ts +0 -329
- package/dist/bundling-sources/sdk/src/worker-play-entry.ts +0 -240
- package/dist/bundling-sources/shared_libs/play-runtime/governor/coordinator-rate-state-backend.ts +0 -252
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver-neon-serverless.ts +0 -217
|
@@ -1,1386 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PlayDedup Durable Object — cross-attempt idempotency cache for one play run.
|
|
3
|
-
*
|
|
4
|
-
* One DO instance per runId. Workers and queue-consumers fetch into it to:
|
|
5
|
-
* - claim a hash to compute (atomic; first claimer wins)
|
|
6
|
-
* - publish a result (unblocks awaiters)
|
|
7
|
-
* - long-poll await for a result claimed by another caller
|
|
8
|
-
*
|
|
9
|
-
* Storage shape (DO storage):
|
|
10
|
-
* key: `${toolId}:${inputHash}`
|
|
11
|
-
* value: DedupEntry
|
|
12
|
-
*
|
|
13
|
-
* The whole DO is evicted via `/finish` at run end. Storage is single-region
|
|
14
|
-
* but strongly-consistent within the DO instance — exactly what dedup needs.
|
|
15
|
-
*
|
|
16
|
-
* Latency: ~5ms per request (DO storage is sub-ms; HTTP add ~3-5ms).
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { sanitizeLiveLogLines } from './runtime/live-progress';
|
|
20
|
-
import {
|
|
21
|
-
DB_SESSION_MAX_TTL_SECONDS,
|
|
22
|
-
type PreloadedRuntimeDbSession,
|
|
23
|
-
} from '../../../shared_libs/play-runtime/db-session';
|
|
24
|
-
import { createSecretRedactionContext } from '../../../shared_libs/play-runtime/secret-redaction';
|
|
25
|
-
|
|
26
|
-
const durableStorageRedactor = createSecretRedactionContext();
|
|
27
|
-
|
|
28
|
-
type DurableObjectStorage = {
|
|
29
|
-
get<T>(key: string): Promise<T | undefined>;
|
|
30
|
-
get<T>(keys: string[]): Promise<Map<string, T>>;
|
|
31
|
-
put<T>(key: string, value: T): Promise<void>;
|
|
32
|
-
put<T>(entries: Record<string, T>): Promise<void>;
|
|
33
|
-
delete(key: string | string[]): Promise<unknown>;
|
|
34
|
-
deleteAll(): Promise<void>;
|
|
35
|
-
list<T>(options?: { prefix?: string }): Promise<Map<string, T>>;
|
|
36
|
-
setAlarm(scheduledTime: number): Promise<void>;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
type DurableObjectState = {
|
|
40
|
-
storage: DurableObjectStorage;
|
|
41
|
-
blockConcurrencyWhile<T>(callback: () => Promise<T>): Promise<T>;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
type DurableObject = {
|
|
45
|
-
fetch(request: Request): Promise<Response>;
|
|
46
|
-
alarm?(): Promise<void>;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
type DurableObjectId = {
|
|
50
|
-
toString(): string;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
type DurableObjectStub = {
|
|
54
|
-
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
type DurableObjectNamespace = {
|
|
58
|
-
idFromName(name: string): DurableObjectId;
|
|
59
|
-
get(id: DurableObjectId): DurableObjectStub;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
type DedupEntry =
|
|
63
|
-
| { status: 'in_flight'; claimedBy: string; claimedAt: number }
|
|
64
|
-
| { status: 'completed'; result: unknown; completedAt: number }
|
|
65
|
-
| { status: 'failed'; error: string; failedAt: number };
|
|
66
|
-
|
|
67
|
-
type LookupOrClaimRequest = {
|
|
68
|
-
runId: string;
|
|
69
|
-
toolId: string;
|
|
70
|
-
inputHashes: string[];
|
|
71
|
-
/** Identifier for the caller — used as `claimedBy` for in-flight entries. */
|
|
72
|
-
callerId: string;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
type LookupOrClaimResponse = {
|
|
76
|
-
hits: Record<string, unknown>;
|
|
77
|
-
claimed: string[];
|
|
78
|
-
inFlight: string[];
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
type PublishRequest = {
|
|
82
|
-
runId: string;
|
|
83
|
-
toolId: string;
|
|
84
|
-
results: Array<{ inputHash: string; result: unknown }>;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
type AwaitRequest = {
|
|
88
|
-
runId: string;
|
|
89
|
-
toolId: string;
|
|
90
|
-
inputHashes: string[];
|
|
91
|
-
timeoutMs: number;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
type WorkflowRunRetryState = {
|
|
95
|
-
runId: string;
|
|
96
|
-
params: unknown;
|
|
97
|
-
paramsRef?: unknown;
|
|
98
|
-
paramsBytes?: number;
|
|
99
|
-
retryAttempts: number;
|
|
100
|
-
updatedAt: number;
|
|
101
|
-
expiresAt: number;
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
type WorkflowInstanceState = {
|
|
105
|
-
runId: string;
|
|
106
|
-
instanceId: string;
|
|
107
|
-
updatedAt: number;
|
|
108
|
-
expiresAt: number;
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
type WorkflowSubmitClaimState = {
|
|
112
|
-
runId: string;
|
|
113
|
-
status: 'creating' | 'created';
|
|
114
|
-
instanceId?: string;
|
|
115
|
-
claimedAt: number;
|
|
116
|
-
updatedAt: number;
|
|
117
|
-
expiresAt: number;
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
type WorkflowDbSessionsState = {
|
|
121
|
-
runId: string;
|
|
122
|
-
sessions: PreloadedRuntimeDbSession[];
|
|
123
|
-
storedAt: number;
|
|
124
|
-
expiresAt: number;
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
type CoordinatorTraceEntry = {
|
|
128
|
-
ts: number;
|
|
129
|
-
source: 'coordinator' | 'dynamic_worker';
|
|
130
|
-
runId: string;
|
|
131
|
-
phase: string;
|
|
132
|
-
ms: number;
|
|
133
|
-
graphHash?: string | null;
|
|
134
|
-
[key: string]: unknown;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
type CoordinatorTerminalState = {
|
|
138
|
-
runId: string;
|
|
139
|
-
status: 'completed' | 'failed' | 'cancelled';
|
|
140
|
-
result?: unknown;
|
|
141
|
-
error?: string | null;
|
|
142
|
-
totalRows?: unknown;
|
|
143
|
-
durationMs?: unknown;
|
|
144
|
-
playName?: string | null;
|
|
145
|
-
liveLogs?: string[] | null;
|
|
146
|
-
liveNodeProgress?: unknown;
|
|
147
|
-
completedAt: number;
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
type CoordinatorRunEvent =
|
|
151
|
-
| {
|
|
152
|
-
seq: number;
|
|
153
|
-
runId: string;
|
|
154
|
-
type: 'status';
|
|
155
|
-
status: string;
|
|
156
|
-
ts: number;
|
|
157
|
-
logs?: string[];
|
|
158
|
-
}
|
|
159
|
-
| {
|
|
160
|
-
seq: number;
|
|
161
|
-
runId: string;
|
|
162
|
-
type: 'log';
|
|
163
|
-
line: string;
|
|
164
|
-
ts: number;
|
|
165
|
-
}
|
|
166
|
-
| {
|
|
167
|
-
seq: number;
|
|
168
|
-
runId: string;
|
|
169
|
-
type: 'progress';
|
|
170
|
-
status: string;
|
|
171
|
-
ts: number;
|
|
172
|
-
logs?: string[];
|
|
173
|
-
activeNodeId?: string | null;
|
|
174
|
-
activeArtifactTableNamespace?: string | null;
|
|
175
|
-
updatedAt?: number | null;
|
|
176
|
-
liveNodeProgress?: unknown;
|
|
177
|
-
}
|
|
178
|
-
| {
|
|
179
|
-
seq: number;
|
|
180
|
-
runId: string;
|
|
181
|
-
type: 'terminal';
|
|
182
|
-
status: 'completed' | 'failed' | 'cancelled';
|
|
183
|
-
ts: number;
|
|
184
|
-
result?: unknown;
|
|
185
|
-
error?: string | null;
|
|
186
|
-
totalRows?: unknown;
|
|
187
|
-
durationMs?: unknown;
|
|
188
|
-
playName?: string | null;
|
|
189
|
-
liveLogs?: string[] | null;
|
|
190
|
-
liveNodeProgress?: unknown;
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
type OmitRunEventSequence<T> = T extends unknown ? Omit<T, 'seq'> : never;
|
|
194
|
-
type CoordinatorRunEventInput = OmitRunEventSequence<CoordinatorRunEvent>;
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Per-(org,provider) rate-state for the distributed Rate State Backend.
|
|
198
|
-
*
|
|
199
|
-
* One PlayDedup DO instance is addressed per bucket via
|
|
200
|
-
* `idFromName('rate:<orgId>:<provider>')`, so a single instance owns the rate
|
|
201
|
-
* window for that bucket and is single-threaded — which is exactly why the
|
|
202
|
-
* in-process algorithm from `InMemoryRateStateBackend` is correct here. The
|
|
203
|
-
* coordinator RPCs `/rate-acquire` (lease N permits) and `/rate-penalize`
|
|
204
|
-
* (Retry-After cooldown) into it; the esm_workers runtime never blocks per call
|
|
205
|
-
* on a full round-trip because it leases small permit BLOCKS at a time.
|
|
206
|
-
*
|
|
207
|
-
* Rate windows are intentionally kept in DO memory (not durable storage): a
|
|
208
|
-
* window is sub-second-to-seconds ephemeral state, the DO instance outlives any
|
|
209
|
-
* single window, and persisting it would only add storage latency to the hot
|
|
210
|
-
* path. This mirrors the in-memory backend and the Redis sliding-window limiter
|
|
211
|
-
* (`src/lib/redis/customer-rate-limiter.ts`), which also keeps window state in a
|
|
212
|
-
* volatile store with TTL rather than durable rows.
|
|
213
|
-
*/
|
|
214
|
-
type RateRule = {
|
|
215
|
-
ruleId: string;
|
|
216
|
-
requestsPerWindow: number;
|
|
217
|
-
windowMs: number;
|
|
218
|
-
maxConcurrency: number | null;
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
type RateRuleWindowState = {
|
|
222
|
-
windowStartedAt: number;
|
|
223
|
-
startedInWindow: number;
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
type RateAcquireRequest = {
|
|
227
|
-
bucketId: string;
|
|
228
|
-
rules: RateRule[];
|
|
229
|
-
/** How many permits the caller wants to lease in this round-trip. */
|
|
230
|
-
requested: number;
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
type RateAcquireResponse = {
|
|
234
|
-
/** Permits actually granted (0..requested). */
|
|
235
|
-
granted: number;
|
|
236
|
-
/** Suggested wait before the next acquire when granted === 0. */
|
|
237
|
-
waitMs: number;
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
type RatePenalizeRequest = {
|
|
241
|
-
bucketId: string;
|
|
242
|
-
cooldownMs: number;
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
const RATE_MIN_WAIT_MS = 10;
|
|
246
|
-
const RATE_STATE_KEY = (bucketId: string, ruleId: string) =>
|
|
247
|
-
`${bucketId}::${ruleId}`;
|
|
248
|
-
|
|
249
|
-
const DEDUP_KEY_PREFIX = 'd:';
|
|
250
|
-
const WORKFLOW_RUN_RETRY_KEY_PREFIX = 'r:';
|
|
251
|
-
const WORKFLOW_DB_SESSIONS_KEY = 'db-sessions';
|
|
252
|
-
const COORDINATOR_TRACE_KEY_PREFIX = 't:';
|
|
253
|
-
const COORDINATOR_RUN_EVENT_KEY_PREFIX = 'e:';
|
|
254
|
-
const COORDINATOR_TERMINAL_KEY = 'terminal';
|
|
255
|
-
const COORDINATOR_RUN_EVENT_SEQUENCE_KEY = 'event-seq';
|
|
256
|
-
const COORDINATOR_TRACE_MAX_ENTRIES = 200;
|
|
257
|
-
const COORDINATOR_RUN_EVENT_MAX_ENTRIES = 500;
|
|
258
|
-
const FINISH_ALARM_DELAY_MS = 60_000; // self-evict 1 min after finish() called
|
|
259
|
-
const WORKFLOW_RUN_STATE_TTL_MS = 60 * 60 * 1000;
|
|
260
|
-
const WORKFLOW_RUN_RETRY_STATE_MAX_BYTES = 110_000;
|
|
261
|
-
// Invariant: DB sessions must not expire before the workflow run/retry state.
|
|
262
|
-
// hydrateWorkflowDbSessions re-reads sessions on every workflow re-entry, so a
|
|
263
|
-
// shorter session TTL used to kill any run that ran longer than the session
|
|
264
|
-
// window (10 min) with a misleading transport 410.
|
|
265
|
-
const WORKFLOW_DB_SESSIONS_TTL_MS = WORKFLOW_RUN_STATE_TTL_MS;
|
|
266
|
-
const WORKFLOW_DB_SESSIONS_MAX_TTL_MS = DB_SESSION_MAX_TTL_SECONDS * 1000;
|
|
267
|
-
|
|
268
|
-
function jsonByteLength(value: unknown): number {
|
|
269
|
-
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function assertEncryptedDbSessionsForStorage(
|
|
273
|
-
sessions: PreloadedRuntimeDbSession[],
|
|
274
|
-
): void {
|
|
275
|
-
for (const session of sessions) {
|
|
276
|
-
if (session.session.postgresUrl) {
|
|
277
|
-
throw new Error('preloaded db session storage rejects raw Postgres URLs');
|
|
278
|
-
}
|
|
279
|
-
if (!session.session.encryptedPostgresUrl) {
|
|
280
|
-
throw new Error(
|
|
281
|
-
'preloaded db session storage requires encrypted Postgres URLs',
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
interface DedupEnv {
|
|
288
|
-
PLAY_DEDUP: DurableObjectNamespace;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export class PlayDedup implements DurableObject {
|
|
292
|
-
// Promises waiting on /await endpoints. Keyed by `${toolId}:${inputHash}`.
|
|
293
|
-
// Resolved by /publish; rejected on timeout.
|
|
294
|
-
private waiters: Map<string, Set<(value: unknown) => void>> = new Map();
|
|
295
|
-
private runEventWaiters: Set<(value: CoordinatorRunEvent) => void> =
|
|
296
|
-
new Set();
|
|
297
|
-
// Per-(bucketId, ruleId) request-window state for the rate-state backend.
|
|
298
|
-
// In-memory by design — see the RateRule doc block. Single-threaded DO access
|
|
299
|
-
// makes this the same sliding-window algorithm as InMemoryRateStateBackend.
|
|
300
|
-
private rateWindows: Map<string, RateRuleWindowState> = new Map();
|
|
301
|
-
// Per-bucket Retry-After cooldown floors (penalize()).
|
|
302
|
-
private rateCooldownUntil: Map<string, number> = new Map();
|
|
303
|
-
|
|
304
|
-
constructor(
|
|
305
|
-
private readonly state: DurableObjectState,
|
|
306
|
-
private readonly env: DedupEnv,
|
|
307
|
-
) {}
|
|
308
|
-
|
|
309
|
-
private storageKey(toolId: string, inputHash: string): string {
|
|
310
|
-
return `${DEDUP_KEY_PREFIX}${toolId}:${inputHash}`;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
private traceKey(entry: CoordinatorTraceEntry): string {
|
|
314
|
-
return `${COORDINATOR_TRACE_KEY_PREFIX}${String(entry.ts).padStart(13, '0')}:${crypto.randomUUID().slice(0, 8)}`;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
private runEventKey(event: CoordinatorRunEvent): string {
|
|
318
|
-
return `${COORDINATOR_RUN_EVENT_KEY_PREFIX}${String(event.seq).padStart(13, '0')}`;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
private runEventKeyForSeq(seq: number): string {
|
|
322
|
-
return `${COORDINATOR_RUN_EVENT_KEY_PREFIX}${String(seq).padStart(13, '0')}`;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
private waiterKey(toolId: string, inputHash: string): string {
|
|
326
|
-
return `${toolId}:${inputHash}`;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
async fetch(req: Request): Promise<Response> {
|
|
330
|
-
const url = new URL(req.url);
|
|
331
|
-
try {
|
|
332
|
-
switch (url.pathname) {
|
|
333
|
-
case '/lookup-or-claim':
|
|
334
|
-
return await this.handleLookupOrClaim(req);
|
|
335
|
-
case '/publish':
|
|
336
|
-
return await this.handlePublish(req);
|
|
337
|
-
case '/await':
|
|
338
|
-
return await this.handleAwait(req);
|
|
339
|
-
case '/finish':
|
|
340
|
-
return await this.handleFinish(req);
|
|
341
|
-
case '/debug':
|
|
342
|
-
return await this.handleDebug(req);
|
|
343
|
-
case '/run-retry-state-put':
|
|
344
|
-
return await this.handleRunRetryStatePut(req);
|
|
345
|
-
case '/run-launch-state-put':
|
|
346
|
-
return await this.handleRunLaunchStatePut(req);
|
|
347
|
-
case '/workflow-instance-put':
|
|
348
|
-
return await this.handleWorkflowInstancePut(req);
|
|
349
|
-
case '/workflow-instance-get':
|
|
350
|
-
return await this.handleWorkflowInstanceGet(req);
|
|
351
|
-
case '/workflow-submit-claim':
|
|
352
|
-
return await this.handleWorkflowSubmitClaim(req);
|
|
353
|
-
case '/workflow-submit-release':
|
|
354
|
-
return await this.handleWorkflowSubmitRelease(req);
|
|
355
|
-
case '/run-retry-claim':
|
|
356
|
-
return await this.handleRunRetryClaim(req);
|
|
357
|
-
case '/db-sessions-put':
|
|
358
|
-
return await this.handleDbSessionsPut(req);
|
|
359
|
-
case '/db-sessions-get':
|
|
360
|
-
return await this.handleDbSessionsGet(req);
|
|
361
|
-
case '/trace-add':
|
|
362
|
-
return await this.handleTraceAdd(req);
|
|
363
|
-
case '/trace-list':
|
|
364
|
-
return await this.handleTraceList();
|
|
365
|
-
case '/event-add':
|
|
366
|
-
return await this.handleRunEventAdd(req);
|
|
367
|
-
case '/event-list':
|
|
368
|
-
return await this.handleRunEventList(req);
|
|
369
|
-
case '/terminal-set':
|
|
370
|
-
return await this.handleTerminalSet(req);
|
|
371
|
-
case '/terminal-get':
|
|
372
|
-
return await this.handleTerminalGet();
|
|
373
|
-
case '/rate-acquire':
|
|
374
|
-
return await this.handleRateAcquire(req);
|
|
375
|
-
case '/rate-penalize':
|
|
376
|
-
return await this.handleRatePenalize(req);
|
|
377
|
-
default:
|
|
378
|
-
return new Response('not found', { status: 404 });
|
|
379
|
-
}
|
|
380
|
-
} catch (error) {
|
|
381
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
382
|
-
return new Response(JSON.stringify({ error: message }), {
|
|
383
|
-
status: 500,
|
|
384
|
-
headers: { 'content-type': 'application/json' },
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
async alarm(): Promise<void> {
|
|
390
|
-
// Fired after /finish was called. Evict storage and let the DO instance
|
|
391
|
-
// be garbage-collected by Cloudflare.
|
|
392
|
-
await this.state.storage.deleteAll();
|
|
393
|
-
this.waiters.clear();
|
|
394
|
-
this.runEventWaiters.clear();
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
private async handleLookupOrClaim(req: Request): Promise<Response> {
|
|
398
|
-
const body = (await req.json()) as LookupOrClaimRequest;
|
|
399
|
-
const hits: Record<string, unknown> = {};
|
|
400
|
-
const claimed: string[] = [];
|
|
401
|
-
const inFlight: string[] = [];
|
|
402
|
-
|
|
403
|
-
// Atomic transaction: read all keys, decide outcome per hash, write claims
|
|
404
|
-
// back. DO storage transactions are serialized within the DO instance.
|
|
405
|
-
await this.state.blockConcurrencyWhile(async () => {
|
|
406
|
-
const keys = body.inputHashes.map((h) => this.storageKey(body.toolId, h));
|
|
407
|
-
const existing = (await this.state.storage.get<DedupEntry>(keys)) as Map<
|
|
408
|
-
string,
|
|
409
|
-
DedupEntry
|
|
410
|
-
>;
|
|
411
|
-
const now = Date.now();
|
|
412
|
-
const toWrite = new Map<string, DedupEntry>();
|
|
413
|
-
|
|
414
|
-
for (let i = 0; i < body.inputHashes.length; i += 1) {
|
|
415
|
-
const hash = body.inputHashes[i]!;
|
|
416
|
-
const key = keys[i]!;
|
|
417
|
-
const entry = existing.get(key);
|
|
418
|
-
if (!entry) {
|
|
419
|
-
// Claim it for the caller.
|
|
420
|
-
claimed.push(hash);
|
|
421
|
-
toWrite.set(key, {
|
|
422
|
-
status: 'in_flight',
|
|
423
|
-
claimedBy: body.callerId,
|
|
424
|
-
claimedAt: now,
|
|
425
|
-
});
|
|
426
|
-
} else if (entry.status === 'completed') {
|
|
427
|
-
hits[hash] = entry.result;
|
|
428
|
-
} else if (entry.status === 'failed') {
|
|
429
|
-
// Re-claim failed entries — the caller can retry.
|
|
430
|
-
claimed.push(hash);
|
|
431
|
-
toWrite.set(key, {
|
|
432
|
-
status: 'in_flight',
|
|
433
|
-
claimedBy: body.callerId,
|
|
434
|
-
claimedAt: now,
|
|
435
|
-
});
|
|
436
|
-
} else {
|
|
437
|
-
// Already in-flight by someone else; tell caller to await.
|
|
438
|
-
inFlight.push(hash);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (toWrite.size > 0) {
|
|
443
|
-
await this.state.storage.put(Object.fromEntries(toWrite));
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
const response: LookupOrClaimResponse = { hits, claimed, inFlight };
|
|
448
|
-
return new Response(JSON.stringify(response), {
|
|
449
|
-
headers: { 'content-type': 'application/json' },
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
private async handlePublish(req: Request): Promise<Response> {
|
|
454
|
-
const body = (await req.json()) as PublishRequest;
|
|
455
|
-
const now = Date.now();
|
|
456
|
-
const writes: Record<string, DedupEntry> = {};
|
|
457
|
-
for (const { inputHash, result } of body.results) {
|
|
458
|
-
writes[this.storageKey(body.toolId, inputHash)] = {
|
|
459
|
-
status: 'completed',
|
|
460
|
-
result,
|
|
461
|
-
completedAt: now,
|
|
462
|
-
};
|
|
463
|
-
}
|
|
464
|
-
await this.state.storage.put(writes);
|
|
465
|
-
|
|
466
|
-
// Wake any awaiters.
|
|
467
|
-
for (const { inputHash, result } of body.results) {
|
|
468
|
-
const key = this.waiterKey(body.toolId, inputHash);
|
|
469
|
-
const set = this.waiters.get(key);
|
|
470
|
-
if (set) {
|
|
471
|
-
for (const resolve of set) resolve(result);
|
|
472
|
-
this.waiters.delete(key);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
return new Response('{}', {
|
|
477
|
-
headers: { 'content-type': 'application/json' },
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
private async handleAwait(req: Request): Promise<Response> {
|
|
482
|
-
const body = (await req.json()) as AwaitRequest;
|
|
483
|
-
const results: Record<string, unknown> = {};
|
|
484
|
-
const stillWaiting: string[] = [];
|
|
485
|
-
|
|
486
|
-
// Synchronous read first.
|
|
487
|
-
const keys = body.inputHashes.map((h) => this.storageKey(body.toolId, h));
|
|
488
|
-
const initial = (await this.state.storage.get<DedupEntry>(keys)) as Map<
|
|
489
|
-
string,
|
|
490
|
-
DedupEntry
|
|
491
|
-
>;
|
|
492
|
-
for (let i = 0; i < body.inputHashes.length; i += 1) {
|
|
493
|
-
const hash = body.inputHashes[i]!;
|
|
494
|
-
const entry = initial.get(keys[i]!);
|
|
495
|
-
if (entry?.status === 'completed') {
|
|
496
|
-
results[hash] = entry.result;
|
|
497
|
-
} else {
|
|
498
|
-
stillWaiting.push(hash);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (stillWaiting.length === 0) {
|
|
503
|
-
return new Response(JSON.stringify({ results }), {
|
|
504
|
-
headers: { 'content-type': 'application/json' },
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Long-poll. Register waiter promises; resolve via /publish or timeout.
|
|
509
|
-
const promises = stillWaiting.map(
|
|
510
|
-
(hash) =>
|
|
511
|
-
new Promise<{ hash: string; result: unknown }>((resolve, reject) => {
|
|
512
|
-
const wKey = this.waiterKey(body.toolId, hash);
|
|
513
|
-
if (!this.waiters.has(wKey)) this.waiters.set(wKey, new Set());
|
|
514
|
-
const onResolve = (result: unknown) => resolve({ hash, result });
|
|
515
|
-
this.waiters.get(wKey)!.add(onResolve);
|
|
516
|
-
setTimeout(() => {
|
|
517
|
-
this.waiters.get(wKey)?.delete(onResolve);
|
|
518
|
-
reject(new Error(`Dedup await timeout for ${hash}`));
|
|
519
|
-
}, body.timeoutMs);
|
|
520
|
-
}),
|
|
521
|
-
);
|
|
522
|
-
|
|
523
|
-
try {
|
|
524
|
-
const settled = await Promise.allSettled(promises);
|
|
525
|
-
for (const s of settled) {
|
|
526
|
-
if (s.status === 'fulfilled') {
|
|
527
|
-
results[s.value.hash] = s.value.result;
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
} catch {
|
|
531
|
-
// Individual timeouts are not fatal; return whatever resolved.
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
return new Response(JSON.stringify({ results }), {
|
|
535
|
-
headers: { 'content-type': 'application/json' },
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
private async handleFinish(_req: Request): Promise<Response> {
|
|
540
|
-
// Schedule alarm to evict after a grace period (any straggler awaits
|
|
541
|
-
// get a chance to resolve). Grace = 60s.
|
|
542
|
-
await this.state.storage.setAlarm(Date.now() + FINISH_ALARM_DELAY_MS);
|
|
543
|
-
return new Response('{}', {
|
|
544
|
-
headers: { 'content-type': 'application/json' },
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
private async handleDebug(_req: Request): Promise<Response> {
|
|
549
|
-
const all = await this.state.storage.list({ prefix: DEDUP_KEY_PREFIX });
|
|
550
|
-
const dump: Record<string, DedupEntry> = {};
|
|
551
|
-
for (const [k, v] of all) dump[k] = v as DedupEntry;
|
|
552
|
-
return new Response(
|
|
553
|
-
JSON.stringify({
|
|
554
|
-
size: all.size,
|
|
555
|
-
entries: dump,
|
|
556
|
-
waiters: this.waiters.size,
|
|
557
|
-
}),
|
|
558
|
-
{ headers: { 'content-type': 'application/json' } },
|
|
559
|
-
);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
private async handleRunRetryStatePut(req: Request): Promise<Response> {
|
|
563
|
-
const body = (await req.json().catch(() => null)) as {
|
|
564
|
-
runId?: unknown;
|
|
565
|
-
params?: unknown;
|
|
566
|
-
ttlMs?: unknown;
|
|
567
|
-
} | null;
|
|
568
|
-
const runId = typeof body?.runId === 'string' ? body.runId : '';
|
|
569
|
-
if (!runId || !body || (!('params' in body) && !('paramsRef' in body))) {
|
|
570
|
-
return new Response('runId and params or paramsRef are required', {
|
|
571
|
-
status: 400,
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
const now = Date.now();
|
|
575
|
-
const ttlMs =
|
|
576
|
-
typeof body.ttlMs === 'number' && Number.isFinite(body.ttlMs)
|
|
577
|
-
? Math.max(60_000, Math.min(body.ttlMs, WORKFLOW_RUN_STATE_TTL_MS))
|
|
578
|
-
: WORKFLOW_RUN_STATE_TTL_MS;
|
|
579
|
-
const key = `${WORKFLOW_RUN_RETRY_KEY_PREFIX}${runId}`;
|
|
580
|
-
await this.state.blockConcurrencyWhile(async () => {
|
|
581
|
-
const existing = await this.state.storage.get<WorkflowRunRetryState>(key);
|
|
582
|
-
const retryState = {
|
|
583
|
-
runId,
|
|
584
|
-
params: 'params' in body ? body.params : null,
|
|
585
|
-
paramsRef: 'paramsRef' in body ? body.paramsRef : null,
|
|
586
|
-
paramsBytes:
|
|
587
|
-
typeof (body as { paramsBytes?: unknown }).paramsBytes === 'number' &&
|
|
588
|
-
Number.isFinite((body as { paramsBytes?: number }).paramsBytes)
|
|
589
|
-
? (body as { paramsBytes: number }).paramsBytes
|
|
590
|
-
: undefined,
|
|
591
|
-
retryAttempts:
|
|
592
|
-
existing?.runId === runId &&
|
|
593
|
-
typeof existing.retryAttempts === 'number'
|
|
594
|
-
? existing.retryAttempts
|
|
595
|
-
: 0,
|
|
596
|
-
updatedAt: now,
|
|
597
|
-
expiresAt: now + ttlMs,
|
|
598
|
-
} satisfies WorkflowRunRetryState;
|
|
599
|
-
const bytes = jsonByteLength(retryState);
|
|
600
|
-
if (bytes > WORKFLOW_RUN_RETRY_STATE_MAX_BYTES) {
|
|
601
|
-
throw new Error(
|
|
602
|
-
`workflow retry state too large: ${bytes} bytes exceeds ${WORKFLOW_RUN_RETRY_STATE_MAX_BYTES}`,
|
|
603
|
-
);
|
|
604
|
-
}
|
|
605
|
-
await this.state.storage.put(key, retryState);
|
|
606
|
-
});
|
|
607
|
-
return new Response(JSON.stringify({ ok: true }), {
|
|
608
|
-
headers: { 'content-type': 'application/json' },
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
private async handleRunLaunchStatePut(req: Request): Promise<Response> {
|
|
613
|
-
const body = (await req.json().catch(() => null)) as {
|
|
614
|
-
runId?: unknown;
|
|
615
|
-
params?: unknown;
|
|
616
|
-
paramsRef?: unknown;
|
|
617
|
-
paramsBytes?: unknown;
|
|
618
|
-
sessions?: unknown;
|
|
619
|
-
retryTtlMs?: unknown;
|
|
620
|
-
dbSessionsTtlMs?: unknown;
|
|
621
|
-
} | null;
|
|
622
|
-
const runId = typeof body?.runId === 'string' ? body.runId : '';
|
|
623
|
-
const sessions = Array.isArray(body?.sessions)
|
|
624
|
-
? (body.sessions as PreloadedRuntimeDbSession[])
|
|
625
|
-
: null;
|
|
626
|
-
if (
|
|
627
|
-
!runId ||
|
|
628
|
-
!body ||
|
|
629
|
-
(!('params' in body) && !('paramsRef' in body)) ||
|
|
630
|
-
!sessions
|
|
631
|
-
) {
|
|
632
|
-
return new Response(
|
|
633
|
-
'runId, params or paramsRef, and sessions are required',
|
|
634
|
-
{
|
|
635
|
-
status: 400,
|
|
636
|
-
},
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
assertEncryptedDbSessionsForStorage(sessions);
|
|
640
|
-
const now = Date.now();
|
|
641
|
-
const retryTtlMs =
|
|
642
|
-
typeof body.retryTtlMs === 'number' && Number.isFinite(body.retryTtlMs)
|
|
643
|
-
? Math.max(60_000, Math.min(body.retryTtlMs, WORKFLOW_RUN_STATE_TTL_MS))
|
|
644
|
-
: WORKFLOW_RUN_STATE_TTL_MS;
|
|
645
|
-
const dbSessionsTtlMs =
|
|
646
|
-
typeof body.dbSessionsTtlMs === 'number' &&
|
|
647
|
-
Number.isFinite(body.dbSessionsTtlMs) &&
|
|
648
|
-
body.dbSessionsTtlMs > 0
|
|
649
|
-
? Math.min(
|
|
650
|
-
Math.max(Math.floor(body.dbSessionsTtlMs), 60_000),
|
|
651
|
-
WORKFLOW_DB_SESSIONS_MAX_TTL_MS,
|
|
652
|
-
)
|
|
653
|
-
: WORKFLOW_DB_SESSIONS_TTL_MS;
|
|
654
|
-
const retryKey = `${WORKFLOW_RUN_RETRY_KEY_PREFIX}${runId}`;
|
|
655
|
-
const dbSessionsState: WorkflowDbSessionsState = {
|
|
656
|
-
runId,
|
|
657
|
-
sessions,
|
|
658
|
-
storedAt: now,
|
|
659
|
-
expiresAt: now + dbSessionsTtlMs,
|
|
660
|
-
};
|
|
661
|
-
let retryStateExpiresAt = now + retryTtlMs;
|
|
662
|
-
await this.state.blockConcurrencyWhile(async () => {
|
|
663
|
-
const existing =
|
|
664
|
-
await this.state.storage.get<WorkflowRunRetryState>(retryKey);
|
|
665
|
-
const retryState = {
|
|
666
|
-
runId,
|
|
667
|
-
params: 'params' in body ? body.params : null,
|
|
668
|
-
paramsRef: 'paramsRef' in body ? body.paramsRef : null,
|
|
669
|
-
paramsBytes:
|
|
670
|
-
typeof body.paramsBytes === 'number' &&
|
|
671
|
-
Number.isFinite(body.paramsBytes)
|
|
672
|
-
? body.paramsBytes
|
|
673
|
-
: undefined,
|
|
674
|
-
retryAttempts:
|
|
675
|
-
existing?.runId === runId &&
|
|
676
|
-
typeof existing.retryAttempts === 'number'
|
|
677
|
-
? existing.retryAttempts
|
|
678
|
-
: 0,
|
|
679
|
-
updatedAt: now,
|
|
680
|
-
expiresAt: retryStateExpiresAt,
|
|
681
|
-
} satisfies WorkflowRunRetryState;
|
|
682
|
-
const bytes = jsonByteLength(retryState);
|
|
683
|
-
if (bytes > WORKFLOW_RUN_RETRY_STATE_MAX_BYTES) {
|
|
684
|
-
throw new Error(
|
|
685
|
-
`workflow retry state too large: ${bytes} bytes exceeds ${WORKFLOW_RUN_RETRY_STATE_MAX_BYTES}`,
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
retryStateExpiresAt = retryState.expiresAt;
|
|
689
|
-
await this.state.storage.put(retryKey, retryState);
|
|
690
|
-
await this.state.storage.put(WORKFLOW_DB_SESSIONS_KEY, dbSessionsState);
|
|
691
|
-
});
|
|
692
|
-
return new Response(
|
|
693
|
-
JSON.stringify({
|
|
694
|
-
ok: true,
|
|
695
|
-
sessionCount: sessions.length,
|
|
696
|
-
retryExpiresAt: retryStateExpiresAt,
|
|
697
|
-
dbSessionsExpiresAt: dbSessionsState.expiresAt,
|
|
698
|
-
}),
|
|
699
|
-
{ headers: { 'content-type': 'application/json' } },
|
|
700
|
-
);
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
private async handleRunRetryClaim(req: Request): Promise<Response> {
|
|
704
|
-
const body = (await req.json().catch(() => null)) as {
|
|
705
|
-
runId?: unknown;
|
|
706
|
-
maxAttempts?: unknown;
|
|
707
|
-
} | null;
|
|
708
|
-
const runId = typeof body?.runId === 'string' ? body.runId : '';
|
|
709
|
-
if (!runId) {
|
|
710
|
-
return new Response('runId is required', { status: 400 });
|
|
711
|
-
}
|
|
712
|
-
const maxAttempts =
|
|
713
|
-
typeof body?.maxAttempts === 'number' && Number.isFinite(body.maxAttempts)
|
|
714
|
-
? Math.max(0, Math.floor(body.maxAttempts))
|
|
715
|
-
: 1;
|
|
716
|
-
const now = Date.now();
|
|
717
|
-
const key = `${WORKFLOW_RUN_RETRY_KEY_PREFIX}${runId}`;
|
|
718
|
-
let response: Record<string, unknown> = {
|
|
719
|
-
claimed: false,
|
|
720
|
-
attempts: 0,
|
|
721
|
-
params: null,
|
|
722
|
-
};
|
|
723
|
-
await this.state.blockConcurrencyWhile(async () => {
|
|
724
|
-
const existing = await this.state.storage.get<WorkflowRunRetryState>(key);
|
|
725
|
-
if (!existing || existing.expiresAt <= now) {
|
|
726
|
-
response = { claimed: false, attempts: 0, params: null };
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
if (existing.retryAttempts >= maxAttempts) {
|
|
730
|
-
response = {
|
|
731
|
-
claimed: false,
|
|
732
|
-
attempts: existing.retryAttempts,
|
|
733
|
-
params: existing.params,
|
|
734
|
-
paramsRef: existing.paramsRef ?? null,
|
|
735
|
-
paramsBytes: existing.paramsBytes ?? null,
|
|
736
|
-
};
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
const nextAttempts = existing.retryAttempts + 1;
|
|
740
|
-
await this.state.storage.put(key, {
|
|
741
|
-
...existing,
|
|
742
|
-
retryAttempts: nextAttempts,
|
|
743
|
-
updatedAt: now,
|
|
744
|
-
expiresAt: now + WORKFLOW_RUN_STATE_TTL_MS,
|
|
745
|
-
} satisfies WorkflowRunRetryState);
|
|
746
|
-
response = {
|
|
747
|
-
claimed: true,
|
|
748
|
-
attempts: nextAttempts,
|
|
749
|
-
params: existing.params,
|
|
750
|
-
paramsRef: existing.paramsRef ?? null,
|
|
751
|
-
paramsBytes: existing.paramsBytes ?? null,
|
|
752
|
-
};
|
|
753
|
-
});
|
|
754
|
-
return new Response(JSON.stringify(response), {
|
|
755
|
-
headers: { 'content-type': 'application/json' },
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
private async handleWorkflowInstancePut(req: Request): Promise<Response> {
|
|
760
|
-
const body = (await req.json().catch(() => null)) as {
|
|
761
|
-
runId?: unknown;
|
|
762
|
-
instanceId?: unknown;
|
|
763
|
-
ttlMs?: unknown;
|
|
764
|
-
} | null;
|
|
765
|
-
const runId = typeof body?.runId === 'string' ? body.runId : '';
|
|
766
|
-
const instanceId =
|
|
767
|
-
typeof body?.instanceId === 'string' ? body.instanceId : '';
|
|
768
|
-
if (!runId || !instanceId) {
|
|
769
|
-
return new Response('runId and instanceId are required', { status: 400 });
|
|
770
|
-
}
|
|
771
|
-
const now = Date.now();
|
|
772
|
-
const ttlMs =
|
|
773
|
-
typeof body?.ttlMs === 'number' &&
|
|
774
|
-
Number.isFinite(body.ttlMs) &&
|
|
775
|
-
body.ttlMs > 0
|
|
776
|
-
? Math.max(60_000, Math.min(body.ttlMs, WORKFLOW_RUN_STATE_TTL_MS))
|
|
777
|
-
: WORKFLOW_RUN_STATE_TTL_MS;
|
|
778
|
-
await this.state.storage.put({
|
|
779
|
-
'workflow-instance': {
|
|
780
|
-
runId,
|
|
781
|
-
instanceId,
|
|
782
|
-
updatedAt: now,
|
|
783
|
-
expiresAt: now + ttlMs,
|
|
784
|
-
} satisfies WorkflowInstanceState,
|
|
785
|
-
'workflow-submit-claim': {
|
|
786
|
-
runId,
|
|
787
|
-
status: 'created',
|
|
788
|
-
instanceId,
|
|
789
|
-
claimedAt: now,
|
|
790
|
-
updatedAt: now,
|
|
791
|
-
expiresAt: now + ttlMs,
|
|
792
|
-
} satisfies WorkflowSubmitClaimState,
|
|
793
|
-
});
|
|
794
|
-
return new Response(JSON.stringify({ ok: true }), {
|
|
795
|
-
headers: { 'content-type': 'application/json' },
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
private async handleWorkflowInstanceGet(req: Request): Promise<Response> {
|
|
800
|
-
const runId = new URL(req.url).searchParams.get('runId') ?? '';
|
|
801
|
-
if (!runId) {
|
|
802
|
-
return new Response('runId is required', { status: 400 });
|
|
803
|
-
}
|
|
804
|
-
const state =
|
|
805
|
-
await this.state.storage.get<WorkflowInstanceState>('workflow-instance');
|
|
806
|
-
if (!state || state.runId !== runId) {
|
|
807
|
-
return new Response(JSON.stringify({ instanceId: null }), {
|
|
808
|
-
headers: { 'content-type': 'application/json' },
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
if (state.expiresAt <= Date.now()) {
|
|
812
|
-
await this.state.storage.delete('workflow-instance');
|
|
813
|
-
return new Response(JSON.stringify({ instanceId: null }), {
|
|
814
|
-
headers: { 'content-type': 'application/json' },
|
|
815
|
-
});
|
|
816
|
-
}
|
|
817
|
-
return new Response(JSON.stringify({ instanceId: state.instanceId }), {
|
|
818
|
-
headers: { 'content-type': 'application/json' },
|
|
819
|
-
});
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
private async handleWorkflowSubmitClaim(req: Request): Promise<Response> {
|
|
823
|
-
const body = (await req.json().catch(() => null)) as {
|
|
824
|
-
runId?: unknown;
|
|
825
|
-
ttlMs?: unknown;
|
|
826
|
-
} | null;
|
|
827
|
-
const runId = typeof body?.runId === 'string' ? body.runId : '';
|
|
828
|
-
if (!runId) {
|
|
829
|
-
return new Response('runId is required', { status: 400 });
|
|
830
|
-
}
|
|
831
|
-
const now = Date.now();
|
|
832
|
-
const ttlMs =
|
|
833
|
-
typeof body?.ttlMs === 'number' &&
|
|
834
|
-
Number.isFinite(body.ttlMs) &&
|
|
835
|
-
body.ttlMs > 0
|
|
836
|
-
? Math.max(60_000, Math.min(body.ttlMs, WORKFLOW_RUN_STATE_TTL_MS))
|
|
837
|
-
: WORKFLOW_RUN_STATE_TTL_MS;
|
|
838
|
-
let response: {
|
|
839
|
-
claimed: boolean;
|
|
840
|
-
status: WorkflowSubmitClaimState['status'];
|
|
841
|
-
instanceId: string | null;
|
|
842
|
-
} = { claimed: true, status: 'creating', instanceId: null };
|
|
843
|
-
await this.state.blockConcurrencyWhile(async () => {
|
|
844
|
-
const key = 'workflow-submit-claim';
|
|
845
|
-
const existing =
|
|
846
|
-
await this.state.storage.get<WorkflowSubmitClaimState>(key);
|
|
847
|
-
if (existing?.runId === runId && existing.expiresAt > now) {
|
|
848
|
-
response = {
|
|
849
|
-
claimed: false,
|
|
850
|
-
status: existing.status,
|
|
851
|
-
instanceId: existing.instanceId ?? null,
|
|
852
|
-
};
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
const state = {
|
|
856
|
-
runId,
|
|
857
|
-
status: 'creating',
|
|
858
|
-
claimedAt: now,
|
|
859
|
-
updatedAt: now,
|
|
860
|
-
expiresAt: now + ttlMs,
|
|
861
|
-
} satisfies WorkflowSubmitClaimState;
|
|
862
|
-
await this.state.storage.put(key, state);
|
|
863
|
-
});
|
|
864
|
-
return new Response(JSON.stringify(response), {
|
|
865
|
-
headers: { 'content-type': 'application/json' },
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
private async handleWorkflowSubmitRelease(req: Request): Promise<Response> {
|
|
870
|
-
const body = (await req.json().catch(() => null)) as {
|
|
871
|
-
runId?: unknown;
|
|
872
|
-
} | null;
|
|
873
|
-
const runId = typeof body?.runId === 'string' ? body.runId : '';
|
|
874
|
-
if (!runId) {
|
|
875
|
-
return new Response('runId is required', { status: 400 });
|
|
876
|
-
}
|
|
877
|
-
let released = false;
|
|
878
|
-
await this.state.blockConcurrencyWhile(async () => {
|
|
879
|
-
const key = 'workflow-submit-claim';
|
|
880
|
-
const existing =
|
|
881
|
-
await this.state.storage.get<WorkflowSubmitClaimState>(key);
|
|
882
|
-
if (existing?.runId === runId && existing.status === 'creating') {
|
|
883
|
-
await this.state.storage.delete(key);
|
|
884
|
-
released = true;
|
|
885
|
-
}
|
|
886
|
-
});
|
|
887
|
-
return new Response(JSON.stringify({ ok: true, released }), {
|
|
888
|
-
headers: { 'content-type': 'application/json' },
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
private async handleDbSessionsPut(req: Request): Promise<Response> {
|
|
893
|
-
const body = (await req.json().catch(() => null)) as {
|
|
894
|
-
runId?: unknown;
|
|
895
|
-
sessions?: unknown;
|
|
896
|
-
ttlMs?: unknown;
|
|
897
|
-
} | null;
|
|
898
|
-
const runId = typeof body?.runId === 'string' ? body.runId : '';
|
|
899
|
-
const sessions = Array.isArray(body?.sessions)
|
|
900
|
-
? (body.sessions as PreloadedRuntimeDbSession[])
|
|
901
|
-
: null;
|
|
902
|
-
if (!runId || !sessions) {
|
|
903
|
-
return new Response('runId and sessions are required', { status: 400 });
|
|
904
|
-
}
|
|
905
|
-
assertEncryptedDbSessionsForStorage(sessions);
|
|
906
|
-
const now = Date.now();
|
|
907
|
-
const ttlMs =
|
|
908
|
-
typeof body?.ttlMs === 'number' &&
|
|
909
|
-
Number.isFinite(body.ttlMs) &&
|
|
910
|
-
body.ttlMs > 0
|
|
911
|
-
? Math.min(
|
|
912
|
-
Math.max(Math.floor(body.ttlMs), 60_000),
|
|
913
|
-
WORKFLOW_DB_SESSIONS_MAX_TTL_MS,
|
|
914
|
-
)
|
|
915
|
-
: WORKFLOW_DB_SESSIONS_TTL_MS;
|
|
916
|
-
const state: WorkflowDbSessionsState = {
|
|
917
|
-
runId,
|
|
918
|
-
sessions,
|
|
919
|
-
storedAt: now,
|
|
920
|
-
expiresAt: now + ttlMs,
|
|
921
|
-
};
|
|
922
|
-
await this.state.storage.put(WORKFLOW_DB_SESSIONS_KEY, state);
|
|
923
|
-
return new Response(
|
|
924
|
-
JSON.stringify({
|
|
925
|
-
ok: true,
|
|
926
|
-
sessionCount: sessions.length,
|
|
927
|
-
expiresAt: state.expiresAt,
|
|
928
|
-
}),
|
|
929
|
-
{ headers: { 'content-type': 'application/json' } },
|
|
930
|
-
);
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
private async handleDbSessionsGet(req: Request): Promise<Response> {
|
|
934
|
-
const url = new URL(req.url);
|
|
935
|
-
const runId = url.searchParams.get('runId') ?? '';
|
|
936
|
-
if (!runId) {
|
|
937
|
-
return new Response('runId is required', { status: 400 });
|
|
938
|
-
}
|
|
939
|
-
const state = await this.state.storage.get<WorkflowDbSessionsState>(
|
|
940
|
-
WORKFLOW_DB_SESSIONS_KEY,
|
|
941
|
-
);
|
|
942
|
-
if (!state || state.runId !== runId) {
|
|
943
|
-
return new Response(JSON.stringify({ error: 'db sessions not found' }), {
|
|
944
|
-
status: 404,
|
|
945
|
-
headers: { 'content-type': 'application/json' },
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
if (state.expiresAt <= Date.now()) {
|
|
949
|
-
await this.state.storage.delete(WORKFLOW_DB_SESSIONS_KEY);
|
|
950
|
-
return new Response(
|
|
951
|
-
JSON.stringify({
|
|
952
|
-
error:
|
|
953
|
-
'workflow db sessions expired: the run exceeded the session retention window before completing. This usually follows an earlier runtime persistence failure or a stalled run; re-run to resume.',
|
|
954
|
-
}),
|
|
955
|
-
{
|
|
956
|
-
status: 410,
|
|
957
|
-
headers: { 'content-type': 'application/json' },
|
|
958
|
-
},
|
|
959
|
-
);
|
|
960
|
-
}
|
|
961
|
-
assertEncryptedDbSessionsForStorage(state.sessions);
|
|
962
|
-
return new Response(
|
|
963
|
-
JSON.stringify({
|
|
964
|
-
sessions: state.sessions,
|
|
965
|
-
sessionCount: state.sessions.length,
|
|
966
|
-
expiresAt: state.expiresAt,
|
|
967
|
-
}),
|
|
968
|
-
{ headers: { 'content-type': 'application/json' } },
|
|
969
|
-
);
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
private async handleTraceAdd(req: Request): Promise<Response> {
|
|
973
|
-
const body = (await req
|
|
974
|
-
.json()
|
|
975
|
-
.catch(() => null)) as Partial<CoordinatorTraceEntry> | null;
|
|
976
|
-
if (
|
|
977
|
-
!body ||
|
|
978
|
-
(body.source !== 'coordinator' && body.source !== 'dynamic_worker') ||
|
|
979
|
-
typeof body.runId !== 'string' ||
|
|
980
|
-
typeof body.phase !== 'string' ||
|
|
981
|
-
typeof body.ts !== 'number' ||
|
|
982
|
-
typeof body.ms !== 'number'
|
|
983
|
-
) {
|
|
984
|
-
return new Response('invalid trace entry', { status: 400 });
|
|
985
|
-
}
|
|
986
|
-
const entry = durableStorageRedactor.redact({
|
|
987
|
-
...body,
|
|
988
|
-
source: body.source,
|
|
989
|
-
runId: body.runId,
|
|
990
|
-
phase: body.phase,
|
|
991
|
-
ts: body.ts,
|
|
992
|
-
ms: body.ms,
|
|
993
|
-
graphHash:
|
|
994
|
-
typeof body.graphHash === 'string' || body.graphHash === null
|
|
995
|
-
? body.graphHash
|
|
996
|
-
: undefined,
|
|
997
|
-
}) as CoordinatorTraceEntry;
|
|
998
|
-
await this.state.blockConcurrencyWhile(async () => {
|
|
999
|
-
await this.state.storage.put(this.traceKey(entry), entry);
|
|
1000
|
-
const entries = await this.state.storage.list<CoordinatorTraceEntry>({
|
|
1001
|
-
prefix: COORDINATOR_TRACE_KEY_PREFIX,
|
|
1002
|
-
});
|
|
1003
|
-
const overflow = entries.size - COORDINATOR_TRACE_MAX_ENTRIES;
|
|
1004
|
-
if (overflow > 0) {
|
|
1005
|
-
await this.state.storage.delete([...entries.keys()].slice(0, overflow));
|
|
1006
|
-
}
|
|
1007
|
-
});
|
|
1008
|
-
return new Response('{}', {
|
|
1009
|
-
headers: { 'content-type': 'application/json' },
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
private async handleTraceList(): Promise<Response> {
|
|
1014
|
-
const entries = await this.state.storage.list<CoordinatorTraceEntry>({
|
|
1015
|
-
prefix: COORDINATOR_TRACE_KEY_PREFIX,
|
|
1016
|
-
});
|
|
1017
|
-
return new Response(
|
|
1018
|
-
JSON.stringify({
|
|
1019
|
-
entries: [...entries.values()].sort(
|
|
1020
|
-
(left, right) => left.ts - right.ts,
|
|
1021
|
-
),
|
|
1022
|
-
}),
|
|
1023
|
-
{ headers: { 'content-type': 'application/json' } },
|
|
1024
|
-
);
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
private async handleTerminalSet(req: Request): Promise<Response> {
|
|
1028
|
-
const body = (await req
|
|
1029
|
-
.json()
|
|
1030
|
-
.catch(() => null)) as Partial<CoordinatorTerminalState> | null;
|
|
1031
|
-
if (
|
|
1032
|
-
!body ||
|
|
1033
|
-
typeof body.runId !== 'string' ||
|
|
1034
|
-
(body.status !== 'completed' &&
|
|
1035
|
-
body.status !== 'failed' &&
|
|
1036
|
-
body.status !== 'cancelled')
|
|
1037
|
-
) {
|
|
1038
|
-
return new Response('invalid terminal state', { status: 400 });
|
|
1039
|
-
}
|
|
1040
|
-
const state: CoordinatorTerminalState = {
|
|
1041
|
-
runId: body.runId,
|
|
1042
|
-
status: body.status,
|
|
1043
|
-
result: body.result,
|
|
1044
|
-
error:
|
|
1045
|
-
typeof body.error === 'string' && body.error.trim()
|
|
1046
|
-
? body.error.trim()
|
|
1047
|
-
: body.status === 'failed'
|
|
1048
|
-
? 'Cloudflare workflow failed before the runner reported an error.'
|
|
1049
|
-
: null,
|
|
1050
|
-
totalRows: body.totalRows,
|
|
1051
|
-
durationMs: body.durationMs,
|
|
1052
|
-
playName: typeof body.playName === 'string' ? body.playName : null,
|
|
1053
|
-
liveLogs: sanitizeLiveLogLines(body.liveLogs),
|
|
1054
|
-
liveNodeProgress: body.liveNodeProgress,
|
|
1055
|
-
completedAt:
|
|
1056
|
-
typeof body.completedAt === 'number' ? body.completedAt : Date.now(),
|
|
1057
|
-
};
|
|
1058
|
-
await this.state.storage.put(COORDINATOR_TERMINAL_KEY, state);
|
|
1059
|
-
const event = await this.storeRunEvent({
|
|
1060
|
-
runId: state.runId,
|
|
1061
|
-
type: 'terminal',
|
|
1062
|
-
status: state.status,
|
|
1063
|
-
result: state.result,
|
|
1064
|
-
error: state.error,
|
|
1065
|
-
totalRows: state.totalRows,
|
|
1066
|
-
durationMs: state.durationMs,
|
|
1067
|
-
playName: state.playName,
|
|
1068
|
-
liveLogs: state.liveLogs,
|
|
1069
|
-
liveNodeProgress: state.liveNodeProgress,
|
|
1070
|
-
ts: state.completedAt,
|
|
1071
|
-
});
|
|
1072
|
-
this.wakeRunEventWaiters(event);
|
|
1073
|
-
return new Response('{}', {
|
|
1074
|
-
headers: { 'content-type': 'application/json' },
|
|
1075
|
-
});
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
private async handleTerminalGet(): Promise<Response> {
|
|
1079
|
-
const state = await this.state.storage.get<CoordinatorTerminalState>(
|
|
1080
|
-
COORDINATOR_TERMINAL_KEY,
|
|
1081
|
-
);
|
|
1082
|
-
return new Response(JSON.stringify({ state: state ?? null }), {
|
|
1083
|
-
headers: { 'content-type': 'application/json' },
|
|
1084
|
-
});
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
/**
|
|
1088
|
-
* Lease up to `requested` request-window permits for `bucketId` under all
|
|
1089
|
-
* `rules`. Single-threaded DO access means this is the same sliding-window
|
|
1090
|
-
* math as InMemoryRateStateBackend.acquire, generalized to grant a BLOCK of
|
|
1091
|
-
* permits at once so the runtime amortizes the round-trip across many calls.
|
|
1092
|
-
*
|
|
1093
|
-
* Concurrency rules (`maxConcurrency`) are NOT enforced here: simultaneous
|
|
1094
|
-
* in-flight tracking across isolates needs a reliable release signal, which a
|
|
1095
|
-
* dying isolate cannot guarantee. The Governor's global tool-concurrency
|
|
1096
|
-
* semaphore is the cross-call concurrency backstop; this DO owns the
|
|
1097
|
-
* cross-isolate REQUEST RATE, which is the throughput governor.
|
|
1098
|
-
*/
|
|
1099
|
-
private async handleRateAcquire(req: Request): Promise<Response> {
|
|
1100
|
-
const body = (await req
|
|
1101
|
-
.json()
|
|
1102
|
-
.catch(() => null)) as RateAcquireRequest | null;
|
|
1103
|
-
if (
|
|
1104
|
-
!body ||
|
|
1105
|
-
typeof body.bucketId !== 'string' ||
|
|
1106
|
-
!body.bucketId.trim() ||
|
|
1107
|
-
!Array.isArray(body.rules)
|
|
1108
|
-
) {
|
|
1109
|
-
return new Response('bucketId and rules are required', { status: 400 });
|
|
1110
|
-
}
|
|
1111
|
-
const requested =
|
|
1112
|
-
typeof body.requested === 'number' && Number.isFinite(body.requested)
|
|
1113
|
-
? Math.max(1, Math.floor(body.requested))
|
|
1114
|
-
: 1;
|
|
1115
|
-
const result = this.computeRateAcquire(
|
|
1116
|
-
body.bucketId,
|
|
1117
|
-
body.rules,
|
|
1118
|
-
requested,
|
|
1119
|
-
);
|
|
1120
|
-
return new Response(JSON.stringify(result), {
|
|
1121
|
-
headers: { 'content-type': 'application/json' },
|
|
1122
|
-
});
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
private computeRateAcquire(
|
|
1126
|
-
bucketId: string,
|
|
1127
|
-
rules: RateRule[],
|
|
1128
|
-
requested: number,
|
|
1129
|
-
): RateAcquireResponse {
|
|
1130
|
-
const now = Date.now();
|
|
1131
|
-
const usableRules = rules.filter(
|
|
1132
|
-
(rule) =>
|
|
1133
|
-
rule &&
|
|
1134
|
-
typeof rule.ruleId === 'string' &&
|
|
1135
|
-
typeof rule.requestsPerWindow === 'number' &&
|
|
1136
|
-
rule.requestsPerWindow > 0 &&
|
|
1137
|
-
typeof rule.windowMs === 'number',
|
|
1138
|
-
);
|
|
1139
|
-
if (usableRules.length === 0) {
|
|
1140
|
-
return { granted: requested, waitMs: 0 };
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
let waitMs = 0;
|
|
1144
|
-
const cooldownUntil = this.rateCooldownUntil.get(bucketId) ?? 0;
|
|
1145
|
-
if (cooldownUntil > now) waitMs = Math.max(waitMs, cooldownUntil - now);
|
|
1146
|
-
|
|
1147
|
-
// The grant is the min remaining capacity across every rule's window. A
|
|
1148
|
-
// single round-trip can never debit more than the tightest rule allows.
|
|
1149
|
-
let grantable = requested;
|
|
1150
|
-
for (const rule of usableRules) {
|
|
1151
|
-
const state = this.getRateWindowState(bucketId, rule, now);
|
|
1152
|
-
this.resetExpiredRateWindow(state, rule.windowMs, now);
|
|
1153
|
-
const remaining = rule.requestsPerWindow - state.startedInWindow;
|
|
1154
|
-
grantable = Math.min(grantable, Math.max(0, remaining));
|
|
1155
|
-
if (remaining <= 0) {
|
|
1156
|
-
waitMs = Math.max(
|
|
1157
|
-
waitMs,
|
|
1158
|
-
rule.windowMs > 0
|
|
1159
|
-
? state.windowStartedAt + rule.windowMs - now
|
|
1160
|
-
: RATE_MIN_WAIT_MS,
|
|
1161
|
-
);
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
if (waitMs > 0 || grantable <= 0) {
|
|
1166
|
-
return { granted: 0, waitMs: Math.max(RATE_MIN_WAIT_MS, waitMs) };
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
for (const rule of usableRules) {
|
|
1170
|
-
const state = this.getRateWindowState(bucketId, rule, now);
|
|
1171
|
-
state.startedInWindow += grantable;
|
|
1172
|
-
}
|
|
1173
|
-
return { granted: grantable, waitMs: 0 };
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
private async handleRatePenalize(req: Request): Promise<Response> {
|
|
1177
|
-
const body = (await req
|
|
1178
|
-
.json()
|
|
1179
|
-
.catch(() => null)) as RatePenalizeRequest | null;
|
|
1180
|
-
if (!body || typeof body.bucketId !== 'string' || !body.bucketId.trim()) {
|
|
1181
|
-
return new Response('bucketId is required', { status: 400 });
|
|
1182
|
-
}
|
|
1183
|
-
const cooldownMs =
|
|
1184
|
-
typeof body.cooldownMs === 'number' && Number.isFinite(body.cooldownMs)
|
|
1185
|
-
? Math.floor(body.cooldownMs)
|
|
1186
|
-
: 0;
|
|
1187
|
-
if (cooldownMs > 0) {
|
|
1188
|
-
const until = Date.now() + cooldownMs;
|
|
1189
|
-
const existing = this.rateCooldownUntil.get(body.bucketId) ?? 0;
|
|
1190
|
-
this.rateCooldownUntil.set(body.bucketId, Math.max(existing, until));
|
|
1191
|
-
}
|
|
1192
|
-
return new Response('{}', {
|
|
1193
|
-
headers: { 'content-type': 'application/json' },
|
|
1194
|
-
});
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
private getRateWindowState(
|
|
1198
|
-
bucketId: string,
|
|
1199
|
-
rule: RateRule,
|
|
1200
|
-
now: number,
|
|
1201
|
-
): RateRuleWindowState {
|
|
1202
|
-
const key = RATE_STATE_KEY(bucketId, rule.ruleId);
|
|
1203
|
-
const existing = this.rateWindows.get(key);
|
|
1204
|
-
if (existing) return existing;
|
|
1205
|
-
const created: RateRuleWindowState = {
|
|
1206
|
-
windowStartedAt: now,
|
|
1207
|
-
startedInWindow: 0,
|
|
1208
|
-
};
|
|
1209
|
-
this.rateWindows.set(key, created);
|
|
1210
|
-
return created;
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
private resetExpiredRateWindow(
|
|
1214
|
-
state: RateRuleWindowState,
|
|
1215
|
-
windowMs: number,
|
|
1216
|
-
now: number,
|
|
1217
|
-
): void {
|
|
1218
|
-
if (windowMs <= 0) {
|
|
1219
|
-
state.windowStartedAt = now;
|
|
1220
|
-
state.startedInWindow = 0;
|
|
1221
|
-
return;
|
|
1222
|
-
}
|
|
1223
|
-
if (now - state.windowStartedAt < windowMs) return;
|
|
1224
|
-
const elapsed = Math.floor((now - state.windowStartedAt) / windowMs);
|
|
1225
|
-
state.windowStartedAt += elapsed * windowMs;
|
|
1226
|
-
state.startedInWindow = 0;
|
|
1227
|
-
if (now - state.windowStartedAt >= windowMs) state.windowStartedAt = now;
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
private async storeRunEvent(
|
|
1231
|
-
input: CoordinatorRunEventInput,
|
|
1232
|
-
): Promise<CoordinatorRunEvent> {
|
|
1233
|
-
let event: CoordinatorRunEvent | null = null;
|
|
1234
|
-
await this.state.blockConcurrencyWhile(async () => {
|
|
1235
|
-
const current =
|
|
1236
|
-
(await this.state.storage.get<number>(
|
|
1237
|
-
COORDINATOR_RUN_EVENT_SEQUENCE_KEY,
|
|
1238
|
-
)) ?? 0;
|
|
1239
|
-
const seq = current + 1;
|
|
1240
|
-
event = { ...input, seq } as CoordinatorRunEvent;
|
|
1241
|
-
await this.state.storage.put({
|
|
1242
|
-
[COORDINATOR_RUN_EVENT_SEQUENCE_KEY]: seq,
|
|
1243
|
-
[this.runEventKey(event)]: event,
|
|
1244
|
-
});
|
|
1245
|
-
const pruneSeq = seq - COORDINATOR_RUN_EVENT_MAX_ENTRIES;
|
|
1246
|
-
if (pruneSeq > 0) {
|
|
1247
|
-
await this.state.storage.delete(this.runEventKeyForSeq(pruneSeq));
|
|
1248
|
-
}
|
|
1249
|
-
});
|
|
1250
|
-
if (!event) {
|
|
1251
|
-
throw new Error('failed to store run event');
|
|
1252
|
-
}
|
|
1253
|
-
return event;
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
private wakeRunEventWaiters(event: CoordinatorRunEvent): void {
|
|
1257
|
-
for (const resolve of this.runEventWaiters) {
|
|
1258
|
-
resolve(event);
|
|
1259
|
-
}
|
|
1260
|
-
this.runEventWaiters.clear();
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
private async handleRunEventAdd(req: Request): Promise<Response> {
|
|
1264
|
-
const body = (await req
|
|
1265
|
-
.json()
|
|
1266
|
-
.catch(() => null)) as Partial<CoordinatorRunEvent> | null;
|
|
1267
|
-
if (!body || typeof body.runId !== 'string') {
|
|
1268
|
-
return new Response('invalid run event', { status: 400 });
|
|
1269
|
-
}
|
|
1270
|
-
let eventInput: CoordinatorRunEventInput | null = null;
|
|
1271
|
-
const ts = typeof body.ts === 'number' ? body.ts : Date.now();
|
|
1272
|
-
if (body.type === 'log' && typeof body.line === 'string') {
|
|
1273
|
-
eventInput = {
|
|
1274
|
-
runId: body.runId,
|
|
1275
|
-
type: 'log',
|
|
1276
|
-
line: body.line,
|
|
1277
|
-
ts,
|
|
1278
|
-
};
|
|
1279
|
-
} else if (body.type === 'status' && typeof body.status === 'string') {
|
|
1280
|
-
eventInput = {
|
|
1281
|
-
runId: body.runId,
|
|
1282
|
-
type: 'status',
|
|
1283
|
-
status: body.status,
|
|
1284
|
-
ts,
|
|
1285
|
-
logs: Array.isArray(body.logs)
|
|
1286
|
-
? body.logs.filter((line): line is string => typeof line === 'string')
|
|
1287
|
-
: undefined,
|
|
1288
|
-
};
|
|
1289
|
-
} else if (body.type === 'progress' && typeof body.status === 'string') {
|
|
1290
|
-
eventInput = {
|
|
1291
|
-
runId: body.runId,
|
|
1292
|
-
type: 'progress',
|
|
1293
|
-
status: body.status,
|
|
1294
|
-
ts,
|
|
1295
|
-
logs: Array.isArray(body.logs)
|
|
1296
|
-
? body.logs.filter((line): line is string => typeof line === 'string')
|
|
1297
|
-
: undefined,
|
|
1298
|
-
activeNodeId:
|
|
1299
|
-
typeof body.activeNodeId === 'string' ? body.activeNodeId : null,
|
|
1300
|
-
activeArtifactTableNamespace:
|
|
1301
|
-
typeof body.activeArtifactTableNamespace === 'string'
|
|
1302
|
-
? body.activeArtifactTableNamespace
|
|
1303
|
-
: null,
|
|
1304
|
-
updatedAt: typeof body.updatedAt === 'number' ? body.updatedAt : null,
|
|
1305
|
-
liveNodeProgress: body.liveNodeProgress,
|
|
1306
|
-
};
|
|
1307
|
-
} else if (
|
|
1308
|
-
body.type === 'terminal' &&
|
|
1309
|
-
(body.status === 'completed' ||
|
|
1310
|
-
body.status === 'failed' ||
|
|
1311
|
-
body.status === 'cancelled')
|
|
1312
|
-
) {
|
|
1313
|
-
eventInput = {
|
|
1314
|
-
runId: body.runId,
|
|
1315
|
-
type: 'terminal',
|
|
1316
|
-
status: body.status,
|
|
1317
|
-
ts,
|
|
1318
|
-
result: body.result,
|
|
1319
|
-
error: typeof body.error === 'string' ? body.error : null,
|
|
1320
|
-
totalRows: body.totalRows,
|
|
1321
|
-
durationMs: body.durationMs,
|
|
1322
|
-
playName: typeof body.playName === 'string' ? body.playName : null,
|
|
1323
|
-
};
|
|
1324
|
-
}
|
|
1325
|
-
if (!eventInput) {
|
|
1326
|
-
return new Response('invalid run event', { status: 400 });
|
|
1327
|
-
}
|
|
1328
|
-
const event = await this.storeRunEvent(eventInput);
|
|
1329
|
-
this.wakeRunEventWaiters(event);
|
|
1330
|
-
return new Response(JSON.stringify({ event }), {
|
|
1331
|
-
headers: { 'content-type': 'application/json' },
|
|
1332
|
-
});
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
private async handleRunEventList(req: Request): Promise<Response> {
|
|
1336
|
-
const url = new URL(req.url);
|
|
1337
|
-
const afterSeq = Math.max(
|
|
1338
|
-
0,
|
|
1339
|
-
Math.floor(Number(url.searchParams.get('afterSeq') ?? '0')),
|
|
1340
|
-
);
|
|
1341
|
-
const timeoutMs = Math.min(
|
|
1342
|
-
Math.max(Number(url.searchParams.get('timeoutMs') ?? '0'), 0),
|
|
1343
|
-
30_000,
|
|
1344
|
-
);
|
|
1345
|
-
const readLatestSeq = async () =>
|
|
1346
|
-
(await this.state.storage.get<number>(
|
|
1347
|
-
COORDINATOR_RUN_EVENT_SEQUENCE_KEY,
|
|
1348
|
-
)) ?? afterSeq;
|
|
1349
|
-
const readEvents = async (latestSeq: number) => {
|
|
1350
|
-
const firstAvailableSeq = Math.max(
|
|
1351
|
-
1,
|
|
1352
|
-
latestSeq - COORDINATOR_RUN_EVENT_MAX_ENTRIES + 1,
|
|
1353
|
-
);
|
|
1354
|
-
const startSeq = Math.max(afterSeq + 1, firstAvailableSeq);
|
|
1355
|
-
if (latestSeq < startSeq) return [];
|
|
1356
|
-
const keys: string[] = [];
|
|
1357
|
-
for (let seq = startSeq; seq <= latestSeq; seq += 1) {
|
|
1358
|
-
keys.push(this.runEventKeyForSeq(seq));
|
|
1359
|
-
}
|
|
1360
|
-
const entries = await this.state.storage.get<CoordinatorRunEvent>(keys);
|
|
1361
|
-
return [...entries.values()].sort((left, right) => left.seq - right.seq);
|
|
1362
|
-
};
|
|
1363
|
-
let latestSeq = await readLatestSeq();
|
|
1364
|
-
let events = await readEvents(latestSeq);
|
|
1365
|
-
if (events.length === 0 && timeoutMs > 0) {
|
|
1366
|
-
await new Promise<void>((resolve) => {
|
|
1367
|
-
let settled = false;
|
|
1368
|
-
const finish = () => {
|
|
1369
|
-
if (settled) return;
|
|
1370
|
-
settled = true;
|
|
1371
|
-
this.runEventWaiters.delete(onEvent);
|
|
1372
|
-
clearTimeout(timeout);
|
|
1373
|
-
resolve();
|
|
1374
|
-
};
|
|
1375
|
-
const onEvent = () => finish();
|
|
1376
|
-
const timeout = setTimeout(finish, timeoutMs);
|
|
1377
|
-
this.runEventWaiters.add(onEvent);
|
|
1378
|
-
});
|
|
1379
|
-
latestSeq = await readLatestSeq();
|
|
1380
|
-
events = await readEvents(latestSeq);
|
|
1381
|
-
}
|
|
1382
|
-
return new Response(JSON.stringify({ events, latestSeq }), {
|
|
1383
|
-
headers: { 'content-type': 'application/json' },
|
|
1384
|
-
});
|
|
1385
|
-
}
|
|
1386
|
-
}
|