2026-10-07 14:44:59 -07:00
|
|
|
import { constants } from "node:fs";
|
|
|
|
|
import { open } from "node:fs/promises";
|
|
|
|
|
|
|
|
|
|
/** Admission estimates, not a hard V8 heap limit. A process/cgroup ceiling is
|
|
|
|
|
* still required. Charge input, parse trees and derived indexes before reading
|
|
|
|
|
* or parsing. Each live candidate retains its reservation until explicit close. */
|
|
|
|
|
export class CandidateMetadataBudget {
|
|
|
|
|
readonly limits: Readonly<{ bytes: number; ownerBytes: number; candidates: number }>;
|
|
|
|
|
private bytes = 0;
|
|
|
|
|
private scopes = 0;
|
|
|
|
|
private readonly owners = new Map<string, number>();
|
2026-10-08 11:11:28 -07:00
|
|
|
// A cutover holds both the serving and proposed installation plus transient
|
2026-10-09 20:57:55 -07:00
|
|
|
// parsing space. The Reader with checked Query Trees retains ~176 MiB per
|
|
|
|
|
// installation; its replacement also needs transient parse/index space.
|
2026-10-08 11:11:28 -07:00
|
|
|
// Keep the aggregate ceiling independent and leave room for parsing overlap.
|
2026-10-09 20:57:55 -07:00
|
|
|
constructor(limits = { bytes: 768 * 1024 * 1024, ownerBytes: 512 * 1024 * 1024, candidates: 128 }) {
|
2026-10-07 14:44:59 -07:00
|
|
|
for (const value of Object.values(limits))
|
|
|
|
|
if (!Number.isSafeInteger(value) || value < 1) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
|
|
|
|
if (limits.ownerBytes > limits.bytes) throw Error("INVALID_CANDIDATE_METADATA_BUDGET");
|
|
|
|
|
this.limits = Object.freeze({ ...limits });
|
|
|
|
|
}
|
|
|
|
|
snapshot() {
|
|
|
|
|
return { bytes: this.bytes, candidates: this.scopes, owners: this.owners.size, limits: this.limits };
|
|
|
|
|
}
|
2026-10-12 14:09:34 -07:00
|
|
|
ownedBytes(owner: string) {
|
|
|
|
|
return this.owners.get(owner) ?? 0;
|
|
|
|
|
}
|
2026-10-07 14:44:59 -07:00
|
|
|
acquire(owner: string) {
|
|
|
|
|
if (!owner || owner.length > 4096) throw Error("INVALID_CANDIDATE_METADATA_OWNER");
|
|
|
|
|
if (this.scopes >= this.limits.candidates) throw Error("CANDIDATE_METADATA_LIMIT");
|
|
|
|
|
this.scopes++;
|
|
|
|
|
let bytes = 0,
|
|
|
|
|
closed = false;
|
2026-10-07 16:14:15 -07:00
|
|
|
const charge = (amount: number) => {
|
|
|
|
|
if (closed) throw Error("CANDIDATE_METADATA_CLOSED");
|
|
|
|
|
if (!Number.isSafeInteger(amount) || amount < 0) throw Error("INVALID_CANDIDATE_METADATA_CHARGE");
|
|
|
|
|
const owned = this.owners.get(owner) ?? 0;
|
|
|
|
|
if (this.bytes + amount > this.limits.bytes || owned + amount > this.limits.ownerBytes)
|
|
|
|
|
throw Error("CANDIDATE_METADATA_LIMIT");
|
|
|
|
|
bytes += amount;
|
|
|
|
|
this.bytes += amount;
|
|
|
|
|
this.owners.set(owner, owned + amount);
|
|
|
|
|
};
|
|
|
|
|
const subtract = (amount: number) => {
|
|
|
|
|
bytes -= amount;
|
|
|
|
|
this.bytes -= amount;
|
|
|
|
|
const remaining = (this.owners.get(owner) ?? 0) - amount;
|
|
|
|
|
if (remaining) this.owners.set(owner, remaining);
|
|
|
|
|
else this.owners.delete(owner);
|
|
|
|
|
};
|
2026-10-07 14:44:59 -07:00
|
|
|
return {
|
|
|
|
|
get bytes() {
|
|
|
|
|
return bytes;
|
|
|
|
|
},
|
2026-10-07 16:14:15 -07:00
|
|
|
charge,
|
|
|
|
|
/** Opaque, idempotent transient reservation; callers cannot subtract an
|
|
|
|
|
* arbitrary amount or release another candidate's charges. */
|
|
|
|
|
reserve: (amount: number) => {
|
|
|
|
|
charge(amount);
|
|
|
|
|
let released = false;
|
|
|
|
|
return () => {
|
|
|
|
|
if (closed || released) return;
|
|
|
|
|
released = true;
|
|
|
|
|
subtract(amount);
|
|
|
|
|
};
|
2026-10-07 14:44:59 -07:00
|
|
|
},
|
|
|
|
|
release: () => {
|
|
|
|
|
if (closed) return;
|
|
|
|
|
closed = true;
|
2026-10-07 16:14:15 -07:00
|
|
|
subtract(bytes);
|
2026-10-07 14:44:59 -07:00
|
|
|
this.scopes--;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
export type CandidateMetadataScope = ReturnType<CandidateMetadataBudget["acquire"]>;
|
|
|
|
|
export const candidateMetadataBudget = new CandidateMetadataBudget();
|
|
|
|
|
|
2026-10-07 16:14:15 -07:00
|
|
|
// Estimate JSON allocation without constructing its tree. Object/array headers,
|
|
|
|
|
// value slots and strings get explicit weights, with a multiplier for checked
|
|
|
|
|
// indexes/derived representations. Indentation must not multiply tree charges.
|
|
|
|
|
function jsonAllocation(bytes: Uint8Array): number {
|
|
|
|
|
let estimate = 0;
|
|
|
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
|
|
|
const c = bytes[i]!;
|
|
|
|
|
if (c === 123) estimate += 64;
|
|
|
|
|
else if (c === 91) estimate += 32;
|
|
|
|
|
else if (c === 34) {
|
|
|
|
|
const start = ++i;
|
|
|
|
|
while (i < bytes.length && bytes[i] !== 34) {
|
|
|
|
|
if (bytes[i] === 92) i++;
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
estimate += 32 + 2 * (i - start);
|
|
|
|
|
} else if (c === 45 || (c >= 48 && c <= 57) || c === 116 || c === 102 || c === 110) {
|
|
|
|
|
estimate += 16;
|
|
|
|
|
while (i + 1 < bytes.length) {
|
|
|
|
|
const next = bytes[i + 1]!;
|
|
|
|
|
if (next === 9 || next === 10 || next === 13 || next === 32 || next === 44 || next === 93 || next === 125)
|
|
|
|
|
break;
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return estimate * 2;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 14:44:59 -07:00
|
|
|
/** Immutable regular files only. Fixed allocation plus an extra byte catches
|
|
|
|
|
* growth; readFile() would allocate before the admission check can intervene. */
|
|
|
|
|
export async function readCandidateMetadata(
|
|
|
|
|
path: string,
|
|
|
|
|
scope: CandidateMetadataScope,
|
|
|
|
|
limit: number,
|
2026-10-07 16:52:20 -07:00
|
|
|
): Promise<{ bytes: Uint8Array; releaseInput: () => void; releaseTree: () => void }> {
|
2026-10-07 14:44:59 -07:00
|
|
|
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
2026-10-07 16:14:15 -07:00
|
|
|
let releaseInput: (() => void) | undefined;
|
2026-10-07 14:44:59 -07:00
|
|
|
try {
|
|
|
|
|
const stat = await handle.stat();
|
|
|
|
|
if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size > limit)
|
|
|
|
|
throw Error("CANDIDATE_METADATA_FILE_LIMIT");
|
2026-10-07 16:14:15 -07:00
|
|
|
// Reserve the input buffer and UTF-16 decoding before allocating either.
|
|
|
|
|
releaseInput = scope.reserve((stat.size + 1) * 3);
|
2026-10-07 14:44:59 -07:00
|
|
|
const bytes = Buffer.alloc(stat.size + 1);
|
|
|
|
|
let offset = 0;
|
|
|
|
|
while (offset < bytes.length) {
|
|
|
|
|
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
|
|
|
if (!read.bytesRead) break;
|
|
|
|
|
offset += read.bytesRead;
|
|
|
|
|
}
|
|
|
|
|
if (offset !== stat.size) throw Error("CANDIDATE_METADATA_CHANGED");
|
2026-10-07 16:14:15 -07:00
|
|
|
const content = bytes.subarray(0, offset);
|
|
|
|
|
// No JSON.parse/decoded tree exists until its shape-based charge succeeds.
|
2026-10-07 16:52:20 -07:00
|
|
|
const releaseTree = scope.reserve(jsonAllocation(content));
|
|
|
|
|
return { bytes: content, releaseInput, releaseTree };
|
2026-10-07 16:14:15 -07:00
|
|
|
} catch (error) {
|
|
|
|
|
releaseInput?.();
|
|
|
|
|
throw error;
|
2026-10-07 14:44:59 -07:00
|
|
|
} finally {
|
|
|
|
|
await handle.close();
|
|
|
|
|
}
|
|
|
|
|
}
|