mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Add scheduled reachability GC for Piledriver via historical roots
Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
parent
afa2d74ba0
commit
9ddadcad5a
@ -8,6 +8,7 @@
|
||||
"start": "tsx --expose-gc src/index.ts",
|
||||
"dev": "NODE_ENV=development tsx watch --clear-screen=false --expose-gc src/index.ts",
|
||||
"run-bulldozer-studio": "NODE_ENV=development tsx watch --clear-screen=false scripts/run-bulldozer-studio.ts",
|
||||
"piledriver-gc": "tsx scripts/run-piledriver-gc.ts",
|
||||
"profile:performance": "tsx scripts/profile-bulldozer-performance.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch",
|
||||
|
||||
60
apps/bulldozer-js/scripts/run-piledriver-gc.ts
Normal file
60
apps/bulldozer-js/scripts/run-piledriver-gc.ts
Normal file
@ -0,0 +1,60 @@
|
||||
// Load .env files before anything reads process.env (mirrors src/index.ts). This script is its own
|
||||
// entrypoint and does not go through index.ts, so import load-env explicitly and first.
|
||||
import "../src/load-env.js";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { declareLmdbLowLevelDatabase } from "../src/databases/low-level/implementations/lmdb.js";
|
||||
import type { LowLevelDatabase } from "../src/databases/low-level/index.js";
|
||||
import { collectPiledriverGarbage } from "../src/databases/piledriver/gc.js";
|
||||
|
||||
/**
|
||||
* Standalone, schedulable Piledriver garbage collector. Run it on a schedule (like the backfill),
|
||||
* pointed at the same LMDB directory the Bulldozer server uses. LMDB allows one writer plus many
|
||||
* readers across processes, so this can run while the server is live.
|
||||
*
|
||||
* Requires the server to be configured with `enableRootHistory: true` (env
|
||||
* HEXCLAVE_BULLDOZER_JS_ENABLE_ROOT_HISTORY=1) so the root-history the GC relies on exists.
|
||||
*
|
||||
* Env:
|
||||
* - HEXCLAVE_BULLDOZER_JS_LMDB_PATH: LMDB directory (defaults to <cwd>/.data/bulldozer-js-lmdb).
|
||||
* - HEXCLAVE_BULLDOZER_JS_GC_ROOT_HISTORY_RETENTION_MS: retention window; MUST exceed the server's
|
||||
* HEXCLAVE_BULLDOZER_JS_HEAP_REFERENCE_MAX_AGE_MS (M). Defaults to 1 hour.
|
||||
* - HEXCLAVE_BULLDOZER_JS_GC_DELETE_BATCH_SIZE: keys deleted per write (defaults to 1000).
|
||||
* - HEXCLAVE_BULLDOZER_JS_GC_DRY_RUN=1: report what would be collected without deleting.
|
||||
*/
|
||||
|
||||
function requiredPositiveNumberEnv(name: string, fallback: number): number {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value.length === 0) return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${name} must be a positive finite number, got ${JSON.stringify(value)}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function lmdbPath(): string {
|
||||
const configured = process.env.HEXCLAVE_BULLDOZER_JS_LMDB_PATH;
|
||||
if (configured !== undefined && configured.length > 0) return configured;
|
||||
return join(process.cwd(), ".data", "bulldozer-js-lmdb");
|
||||
}
|
||||
|
||||
const path = lmdbPath();
|
||||
mkdirSync(path, { recursive: true });
|
||||
|
||||
// The GC opens the raw LMDB backend directly: it does not need the instant-availability read cache
|
||||
// the server layers on top, and going straight to LMDB keeps the delete path simple.
|
||||
const lowLevelDb: LowLevelDatabase = declareLmdbLowLevelDatabase({ path });
|
||||
|
||||
const rootHistoryRetentionMs = requiredPositiveNumberEnv("HEXCLAVE_BULLDOZER_JS_GC_ROOT_HISTORY_RETENTION_MS", 60 * 60 * 1000);
|
||||
const deleteBatchSize = requiredPositiveNumberEnv("HEXCLAVE_BULLDOZER_JS_GC_DELETE_BATCH_SIZE", 1_000);
|
||||
const dryRun = process.env.HEXCLAVE_BULLDOZER_JS_GC_DRY_RUN === "1";
|
||||
|
||||
const startedAt = performance.now();
|
||||
const result = await collectPiledriverGarbage(lowLevelDb, { rootHistoryRetentionMs, deleteBatchSize, dryRun });
|
||||
console.log(JSON.stringify({
|
||||
component: "bulldozer-js",
|
||||
event: "piledriver-gc-finished",
|
||||
path,
|
||||
rootHistoryRetentionMs,
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
...result,
|
||||
}));
|
||||
@ -70,6 +70,25 @@ describe("LMDB low-level database", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("supports deleting dump entries (for garbage collection)", async () => {
|
||||
const path = await tempLmdbPath();
|
||||
try {
|
||||
const db = declareLmdbLowLevelDatabase({ path, dbId: "dump-delete" });
|
||||
const dump = db.declareKvDump("heap");
|
||||
const { keys, seq } = await dump.insertAll([buffer("keep"), buffer("collect")]);
|
||||
await db.waitUntilAvailable(seq);
|
||||
expect(text((await dump.get(keys[0])).buffer)).toBe("keep");
|
||||
expect(text((await dump.get(keys[1])).buffer)).toBe("collect");
|
||||
|
||||
const deleted = await dump.deleteAll([keys[1]]);
|
||||
await db.waitUntilAvailable(deleted.seq);
|
||||
expect(text((await dump.get(keys[0])).buffer)).toBe("keep");
|
||||
expect(text((await dump.get(keys[1])).buffer)).toBe(null);
|
||||
} finally {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("batches store and dump writes", async () => {
|
||||
const path = await tempLmdbPath();
|
||||
try {
|
||||
|
||||
@ -54,6 +54,10 @@ export type LowLevelKvStore = {
|
||||
* If values are never modified, then a KV dump can be significantly more efficient than a KV store, especially in a
|
||||
* distributed setting.
|
||||
*
|
||||
* A dump *can* delete entries (via `deleteAll`) — immutability only forbids *changing* a value in place, not removing
|
||||
* it once nothing references it anymore. This is what makes garbage collection possible (see `piledriver/gc.ts`).
|
||||
* `setAll` and `compareAndSet` remain omitted: values are content that the dump assigns keys to, so callers must never
|
||||
* choose keys or overwrite existing entries.
|
||||
*
|
||||
* Keys must be <= 64 bytes and value must be <= 2 GB. These restrictions should be strictly enforced by the
|
||||
* implementation.
|
||||
@ -61,7 +65,7 @@ export type LowLevelKvStore = {
|
||||
* Note that durability of a modifying function is only guaranteed after `waitUntilDurable(seq)` for either the returned
|
||||
* `seq` or a `seq` that's greater (determined using `maxSeq`).
|
||||
*/
|
||||
export type LowLevelKvDump = Omit<LowLevelKvStore, "setAll" | "compareAndSet" | "deleteAll"> & {
|
||||
export type LowLevelKvDump = Omit<LowLevelKvStore, "setAll" | "compareAndSet"> & {
|
||||
/**
|
||||
* Inserts the values and returns their keys in the same order.
|
||||
*
|
||||
|
||||
126
apps/bulldozer-js/src/databases/piledriver/gc.test.ts
Normal file
126
apps/bulldozer-js/src/databases/piledriver/gc.test.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { declareInMemoryLowLevelDatabase } from "../low-level/implementations/in-memory.js";
|
||||
import { collectPiledriverGarbage } from "./gc.js";
|
||||
import { asHeapObject, declarePiledriverDatabase, isPiledriverHeapObjectSymbol, PiledriverObject } from "./index.js";
|
||||
|
||||
const rootKey = new TextEncoder().encode("root").buffer;
|
||||
const otherRootKey = new TextEncoder().encode("other").buffer;
|
||||
|
||||
// A large-ish unique payload per commit so each root references a distinct heap object.
|
||||
const bigValue = (tag: string) => ({ tag, blob: tag.repeat(64) });
|
||||
|
||||
function heapChild(object: PiledriverObject, propertyName: string) {
|
||||
if (typeof object !== "object" || object === null || Array.isArray(object) || isPiledriverHeapObjectSymbol in object) {
|
||||
throw new Error(`Expected a plain root object to read "${propertyName}" from`);
|
||||
}
|
||||
const child = object[propertyName];
|
||||
if (typeof child !== "object" || child === null || Array.isArray(child) || !(isPiledriverHeapObjectSymbol in child)) {
|
||||
throw new Error(`Expected "${propertyName}" to be a heap object`);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("collectPiledriverGarbage", () => {
|
||||
// Only Date is faked; root-history keys embed Date.now(), which is what we control here. M is left
|
||||
// at its default (Infinity) in these tests, so performance.now()-based expiry is irrelevant.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("deletes heap objects unreachable from every retained root, keeping the current root's closure", async () => {
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
const db = declarePiledriverDatabase(lowLevel, { enableRootHistory: true });
|
||||
|
||||
vi.setSystemTime(0);
|
||||
await db.setRootObject(rootKey, { a: asHeapObject(bigValue("v1")) });
|
||||
vi.setSystemTime(10 * 60_000); // +10 min
|
||||
await db.setRootObject(rootKey, { a: asHeapObject(bigValue("v2")) });
|
||||
|
||||
// Two heap objects exist (v1 now orphaned; current root references only v2).
|
||||
expect((await lowLevel.debugSnapshot!()).dumps.heap).toHaveLength(2);
|
||||
|
||||
// Retention long enough to cover both history entries (committed at 0 and 10min): nothing is
|
||||
// unreachable, so nothing is collected.
|
||||
const now = 20 * 60_000;
|
||||
const dry = await collectPiledriverGarbage(lowLevel, { rootHistoryRetentionMs: 25 * 60_000, now });
|
||||
expect(dry).toMatchObject({ totalHeapObjectCount: 2, liveHeapObjectCount: 2, deletedHeapObjectCount: 0, prunedRootHistoryCount: 0 });
|
||||
|
||||
// Short retention: both history entries age out, only the current root (referencing v2) is
|
||||
// retained, so the orphaned v1 is collected and both stale history entries pruned.
|
||||
const result = await collectPiledriverGarbage(lowLevel, { rootHistoryRetentionMs: 5 * 60_000, now });
|
||||
expect(result).toMatchObject({ totalHeapObjectCount: 2, liveHeapObjectCount: 1, deletedHeapObjectCount: 1, prunedRootHistoryCount: 2 });
|
||||
expect((await lowLevel.debugSnapshot!()).dumps.heap).toHaveLength(1);
|
||||
|
||||
// The database is still usable and the current root's data is intact after collection.
|
||||
const reader = declarePiledriverDatabase(lowLevel, { enableRootHistory: true });
|
||||
const { object } = await reader.getRootObject(rootKey);
|
||||
expect(await heapChild(object, "a").get()).toEqual(bigValue("v2"));
|
||||
});
|
||||
|
||||
it("retains a recently-orphaned object while it is still within the retention window", async () => {
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
const db = declarePiledriverDatabase(lowLevel, { enableRootHistory: true });
|
||||
|
||||
vi.setSystemTime(0);
|
||||
await db.setRootObject(rootKey, { a: asHeapObject(bigValue("v1")) });
|
||||
vi.setSystemTime(60_000); // +1 min: v1 becomes orphaned but was referenced by a root only 1 min ago
|
||||
await db.setRootObject(rootKey, { a: asHeapObject(bigValue("v2")) });
|
||||
|
||||
// now = 2 min; retention = 5 min covers the root committed at 0 (which references v1) -> v1 retained.
|
||||
const result = await collectPiledriverGarbage(lowLevel, { rootHistoryRetentionMs: 5 * 60_000, now: 2 * 60_000 });
|
||||
expect(result).toMatchObject({ liveHeapObjectCount: 2, deletedHeapObjectCount: 0, prunedRootHistoryCount: 0 });
|
||||
});
|
||||
|
||||
it("computes transitive reachability through nested heap references", async () => {
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
const db = declarePiledriverDatabase(lowLevel, { enableRootHistory: true });
|
||||
|
||||
vi.setSystemTime(0);
|
||||
// root -> outer -> inner (two chained heap objects).
|
||||
await db.setRootObject(rootKey, { outer: asHeapObject({ inner: asHeapObject({ leaf: "deep" }) }) });
|
||||
expect((await lowLevel.debugSnapshot!()).dumps.heap).toHaveLength(2);
|
||||
|
||||
// Both are reachable from the current root: nothing collected even with tiny retention.
|
||||
const keepAll = await collectPiledriverGarbage(lowLevel, { rootHistoryRetentionMs: 0, now: 10 * 60_000, dryRun: true });
|
||||
expect(keepAll).toMatchObject({ totalHeapObjectCount: 2, liveHeapObjectCount: 2, deletedHeapObjectCount: 0 });
|
||||
|
||||
// Replace the whole root so both outer and inner become unreachable, then collect with retention
|
||||
// that excludes the old root: the entire chain is collected transitively.
|
||||
vi.setSystemTime(60_000);
|
||||
await db.setRootObject(rootKey, { plain: "no-heap" });
|
||||
const collected = await collectPiledriverGarbage(lowLevel, { rootHistoryRetentionMs: 30_000, now: 2 * 60_000 });
|
||||
expect(collected).toMatchObject({ liveHeapObjectCount: 0, deletedHeapObjectCount: 2 });
|
||||
expect((await lowLevel.debugSnapshot!()).dumps.heap).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps objects reachable from any of several distinct current roots", async () => {
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
const db = declarePiledriverDatabase(lowLevel, { enableRootHistory: true });
|
||||
|
||||
vi.setSystemTime(0);
|
||||
await db.setRootObject(rootKey, { a: asHeapObject(bigValue("root-a")) });
|
||||
await db.setRootObject(otherRootKey, { b: asHeapObject(bigValue("root-b")) });
|
||||
|
||||
// Both current roots (in the `root` store) are always retained regardless of history retention.
|
||||
const result = await collectPiledriverGarbage(lowLevel, { rootHistoryRetentionMs: 0, now: 10 * 60_000 });
|
||||
expect(result).toMatchObject({ retainedRootCount: 2, totalHeapObjectCount: 2, liveHeapObjectCount: 2, deletedHeapObjectCount: 0 });
|
||||
});
|
||||
|
||||
it("does not delete anything in dryRun mode", async () => {
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
const db = declarePiledriverDatabase(lowLevel, { enableRootHistory: true });
|
||||
|
||||
vi.setSystemTime(0);
|
||||
await db.setRootObject(rootKey, { a: asHeapObject(bigValue("v1")) });
|
||||
vi.setSystemTime(10 * 60_000);
|
||||
await db.setRootObject(rootKey, { a: asHeapObject(bigValue("v2")) });
|
||||
|
||||
const dry = await collectPiledriverGarbage(lowLevel, { rootHistoryRetentionMs: 60_000, now: 20 * 60_000, dryRun: true });
|
||||
expect(dry).toMatchObject({ dryRun: true, deletedHeapObjectCount: 1, prunedRootHistoryCount: 2 });
|
||||
// Nothing actually removed.
|
||||
expect((await lowLevel.debugSnapshot!()).dumps.heap).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
168
apps/bulldozer-js/src/databases/piledriver/gc.ts
Normal file
168
apps/bulldozer-js/src/databases/piledriver/gc.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { decodeBase64 } from "@hexclave/shared/dist/utils/bytes";
|
||||
import { LowLevelDatabase, LowLevelDatabaseDebugEntry, LowLevelKvDump, LowLevelKvStore } from "../low-level/index.js";
|
||||
import { decodeRootHistoryKeyTimestamp, PILEDRIVER_HEAP_DUMP_ID, PILEDRIVER_HEAP_REFERENCE_MARKER, PILEDRIVER_ROOT_HISTORY_STORE_ID, PILEDRIVER_ROOT_STORE_ID } from "./index.js";
|
||||
|
||||
/**
|
||||
* Standalone Piledriver garbage collector.
|
||||
*
|
||||
* This module deliberately shares only the low-level KV format and the heap-reference marker
|
||||
* convention with the rest of Piledriver — it never imports the high-level database implementation,
|
||||
* so it can run in a separate, schedulable process (see `scripts/run-piledriver-gc.ts`).
|
||||
*
|
||||
* How it stays correct with almost no bookkeeping (root-history model):
|
||||
* - The database persists every committed root, with a wall-clock timestamp, into an append-only
|
||||
* `root-history` store (enabled via `PiledriverDatabaseOptions.enableRootHistory`).
|
||||
* - The GC keeps every heap object transitively reachable from ANY root committed within its
|
||||
* retention window (plus whatever is currently in `root`), and deletes everything else.
|
||||
* - The one invariant callers must uphold: `rootHistoryRetentionMs` MUST exceed the database's
|
||||
* `heapReferenceMaxAgeMs` (M). A reader/writer can only still touch a heap object through an
|
||||
* in-memory handle that is younger than M, and such a handle descends from a root that was read
|
||||
* within M — hence committed (and therefore in history) within M < retention — hence that object
|
||||
* is still reachable from a retained root and will not be collected. Past M the handle's `.get()`
|
||||
* (and key reuse) throws, so no live handle can reach a collected object.
|
||||
*
|
||||
* Concurrency: everything to be deleted is chosen from a single enumeration of the heap taken at the
|
||||
* start of the pass. Objects inserted concurrently (new random-ish keys) are simply not in that
|
||||
* enumeration, so a running pass never deletes them; new roots committed during the pass are the
|
||||
* freshest and always inside the retention window. Deletes are issued in bounded batches to keep the
|
||||
* single-writer lock held only briefly at a time.
|
||||
*/
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export type PiledriverGarbageCollectionOptions = {
|
||||
/**
|
||||
* Only roots committed within this many milliseconds of `now` are retained (in addition to the
|
||||
* current root(s) in the `root` store). MUST be strictly larger than the database's
|
||||
* `heapReferenceMaxAgeMs` (M); see the module doc for why.
|
||||
*/
|
||||
rootHistoryRetentionMs: number,
|
||||
/** Wall-clock reference time in ms; defaults to `Date.now()`. Matches the units of root-history keys. */
|
||||
now?: number,
|
||||
/** When true, compute what would be collected/pruned but perform no deletions. */
|
||||
dryRun?: boolean,
|
||||
/** Maximum number of keys deleted per low-level write, bounding how long the writer lock is held. */
|
||||
deleteBatchSize?: number,
|
||||
};
|
||||
|
||||
export type PiledriverGarbageCollectionResult = {
|
||||
retainedRootCount: number,
|
||||
retainedRootHistoryCount: number,
|
||||
totalHeapObjectCount: number,
|
||||
liveHeapObjectCount: number,
|
||||
deletedHeapObjectCount: number,
|
||||
prunedRootHistoryCount: number,
|
||||
dryRun: boolean,
|
||||
};
|
||||
|
||||
// Walks a parsed serialized Piledriver payload and collects the base64 keys of every heap reference
|
||||
// (`["heap-reference", key]`) it contains, at any nesting depth. The serialized encoding only ever
|
||||
// emits arrays for its own wrappers (`["array", ...]`, `["heap-reference", ...]`, `["NaN"]`, ...),
|
||||
// so any array whose head is the heap-reference marker is unambiguously a heap reference.
|
||||
function collectHeapReferenceKeysFromJsonable(node: unknown, out: Set<string>): void {
|
||||
if (Array.isArray(node)) {
|
||||
if (node[0] === PILEDRIVER_HEAP_REFERENCE_MARKER && typeof node[1] === "string") {
|
||||
out.add(node[1]);
|
||||
return;
|
||||
}
|
||||
for (const item of node) collectHeapReferenceKeysFromJsonable(item, out);
|
||||
} else if (node !== null && typeof node === "object") {
|
||||
for (const value of Object.values(node)) collectHeapReferenceKeysFromJsonable(value, out);
|
||||
}
|
||||
}
|
||||
|
||||
function heapReferenceKeysInSerializedValue(valueBase64: string): string[] {
|
||||
const bytes = decodeBase64(valueBase64);
|
||||
const jsonable: unknown = JSON.parse(textDecoder.decode(bytes));
|
||||
const out = new Set<string>();
|
||||
collectHeapReferenceKeysFromJsonable(jsonable, out);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
async function requireDebugEntries(storeOrDump: LowLevelKvStore | LowLevelKvDump, label: string): Promise<LowLevelDatabaseDebugEntry[]> {
|
||||
if (storeOrDump.debugEntries === undefined) {
|
||||
throw new Error(`Piledriver GC requires debugEntries() to enumerate the "${label}" store/dump, but the low-level backend does not implement it`);
|
||||
}
|
||||
return await storeOrDump.debugEntries();
|
||||
}
|
||||
|
||||
export async function collectPiledriverGarbage(lowLevelDb: LowLevelDatabase, options: PiledriverGarbageCollectionOptions): Promise<PiledriverGarbageCollectionResult> {
|
||||
const now = options.now ?? Date.now();
|
||||
const retentionMs = options.rootHistoryRetentionMs;
|
||||
if (!(Number.isFinite(retentionMs) && retentionMs >= 0)) throw new Error(`rootHistoryRetentionMs must be a non-negative finite number, got ${retentionMs}`);
|
||||
const dryRun = options.dryRun === true;
|
||||
const deleteBatchSize = options.deleteBatchSize ?? 1_000;
|
||||
if (!(Number.isInteger(deleteBatchSize) && deleteBatchSize > 0)) throw new Error(`deleteBatchSize must be a positive integer, got ${deleteBatchSize}`);
|
||||
|
||||
const rootStore = lowLevelDb.declareKvStore(PILEDRIVER_ROOT_STORE_ID);
|
||||
const heapDump = lowLevelDb.declareKvDump(PILEDRIVER_HEAP_DUMP_ID);
|
||||
const rootHistoryStore = lowLevelDb.declareKvStore(PILEDRIVER_ROOT_HISTORY_STORE_ID);
|
||||
|
||||
// Enumerate everything up front. Deletions are chosen only from this snapshot of the heap, which is
|
||||
// what makes the pass safe against concurrent writers (see module doc).
|
||||
const rootEntries = await requireDebugEntries(rootStore, "root");
|
||||
const historyEntries = await requireDebugEntries(rootHistoryStore, "root-history");
|
||||
const heapEntries = await requireDebugEntries(heapDump, "heap");
|
||||
|
||||
const retainedRootValueBase64: string[] = rootEntries.map(entry => entry.valueBase64);
|
||||
const prunableHistoryKeys: ArrayBuffer[] = [];
|
||||
let retainedRootHistoryCount = 0;
|
||||
for (const entry of historyEntries) {
|
||||
if (entry.keyUtf8 === null) throw new Error(`Piledriver GC encountered a root-history entry with a non-UTF-8 key (keyBase64=${entry.keyBase64})`);
|
||||
const committedAt = decodeRootHistoryKeyTimestamp(entry.keyUtf8);
|
||||
if (now - committedAt <= retentionMs) {
|
||||
retainedRootValueBase64.push(entry.valueBase64);
|
||||
retainedRootHistoryCount++;
|
||||
} else {
|
||||
prunableHistoryKeys.push(decodeBase64(entry.keyBase64).buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// Transitive reachability from all retained roots. Consecutive roots share almost their entire
|
||||
// closure (copy-on-write churn), so the `live` set means each object is scanned at most once.
|
||||
const heapValueByKeyBase64 = new Map(heapEntries.map(entry => [entry.keyBase64, entry.valueBase64]));
|
||||
const live = new Set<string>();
|
||||
const queue: string[] = [];
|
||||
const enqueue = (keyBase64: string) => {
|
||||
if (!live.has(keyBase64)) {
|
||||
live.add(keyBase64);
|
||||
queue.push(keyBase64);
|
||||
}
|
||||
};
|
||||
for (const valueBase64 of retainedRootValueBase64) {
|
||||
for (const ref of heapReferenceKeysInSerializedValue(valueBase64)) enqueue(ref);
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const keyBase64 = queue.pop();
|
||||
if (keyBase64 === undefined) break;
|
||||
const valueBase64 = heapValueByKeyBase64.get(keyBase64);
|
||||
// Missing means it was inserted after our enumeration (so not our concern this pass) or already
|
||||
// absent; either way there is nothing to traverse or delete.
|
||||
if (valueBase64 === undefined) continue;
|
||||
for (const ref of heapReferenceKeysInSerializedValue(valueBase64)) enqueue(ref);
|
||||
}
|
||||
|
||||
const liveHeapObjectCount = heapEntries.reduce((count, entry) => count + (live.has(entry.keyBase64) ? 1 : 0), 0);
|
||||
const heapKeysToDelete = heapEntries.filter(entry => !live.has(entry.keyBase64)).map(entry => decodeBase64(entry.keyBase64).buffer);
|
||||
|
||||
if (!dryRun) {
|
||||
for (let i = 0; i < heapKeysToDelete.length; i += deleteBatchSize) {
|
||||
const { seq } = await heapDump.deleteAll(heapKeysToDelete.slice(i, i + deleteBatchSize));
|
||||
await lowLevelDb.waitUntilAvailable(seq);
|
||||
}
|
||||
for (let i = 0; i < prunableHistoryKeys.length; i += deleteBatchSize) {
|
||||
const { seq } = await rootHistoryStore.deleteAll(prunableHistoryKeys.slice(i, i + deleteBatchSize));
|
||||
await lowLevelDb.waitUntilAvailable(seq);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
retainedRootCount: rootEntries.length,
|
||||
retainedRootHistoryCount,
|
||||
totalHeapObjectCount: heapEntries.length,
|
||||
liveHeapObjectCount,
|
||||
deletedHeapObjectCount: heapKeysToDelete.length,
|
||||
prunedRootHistoryCount: prunableHistoryKeys.length,
|
||||
dryRun,
|
||||
};
|
||||
}
|
||||
@ -1,7 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LowLevelDatabase } from "../low-level/index.js";
|
||||
import { declareInMemoryLowLevelDatabase } from "../low-level/implementations/in-memory.js";
|
||||
import { asHeapObject, declarePiledriverDatabase, isPiledriverHeapObjectSymbol, piledriverObjectEquals } from "./index.js";
|
||||
import { asHeapObject, declarePiledriverDatabase, isPiledriverHeapObjectSymbol, PiledriverHeapObject, PiledriverObject, piledriverObjectEquals } from "./index.js";
|
||||
|
||||
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// Narrows a deserialized root's named property to a heap-object handle (throwing loudly otherwise),
|
||||
// so tests can exercise `.get()`/expiry without unsafe casts.
|
||||
function heapChild(object: PiledriverObject, propertyName: string): PiledriverHeapObject {
|
||||
if (typeof object !== "object" || object === null || Array.isArray(object) || isPiledriverHeapObjectSymbol in object) {
|
||||
throw new Error(`Expected a plain root object to read "${propertyName}" from`);
|
||||
}
|
||||
const child = object[propertyName];
|
||||
if (typeof child !== "object" || child === null || Array.isArray(child) || !(isPiledriverHeapObjectSymbol in child)) {
|
||||
throw new Error(`Expected "${propertyName}" to be a heap object`);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
function wrapWithHeapGetCounter(lowLevel: LowLevelDatabase, onHeapGet: () => void): LowLevelDatabase {
|
||||
return {
|
||||
@ -47,6 +62,89 @@ describe("PiledriverDatabase", () => {
|
||||
expect(await child.get()).toEqual({ nested: "value" });
|
||||
expect(heapGets).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects heapReferenceMaxAgeMs that is not positive or Infinity", () => {
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
expect(() => declarePiledriverDatabase(lowLevel, { heapReferenceMaxAgeMs: 0 })).toThrow(/heapReferenceMaxAgeMs/);
|
||||
expect(() => declarePiledriverDatabase(lowLevel, { heapReferenceMaxAgeMs: -1 })).toThrow(/heapReferenceMaxAgeMs/);
|
||||
expect(() => declarePiledriverDatabase(lowLevel, { heapReferenceMaxAgeMs: NaN })).toThrow(/heapReferenceMaxAgeMs/);
|
||||
// Infinity (the default) and positive finite values are accepted.
|
||||
declarePiledriverDatabase(lowLevel, { heapReferenceMaxAgeMs: Infinity });
|
||||
declarePiledriverDatabase(lowLevel, { heapReferenceMaxAgeMs: 1000, heapReferenceCacheSweepIntervalMs: 50 });
|
||||
});
|
||||
|
||||
it("throws from a heap handle's .get() once it is older than M", async () => {
|
||||
const key = new TextEncoder().encode("root").buffer;
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
const heapReferenceMaxAgeMs = 40;
|
||||
await declarePiledriverDatabase(lowLevel).setRootObject(key, { child: asHeapObject({ nested: "value" }) });
|
||||
|
||||
const reader = declarePiledriverDatabase(lowLevel, { heapReferenceMaxAgeMs, heapReferenceCacheSweepIntervalMs: 20 });
|
||||
const { object } = await reader.getRootObject(key);
|
||||
const child = heapChild(object, "child");
|
||||
// Fresh handle (within M) reads fine.
|
||||
expect(await child.get()).toEqual({ nested: "value" });
|
||||
|
||||
await wait(heapReferenceMaxAgeMs + 60);
|
||||
// The same handle, now older than M, refuses to read — even though the value was already loaded.
|
||||
await expect(child.get()).rejects.toThrow(/expired/);
|
||||
});
|
||||
|
||||
it("propagates the root's referencedAt to lazily-loaded nested handles (so nested reads also expire)", async () => {
|
||||
const key = new TextEncoder().encode("root").buffer;
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
const heapReferenceMaxAgeMs = 40;
|
||||
await declarePiledriverDatabase(lowLevel).setRootObject(key, {
|
||||
outer: asHeapObject({ inner: asHeapObject({ leaf: "deep" }) }),
|
||||
});
|
||||
|
||||
const reader = declarePiledriverDatabase(lowLevel, { heapReferenceMaxAgeMs, heapReferenceCacheSweepIntervalMs: 20 });
|
||||
const { object } = await reader.getRootObject(key);
|
||||
const outer = heapChild(object, "outer");
|
||||
// Load the outer handle immediately (within M), which lazily creates the inner handle. The inner
|
||||
// handle must inherit the outer/root referencedAt rather than being stamped with `now`.
|
||||
const inner = heapChild(await outer.get(), "inner");
|
||||
|
||||
await wait(heapReferenceMaxAgeMs + 60);
|
||||
// If the inner handle had been re-stamped at creation time it would still be readable here; it
|
||||
// must expire together with the root snapshot it descends from.
|
||||
await expect(inner.get()).rejects.toThrow(/expired/);
|
||||
});
|
||||
|
||||
it("evicts read-cache entries once they are older than M", async () => {
|
||||
const key = new TextEncoder().encode("root").buffer;
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
await declarePiledriverDatabase(lowLevel).setRootObject(key, { child: asHeapObject({ nested: "value" }) });
|
||||
|
||||
const reader = declarePiledriverDatabase(lowLevel, { heapReferenceMaxAgeMs: 40, heapReferenceCacheSweepIntervalMs: 20 });
|
||||
// Keep a strong reference to the snapshot so the cache entry can only be removed by the age-based
|
||||
// sweep, not by weak-ref finalization.
|
||||
const { object } = await reader.getRootObject(key);
|
||||
const readCache = reader.getDebugInfo().heapObjectsByHeapKeyBase64;
|
||||
expect(readCache.size).toBeGreaterThan(0);
|
||||
|
||||
await wait(120);
|
||||
expect(readCache.size).toBe(0);
|
||||
// Reference retained so it isn't collected before the assertion above.
|
||||
expect(object).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-inserts under a fresh key instead of reusing a stale (possibly-collected) key past M", async () => {
|
||||
const lowLevel = declareInMemoryLowLevelDatabase(crypto.randomUUID());
|
||||
const db = declarePiledriverDatabase(lowLevel, { heapReferenceMaxAgeMs: 40, heapReferenceCacheSweepIntervalMs: 20 });
|
||||
const heapObj = asHeapObject({ value: "shared" });
|
||||
|
||||
await db.setRootObject(new TextEncoder().encode("a").buffer, { ref: heapObj });
|
||||
const afterFirst = await lowLevel.debugSnapshot!();
|
||||
expect(afterFirst.dumps.heap).toHaveLength(1);
|
||||
|
||||
await wait(120);
|
||||
// The in-memory handle is now older than M. Committing it again must NOT reuse its old key (which
|
||||
// the GC may have collected); it must re-insert under a new key -> a second heap object appears.
|
||||
await db.setRootObject(new TextEncoder().encode("b").buffer, { ref: heapObj });
|
||||
const afterSecond = await lowLevel.debugSnapshot!();
|
||||
expect(afterSecond.dumps.heap).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("piledriverObjectEquals", () => {
|
||||
|
||||
@ -10,9 +10,42 @@ export type PiledriverHeapObject = {
|
||||
[isPiledriverHeapObjectSymbol]: true,
|
||||
};
|
||||
|
||||
// Per-in-memory-heap-reference bookkeeping shared (as a mutable box) between the read-cache entry
|
||||
// and the handle it describes. `referencedAt` is a `performance.now()` timestamp of the most recent
|
||||
// moment we *knew* this heap object was reachable from a live root: it is stamped when the handle is
|
||||
// created while reading a fresh root and refreshed whenever the same key is re-read from, or
|
||||
// re-committed into, a root. Once it is older than the configured `heapReferenceMaxAgeMs` (M), the
|
||||
// handle's `.get()` refuses to read (the object may already have been collected) and the read cache
|
||||
// evicts the entry. See the garbage collector (`gc.ts`) for the matching root-retention window.
|
||||
type HeapReferenceState = { referencedAt: number };
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
// Low-level store/dump ids used by a Piledriver database. Exported so the standalone GC can open the
|
||||
// exact same stores/dump without importing the high-level database implementation.
|
||||
export const PILEDRIVER_ROOT_STORE_ID = "root";
|
||||
export const PILEDRIVER_HEAP_DUMP_ID = "heap";
|
||||
export const PILEDRIVER_ROOT_HISTORY_STORE_ID = "root-history";
|
||||
// The marker used as the first element of a serialized heap reference array: `["heap-reference", key]`.
|
||||
export const PILEDRIVER_HEAP_REFERENCE_MARKER = "heap-reference";
|
||||
|
||||
// Root-history keys embed a WALL-CLOCK timestamp (Date.now(), not performance.now()) because the GC
|
||||
// runs in a *separate process* and must compare these timestamps against its own wall clock;
|
||||
// performance.now() is only monotonic within a single process. The zero-padded prefix keeps keys
|
||||
// lexicographically time-ordered; the random hex suffix disambiguates commits within the same
|
||||
// millisecond. Total length stays well under the 64-byte key limit.
|
||||
const ROOT_HISTORY_TIMESTAMP_DIGITS = 16;
|
||||
export function encodeRootHistoryKey(wallClockMs: number): ArrayBuffer {
|
||||
const suffix = [...crypto.getRandomValues(new Uint8Array(8))].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
return textEncoder.encode(`${Math.floor(wallClockMs).toString().padStart(ROOT_HISTORY_TIMESTAMP_DIGITS, "0")}-${suffix}`).buffer;
|
||||
}
|
||||
export function decodeRootHistoryKeyTimestamp(keyUtf8: string): number {
|
||||
const millis = Number.parseInt(keyUtf8.slice(0, keyUtf8.indexOf("-")), 10);
|
||||
if (!Number.isFinite(millis)) throw new Error(`Malformed Piledriver root-history key (expected "<paddedMillis>-<hex>"): ${keyUtf8}`);
|
||||
return millis;
|
||||
}
|
||||
|
||||
const heapObjectsMapNullSentinel = { __heapObjectsMapNullSentinel: true };
|
||||
const heapObjectsByObject = new WeakMap<PiledriverObject & object, PiledriverHeapObject>();
|
||||
/**
|
||||
@ -68,6 +101,25 @@ export type PiledriverDatabaseDebugSnapshot = {
|
||||
};
|
||||
export type PiledriverDatabaseOptions = {
|
||||
disableHeapReadCache?: boolean,
|
||||
/**
|
||||
* M: the maximum age (in ms, measured with `performance.now()`) of an in-memory heap reference,
|
||||
* counted from the last moment we knew it was referenced from a live root. Past this age, a
|
||||
* handle's `.get()` throws (its object may have been garbage-collected under the matching GC
|
||||
* retention window) and the read cache evicts the entry. Defaults to `Infinity` (never expire,
|
||||
* i.e. the previous behavior). Callers that also run the GC MUST set this below the GC's root
|
||||
* retention window so that any object reachable from a still-usable handle is still retained.
|
||||
*/
|
||||
heapReferenceMaxAgeMs?: number,
|
||||
/** How often to sweep expired entries out of the read cache. Defaults to a fraction of M. */
|
||||
heapReferenceCacheSweepIntervalMs?: number,
|
||||
/**
|
||||
* When true, every committed root is also appended (with a wall-clock timestamp) to an
|
||||
* append-only `root-history` store. The standalone GC keeps every heap object reachable from any
|
||||
* root committed within its retention window; without this history it could only see the current
|
||||
* root and would collect objects that in-flight readers/writers can still reach. Defaults to
|
||||
* false so callers that don't run the GC don't pay for the extra writes.
|
||||
*/
|
||||
enableRootHistory?: boolean,
|
||||
};
|
||||
|
||||
// Tracks the chain of *heap objects* currently being serialized, so heap cycles fail fast with
|
||||
@ -245,20 +297,41 @@ function serializationBranchStatsByKey(stats: PiledriverSerializationTimingStats
|
||||
export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options: PiledriverDatabaseOptions = {}): PiledriverDatabase {
|
||||
// TODO actually support cycles both for heap and non-heap objects (right now they are detected and rejected)
|
||||
|
||||
const rootStore = lowLevelDb.declareKvStore("root");
|
||||
const heapDump = lowLevelDb.declareKvDump("heap");
|
||||
const rootStore = lowLevelDb.declareKvStore(PILEDRIVER_ROOT_STORE_ID);
|
||||
const heapDump = lowLevelDb.declareKvDump(PILEDRIVER_HEAP_DUMP_ID);
|
||||
const rootHistoryStore = options.enableRootHistory === true ? lowLevelDb.declareKvStore(PILEDRIVER_ROOT_HISTORY_STORE_ID) : null;
|
||||
|
||||
const heapObjectsByHeapKeyBase64 = new Map<string, { refIdentity: string, object: WeakRef<PiledriverHeapObject>, seq: DatabaseSeq }>();
|
||||
const heapReferenceMaxAgeMs = options.heapReferenceMaxAgeMs ?? Infinity;
|
||||
if (!(heapReferenceMaxAgeMs === Infinity || (Number.isFinite(heapReferenceMaxAgeMs) && heapReferenceMaxAgeMs > 0))) {
|
||||
throw new Error(`heapReferenceMaxAgeMs must be a positive number or Infinity, got ${heapReferenceMaxAgeMs}`);
|
||||
}
|
||||
|
||||
const heapObjectsByHeapKeyBase64 = new Map<string, { refIdentity: string, object: WeakRef<PiledriverHeapObject>, seq: DatabaseSeq, state: HeapReferenceState }>();
|
||||
const heapObjectsByHeapKeyFinalizer = new FinalizationRegistry(([keyBase64, refIdentity]: [string, string]) => heapObjectsByHeapKeyBase64.get(keyBase64)?.refIdentity === refIdentity && heapObjectsByHeapKeyBase64.delete(keyBase64));
|
||||
const heapKeysAndSeqByHeapObjects = new WeakMap<PiledriverHeapObject, Promise<{ key: ArrayBuffer, seq: DatabaseSeq }>>();
|
||||
const heapKeysAndSeqByHeapObjects = new WeakMap<PiledriverHeapObject, { promise: Promise<{ key: ArrayBuffer, seq: DatabaseSeq }>, state: HeapReferenceState }>();
|
||||
|
||||
const cacheHeapObjectByKey = (key: ArrayBuffer, heapObj: PiledriverHeapObject, seq: DatabaseSeq) => {
|
||||
const cacheHeapObjectByKey = (key: ArrayBuffer, heapObj: PiledriverHeapObject, seq: DatabaseSeq, state: HeapReferenceState) => {
|
||||
const keyBase64 = encodeBase64(new Uint8Array(key));
|
||||
const refIdentity = crypto.randomUUID();
|
||||
heapObjectsByHeapKeyBase64.set(keyBase64, { refIdentity, object: new WeakRef(heapObj), seq });
|
||||
heapObjectsByHeapKeyBase64.set(keyBase64, { refIdentity, object: new WeakRef(heapObj), seq, state });
|
||||
heapObjectsByHeapKeyFinalizer.register(heapObj, [keyBase64, refIdentity]);
|
||||
};
|
||||
|
||||
// The read cache (heapObjectsByHeapKeyBase64) is a pure performance optimization now — correctness
|
||||
// is enforced by each handle's own M-check in .get() and by the GC's root-history retention — so we
|
||||
// can safely evict entries we haven't re-confirmed live within M. This also bounds the map's size.
|
||||
if (Number.isFinite(heapReferenceMaxAgeMs)) {
|
||||
const sweepIntervalMs = options.heapReferenceCacheSweepIntervalMs ?? Math.max(1_000, Math.min(heapReferenceMaxAgeMs, 60_000));
|
||||
const sweepTimer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
for (const [keyBase64, entry] of heapObjectsByHeapKeyBase64) {
|
||||
if (now - entry.state.referencedAt > heapReferenceMaxAgeMs) heapObjectsByHeapKeyBase64.delete(keyBase64);
|
||||
}
|
||||
}, sweepIntervalMs);
|
||||
// Don't keep the process alive just for cache sweeping.
|
||||
sweepTimer.unref();
|
||||
}
|
||||
|
||||
// Deduplicates and drops initial seqs before delegating to the low-level combineSeqs. This is
|
||||
// important for performance: each low-level combined seq allocates promises/map entries, so we
|
||||
// only want to create one when there are actually ≥2 distinct non-initial seqs to combine.
|
||||
@ -276,7 +349,16 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
if (heapPath.has(heapObj)) throw new Error("Piledriver objects must not contain cycles (found a cycle of heap objects)");
|
||||
|
||||
const existing = heapKeysAndSeqByHeapObjects.get(heapObj);
|
||||
if (existing) {
|
||||
// Only reuse a cached key while the handle is still within M of when we last knew it was
|
||||
// referenced from a live root. Once it is older than M, the referenced object may already have
|
||||
// been garbage-collected (it was unreachable from every retained root for the whole grace
|
||||
// window), so reusing its key would create a dangling reference. Falling through to the miss
|
||||
// path re-inserts under a fresh key; for a lazily-loaded handle that path calls `.get()`, which
|
||||
// itself throws past M — i.e. you cannot resurrect an object through a stale reader handle.
|
||||
if (existing && performance.now() - existing.state.referencedAt <= heapReferenceMaxAgeMs) {
|
||||
// Reusing this heap object in the root we're committing now re-confirms it as referenced from
|
||||
// a (soon-to-be) live root, so refresh its liveness timestamp.
|
||||
existing.state.referencedAt = performance.now();
|
||||
if (serializationTimingStats !== undefined) {
|
||||
serializationTimingStats.heapObjectCacheHits++;
|
||||
const branch = branchKey === undefined ? undefined : serializationBranchStatsByKey(serializationTimingStats, branchKey);
|
||||
@ -284,7 +366,7 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
}
|
||||
const cacheHitAwaitStartedAt = performance.now();
|
||||
try {
|
||||
return await existing;
|
||||
return await existing.promise;
|
||||
} finally {
|
||||
if (serializationTimingStats !== undefined) serializationTimingStats.heapObjectCacheHitAwaitTotalMs += performance.now() - cacheHitAwaitStartedAt;
|
||||
}
|
||||
@ -295,6 +377,7 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
if (branch !== undefined) branch.heapObjectCacheMisses++;
|
||||
}
|
||||
|
||||
const state: HeapReferenceState = { referencedAt: performance.now() };
|
||||
const promise = (async () => {
|
||||
const childHeapPath = new Set(heapPath).add(heapObj);
|
||||
return await traceSpanHot("bulldozer-js.piledriver.heap.serializeAndInsert", async () => {
|
||||
@ -321,28 +404,36 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
return { key: inserted.keys[0], seq: inserted.seq };
|
||||
});
|
||||
})();
|
||||
heapKeysAndSeqByHeapObjects.set(heapObj, promise);
|
||||
heapKeysAndSeqByHeapObjects.set(heapObj, { promise, state });
|
||||
let result;
|
||||
const cacheMissAwaitStartedAt = performance.now();
|
||||
try {
|
||||
result = await promise;
|
||||
} catch (error) {
|
||||
// Don't leave a poisoned rejected promise in the cache; a later retry may succeed.
|
||||
if (heapKeysAndSeqByHeapObjects.get(heapObj) === promise) heapKeysAndSeqByHeapObjects.delete(heapObj);
|
||||
if (heapKeysAndSeqByHeapObjects.get(heapObj)?.promise === promise) heapKeysAndSeqByHeapObjects.delete(heapObj);
|
||||
throw error;
|
||||
} finally {
|
||||
if (serializationTimingStats !== undefined) serializationTimingStats.heapObjectCacheMissAwaitTotalMs += performance.now() - cacheMissAwaitStartedAt;
|
||||
}
|
||||
cacheHeapObjectByKey(result.key, heapObj, result.seq);
|
||||
cacheHeapObjectByKey(result.key, heapObj, result.seq, state);
|
||||
return result;
|
||||
};
|
||||
|
||||
const getHeapObjectByKey = (key: ArrayBuffer, seq: DatabaseSeq): { object: PiledriverHeapObject, seq: DatabaseSeq } => {
|
||||
// `referencedAt` is the `performance.now()` timestamp at which the enclosing root/heap object was
|
||||
// known to be referenced from a live root. It is inherited from the parent (NOT re-stamped to
|
||||
// `now` for each child) so that a snapshot read from a root that is already close to expiring
|
||||
// cannot indefinitely refresh its descendants by lazily loading them later — otherwise a stale
|
||||
// reader could still walk into objects the GC has collected.
|
||||
const getHeapObjectByKey = (key: ArrayBuffer, seq: DatabaseSeq, referencedAt: number): { object: PiledriverHeapObject, seq: DatabaseSeq } => {
|
||||
const keyBase64 = encodeBase64(new Uint8Array(key));
|
||||
const existingEntry = heapObjectsByHeapKeyBase64.get(keyBase64);
|
||||
if (!options.disableHeapReadCache && existingEntry) {
|
||||
const existingObject = existingEntry.object.deref();
|
||||
if (existingObject) {
|
||||
// Re-observing this key while reading a (fresher) root re-confirms it as live, so bump the
|
||||
// shared liveness timestamp — never regress it to an older value.
|
||||
existingEntry.state.referencedAt = Math.max(existingEntry.state.referencedAt, referencedAt);
|
||||
return {
|
||||
object: existingObject,
|
||||
seq: existingEntry.seq,
|
||||
@ -352,13 +443,19 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
}
|
||||
}
|
||||
|
||||
const state: HeapReferenceState = { referencedAt };
|
||||
let loadPromise: Promise<PiledriverObject> | undefined;
|
||||
const heapObj: PiledriverHeapObject = {
|
||||
async get() {
|
||||
const age = performance.now() - state.referencedAt;
|
||||
if (age > heapReferenceMaxAgeMs) {
|
||||
throw new Error(`Piledriver heap reference expired: last known referenced from a live root ${age.toFixed(0)}ms ago, which exceeds the configured heapReferenceMaxAgeMs=${heapReferenceMaxAgeMs}ms. This snapshot/handle outlived the garbage-collection grace period and its heap object may have been collected; re-fetch from a fresh root.`);
|
||||
}
|
||||
loadPromise ??= traceSpanHot({ description: "bulldozer-js.piledriver.heap.get", attributes: { "bulldozer.piledriver.heap_read_cache_disabled": options.disableHeapReadCache === true } }, async () => {
|
||||
const { buffer, seq: heapSeq } = await heapDump.get(key);
|
||||
if (buffer === null) throw new Error(`Assertion error: Heap object with base64 key "${keyBase64}" not found`);
|
||||
const deserialized = await deserializePiledriverObject(buffer, heapSeq);
|
||||
// Children inherit this handle's (possibly refreshed) liveness timestamp; see the note above.
|
||||
const deserialized = await deserializePiledriverObject(buffer, heapSeq, state.referencedAt);
|
||||
return deserialized.object;
|
||||
});
|
||||
try {
|
||||
@ -370,9 +467,9 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
},
|
||||
[isPiledriverHeapObjectSymbol]: true,
|
||||
};
|
||||
heapKeysAndSeqByHeapObjects.set(heapObj, Promise.resolve({ key, seq }));
|
||||
heapKeysAndSeqByHeapObjects.set(heapObj, { promise: Promise.resolve({ key, seq }), state });
|
||||
if (!options.disableHeapReadCache) {
|
||||
cacheHeapObjectByKey(key, heapObj, seq);
|
||||
cacheHeapObjectByKey(key, heapObj, seq, state);
|
||||
}
|
||||
return { object: heapObj, seq };
|
||||
};
|
||||
@ -452,7 +549,7 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
// Fail fast on heap cycles at walk time (resolution would deadlock on the ancestor's
|
||||
// own memoized promise otherwise).
|
||||
if (heapPath.has(node)) throw new Error("Piledriver objects must not contain cycles (found a cycle of heap objects)");
|
||||
const slot: [string, string | null] = ["heap-reference", null];
|
||||
const slot: [string, string | null] = [PILEDRIVER_HEAP_REFERENCE_MARKER, null];
|
||||
pendingSlots.push({ slot, heapObj: node, branchKey });
|
||||
return slot;
|
||||
} else {
|
||||
@ -529,7 +626,7 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
// Fully synchronous (getHeapObjectByKey is sync; heap payloads are only fetched lazily on
|
||||
// .get()). Seqs are collected into one array and combined once per buffer instead of once per
|
||||
// node — see serializePiledriverObject for why this matters.
|
||||
const deserializePiledriverObjectFromJsonableObject = (jsonableObject: unknown, enclosingSeq: DatabaseSeq, seqs: DatabaseSeq[]): PiledriverObject => {
|
||||
const deserializePiledriverObjectFromJsonableObject = (jsonableObject: unknown, enclosingSeq: DatabaseSeq, seqs: DatabaseSeq[], referencedAt: number): PiledriverObject => {
|
||||
switch (typeof jsonableObject) {
|
||||
case "string":
|
||||
case "number":
|
||||
@ -544,10 +641,10 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
case "array": {
|
||||
// any: JSON.parse output is structurally validated by the surrounding switch; a malformed
|
||||
// payload would throw in the recursive call rather than silently passing through.
|
||||
return jsonableObject[1].map((o: any) => deserializePiledriverObjectFromJsonableObject(o, enclosingSeq, seqs));
|
||||
return jsonableObject[1].map((o: any) => deserializePiledriverObjectFromJsonableObject(o, enclosingSeq, seqs, referencedAt));
|
||||
}
|
||||
case "heap-reference": {
|
||||
const heapObjAndSeq = getHeapObjectByKey(decodeBase64(jsonableObject[1]).buffer, enclosingSeq);
|
||||
case PILEDRIVER_HEAP_REFERENCE_MARKER: {
|
||||
const heapObjAndSeq = getHeapObjectByKey(decodeBase64(jsonableObject[1]).buffer, enclosingSeq, referencedAt);
|
||||
seqs.push(heapObjAndSeq.seq);
|
||||
return heapObjAndSeq.object;
|
||||
}
|
||||
@ -570,7 +667,7 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
} else {
|
||||
const result: Record<string, PiledriverObject> = {};
|
||||
for (const [k, v] of Object.entries(jsonableObject)) {
|
||||
result[k] = deserializePiledriverObjectFromJsonableObject(v, enclosingSeq, seqs);
|
||||
result[k] = deserializePiledriverObjectFromJsonableObject(v, enclosingSeq, seqs, referencedAt);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@ -581,9 +678,12 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
}
|
||||
};
|
||||
|
||||
const deserializePiledriverObject = async (buffer: ArrayBuffer, enclosingSeq: DatabaseSeq): Promise<{ object: PiledriverObject, seq: DatabaseSeq }> => {
|
||||
// `referencedAt` is the `performance.now()` moment at which the object being deserialized was known
|
||||
// to be reachable from a live root (see getHeapObjectByKey). Callers reading the current root pass
|
||||
// `performance.now()`; a lazy child load inherits its parent handle's stamp.
|
||||
const deserializePiledriverObject = async (buffer: ArrayBuffer, enclosingSeq: DatabaseSeq, referencedAt: number): Promise<{ object: PiledriverObject, seq: DatabaseSeq }> => {
|
||||
const seqs: DatabaseSeq[] = [];
|
||||
const object = deserializePiledriverObjectFromJsonableObject(JSON.parse(textDecoder.decode(buffer)), enclosingSeq, seqs);
|
||||
const object = deserializePiledriverObjectFromJsonableObject(JSON.parse(textDecoder.decode(buffer)), enclosingSeq, seqs, referencedAt);
|
||||
return { object, seq: combineSeqsDeduped(seqs) };
|
||||
};
|
||||
|
||||
@ -615,7 +715,9 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
return await traceSpan("bulldozer-js.piledriver.getRootObject", async () => {
|
||||
const { buffer, seq: rootSeq } = await rootStore.get(key);
|
||||
if (buffer === null) throw new Error("Root object not found");
|
||||
const { object, seq: deserializeSeq } = await deserializePiledriverObject(buffer, rootSeq);
|
||||
// Reading the current root confirms its whole closure as live right now, so its handles
|
||||
// (and their lazily-loaded descendants) are stamped with `performance.now()`.
|
||||
const { object, seq: deserializeSeq } = await deserializePiledriverObject(buffer, rootSeq, performance.now());
|
||||
return { object, seq: lowLevelDb.combineSeqs(deserializeSeq, rootSeq) };
|
||||
});
|
||||
},
|
||||
@ -632,6 +734,14 @@ export function declarePiledriverDatabase(lowLevelDb: LowLevelDatabase, options:
|
||||
const rootStoreSetAllStartedAt = performance.now();
|
||||
const { seq: rootSeq } = await rootStore.setAll([{ key, value: buffer }], { requiresSeq: seq });
|
||||
const rootStoreSetAllMs = performance.now() - rootStoreSetAllStartedAt;
|
||||
if (rootHistoryStore !== null) {
|
||||
// Append this committed root (same serialized buffer) to the append-only history so the GC
|
||||
// can keep every object reachable from any root committed within its retention window.
|
||||
// requiresSeq: seq ensures the history entry can only become durable once the heap objects
|
||||
// it references are durable. The current root always stays in rootStore; history entries
|
||||
// are pruned by the GC once they age out of the window.
|
||||
await rootHistoryStore.setAll([{ key: encodeRootHistoryKey(Date.now()), value: buffer }], { requiresSeq: seq });
|
||||
}
|
||||
const topSerializationBranches = [...timingStats.branchStats.entries()]
|
||||
.map(([branch, stats]) => ({
|
||||
branch,
|
||||
|
||||
@ -74,6 +74,10 @@ function createLowLevelDatabase(): LowLevelDatabase {
|
||||
const bulldozerDb = declareBulldozerDatabase(
|
||||
declarePiledriverDatabase(createLowLevelDatabase(), {
|
||||
disableHeapReadCache: process.env.HEXCLAVE_BULLDOZER_JS_DISABLE_PILEDRIVER_HEAP_READ_CACHE === "1",
|
||||
// Both default off, keeping behavior identical unless an operator opts in to run the GC. When
|
||||
// enabling, set HEAP_REFERENCE_MAX_AGE_MS (M) below the GC's root-history retention window.
|
||||
heapReferenceMaxAgeMs: readOptionalNonNegativeNumberEnv("HEXCLAVE_BULLDOZER_JS_HEAP_REFERENCE_MAX_AGE_MS"),
|
||||
enableRootHistory: process.env.HEXCLAVE_BULLDOZER_JS_ENABLE_ROOT_HISTORY === "1",
|
||||
}),
|
||||
{ migrations: schema.migrations },
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user