Add scheduled reachability GC for Piledriver via historical roots

Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
Devin AI 2026-07-17 18:44:53 +00:00
parent afa2d74ba0
commit 9ddadcad5a
9 changed files with 616 additions and 26 deletions

View File

@ -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",

View 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,
}));

View File

@ -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 {

View File

@ -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.
*

View 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);
});
});

View 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,
};
}

View File

@ -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", () => {

View File

@ -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,

View File

@ -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 },
);