deepline 0.1.178 → 0.1.180
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/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +132 -7
- package/dist/bundling-sources/apps/play-runner-workers/src/dedup-do.ts +25 -8
- package/dist/bundling-sources/apps/play-runner-workers/src/durable-object-deploy-handoff.ts +8 -2
- package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +414 -196
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/batching.ts +42 -2
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/coordinator-progress.ts +289 -0
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +112 -1
- package/dist/bundling-sources/sdk/src/client.ts +237 -0
- package/dist/bundling-sources/sdk/src/http.ts +100 -9
- package/dist/bundling-sources/sdk/src/release.ts +2 -2
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +10 -2
- package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +58 -2
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +99 -25
- package/dist/bundling-sources/shared_libs/play-runtime/db-session.ts +7 -2
- package/dist/bundling-sources/shared_libs/play-runtime/persistence-latch.ts +167 -0
- package/dist/bundling-sources/shared_libs/play-runtime/receipt-salvage.ts +158 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +44 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +74 -30
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +6 -0
- package/dist/cli/index.js +338 -13
- package/dist/cli/index.mjs +338 -13
- package/dist/index.d.mts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +229 -9
- package/dist/index.mjs +229 -9
- package/package.json +1 -1
|
@@ -19,17 +19,57 @@ export type ChunkExecutionResult<TRequest, TResult> = {
|
|
|
19
19
|
error?: unknown;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
function planExecutionChunks<TRequest>(
|
|
23
|
+
requests: TRequest[],
|
|
24
|
+
batchSize: number,
|
|
25
|
+
maxChunkWeight: number | undefined,
|
|
26
|
+
weightOf: ((request: TRequest) => number) | undefined,
|
|
27
|
+
): TRequest[][] {
|
|
28
|
+
const maxUnits = Math.max(1, Math.floor(batchSize));
|
|
29
|
+
const weightCap =
|
|
30
|
+
maxChunkWeight != null && weightOf != null
|
|
31
|
+
? Math.max(1, Math.floor(maxChunkWeight))
|
|
32
|
+
: null;
|
|
33
|
+
const chunks: TRequest[][] = [];
|
|
34
|
+
let current: TRequest[] = [];
|
|
35
|
+
let weight = 0;
|
|
36
|
+
for (const request of requests) {
|
|
37
|
+
if (
|
|
38
|
+
current.length > 0 &&
|
|
39
|
+
(current.length >= maxUnits ||
|
|
40
|
+
(weightCap != null && weight >= weightCap))
|
|
41
|
+
) {
|
|
42
|
+
chunks.push(current);
|
|
43
|
+
current = [];
|
|
44
|
+
weight = 0;
|
|
45
|
+
}
|
|
46
|
+
current.push(request);
|
|
47
|
+
if (weightCap != null && weightOf != null) {
|
|
48
|
+
weight += Math.max(1, Math.floor(weightOf(request)));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (current.length > 0) chunks.push(current);
|
|
52
|
+
return chunks;
|
|
53
|
+
}
|
|
54
|
+
|
|
22
55
|
export async function executeChunkedRequests<TRequest, TResult>(input: {
|
|
23
56
|
requests: TRequest[];
|
|
24
57
|
batchSize: number;
|
|
58
|
+
maxChunkWeight?: number;
|
|
59
|
+
weightOf?: (request: TRequest) => number;
|
|
25
60
|
execute: (request: TRequest) => Promise<TResult>;
|
|
26
61
|
onChunkComplete?: (
|
|
27
62
|
results: Array<ChunkExecutionResult<TRequest, TResult>>,
|
|
28
63
|
) => void | Promise<void>;
|
|
29
64
|
}): Promise<Array<ChunkExecutionResult<TRequest, TResult>>> {
|
|
30
65
|
const results: Array<ChunkExecutionResult<TRequest, TResult>> = [];
|
|
31
|
-
|
|
32
|
-
|
|
66
|
+
const chunks = planExecutionChunks(
|
|
67
|
+
input.requests,
|
|
68
|
+
input.batchSize,
|
|
69
|
+
input.maxChunkWeight,
|
|
70
|
+
input.weightOf,
|
|
71
|
+
);
|
|
72
|
+
for (const chunk of chunks) {
|
|
33
73
|
let notifyChain: Promise<void> = Promise.resolve();
|
|
34
74
|
const notify = async (
|
|
35
75
|
entry: ChunkExecutionResult<TRequest, TResult>,
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { coordinatorRequestHeaders } from '../../../../shared_libs/play-runtime/coordinator-headers';
|
|
2
|
+
import type {
|
|
3
|
+
LiveNodeProgressMap,
|
|
4
|
+
LiveNodeProgressSnapshot,
|
|
5
|
+
} from './live-progress';
|
|
6
|
+
|
|
7
|
+
type CoordinatorProgressTraceExtra = {
|
|
8
|
+
status: 'ok' | 'failed' | 'skipped_no_url';
|
|
9
|
+
httpStatus?: number;
|
|
10
|
+
activeNodeId?: string | null;
|
|
11
|
+
activeArtifactTableNamespace?: string | null;
|
|
12
|
+
activeCompleted?: number | null;
|
|
13
|
+
activeTotal?: number | null;
|
|
14
|
+
activeMessage?: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type CoordinatorProgressTrace = {
|
|
18
|
+
phase: 'runner.coordinator_progress_publish';
|
|
19
|
+
ms: number;
|
|
20
|
+
extra: CoordinatorProgressTraceExtra;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class CoordinatorProgressAuthorizationError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
message: string,
|
|
26
|
+
readonly status: number,
|
|
27
|
+
) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'CoordinatorProgressAuthorizationError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isCoordinatorProgressAuthorizationError(
|
|
34
|
+
error: unknown,
|
|
35
|
+
): error is CoordinatorProgressAuthorizationError {
|
|
36
|
+
return (
|
|
37
|
+
error instanceof CoordinatorProgressAuthorizationError ||
|
|
38
|
+
(typeof error === 'object' &&
|
|
39
|
+
error !== null &&
|
|
40
|
+
'name' in error &&
|
|
41
|
+
'status' in error &&
|
|
42
|
+
(error as { name?: unknown }).name ===
|
|
43
|
+
'CoordinatorProgressAuthorizationError' &&
|
|
44
|
+
typeof (error as { status?: unknown }).status === 'number')
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function activeProgressFields(liveNodeProgress: LiveNodeProgressMap): {
|
|
49
|
+
activeNodeId: string | null;
|
|
50
|
+
activeProgress: LiveNodeProgressSnapshot | null;
|
|
51
|
+
activeArtifactTableNamespace: string | null;
|
|
52
|
+
activeCompleted: number | null;
|
|
53
|
+
activeTotal: number | null;
|
|
54
|
+
activeMessage: string | null;
|
|
55
|
+
} {
|
|
56
|
+
const activeEntry =
|
|
57
|
+
Object.entries(liveNodeProgress).find(
|
|
58
|
+
([, progress]) => typeof progress.completedAt !== 'number',
|
|
59
|
+
) ?? Object.entries(liveNodeProgress).at(-1);
|
|
60
|
+
const activeNodeId = activeEntry?.[0] ?? null;
|
|
61
|
+
const activeProgress = activeEntry?.[1] ?? null;
|
|
62
|
+
const activeArtifactTableNamespace =
|
|
63
|
+
typeof activeProgress?.artifactTableNamespace === 'string'
|
|
64
|
+
? activeProgress.artifactTableNamespace
|
|
65
|
+
: null;
|
|
66
|
+
const activeCompleted =
|
|
67
|
+
typeof activeProgress?.completed === 'number'
|
|
68
|
+
? activeProgress.completed
|
|
69
|
+
: null;
|
|
70
|
+
const activeTotal =
|
|
71
|
+
typeof activeProgress?.total === 'number' ? activeProgress.total : null;
|
|
72
|
+
const activeMessage =
|
|
73
|
+
typeof activeProgress?.message === 'string' ? activeProgress.message : null;
|
|
74
|
+
return {
|
|
75
|
+
activeNodeId,
|
|
76
|
+
activeProgress,
|
|
77
|
+
activeArtifactTableNamespace,
|
|
78
|
+
activeCompleted,
|
|
79
|
+
activeTotal,
|
|
80
|
+
activeMessage,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function publishCoordinatorProgressEvent(input: {
|
|
85
|
+
runId: string;
|
|
86
|
+
coordinatorUrl?: string | null;
|
|
87
|
+
coordinatorInternalToken?: string | null;
|
|
88
|
+
occurredAt: number;
|
|
89
|
+
runLogBuffer: string[];
|
|
90
|
+
liveNodeProgress: LiveNodeProgressMap;
|
|
91
|
+
fetchFn: typeof fetch;
|
|
92
|
+
makeRequestId: () => string;
|
|
93
|
+
nowMs: () => number;
|
|
94
|
+
recordTrace: (trace: CoordinatorProgressTrace) => void;
|
|
95
|
+
}): Promise<void> {
|
|
96
|
+
const coordinatorUrl = input.coordinatorUrl?.trim();
|
|
97
|
+
if (!coordinatorUrl) {
|
|
98
|
+
input.recordTrace({
|
|
99
|
+
phase: 'runner.coordinator_progress_publish',
|
|
100
|
+
ms: 0,
|
|
101
|
+
extra: { status: 'skipped_no_url' },
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const publishStartedAt = input.nowMs();
|
|
107
|
+
const {
|
|
108
|
+
activeNodeId,
|
|
109
|
+
activeArtifactTableNamespace,
|
|
110
|
+
activeCompleted,
|
|
111
|
+
activeTotal,
|
|
112
|
+
activeMessage,
|
|
113
|
+
} = activeProgressFields(input.liveNodeProgress);
|
|
114
|
+
|
|
115
|
+
const response = await input.fetchFn(
|
|
116
|
+
`${coordinatorUrl.replace(/\/$/, '')}/dedup/${encodeURIComponent(
|
|
117
|
+
input.runId,
|
|
118
|
+
)}/event-add`,
|
|
119
|
+
{
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: {
|
|
122
|
+
'x-deepline-request-id': input.makeRequestId(),
|
|
123
|
+
...coordinatorRequestHeaders({
|
|
124
|
+
runId: input.runId,
|
|
125
|
+
contentType: 'application/json',
|
|
126
|
+
internalToken: input.coordinatorInternalToken,
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
runId: input.runId,
|
|
131
|
+
type: 'progress',
|
|
132
|
+
status: 'running',
|
|
133
|
+
ts: input.occurredAt,
|
|
134
|
+
logs: input.runLogBuffer,
|
|
135
|
+
activeNodeId,
|
|
136
|
+
activeArtifactTableNamespace,
|
|
137
|
+
updatedAt: input.occurredAt,
|
|
138
|
+
liveNodeProgress: input.liveNodeProgress,
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (response.status === 401 || response.status === 403) {
|
|
144
|
+
input.recordTrace({
|
|
145
|
+
phase: 'runner.coordinator_progress_publish',
|
|
146
|
+
ms: input.nowMs() - publishStartedAt,
|
|
147
|
+
extra: {
|
|
148
|
+
status: 'failed',
|
|
149
|
+
httpStatus: response.status,
|
|
150
|
+
activeNodeId,
|
|
151
|
+
activeArtifactTableNamespace,
|
|
152
|
+
activeCompleted,
|
|
153
|
+
activeTotal,
|
|
154
|
+
activeMessage,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
throw new CoordinatorProgressAuthorizationError(
|
|
158
|
+
`Coordinator progress publish was rejected with ${response.status}; ` +
|
|
159
|
+
'the play runner no longer has authority to update its coordinator state. ' +
|
|
160
|
+
'Stopping before more provider work can execute with stale coordinator authority.',
|
|
161
|
+
response.status,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!response.ok) {
|
|
166
|
+
input.recordTrace({
|
|
167
|
+
phase: 'runner.coordinator_progress_publish',
|
|
168
|
+
ms: input.nowMs() - publishStartedAt,
|
|
169
|
+
extra: {
|
|
170
|
+
status: 'failed',
|
|
171
|
+
httpStatus: response.status,
|
|
172
|
+
activeNodeId,
|
|
173
|
+
activeArtifactTableNamespace,
|
|
174
|
+
activeCompleted,
|
|
175
|
+
activeTotal,
|
|
176
|
+
activeMessage,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
throw new Error(`coordinator progress event failed ${response.status}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
input.recordTrace({
|
|
183
|
+
phase: 'runner.coordinator_progress_publish',
|
|
184
|
+
ms: input.nowMs() - publishStartedAt,
|
|
185
|
+
extra: {
|
|
186
|
+
status: 'ok',
|
|
187
|
+
activeNodeId,
|
|
188
|
+
activeArtifactTableNamespace,
|
|
189
|
+
activeCompleted,
|
|
190
|
+
activeTotal,
|
|
191
|
+
activeMessage,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createCoordinatorProgressPublisher(input: {
|
|
197
|
+
runId: string;
|
|
198
|
+
coordinatorUrl?: string | null;
|
|
199
|
+
coordinatorInternalToken?: string | null;
|
|
200
|
+
abortController: AbortController;
|
|
201
|
+
getRunLogBuffer: () => string[];
|
|
202
|
+
getLiveNodeProgress: () => LiveNodeProgressMap;
|
|
203
|
+
recordTrace: (trace: CoordinatorProgressTrace) => void;
|
|
204
|
+
fetchFn?: typeof fetch;
|
|
205
|
+
makeRequestId: () => string;
|
|
206
|
+
nowMs: () => number;
|
|
207
|
+
heartbeatIntervalMs: number;
|
|
208
|
+
}): {
|
|
209
|
+
assertHealthy: () => void;
|
|
210
|
+
flush: (force: boolean) => Promise<void>;
|
|
211
|
+
fatalError: () => Error | null;
|
|
212
|
+
} {
|
|
213
|
+
let lastPublishAt = 0;
|
|
214
|
+
let publishInFlight: Promise<void> = Promise.resolve();
|
|
215
|
+
let publishQueueDepth = 0;
|
|
216
|
+
let fatalError: Error | null = null;
|
|
217
|
+
const abortSignal = input.abortController.signal;
|
|
218
|
+
const fetchFn = input.fetchFn ?? fetch;
|
|
219
|
+
|
|
220
|
+
const markFatal = (error: Error): void => {
|
|
221
|
+
fatalError ??= error;
|
|
222
|
+
if (!abortSignal.aborted) {
|
|
223
|
+
input.abortController.abort(error);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const assertHealthy = (): void => {
|
|
228
|
+
if (fatalError) {
|
|
229
|
+
throw fatalError;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const publish = async (occurredAt: number): Promise<void> => {
|
|
234
|
+
assertHealthy();
|
|
235
|
+
await publishCoordinatorProgressEvent({
|
|
236
|
+
runId: input.runId,
|
|
237
|
+
coordinatorUrl: input.coordinatorUrl,
|
|
238
|
+
coordinatorInternalToken: input.coordinatorInternalToken,
|
|
239
|
+
occurredAt,
|
|
240
|
+
runLogBuffer: input.getRunLogBuffer(),
|
|
241
|
+
liveNodeProgress: input.getLiveNodeProgress(),
|
|
242
|
+
fetchFn,
|
|
243
|
+
makeRequestId: input.makeRequestId,
|
|
244
|
+
nowMs: input.nowMs,
|
|
245
|
+
recordTrace: input.recordTrace,
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const flush = (force: boolean): Promise<void> => {
|
|
250
|
+
if (fatalError) {
|
|
251
|
+
return force ? Promise.reject(fatalError) : Promise.resolve();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const now = input.nowMs();
|
|
255
|
+
if (!force && now - lastPublishAt < input.heartbeatIntervalMs) {
|
|
256
|
+
return Promise.resolve();
|
|
257
|
+
}
|
|
258
|
+
if (!force && publishQueueDepth > 0) {
|
|
259
|
+
return Promise.resolve();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
lastPublishAt = now;
|
|
263
|
+
publishQueueDepth += 1;
|
|
264
|
+
const publishTask = publishInFlight
|
|
265
|
+
.catch(() => undefined)
|
|
266
|
+
.then(async () => {
|
|
267
|
+
try {
|
|
268
|
+
await publish(now);
|
|
269
|
+
} finally {
|
|
270
|
+
publishQueueDepth = Math.max(0, publishQueueDepth - 1);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const handledPublishTask = publishTask.catch((error) => {
|
|
275
|
+
if (isCoordinatorProgressAuthorizationError(error)) {
|
|
276
|
+
markFatal(error);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
publishInFlight = handledPublishTask.catch(() => undefined);
|
|
281
|
+
return force ? handledPublishTask : Promise.resolve();
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
assertHealthy,
|
|
286
|
+
flush,
|
|
287
|
+
fatalError: () => fatalError,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -29,6 +29,116 @@ export function canReclaimTimedOutWorkerToolReceipt(input: {
|
|
|
29
29
|
return Boolean(currentRunId);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Staleness floor for the dead-owner reclaim fast path. A `running` receipt
|
|
34
|
+
* left by a different run whose last write is older than this is treated as an
|
|
35
|
+
* orphan from a dead run — no live worker will ever complete it. Running
|
|
36
|
+
* receipts carry no heartbeat (updatedAt is frozen at claim time), so this is a
|
|
37
|
+
* conservative constant, not a heartbeat multiple.
|
|
38
|
+
*/
|
|
39
|
+
export const RUNNING_RECEIPT_DEAD_OWNER_STALENESS_MS = 60_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide whether a `running` receipt should be reclaimed IMMEDIATELY, skipping
|
|
43
|
+
* the completion poll.
|
|
44
|
+
*
|
|
45
|
+
* The poll (`waitForCompletedRuntimeReceipt`) issues one service-binding
|
|
46
|
+
* subrequest per 250ms tick, up to the receipt's wait budget (~1320 ticks for a
|
|
47
|
+
* 5.5-minute default). On resume, a map can carry many receipts left `running`
|
|
48
|
+
* by a DEAD prior run; polling each one burns its full tick budget waiting on a
|
|
49
|
+
* receipt no live worker will complete, which blows Cloudflare's ~1000
|
|
50
|
+
* subrequests-per-invocation budget and kills the resume with
|
|
51
|
+
* "Too many subrequests by single Worker invocation" (prod resume deaths
|
|
52
|
+
* 20260703t230209 / 20260703t232104).
|
|
53
|
+
*
|
|
54
|
+
* A receipt is a dead-owner orphan when it is owned by a DIFFERENT run (a
|
|
55
|
+
* same-run `running` is this run replaying — its owner may still be live) and
|
|
56
|
+
* its last write is stale (or missing entirely — a live owner would carry a
|
|
57
|
+
* recent claim timestamp). Those reclaim immediately, matching the dispatch
|
|
58
|
+
* code's own contract ("on resume it reclaims and re-executes"). A live or
|
|
59
|
+
* freshly-claimed owner keeps the poll.
|
|
60
|
+
*/
|
|
61
|
+
export function shouldReclaimRunningReceiptWithoutPolling(input: {
|
|
62
|
+
ownerRunId?: string | null;
|
|
63
|
+
currentRunId: string;
|
|
64
|
+
updatedAt?: string | null;
|
|
65
|
+
nowMs?: number;
|
|
66
|
+
stalenessFloorMs?: number;
|
|
67
|
+
}): boolean {
|
|
68
|
+
const currentRunId = input.currentRunId.trim();
|
|
69
|
+
const ownerRunId = input.ownerRunId?.trim() ?? '';
|
|
70
|
+
// Only a receipt owned by a different run can be a dead prior-run orphan.
|
|
71
|
+
if (!currentRunId || !ownerRunId || ownerRunId === currentRunId) return false;
|
|
72
|
+
const floorMs =
|
|
73
|
+
input.stalenessFloorMs ?? RUNNING_RECEIPT_DEAD_OWNER_STALENESS_MS;
|
|
74
|
+
// No freshness signal at all: treat as stale. A live owner would carry a
|
|
75
|
+
// recent claim timestamp; an orphan from a dead run may carry none.
|
|
76
|
+
if (!input.updatedAt) return true;
|
|
77
|
+
const updatedAtMs = Date.parse(input.updatedAt);
|
|
78
|
+
if (!Number.isFinite(updatedAtMs)) return true;
|
|
79
|
+
const now = input.nowMs ?? Date.now();
|
|
80
|
+
return now - updatedAtMs >= floorMs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Metadata a `running` receipt carries about its owner and last write. */
|
|
84
|
+
export interface RunningReceiptOwnershipInput {
|
|
85
|
+
ownerRunId?: string | null;
|
|
86
|
+
currentRunId: string;
|
|
87
|
+
updatedAt?: string | null;
|
|
88
|
+
nowMs?: number;
|
|
89
|
+
stalenessFloorMs?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Outcome of polling a `running` receipt for completion. */
|
|
93
|
+
export type RunningReceiptPollOutcome =
|
|
94
|
+
/** The poll saw the receipt complete and already resolved the group. */
|
|
95
|
+
| { kind: 'completed' }
|
|
96
|
+
/** The poll timed out; the receipt is still `running`. */
|
|
97
|
+
| { kind: 'timed_out'; error: unknown }
|
|
98
|
+
/** The poll failed for a non-timeout reason (e.g. the receipt failed). */
|
|
99
|
+
| { kind: 'errored'; error: unknown };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Orchestrate the resume-time handling of a `running` receipt: reclaim it
|
|
103
|
+
* immediately when it is a dead-owner orphan, otherwise poll for completion and
|
|
104
|
+
* only reclaim after the poll times out. Kept as a pure, dependency-injected
|
|
105
|
+
* function (the poll and reclaim side effects are passed in) so the
|
|
106
|
+
* "skip the poll for a dead owner" decision is unit-testable with a poll counter
|
|
107
|
+
* — the Worker scheduler that owns the real effects imports `cloudflare:workers`
|
|
108
|
+
* and cannot be loaded under vitest.
|
|
109
|
+
*
|
|
110
|
+
* Behavior (must match the scheduler's prior inline logic exactly):
|
|
111
|
+
* - dead-owner + reclaim-eligible → `reclaim` now, never polling;
|
|
112
|
+
* - otherwise `poll`; on `completed` the group is already resolved (return []);
|
|
113
|
+
* on a non-timeout error `reject` and stop; on timeout, `reclaim` if still
|
|
114
|
+
* eligible else `reject` with the timeout error.
|
|
115
|
+
*/
|
|
116
|
+
export async function reclaimRunningReceiptGroupAfterWait<TClaimed>(input: {
|
|
117
|
+
ownership: RunningReceiptOwnershipInput;
|
|
118
|
+
timeoutError: unknown;
|
|
119
|
+
poll: () => Promise<RunningReceiptPollOutcome>;
|
|
120
|
+
reclaim: (rejectionOnFallthrough: unknown) => Promise<TClaimed[]>;
|
|
121
|
+
reject: (error: unknown) => void;
|
|
122
|
+
}): Promise<TClaimed[]> {
|
|
123
|
+
if (
|
|
124
|
+
shouldReclaimRunningReceiptWithoutPolling(input.ownership) &&
|
|
125
|
+
canReclaimTimedOutWorkerToolReceipt(input.ownership)
|
|
126
|
+
) {
|
|
127
|
+
return await input.reclaim(input.timeoutError);
|
|
128
|
+
}
|
|
129
|
+
const outcome = await input.poll();
|
|
130
|
+
if (outcome.kind === 'completed') return [];
|
|
131
|
+
if (outcome.kind === 'errored') {
|
|
132
|
+
input.reject(outcome.error);
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
if (!canReclaimTimedOutWorkerToolReceipt(input.ownership)) {
|
|
136
|
+
input.reject(outcome.error);
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
return await input.reclaim(outcome.error);
|
|
140
|
+
}
|
|
141
|
+
|
|
32
142
|
export function canReclaimFailedWorkerToolReceipt(input: {
|
|
33
143
|
error?: string | null;
|
|
34
144
|
}): boolean {
|
|
@@ -36,7 +146,8 @@ export function canReclaimFailedWorkerToolReceipt(input: {
|
|
|
36
146
|
return Boolean(
|
|
37
147
|
error &&
|
|
38
148
|
(/\b(cancell?ed|aborted|terminate[d]?)\b/i.test(error) ||
|
|
39
|
-
/\bmax runtime\b/i.test(error)
|
|
149
|
+
/\bmax runtime\b/i.test(error) ||
|
|
150
|
+
/persistence failure|durable receipts? could not be marked/i.test(error)),
|
|
40
151
|
);
|
|
41
152
|
}
|
|
42
153
|
|