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
|
`integration-failed`, `unauthenticated`와 credential attach rejection을 anonymous
|
||||||
request로 downgrade하지 않는다.
|
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 집행)
|
#### 5-1. Installed auth profile registry (V3 집행)
|
||||||
|
|
||||||
`installRestAuthProfileRegistry()`가 composition 시점에 profile을 한 번 설치하고
|
`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-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-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-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-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 | — |
|
| 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<{
|
export type ProblemEffectInput<Problem> = Readonly<{
|
||||||
status: number;
|
status: number;
|
||||||
problem: Problem;
|
problem: Problem;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
certaintyForAbandonedAttempt,
|
certaintyForAbandonedAttempt,
|
||||||
classifyProblemEffect,
|
classifyProblemEffect,
|
||||||
|
joinMutationEffectCertainty,
|
||||||
|
type MutationEffectCertainty,
|
||||||
type PhysicalAttemptState,
|
type PhysicalAttemptState,
|
||||||
} from "./http-effect-certainty.ts";
|
} from "./http-effect-certainty.ts";
|
||||||
import { parseRetryAfter } from "./retry-policy.ts";
|
import { parseRetryAfter } from "./retry-policy.ts";
|
||||||
@@ -413,6 +415,25 @@ export function createContractHttpExecutor(
|
|||||||
|
|
||||||
let attemptState: PhysicalAttemptState = "PREPARING";
|
let attemptState: PhysicalAttemptState = "PREPARING";
|
||||||
let attempts = 0;
|
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;
|
let terminalCancellation: CancellationOwner | null = null;
|
||||||
const lifetimeController = new AbortController();
|
const lifetimeController = new AbortController();
|
||||||
const forwardCallerToLifetime = () => {
|
const forwardCallerToLifetime = () => {
|
||||||
@@ -571,24 +592,14 @@ export function createContractHttpExecutor(
|
|||||||
if (patchResult === ABORTED) {
|
if (patchResult === ABORTED) {
|
||||||
if (terminalCancellation === "SCOPE_FENCE") {
|
if (terminalCancellation === "SCOPE_FENCE") {
|
||||||
return finish(
|
return finish(
|
||||||
transportFailure(
|
abandonedTransportFailure("ABORTED_BY_SCOPE"),
|
||||||
"ABORTED_BY_SCOPE",
|
|
||||||
false,
|
|
||||||
attemptState,
|
|
||||||
isCommand,
|
|
||||||
),
|
|
||||||
"SCOPE_FENCED",
|
"SCOPE_FENCED",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return terminalCancellation === "CALLER"
|
return terminalCancellation === "CALLER"
|
||||||
? finish(cancelled("NOT_STARTED"), "CANCELLED")
|
? finish(cancelled("NOT_STARTED"), "CANCELLED")
|
||||||
: finish(
|
: finish(
|
||||||
transportFailure(
|
abandonedTransportFailure("TIMEOUT"),
|
||||||
"TIMEOUT",
|
|
||||||
false,
|
|
||||||
attemptState,
|
|
||||||
isCommand,
|
|
||||||
),
|
|
||||||
"TIMEOUT",
|
"TIMEOUT",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -647,14 +658,14 @@ export function createContractHttpExecutor(
|
|||||||
if (callerSignal?.aborted) {
|
if (callerSignal?.aborted) {
|
||||||
terminalCancellation ??= "CALLER";
|
terminalCancellation ??= "CALLER";
|
||||||
return finish(
|
return finish(
|
||||||
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
|
cancelled(abandonedCertainty()),
|
||||||
"CANCELLED",
|
"CANCELLED",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!context.scope.isCurrent()) {
|
if (!context.scope.isCurrent()) {
|
||||||
terminalCancellation ??= "SCOPE_FENCE";
|
terminalCancellation ??= "SCOPE_FENCE";
|
||||||
return finish(
|
return finish(
|
||||||
scopeFenced(contractViolationEffect(attemptState, isCommand)),
|
scopeFenced(abandonedCertainty()),
|
||||||
"SCOPE_FENCED",
|
"SCOPE_FENCED",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -663,7 +674,7 @@ export function createContractHttpExecutor(
|
|||||||
const budget = remaining();
|
const budget = remaining();
|
||||||
if (budget <= 0) {
|
if (budget <= 0) {
|
||||||
return finish(
|
return finish(
|
||||||
transportFailure("TIMEOUT", false, attemptState, isCommand),
|
abandonedTransportFailure("TIMEOUT"),
|
||||||
"TIMEOUT",
|
"TIMEOUT",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -710,11 +721,11 @@ export function createContractHttpExecutor(
|
|||||||
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
|
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
|
||||||
return finish(
|
return finish(
|
||||||
invariantFailure === "SCOPE_FENCED"
|
invariantFailure === "SCOPE_FENCED"
|
||||||
? scopeFenced(preDispatchEffect(isCommand))
|
? scopeFenced(logicalPreDispatchEffect())
|
||||||
: violation(
|
: violation(
|
||||||
"FINAL_REQUEST_INVARIANT_FAILED",
|
"FINAL_REQUEST_INVARIANT_FAILED",
|
||||||
"REQUEST",
|
"REQUEST",
|
||||||
preDispatchEffect(isCommand),
|
logicalPreDispatchEffect(),
|
||||||
),
|
),
|
||||||
"NOT_STARTED",
|
"NOT_STARTED",
|
||||||
);
|
);
|
||||||
@@ -726,6 +737,9 @@ export function createContractHttpExecutor(
|
|||||||
attempts += 1;
|
attempts += 1;
|
||||||
const pending = fetcher(projected.request.url, init);
|
const pending = fetcher(projected.request.url, init);
|
||||||
attemptState = "DISPATCHED";
|
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;
|
response = await pending;
|
||||||
attemptState = "RESPONSE_HEADERS";
|
attemptState = "RESPONSE_HEADERS";
|
||||||
} catch {
|
} catch {
|
||||||
@@ -734,18 +748,13 @@ export function createContractHttpExecutor(
|
|||||||
const owner = terminalCancellation;
|
const owner = terminalCancellation;
|
||||||
if (owner === "CALLER") {
|
if (owner === "CALLER") {
|
||||||
return finish(
|
return finish(
|
||||||
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
|
cancelled(abandonedCertainty()),
|
||||||
"CANCELLED",
|
"CANCELLED",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (owner === "SCOPE_FENCE") {
|
if (owner === "SCOPE_FENCE") {
|
||||||
return finish(
|
return finish(
|
||||||
transportFailure(
|
abandonedTransportFailure("ABORTED_BY_SCOPE"),
|
||||||
"ABORTED_BY_SCOPE",
|
|
||||||
false,
|
|
||||||
attemptState,
|
|
||||||
isCommand,
|
|
||||||
),
|
|
||||||
"SCOPE_FENCED",
|
"SCOPE_FENCED",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -765,18 +774,11 @@ export function createContractHttpExecutor(
|
|||||||
if (slept === ABORTED) {
|
if (slept === ABORTED) {
|
||||||
return terminalCancellation === "CALLER"
|
return terminalCancellation === "CALLER"
|
||||||
? finish(
|
? finish(
|
||||||
cancelled(
|
cancelled(abandonedCertainty()),
|
||||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
|
||||||
),
|
|
||||||
"CANCELLED",
|
"CANCELLED",
|
||||||
)
|
)
|
||||||
: finish(
|
: finish(
|
||||||
transportFailure(
|
abandonedTransportFailure("TIMEOUT"),
|
||||||
"TIMEOUT",
|
|
||||||
false,
|
|
||||||
attemptState,
|
|
||||||
isCommand,
|
|
||||||
),
|
|
||||||
"TIMEOUT",
|
"TIMEOUT",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -784,7 +786,7 @@ export function createContractHttpExecutor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return finish(
|
return finish(
|
||||||
transportFailure(kind, false, attemptState, isCommand),
|
abandonedTransportFailure(kind),
|
||||||
kind,
|
kind,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -817,18 +819,11 @@ export function createContractHttpExecutor(
|
|||||||
if (slept === ABORTED) {
|
if (slept === ABORTED) {
|
||||||
return terminalCancellation === "CALLER"
|
return terminalCancellation === "CALLER"
|
||||||
? finish(
|
? finish(
|
||||||
cancelled(
|
cancelled(abandonedCertainty()),
|
||||||
certaintyForAbandonedAttempt(attemptState, isCommand),
|
|
||||||
),
|
|
||||||
"CANCELLED",
|
"CANCELLED",
|
||||||
)
|
)
|
||||||
: finish(
|
: finish(
|
||||||
transportFailure(
|
abandonedTransportFailure("TIMEOUT"),
|
||||||
"TIMEOUT",
|
|
||||||
false,
|
|
||||||
attemptState,
|
|
||||||
isCommand,
|
|
||||||
),
|
|
||||||
"TIMEOUT",
|
"TIMEOUT",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -843,7 +838,7 @@ export function createContractHttpExecutor(
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return finish(
|
return finish(
|
||||||
transportFailure("NETWORK_FAILURE", false, attemptState, isCommand),
|
abandonedTransportFailure("NETWORK_FAILURE"),
|
||||||
"RUNTIME_FAILURE",
|
"RUNTIME_FAILURE",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -945,8 +940,7 @@ async function admitResponse<Input, WireOutput, Problem>(
|
|||||||
transportFailure(
|
transportFailure(
|
||||||
"RESPONSE_STREAM_FAILURE",
|
"RESPONSE_STREAM_FAILURE",
|
||||||
false,
|
false,
|
||||||
attemptState,
|
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||||
isCommand,
|
|
||||||
),
|
),
|
||||||
probe.code,
|
probe.code,
|
||||||
);
|
);
|
||||||
@@ -990,8 +984,7 @@ async function admitResponse<Input, WireOutput, Problem>(
|
|||||||
: transportFailure(
|
: transportFailure(
|
||||||
"RESPONSE_STREAM_FAILURE",
|
"RESPONSE_STREAM_FAILURE",
|
||||||
false,
|
false,
|
||||||
attemptState,
|
certaintyForAbandonedAttempt(attemptState, isCommand),
|
||||||
isCommand,
|
|
||||||
),
|
),
|
||||||
bytes.code,
|
bytes.code,
|
||||||
);
|
);
|
||||||
@@ -1323,16 +1316,6 @@ function postDispatchEffect(isCommand: boolean): HttpEffectCertainty {
|
|||||||
return isCommand ? "MAYBE_APPLIED" : "NOT_APPLICABLE";
|
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>(
|
function unauthenticated<Value, Problem>(
|
||||||
effect: string,
|
effect: string,
|
||||||
isCommand: boolean,
|
isCommand: boolean,
|
||||||
@@ -1365,10 +1348,8 @@ function cancelled<Value, Problem>(
|
|||||||
function transportFailure<Value, Problem>(
|
function transportFailure<Value, Problem>(
|
||||||
kind: HttpTransportFailure["kind"],
|
kind: HttpTransportFailure["kind"],
|
||||||
retryable: boolean,
|
retryable: boolean,
|
||||||
attemptState: PhysicalAttemptState,
|
effect: MutationEffectCertainty,
|
||||||
isCommand: boolean,
|
|
||||||
): HttpExecutionOutcome<Value, Problem> {
|
): HttpExecutionOutcome<Value, Problem> {
|
||||||
const effect = certaintyForAbandonedAttempt(attemptState, isCommand);
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
kind: "TRANSPORT_FAILURE" as const,
|
kind: "TRANSPORT_FAILURE" as const,
|
||||||
failure: Object.freeze({ kind, retryable }),
|
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 { createHttpClient } from "../../src/adapters/http/client.ts";
|
||||||
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
|
||||||
|
import { joinMutationEffectCertainty } from "../../src/adapters/http/http-effect-certainty.ts";
|
||||||
import {
|
import {
|
||||||
entityQueryKeys,
|
entityQueryKeys,
|
||||||
TEST_HTTP_CONTRACT,
|
TEST_HTTP_CONTRACT,
|
||||||
@@ -49,6 +50,22 @@ function testClient(options: HttpDependencies) {
|
|||||||
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
|
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", () => {
|
describe("HTTP operation execution contract", () => {
|
||||||
it("permits a keyless command intent but rejects an unexpected key before dispatch", async () => {
|
it("permits a keyless command intent but rejects an unexpected key before dispatch", async () => {
|
||||||
const attachCredentials = vi.fn(() => ({
|
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 () => {
|
it("settles a credential hang at the total operation deadline", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user