fix(session): invalidate snapshots before metadata write dispatch
Stop cached metadata replay before a provider write starts, including while its reply is pending or fails after applying the mutation. Keep snapshot import and export disabled until the target is reopened. Validate failed replies, conflicting provider identity, and overlapping reads through the shared session boundary.
This commit is contained in:
parent
538f8cc722
commit
1c64fe3eff
|
|
@ -150,6 +150,10 @@ an exact result when the target bytes, operation, parameters, provider and
|
|||
settings match. Mutations and cursor-dependent calls are excluded from the
|
||||
cache. Snapshot files are local and use owner-only permissions.
|
||||
|
||||
Dispatching a metadata write invalidates the active session's snapshot cache,
|
||||
even if its reply fails: the provider may already have applied the write.
|
||||
Close and reopen the target before importing or saving another snapshot.
|
||||
|
||||
Snapshots retain eligible target-scoped question histories in full, including
|
||||
mutation Evidence recorded while another target was active. Observations and
|
||||
related questions must still belong to the saved target. If those dependencies
|
||||
|
|
|
|||
|
|
@ -588,6 +588,9 @@ export class BinarySession
|
|||
if (!prepared.ok) return prepared;
|
||||
const { active, capability, profile, cacheable, cached } = prepared.value;
|
||||
if (cached !== undefined) return ok(cached);
|
||||
// A failed reply cannot prove that a dispatched metadata write rolled back.
|
||||
// Stop replay before dispatch, including reads overlapping the write.
|
||||
if (capability.effects.mutatesArtifact) this.invalidateSnapshot();
|
||||
const call = active.client.execute(name, arguments_, options);
|
||||
const result = await call;
|
||||
const profiled = bindExecutionTarget(
|
||||
|
|
@ -645,8 +648,6 @@ export class BinarySession
|
|||
parameters: arguments_,
|
||||
execution: execution.value,
|
||||
});
|
||||
} else if (profiled.ok && capability.effects.mutatesArtifact) {
|
||||
this.invalidateSnapshot();
|
||||
}
|
||||
return execution;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,9 +144,9 @@ export abstract class BinarySessionRecords {
|
|||
if (this.#snapshotInvalidated)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable after analysis metadata mutations",
|
||||
"Analysis snapshots are unavailable because analysis metadata may have changed",
|
||||
{
|
||||
userMessage: `Analysis snapshots are unavailable after analysis metadata mutations. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
||||
userMessage: `Analysis snapshots are unavailable because analysis metadata may have changed. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -177,9 +177,9 @@ export abstract class BinarySessionRecords {
|
|||
if (active !== undefined && this.#snapshotInvalidated)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots cannot be imported after analysis metadata mutations",
|
||||
"Analysis snapshots cannot be imported because analysis metadata may have changed",
|
||||
{
|
||||
userMessage: `Analysis snapshots cannot be imported after analysis metadata mutations. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
||||
userMessage: `Analysis snapshots cannot be imported because analysis metadata may have changed. ${SNAPSHOT_MUTATION_RECOVERY}`,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProvider,
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisClient,
|
||||
type AnalysisProvider,
|
||||
} from "../../../src/application/AnalysisProvider.js";
|
||||
import type { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
|
|
@ -374,7 +375,7 @@ describe("evidence metadata imports and snapshot cache", () => {
|
|||
ok: false,
|
||||
error: {
|
||||
message: expect.stringContaining(
|
||||
"Analysis snapshots are unavailable after analysis metadata mutations",
|
||||
"Analysis snapshots are unavailable because analysis metadata may have changed",
|
||||
),
|
||||
},
|
||||
});
|
||||
|
|
@ -408,6 +409,108 @@ describe("evidence metadata imports and snapshot cache", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("snapshot invalidation at mutation dispatch", () => {
|
||||
it.each(["provider failure", "invalid reply identity"] as const)(
|
||||
"does not replay metadata after a write followed by %s",
|
||||
async (failure) => {
|
||||
const [target] = await createBinarySessionTargets();
|
||||
const provider = createCacheProvider([]);
|
||||
let name = "before";
|
||||
provider.createClient = () => ({
|
||||
execute: (operation) => {
|
||||
if (operation === "health") return Promise.resolve(ok(null));
|
||||
if (operation === "address_name") return Promise.resolve(ok(name));
|
||||
if (operation !== "set_address_name")
|
||||
throw new Error(`Unexpected operation: ${operation}`);
|
||||
name = "after";
|
||||
return Promise.resolve(
|
||||
failure === "provider failure"
|
||||
? err(new ProviderAdapterError("fixture", operation))
|
||||
: resultOk(
|
||||
createAnalysisExecution(true, {
|
||||
id: "wrong",
|
||||
name: "Wrong",
|
||||
version: "1",
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
close: () => Promise.resolve(resultOk(null)),
|
||||
});
|
||||
const session = createTestBinarySession(provider);
|
||||
try {
|
||||
const opened = await session.open(target);
|
||||
if (!opened.ok) throw opened.error;
|
||||
const parameters = { address: "0x1000", document: "first" };
|
||||
const before = await session.execute("address_name", parameters);
|
||||
if (!before.ok) throw before.error;
|
||||
expect(before.value.result).toBe("before");
|
||||
const baseline = session.exportAnalysisSnapshot();
|
||||
if (!baseline.ok) throw baseline.error;
|
||||
|
||||
const written = await session.execute("set_address_name", {
|
||||
...parameters,
|
||||
name: "after",
|
||||
});
|
||||
expect(written).toMatchObject({ ok: false });
|
||||
const after = await session.execute("address_name", parameters);
|
||||
if (!after.ok) throw after.error;
|
||||
expect(after.value.result).toBe("after");
|
||||
expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: false });
|
||||
expect(session.importAnalysisSnapshot(baseline.value)).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
expect(await session.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("stops replay while a dispatched write is still awaiting its reply", async () => {
|
||||
const [target] = await createBinarySessionTargets();
|
||||
const provider = createCacheProvider([]);
|
||||
const started = createDeferred<void>();
|
||||
const reply = createDeferred<ReturnType<typeof ok>>();
|
||||
let name = "before";
|
||||
provider.createClient = () => ({
|
||||
execute: (operation) => {
|
||||
if (operation === "health") return Promise.resolve(ok(null));
|
||||
if (operation === "address_name") return Promise.resolve(ok(name));
|
||||
if (operation !== "set_address_name")
|
||||
throw new Error(`Unexpected operation: ${operation}`);
|
||||
name = "after";
|
||||
started.resolve();
|
||||
return reply.promise;
|
||||
},
|
||||
close: () => Promise.resolve(resultOk(null)),
|
||||
});
|
||||
const session = createTestBinarySession(provider);
|
||||
try {
|
||||
const opened = await session.open(target);
|
||||
if (!opened.ok) throw opened.error;
|
||||
const parameters = { address: "0x1000", document: "first" };
|
||||
const before = await session.execute("address_name", parameters);
|
||||
if (!before.ok) throw before.error;
|
||||
const writing = session.execute("set_address_name", {
|
||||
...parameters,
|
||||
name: "after",
|
||||
});
|
||||
await started.promise;
|
||||
try {
|
||||
const during = await session.execute("address_name", parameters);
|
||||
if (!during.ok) throw during.error;
|
||||
expect(during.value.result).toBe("after");
|
||||
expect(session.exportAnalysisSnapshot()).toMatchObject({ ok: false });
|
||||
} finally {
|
||||
reply.resolve(ok(true));
|
||||
expect(await writing).toMatchObject({ ok: true });
|
||||
}
|
||||
} finally {
|
||||
expect(await session.close()).toEqual({ ok: true, value: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("replay of exact immutable calls", () => {
|
||||
it("replays exact immutable calls from a matching provider-neutral snapshot", async () => {
|
||||
const [first, second] = await createBinarySessionTargets();
|
||||
|
|
|
|||
Loading…
Reference in New Issue