Repair packed agent startup and bound activation metadata retention
This commit is contained in:
@@ -36,33 +36,37 @@ export async function loadCheckedInstallationCandidate(
|
|||||||
if (!rootPattern.test(root) || !(await lstat(root)).isDirectory())
|
if (!rootPattern.test(root) || !(await lstat(root)).isDirectory())
|
||||||
throw Error("Candidate must be an immutable Nix output directory");
|
throw Error("Candidate must be an immutable Nix output directory");
|
||||||
const scope = (resources.budget ?? candidateMetadataBudget).acquire(resources.owner ?? root);
|
const scope = (resources.budget ?? candidateMetadataBudget).acquire(resources.owner ?? root);
|
||||||
let releaseValidation = () => {};
|
|
||||||
try {
|
try {
|
||||||
|
// Validate the duplicated source contract by digest, then discard its tree
|
||||||
|
// before allocating the substantially larger checked execution plan.
|
||||||
|
const workspaceDigest = await (async () => {
|
||||||
|
const input = await readJson(root, "candidate.json", scope);
|
||||||
|
try {
|
||||||
|
const { executionContracts: _legacyContracts, ...workspace } = input.value;
|
||||||
|
return hexDigest(workspace);
|
||||||
|
} finally {
|
||||||
|
input.releaseTree();
|
||||||
|
}
|
||||||
|
})();
|
||||||
// Sequential reads avoid partially rejected Promise.all() work allocating
|
// Sequential reads avoid partially rejected Promise.all() work allocating
|
||||||
// after the failed candidate's reservation has been released.
|
// after the failed candidate's reservation has been released.
|
||||||
const values = [];
|
const values = [];
|
||||||
for (const file of [
|
for (const file of [
|
||||||
"candidate.json",
|
|
||||||
"execution-plan.json",
|
"execution-plan.json",
|
||||||
"execution-artifacts.json",
|
"execution-artifacts.json",
|
||||||
"source.json",
|
"source.json",
|
||||||
"entry.json",
|
"entry.json",
|
||||||
"kernel.json",
|
"kernel.json",
|
||||||
"queries.json",
|
"queries.json",
|
||||||
"graph-execution.json",
|
|
||||||
]) {
|
]) {
|
||||||
const parsed = await readJson(root, file, scope);
|
const parsed = await readJson(root, file, scope);
|
||||||
values.push(parsed.value);
|
values.push(parsed.value);
|
||||||
// candidate.json duplicates the checked plan for validation. Nothing in
|
|
||||||
// the installed candidate retains this input tree after validation.
|
|
||||||
if (file === "candidate.json") releaseValidation = parsed.releaseTree;
|
|
||||||
}
|
}
|
||||||
const [candidate, plan, artifacts, source, entryRequest, kernel, queries, graph] = values;
|
const [plan, artifacts, source, entryRequest, kernel, queries] = values;
|
||||||
const { executionContracts: _legacyContracts, ...workspace } = candidate;
|
|
||||||
if (
|
if (
|
||||||
plan?.definition?.codec !== "camino-execution-plan-v1" ||
|
plan?.definition?.codec !== "camino-execution-plan-v1" ||
|
||||||
hexDigest(plan.definition) !== plan.digest ||
|
hexDigest(plan.definition) !== plan.digest ||
|
||||||
hexDigest(workspace) !== hexDigest(plan.definition.workspace)
|
workspaceDigest !== hexDigest(plan.definition.workspace)
|
||||||
)
|
)
|
||||||
throw new ExecutionPlanError(
|
throw new ExecutionPlanError(
|
||||||
"CANDIDATE_PLAN_MISMATCH",
|
"CANDIDATE_PLAN_MISMATCH",
|
||||||
@@ -113,14 +117,24 @@ export async function loadCheckedInstallationCandidate(
|
|||||||
"Stored interpreter input must match the checked build and plan",
|
"Stored interpreter input must match the checked build and plan",
|
||||||
);
|
);
|
||||||
const expectedGraph = graphExecutionRepresentation(plan, installation, kernel, queries);
|
const expectedGraph = graphExecutionRepresentation(plan, installation, kernel, queries);
|
||||||
if (hexDigest(graph) !== hexDigest(expectedGraph) || artifacts.execution !== hexDigest(graph))
|
// The stored graph embeds the same kernel/query programs. Validate it in a
|
||||||
throw new ExecutionPlanError(
|
// short-lived scope and retain the representation that shares those trees.
|
||||||
"CANDIDATE_EXECUTION_MISMATCH",
|
await (async () => {
|
||||||
root,
|
const input = await readJson(root, "graph-execution.json", scope);
|
||||||
"Stored graph execution differs from checked inputs",
|
try {
|
||||||
);
|
const digest = hexDigest(input.value);
|
||||||
|
if (digest !== hexDigest(expectedGraph) || artifacts.execution !== digest)
|
||||||
|
throw new ExecutionPlanError(
|
||||||
|
"CANDIDATE_EXECUTION_MISMATCH",
|
||||||
|
root,
|
||||||
|
"Stored graph execution differs from checked inputs",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
input.releaseTree();
|
||||||
|
}
|
||||||
|
})();
|
||||||
const provenance = {
|
const provenance = {
|
||||||
workspaceRevision: workspace.id as string,
|
workspaceRevision: plan.definition.workspace.id as string,
|
||||||
source: { repository: source.repository as string, commit: source.commit as string },
|
source: { repository: source.repository as string, commit: source.commit as string },
|
||||||
generator: artifacts.generator as string,
|
generator: artifacts.generator as string,
|
||||||
packages: (plan as ExecutionPlan).definition.packages.map((pkg) => ({
|
packages: (plan as ExecutionPlan).definition.packages.map((pkg) => ({
|
||||||
@@ -142,9 +156,9 @@ export async function loadCheckedInstallationCandidate(
|
|||||||
return {
|
return {
|
||||||
reference,
|
reference,
|
||||||
workspace: plan.definition.workspace.workspaceId,
|
workspace: plan.definition.workspace.workspaceId,
|
||||||
world: hexDigest(graph),
|
world: hexDigest(expectedGraph),
|
||||||
plan: kernel.plan as string,
|
plan: kernel.plan as string,
|
||||||
graphExecution: graph,
|
graphExecution: expectedGraph,
|
||||||
provenance,
|
provenance,
|
||||||
entry,
|
entry,
|
||||||
kernel,
|
kernel,
|
||||||
@@ -161,8 +175,6 @@ export async function loadCheckedInstallationCandidate(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
scope.release();
|
scope.release();
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
releaseValidation();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ export class CandidateMetadataBudget {
|
|||||||
private bytes = 0;
|
private bytes = 0;
|
||||||
private scopes = 0;
|
private scopes = 0;
|
||||||
private readonly owners = new Map<string, number>();
|
private readonly owners = new Map<string, number>();
|
||||||
constructor(limits = { bytes: 512 * 1024 * 1024, ownerBytes: 256 * 1024 * 1024, candidates: 128 }) {
|
// A cutover holds both the serving and proposed installation plus transient
|
||||||
|
// parsing space. The 57-repository reader retains ~133 MiB per installation;
|
||||||
|
// a 256 MiB owner ceiling cannot admit its ordinary atomic replacement.
|
||||||
|
// Keep the aggregate ceiling independent and leave room for parsing overlap.
|
||||||
|
constructor(limits = { bytes: 512 * 1024 * 1024, ownerBytes: 384 * 1024 * 1024, candidates: 128 }) {
|
||||||
for (const value of Object.values(limits))
|
for (const value of Object.values(limits))
|
||||||
if (!Number.isSafeInteger(value) || value < 1) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
if (!Number.isSafeInteger(value) || value < 1) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
||||||
if (limits.ownerBytes > limits.bytes) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
if (limits.ownerBytes > limits.bytes) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
||||||
|
|||||||
@@ -4,6 +4,62 @@ import { mkdtemp, writeFile, symlink, rm } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { CandidateMetadataBudget, readCandidateMetadata } from "../src/execution-world/metadata.js";
|
import { CandidateMetadataBudget, readCandidateMetadata } from "../src/execution-world/metadata.js";
|
||||||
|
import { loadCheckedInstallationCandidate } from "../src/execution-world/candidate.js";
|
||||||
|
|
||||||
|
test(
|
||||||
|
"a real checked application can stage its replacement within the default metadata budget",
|
||||||
|
{
|
||||||
|
skip: !process.env.QX_CHECKED_CANDIDATE,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const root = process.env.QX_CHECKED_CANDIDATE!;
|
||||||
|
const budget = new CandidateMetadataBudget();
|
||||||
|
const first = await loadCheckedInstallationCandidate(root, {}, { budget, owner: "reader" });
|
||||||
|
try {
|
||||||
|
const retained = budget.snapshot().bytes;
|
||||||
|
const second = await loadCheckedInstallationCandidate(root, {}, { budget, owner: "reader" });
|
||||||
|
try {
|
||||||
|
assert.equal(budget.snapshot().bytes, retained * 2);
|
||||||
|
assert.deepEqual(second.reference, first.reference, "Compaction preserves exact checked identity");
|
||||||
|
assert.equal(second.graphExecution.kernel, second.kernel, "Graph representation shares the validated program");
|
||||||
|
assert.equal(second.graphExecution.queries, second.queries);
|
||||||
|
// A deliberately tighter pool admits the serving installation but must
|
||||||
|
// reject replacement parsing without leaking a candidate or tree lease.
|
||||||
|
const tight = new CandidateMetadataBudget({
|
||||||
|
bytes: retained + 1,
|
||||||
|
ownerBytes: retained + 1,
|
||||||
|
candidates: 2,
|
||||||
|
});
|
||||||
|
const blocker = tight.acquire("reader");
|
||||||
|
blocker.charge(retained);
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
loadCheckedInstallationCandidate(root, {}, { budget: tight, owner: "reader" }),
|
||||||
|
/CANDIDATE_METADATA_LIMIT/,
|
||||||
|
);
|
||||||
|
assert.equal(tight.snapshot().bytes, retained);
|
||||||
|
assert.equal(tight.snapshot().candidates, 1);
|
||||||
|
} finally {
|
||||||
|
blocker.release();
|
||||||
|
}
|
||||||
|
const other = budget.acquire("other");
|
||||||
|
try {
|
||||||
|
assert.throws(() => other.charge(budget.limits.bytes), /CANDIDATE_METADATA_LIMIT/);
|
||||||
|
assert.equal(budget.snapshot().bytes, retained * 2, "Rejected admission must not leak charges");
|
||||||
|
} finally {
|
||||||
|
other.release();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
second.metadata.release();
|
||||||
|
}
|
||||||
|
assert.equal(budget.snapshot().bytes, retained, "The serving candidate survives failed or completed staging");
|
||||||
|
} finally {
|
||||||
|
first.metadata.release();
|
||||||
|
}
|
||||||
|
assert.equal(budget.snapshot().bytes, 0);
|
||||||
|
assert.equal(budget.snapshot().candidates, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("concurrent candidate reservations enforce aggregate and owner limits and release exactly once", () => {
|
test("concurrent candidate reservations enforce aggregate and owner limits and release exactly once", () => {
|
||||||
const budget = new CandidateMetadataBudget({ bytes: 1000, ownerBytes: 600, candidates: 3 });
|
const budget = new CandidateMetadataBudget({ bytes: 1000, ownerBytes: 600, candidates: 3 });
|
||||||
|
|||||||
Reference in New Issue
Block a user