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
@@ -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