fix(sqlite): preserve completed member facts on acquisition failure

This commit is contained in:
morluto committed 2026-10-27 02:02:27 +08:00
1 parent e813c4c344
commit 669e617eec
4 files changed
+284 -102

No files matched your search

+22
View File
@@ -10,6 +10,7 @@ import {
} from "../domain/analysisErrorProjection.js";
import {
AnalysisError,
primaryAnalysisError,
type AnalysisCapturedOutput,
type AnalysisErrorOptions,
} from "../domain/analysisErrorBase.js";
@@ -34,6 +35,7 @@ import {
MAX_SQLITE_WORKER_TIMEOUT_MS,
} from "../domain/sqlite/sqliteDatabase.js";
import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js";
import { SqliteSnapshotAcquisitionFailure } from "./SqliteDatabaseSnapshot.js";
/** Preserve diagnostics independently of worker exit and private-root cleanup. */
export const capturedSqliteOutput = (
@@ -91,6 +93,26 @@ export const sqliteDatabaseFailure = (
workerTimeoutMs = DEFAULT_SQLITE_WORKER_TIMEOUT_MS,
): AnalysisError => {
const operation = "inspect_sqlite_database";
if (cause instanceof SqliteSnapshotAcquisitionFailure) {
const failure = sqliteDatabaseFailure(
cause.operationCause,
path,
phase,
signal,
workerTimeoutMs,
);
const diagnostics = {
snapshot_acquisition: {
consistency: "unverified",
members: cause.completedMembers.map((member) => ({ ...member })),
},
};
failure.retainDiagnostics(diagnostics);
// Keep the facts on the semantic error after its cleanup envelope resolves.
const primary = primaryAnalysisError(failure);
if (primary !== failure) primary.retainDiagnostics(diagnostics);
return failure;
}
if (cause instanceof AnalysisError) return cause;
if (cause instanceof ArtifactReaderFailure) {
if (cause.cleanup !== undefined) {
+163 -98
View File
@@ -14,7 +14,9 @@ import {
assertArtifactReadActive,
} from "../artifacts/ArtifactFileOperation.js";
import {
completedStableArtifact,
readStableArtifact,
type StableArtifactSnapshot,
type StableArtifactFileSystem,
} from "../artifacts/readStableArtifact.js";
import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js";
@@ -24,6 +26,34 @@ const fileSystem: StableArtifactFileSystem = {
open,
};
type SnapshotMemberRole = "database" | "wal";
type CompletedSnapshotMember = {
readonly role: SnapshotMemberRole;
readonly path: string;
readonly sha256: string;
readonly bytes: number;
};
/** Individual source reads do not establish that the whole file set is consistent. */
export class SqliteSnapshotAcquisitionFailure extends Error {
readonly _tag = "SqliteSnapshotAcquisitionFailure";
readonly completedMembers: readonly CompletedSnapshotMember[];
constructor(
readonly operationCause: unknown,
members: readonly CompletedSnapshotMember[],
) {
super(
operationCause instanceof Error
? operationCause.message
: "SQLite source acquisition failed after completed member reads",
{ cause: operationCause },
);
this.name = this._tag;
this.completedMembers = members.map((member) => ({ ...member }));
}
}
/** Probe a generated sibling only after the caller-selected database is observed. */
const optionalSidecarStat = async (
path: string,
@@ -248,112 +278,147 @@ export const captureSqliteDatabaseSnapshot = async (
signal?: AbortSignal,
selectedFileSystem: StableArtifactFileSystem = fileSystem,
) => {
assertArtifactReadActive(signal);
const walPath = `${path}-wal`;
const journalPath = `${path}-journal`;
const paths = [path, walPath, journalPath] as const;
const database = await optionalStat(path, selectedFileSystem);
if (database === null) throw missingDatabase(path);
const wal = await optionalSidecarStat(
walPath,
path,
database,
"WAL",
selectedFileSystem,
);
const journal = await optionalSidecarStat(
journalPath,
path,
database,
"rollback journal",
selectedFileSystem,
);
const before = [database, wal, journal] as const;
const selectedSize = database.size + (wal?.size ?? 0n);
if (selectedSize > BigInt(SQLITE_DATABASE_LIMITS.inputBytes))
throw new AnalysisResourceConstraintError(
"inspect_sqlite_database",
"file-size",
`Selected SQLite database and WAL total ${selectedSize.toString()} bytes, exceeding the combined ${String(SQLITE_DATABASE_LIMITS.inputBytes)}-byte offline snapshot budget: ${path}`,
{
sqlite_snapshot_input_bytes: SQLITE_DATABASE_LIMITS.inputBytes,
observed_sqlite_snapshot_input_bytes: selectedSize.toString(),
},
{
remediationAction: `Use the application's supported export workflow to create a consistent, smaller offline SQLite snapshot containing only the relevant data, then inspect that snapshot. REA will not truncate or modify the selected source. The combined input allowance is ${String(SQLITE_DATABASE_LIMITS.inputBytes)} bytes. This release has no runtime override. worker_timeout_ms does not raise it.`,
},
);
if (journal !== null)
await verifyInactiveJournal(
`${path}-journal`,
journal,
selectedFileSystem,
resourceScope,
"initial",
signal,
);
const snapshotPath = join(root, "database.snapshot");
const captured = await readObservedArtifact(
path,
database,
SQLITE_DATABASE_LIMITS.inputBytes,
selectedFileSystem,
resourceScope,
signal,
);
await writeFile(snapshotPath, captured.bytes, {
flag: "wx",
mode: 0o600,
...(signal === undefined ? {} : { signal }),
});
const artifact = {
path,
sha256: captured.sha256,
bytes: captured.bytes.length,
const completedMembers: CompletedSnapshotMember[] = [];
const recordMember = (
role: SnapshotMemberRole,
snapshot: StableArtifactSnapshot,
) => {
completedMembers.push({
role,
path: role === "database" ? path : walPath,
sha256: snapshot.sha256,
bytes: snapshot.bytes.length,
});
return snapshot;
};
let walArtifact: typeof artifact | null = null;
if (wal !== null) {
const capturedWal = await readObservedArtifact(
`${path}-wal`,
wal,
SQLITE_DATABASE_LIMITS.inputBytes - artifact.bytes,
const readMember = async (
role: SnapshotMemberRole,
observed: BigIntStats,
maximumBytes: number,
) => {
try {
return recordMember(
role,
await readObservedArtifact(
role === "database" ? path : walPath,
observed,
maximumBytes,
selectedFileSystem,
resourceScope,
signal,
),
);
} catch (cause: unknown) {
const completed = completedStableArtifact(cause);
if (completed !== undefined) recordMember(role, completed);
throw cause;
}
};
try {
assertArtifactReadActive(signal);
const journalPath = `${path}-journal`;
const paths = [path, walPath, journalPath] as const;
const database = await optionalStat(path, selectedFileSystem);
if (database === null) throw missingDatabase(path);
const wal = await optionalSidecarStat(
walPath,
path,
database,
"WAL",
selectedFileSystem,
resourceScope,
signal,
);
await writeFile(`${snapshotPath}-wal`, capturedWal.bytes, {
const journal = await optionalSidecarStat(
journalPath,
path,
database,
"rollback journal",
selectedFileSystem,
);
const before = [database, wal, journal] as const;
const selectedSize = database.size + (wal?.size ?? 0n);
if (selectedSize > BigInt(SQLITE_DATABASE_LIMITS.inputBytes))
throw new AnalysisResourceConstraintError(
"inspect_sqlite_database",
"file-size",
`Selected SQLite database and WAL total ${selectedSize.toString()} bytes, exceeding the combined ${String(SQLITE_DATABASE_LIMITS.inputBytes)}-byte offline snapshot budget: ${path}`,
{
sqlite_snapshot_input_bytes: SQLITE_DATABASE_LIMITS.inputBytes,
observed_sqlite_snapshot_input_bytes: selectedSize.toString(),
},
{
remediationAction: `Use the application's supported export workflow to create a consistent, smaller offline SQLite snapshot containing only the relevant data, then inspect that snapshot. REA will not truncate or modify the selected source. The combined input allowance is ${String(SQLITE_DATABASE_LIMITS.inputBytes)} bytes. This release has no runtime override. worker_timeout_ms does not raise it.`,
},
);
if (journal !== null)
await verifyInactiveJournal(
`${path}-journal`,
journal,
selectedFileSystem,
resourceScope,
"initial",
signal,
);
const snapshotPath = join(root, "database.snapshot");
const captured = await readMember(
"database",
database,
SQLITE_DATABASE_LIMITS.inputBytes,
);
await writeFile(snapshotPath, captured.bytes, {
flag: "wx",
mode: 0o600,
...(signal === undefined ? {} : { signal }),
});
walArtifact = {
path: `${path}-wal`,
sha256: capturedWal.sha256,
bytes: capturedWal.bytes.length,
const artifact = {
path,
sha256: captured.sha256,
bytes: captured.bytes.length,
};
}
// Identity alone does not establish an inactive journal when timestamps are
// coarse. Re-read the header after copying, using the same validity owner.
if (journal !== null)
await verifyInactiveJournal(
`${path}-journal`,
journal,
selectedFileSystem,
resourceScope,
"after-copy",
signal,
);
const after = await Promise.all(
paths.map((path) =>
duringAcquisition(path, () => optionalStat(path, selectedFileSystem)),
),
);
for (const [index, expected] of before.entries())
if (!sameState(expected, after[index] ?? null))
throw new ArtifactReaderFailure(
"integrity",
`SQLite snapshot file set changed during acquisition: ${paths[index] ?? path}`,
let walArtifact: typeof artifact | null = null;
if (wal !== null) {
const capturedWal = await readMember(
"wal",
wal,
SQLITE_DATABASE_LIMITS.inputBytes - artifact.bytes,
);
assertArtifactReadActive(signal);
return { snapshotPath, artifact, wal: walArtifact };
await writeFile(`${snapshotPath}-wal`, capturedWal.bytes, {
flag: "wx",
mode: 0o600,
...(signal === undefined ? {} : { signal }),
});
walArtifact = {
path: `${path}-wal`,
sha256: capturedWal.sha256,
bytes: capturedWal.bytes.length,
};
}
// Identity alone does not establish an inactive journal when timestamps are
// coarse. Re-read the header after copying, using the same validity owner.
if (journal !== null)
await verifyInactiveJournal(
`${path}-journal`,
journal,
selectedFileSystem,
resourceScope,
"after-copy",
signal,
);
const after = await Promise.all(
paths.map((path) =>
duringAcquisition(path, () => optionalStat(path, selectedFileSystem)),
),
);
for (const [index, expected] of before.entries())
if (!sameState(expected, after[index] ?? null))
throw new ArtifactReaderFailure(
"integrity",
`SQLite snapshot file set changed during acquisition: ${paths[index] ?? path}`,
);
assertArtifactReadActive(signal);
return { snapshotPath, artifact, wal: walArtifact };
} catch (cause: unknown) {
if (completedMembers.length === 0) throw cause;
throw new SqliteSnapshotAcquisitionFailure(cause, completedMembers);
}
};
@@ -183,6 +183,16 @@ const serveInspection = async (
return { output: JSON.parse(stdout) as unknown, exitCode: process.exitCode };
};
const expectedMember = async (role: string, path: string) => {
const bytes = await readFile(path);
return {
role,
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
};
};
it.each(["database", "WAL", "journal"] as const)(
"retains a failed %s descriptor and retries it at shutdown",
async (member) => {
@@ -190,6 +200,7 @@ it.each(["database", "WAL", "journal"] as const)(
const first = await selected.provider.inspect({
path: selected.database.path,
});
if (first.ok) throw new Error("Expected incomplete source cleanup");
expect(first).toMatchObject({
ok: false,
error: {
@@ -197,6 +208,29 @@ it.each(["database", "WAL", "journal"] as const)(
cleanupResources: [expect.stringContaining(selected.path)],
},
});
const sources =
member === "journal"
? []
: [
{ role: "database", path: selected.database.path },
...(member === "WAL"
? [{ role: "wal", path: selected.database.walPath }]
: []),
];
const members = await Promise.all(
sources.map((source) => expectedMember(source.role, source.path)),
);
if (members.length > 0)
expect(
projectAnalysisError(first.error).details?.diagnostics,
).toMatchObject({
snapshot_acquisition: { consistency: "unverified", members },
});
else
expect(
projectAnalysisError(first.error).details?.diagnostics,
).not.toHaveProperty("snapshot_acquisition");
expect(first.error.partialObservation).toBeUndefined();
const handle = descriptorCleanup.handles[0];
if (handle === undefined)
throw new Error("Expected owned source descriptor");
@@ -217,11 +251,46 @@ it.each(["database", "WAL", "journal"] as const)(
await selected.provider.close();
expect(descriptorCleanup.attempts).toBe(4);
expect(handle.fd).toBe(-1);
if (members.length > 0)
expect(
projectAnalysisError(first.error).details?.diagnostics,
).toMatchObject({
snapshot_acquisition: { consistency: "unverified", members },
});
for (const root of selected.roots)
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it("delivers individually completed database and WAL facts through CLI cleanup failure", async () => {
const selected = await fixture("WAL");
const members = await Promise.all([
expectedMember("database", selected.database.path),
expectedMember("wal", selected.database.walPath),
]);
const result = await serveInspection(
selected.provider,
selected.database.path,
);
expect(result).toMatchObject({
exitCode: 1,
output: {
code: "cleanup_incomplete",
details: {
diagnostics: {
primary_error: {
details: {
diagnostics: {
snapshot_acquisition: { consistency: "unverified", members },
},
},
},
},
},
},
});
});
it.each(["EACCES", "EPERM"])(
"retains the original %s inspection diagnostics through CLI shutdown",
async (code) => {
@@ -604,6 +673,10 @@ it.each(["EACCES", "EPERM"])(
diagnostics: { previous_error: { code: "access_denied" } },
},
});
if (result.ok) throw new Error("Expected physical source denial");
expect(
projectAnalysisError(result.error).details?.diagnostics,
).not.toHaveProperty("snapshot_acquisition");
},
);
@@ -636,6 +709,15 @@ it.each(["database", "WAL", "journal"] as const)(
_tag: "AnalysisCancelledError",
});
expect(result.error.cleanupPrimary?.cause).toBe(reason);
if (member === "WAL")
expect(
projectAnalysisError(result.error).details?.diagnostics,
).toMatchObject({
snapshot_acquisition: {
consistency: "unverified",
members: [await expectedMember("database", selected.database.path)],
},
});
for (const root of selected.roots)
await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" });
},
@@ -9,6 +9,7 @@ import {
writeFile,
} from "node:fs/promises";
import { DatabaseSync } from "node:sqlite";
import { createHash } from "node:crypto";
import { expect, it, onTestFinished, vi } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import type { StableArtifactFileSystem } from "../../../src/artifacts/readStableArtifact.js";
@@ -227,7 +228,8 @@ it.each(["activate", "replace", "remove"])(
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
_tag: "SqliteSnapshotAcquisitionFailure",
operationCause: { reason: "integrity" },
});
},
);
@@ -252,7 +254,8 @@ it("refuses an activated journal even when filesystem metadata cannot distinguis
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
_tag: "SqliteSnapshotAcquisitionFailure",
operationCause: { reason: "integrity" },
});
});
@@ -276,7 +279,16 @@ it("rejects a database changed after its copy but before WAL acquisition", async
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
_tag: "SqliteSnapshotAcquisitionFailure",
operationCause: { reason: "integrity" },
completedMembers: expect.arrayContaining([
{
role: "database",
path: database.path,
sha256: createHash("sha256").update(initial).digest("hex"),
bytes: initial.length,
},
]),
});
expect(changed).toBe(true);
expect(
@@ -303,7 +315,8 @@ it.each(["-wal", "-journal"] as const)(
},
};
await expect(capture(fileSystem)).rejects.toMatchObject({
reason: "integrity",
_tag: "SqliteSnapshotAcquisitionFailure",
operationCause: { reason: "integrity" },
});
expect(created).toBe(true);
},