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
|
@@ -78,6 +78,13 @@ import {
|
|
|
78
78
|
type DurableReceiptExecutionStore,
|
|
79
79
|
} from './durable-receipt-execution';
|
|
80
80
|
import { isRowIsolationExemptError } from './row-isolation';
|
|
81
|
+
import {
|
|
82
|
+
createRuntimePersistenceLatch,
|
|
83
|
+
PROVIDER_DISPATCH_WAVE_SIZE,
|
|
84
|
+
RuntimePersistenceCircuitOpenError,
|
|
85
|
+
tripRuntimePersistenceLatch,
|
|
86
|
+
type RuntimePersistenceLatch,
|
|
87
|
+
} from './persistence-latch';
|
|
81
88
|
import { createRuntimeDatasetId } from './dataset-id';
|
|
82
89
|
import { dedupeExplicitMapKeyRows } from './map-row-identity';
|
|
83
90
|
import {
|
|
@@ -712,6 +719,16 @@ export class PlayContextImpl {
|
|
|
712
719
|
createSecretRedactionContext();
|
|
713
720
|
private mapInvocationIndex = 0;
|
|
714
721
|
private readonly stepCallIndexByKey = new Map<string, number>();
|
|
722
|
+
/**
|
|
723
|
+
* Runtime persistence-failure circuit breaker (postgres_fast parity with the
|
|
724
|
+
* Workers runtime). The first persistence failure — a receipt-completion
|
|
725
|
+
* failure after a successful tool call, a receipt-failure write failure, or a
|
|
726
|
+
* map-row sheet flush failure — trips this latch; the dispatch loops then stop
|
|
727
|
+
* dispatching NEW provider calls so a dead tenant DB cannot keep billing calls
|
|
728
|
+
* that have nowhere durable to land.
|
|
729
|
+
*/
|
|
730
|
+
private readonly persistenceLatch: RuntimePersistenceLatch =
|
|
731
|
+
createRuntimePersistenceLatch();
|
|
715
732
|
/**
|
|
716
733
|
* The single source of every concurrency, budget, and pacing decision for this
|
|
717
734
|
* run-attempt. Tool/play/row slots are blocking semaphores; budgets are charged
|
|
@@ -1722,8 +1739,10 @@ export class PlayContextImpl {
|
|
|
1722
1739
|
receiptKey,
|
|
1723
1740
|
}),
|
|
1724
1741
|
});
|
|
1725
|
-
|
|
1726
|
-
|
|
1742
|
+
let completed: RuntimeStepReceipt | null | undefined = null;
|
|
1743
|
+
if (receiptKey) {
|
|
1744
|
+
try {
|
|
1745
|
+
completed = (
|
|
1727
1746
|
await this.completeRuntimeStepReceipts([
|
|
1728
1747
|
{
|
|
1729
1748
|
key: receiptKey,
|
|
@@ -1731,8 +1750,14 @@ export class PlayContextImpl {
|
|
|
1731
1750
|
output: serializeToolExecuteResult(wrapped),
|
|
1732
1751
|
},
|
|
1733
1752
|
])
|
|
1734
|
-
).get(receiptKey)
|
|
1735
|
-
|
|
1753
|
+
).get(receiptKey);
|
|
1754
|
+
} catch (receiptError) {
|
|
1755
|
+
// Completion failed AFTER a successful (billed) tool call: trip the
|
|
1756
|
+
// breaker so remaining rows stop dispatching new provider calls.
|
|
1757
|
+
tripRuntimePersistenceLatch(this.persistenceLatch, receiptError);
|
|
1758
|
+
throw receiptError;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1736
1761
|
return await this.finalizeResolvedToolCall(
|
|
1737
1762
|
toolId,
|
|
1738
1763
|
request,
|
|
@@ -1781,10 +1806,19 @@ export class PlayContextImpl {
|
|
|
1781
1806
|
]
|
|
1782
1807
|
: [];
|
|
1783
1808
|
});
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1809
|
+
let completedByKey: Map<string, RuntimeStepReceipt>;
|
|
1810
|
+
if (receiptInputs.length > 0) {
|
|
1811
|
+
try {
|
|
1812
|
+
completedByKey = await this.completeRuntimeStepReceipts(receiptInputs);
|
|
1813
|
+
} catch (receiptError) {
|
|
1814
|
+
// Completion failed AFTER successful (billed) tool calls: trip the
|
|
1815
|
+
// breaker so remaining rows stop dispatching new provider calls.
|
|
1816
|
+
tripRuntimePersistenceLatch(this.persistenceLatch, receiptError);
|
|
1817
|
+
throw receiptError;
|
|
1818
|
+
}
|
|
1819
|
+
} else {
|
|
1820
|
+
completedByKey = new Map<string, RuntimeStepReceipt>();
|
|
1821
|
+
}
|
|
1788
1822
|
const results: unknown[] = [];
|
|
1789
1823
|
for (const entry of wrappedEntries) {
|
|
1790
1824
|
const receiptKey = entry.request.receiptKey?.trim() || null;
|
|
@@ -1875,6 +1909,7 @@ export class PlayContextImpl {
|
|
|
1875
1909
|
},
|
|
1876
1910
|
]);
|
|
1877
1911
|
} catch (receiptError) {
|
|
1912
|
+
tripRuntimePersistenceLatch(this.persistenceLatch, receiptError);
|
|
1878
1913
|
rejectionError = new AggregateError(
|
|
1879
1914
|
[error, receiptError],
|
|
1880
1915
|
'Tool call failed and durable receipt could not be marked failed',
|
|
@@ -2285,17 +2320,24 @@ export class PlayContextImpl {
|
|
|
2285
2320
|
let chunkBytes = 0;
|
|
2286
2321
|
const flushChunk = async () => {
|
|
2287
2322
|
if (chunk.length === 0) return;
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2323
|
+
try {
|
|
2324
|
+
await this.#options.onMapRowsCompleted!({
|
|
2325
|
+
playName: this.#options.playName,
|
|
2326
|
+
playId: this.#options.playId,
|
|
2327
|
+
runId: this.#options.runId,
|
|
2328
|
+
tableNamespace: resolvedTableNamespace,
|
|
2329
|
+
rows: chunk,
|
|
2330
|
+
outputFields: datasetColumnNames.filter((field) =>
|
|
2331
|
+
shouldPersistMapCellField(field),
|
|
2332
|
+
),
|
|
2333
|
+
staticPipeline: this.#options.staticPipeline ?? null,
|
|
2334
|
+
});
|
|
2335
|
+
} catch (error) {
|
|
2336
|
+
// Output-sheet flush failed: trip the breaker so the dispatch loops
|
|
2337
|
+
// stop dispatching new provider calls for the rest of this map.
|
|
2338
|
+
tripRuntimePersistenceLatch(this.persistenceLatch, error);
|
|
2339
|
+
throw error;
|
|
2340
|
+
}
|
|
2299
2341
|
chunk = [];
|
|
2300
2342
|
chunkBytes = 0;
|
|
2301
2343
|
};
|
|
@@ -4742,8 +4784,23 @@ export class PlayContextImpl {
|
|
|
4742
4784
|
await executeChunkedRequests({
|
|
4743
4785
|
requests: compiledBatches,
|
|
4744
4786
|
batchSize,
|
|
4745
|
-
|
|
4746
|
-
|
|
4787
|
+
// Bounded dispatch wave (postgres_fast parity with workers_edge):
|
|
4788
|
+
// cap the provider calls (counted by batch member size) a single
|
|
4789
|
+
// chunk dispatches before the latch is read again, so a
|
|
4790
|
+
// persistence failure prevents the rest of a large batch group.
|
|
4791
|
+
maxChunkWeight: PROVIDER_DISPATCH_WAVE_SIZE,
|
|
4792
|
+
weightOf: (batch) => batch.memberRequests.length,
|
|
4793
|
+
execute: async (batch) => {
|
|
4794
|
+
// Circuit breaker: skip dispatching this batch's provider call
|
|
4795
|
+
// once a persistence failure has occurred in this run.
|
|
4796
|
+
if (this.persistenceLatch.tripped) {
|
|
4797
|
+
this.persistenceLatch.preventedCallCount +=
|
|
4798
|
+
batch.memberRequests.length;
|
|
4799
|
+
throw new RuntimePersistenceCircuitOpenError(
|
|
4800
|
+
this.persistenceLatch,
|
|
4801
|
+
);
|
|
4802
|
+
}
|
|
4803
|
+
return await this.callToolAPI(
|
|
4747
4804
|
batch.batchOperation,
|
|
4748
4805
|
batch.batchPayload,
|
|
4749
4806
|
{
|
|
@@ -4751,7 +4808,8 @@ export class PlayContextImpl {
|
|
|
4751
4808
|
batch.memberRequests,
|
|
4752
4809
|
),
|
|
4753
4810
|
},
|
|
4754
|
-
)
|
|
4811
|
+
);
|
|
4812
|
+
},
|
|
4755
4813
|
onChunkComplete: async (chunkResults) => {
|
|
4756
4814
|
for (const entry of chunkResults) {
|
|
4757
4815
|
if (entry.error !== undefined) {
|
|
@@ -4790,9 +4848,13 @@ export class PlayContextImpl {
|
|
|
4790
4848
|
},
|
|
4791
4849
|
});
|
|
4792
4850
|
} else {
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4851
|
+
// Bounded dispatch wave (postgres_fast parity with workers_edge):
|
|
4852
|
+
// an unbatched tool group can carry a whole chunk's rows, so cap the
|
|
4853
|
+
// provider calls dispatched before the latch is read again to the
|
|
4854
|
+
// wave size. Respect a lower provider-pacing suggestion when present.
|
|
4855
|
+
const batchSize = Math.min(
|
|
4856
|
+
await this.governor.suggestedParallelism(toolId, 50),
|
|
4857
|
+
PROVIDER_DISPATCH_WAVE_SIZE,
|
|
4796
4858
|
);
|
|
4797
4859
|
for (
|
|
4798
4860
|
let start = 0;
|
|
@@ -4802,6 +4864,18 @@ export class PlayContextImpl {
|
|
|
4802
4864
|
const chunk = pendingRequests.slice(start, start + batchSize);
|
|
4803
4865
|
await Promise.allSettled(
|
|
4804
4866
|
chunk.map(async (request) => {
|
|
4867
|
+
// Circuit breaker: do not dispatch this provider call once a
|
|
4868
|
+
// persistence failure has occurred in this run.
|
|
4869
|
+
if (this.persistenceLatch.tripped) {
|
|
4870
|
+
this.persistenceLatch.preventedCallCount += 1;
|
|
4871
|
+
await rejectWithLiveFollowers(
|
|
4872
|
+
request,
|
|
4873
|
+
new RuntimePersistenceCircuitOpenError(
|
|
4874
|
+
this.persistenceLatch,
|
|
4875
|
+
),
|
|
4876
|
+
);
|
|
4877
|
+
return;
|
|
4878
|
+
}
|
|
4805
4879
|
try {
|
|
4806
4880
|
const execution = await this.callToolExecutionAPI(
|
|
4807
4881
|
toolId,
|
|
@@ -3,8 +3,13 @@ import type {
|
|
|
3
3
|
PostgresUrlEncryptionRequest,
|
|
4
4
|
} from './db-session-crypto';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// Workflow DB sessions must not expire before the workflow run/retry state they
|
|
7
|
+
// pair with. Run/retry state lives 60 min (WORKFLOW_RUN_STATE_TTL_MS in the
|
|
8
|
+
// coordinator), so these are aligned to 60 min. Raising the default WITHOUT
|
|
9
|
+
// raising the max would leave the coordinator DO clamp / request validator
|
|
10
|
+
// silently capping the session at 30 min, so both move together.
|
|
11
|
+
export const DB_SESSION_DEFAULT_TTL_SECONDS = 60 * 60;
|
|
12
|
+
export const DB_SESSION_MAX_TTL_SECONDS = 60 * 60;
|
|
8
13
|
|
|
9
14
|
export const DB_SESSION_OPERATIONS = [
|
|
10
15
|
'rows.read',
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime persistence-failure circuit breaker.
|
|
3
|
+
*
|
|
4
|
+
* When a runtime persistence write fails mid-map (a work-receipt claim/complete
|
|
5
|
+
* or an output-sheet flush against the tenant Postgres), continuing to dispatch
|
|
6
|
+
* NEW provider calls only widens the "billed but not durable" gap: every extra
|
|
7
|
+
* call bills server-side yet has nowhere to land. The latch trips on the first
|
|
8
|
+
* such failure and is checked by the worker dispatch loops before they dispatch
|
|
9
|
+
* any further provider call, converting "all billed, half persisted" into
|
|
10
|
+
* "billed ≈ persisted + small in-flight window".
|
|
11
|
+
*
|
|
12
|
+
* The latch is a plain mutable object shared across all row workers of a map
|
|
13
|
+
* (single isolate, cooperative scheduling — no locking needed).
|
|
14
|
+
*/
|
|
15
|
+
export type RuntimePersistenceLatch = {
|
|
16
|
+
/** True once the first runtime persistence failure has been observed. */
|
|
17
|
+
tripped: boolean;
|
|
18
|
+
/** Formatted first-failure message (first trip wins). */
|
|
19
|
+
cause: string | null;
|
|
20
|
+
/** Provider calls NOT dispatched because the latch was already tripped. */
|
|
21
|
+
preventedCallCount: number;
|
|
22
|
+
/**
|
|
23
|
+
* True once a full completeReceipt retry ladder has been exhausted in this
|
|
24
|
+
* map. Bookkeeping only: the shorten decision now keys on `tripped` (see
|
|
25
|
+
* `shouldShortenCompleteReceiptLadder`) so fresh entrants collapse as soon as
|
|
26
|
+
* a persistence failure is confirmed rather than only after the first ladder
|
|
27
|
+
* fully drains.
|
|
28
|
+
*/
|
|
29
|
+
latchRetryBudgetExhausted: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function createRuntimePersistenceLatch(): RuntimePersistenceLatch {
|
|
33
|
+
return {
|
|
34
|
+
tripped: false,
|
|
35
|
+
cause: null,
|
|
36
|
+
preventedCallCount: 0,
|
|
37
|
+
latchRetryBudgetExhausted: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bounded provider-dispatch wave size.
|
|
43
|
+
*
|
|
44
|
+
* A single map chunk can hold hundreds of rows. The persistence latch only
|
|
45
|
+
* trips AFTER a receipt completes (i.e. after the provider call already billed),
|
|
46
|
+
* so if every row in a big chunk dispatches its provider call in one synchronous
|
|
47
|
+
* wave, the latch cannot gate anything within that chunk — all calls bill before
|
|
48
|
+
* the first failure is even observed. That is the 250/250 incident (run
|
|
49
|
+
* play/x-lima-shard-03/run/20260703t231850-473-44f3d408: a customer billed
|
|
50
|
+
* 250/250 with preventedCallCount=0, because enrich batches compile to one
|
|
51
|
+
* ~245-row chunk with effectively unbounded row concurrency).
|
|
52
|
+
*
|
|
53
|
+
* Dispatching provider calls in waves of this size, with a single latch read
|
|
54
|
+
* between waves, bounds billed exposure for ANY chunk shape to ~one wave plus
|
|
55
|
+
* the calls already in flight when the latch trips. Constant on purpose (house
|
|
56
|
+
* preference: searchable, no env var).
|
|
57
|
+
*/
|
|
58
|
+
export const PROVIDER_DISPATCH_WAVE_SIZE = 25;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Partition dispatch units into bounded waves. Each unit contributes `sizeOf`
|
|
62
|
+
* toward the wave budget: 1 for a single provider call, the batch member count
|
|
63
|
+
* for a batched group. A batch is atomic and is never split across waves — an
|
|
64
|
+
* over-budget batch forms its own wave and still counts its full member size
|
|
65
|
+
* toward the budget. Pure + synchronous so the wave arithmetic is unit-testable
|
|
66
|
+
* apart from the async dispatch loops that consume it.
|
|
67
|
+
*/
|
|
68
|
+
export function partitionDispatchWaves<T>(
|
|
69
|
+
units: readonly T[],
|
|
70
|
+
sizeOf: (unit: T) => number,
|
|
71
|
+
waveSize: number = PROVIDER_DISPATCH_WAVE_SIZE,
|
|
72
|
+
): T[][] {
|
|
73
|
+
const budget = Math.max(1, Math.floor(waveSize));
|
|
74
|
+
const waves: T[][] = [];
|
|
75
|
+
let current: T[] = [];
|
|
76
|
+
let weight = 0;
|
|
77
|
+
for (const unit of units) {
|
|
78
|
+
if (current.length > 0 && weight >= budget) {
|
|
79
|
+
waves.push(current);
|
|
80
|
+
current = [];
|
|
81
|
+
weight = 0;
|
|
82
|
+
}
|
|
83
|
+
current.push(unit);
|
|
84
|
+
weight += Math.max(1, Math.floor(sizeOf(unit)));
|
|
85
|
+
}
|
|
86
|
+
if (current.length > 0) waves.push(current);
|
|
87
|
+
return waves;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatLatchCause(error: unknown): string {
|
|
91
|
+
const raw =
|
|
92
|
+
error instanceof Error
|
|
93
|
+
? error.message
|
|
94
|
+
: typeof error === 'string'
|
|
95
|
+
? error
|
|
96
|
+
: (() => {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.stringify(error);
|
|
99
|
+
} catch {
|
|
100
|
+
return String(error);
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
const message = (raw ?? '').replace(/\s+/g, ' ').trim();
|
|
104
|
+
if (!message) return 'Runtime persistence failed.';
|
|
105
|
+
return message.length > 1_000 ? `${message.slice(0, 1_000)}…` : message;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Trip the latch on the first persistence failure. Idempotent: later trips are
|
|
110
|
+
* no-ops so `cause` keeps the first, root failure.
|
|
111
|
+
*/
|
|
112
|
+
export function tripRuntimePersistenceLatch(
|
|
113
|
+
latch: RuntimePersistenceLatch,
|
|
114
|
+
error: unknown,
|
|
115
|
+
): void {
|
|
116
|
+
if (latch.tripped) return;
|
|
117
|
+
latch.tripped = true;
|
|
118
|
+
latch.cause = formatLatchCause(error);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Thrown in place of dispatching a provider call once the latch is tripped. The
|
|
123
|
+
* message deliberately contains the substring `persistence failure` so that a
|
|
124
|
+
* `failed` receipt carrying this error is reclaimable on resume (see
|
|
125
|
+
* `canReclaimFailedWorkerToolReceipt`).
|
|
126
|
+
*/
|
|
127
|
+
export class RuntimePersistenceCircuitOpenError extends Error {
|
|
128
|
+
constructor(latch: RuntimePersistenceLatch) {
|
|
129
|
+
super(
|
|
130
|
+
`Runtime persistence failure circuit breaker: skipped dispatching this provider call ` +
|
|
131
|
+
`because a runtime persistence failure already occurred in this map. ` +
|
|
132
|
+
`First persistence error: ${latch.cause}`,
|
|
133
|
+
);
|
|
134
|
+
this.name = 'RuntimePersistenceCircuitOpenError';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Decide whether a completeReceipt retry ladder entrant should collapse to a
|
|
140
|
+
* single attempt instead of running the full backoff ladder.
|
|
141
|
+
*
|
|
142
|
+
* Keyed on `latch.tripped` (NOT `latchRetryBudgetExhausted`): the latch only
|
|
143
|
+
* trips once a persistence failure is CONFIRMED — either a full completeReceipt
|
|
144
|
+
* ladder already gave up, or an output-sheet flush failed. A transient blip that
|
|
145
|
+
* a ladder rides out never trips the latch, so the first ladder always keeps its
|
|
146
|
+
* full budget and blip tolerance is preserved. Once the latch is tripped the
|
|
147
|
+
* endpoint is known-dead, so every FRESH entrant collapses to one attempt rather
|
|
148
|
+
* than piling another full ladder of nested connect attempts onto a saturated
|
|
149
|
+
* database. A ladder that already committed to retrying (`committedToFullLadder`)
|
|
150
|
+
* is protected: it keeps its full budget so an in-progress leader is never
|
|
151
|
+
* truncated mid-flight by a peer that tripped the latch a moment later.
|
|
152
|
+
*/
|
|
153
|
+
export function shouldShortenCompleteReceiptLadder(
|
|
154
|
+
latch: RuntimePersistenceLatch,
|
|
155
|
+
committedToFullLadder: boolean,
|
|
156
|
+
): boolean {
|
|
157
|
+
return latch.tripped && !committedToFullLadder;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isRuntimePersistenceCircuitOpenError(error: unknown): boolean {
|
|
161
|
+
if (!error || typeof error !== 'object') return false;
|
|
162
|
+
if (error instanceof RuntimePersistenceCircuitOpenError) return true;
|
|
163
|
+
return (
|
|
164
|
+
error instanceof Error &&
|
|
165
|
+
error.name === 'RuntimePersistenceCircuitOpenError'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Salvage of orphaned billed tool results.
|
|
3
|
+
*
|
|
4
|
+
* When a provider call has billed server-side but its durable receipt cannot be
|
|
5
|
+
* marked completed (tenant Postgres unwritable) even after the retry ladder,
|
|
6
|
+
* the serialized tool output would otherwise live only in isolate memory and be
|
|
7
|
+
* lost when the run fails. This module collects those outputs and builds a
|
|
8
|
+
* size-capped salvage record that rides on the run.failed ledger event's
|
|
9
|
+
* `result` field — a different durable system (Convex run ledger) than the dead
|
|
10
|
+
* tenant Postgres.
|
|
11
|
+
*
|
|
12
|
+
* The salvage is a LOUD marker on a FAILED run, never a fallback that lets the
|
|
13
|
+
* run succeed. Entries carry exactly the bytes a receipt would have stored
|
|
14
|
+
* (customer-owned tool output); no provider-spend / cost metadata is added.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Total salvage payload cap. Well under Convex's ~1MB document limit once the
|
|
18
|
+
* rest of the failure result + run snapshot are accounted for. */
|
|
19
|
+
export const SALVAGE_MAX_TOTAL_BYTES = 262_144;
|
|
20
|
+
/** Per-entry output cap: an oversized single output is dropped (metadata kept). */
|
|
21
|
+
export const SALVAGE_MAX_ENTRY_BYTES = 32_768;
|
|
22
|
+
|
|
23
|
+
export type ReceiptSalvageEntry = {
|
|
24
|
+
receiptKey: string;
|
|
25
|
+
toolId: string;
|
|
26
|
+
cacheKey?: string;
|
|
27
|
+
/** Serialized tool output (what completeReceipt would have persisted).
|
|
28
|
+
* Omitted when the output was dropped for size. */
|
|
29
|
+
output?: unknown;
|
|
30
|
+
/** UTF-8 byte length of the serialized output (0 when there was none). */
|
|
31
|
+
outputBytes: number;
|
|
32
|
+
/** True when this entry's output was dropped for size. */
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
/** completeReceipt attempts made before giving up. */
|
|
35
|
+
completeAttempts: number;
|
|
36
|
+
/** epoch ms of the first completion failure for this entry. */
|
|
37
|
+
firstErrorAt: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ReceiptSalvage = {
|
|
41
|
+
entries: ReceiptSalvageEntry[];
|
|
42
|
+
/** Total orphaned results collected, including any dropped entirely. */
|
|
43
|
+
totalEntries: number;
|
|
44
|
+
/** Entries dropped entirely (not even metadata retained) for total-size cap. */
|
|
45
|
+
droppedEntries: number;
|
|
46
|
+
/** True when any output was dropped or any entry was dropped entirely. */
|
|
47
|
+
truncated: boolean;
|
|
48
|
+
/** Loud human-readable marker (see note strings below). */
|
|
49
|
+
note: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type ReceiptSalvageInput = {
|
|
53
|
+
receiptKey: string;
|
|
54
|
+
toolId: string;
|
|
55
|
+
cacheKey?: string;
|
|
56
|
+
/** Serialized durable output (already JSON-safe, as receipts store it). */
|
|
57
|
+
output: unknown;
|
|
58
|
+
completeAttempts: number;
|
|
59
|
+
firstErrorAt: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function jsonByteLength(value: unknown): number {
|
|
63
|
+
let json: string | undefined;
|
|
64
|
+
try {
|
|
65
|
+
json = JSON.stringify(value);
|
|
66
|
+
} catch {
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (!json) return 0;
|
|
70
|
+
return new TextEncoder().encode(json).length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cap-enforcing, pure builder. Keeps metadata-only entries until even metadata
|
|
75
|
+
* would exceed the total cap, then drops remaining entries entirely and counts
|
|
76
|
+
* them in `droppedEntries`.
|
|
77
|
+
*/
|
|
78
|
+
export function buildReceiptSalvage(
|
|
79
|
+
inputs: readonly ReceiptSalvageInput[],
|
|
80
|
+
): ReceiptSalvage | null {
|
|
81
|
+
if (inputs.length === 0) return null;
|
|
82
|
+
const totalEntries = inputs.length;
|
|
83
|
+
const entries: ReceiptSalvageEntry[] = [];
|
|
84
|
+
// Reserve the enclosing `[` and `]`. Every appended entry also reserves one
|
|
85
|
+
// byte for the joining comma, so `usedBytes` stays a conservative upper bound
|
|
86
|
+
// on the serialized `entries` array length.
|
|
87
|
+
let usedBytes = 2;
|
|
88
|
+
let droppedEntries = 0;
|
|
89
|
+
let truncatedOutputs = 0;
|
|
90
|
+
for (const input of inputs) {
|
|
91
|
+
const outputBytes = jsonByteLength(input.output);
|
|
92
|
+
const metaOnly: ReceiptSalvageEntry = {
|
|
93
|
+
receiptKey: input.receiptKey,
|
|
94
|
+
toolId: input.toolId,
|
|
95
|
+
...(input.cacheKey ? { cacheKey: input.cacheKey } : {}),
|
|
96
|
+
outputBytes,
|
|
97
|
+
truncated: outputBytes > 0,
|
|
98
|
+
completeAttempts: input.completeAttempts,
|
|
99
|
+
firstErrorAt: input.firstErrorAt,
|
|
100
|
+
};
|
|
101
|
+
const metaBytes = jsonByteLength(metaOnly) + 1;
|
|
102
|
+
// Even the metadata-only footprint would blow the total cap: drop entirely.
|
|
103
|
+
if (usedBytes + metaBytes > SALVAGE_MAX_TOTAL_BYTES) {
|
|
104
|
+
droppedEntries += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const withOutput: ReceiptSalvageEntry = {
|
|
108
|
+
...metaOnly,
|
|
109
|
+
truncated: false,
|
|
110
|
+
output: input.output,
|
|
111
|
+
};
|
|
112
|
+
const fullBytes = jsonByteLength(withOutput) + 1;
|
|
113
|
+
const canIncludeOutput =
|
|
114
|
+
outputBytes > 0 &&
|
|
115
|
+
outputBytes <= SALVAGE_MAX_ENTRY_BYTES &&
|
|
116
|
+
usedBytes + fullBytes <= SALVAGE_MAX_TOTAL_BYTES;
|
|
117
|
+
if (canIncludeOutput) {
|
|
118
|
+
entries.push(withOutput);
|
|
119
|
+
usedBytes += fullBytes;
|
|
120
|
+
} else {
|
|
121
|
+
entries.push(metaOnly);
|
|
122
|
+
if (outputBytes > 0) truncatedOutputs += 1;
|
|
123
|
+
usedBytes += metaBytes;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const truncated = droppedEntries > 0 || truncatedOutputs > 0;
|
|
127
|
+
const note = truncated
|
|
128
|
+
? `SALVAGE TRUNCATED: ${truncatedOutputs + droppedEntries} of ${totalEntries} billed-but-unpersisted tool outputs exceeded the salvage size cap and were dropped. All listed receiptKeys were billed; re-running will re-execute unpersisted calls.`
|
|
129
|
+
: `${totalEntries} billed tool result(s) could not be durably persisted; serialized outputs are attached for manual export.`;
|
|
130
|
+
return { entries, totalEntries, droppedEntries, truncated, note };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Loud one-line suffix appended to the run.failed error message. */
|
|
134
|
+
export function receiptSalvageFailureSuffix(salvage: ReceiptSalvage): string {
|
|
135
|
+
return `${salvage.totalEntries} provider result(s) were billed but could not be persisted; salvage attached to the run record.`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Run-scoped collection buffer. Thin wrapper over an array so a single
|
|
140
|
+
* reference can be threaded to every scheduler in the run; `build()` runs the
|
|
141
|
+
* pure cap-enforcing builder at terminal time.
|
|
142
|
+
*/
|
|
143
|
+
export type ReceiptSalvageBuffer = {
|
|
144
|
+
push(input: ReceiptSalvageInput): void;
|
|
145
|
+
size(): number;
|
|
146
|
+
build(): ReceiptSalvage | null;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export function createReceiptSalvageBuffer(): ReceiptSalvageBuffer {
|
|
150
|
+
const inputs: ReceiptSalvageInput[] = [];
|
|
151
|
+
return {
|
|
152
|
+
push: (input) => {
|
|
153
|
+
inputs.push(input);
|
|
154
|
+
},
|
|
155
|
+
size: () => inputs.length,
|
|
156
|
+
build: () => buildReceiptSalvage(inputs),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -7,6 +7,37 @@ export const PLATFORM_DEPLOY_INTERRUPTED_MESSAGE =
|
|
|
7
7
|
export const INTERNAL_RUNTIME_STORAGE_ERROR_MESSAGE =
|
|
8
8
|
'Internal play runtime storage failed. Please retry the run; if this keeps happening, contact Deepline support with the run ID.';
|
|
9
9
|
|
|
10
|
+
export const WORKSPACE_STORAGE_NOT_READY_CODE = 'WORKSPACE_STORAGE_NOT_READY';
|
|
11
|
+
|
|
12
|
+
// User-facing message. Contains NO SQL, role names, database names, or hosts by
|
|
13
|
+
// construction, so it is safe past redactRuntimeInternalError (which only strips
|
|
14
|
+
// postgres-URI passwords). The raw pg error goes in the Error `cause` for server
|
|
15
|
+
// logs only, never into this message or the normalized public failure.
|
|
16
|
+
export const WORKSPACE_STORAGE_NOT_READY_MESSAGE =
|
|
17
|
+
'Workspace storage for this organization is not ready, so the run was stopped before any provider calls were made (nothing was billed). ' +
|
|
18
|
+
'Ask an organization admin to run `deepline db repair` (POST /api/v2/ingestion/repair), then re-run. ' +
|
|
19
|
+
'If this keeps happening, contact Deepline support with the run ID.';
|
|
20
|
+
|
|
21
|
+
export class WorkspaceStorageNotReadyError extends Error {
|
|
22
|
+
readonly code = WORKSPACE_STORAGE_NOT_READY_CODE;
|
|
23
|
+
constructor(options?: { cause?: unknown }) {
|
|
24
|
+
// Prefix the code token so `String(error)` and any stored error string
|
|
25
|
+
// carry it. The run doc persists only the message string, and the read
|
|
26
|
+
// path re-normalizes that string (extractPublicRunErrors), so the token
|
|
27
|
+
// is how `errorCode` survives the serialization boundary back to the CLI.
|
|
28
|
+
super(
|
|
29
|
+
`${WORKSPACE_STORAGE_NOT_READY_CODE}: ${WORKSPACE_STORAGE_NOT_READY_MESSAGE}`,
|
|
30
|
+
);
|
|
31
|
+
this.name = 'WorkspaceStorageNotReadyError';
|
|
32
|
+
// Attach `cause` manually instead of via the 2-arg Error constructor:
|
|
33
|
+
// ErrorOptions is ES2022, but the Convex tsconfig typechecks this file
|
|
34
|
+
// transitively under `lib: ES2021`, where the 2-arg form is a type error.
|
|
35
|
+
if (options?.cause !== undefined) {
|
|
36
|
+
(this as { cause?: unknown }).cause = options.cause;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
10
41
|
export type PlayRunFailureDetails = {
|
|
11
42
|
code: string;
|
|
12
43
|
phase: string;
|
|
@@ -45,6 +76,19 @@ export function normalizePlayRunFailure(error: unknown): PlayRunFailureDetails {
|
|
|
45
76
|
cause,
|
|
46
77
|
};
|
|
47
78
|
}
|
|
79
|
+
if (
|
|
80
|
+
error instanceof WorkspaceStorageNotReadyError ||
|
|
81
|
+
/\bWORKSPACE_STORAGE_NOT_READY\b/.test(cause)
|
|
82
|
+
) {
|
|
83
|
+
return {
|
|
84
|
+
code: WORKSPACE_STORAGE_NOT_READY_CODE,
|
|
85
|
+
phase: 'storage',
|
|
86
|
+
// Deliberately return the clean constant, NOT `cause`: the raw string may
|
|
87
|
+
// carry the code token prefix, and we never surface tenant DB internals.
|
|
88
|
+
message: WORKSPACE_STORAGE_NOT_READY_MESSAGE,
|
|
89
|
+
retryable: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
48
92
|
const lowerCause = cause.toLowerCase();
|
|
49
93
|
if (
|
|
50
94
|
lowerCause.includes('neondberror') ||
|