Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/disk-persist-tree-codec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@milaboratories/pl-tree": minor
---

Snapshot codec for persisting a tree mirror to disk, plus capture and restore. Bodies are stored against global ids with signatures in a side table, so a snapshot survives the signatures it was taken with.
6 changes: 6 additions & 0 deletions .changeset/disk-persist-tree-middle-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@milaboratories/pl-middle-layer": minor
"@milaboratories/pl-tree": minor
---

Persist project tree mirrors to disk and restore them on open, so reopening a project transfers what changed rather than the whole tree. On by default, with a kill switch in `treeSnapshotOps`.
43 changes: 42 additions & 1 deletion lib/node/pl-middle-layer/build.node.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
import { createRolldownNodeConfig } from "@milaboratories/ts-builder/configs/utils/createRolldownNodeConfig.js";
import { execFileSync } from "node:child_process";

/**
* Identifies this build for the persisted-tree cache key, see `src/middle_layer/build_stamp.ts`.
*
* A clean worktree stamps its commit; a dirty one stamps the build time too, so editing the
* tree pruning or traversal rules locally cannot hit a snapshot written under the old ones.
* `git status` covers the whole repo, which over-invalidates rather than under-.
*
* Note that release builds take the dirty path as well: CI runs `version-packages` before
* building and commits the bump afterwards, so the worktree always carries the version edits at
* build time. Harmless, because each published artifact bakes in one stamp and only has to be
* stable within itself, but it does mean a rebuild of identical code produces a different one.
*/
function buildStamp() {
const git = (args) =>
execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
try {
const sha = git(["rev-parse", "--short=12", "HEAD"]);
const dirty = git(["status", "--porcelain"]).length > 0;
return dirty ? `${sha}-dirty-${Date.now()}` : sha;
} catch {
// No git available (a published tarball being rebuilt, for instance). Falling back to the
// build time keeps the stamp honest: it cannot claim to be a commit it does not know.
return `nogit-${Date.now()}`;
}
}

export default createRolldownNodeConfig({
entry: ["./src/index.ts", "./src/worker/worker.ts"],
});
}).map((config) => ({
...config,
// Note `transform.define`, not a top-level `define`: rolldown ignores the latter without
// complaining, which leaves the identifier in the output and the cache permanently cold.
// The spread preserves `transform.target` from the shared config.
transform: {
...config.transform,
define: {
...config.transform?.define,
__PL_ML_BUILD_STAMP__: JSON.stringify(buildStamp()),
},
},
}));
36 changes: 36 additions & 0 deletions lib/node/pl-middle-layer/src/middle_layer/build_stamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** Injected by rolldown at build time, see `build.node.config.js`. Absent when the package is
* consumed straight from sources (`USE_SOURCES=1`), because no build step runs then. */
declare const __PL_ML_BUILD_STAMP__: string | undefined;

function injectedStamp(): string | undefined {
try {
// Read inside a try: with no build step the identifier is an undeclared global, and
// reading it throws a ReferenceError rather than yielding undefined.
return __PL_ML_BUILD_STAMP__;
} catch {
return undefined;
}
}

/**
* Identifies the build of this package, and through it the rules that shape what a persisted
* tree mirror contains: the pruning function, the field filter and the traversal stop rules,
* all of which live in this package. (The finality predicate comes from pl-client and is NOT
* covered, which is harmless: finality is recomputed on restore, so it is the one rule that
* cannot poison a stored file.)
*
* Used as a cache-key component, so a change to those rules invalidates every snapshot, costing
* one cold open. Each built artifact bakes in one stamp, so reopens stay warm across restarts
* of an installed version. A build from a dirty worktree includes the build time, so editing
* those rules locally can never hit a snapshot written under the old ones. In practice release
* builds are dirty too, because CI writes version bumps into the worktree before building; that
* costs nothing, since the stamp only has to be stable within an artifact.
*
* With no build at all (sources mode) the value is a constant. That deliberately trades away
* the dirty-worktree guarantee: it means someone running from sources exercises the restore
* path at all, rather than every snapshot being a guaranteed miss for the one audience most
* likely to find its defects. The exposure it reintroduces, editing pruning rules from sources
* and hitting a mirror written under the old ones, is the local-development gap the design
* already accepts, and `treeSnapshots: false` or deleting the directory clears it.
*/
export const ML_BUILD_STAMP: string = injectedStamp() ?? "sources";
1 change: 1 addition & 0 deletions lib/node/pl-middle-layer/src/middle_layer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export { MiddleLayer } from "./middle_layer";
export { Project } from "./project";
export * from "./driver_kit";
export * from "./ops";
export type { TreeSnapshotMiss, TreeSnapshotStat } from "./tree_snapshot_store";
export { ProjectsField } from "./project_list";
export type { OutgoingShare, PendingShare } from "./sharing_list";
77 changes: 77 additions & 0 deletions lib/node/pl-middle-layer/src/middle_layer/middle_layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ import type { Dispatcher } from "undici";
import { RetryAgent } from "undici";
import { getDebugFlags } from "../debug";
import { ProjectHelper } from "../model/project_helper";
import type { TreeSnapshotStat } from "./tree_snapshot_store";
import { TreeSnapshotStore } from "./tree_snapshot_store";

/** How long shutdown waits for close-boundary snapshot writes that are already running. Long
* enough for a ten-megabyte encode and write on ordinary storage, short enough that a wedged
* filesystem does not hold the quit open. */
const SNAPSHOT_DRAIN_TIMEOUT_MS = 5_000;

export interface MiddleLayerEnvironment {
dispose(): Promise<void>;
Expand All @@ -112,6 +119,9 @@ export interface MiddleLayerEnvironment {
readonly driverKit: MiddleLayerDriverKit;
readonly serviceRegistry: ModelServiceRegistry;
readonly projectHelper: ProjectHelper;
/** Persisted project tree mirrors. Undefined when snapshots are switched off, or when the
* client is impersonating another user, in which case nothing is read or written. */
readonly treeSnapshots?: TreeSnapshotStore;
}

/**
Expand Down Expand Up @@ -941,6 +951,36 @@ export class MiddleLayer {

private readonly openedProjects = new Map<ProjectId, Project>();

/** Snapshot writes started by {@link closeProject} and not yet finished. Held only so
* {@link close} can give them a bounded chance to land. */
private readonly pendingSnapshotWrites = new Set<Promise<void>>();

private trackSnapshotWrite(write: Promise<void>): void {
this.pendingSnapshotWrites.add(write);
void write.finally(() => this.pendingSnapshotWrites.delete(write));
}

/** Waits for close-boundary snapshot writes that are already running, up to `timeoutMs`.
*
* This starts no work: quitting still performs no snapshot of its own. It only lets a write
* that a project close already began finish, so closing a project and immediately quitting
* does not routinely lose it. Bounded, because a wedged filesystem must not hang the quit,
* and losing the write costs one cold open rather than any correctness. */
private async drainSnapshotWrites(timeoutMs: number): Promise<void> {
if (this.pendingSnapshotWrites.size === 0) return;

let timer: NodeJS.Timeout | undefined;
const expiry = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
timer.unref?.();
});
try {
await Promise.race([Promise.allSettled(this.pendingSnapshotWrites), expiry]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}

/** Opens a project, and starts corresponding project maintenance loop. */
public async openProject(id: ProjectId): Promise<void> {
if (this.openedProjects.has(id)) throw new Error(`Project ${id} already opened`);
Expand All @@ -954,6 +994,16 @@ export class MiddleLayer {
const prj = this.openedProjects.get(id);
if (prj === undefined) throw new Error(`Project ${id} not found among opened projects`);
this.openedProjects.delete(id);

// Snapshot before destroy, and here rather than inside destroy(): destroy() is also what
// application shutdown runs, and quitting should perform no snapshot work. Terminating the
// tree invalidates it, so the state has to be taken first either way.
//
// Started, not awaited. The capture happens synchronously inside, which is the part that
// needs the tree alive; the encode and write are up to ten megabytes of work that closing a
// project should not sit behind. Kept so shutdown can drain it.
this.trackSnapshotWrite(prj.snapshotOnClose());

await prj.destroy();
this.openedProjectsList.setValue([...this.openedProjects.keys()]);
}
Expand All @@ -970,6 +1020,13 @@ export class MiddleLayer {
return this.openedProjects.has(id);
}

/** Counters for the persisted project tree mirrors, or undefined when they are switched off.
* Reads and hits are what show whether a reopen was actually warm, and the miss breakdown
* says why it was not. */
public get treeSnapshotStats(): Readonly<TreeSnapshotStat> | undefined {
return this.env.treeSnapshots?.stats;
}

/**
* Deallocates all runtime resources consumed by this object and awaits
* actual termination of event loops and other processes associated with
Expand All @@ -985,6 +1042,7 @@ export class MiddleLayer {
this.sharingStateTree.terminate(),
this.pendingSharesTree.terminate(),
]);
await this.drainSnapshotWrites(SNAPSHOT_DRAIN_TIMEOUT_MS);
await this.env.dispose();
await this.pl.close();
}
Expand Down Expand Up @@ -1092,6 +1150,24 @@ export class MiddleLayer {

const serviceRegistry = createModelServiceRegistry({ logger });

const treeSnapshots = TreeSnapshotStore.create(pl, {
dir: ops.treeSnapshotPath,
maxSizeBytes: ops.treeSnapshotOps.maxSizeBytes,
enabled: ops.treeSnapshotOps.enabled,
logger,
});
if (ops.treeSnapshotOps.enabled) {
// Housekeeping before any project opens: drop snapshots from other builds, backends and
// users, then trim to the ceiling.
await treeSnapshots?.evict();
} else {
// Switched off, so reclaim what earlier sessions left on disk. The reason to reach for
// this switch is usually the disk itself, and leaving the files behind would answer the
// wrong half of that complaint. Keyed on the setting, not on the store being absent: it
// is also absent for an impersonated client, whose session must not delete anything.
await TreeSnapshotStore.purge(ops.treeSnapshotPath, logger);
}

const env: MiddleLayerEnvironment = {
pl,
blockEventDispatcher: new BlockEventDispatcher(),
Expand All @@ -1112,6 +1188,7 @@ export class MiddleLayer {
serviceRegistry,
quickJs,
projectHelper: new ProjectHelper(quickJs, logger),
treeSnapshots,
dispose: async () => {
await serviceRegistry.dispose();
await retryHttpDispatcher.destroy();
Expand Down
47 changes: 46 additions & 1 deletion lib/node/pl-middle-layer/src/middle_layer/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,40 @@ export type DriverKitOpsConstructor = Omit<
export type MiddleLayerOpsPaths = DriverKitOpsPaths & {
/** Common root where to put frontend code. */
readonly frontendDownloadPath: string;

/**
* Directory holding persisted project tree mirrors, one file per project. Like
* {@link DriverKitOpsPaths.parquetCachePath} and unlike the spill directories, it is NOT
* emptied on startup: surviving a restart is the entire point. It is pruned instead, see
* {@link TreeSnapshotOps.maxSizeBytes}.
*/
readonly treeSnapshotPath: string;
};

/** Tuning for the persisted project tree mirrors. Their directory is
* {@link MiddleLayerOpsPaths.treeSnapshotPath}; this carries the behaviour knobs. */
export type TreeSnapshotOps = {
/**
* Whether project tree mirrors are persisted and restored at all.
*
* On by default. This is an operational kill switch, for a deployment where the cache
* directory turns out to be unwritable or otherwise troublesome, not a rollout gate: the
* floor of the feature is current behaviour, since a cache that never hits is a cold open.
*/
readonly enabled: boolean;

/**
* Minimum wall-clock gap between periodic writes for one project.
*
* Can be generous, because a stale snapshot is less complete rather than wrong: final
* resources never change and are never refetched, so this only bounds how much recent work
* comes back from the non-final frontier on restore.
*/
readonly writeInterval: number;

/** Total bytes the snapshot directory may occupy after startup eviction. Needed because a
* heavy project runs to roughly ten megabytes. */
readonly maxSizeBytes: number;
};

/** Debug options for middle layer. */
Expand Down Expand Up @@ -253,6 +287,10 @@ export type MiddleLayerOpsSettings = DriverKitOpsSettings & {
* `sharedAt + envelopeTtlMs`. Share-with-everybody envelopes never expire
* (`expiresAt: null`) and ignore this. */
readonly envelopeTtlMs: number;

/** Settings for persisting project tree mirrors to disk, so reopening a project transfers
* what changed rather than the tree again. */
readonly treeSnapshotOps: TreeSnapshotOps;
};

export type MiddleLayerOps = MiddleLayerOpsSettings & MiddleLayerOpsPaths;
Expand All @@ -266,6 +304,7 @@ export const DefaultMiddleLayerOpsSettings: Pick<
| "devBlockUpdateRecheckInterval"
| "debugOps"
| "envelopeTtlMs"
| "treeSnapshotOps"
> = {
...DefaultDriverKitOpsSettings,
defaultTreeOptions: {
Expand All @@ -279,17 +318,23 @@ export const DefaultMiddleLayerOpsSettings: Pick<
devBlockUpdateRecheckInterval: 1000,
projectRefreshInterval: 2000,
envelopeTtlMs: 14 * 24 * 3600 * 1000, // 14 days
treeSnapshotOps: {
enabled: true,
writeInterval: 5 * 60 * 1000, // 5 minutes
maxSizeBytes: 256 * 1024 * 1024, // 256 MB, roughly 25 heavy projects
},
};

export function DefaultMiddleLayerOpsPaths(
workDir: string,
): Pick<
MiddleLayerOpsPaths,
keyof ReturnType<typeof DefaultDriverKitOpsPaths> | "frontendDownloadPath"
keyof ReturnType<typeof DefaultDriverKitOpsPaths> | "frontendDownloadPath" | "treeSnapshotPath"
> {
return {
...DefaultDriverKitOpsPaths(workDir),
frontendDownloadPath: path.join(workDir, "frontend"),
treeSnapshotPath: path.join(workDir, "treeSnapshots"),
};
}

Expand Down
Loading
Loading