mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 03:15:54 +00:00
fix(evidence)!: report storage publication limits (#7887)
* fix(evidence): report storage publication limits * test(evidence): cover staged storage failure cleanup
This commit is contained in:
1 parent
539a494d25
commit
bf115be2cd
6 files changed
+187
-12
No files matched your search
@@ -411,16 +411,16 @@ const writeTextPartsOwned = async (
|
||||
}),
|
||||
cause.lease,
|
||||
);
|
||||
const failure = classifyWriteFailure(cause.outcome.error);
|
||||
return stagedCleanupFailure(
|
||||
new EvidenceFileError(
|
||||
"write",
|
||||
fileAccessFailureReason(cause.outcome.error),
|
||||
{
|
||||
cause,
|
||||
path: requestedPath,
|
||||
cleanup,
|
||||
},
|
||||
),
|
||||
new EvidenceFileError("write", failure.reason, {
|
||||
cause,
|
||||
path: requestedPath,
|
||||
cleanup,
|
||||
...(failure.systemCode === undefined
|
||||
? {}
|
||||
: { systemCode: failure.systemCode }),
|
||||
}),
|
||||
cause.lease,
|
||||
);
|
||||
}
|
||||
@@ -437,15 +437,32 @@ const writeTextPartsOwned = async (
|
||||
path: requestedPath,
|
||||
}),
|
||||
);
|
||||
const failure = classifyWriteFailure(cause);
|
||||
return err(
|
||||
new EvidenceFileError("write", fileAccessFailureReason(cause), {
|
||||
new EvidenceFileError("write", failure.reason, {
|
||||
cause,
|
||||
path: requestedPath,
|
||||
...(failure.systemCode === undefined
|
||||
? {}
|
||||
: { systemCode: failure.systemCode }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Recognize only host codes that establish a publication resource refusal. */
|
||||
const classifyWriteFailure = (
|
||||
cause: unknown,
|
||||
): {
|
||||
readonly reason: EvidenceFileError["reason"];
|
||||
readonly systemCode?: "EFBIG" | "ENOSPC" | "EDQUOT";
|
||||
} => {
|
||||
const code = fileErrorCode(cause);
|
||||
return code === "EFBIG" || code === "ENOSPC" || code === "EDQUOT"
|
||||
? { reason: "storage-limit", systemCode: code }
|
||||
: { reason: fileAccessFailureReason(cause) };
|
||||
};
|
||||
|
||||
/** The retained staging lease clears this error only when a retry confirms release. */
|
||||
const stagedCleanupFailure = <
|
||||
Failure extends EvidenceFileError | AnalysisCancelledError,
|
||||
|
||||
@@ -260,7 +260,10 @@ export const analysisErrorCategory = (
|
||||
return "unavailable";
|
||||
if (error instanceof ArtifactOperationError)
|
||||
return artifactErrorCategory(error.reason);
|
||||
if (error instanceof EvidenceFileError && error.reason === "path-limit")
|
||||
if (
|
||||
error instanceof EvidenceFileError &&
|
||||
(error.reason === "path-limit" || error.reason === "storage-limit")
|
||||
)
|
||||
return "resource_constraint";
|
||||
if (
|
||||
error instanceof EvidenceFileError &&
|
||||
@@ -565,11 +568,18 @@ const evidenceFileMessage = ({
|
||||
operation,
|
||||
reason,
|
||||
published,
|
||||
systemCode,
|
||||
}: EvidenceFileError): string => {
|
||||
if (published !== undefined)
|
||||
return "The output file was published, but staging cleanup remains incomplete.";
|
||||
if (reason === "path-limit")
|
||||
return "Filesystem path-length constraints prevented reading or atomic publication at the selected path. Choose a shorter local path.";
|
||||
if (reason === "storage-limit")
|
||||
return systemCode === "EFBIG"
|
||||
? "The host file-size limit prevented Evidence publication. Increase the inherited file-size allowance and retry."
|
||||
: systemCode === undefined
|
||||
? "A host filesystem resource limit prevented Evidence publication. Check the file-size allowance, filesystem space and quota before retrying."
|
||||
: "The filesystem lacks space or quota for Evidence publication. Free space or increase quota and retry.";
|
||||
if (reason === "symlink-traversal")
|
||||
return "The selected path cannot be resolved because its symbolic-link chain loops or exceeds the host traversal limit. Repair the chain or select another path.";
|
||||
if (reason === "missing")
|
||||
|
||||
@@ -236,7 +236,8 @@ const evidenceFileCode = (
|
||||
reason: EvidenceFileError["reason"],
|
||||
): AnalysisErrorProjection["code"] => {
|
||||
if (reason === "invalid-json") return "evidence_integrity_mismatch";
|
||||
if (reason === "path-limit") return "resource_constraint";
|
||||
if (reason === "path-limit" || reason === "storage-limit")
|
||||
return "resource_constraint";
|
||||
// A missing, non-file or already-existing path is the caller's selection.
|
||||
if (reason === "io") return "execution_failure";
|
||||
return "invalid_request";
|
||||
@@ -418,6 +419,9 @@ const artifactStateErrorDetails = (
|
||||
operation: error.operation,
|
||||
reason: error.reason,
|
||||
...(error.reason === "path-limit" ? { boundary: "filesystem-path" } : {}),
|
||||
...(error.systemCode === undefined
|
||||
? {}
|
||||
: { system_code: error.systemCode }),
|
||||
...(error.path === undefined ? {} : { path: error.path }),
|
||||
...(error.contentIssues === undefined
|
||||
? {}
|
||||
|
||||
@@ -60,6 +60,7 @@ export class EvidenceFileError extends AnalysisError {
|
||||
readonly published: PublishedEvidenceFileObservation | undefined;
|
||||
/** Schema issues relative to the selected file's JSON root, never the request envelope. */
|
||||
readonly contentIssues: readonly AnalysisInputIssue[] | undefined;
|
||||
readonly systemCode: "EFBIG" | "ENOSPC" | "EDQUOT" | undefined;
|
||||
|
||||
constructor(
|
||||
readonly operation: "read" | "write",
|
||||
@@ -70,6 +71,7 @@ export class EvidenceFileError extends AnalysisError {
|
||||
| "invalid-json"
|
||||
| "missing"
|
||||
| "path-limit"
|
||||
| "storage-limit"
|
||||
| "symlink-traversal"
|
||||
| "io",
|
||||
options?: AnalysisErrorOptions & {
|
||||
@@ -77,6 +79,7 @@ export class EvidenceFileError extends AnalysisError {
|
||||
readonly published?: PublishedEvidenceFileObservation;
|
||||
readonly userMessage?: string;
|
||||
readonly contentIssues?: readonly AnalysisInputIssue[];
|
||||
readonly systemCode?: "EFBIG" | "ENOSPC" | "EDQUOT";
|
||||
},
|
||||
) {
|
||||
super(`Evidence bundle ${operation} failed: ${reason}`, options);
|
||||
@@ -84,5 +87,6 @@ export class EvidenceFileError extends AnalysisError {
|
||||
this.published = options?.published;
|
||||
this.userMessage = options?.userMessage;
|
||||
this.contentIssues = options?.contentIssues;
|
||||
this.systemCode = options?.systemCode;
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,10 @@ const redactFailure = (
|
||||
const publishedPath = identity(error.published?.path);
|
||||
return new EvidenceFileError(error.operation, error.reason, {
|
||||
...context,
|
||||
...(error.systemCode === undefined ||
|
||||
text(error.systemCode) !== error.systemCode
|
||||
? {}
|
||||
: { systemCode: error.systemCode }),
|
||||
...(retainedPath === undefined ? {} : { path: retainedPath }),
|
||||
...(error.userMessage === undefined
|
||||
? {}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const failure = vi.hoisted(() => ({ code: "" }));
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const filesystem = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...filesystem,
|
||||
mkdtemp: async (...args: Parameters<typeof filesystem.mkdtemp>) => {
|
||||
if (failure.code !== "")
|
||||
throw Object.assign(new Error("controlled staging refusal"), {
|
||||
code: failure.code,
|
||||
});
|
||||
return filesystem.mkdtemp(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { writeTextParts } from "../../../src/application/JsonFiles.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { redactExplicitFailure } from "../../../src/domain/explicitSensitiveFailure.js";
|
||||
|
||||
it.each([
|
||||
["EFBIG", "file-size allowance"],
|
||||
["ENOSPC", "space or quota"],
|
||||
["EDQUOT", "space or quota"],
|
||||
] as const)(
|
||||
"preserves the %s host resource refusal through redaction",
|
||||
async (code, guidance) => {
|
||||
const root = await createTestTempDirectory("rea-write-resource-");
|
||||
const resources = new ArtifactResourceScope();
|
||||
onTestFinished(() => resources.close());
|
||||
failure.code = code;
|
||||
try {
|
||||
const result = await writeTextParts(
|
||||
["bytes"],
|
||||
join(root, "out.json"),
|
||||
false,
|
||||
resources,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
const projected = projectAnalysisError(
|
||||
redactExplicitFailure(result.error, ["unrelated-secret"]),
|
||||
);
|
||||
expect(projected).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
category: "resource_constraint",
|
||||
retryable: false,
|
||||
details: {
|
||||
operation: "write",
|
||||
reason: "storage-limit",
|
||||
system_code: code,
|
||||
path: join(root, "out.json"),
|
||||
},
|
||||
});
|
||||
expect(projected.remediation.action).toContain(guidance);
|
||||
const excluded = projectAnalysisError(
|
||||
redactExplicitFailure(result.error, [code]),
|
||||
);
|
||||
expect(excluded.details).not.toHaveProperty("system_code");
|
||||
expect(excluded.remediation.action).toContain("file-size allowance");
|
||||
}
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
} finally {
|
||||
failure.code = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Fail after the first staged part has been written, before publication. */
|
||||
function* limitedParts(): Generator<string> {
|
||||
yield "staged bytes";
|
||||
throw Object.assign(new Error("file size limit"), { code: "EFBIG" });
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
"retains a staged EFBIG and cleans or retries its owner (cleanup fails: %s)",
|
||||
async (cleanupFails) => {
|
||||
const root = await createTestTempDirectory("rea-staged-resource-");
|
||||
const output = join(root, "out.json");
|
||||
const resources = new ArtifactResourceScope();
|
||||
const { OwnedStagedOutput } =
|
||||
await import("../../../src/filesystem/OwnedStagedOutput.js");
|
||||
const close = vi.spyOn(OwnedStagedOutput.prototype, "close");
|
||||
if (cleanupFails)
|
||||
close.mockRejectedValueOnce(new Error("controlled cleanup refusal"));
|
||||
try {
|
||||
const result = await writeTextParts(
|
||||
limitedParts(),
|
||||
output,
|
||||
false,
|
||||
resources,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected resource refusal");
|
||||
const redacted = redactExplicitFailure(result.error, [
|
||||
"unrelated-secret",
|
||||
]);
|
||||
const projected = projectAnalysisError(redacted);
|
||||
expect(projected).toMatchObject({
|
||||
code: cleanupFails ? "cleanup_incomplete" : "resource_constraint",
|
||||
details: {
|
||||
reason: "storage-limit",
|
||||
system_code: "EFBIG",
|
||||
path: output,
|
||||
},
|
||||
});
|
||||
const staging = (await readdir(root)).filter((name) =>
|
||||
name.startsWith(".rea-write-"),
|
||||
);
|
||||
expect(staging).toHaveLength(cleanupFails ? 1 : 0);
|
||||
expect((await readdir(root)).includes("out.json")).toBe(false);
|
||||
if (cleanupFails) {
|
||||
expect(resources.hasPending).toBe(true);
|
||||
expect(redacted.cleanupResources).toContain(join(root, staging[0]!));
|
||||
await resources.retryCleanup();
|
||||
expect(resources.hasPending).toBe(false);
|
||||
expect(redacted.cleanupIncomplete).toBe(false);
|
||||
expect(projectAnalysisError(redacted)).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
reason: "storage-limit",
|
||||
system_code: "EFBIG",
|
||||
path: output,
|
||||
},
|
||||
});
|
||||
expect(await readdir(root)).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
close.mockRestore();
|
||||
await resources.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user