deepline 0.1.227 → 0.1.228
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/entry.ts +63 -32
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/ledger-event-batches.ts +81 -0
- package/dist/bundling-sources/sdk/src/client.ts +24 -2
- package/dist/bundling-sources/sdk/src/play.ts +2 -2
- package/dist/bundling-sources/sdk/src/release.ts +2 -2
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +315 -16
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +49 -17
- package/dist/bundling-sources/shared_libs/play-runtime/live-state-contract.ts +3 -9
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +26 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-lifecycle-policy.ts +5 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +44 -9
- package/dist/bundling-sources/shared_libs/play-runtime/transport-error-diagnostics.ts +63 -0
- package/dist/cli/index.js +78 -11
- package/dist/cli/index.mjs +78 -11
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +14 -5
- package/dist/index.mjs +14 -5
- package/package.json +1 -1
|
@@ -380,6 +380,8 @@ function applyRetryJitter(delayMs: number): number {
|
|
|
380
380
|
return Math.round(half + Math.random() * half);
|
|
381
381
|
}
|
|
382
382
|
const APP_RUNTIME_API_DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
383
|
+
const APP_RUNTIME_RECEIPT_RETRY_TELEMETRY_TAG =
|
|
384
|
+
'[perf][worker.receipt_api.transport]';
|
|
383
385
|
const RUN_STATUS_LEDGER_SNAPSHOT_CACHE_LIMIT = 1_000;
|
|
384
386
|
const runStatusLedgerSnapshots = new Map<string, PlayRunLedgerSnapshot>();
|
|
385
387
|
// Positional log cursor per run attempt: count of liveLogs lines already
|
|
@@ -454,7 +456,7 @@ async function appendRunEventBatchesViaAppRuntime(
|
|
|
454
456
|
input: { playId: string; events: readonly PlayRunLedgerEvent[] },
|
|
455
457
|
): Promise<void> {
|
|
456
458
|
for (const events of partitionRunEventsForAppRuntime(input.events)) {
|
|
457
|
-
await
|
|
459
|
+
await appendRunEventsViaAppRuntimeRaw(context, {
|
|
458
460
|
playId: input.playId,
|
|
459
461
|
events,
|
|
460
462
|
});
|
|
@@ -583,7 +585,7 @@ function appRuntimeErrorCode(response: Response, body: string): string | null {
|
|
|
583
585
|
}
|
|
584
586
|
|
|
585
587
|
export function isTransientAppRuntimeFailureBody(body: string): boolean {
|
|
586
|
-
return /WorkerOverloaded|"\s*code\s*"\s*:\s*"\s*InternalServerError\s*"|Your request couldn't be completed\. Try again later\.|timeout exceeded when trying to connect|timed out|fetch failed|ECONNRESET|ECONNREFUSED|UND_ERR_CONNECT_TIMEOUT|tuple concurrently updated|
|
|
588
|
+
return /WorkerOverloaded|"\s*code\s*"\s*:\s*"\s*InternalServerError\s*"|Your request couldn't be completed\. Try again later\.|timeout exceeded when trying to connect|timed out|fetch failed|ECONNRESET|ECONNREFUSED|UND_ERR_CONNECT_TIMEOUT|tuple concurrently updated|RUNTIME_SCHEDULER_(?:SATURATED|UNAVAILABLE)|Runtime scheduler DB pool is saturated|Runtime scheduler DB circuit breaker is open|OptimisticConcurrencyControlFailure|changed while this mutation was being run|Documents read from or written to the .* table changed while this mutation/i.test(
|
|
587
589
|
body,
|
|
588
590
|
);
|
|
589
591
|
}
|
|
@@ -733,14 +735,146 @@ function sleep(ms: number): Promise<void> {
|
|
|
733
735
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
734
736
|
}
|
|
735
737
|
|
|
738
|
+
type AppRuntimeRetryFailureKind =
|
|
739
|
+
| 'connection_refused'
|
|
740
|
+
| 'connection_reset'
|
|
741
|
+
| 'connection_timeout'
|
|
742
|
+
| 'dns'
|
|
743
|
+
| 'fetch_failed'
|
|
744
|
+
| 'http_error'
|
|
745
|
+
| 'request_timeout'
|
|
746
|
+
| 'response_body_error'
|
|
747
|
+
| 'response_body_timeout'
|
|
748
|
+
| 'retryable_http'
|
|
749
|
+
| 'transport_error';
|
|
750
|
+
|
|
751
|
+
type AppRuntimeRetryFailure = {
|
|
752
|
+
attempt: number;
|
|
753
|
+
failureKind: AppRuntimeRetryFailureKind;
|
|
754
|
+
attemptElapsedMs: number;
|
|
755
|
+
retryDelayMs: number;
|
|
756
|
+
httpStatus?: number;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
type AppRuntimeRetryTelemetry = {
|
|
760
|
+
enabled: boolean;
|
|
761
|
+
startedAt: number;
|
|
762
|
+
retryCount: number;
|
|
763
|
+
retrySleepMs: number;
|
|
764
|
+
failures: AppRuntimeRetryFailure[];
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
function appRuntimeTransportFailureKind(
|
|
768
|
+
error: unknown,
|
|
769
|
+
): AppRuntimeRetryFailureKind {
|
|
770
|
+
if (isRequestTimeoutAbort(error)) return 'request_timeout';
|
|
771
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
772
|
+
if (/\b(?:ENOTFOUND|EAI_AGAIN)\b|getaddrinfo|\bdns\b/i.test(message)) {
|
|
773
|
+
return 'dns';
|
|
774
|
+
}
|
|
775
|
+
if (/\bECONNRESET\b|connection reset/i.test(message)) {
|
|
776
|
+
return 'connection_reset';
|
|
777
|
+
}
|
|
778
|
+
if (/\bECONNREFUSED\b|connection refused/i.test(message)) {
|
|
779
|
+
return 'connection_refused';
|
|
780
|
+
}
|
|
781
|
+
if (
|
|
782
|
+
/\bETIMEDOUT\b|UND_ERR_CONNECT_TIMEOUT|connection (?:timeout|timed out)/i.test(
|
|
783
|
+
message,
|
|
784
|
+
)
|
|
785
|
+
) {
|
|
786
|
+
return 'connection_timeout';
|
|
787
|
+
}
|
|
788
|
+
return /fetch failed/i.test(message) ? 'fetch_failed' : 'transport_error';
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function recordAppRuntimeRetryFailure(input: {
|
|
792
|
+
telemetry: AppRuntimeRetryTelemetry;
|
|
793
|
+
attempt: number;
|
|
794
|
+
failureKind: AppRuntimeRetryFailureKind;
|
|
795
|
+
attemptStartedAt: number;
|
|
796
|
+
retryDelayMs: number;
|
|
797
|
+
httpStatus?: number;
|
|
798
|
+
}): void {
|
|
799
|
+
if (!input.telemetry.enabled) return;
|
|
800
|
+
// Receipt actions have at most eight attempts. Keep the terminal eighth
|
|
801
|
+
// failure instead of truncating the causally important final observation.
|
|
802
|
+
if (input.telemetry.failures.length < 8) {
|
|
803
|
+
input.telemetry.failures.push({
|
|
804
|
+
attempt: input.attempt,
|
|
805
|
+
failureKind: input.failureKind,
|
|
806
|
+
attemptElapsedMs: Date.now() - input.attemptStartedAt,
|
|
807
|
+
retryDelayMs: input.retryDelayMs,
|
|
808
|
+
...(input.httpStatus === undefined
|
|
809
|
+
? {}
|
|
810
|
+
: { httpStatus: input.httpStatus }),
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
if (input.retryDelayMs > 0) {
|
|
814
|
+
input.telemetry.retryCount += 1;
|
|
815
|
+
input.telemetry.retrySleepMs += input.retryDelayMs;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function emitAppRuntimeRetrySummary(input: {
|
|
820
|
+
telemetry: AppRuntimeRetryTelemetry;
|
|
821
|
+
action: RuntimeApiRequest['action'];
|
|
822
|
+
outcome: 'failed_after_retry' | 'recovered_after_retry';
|
|
823
|
+
attempts: number;
|
|
824
|
+
maxAttempts: number;
|
|
825
|
+
finalAttemptStartedAt: number;
|
|
826
|
+
}): void {
|
|
827
|
+
if (!input.telemetry.enabled || input.telemetry.retryCount === 0) return;
|
|
828
|
+
const payload = {
|
|
829
|
+
action: input.action,
|
|
830
|
+
outcome: input.outcome,
|
|
831
|
+
attempts: input.attempts,
|
|
832
|
+
maxAttempts: input.maxAttempts,
|
|
833
|
+
totalElapsedMs: Date.now() - input.telemetry.startedAt,
|
|
834
|
+
retrySleepMs: input.telemetry.retrySleepMs,
|
|
835
|
+
finalAttemptElapsedMs: Date.now() - input.finalAttemptStartedAt,
|
|
836
|
+
failures: input.telemetry.failures,
|
|
837
|
+
};
|
|
838
|
+
// Observability must never alter receipt execution. The payload contains
|
|
839
|
+
// primitives from a fixed taxonomy only: no URL, token, body, raw error,
|
|
840
|
+
// run/play/org id, receipt key, input, or output.
|
|
841
|
+
try {
|
|
842
|
+
const serialized = JSON.stringify(payload);
|
|
843
|
+
if (input.outcome === 'failed_after_retry') {
|
|
844
|
+
console.warn(APP_RUNTIME_RECEIPT_RETRY_TELEMETRY_TAG, serialized);
|
|
845
|
+
} else {
|
|
846
|
+
console.info(APP_RUNTIME_RECEIPT_RETRY_TELEMETRY_TAG, serialized);
|
|
847
|
+
}
|
|
848
|
+
} catch {}
|
|
849
|
+
}
|
|
850
|
+
|
|
736
851
|
async function retryAppRuntimeBodyTimeoutOrThrow(input: {
|
|
737
852
|
action: RuntimeApiRequest['action'];
|
|
738
853
|
attempt: number;
|
|
739
854
|
maxAttempts: number;
|
|
740
855
|
error: unknown;
|
|
856
|
+
telemetry: AppRuntimeRetryTelemetry;
|
|
857
|
+
attemptStartedAt: number;
|
|
858
|
+
httpStatus?: number;
|
|
741
859
|
retryAfterMs?: number | null;
|
|
742
860
|
}): Promise<void> {
|
|
743
861
|
if (!isRequestTimeoutAbort(input.error)) {
|
|
862
|
+
recordAppRuntimeRetryFailure({
|
|
863
|
+
telemetry: input.telemetry,
|
|
864
|
+
attempt: input.attempt,
|
|
865
|
+
failureKind: 'response_body_error',
|
|
866
|
+
attemptStartedAt: input.attemptStartedAt,
|
|
867
|
+
retryDelayMs: 0,
|
|
868
|
+
httpStatus: input.httpStatus,
|
|
869
|
+
});
|
|
870
|
+
emitAppRuntimeRetrySummary({
|
|
871
|
+
telemetry: input.telemetry,
|
|
872
|
+
action: input.action,
|
|
873
|
+
outcome: 'failed_after_retry',
|
|
874
|
+
attempts: input.attempt,
|
|
875
|
+
maxAttempts: input.maxAttempts,
|
|
876
|
+
finalAttemptStartedAt: input.attemptStartedAt,
|
|
877
|
+
});
|
|
744
878
|
throw input.error;
|
|
745
879
|
}
|
|
746
880
|
if (
|
|
@@ -750,14 +884,37 @@ async function retryAppRuntimeBodyTimeoutOrThrow(input: {
|
|
|
750
884
|
error: input.error,
|
|
751
885
|
})
|
|
752
886
|
) {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
input.retryAfterMs ?? 0,
|
|
757
|
-
),
|
|
887
|
+
const retryDelayMs = Math.max(
|
|
888
|
+
applyRetryJitter(appRuntimeRetryDelayMs(input.action, input.attempt)),
|
|
889
|
+
input.retryAfterMs ?? 0,
|
|
758
890
|
);
|
|
891
|
+
recordAppRuntimeRetryFailure({
|
|
892
|
+
telemetry: input.telemetry,
|
|
893
|
+
attempt: input.attempt,
|
|
894
|
+
failureKind: 'response_body_timeout',
|
|
895
|
+
attemptStartedAt: input.attemptStartedAt,
|
|
896
|
+
retryDelayMs,
|
|
897
|
+
httpStatus: input.httpStatus,
|
|
898
|
+
});
|
|
899
|
+
await sleep(retryDelayMs);
|
|
759
900
|
return;
|
|
760
901
|
}
|
|
902
|
+
recordAppRuntimeRetryFailure({
|
|
903
|
+
telemetry: input.telemetry,
|
|
904
|
+
attempt: input.attempt,
|
|
905
|
+
failureKind: 'response_body_timeout',
|
|
906
|
+
attemptStartedAt: input.attemptStartedAt,
|
|
907
|
+
retryDelayMs: 0,
|
|
908
|
+
httpStatus: input.httpStatus,
|
|
909
|
+
});
|
|
910
|
+
emitAppRuntimeRetrySummary({
|
|
911
|
+
telemetry: input.telemetry,
|
|
912
|
+
action: input.action,
|
|
913
|
+
outcome: 'failed_after_retry',
|
|
914
|
+
attempts: input.attempt,
|
|
915
|
+
maxAttempts: input.maxAttempts,
|
|
916
|
+
finalAttemptStartedAt: input.attemptStartedAt,
|
|
917
|
+
});
|
|
761
918
|
throw new AppRuntimeApiTransportError({
|
|
762
919
|
action: input.action,
|
|
763
920
|
attempts: input.attempt,
|
|
@@ -811,6 +968,13 @@ async function postAppRuntimeApi<TResponse>(
|
|
|
811
968
|
});
|
|
812
969
|
|
|
813
970
|
const maxAttempts = appRuntimeMaxAttempts(body.action);
|
|
971
|
+
const retryTelemetry: AppRuntimeRetryTelemetry = {
|
|
972
|
+
enabled: isRuntimeStepReceiptAction(body.action),
|
|
973
|
+
startedAt: Date.now(),
|
|
974
|
+
retryCount: 0,
|
|
975
|
+
retrySleepMs: 0,
|
|
976
|
+
failures: [],
|
|
977
|
+
};
|
|
814
978
|
const requestTimeoutMs = Math.max(
|
|
815
979
|
1,
|
|
816
980
|
Math.floor(
|
|
@@ -819,6 +983,7 @@ async function postAppRuntimeApi<TResponse>(
|
|
|
819
983
|
);
|
|
820
984
|
|
|
821
985
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
986
|
+
const attemptStartedAt = Date.now();
|
|
822
987
|
let response: Response;
|
|
823
988
|
try {
|
|
824
989
|
response = await runtimeFetch(resolveAppRuntimeApiUrl(context), {
|
|
@@ -843,11 +1008,34 @@ async function postAppRuntimeApi<TResponse>(
|
|
|
843
1008
|
attempt < maxAttempts &&
|
|
844
1009
|
isRetryableAppRuntimeFetchError({ action: body.action, error })
|
|
845
1010
|
) {
|
|
846
|
-
|
|
847
|
-
|
|
1011
|
+
const retryDelayMs = applyRetryJitter(
|
|
1012
|
+
appRuntimeRetryDelayMs(body.action, attempt),
|
|
848
1013
|
);
|
|
1014
|
+
recordAppRuntimeRetryFailure({
|
|
1015
|
+
telemetry: retryTelemetry,
|
|
1016
|
+
attempt,
|
|
1017
|
+
failureKind: appRuntimeTransportFailureKind(error),
|
|
1018
|
+
attemptStartedAt,
|
|
1019
|
+
retryDelayMs,
|
|
1020
|
+
});
|
|
1021
|
+
await sleep(retryDelayMs);
|
|
849
1022
|
continue;
|
|
850
1023
|
}
|
|
1024
|
+
recordAppRuntimeRetryFailure({
|
|
1025
|
+
telemetry: retryTelemetry,
|
|
1026
|
+
attempt,
|
|
1027
|
+
failureKind: appRuntimeTransportFailureKind(error),
|
|
1028
|
+
attemptStartedAt,
|
|
1029
|
+
retryDelayMs: 0,
|
|
1030
|
+
});
|
|
1031
|
+
emitAppRuntimeRetrySummary({
|
|
1032
|
+
telemetry: retryTelemetry,
|
|
1033
|
+
action: body.action,
|
|
1034
|
+
outcome: 'failed_after_retry',
|
|
1035
|
+
attempts: attempt,
|
|
1036
|
+
maxAttempts,
|
|
1037
|
+
finalAttemptStartedAt: attemptStartedAt,
|
|
1038
|
+
});
|
|
851
1039
|
throw new AppRuntimeApiTransportError({
|
|
852
1040
|
action: body.action,
|
|
853
1041
|
attempts: attempt,
|
|
@@ -856,13 +1044,25 @@ async function postAppRuntimeApi<TResponse>(
|
|
|
856
1044
|
}
|
|
857
1045
|
if (response.ok) {
|
|
858
1046
|
try {
|
|
859
|
-
|
|
1047
|
+
const parsed = (await response.json()) as TResponse;
|
|
1048
|
+
emitAppRuntimeRetrySummary({
|
|
1049
|
+
telemetry: retryTelemetry,
|
|
1050
|
+
action: body.action,
|
|
1051
|
+
outcome: 'recovered_after_retry',
|
|
1052
|
+
attempts: attempt,
|
|
1053
|
+
maxAttempts,
|
|
1054
|
+
finalAttemptStartedAt: attemptStartedAt,
|
|
1055
|
+
});
|
|
1056
|
+
return parsed;
|
|
860
1057
|
} catch (error) {
|
|
861
1058
|
await retryAppRuntimeBodyTimeoutOrThrow({
|
|
862
1059
|
action: body.action,
|
|
863
1060
|
attempt,
|
|
864
1061
|
maxAttempts,
|
|
865
1062
|
error,
|
|
1063
|
+
telemetry: retryTelemetry,
|
|
1064
|
+
attemptStartedAt,
|
|
1065
|
+
httpStatus: response.status,
|
|
866
1066
|
});
|
|
867
1067
|
continue;
|
|
868
1068
|
}
|
|
@@ -875,6 +1075,22 @@ async function postAppRuntimeApi<TResponse>(
|
|
|
875
1075
|
isRequestTimeoutAbort(error) &&
|
|
876
1076
|
!isRetryableAppRuntimeResponseStatus(response.status)
|
|
877
1077
|
) {
|
|
1078
|
+
recordAppRuntimeRetryFailure({
|
|
1079
|
+
telemetry: retryTelemetry,
|
|
1080
|
+
attempt,
|
|
1081
|
+
failureKind: 'response_body_timeout',
|
|
1082
|
+
attemptStartedAt,
|
|
1083
|
+
retryDelayMs: 0,
|
|
1084
|
+
httpStatus: response.status,
|
|
1085
|
+
});
|
|
1086
|
+
emitAppRuntimeRetrySummary({
|
|
1087
|
+
telemetry: retryTelemetry,
|
|
1088
|
+
action: body.action,
|
|
1089
|
+
outcome: 'failed_after_retry',
|
|
1090
|
+
attempts: attempt,
|
|
1091
|
+
maxAttempts,
|
|
1092
|
+
finalAttemptStartedAt: attemptStartedAt,
|
|
1093
|
+
});
|
|
878
1094
|
throw new Error(
|
|
879
1095
|
`App runtime API ${body.action} failed with status ${response.status}: response body timed out`,
|
|
880
1096
|
{ cause: error },
|
|
@@ -885,6 +1101,9 @@ async function postAppRuntimeApi<TResponse>(
|
|
|
885
1101
|
attempt,
|
|
886
1102
|
maxAttempts,
|
|
887
1103
|
error,
|
|
1104
|
+
telemetry: retryTelemetry,
|
|
1105
|
+
attemptStartedAt,
|
|
1106
|
+
httpStatus: response.status,
|
|
888
1107
|
retryAfterMs: appRuntimeRetryAfterMs(response, ''),
|
|
889
1108
|
});
|
|
890
1109
|
continue;
|
|
@@ -897,14 +1116,43 @@ async function postAppRuntimeApi<TResponse>(
|
|
|
897
1116
|
body: responseText,
|
|
898
1117
|
})
|
|
899
1118
|
) {
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
appRuntimeRetryAfterMs(response, responseText) ?? 0,
|
|
904
|
-
),
|
|
1119
|
+
const retryDelayMs = Math.max(
|
|
1120
|
+
applyRetryJitter(appRuntimeRetryDelayMs(body.action, attempt)),
|
|
1121
|
+
appRuntimeRetryAfterMs(response, responseText) ?? 0,
|
|
905
1122
|
);
|
|
1123
|
+
recordAppRuntimeRetryFailure({
|
|
1124
|
+
telemetry: retryTelemetry,
|
|
1125
|
+
attempt,
|
|
1126
|
+
failureKind: 'retryable_http',
|
|
1127
|
+
attemptStartedAt,
|
|
1128
|
+
retryDelayMs,
|
|
1129
|
+
httpStatus: response.status,
|
|
1130
|
+
});
|
|
1131
|
+
await sleep(retryDelayMs);
|
|
906
1132
|
continue;
|
|
907
1133
|
}
|
|
1134
|
+
recordAppRuntimeRetryFailure({
|
|
1135
|
+
telemetry: retryTelemetry,
|
|
1136
|
+
attempt,
|
|
1137
|
+
failureKind: isRetryableAppRuntimeResponse({
|
|
1138
|
+
action: body.action,
|
|
1139
|
+
status: response.status,
|
|
1140
|
+
body: responseText,
|
|
1141
|
+
})
|
|
1142
|
+
? 'retryable_http'
|
|
1143
|
+
: 'http_error',
|
|
1144
|
+
attemptStartedAt,
|
|
1145
|
+
retryDelayMs: 0,
|
|
1146
|
+
httpStatus: response.status,
|
|
1147
|
+
});
|
|
1148
|
+
emitAppRuntimeRetrySummary({
|
|
1149
|
+
telemetry: retryTelemetry,
|
|
1150
|
+
action: body.action,
|
|
1151
|
+
outcome: 'failed_after_retry',
|
|
1152
|
+
attempts: attempt,
|
|
1153
|
+
maxAttempts,
|
|
1154
|
+
finalAttemptStartedAt: attemptStartedAt,
|
|
1155
|
+
});
|
|
908
1156
|
const code = appRuntimeErrorCode(response, responseText);
|
|
909
1157
|
const requestId = response.headers.get('x-deepline-request-id')?.trim();
|
|
910
1158
|
throw new Error(
|
|
@@ -1239,7 +1487,7 @@ async function updateRunStatusViaAppRuntimeUnlocked(
|
|
|
1239
1487
|
);
|
|
1240
1488
|
}
|
|
1241
1489
|
|
|
1242
|
-
|
|
1490
|
+
async function appendRunEventsViaAppRuntimeRaw(
|
|
1243
1491
|
context: WorkerRuntimeApiContext,
|
|
1244
1492
|
input: {
|
|
1245
1493
|
playId: string;
|
|
@@ -1253,6 +1501,57 @@ export async function appendRunEventsViaAppRuntime(
|
|
|
1253
1501
|
});
|
|
1254
1502
|
}
|
|
1255
1503
|
|
|
1504
|
+
/**
|
|
1505
|
+
* Append Run Ledger events through the bounded transport used by every runtime
|
|
1506
|
+
* producer. Scheduler terminal outbox events can carry a complete log replay,
|
|
1507
|
+
* so this boundary must never forward an arbitrarily large event array or log
|
|
1508
|
+
* event directly to Convex.
|
|
1509
|
+
*/
|
|
1510
|
+
export async function appendRunEventsViaAppRuntime(
|
|
1511
|
+
context: WorkerRuntimeApiContext,
|
|
1512
|
+
input: {
|
|
1513
|
+
playId: string;
|
|
1514
|
+
events: PlayRunLedgerEvent[];
|
|
1515
|
+
idempotencyKey?: string;
|
|
1516
|
+
},
|
|
1517
|
+
): Promise<void> {
|
|
1518
|
+
const batches = partitionRunEventsForAppRuntime(input.events);
|
|
1519
|
+
if (batches.length <= 1) {
|
|
1520
|
+
await appendRunEventsViaAppRuntimeRaw(context, input);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Terminal idempotency keys record a completed delivery. Sending the same
|
|
1525
|
+
// key on an earlier log-only batch would make Convex drop the terminal batch,
|
|
1526
|
+
// so attach it to the batch that contains the final terminal event only.
|
|
1527
|
+
let terminalBatchIndex = -1;
|
|
1528
|
+
for (let index = batches.length - 1; index >= 0; index -= 1) {
|
|
1529
|
+
if (
|
|
1530
|
+
batches[index]!.some(
|
|
1531
|
+
(event) =>
|
|
1532
|
+
event.type === 'run.completed' ||
|
|
1533
|
+
event.type === 'run.failed' ||
|
|
1534
|
+
event.type === 'run.cancelled',
|
|
1535
|
+
)
|
|
1536
|
+
) {
|
|
1537
|
+
terminalBatchIndex = index;
|
|
1538
|
+
break;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const idempotencyBatchIndex =
|
|
1542
|
+
terminalBatchIndex >= 0 ? terminalBatchIndex : batches.length - 1;
|
|
1543
|
+
|
|
1544
|
+
for (let index = 0; index < batches.length; index += 1) {
|
|
1545
|
+
await appendRunEventsViaAppRuntimeRaw(context, {
|
|
1546
|
+
playId: input.playId,
|
|
1547
|
+
events: batches[index]!,
|
|
1548
|
+
...(input.idempotencyKey && index === idempotencyBatchIndex
|
|
1549
|
+
? { idempotencyKey: input.idempotencyKey }
|
|
1550
|
+
: {}),
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1256
1555
|
export async function startRunViaAppRuntime(
|
|
1257
1556
|
context: WorkerRuntimeApiContext,
|
|
1258
1557
|
input: {
|
|
@@ -108,6 +108,10 @@ import {
|
|
|
108
108
|
ToolExecuteAuthScopeChangedError,
|
|
109
109
|
} from './tool-execute-retry-policy';
|
|
110
110
|
import { ToolHttpError } from './tool-http-errors';
|
|
111
|
+
import {
|
|
112
|
+
describeTransportError,
|
|
113
|
+
transportGatewayOriginForDiagnostic,
|
|
114
|
+
} from './transport-error-diagnostics';
|
|
111
115
|
import {
|
|
112
116
|
buildDurableCtxCallCacheKey,
|
|
113
117
|
buildDurableRunPlayInvocationScope,
|
|
@@ -8725,10 +8729,26 @@ export class PlayContextImpl {
|
|
|
8725
8729
|
const retrySafeTransientHttp =
|
|
8726
8730
|
retryPolicy?.retrySafeTransientHttp === true;
|
|
8727
8731
|
let transportAttempt = 0;
|
|
8728
|
-
const retryToolTransportFailure = async (
|
|
8729
|
-
|
|
8730
|
-
|
|
8732
|
+
const retryToolTransportFailure = async (input: {
|
|
8733
|
+
error: unknown;
|
|
8734
|
+
elapsedMs: number;
|
|
8735
|
+
requestId: string | null;
|
|
8736
|
+
aborted: boolean;
|
|
8737
|
+
}): Promise<void> => {
|
|
8731
8738
|
transportAttempt += 1;
|
|
8739
|
+
const diagnostic = describeTransportError(input.error);
|
|
8740
|
+
this.log(
|
|
8741
|
+
`[runtime.transport_failure] ${JSON.stringify({
|
|
8742
|
+
tool_id: toolId,
|
|
8743
|
+
gateway_origin: transportGatewayOriginForDiagnostic(url),
|
|
8744
|
+
attempt: transportAttempt,
|
|
8745
|
+
max_attempts: TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS,
|
|
8746
|
+
elapsed_ms: input.elapsedMs,
|
|
8747
|
+
request_id: input.requestId,
|
|
8748
|
+
aborted: input.aborted,
|
|
8749
|
+
error: diagnostic,
|
|
8750
|
+
})}`,
|
|
8751
|
+
);
|
|
8732
8752
|
if (transportAttempt < TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS) {
|
|
8733
8753
|
await this.governor.chargeBudget('retry');
|
|
8734
8754
|
const retryAfterMs =
|
|
@@ -8738,14 +8758,14 @@ export class PlayContextImpl {
|
|
|
8738
8758
|
transportAttempt,
|
|
8739
8759
|
);
|
|
8740
8760
|
this.log(
|
|
8741
|
-
`Tool ${toolId} transport failed calling ${url} on attempt ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS}; retrying after ${retryAfterMs}ms: ${message}`,
|
|
8761
|
+
`Tool ${toolId} transport failed calling ${url} on attempt ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS}; retrying after ${retryAfterMs}ms: ${diagnostic.message ?? 'unknown transport error'}`,
|
|
8742
8762
|
);
|
|
8743
8763
|
await options?.parkProviderCall?.();
|
|
8744
8764
|
await this.sleepWithCheckpointHeartbeat(retryAfterMs);
|
|
8745
8765
|
return;
|
|
8746
8766
|
}
|
|
8747
8767
|
throw new ToolHttpError(
|
|
8748
|
-
`Tool ${toolId} transport failed calling ${url} after ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS} attempts: ${message}`,
|
|
8768
|
+
`Tool ${toolId} transport failed calling ${url} after ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS} attempts: ${diagnostic.message ?? 'unknown transport error'}`,
|
|
8749
8769
|
null,
|
|
8750
8770
|
0,
|
|
8751
8771
|
'repairable',
|
|
@@ -8767,9 +8787,12 @@ export class PlayContextImpl {
|
|
|
8767
8787
|
: null;
|
|
8768
8788
|
const providerIdempotencyKey =
|
|
8769
8789
|
options?.providerIdempotencyKey?.trim() || durableCallReceiptKey;
|
|
8790
|
+
// Every gateway request needs a correlation ID. When the call has
|
|
8791
|
+
// an idempotency key, keep its stable ID across retries; otherwise
|
|
8792
|
+
// give this individual attempt a fresh ID for gateway pairing.
|
|
8770
8793
|
const deeplineRequestId = providerIdempotencyKey
|
|
8771
8794
|
? `ctx-tool-${stableDigest(providerIdempotencyKey).slice(0, 32)}`
|
|
8772
|
-
:
|
|
8795
|
+
: `ctx-tool-${crypto.randomUUID()}`;
|
|
8773
8796
|
// Receipt ownership is a liveness contract, not a one-time check.
|
|
8774
8797
|
// Keep it alive for the whole provider HTTP request. The cadence
|
|
8775
8798
|
// comes from the store-issued expiry because a remote runner may
|
|
@@ -8837,10 +8860,10 @@ export class PlayContextImpl {
|
|
|
8837
8860
|
V2_EXECUTE_RESPONSE_CONTRACT,
|
|
8838
8861
|
[EXECUTE_RESPONSE_INTENT_HEADER]: 'dataset',
|
|
8839
8862
|
[EXECUTE_TOOL_METADATA_HEADER]: 'true',
|
|
8840
|
-
|
|
8863
|
+
'x-deepline-request-id': deeplineRequestId,
|
|
8864
|
+
...(providerIdempotencyKey
|
|
8841
8865
|
? {
|
|
8842
|
-
'x-deepline-
|
|
8843
|
-
'x-deepline-idempotency-key': providerIdempotencyKey!,
|
|
8866
|
+
'x-deepline-idempotency-key': providerIdempotencyKey,
|
|
8844
8867
|
}
|
|
8845
8868
|
: {}),
|
|
8846
8869
|
...(await this.vercelProtectionHeaders()),
|
|
@@ -8918,11 +8941,15 @@ export class PlayContextImpl {
|
|
|
8918
8941
|
`Tool ${toolId} runtime API call timed out after ${timeoutMs}ms.`,
|
|
8919
8942
|
)
|
|
8920
8943
|
: error;
|
|
8921
|
-
|
|
8922
|
-
transportError
|
|
8923
|
-
|
|
8924
|
-
|
|
8925
|
-
|
|
8944
|
+
await retryToolTransportFailure({
|
|
8945
|
+
error: transportError,
|
|
8946
|
+
elapsedMs:
|
|
8947
|
+
providerCallStartedAt === null
|
|
8948
|
+
? 0
|
|
8949
|
+
: Date.now() - providerCallStartedAt,
|
|
8950
|
+
requestId: deeplineRequestId,
|
|
8951
|
+
aborted: abortController?.signal.aborted === true,
|
|
8952
|
+
});
|
|
8926
8953
|
continue;
|
|
8927
8954
|
} finally {
|
|
8928
8955
|
if (timeoutHandle) {
|
|
@@ -8941,9 +8968,14 @@ export class PlayContextImpl {
|
|
|
8941
8968
|
bodyText: text,
|
|
8942
8969
|
})
|
|
8943
8970
|
) {
|
|
8944
|
-
await retryToolTransportFailure(
|
|
8945
|
-
|
|
8946
|
-
|
|
8971
|
+
await retryToolTransportFailure({
|
|
8972
|
+
error: new Error(
|
|
8973
|
+
'the Deepline development tunnel returned a branded 502 before reaching the app origin',
|
|
8974
|
+
),
|
|
8975
|
+
elapsedMs: providerCallElapsedMs ?? 0,
|
|
8976
|
+
requestId: deeplineRequestId,
|
|
8977
|
+
aborted: false,
|
|
8978
|
+
});
|
|
8947
8979
|
continue;
|
|
8948
8980
|
}
|
|
8949
8981
|
const authScopeChanged = parseToolExecuteAuthScopeChangedError({
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
| 'completed'
|
|
5
|
-
| 'failed'
|
|
6
|
-
| 'cancelled'
|
|
7
|
-
| 'terminated'
|
|
8
|
-
| 'timed_out'
|
|
9
|
-
| 'unknown';
|
|
1
|
+
import type { PlayRunLifecycleStatus } from './run-lifecycle-policy';
|
|
2
|
+
|
|
3
|
+
export type PlayRunLiveStatus = PlayRunLifecycleStatus;
|
|
10
4
|
|
|
11
5
|
export type PlayVisualNodeStatus =
|
|
12
6
|
| 'idle'
|
|
@@ -166,6 +166,12 @@ export type PlayRunLedgerEvent =
|
|
|
166
166
|
playName?: string | null;
|
|
167
167
|
runtimeBackend?: string | null;
|
|
168
168
|
})
|
|
169
|
+
| (PlayRunLedgerBaseEvent & {
|
|
170
|
+
type: 'run.waiting';
|
|
171
|
+
})
|
|
172
|
+
| (PlayRunLedgerBaseEvent & {
|
|
173
|
+
type: 'run.resumed';
|
|
174
|
+
})
|
|
169
175
|
| (PlayRunLedgerBaseEvent & {
|
|
170
176
|
type: 'run.completed';
|
|
171
177
|
result?: unknown;
|
|
@@ -1025,6 +1031,16 @@ export function reducePlayRunLedgerEvent(
|
|
|
1025
1031
|
if (event.runId !== snapshot.runId) {
|
|
1026
1032
|
return snapshot;
|
|
1027
1033
|
}
|
|
1034
|
+
// The scheduler's outbox can replay an older wait transition after its run
|
|
1035
|
+
// reached a terminal state. That transition is no longer meaningful and
|
|
1036
|
+
// must be byte-for-byte inert: even advancing `updatedAt` would re-publish
|
|
1037
|
+
// a stale terminal snapshot to every observer.
|
|
1038
|
+
if (
|
|
1039
|
+
(event.type === 'run.waiting' || event.type === 'run.resumed') &&
|
|
1040
|
+
shouldIgnoreNonTerminalAfterStickyTerminal(snapshot)
|
|
1041
|
+
) {
|
|
1042
|
+
return snapshot;
|
|
1043
|
+
}
|
|
1028
1044
|
const occurredAt = Math.max(0, event.occurredAt);
|
|
1029
1045
|
const base: PlayRunLedgerSnapshot = {
|
|
1030
1046
|
...snapshot,
|
|
@@ -1062,6 +1078,16 @@ export function reducePlayRunLedgerEvent(
|
|
|
1062
1078
|
: 'running',
|
|
1063
1079
|
startedAt: base.startedAt ?? occurredAt,
|
|
1064
1080
|
});
|
|
1081
|
+
case 'run.waiting':
|
|
1082
|
+
return withTiming({
|
|
1083
|
+
...base,
|
|
1084
|
+
status: 'waiting',
|
|
1085
|
+
});
|
|
1086
|
+
case 'run.resumed':
|
|
1087
|
+
return withTiming({
|
|
1088
|
+
...base,
|
|
1089
|
+
status: 'running',
|
|
1090
|
+
});
|
|
1065
1091
|
case 'run.completed':
|
|
1066
1092
|
return (
|
|
1067
1093
|
conflictingTerminalSnapshot(base, event.type, occurredAt, null, {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type PlayRunLifecycleStatus =
|
|
2
2
|
| 'queued'
|
|
3
3
|
| 'running'
|
|
4
|
+
| 'waiting'
|
|
4
5
|
| 'completed'
|
|
5
6
|
| 'failed'
|
|
6
7
|
| 'cancelled'
|
|
@@ -35,8 +36,9 @@ export function normalizePlayRunLifecycleStatus(
|
|
|
35
36
|
return 'running';
|
|
36
37
|
case 'running':
|
|
37
38
|
case 'started':
|
|
38
|
-
case 'waiting':
|
|
39
39
|
return 'running';
|
|
40
|
+
case 'waiting':
|
|
41
|
+
return 'waiting';
|
|
40
42
|
case 'completed':
|
|
41
43
|
case 'complete':
|
|
42
44
|
case 'succeeded':
|
|
@@ -62,7 +64,8 @@ export function isTerminalPlayRunLifecycleStatus(status: unknown): boolean {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
export function isActivePlayRunLifecycleStatus(status: unknown): boolean {
|
|
65
|
-
|
|
67
|
+
const normalized = normalizePlayRunLifecycleStatus(status);
|
|
68
|
+
return normalized === 'running' || normalized === 'waiting';
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export function isRecoverableDatasetRowStatus(status: unknown): boolean {
|