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
@@ -262,6 +262,22 @@ auth-required operation은 session state가 `authenticated`가 아니면 fetch
|
||||
`integration-failed`, `unauthenticated`와 credential attach rejection을 anonymous
|
||||
request로 downgrade하지 않는다.
|
||||
|
||||
#### 5-0. Logical effect certainty는 단조 증가한다
|
||||
|
||||
`PhysicalAttemptState`는 현재 attempt만 설명한다. logical execution 전체에는
|
||||
별도의 monotonic accumulator를 두고 `joinMutationEffectCertainty`로 join한다.
|
||||
join 순서는 보수적이다.
|
||||
|
||||
```text
|
||||
NOT_STARTED < NOT_APPLIED < MAYBE_APPLIED < APPLIED_CONFIRMED
|
||||
```
|
||||
|
||||
`fetch()` dispatch 시점에 command는 즉시 `MAYBE_APPLIED`를 기록한다. 이후 retry
|
||||
loop entry, pre-dispatch final invariant, scope fence, cancellation, timeout
|
||||
return은 모두 accumulator를 읽는다. 아직 보내지 않은 새 retry가 있다는 이유로
|
||||
전체 logical operation을 `NOT_STARTED`로 되돌리지 않는다. query operation은
|
||||
`NOT_APPLICABLE`로 남고 이 lattice를 쓰지 않는다.
|
||||
|
||||
#### 5-1. Installed auth profile registry (V3 집행)
|
||||
|
||||
`installRestAuthProfileRegistry()`가 composition 시점에 profile을 한 번 설치하고
|
||||
|
||||
@@ -74,7 +74,7 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| N-01 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-observability.test.ts` | `fix: restore V3 HTTP observability` | `FIXED_NOT_RELEASED` | diagnostics/telemetry producer gate regression | Red 5/5 failed → green 5/5; `check:diagnostics` PASS (8 diagnostics, 5 telemetry producers); `check:types` PASS; `check:architecture` PASS |
|
||||
| N-02 | Live V3 path | `corepack pnpm exec vitest run tests/integration/http-execution-v3-auth-profile.test.ts` | `fix: enforce installed HTTP auth profiles` | `FIXED_NOT_RELEASED` | authenticated request 4xx spike after profile enforcement | Red suite failed to load (`installRestAuthProfileRegistry` absent) → green 7/7; `check:types` PASS; `check:architecture` PASS; `lint` PASS; unit+integration+features 1560 passed with only the pre-existing environmental `ci-artifact-contract` failures |
|
||||
| N-03 | Live V3 path | `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts` | — | `NOT_STARTED` | command effect verdict regression | — |
|
||||
| N-03 | Live V3 path | `corepack pnpm exec vitest run tests/unit/http-execution-v3.test.ts -t "retry-time fence"` | `fix: preserve command effect certainty across retries` | `FIXED_NOT_RELEASED` | command effect verdict regression | Red reproduced `SCOPE_FENCED` with `NOT_STARTED` after one dispatched attempt → green `MAYBE_APPLIED`; lattice table 9/9; `check:types` PASS; `lint` PASS |
|
||||
| N-04 | Live composition teardown | `corepack pnpm exec vitest run tests/unit/telemetry.test.ts` | — | `NOT_STARTED` | telemetry delivery loss after teardown change | — |
|
||||
| N-05 | Rollout blocker (sidecar not composed) | `corepack pnpm exec vitest run tests/unit/conditional-validator-store.test.ts` | — | `NOT_STARTED` | persisted validator key incompatibility | — |
|
||||
| N-06 | Legacy V2 rollback seam | `corepack pnpm exec vitest run tests/integration/http-client.test.ts` | — | `NOT_STARTED` | legacy keyed command rejection spike | — |
|
||||
|
||||
@@ -48,6 +48,30 @@ export function certaintyForAbandonedAttempt(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* §8.7. The conservative certainty lattice for one logical execution.
|
||||
*
|
||||
* `PhysicalAttemptState` describes only the attempt in flight. A new retry that
|
||||
* has not been sent yet must never lower what an earlier attempt already
|
||||
* established, so the executor joins observations into a monotonic accumulator.
|
||||
*/
|
||||
const CERTAINTY_RANK: Readonly<Record<MutationEffectCertainty, number>> =
|
||||
Object.freeze({
|
||||
NOT_STARTED: 0,
|
||||
NOT_APPLIED: 1,
|
||||
MAYBE_APPLIED: 2,
|
||||
APPLIED_CONFIRMED: 3,
|
||||
});
|
||||
|
||||
export function joinMutationEffectCertainty(
|
||||
current: MutationEffectCertainty,
|
||||
observed: MutationEffectCertainty,
|
||||
): MutationEffectCertainty {
|
||||
return CERTAINTY_RANK[observed] > CERTAINTY_RANK[current]
|
||||
? observed
|
||||
: current;
|
||||
}
|
||||
|
||||
export type ProblemEffectInput<Problem> = Readonly<{
|
||||
status: number;
|
||||
problem: Problem;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createHttpClient } from "../../src/adapters/http/client.ts";
|
||||
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
||||
import { joinMutationEffectCertainty } from "../../src/adapters/http/http-effect-certainty.ts";
|
||||
import {
|
||||
entityQueryKeys,
|
||||
TEST_HTTP_CONTRACT,
|
||||
@@ -49,6 +50,22 @@ function testClient(options: HttpDependencies) {
|
||||
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
|
||||
}
|
||||
|
||||
describe("mutation effect certainty lattice", () => {
|
||||
it.each([
|
||||
["NOT_STARTED", "NOT_APPLIED", "NOT_APPLIED"],
|
||||
["NOT_APPLIED", "NOT_STARTED", "NOT_APPLIED"],
|
||||
["NOT_STARTED", "MAYBE_APPLIED", "MAYBE_APPLIED"],
|
||||
["MAYBE_APPLIED", "NOT_STARTED", "MAYBE_APPLIED"],
|
||||
["MAYBE_APPLIED", "NOT_APPLIED", "MAYBE_APPLIED"],
|
||||
["NOT_APPLIED", "MAYBE_APPLIED", "MAYBE_APPLIED"],
|
||||
["MAYBE_APPLIED", "APPLIED_CONFIRMED", "APPLIED_CONFIRMED"],
|
||||
["APPLIED_CONFIRMED", "NOT_STARTED", "APPLIED_CONFIRMED"],
|
||||
["APPLIED_CONFIRMED", "MAYBE_APPLIED", "APPLIED_CONFIRMED"],
|
||||
] as const)("joins %s with %s as %s", (current, observed, expected) => {
|
||||
expect(joinMutationEffectCertainty(current, observed)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTP operation execution contract", () => {
|
||||
it("permits a keyless command intent but rejects an unexpected key before dispatch", async () => {
|
||||
const attachCredentials = vi.fn(() => ({
|
||||
|
||||
@@ -474,6 +474,64 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a dispatched command MAYBE_APPLIED when a retry-time fence lands between scope checks", async () => {
|
||||
// Attempt 1 dispatches an idempotent command and receives 429. The retry
|
||||
// sleep resolves, the loop-entry scope check is still current, and only the
|
||||
// pre-dispatch final invariant observes the fence.
|
||||
let armed = false;
|
||||
let checksAfterArming = 0;
|
||||
const idempotentCommand: InstalledHttpContract<unknown, unknown, unknown> = {
|
||||
...createInstalled,
|
||||
contract: {
|
||||
...createInstalled.contract,
|
||||
retrySemantics: "IDEMPOTENT" as const,
|
||||
},
|
||||
frontend: { ...createInstalled.frontend, retryBudget: 1 as const },
|
||||
};
|
||||
const racingScope = Object.freeze({
|
||||
...scope,
|
||||
isCurrent: () => {
|
||||
if (!armed) return true;
|
||||
checksAfterArming += 1;
|
||||
// The retry loop entry still observes a current scope; the pre-dispatch
|
||||
// final invariant is the first observation of the fence.
|
||||
return checksAfterArming <= 1;
|
||||
},
|
||||
});
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ type: "about:blank", title: "slow down", status: 429 }, {
|
||||
status: 429,
|
||||
}),
|
||||
);
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 1,
|
||||
attachCredentials: () => ({ kind: "READY", headers: {} }),
|
||||
fetcher,
|
||||
sleep: async () => {
|
||||
armed = true;
|
||||
},
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
const outcome = await executor.execute(
|
||||
idempotentCommand,
|
||||
{ name: "created" },
|
||||
{
|
||||
routeId: ROUTE_ID,
|
||||
scope: racingScope,
|
||||
intent: mutationIntent({ idempotencyKey: null }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
violation: { kind: "SCOPE_FENCED" },
|
||||
effect: "MAYBE_APPLIED",
|
||||
});
|
||||
});
|
||||
|
||||
it("settles a credential hang at the total operation deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
let settled = false;
|
||||
|
||||
Reference in New Issue
Block a user