mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(sqlite): preserve completed member facts on acquisition failure
This commit is contained in:
1 parent
e813c4c344
commit
669e617eec
4 files changed
+284
-102
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user