deepline 0.1.179 → 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 +131 -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 +379 -57
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/batching.ts +42 -2
- 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/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/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
|
@@ -155,10 +155,10 @@ import {
|
|
|
155
155
|
import type { RuntimeStepReceipt } from '../../../shared_libs/play-runtime/ctx-types';
|
|
156
156
|
import {
|
|
157
157
|
canReclaimFailedWorkerToolReceipt,
|
|
158
|
-
canReclaimTimedOutWorkerToolReceipt,
|
|
159
158
|
markWorkerToolReceiptResultCached,
|
|
160
159
|
markWorkerToolReceiptResultExecution,
|
|
161
160
|
planWorkerToolReceiptGroups,
|
|
161
|
+
reclaimRunningReceiptGroupAfterWait,
|
|
162
162
|
resolveWorkerToolReceiptGroupWaitMaxAttempts,
|
|
163
163
|
resolveWorkerToolRuntimeTimeoutMs,
|
|
164
164
|
} from './runtime/tool-receipts';
|
|
@@ -216,6 +216,22 @@ import {
|
|
|
216
216
|
} from '../../../shared_libs/play-runtime/csv-rename';
|
|
217
217
|
import { coordinatorRequestHeaders } from '../../../shared_libs/play-runtime/coordinator-headers';
|
|
218
218
|
import { normalizePlayRunFailure } from '../../../shared_libs/play-runtime/run-failure';
|
|
219
|
+
import {
|
|
220
|
+
createRuntimePersistenceLatch,
|
|
221
|
+
isRuntimePersistenceCircuitOpenError,
|
|
222
|
+
partitionDispatchWaves,
|
|
223
|
+
PROVIDER_DISPATCH_WAVE_SIZE,
|
|
224
|
+
RuntimePersistenceCircuitOpenError,
|
|
225
|
+
shouldShortenCompleteReceiptLadder,
|
|
226
|
+
tripRuntimePersistenceLatch,
|
|
227
|
+
type RuntimePersistenceLatch,
|
|
228
|
+
} from '../../../shared_libs/play-runtime/persistence-latch';
|
|
229
|
+
import {
|
|
230
|
+
createReceiptSalvageBuffer,
|
|
231
|
+
receiptSalvageFailureSuffix,
|
|
232
|
+
type ReceiptSalvage,
|
|
233
|
+
type ReceiptSalvageBuffer,
|
|
234
|
+
} from '../../../shared_libs/play-runtime/receipt-salvage';
|
|
219
235
|
import { createSecretRedactionContext } from '../../../shared_libs/play-runtime/secret-redaction';
|
|
220
236
|
import {
|
|
221
237
|
assertNoSecretTaint,
|
|
@@ -1741,10 +1757,21 @@ function isRunFatalWorkerRowError(error: unknown): boolean {
|
|
|
1741
1757
|
return (
|
|
1742
1758
|
isCoordinatorProgressAuthorizationError(error) ||
|
|
1743
1759
|
isRowIsolationExemptError(error) ||
|
|
1744
|
-
isRuntimeReceiptPersistenceError(error)
|
|
1760
|
+
isRuntimeReceiptPersistenceError(error) ||
|
|
1761
|
+
isRuntimePersistenceCircuitOpenError(error)
|
|
1745
1762
|
);
|
|
1746
1763
|
}
|
|
1747
1764
|
|
|
1765
|
+
/**
|
|
1766
|
+
* completeReceipt salvage retry ladder. On a persistence failure after a billed
|
|
1767
|
+
* call we retry the receipt completion past the server-side 5s connect window:
|
|
1768
|
+
* 1 initial attempt + 3 retries at 1s / 3s / 9s (single isolate — no jitter).
|
|
1769
|
+
* Bounded by the run deadline (`runtimeDeadlineMs`); we stop early unless there
|
|
1770
|
+
* is at least one backoff's worth plus headroom of budget left.
|
|
1771
|
+
*/
|
|
1772
|
+
const COMPLETE_RECEIPT_RETRY_BACKOFFS_MS = [1_000, 3_000, 9_000] as const;
|
|
1773
|
+
const COMPLETE_RECEIPT_RETRY_MIN_HEADROOM_MS = 10_000;
|
|
1774
|
+
|
|
1748
1775
|
// Fallback batch-chunk parallelism when a tool declares no provider rate hints.
|
|
1749
1776
|
// Matches the prior hardcoded `Math.min(4, ...)` so undeclared providers keep
|
|
1750
1777
|
// their previous batching behavior; declared providers tighten via the
|
|
@@ -1820,8 +1847,96 @@ class WorkerToolBatchScheduler {
|
|
|
1820
1847
|
private readonly receiptStore?: WorkerRuntimeReceiptStore,
|
|
1821
1848
|
private readonly allowLocalRetryReceipts = false,
|
|
1822
1849
|
private readonly runtimeDeadlineMs?: number,
|
|
1850
|
+
// Map-wide (or ctx-wide for the root scheduler) persistence-failure circuit
|
|
1851
|
+
// breaker. Shared by every row worker so the first persistence failure
|
|
1852
|
+
// halts NEW provider dispatch across the whole map.
|
|
1853
|
+
private readonly latch: RuntimePersistenceLatch = createRuntimePersistenceLatch(),
|
|
1854
|
+
// Run-scoped buffer for orphaned billed results whose receipt could not be
|
|
1855
|
+
// completed even after the retry ladder.
|
|
1856
|
+
private readonly salvage?: ReceiptSalvageBuffer,
|
|
1823
1857
|
) {}
|
|
1824
1858
|
|
|
1859
|
+
/**
|
|
1860
|
+
* Run `completeReceipt`/`completeReceipts` with the salvage retry ladder.
|
|
1861
|
+
* Retries a receipt-completion failure past the server-side 5s connect window
|
|
1862
|
+
* so a transient blip does not orphan a billed result. Bounded by the run
|
|
1863
|
+
* deadline; skips retries when the latch has already burned a ladder in this
|
|
1864
|
+
* map so many in-flight orphans do not each serialize a long loop.
|
|
1865
|
+
*/
|
|
1866
|
+
private async completeReceiptWithRetryLadder<T>(
|
|
1867
|
+
run: () => Promise<T>,
|
|
1868
|
+
onGiveUp?: (attempts: number) => void,
|
|
1869
|
+
): Promise<T> {
|
|
1870
|
+
let attempt = 0;
|
|
1871
|
+
// A ladder that has already started retrying keeps its full budget even if
|
|
1872
|
+
// a peer trips the latch mid-flight — only FRESH entrants collapse once the
|
|
1873
|
+
// failure is confirmed. This is what guarantees the first ladder rides out
|
|
1874
|
+
// a genuine transient blip.
|
|
1875
|
+
let committedToFullLadder = false;
|
|
1876
|
+
const giveUp = (error: unknown): never => {
|
|
1877
|
+
this.latch.latchRetryBudgetExhausted = true;
|
|
1878
|
+
onGiveUp?.(attempt);
|
|
1879
|
+
throw error;
|
|
1880
|
+
};
|
|
1881
|
+
while (true) {
|
|
1882
|
+
try {
|
|
1883
|
+
return await run();
|
|
1884
|
+
} catch (error) {
|
|
1885
|
+
attempt += 1;
|
|
1886
|
+
if (
|
|
1887
|
+
shouldShortenCompleteReceiptLadder(this.latch, committedToFullLadder) ||
|
|
1888
|
+
attempt > COMPLETE_RECEIPT_RETRY_BACKOFFS_MS.length ||
|
|
1889
|
+
this.abortSignal?.aborted
|
|
1890
|
+
) {
|
|
1891
|
+
return giveUp(error);
|
|
1892
|
+
}
|
|
1893
|
+
const backoffMs = COMPLETE_RECEIPT_RETRY_BACKOFFS_MS[attempt - 1]!;
|
|
1894
|
+
// resolveRuntimeDeadlineRemainingMs THROWS WorkflowAbortError once the
|
|
1895
|
+
// run deadline has passed — the correct loud outcome (there is no
|
|
1896
|
+
// separate per-chunk step timeout). Route that throw through giveUp so
|
|
1897
|
+
// the billed result is still salvaged and the ladder budget flag is
|
|
1898
|
+
// set: a billed output must never lose its durable trace because the
|
|
1899
|
+
// clock ran out mid-ladder.
|
|
1900
|
+
let remainingMs: number;
|
|
1901
|
+
try {
|
|
1902
|
+
remainingMs = resolveRuntimeDeadlineRemainingMs(
|
|
1903
|
+
this.runtimeDeadlineMs,
|
|
1904
|
+
);
|
|
1905
|
+
} catch (deadlineError) {
|
|
1906
|
+
return giveUp(deadlineError);
|
|
1907
|
+
}
|
|
1908
|
+
if (remainingMs <= backoffMs + COMPLETE_RECEIPT_RETRY_MIN_HEADROOM_MS) {
|
|
1909
|
+
return giveUp(error);
|
|
1910
|
+
}
|
|
1911
|
+
// Past every give-up gate: this ladder is now committed to the full
|
|
1912
|
+
// budget and must not be collapsed by a peer tripping the latch mid-sleep.
|
|
1913
|
+
committedToFullLadder = true;
|
|
1914
|
+
await sleepWorkerMs(backoffMs);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Record a billed-but-unpersisted tool output into the run-scoped salvage
|
|
1921
|
+
* buffer. Called only after the completeReceipt ladder is exhausted, with the
|
|
1922
|
+
* exact serialized payload a receipt would have stored.
|
|
1923
|
+
*/
|
|
1924
|
+
private recordReceiptSalvage(input: {
|
|
1925
|
+
claimed: ClaimedWorkerToolBatchRequest;
|
|
1926
|
+
output: unknown;
|
|
1927
|
+
attempts: number;
|
|
1928
|
+
}): void {
|
|
1929
|
+
if (!this.salvage || !input.claimed.receiptKey) return;
|
|
1930
|
+
this.salvage.push({
|
|
1931
|
+
receiptKey: input.claimed.receiptKey,
|
|
1932
|
+
toolId: input.claimed.request.toolId,
|
|
1933
|
+
cacheKey: input.claimed.request.cacheKey,
|
|
1934
|
+
output: input.output,
|
|
1935
|
+
completeAttempts: input.attempts,
|
|
1936
|
+
firstErrorAt: nowMs(),
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1825
1940
|
/**
|
|
1826
1941
|
* Report a provider 429 / Retry-After back into the Governor's shared pacer
|
|
1827
1942
|
* so future acquires for this (org, provider) bucket back off across all
|
|
@@ -2054,37 +2169,73 @@ class WorkerToolBatchScheduler {
|
|
|
2054
2169
|
);
|
|
2055
2170
|
return [];
|
|
2056
2171
|
}
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2172
|
+
// A `running` receipt left by a DIFFERENT run whose last write is stale will
|
|
2173
|
+
// never be completed by a live worker. Polling it burns one service-binding
|
|
2174
|
+
// subrequest per tick, up to the receipt's wait budget (~1320) — many such
|
|
2175
|
+
// orphans on a resume exhaust Cloudflare's per-invocation subrequest budget
|
|
2176
|
+
// and kill the run. The orchestrator reclaims those immediately (skipping the
|
|
2177
|
+
// poll) and re-executes, matching the "on resume it reclaims and re-executes"
|
|
2178
|
+
// contract; live/fresh/same-run owners still poll before reclaiming. Kept as
|
|
2179
|
+
// an injected helper so the skip-poll decision is unit-testable without
|
|
2180
|
+
// loading this cloudflare:workers Worker entrypoint.
|
|
2181
|
+
return await reclaimRunningReceiptGroupAfterWait<ClaimedWorkerToolBatchRequest>(
|
|
2182
|
+
{
|
|
2183
|
+
ownership: {
|
|
2184
|
+
ownerRunId: input.runningReceipt.runId,
|
|
2185
|
+
currentRunId: this.req.runId,
|
|
2186
|
+
updatedAt: input.runningReceipt.updatedAt,
|
|
2187
|
+
},
|
|
2188
|
+
timeoutError: new RuntimeReceiptWaitTimeoutError(input.receiptKey),
|
|
2189
|
+
poll: async () => {
|
|
2190
|
+
try {
|
|
2191
|
+
const receipt = await this.waitForDurableToolReceipt({
|
|
2192
|
+
receiptKey: input.receiptKey,
|
|
2193
|
+
maxAttempts: resolveWorkerToolReceiptGroupWaitMaxAttempts(
|
|
2194
|
+
input.group,
|
|
2195
|
+
(groupRequest) => groupRequest.receiptWaitMaxAttempts,
|
|
2196
|
+
),
|
|
2197
|
+
});
|
|
2198
|
+
await this.resolveCompletedDurableToolReceiptGroup({
|
|
2199
|
+
group: input.group,
|
|
2200
|
+
receiptKey: input.receiptKey,
|
|
2201
|
+
receipt,
|
|
2202
|
+
source: 'in_flight',
|
|
2203
|
+
});
|
|
2204
|
+
return { kind: 'completed' };
|
|
2205
|
+
} catch (error) {
|
|
2206
|
+
if (error instanceof RuntimeReceiptWaitTimeoutError) {
|
|
2207
|
+
return { kind: 'timed_out', error };
|
|
2208
|
+
}
|
|
2209
|
+
return { kind: 'errored', error };
|
|
2210
|
+
}
|
|
2211
|
+
},
|
|
2212
|
+
reclaim: (rejectionOnFallthrough) =>
|
|
2213
|
+
this.forceReclaimRunningDurableToolReceiptGroup({
|
|
2214
|
+
group: input.group,
|
|
2215
|
+
request,
|
|
2216
|
+
followers,
|
|
2217
|
+
receiptKey: input.receiptKey,
|
|
2218
|
+
rejectionOnFallthrough,
|
|
2219
|
+
}),
|
|
2220
|
+
reject: (error) => this.rejectRawRequests(input.group, error),
|
|
2221
|
+
},
|
|
2222
|
+
);
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
/**
|
|
2226
|
+
* Steal a `running` receipt (reclaimRunning) and route by claim disposition.
|
|
2227
|
+
* Shared by the post-poll-timeout reclaim and the dead-owner fast path, so
|
|
2228
|
+
* both dispose of a reclaimed/reused/failed receipt identically.
|
|
2229
|
+
*/
|
|
2230
|
+
private async forceReclaimRunningDurableToolReceiptGroup(input: {
|
|
2231
|
+
group: WorkerToolBatchRequest[];
|
|
2232
|
+
request: WorkerToolBatchRequest;
|
|
2233
|
+
followers: WorkerToolBatchRequest[];
|
|
2234
|
+
receiptKey: string;
|
|
2235
|
+
rejectionOnFallthrough: unknown;
|
|
2236
|
+
}): Promise<ClaimedWorkerToolBatchRequest[]> {
|
|
2237
|
+
if (!this.receiptStore) {
|
|
2238
|
+
this.rejectRawRequests(input.group, input.rejectionOnFallthrough);
|
|
2088
2239
|
return [];
|
|
2089
2240
|
}
|
|
2090
2241
|
let claim: WorkerRuntimeReceiptClaim;
|
|
@@ -2100,7 +2251,13 @@ class WorkerToolBatchScheduler {
|
|
|
2100
2251
|
return [];
|
|
2101
2252
|
}
|
|
2102
2253
|
if (claim.disposition === 'claimed') {
|
|
2103
|
-
return [
|
|
2254
|
+
return [
|
|
2255
|
+
{
|
|
2256
|
+
request: input.request,
|
|
2257
|
+
receiptKey: input.receiptKey,
|
|
2258
|
+
followers: input.followers,
|
|
2259
|
+
},
|
|
2260
|
+
];
|
|
2104
2261
|
}
|
|
2105
2262
|
if (claim.disposition === 'reused') {
|
|
2106
2263
|
await this.resolveCompletedDurableToolReceiptGroup({
|
|
@@ -2118,7 +2275,7 @@ class WorkerToolBatchScheduler {
|
|
|
2118
2275
|
failedReceipt: claim.receipt,
|
|
2119
2276
|
});
|
|
2120
2277
|
}
|
|
2121
|
-
this.rejectRawRequests(input.group,
|
|
2278
|
+
this.rejectRawRequests(input.group, input.rejectionOnFallthrough);
|
|
2122
2279
|
return [];
|
|
2123
2280
|
}
|
|
2124
2281
|
|
|
@@ -2203,6 +2360,7 @@ class WorkerToolBatchScheduler {
|
|
|
2203
2360
|
await this.failDurableToolRequest(claimed, error);
|
|
2204
2361
|
return error;
|
|
2205
2362
|
} catch (receiptError) {
|
|
2363
|
+
tripRuntimePersistenceLatch(this.latch, receiptError);
|
|
2206
2364
|
return new RuntimeReceiptPersistenceError(
|
|
2207
2365
|
'Tool call failed and durable receipt could not be marked failed',
|
|
2208
2366
|
[error, receiptError],
|
|
@@ -2354,6 +2512,9 @@ class WorkerToolBatchScheduler {
|
|
|
2354
2512
|
claimedRequests.push({ request, receiptKey, followers });
|
|
2355
2513
|
}
|
|
2356
2514
|
} catch (error) {
|
|
2515
|
+
// A receipt claim failure means the tenant Postgres is already
|
|
2516
|
+
// unwritable: trip the breaker so remaining groups/rows stop dispatching.
|
|
2517
|
+
tripRuntimePersistenceLatch(this.latch, error);
|
|
2357
2518
|
for (const group of receiptGroupPlan.durableGroups) {
|
|
2358
2519
|
this.rejectRawRequests(group.requests, error);
|
|
2359
2520
|
}
|
|
@@ -2372,12 +2533,25 @@ class WorkerToolBatchScheduler {
|
|
|
2372
2533
|
kind: 'live',
|
|
2373
2534
|
receiptKey: claimed.receiptKey,
|
|
2374
2535
|
});
|
|
2375
|
-
const
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2536
|
+
const serializedOutput = serializeDurableStepValue(ownerResult);
|
|
2537
|
+
const completed = await this.completeReceiptWithRetryLadder(
|
|
2538
|
+
() =>
|
|
2539
|
+
this.receiptStore!.completeReceipt({
|
|
2540
|
+
playName: this.req.playName,
|
|
2541
|
+
runId: this.req.runId,
|
|
2542
|
+
key: claimed.receiptKey!,
|
|
2543
|
+
output: serializedOutput,
|
|
2544
|
+
}),
|
|
2545
|
+
// Ladder exhausted: the call billed but the receipt could not land.
|
|
2546
|
+
// Salvage the exact serialized output onto the run record before the
|
|
2547
|
+
// caller turns this into a RuntimeReceiptPersistenceError.
|
|
2548
|
+
(attempts) =>
|
|
2549
|
+
this.recordReceiptSalvage({
|
|
2550
|
+
claimed,
|
|
2551
|
+
output: serializedOutput,
|
|
2552
|
+
attempts,
|
|
2553
|
+
}),
|
|
2554
|
+
);
|
|
2381
2555
|
if (
|
|
2382
2556
|
completed &&
|
|
2383
2557
|
(completed.status === 'completed' || completed.status === 'skipped') &&
|
|
@@ -2453,14 +2627,32 @@ class WorkerToolBatchScheduler {
|
|
|
2453
2627
|
);
|
|
2454
2628
|
return results;
|
|
2455
2629
|
}
|
|
2456
|
-
const
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2630
|
+
const serializedByEntry = durableEntries.map((entry) =>
|
|
2631
|
+
serializeDurableStepValue(entry.ownerResult),
|
|
2632
|
+
);
|
|
2633
|
+
const completed = await this.completeReceiptWithRetryLadder(
|
|
2634
|
+
() =>
|
|
2635
|
+
this.receiptStore!.completeReceipts!({
|
|
2636
|
+
playName: this.req.playName,
|
|
2637
|
+
receipts: durableEntries.map((entry, entryIndex) => ({
|
|
2638
|
+
runId: this.req.runId,
|
|
2639
|
+
key: entry.claimed.receiptKey!,
|
|
2640
|
+
output: serializedByEntry[entryIndex],
|
|
2641
|
+
})),
|
|
2642
|
+
}),
|
|
2643
|
+
// Ladder exhausted: every entry in this batch billed but could not be
|
|
2644
|
+
// marked completed. Salvage each serialized output before the caller
|
|
2645
|
+
// turns this into a RuntimeReceiptPersistenceError.
|
|
2646
|
+
(attempts) => {
|
|
2647
|
+
durableEntries.forEach((entry, entryIndex) => {
|
|
2648
|
+
this.recordReceiptSalvage({
|
|
2649
|
+
claimed: entry.claimed,
|
|
2650
|
+
output: serializedByEntry[entryIndex],
|
|
2651
|
+
attempts,
|
|
2652
|
+
});
|
|
2653
|
+
});
|
|
2654
|
+
},
|
|
2655
|
+
);
|
|
2464
2656
|
durableEntries.forEach((entry, resultIndex) => {
|
|
2465
2657
|
results[entry.index] = this.completedDurableToolRequestResult({
|
|
2466
2658
|
claimed: entry.claimed,
|
|
@@ -2514,6 +2706,17 @@ class WorkerToolBatchScheduler {
|
|
|
2514
2706
|
toolId: string,
|
|
2515
2707
|
requests: WorkerToolBatchRequest[],
|
|
2516
2708
|
): Promise<void> {
|
|
2709
|
+
// Circuit breaker, gated BEFORE the receipt claim: never-dispatched rows
|
|
2710
|
+
// get NO receipt (not even `running`), so a re-run claims them fresh and
|
|
2711
|
+
// executes them exactly once.
|
|
2712
|
+
if (this.latch.tripped) {
|
|
2713
|
+
this.latch.preventedCallCount += requests.length;
|
|
2714
|
+
this.rejectRawRequests(
|
|
2715
|
+
requests,
|
|
2716
|
+
new RuntimePersistenceCircuitOpenError(this.latch),
|
|
2717
|
+
);
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2517
2720
|
const { claimedRequests, deferredClaimedRequests } =
|
|
2518
2721
|
await this.prepareDurableToolRequests(requests);
|
|
2519
2722
|
await this.executeClaimedToolRequests(
|
|
@@ -2549,9 +2752,23 @@ class WorkerToolBatchScheduler {
|
|
|
2549
2752
|
claimedRequests.length < 2
|
|
2550
2753
|
) {
|
|
2551
2754
|
const groupStartedAt = nowMs();
|
|
2552
|
-
|
|
2553
|
-
|
|
2755
|
+
const dispatchOne = async (
|
|
2756
|
+
claimed: ClaimedWorkerToolBatchRequest,
|
|
2757
|
+
): Promise<void> => {
|
|
2554
2758
|
const { request } = claimed;
|
|
2759
|
+
// Circuit breaker: the latch may have tripped after this group was
|
|
2760
|
+
// claimed but before this particular call started. Do NOT call the
|
|
2761
|
+
// provider. This row has a `running` receipt (claimed, never
|
|
2762
|
+
// executed); on resume it reclaims and re-executes — safe, it never
|
|
2763
|
+
// billed.
|
|
2764
|
+
if (this.latch.tripped) {
|
|
2765
|
+
this.latch.preventedCallCount += 1 + claimed.followers.length;
|
|
2766
|
+
this.rejectRequests(
|
|
2767
|
+
claimed,
|
|
2768
|
+
new RuntimePersistenceCircuitOpenError(this.latch),
|
|
2769
|
+
);
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2555
2772
|
const toolContract = await this.resolvePacing(toolId).catch(
|
|
2556
2773
|
() => null,
|
|
2557
2774
|
);
|
|
@@ -2590,6 +2807,7 @@ class WorkerToolBatchScheduler {
|
|
|
2590
2807
|
await this.completeDurableToolRequest(claimed, result),
|
|
2591
2808
|
);
|
|
2592
2809
|
} catch (receiptError) {
|
|
2810
|
+
tripRuntimePersistenceLatch(this.latch, receiptError);
|
|
2593
2811
|
this.rejectRequests(
|
|
2594
2812
|
claimed,
|
|
2595
2813
|
new RuntimeReceiptPersistenceError(
|
|
@@ -2600,8 +2818,32 @@ class WorkerToolBatchScheduler {
|
|
|
2600
2818
|
} finally {
|
|
2601
2819
|
slot.release();
|
|
2602
2820
|
}
|
|
2603
|
-
|
|
2604
|
-
|
|
2821
|
+
};
|
|
2822
|
+
// Bounded dispatch waves: an unbatched tool group can carry a whole chunk's
|
|
2823
|
+
// rows (enrich compiles to one ~245-row chunk). Dispatching them all at
|
|
2824
|
+
// once billed every call before the first receipt-completion failure could
|
|
2825
|
+
// trip the latch (the 250/250 incident). Instead, dispatch one wave, AWAIT
|
|
2826
|
+
// it so any completion failure trips the latch, then read the latch before
|
|
2827
|
+
// the next wave — remaining waves are prevented (counted + rejected) exactly
|
|
2828
|
+
// like the mid-wave gate above. The wave calls stay fully concurrent; the
|
|
2829
|
+
// only added cost on the healthy path is one latch read per wave.
|
|
2830
|
+
for (const wave of partitionDispatchWaves(
|
|
2831
|
+
claimedRequests,
|
|
2832
|
+
() => 1,
|
|
2833
|
+
PROVIDER_DISPATCH_WAVE_SIZE,
|
|
2834
|
+
)) {
|
|
2835
|
+
if (this.latch.tripped) {
|
|
2836
|
+
for (const claimed of wave) {
|
|
2837
|
+
this.latch.preventedCallCount += 1 + claimed.followers.length;
|
|
2838
|
+
this.rejectRequests(
|
|
2839
|
+
claimed,
|
|
2840
|
+
new RuntimePersistenceCircuitOpenError(this.latch),
|
|
2841
|
+
);
|
|
2842
|
+
}
|
|
2843
|
+
continue;
|
|
2844
|
+
}
|
|
2845
|
+
await Promise.all(wave.map(dispatchOne));
|
|
2846
|
+
}
|
|
2605
2847
|
recordRunnerPerfTrace({
|
|
2606
2848
|
req: this.req,
|
|
2607
2849
|
phase: 'runner.tool.group',
|
|
@@ -2639,6 +2881,7 @@ class WorkerToolBatchScheduler {
|
|
|
2639
2881
|
await this.failDurableToolRequests(claimedRequests, error),
|
|
2640
2882
|
settleRequest: (claimed, result) => this.settleRequests(claimed, result),
|
|
2641
2883
|
rejectRequest: (claimed, error) => this.rejectRequests(claimed, error),
|
|
2884
|
+
latch: this.latch,
|
|
2642
2885
|
});
|
|
2643
2886
|
recordRunnerPerfTrace({
|
|
2644
2887
|
req: this.req,
|
|
@@ -2696,6 +2939,7 @@ async function executeBatchedWorkerToolGroup(input: {
|
|
|
2696
2939
|
request: ClaimedWorkerToolBatchRequest,
|
|
2697
2940
|
error: unknown,
|
|
2698
2941
|
) => void;
|
|
2942
|
+
latch: RuntimePersistenceLatch;
|
|
2699
2943
|
}): Promise<void> {
|
|
2700
2944
|
const compiledBatches = compileRequestsWithStrategy({
|
|
2701
2945
|
requests: input.requests,
|
|
@@ -2723,7 +2967,20 @@ async function executeBatchedWorkerToolGroup(input: {
|
|
|
2723
2967
|
1,
|
|
2724
2968
|
Math.min(input.suggestedParallelism, compiledBatches.length || 1),
|
|
2725
2969
|
),
|
|
2970
|
+
// Bounded dispatch wave: cap the provider calls (counted by batch member
|
|
2971
|
+
// size) a single chunk dispatches before the latch is read again, so a
|
|
2972
|
+
// persistence failure prevents the rest of a large batch group instead of
|
|
2973
|
+
// billing it all. A single over-budget batch still forms its own wave.
|
|
2974
|
+
maxChunkWeight: PROVIDER_DISPATCH_WAVE_SIZE,
|
|
2975
|
+
weightOf: (batch) => batch.memberRequests.length,
|
|
2726
2976
|
execute: async (batch) => {
|
|
2977
|
+
// Circuit breaker: once a persistence failure has occurred in this map,
|
|
2978
|
+
// do NOT dispatch this batch's provider call. The chunk plumbing rejects
|
|
2979
|
+
// its member requests with the latch error.
|
|
2980
|
+
if (input.latch.tripped) {
|
|
2981
|
+
input.latch.preventedCallCount += batch.memberRequests.length;
|
|
2982
|
+
throw new RuntimePersistenceCircuitOpenError(input.latch);
|
|
2983
|
+
}
|
|
2727
2984
|
const toolContract = await input
|
|
2728
2985
|
.resolveToolContract(batch.batchOperation)
|
|
2729
2986
|
.catch(() => null);
|
|
@@ -2772,6 +3029,7 @@ async function executeBatchedWorkerToolGroup(input: {
|
|
|
2772
3029
|
try {
|
|
2773
3030
|
await input.failRequests(entry.request.memberRequests, entry.error);
|
|
2774
3031
|
} catch (receiptError) {
|
|
3032
|
+
tripRuntimePersistenceLatch(input.latch, receiptError);
|
|
2775
3033
|
rejection = new RuntimeReceiptPersistenceError(
|
|
2776
3034
|
'Tool call failed and durable receipts could not be marked failed',
|
|
2777
3035
|
[entry.error, receiptError],
|
|
@@ -2802,6 +3060,7 @@ async function executeBatchedWorkerToolGroup(input: {
|
|
|
2802
3060
|
})),
|
|
2803
3061
|
);
|
|
2804
3062
|
} catch (receiptError) {
|
|
3063
|
+
tripRuntimePersistenceLatch(input.latch, receiptError);
|
|
2805
3064
|
const rejection = new RuntimeReceiptPersistenceError(
|
|
2806
3065
|
'Tool call succeeded but durable receipts could not be marked completed',
|
|
2807
3066
|
[receiptError],
|
|
@@ -2823,6 +3082,7 @@ async function executeBatchedWorkerToolGroup(input: {
|
|
|
2823
3082
|
try {
|
|
2824
3083
|
await input.failRequests(input.requests, error);
|
|
2825
3084
|
} catch (receiptError) {
|
|
3085
|
+
tripRuntimePersistenceLatch(input.latch, receiptError);
|
|
2826
3086
|
rejection = new RuntimeReceiptPersistenceError(
|
|
2827
3087
|
'Tool call failed and durable receipts could not be marked failed',
|
|
2828
3088
|
[error, receiptError],
|
|
@@ -4487,6 +4747,7 @@ function createMinimalWorkerCtx(
|
|
|
4487
4747
|
abortSignal?: AbortSignal,
|
|
4488
4748
|
callbacks?: WorkerCtxCallbacks,
|
|
4489
4749
|
runtimeDeadlineMs?: number,
|
|
4750
|
+
receiptSalvage?: ReceiptSalvageBuffer,
|
|
4490
4751
|
): unknown {
|
|
4491
4752
|
const { governor, resolvePacing: resolveToolPacing } =
|
|
4492
4753
|
createGovernorForRun(req);
|
|
@@ -4600,6 +4861,10 @@ function createMinimalWorkerCtx(
|
|
|
4600
4861
|
staleAfterSeconds,
|
|
4601
4862
|
});
|
|
4602
4863
|
};
|
|
4864
|
+
// Ctx-wide breaker for top-level ctx.tool calls (outside any map). Each map
|
|
4865
|
+
// creates its own map-scoped latch (see runMap) so per-map dispatch stats and
|
|
4866
|
+
// the runMap failure message stay map-local.
|
|
4867
|
+
const rootPersistenceLatch = createRuntimePersistenceLatch();
|
|
4603
4868
|
const rootToolBatchScheduler = new WorkerToolBatchScheduler(
|
|
4604
4869
|
req,
|
|
4605
4870
|
governor,
|
|
@@ -4611,6 +4876,8 @@ function createMinimalWorkerCtx(
|
|
|
4611
4876
|
receiptStore,
|
|
4612
4877
|
true,
|
|
4613
4878
|
runtimeDeadlineMs,
|
|
4879
|
+
rootPersistenceLatch,
|
|
4880
|
+
receiptSalvage,
|
|
4614
4881
|
);
|
|
4615
4882
|
// Local ancestry chain that always ENDS with the currently-executing play
|
|
4616
4883
|
// (req.playName). The /api/v2/plays/run lineage validator requires the
|
|
@@ -4646,6 +4913,12 @@ function createMinimalWorkerCtx(
|
|
|
4646
4913
|
): Promise<unknown> => {
|
|
4647
4914
|
const mapStartedAt = nowMs();
|
|
4648
4915
|
const mapNodeId = `map:${name}`;
|
|
4916
|
+
// Map-wide persistence-failure circuit breaker. Shared across every chunk's
|
|
4917
|
+
// scheduler (the scheduler is rebuilt per chunk) and the map's sheet-persist
|
|
4918
|
+
// flush chain, so the first persistence failure halts NEW provider dispatch
|
|
4919
|
+
// for the rest of the map and its prevented-call count surfaces in the
|
|
4920
|
+
// runMap failure message below.
|
|
4921
|
+
const persistenceLatch = createRuntimePersistenceLatch();
|
|
4649
4922
|
const inputRows = rows;
|
|
4650
4923
|
const rowCountHint = datasetRowCountHint(inputRows);
|
|
4651
4924
|
const baseOffset = 0;
|
|
@@ -5089,6 +5362,8 @@ function createMinimalWorkerCtx(
|
|
|
5089
5362
|
receiptStore,
|
|
5090
5363
|
false,
|
|
5091
5364
|
runtimeDeadlineMs,
|
|
5365
|
+
persistenceLatch,
|
|
5366
|
+
receiptSalvage,
|
|
5092
5367
|
);
|
|
5093
5368
|
let stepCellsCompleted = 0;
|
|
5094
5369
|
let stepCellsSkipped = 0;
|
|
@@ -5231,6 +5506,9 @@ function createMinimalWorkerCtx(
|
|
|
5231
5506
|
});
|
|
5232
5507
|
persistFlushChain = task.catch((error) => {
|
|
5233
5508
|
persistFailure ??= error;
|
|
5509
|
+
// Output-sheet flush failed: trip the breaker so the scheduler stops
|
|
5510
|
+
// dispatching new provider calls for the rest of this map.
|
|
5511
|
+
tripRuntimePersistenceLatch(persistenceLatch, error);
|
|
5234
5512
|
});
|
|
5235
5513
|
return task;
|
|
5236
5514
|
};
|
|
@@ -5382,6 +5660,15 @@ function createMinimalWorkerCtx(
|
|
|
5382
5660
|
force: request.force === true || !!req.force,
|
|
5383
5661
|
staleAfterSeconds: request.staleAfterSeconds,
|
|
5384
5662
|
},
|
|
5663
|
+
// Parity with the play-level ctx.tools.execute (and the
|
|
5664
|
+
// cjs runner's map path): the public ToolExecutionRequest
|
|
5665
|
+
// runtime options must not be silently dropped for map
|
|
5666
|
+
// rows — receiptWaitMs bounds how long a resumed run
|
|
5667
|
+
// waits on a stuck `running` receipt before reclaiming.
|
|
5668
|
+
{
|
|
5669
|
+
timeoutMs: request.timeoutMs,
|
|
5670
|
+
receiptWaitMs: request.receiptWaitMs,
|
|
5671
|
+
},
|
|
5385
5672
|
);
|
|
5386
5673
|
},
|
|
5387
5674
|
},
|
|
@@ -5675,7 +5962,13 @@ function createMinimalWorkerCtx(
|
|
|
5675
5962
|
result.status === 'rejected',
|
|
5676
5963
|
);
|
|
5677
5964
|
if (rejectedWorker) {
|
|
5678
|
-
if (
|
|
5965
|
+
if (
|
|
5966
|
+
isRuntimeReceiptPersistenceError(rejectedWorker.reason) ||
|
|
5967
|
+
isRuntimePersistenceCircuitOpenError(rejectedWorker.reason)
|
|
5968
|
+
) {
|
|
5969
|
+
// Persistence-failure / circuit-breaker rejections are returned as a
|
|
5970
|
+
// fatal summary (NOT rethrown into the durable step) so the chunk is
|
|
5971
|
+
// not retried and provider calls are not re-billed.
|
|
5679
5972
|
return await fatalMapChunkSummary(rejectedWorker.reason);
|
|
5680
5973
|
}
|
|
5681
5974
|
throw rejectedWorker.reason;
|
|
@@ -5982,10 +6275,16 @@ function createMinimalWorkerCtx(
|
|
|
5982
6275
|
}
|
|
5983
6276
|
const chunkResult = await runChunkStep(chunkRows, chunkStart, chunkIndex);
|
|
5984
6277
|
if (chunkResult.fatalError) {
|
|
6278
|
+
const circuitBreakerSentence =
|
|
6279
|
+
persistenceLatch.tripped && persistenceLatch.preventedCallCount > 0
|
|
6280
|
+
? `Circuit breaker prevented ${persistenceLatch.preventedCallCount} queued provider call(s) ` +
|
|
6281
|
+
`from dispatching after the first persistence failure. `
|
|
6282
|
+
: '';
|
|
5985
6283
|
throw new Error(
|
|
5986
6284
|
`ctx.dataset("${name}") stopped after a runtime persistence failure ` +
|
|
5987
6285
|
`outside the retryable chunk step. Provider calls already executed for ` +
|
|
5988
6286
|
`${chunkResult.rowsExecuted} row(s), so the chunk was not retried. ` +
|
|
6287
|
+
circuitBreakerSentence +
|
|
5989
6288
|
`Fix the runtime persistence cause and re-run to resume. ` +
|
|
5990
6289
|
`First error: ${chunkResult.fatalError}`,
|
|
5991
6290
|
);
|
|
@@ -7472,6 +7771,11 @@ async function executeRunRequest(
|
|
|
7472
7771
|
stepLifecycle?.markPreDatasetStepsStarted(startedAt);
|
|
7473
7772
|
flushLedgerEvents(false);
|
|
7474
7773
|
const runtimeDeadlineMs = nowMs() + STANDARD_PLAY_RUNTIME_LIMIT_MS;
|
|
7774
|
+
// Run-scoped buffer for provider results that billed but whose durable
|
|
7775
|
+
// receipt could not be marked completed even after the retry ladder. On
|
|
7776
|
+
// terminal failure the buffer is built into a size-capped salvage record and
|
|
7777
|
+
// attached to the run.failed ledger event so billed data is never memory-only.
|
|
7778
|
+
const receiptSalvage = createReceiptSalvageBuffer();
|
|
7475
7779
|
const ctx = createMinimalWorkerCtx(
|
|
7476
7780
|
req,
|
|
7477
7781
|
wrappedEmit,
|
|
@@ -7480,6 +7784,7 @@ async function executeRunRequest(
|
|
|
7480
7784
|
abortSignal,
|
|
7481
7785
|
workerCallbacks,
|
|
7482
7786
|
runtimeDeadlineMs,
|
|
7787
|
+
receiptSalvage,
|
|
7483
7788
|
);
|
|
7484
7789
|
// Hard wall-clock cap on active user-code runtime. CF Workflows does not
|
|
7485
7790
|
// impose a play-level execution ceiling on this substrate, so without this a
|
|
@@ -7674,8 +7979,16 @@ async function executeRunRequest(
|
|
|
7674
7979
|
stepLifecycle?.markStartedFailed(nowMs());
|
|
7675
7980
|
// A runtime-limit abort is a timeout failure, not a user cancellation, so
|
|
7676
7981
|
// it should be reported as run.failed with the limit message rather than
|
|
7677
|
-
// run.cancelled.
|
|
7678
|
-
|
|
7982
|
+
// run.cancelled. The deadline can also surface as a WorkflowAbortError
|
|
7983
|
+
// thrown synchronously from the completeReceipt retry ladder BEFORE the
|
|
7984
|
+
// runtimeDeadlineTimer macrotask flips the flag — match the limit message
|
|
7985
|
+
// directly so that race can never misclassify a timeout as a cancel (which
|
|
7986
|
+
// would silently drop the salvage buffer of billed-but-unpersisted rows).
|
|
7987
|
+
const runtimeLimitError =
|
|
7988
|
+
runtimeLimitExceeded ||
|
|
7989
|
+
(error instanceof Error &&
|
|
7990
|
+
error.message === STANDARD_PLAY_RUNTIME_LIMIT_MESSAGE);
|
|
7991
|
+
const aborted = isAbortLikeError(error) && !runtimeLimitError;
|
|
7679
7992
|
if (aborted) {
|
|
7680
7993
|
// Flip the controller so any concurrent user code observes the abort
|
|
7681
7994
|
// through ctx.signal. We mark the run cancelled instead of failed.
|
|
@@ -7684,7 +7997,15 @@ async function executeRunRequest(
|
|
|
7684
7997
|
);
|
|
7685
7998
|
}
|
|
7686
7999
|
const failure = normalizePlayRunFailure(error);
|
|
7687
|
-
|
|
8000
|
+
// Billed-but-unpersisted salvage rides on the run.failed event's `result`.
|
|
8001
|
+
// The run STILL FAILS — salvage is a loud marker, never a success fallback.
|
|
8002
|
+
const salvage: ReceiptSalvage | null = aborted
|
|
8003
|
+
? null
|
|
8004
|
+
: receiptSalvage.build();
|
|
8005
|
+
const message =
|
|
8006
|
+
salvage && salvage.totalEntries > 0
|
|
8007
|
+
? `${failure.message} ${receiptSalvageFailureSuffix(salvage)}`
|
|
8008
|
+
: failure.message;
|
|
7688
8009
|
const errorBilling = extractErrorBilling(error);
|
|
7689
8010
|
if (options?.persistResultDatasets) {
|
|
7690
8011
|
appendRunLogLine(
|
|
@@ -7713,6 +8034,7 @@ async function executeRunRequest(
|
|
|
7713
8034
|
...(failure.cause ? { cause: failure.cause } : {}),
|
|
7714
8035
|
},
|
|
7715
8036
|
],
|
|
8037
|
+
...(salvage && salvage.totalEntries > 0 ? { salvage } : {}),
|
|
7716
8038
|
},
|
|
7717
8039
|
});
|
|
7718
8040
|
recordRunnerPerfTrace({
|