fix: preserve command effect certainty across retries
Separate per-attempt physical state from the logical execution history. The executor now keeps one monotonic certainty accumulator joined through joinMutationEffectCertainty, records MAYBE_APPLIED at dispatch, and reads the accumulator from every retry-loop fence, final-invariant, cancellation and timeout return. A retry-time scope fence landing between the loop-entry check and the pre-dispatch invariant can no longer downgrade an already dispatched command to NOT_STARTED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4e87bacdf3
commit
e06e4377ca
@@ -30,6 +30,8 @@ import {
|
||||
import {
|
||||
certaintyForAbandonedAttempt,
|
||||
classifyProblemEffect,
|
||||
joinMutationEffectCertainty,
|
||||
type MutationEffectCertainty,
|
||||
type PhysicalAttemptState,
|
||||
} from "./http-effect-certainty.ts";
|
||||
import { parseRetryAfter } from "./retry-policy.ts";
|
||||
@@ -413,6 +415,25 @@ export function createContractHttpExecutor(
|
||||
|
||||
let attemptState: PhysicalAttemptState = "PREPARING";
|
||||
let attempts = 0;
|
||||
/**
|
||||
* §8.7 / D-01. Per-attempt state stays local to the attempt; this monotonic
|
||||
* accumulator is the logical execution history. A retry that has not been
|
||||
* dispatched can never lower what an earlier attempt already established.
|
||||
*/
|
||||
let logicalCertainty: MutationEffectCertainty = "NOT_STARTED";
|
||||
const observeCertainty = (observed: MutationEffectCertainty) => {
|
||||
logicalCertainty = joinMutationEffectCertainty(logicalCertainty, observed);
|
||||
return logicalCertainty;
|
||||
};
|
||||
/** Pre-dispatch failures read the accumulator, never a fresh attempt. */
|
||||
const logicalPreDispatchEffect = (): HttpEffectCertainty =>
|
||||
isCommand ? logicalCertainty : "NOT_APPLICABLE";
|
||||
const abandonedCertainty = (): MutationEffectCertainty =>
|
||||
observeCertainty(certaintyForAbandonedAttempt(attemptState, isCommand));
|
||||
const abandonedTransportFailure = (
|
||||
kind: HttpTransportFailure["kind"],
|
||||
): HttpExecutionOutcome<WireOutput, Problem> =>
|
||||
transportFailure(kind, false, abandonedCertainty());
|
||||
let terminalCancellation: CancellationOwner | null = null;
|
||||
const lifetimeController = new AbortController();
|
||||
const forwardCallerToLifetime = () => {
|
||||
@@ -571,24 +592,14 @@ export function createContractHttpExecutor(
|
||||
if (patchResult === ABORTED) {
|
||||
if (terminalCancellation === "SCOPE_FENCE") {
|
||||
return finish(
|
||||
transportFailure(
|
||||
"ABORTED_BY_SCOPE",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("ABORTED_BY_SCOPE"),
|
||||
"SCOPE_FENCED",
|
||||
);
|
||||
}
|
||||
return terminalCancellation === "CALLER"
|
||||
? finish(cancelled("NOT_STARTED"), "CANCELLED")
|
||||
: finish(
|
||||
transportFailure(
|
||||
"TIMEOUT",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("TIMEOUT"),
|
||||
"TIMEOUT",
|
||||
);
|
||||
}
|
||||
@@ -647,14 +658,14 @@ export function createContractHttpExecutor(
|
||||
if (callerSignal?.aborted) {
|
||||
terminalCancellation ??= "CALLER";
|
||||
return finish(
|
||||
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
|
||||
cancelled(abandonedCertainty()),
|
||||
"CANCELLED",
|
||||
);
|
||||
}
|
||||
if (!context.scope.isCurrent()) {
|
||||
terminalCancellation ??= "SCOPE_FENCE";
|
||||
return finish(
|
||||
scopeFenced(contractViolationEffect(attemptState, isCommand)),
|
||||
scopeFenced(abandonedCertainty()),
|
||||
"SCOPE_FENCED",
|
||||
);
|
||||
}
|
||||
@@ -663,7 +674,7 @@ export function createContractHttpExecutor(
|
||||
const budget = remaining();
|
||||
if (budget <= 0) {
|
||||
return finish(
|
||||
transportFailure("TIMEOUT", false, attemptState, isCommand),
|
||||
abandonedTransportFailure("TIMEOUT"),
|
||||
"TIMEOUT",
|
||||
);
|
||||
}
|
||||
@@ -710,11 +721,11 @@ export function createContractHttpExecutor(
|
||||
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
|
||||
return finish(
|
||||
invariantFailure === "SCOPE_FENCED"
|
||||
? scopeFenced(preDispatchEffect(isCommand))
|
||||
? scopeFenced(logicalPreDispatchEffect())
|
||||
: violation(
|
||||
"FINAL_REQUEST_INVARIANT_FAILED",
|
||||
"REQUEST",
|
||||
preDispatchEffect(isCommand),
|
||||
logicalPreDispatchEffect(),
|
||||
),
|
||||
"NOT_STARTED",
|
||||
);
|
||||
@@ -726,6 +737,9 @@ export function createContractHttpExecutor(
|
||||
attempts += 1;
|
||||
const pending = fetcher(projected.request.url, init);
|
||||
attemptState = "DISPATCHED";
|
||||
// D-01. Dispatch is the point of no return for the logical execution.
|
||||
// No later retry may claim the command never started.
|
||||
observeCertainty(certaintyForAbandonedAttempt("DISPATCHED", isCommand));
|
||||
response = await pending;
|
||||
attemptState = "RESPONSE_HEADERS";
|
||||
} catch {
|
||||
@@ -734,18 +748,13 @@ export function createContractHttpExecutor(
|
||||
const owner = terminalCancellation;
|
||||
if (owner === "CALLER") {
|
||||
return finish(
|
||||
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
|
||||
cancelled(abandonedCertainty()),
|
||||
"CANCELLED",
|
||||
);
|
||||
}
|
||||
if (owner === "SCOPE_FENCE") {
|
||||
return finish(
|
||||
transportFailure(
|
||||
"ABORTED_BY_SCOPE",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("ABORTED_BY_SCOPE"),
|
||||
"SCOPE_FENCED",
|
||||
);
|
||||
}
|
||||
@@ -765,18 +774,11 @@ export function createContractHttpExecutor(
|
||||
if (slept === ABORTED) {
|
||||
return terminalCancellation === "CALLER"
|
||||
? finish(
|
||||
cancelled(
|
||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||
),
|
||||
cancelled(abandonedCertainty()),
|
||||
"CANCELLED",
|
||||
)
|
||||
: finish(
|
||||
transportFailure(
|
||||
"TIMEOUT",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("TIMEOUT"),
|
||||
"TIMEOUT",
|
||||
);
|
||||
}
|
||||
@@ -784,7 +786,7 @@ export function createContractHttpExecutor(
|
||||
}
|
||||
}
|
||||
return finish(
|
||||
transportFailure(kind, false, attemptState, isCommand),
|
||||
abandonedTransportFailure(kind),
|
||||
kind,
|
||||
);
|
||||
}
|
||||
@@ -817,18 +819,11 @@ export function createContractHttpExecutor(
|
||||
if (slept === ABORTED) {
|
||||
return terminalCancellation === "CALLER"
|
||||
? finish(
|
||||
cancelled(
|
||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||
),
|
||||
cancelled(abandonedCertainty()),
|
||||
"CANCELLED",
|
||||
)
|
||||
: finish(
|
||||
transportFailure(
|
||||
"TIMEOUT",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
),
|
||||
abandonedTransportFailure("TIMEOUT"),
|
||||
"TIMEOUT",
|
||||
);
|
||||
}
|
||||
@@ -843,7 +838,7 @@ export function createContractHttpExecutor(
|
||||
}
|
||||
} catch {
|
||||
return finish(
|
||||
transportFailure("NETWORK_FAILURE", false, attemptState, isCommand),
|
||||
abandonedTransportFailure("NETWORK_FAILURE"),
|
||||
"RUNTIME_FAILURE",
|
||||
);
|
||||
} finally {
|
||||
@@ -945,8 +940,7 @@ async function admitResponse<Input, WireOutput, Problem>(
|
||||
transportFailure(
|
||||
"RESPONSE_STREAM_FAILURE",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||
),
|
||||
probe.code,
|
||||
);
|
||||
@@ -990,8 +984,7 @@ async function admitResponse<Input, WireOutput, Problem>(
|
||||
: transportFailure(
|
||||
"RESPONSE_STREAM_FAILURE",
|
||||
false,
|
||||
attemptState,
|
||||
isCommand,
|
||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||
),
|
||||
bytes.code,
|
||||
);
|
||||
@@ -1323,16 +1316,6 @@ function postDispatchEffect(isCommand: boolean): HttpEffectCertainty {
|
||||
return isCommand ? "MAYBE_APPLIED" : "NOT_APPLICABLE";
|
||||
}
|
||||
|
||||
function contractViolationEffect(
|
||||
attemptState: PhysicalAttemptState,
|
||||
isCommand: boolean,
|
||||
): HttpEffectCertainty {
|
||||
if (!isCommand) return "NOT_APPLICABLE";
|
||||
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND"
|
||||
? "NOT_STARTED"
|
||||
: "MAYBE_APPLIED";
|
||||
}
|
||||
|
||||
function unauthenticated<Value, Problem>(
|
||||
effect: string,
|
||||
isCommand: boolean,
|
||||
@@ -1365,10 +1348,8 @@ function cancelled<Value, Problem>(
|
||||
function transportFailure<Value, Problem>(
|
||||
kind: HttpTransportFailure["kind"],
|
||||
retryable: boolean,
|
||||
attemptState: PhysicalAttemptState,
|
||||
isCommand: boolean,
|
||||
effect: MutationEffectCertainty,
|
||||
): HttpExecutionOutcome<Value, Problem> {
|
||||
const effect = certaintyForAbandonedAttempt(attemptState, isCommand);
|
||||
return Object.freeze({
|
||||
kind: "TRANSPORT_FAILURE" as const,
|
||||
failure: Object.freeze({ kind, retryable }),
|
||||
|
||||
Reference in New Issue
Block a user