fix(sqlite): retain completed evidence through reply finalization

This commit is contained in:
morluto committed 2026-10-26 17:56:47 +08:00
1 parent f2b4468183
commit e596a48251
3 files changed
+287 -54

No files matched your search

@@ -1,5 +1,6 @@
import { Cli } from "incur";
import { access, writeFile } from "node:fs/promises";
import { access, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import type { FileHandle } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -11,7 +12,9 @@ import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceSc
import { registerSqliteCommands } from "../../../src/cli/sqliteCommands.js";
import { createSqliteDatabaseService } from "../../../src/composition/sqlite.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { primaryAnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { silentLogger } from "../../../src/logger.js";
import { SqliteDatabaseProvider } from "../../../src/sqlite/SqliteDatabaseProvider.js";
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
@@ -30,6 +33,8 @@ const descriptorCleanup = vi.hoisted(() => ({
handles: [] as FileHandle[],
readFailure: undefined as Error | undefined,
onOpen: undefined as (() => void) | undefined,
onClose: undefined as (() => void) | undefined,
reply: undefined as string | undefined,
}));
vi.mock("../../../src/composition/sqlite.js", () => ({
@@ -54,9 +59,11 @@ vi.mock("node:fs/promises", async (importOriginal) => {
descriptorCleanup.attempts += 1;
if (descriptorCleanup.denied) {
if (descriptorCleanup.invalidate) await close();
descriptorCleanup.onClose?.();
throw new Error("injected SQLite descriptor close failure");
}
await close();
descriptorCleanup.onClose?.();
};
if (descriptorCleanup.readFailure !== undefined)
vi.spyOn(handle, "stat").mockRejectedValue(
@@ -90,6 +97,15 @@ const fixture = async (
(spawn) => {
if (member !== "reply")
throw new Error("SQLite worker deliberately unavailable");
if (descriptorCleanup.reply !== undefined)
return spawnOwnedProviderProcess({
...spawn,
arguments: [
"-e",
`const fs=require('node:fs');const request=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));fs.writeFileSync(request.reply_path,${JSON.stringify(descriptorCleanup.reply)});`,
spawn.arguments[2] ?? "",
],
});
return spawnOwnedProviderProcess({
...spawn,
arguments: spawn.arguments.map((argument, index) =>
@@ -119,6 +135,8 @@ const fixture = async (
descriptorCleanup.handles = [];
descriptorCleanup.readFailure = undefined;
descriptorCleanup.onOpen = undefined;
descriptorCleanup.onClose = undefined;
descriptorCleanup.reply = undefined;
onTestFinished(async () => {
descriptorCleanup.denied = false;
try {
@@ -128,6 +146,8 @@ const fixture = async (
}
for (const handle of descriptorCleanup.handles) await handle.close();
descriptorCleanup.path = "";
descriptorCleanup.onClose = undefined;
descriptorCleanup.reply = undefined;
database.close();
await removeTestWorkspace(workspace.root);
});
@@ -279,6 +299,7 @@ it("retains reply-read cancellation and retries the failed reply descriptor clos
if (result.ok) throw new Error("Expected cancelled reply read");
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toBeUndefined();
const cancelled = {
code: "cleanup_incomplete",
details: {
@@ -330,6 +351,135 @@ it("retains reply-read cancellation and retries the failed reply descriptor clos
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
});
it.each([
{ cancel: false, denied: false },
{ cancel: true, denied: false },
{ cancel: false, denied: true },
{ cancel: true, denied: true },
])(
"retains completed SQLite Evidence through reply close (cancel=$cancel, denied=$denied)",
async ({ cancel, denied }) => {
const selected = await fixture("reply");
descriptorCleanup.denied = denied;
const controller = new AbortController();
const reason = { requested: "cancel after complete SQLite reply read" };
if (cancel) descriptorCleanup.onClose = () => controller.abort(reason);
const input = {
path: selected.database.path,
table: "records",
columns: ["id"],
row_limit: 1,
worker_timeout_ms: 4321,
};
const databaseBytes = await readFile(input.path);
const walBytes = await readFile(selected.database.walPath);
const databaseDigest = createHash("sha256")
.update(databaseBytes)
.digest("hex");
const walDigest = createHash("sha256").update(walBytes).digest("hex");
const result = await new SqliteDatabaseService(selected.provider).inspect(
input,
{ signal: controller.signal },
);
expect(result.ok).toBe(!cancel && !denied);
const evidence = parseEvidence(
result.ok ? result.value : result.error.partialObservation,
);
expect(evidence).toMatchObject({
operation: "inspect_sqlite_database",
parameters: input,
subject: {
local_path: input.path,
format: "file",
digest: { sha256: databaseDigest },
},
normalized_result: {
artifact: {
path: input.path,
sha256: databaseDigest,
bytes: databaseBytes.length,
},
wal: {
path: selected.database.walPath,
sha256: walDigest,
bytes: walBytes.length,
},
worker_timeout_ms: input.worker_timeout_ms,
schema: { scope: "selected_table", completeness: "complete" },
rows: {
table: "records",
columns: ["id"],
values: [[{ type: "integer", value: "1" }]],
returned_rows: 1,
truncated: true,
},
},
});
if (!result.ok) {
if (cancel) {
expect(primaryAnalysisError(result.error)._tag).toBe(
"AnalysisCancelledError",
);
expect(primaryAnalysisError(result.error).cause).toBe(reason);
}
expect(projectAnalysisError(result.error)).toMatchObject({
details: { partial_observation: evidence },
});
expect(result.error.cleanupIncomplete).toBe(denied);
if (denied) {
const handle = descriptorCleanup.handles[0];
expect(handle?.fd).toBeGreaterThanOrEqual(0);
await expect(selected.provider.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
descriptorCleanup.denied = false;
await selected.provider.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect(result.error.partialObservation).toEqual(evidence);
}
}
await selected.provider.close();
expect(descriptorCleanup.handles.every((handle) => handle.fd === -1)).toBe(
true,
);
for (const root of selected.roots)
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it.each([
{ reply: "{ malformed reply", tag: "AnalysisOutputError" },
{
reply: JSON.stringify({
ok: false,
reason: "selection",
message: "Selected table does not exist",
}),
tag: "AnalysisInputError",
},
])(
"preserves a completed $tag reply alongside denied close without inventing SQLite Evidence",
async ({ reply, tag }) => {
const selected = await fixture("reply");
descriptorCleanup.reply = reply;
const result = await selected.provider.inspect({
path: selected.database.path,
});
if (result.ok) throw new Error("Expected rejected worker reply");
expect(primaryAnalysisError(result.error)._tag).toBe(tag);
expect(result.error.partialObservation).toBeUndefined();
expect(result.error.cleanupIncomplete).toBe(true);
descriptorCleanup.denied = false;
await selected.provider.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(primaryAnalysisError(result.error)._tag).toBe(tag);
expect(descriptorCleanup.handles.every((handle) => handle.fd === -1)).toBe(
true,
);
},
);
it("keeps a completed reply close failure primary when abort follows lease retention", async () => {
const selected = await fixture("reply");
const controller = new AbortController();
@@ -357,12 +507,9 @@ it("keeps a completed reply close failure primary when abort follows lease reten
expect(result.ok).toBe(false);
if (result.ok) return;
const primary =
result.error instanceof AnalysisOutputError
? result.error
: result.error.cleanupPrimary;
expect(primary).toBeInstanceOf(AnalysisOutputError);
if (!(primary instanceof AnalysisOutputError)) return;
const primary = primaryAnalysisError(result.error);
expect(primary).toBeInstanceOf(ProviderCleanupError);
if (!(primary instanceof ProviderCleanupError)) return;
expect(primary).not.toMatchObject({ _tag: "AnalysisCancelledError" });
expect(primary.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
expect(primary.cause).toMatchObject({
@@ -386,7 +533,7 @@ it("keeps a completed reply close failure primary when abort follows lease reten
expect(descriptorCleanup.attempts).toBe(2);
expect(handle.fd).toBe(-1);
expect(primary.cleanupIncomplete).toBe(false);
expect(primary).toBeInstanceOf(AnalysisOutputError);
expect(primary).toBeInstanceOf(ProviderCleanupError);
} finally {
retainSpy.mockRestore();
}