Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
26 changes: 24 additions & 2 deletions docs/topics/cas-first-memoized-materialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,28 @@ through `git-cas`.

## The Live Materialization Lifecycle

When a Git-backed runtime has a state cache, live materialization follows this
coordinate-first lifecycle:
There are now two deliberately different controller contracts:

- `resolveLiveMaterialization()` returns an operation-scoped retained-handle
resolution. An exact coordinate hit uses git-cas `CacheSet.acquire()` and
does not open the legacy state cache, patch streams, a state session, or the
whole-state projector. The acquisition pins the observed cache generation
until the caller invokes `release()`; replacement or eviction cannot collect
its roots during that scope. A miss does not publish a legacy full-state
snapshot and acquires the newly retained handle before returning it.
- `materialize()` remains the explicit compatibility and diagnostic operation
that returns a complete state projection.

The split prevents callers that need only a durable basis from paying the
graph-sized cost required by the legacy result shape. On a retained-handle miss,
handle resolution still performs the cold materialization path until every
independently addressable root can be produced without a complete state.
Acquisition release is mandatory on both warm and cold non-empty resolutions;
release failures remain operational failures, while failure-path cleanup never
replaces the primary materialization error.

When a Git-backed runtime has a state cache, the compatibility `materialize()`
operation follows this coordinate-first lifecycle:

```text
[current frontier]
Expand Down Expand Up @@ -143,6 +163,8 @@ lost payload bytes, or run Git garbage collection.

## Current Limitations

- RuntimeHost and checkpoint creation do not yet consume the handle-first
result, so their compatibility path still owns process-resident whole state.
- Exact state-cache hits bypass replay, but full materialization still hydrates
a full `WarpState`, scans retained node/edge tries, and builds full adjacency.
- Retained materialization descriptors currently carry node/edge trie roots;
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
},
"dependencies": {
"@git-stunts/alfred": "^0.10.4",
"@git-stunts/git-cas": "^6.2.0",
"@git-stunts/git-cas": "^6.3.0",
"@git-stunts/plumbing": "^3.1.0",
"@git-stunts/trailer-codec": "^2.1.1",
"@noble/hashes": "^2.2.0",
Expand Down
135 changes: 135 additions & 0 deletions src/domain/materialization/LiveMaterializationResolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import WarpError from '../errors/WarpError.ts';
import MaterializationHandle from './MaterializationHandle.ts';

export type LiveMaterializationSource = 'empty' | 'retained' | 'materialized';

export type LiveMaterializationResolutionOptions =
| Readonly<{
materialization: null;
source: 'empty';
replayedPatchCount: 0;
release: () => Promise<void>;
}>
| Readonly<{
materialization: MaterializationHandle;
source: 'retained';
replayedPatchCount: 0;
release: () => Promise<void>;
}>
| Readonly<{
materialization: MaterializationHandle;
source: 'materialized';
replayedPatchCount: number;
release: () => Promise<void>;
}>;

const LIVE_MATERIALIZATION_SOURCES = new Set<LiveMaterializationSource>([
'empty',
'retained',
'materialized',
]);

/** Validated operation-scoped access to a live materialization handle. */
export default class LiveMaterializationResolution {
readonly materialization: MaterializationHandle | null;
readonly source: LiveMaterializationSource;
readonly replayedPatchCount: number;
readonly #releaseOperation: () => Promise<void>;

constructor(options: LiveMaterializationResolutionOptions | null | undefined) {
const fields = requireOptions(options);
const materialization = requireMaterialization(fields.materialization);
const source = requireSource(fields.source);
const replayedPatchCount = requirePatchCount(fields.replayedPatchCount);
const releaseOperation = requireRelease(fields.release);
validateCombination({ materialization, source, replayedPatchCount });

this.materialization = materialization;
this.source = source;
this.replayedPatchCount = replayedPatchCount;
this.#releaseOperation = releaseOperation;
Object.freeze(this);
}

release(): Promise<void> {
return this.#releaseOperation();
}
}

function requireOptions(
options: LiveMaterializationResolutionOptions | null | undefined,
): LiveMaterializationResolutionOptions {
if (options === null || options === undefined) {
throw resolutionError('options are required');
}
return options;
}

function requireMaterialization(
materialization: MaterializationHandle | null,
): MaterializationHandle | null {
if (materialization !== null && !(materialization instanceof MaterializationHandle)) {
throw resolutionError('materialization has an invalid runtime identity');
}
return materialization;
}

function requireSource(source: LiveMaterializationSource): LiveMaterializationSource {
if (!LIVE_MATERIALIZATION_SOURCES.has(source)) {
throw resolutionError('source is invalid');
}
return source;
}

function requirePatchCount(replayedPatchCount: number): number {
if (!Number.isSafeInteger(replayedPatchCount) || replayedPatchCount < 0) {
throw resolutionError('replayedPatchCount must be a non-negative safe integer');
}
return replayedPatchCount;
}

function requireRelease(release: () => Promise<void>): () => Promise<void> {
if (typeof release !== 'function') {
throw resolutionError('release must be a function');
}
return release;
}

function validateCombination(fields: Readonly<{
materialization: MaterializationHandle | null;
source: LiveMaterializationSource;
replayedPatchCount: number;
}>): void {
if (fields.source === 'empty') {
validateEmptyCombination(fields);
return;
}
if (fields.materialization === null) {
throw resolutionError(`${fields.source} source requires a materialization`);
}
if (fields.source === 'retained') {
validateRetainedCombination(fields.replayedPatchCount);
}
}

function validateEmptyCombination(fields: Readonly<{
materialization: MaterializationHandle | null;
replayedPatchCount: number;
}>): void {
if (fields.materialization !== null || fields.replayedPatchCount !== 0) {
throw resolutionError('empty source cannot carry a materialization or replayed patches');
}
}

function validateRetainedCombination(replayedPatchCount: number): void {
if (replayedPatchCount !== 0) {
throw resolutionError('retained source cannot report replayed patches');
}
}

function resolutionError(message: string): WarpError {
return new WarpError(
`Live materialization resolution ${message}`,
'E_MATERIALIZATION_RESOLUTION',
);
}
21 changes: 19 additions & 2 deletions src/domain/services/controllers/MaterializationWorkspaceCleanup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type LoggerPort from '../../../ports/LoggerPort.ts';
import type { MaterializationAcquisition } from '../../../ports/MaterializationStorePort.ts';
import type MaterializationWorkspacePort from '../../../ports/MaterializationWorkspacePort.ts';

/** Release a workspace without allowing cleanup to replace an existing failure. */
Expand All @@ -13,9 +14,25 @@ export async function releaseWorkspaceAfterFailure(
}
}

function reportCleanupFailure<Failure>(logger: LoggerPort | undefined, failure: Failure): void {
/** Release a retained materialization without replacing an active failure. */
export async function releaseAcquisitionAfterFailure(
acquisition: Pick<MaterializationAcquisition, 'release'> | null,
logger?: LoggerPort,
): Promise<void> {
try {
await acquisition?.release();
} catch (cleanupFailure) {
reportCleanupFailure(logger, cleanupFailure, 'acquisition');
}
}

function reportCleanupFailure<Failure>(
logger: LoggerPort | undefined,
failure: Failure,
scope = 'workspace',
): void {
try {
logger?.warn('[warp] materialization workspace release failed during error cleanup', {
logger?.warn(`[warp] materialization ${scope} release failed during error cleanup`, {
error: failure instanceof Error ? failure.message : String(failure),
});
} catch {
Expand Down
92 changes: 46 additions & 46 deletions src/domain/services/controllers/MaterializeController.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,4 @@
/**
* MaterializeController — CRDT state replay from patches.
*
* Three materialization pipelines:
* 1. materialize() — full or checkpoint-incremental, live frontier
* 2. materializeCoordinate() — explicit frontier snapshot
* 3. materializeAt() — specific checkpoint SHA
*
* Dependencies are constructor-injected. No host bag.
* Side effects (notification, GC, auto-checkpoint) are the caller's job.
*/
/** CRDT replay and retained-handle resolution across supported coordinates. */
import { reducePatches as reduceJoinedPatches, createEmptyState } from '../JoinReducer.ts';
import { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts';
import { computeStateHash } from '../state/StateSerializer.ts';
Expand Down Expand Up @@ -58,6 +48,7 @@ import type { PatchDiff } from '../../types/PatchDiff.ts';
import AdjacencyMap from '../../capabilities/AdjacencyMap.ts';
import MaterializationCoordinate from '../../materialization/MaterializationCoordinate.ts';
import type MaterializationHandle from '../../materialization/MaterializationHandle.ts';
import type LiveMaterializationResolution from '../../materialization/LiveMaterializationResolution.ts';
import type MaterializationRoots from '../../materialization/MaterializationRoots.ts';
import type StorageRetentionWitness from '../../storage/StorageRetentionWitness.ts';
import WarpError from '../../errors/WarpError.ts';
Expand All @@ -66,7 +57,10 @@ import type {
MaterializeResultBuildInput,
MaterializeStrategyRuntime,
} from './MaterializeStrategyRuntime.ts';
import { releaseWorkspaceAfterFailure } from './MaterializationWorkspaceCleanup.ts';
import {
releaseAcquisitionAfterFailure,
releaseWorkspaceAfterFailure,
} from './MaterializationWorkspaceCleanup.ts';
export type MaterializePersistence = {
readRef(ref: string): Promise<string | null>;
};
Expand Down Expand Up @@ -145,8 +139,6 @@ function reduceMaterializePatches(
return reducePlain(patches, base);
}

// ── Provenance ──────────────────────────────────────────────────────

function buildProvenance(patches: PatchWithSha[], base?: ProvenanceIndex): ProvenanceIndex {
const index = base ? base.clone() : new ProvenanceIndex();
for (const { patch, sha } of patches) {
Expand All @@ -155,14 +147,10 @@ function buildProvenance(patches: PatchWithSha[], base?: ProvenanceIndex): Prove
return index;
}

// ── State hash ──────────────────────────────────────────────────────

async function computeHash(deps: MaterializeDeps, state: WarpState): Promise<string> {
return await computeStateHash(state, { crypto: deps.crypto, codec: deps.codec });
}

// ── Controller ──────────────────────────────────────────────────────

export default class MaterializeController {
private readonly _deps: MaterializeDeps;
private readonly _liveStrategy: MaterializeLiveStrategy;
Expand Down Expand Up @@ -192,6 +180,9 @@ export default class MaterializeController {
wantDiff: opts.wantDiff === true,
});
}
resolveLiveMaterialization(): Promise<LiveMaterializationResolution> {
return this._liveStrategy.resolveMaterialization();
}

/** Coordinate materialization — explicit frontier. */
async materializeCoordinate(
Expand Down Expand Up @@ -352,35 +343,44 @@ export default class MaterializeController {
return null;
}
const coordinate = new MaterializationCoordinate(snapshot.coordinate);
const retained = await this._deps.materializations.findExact(coordinate);
if (retained !== null && retained.stateHash !== snapshot.stateHash) {
throw materializationResumeError('retained handle and snapshot state hashes differ');
const acquisition = await this._deps.materializations.acquireExact(coordinate);
let result: MaterializeResult;
try {
const retained = acquisition?.materialization ?? null;
if (retained !== null && retained.stateHash !== snapshot.stateHash) {
throw materializationResumeError('retained handle and snapshot state hashes differ');
}
const retainedRoots = retained === null
? null
: materializationSessionOpen(retained.roots);
const reduced = await reduceSessionBackedState({
openStateSession,
materializations: this._deps.materializations,
logger: this._deps.logger,
coordinate,
patches: [],
baseState: snapshot.state,
...(retainedRoots === null ? {} : { roots: retainedRoots }),
receipts: false,
wantDiff: options.wantDiff,
});
result = await this._buildResult({
reduced,
summary: new MaterializePatchSummaryAccumulator().toSummary(),
degraded: true,
ceiling: snapshot.coordinate.ceiling,
frontier: snapshot.coordinate.frontier,
publishSnapshot: false,
...(retained === null || retainedRoots === null
? {}
: { materialization: retained }),
});
} catch (raw) {
await releaseAcquisitionAfterFailure(acquisition, this._deps.logger);
throw raw;
}
const retainedRoots = retained === null
? null
: materializationSessionOpen(retained.roots);
const reduced = await reduceSessionBackedState({
openStateSession,
materializations: this._deps.materializations,
logger: this._deps.logger,
coordinate,
patches: [],
baseState: snapshot.state,
...(retainedRoots === null ? {} : { roots: retainedRoots }),
receipts: false,
wantDiff: options.wantDiff,
});
return await this._buildResult({
reduced,
summary: new MaterializePatchSummaryAccumulator().toSummary(),
degraded: true,
ceiling: snapshot.coordinate.ceiling,
frontier: snapshot.coordinate.frontier,
publishSnapshot: false,
...(retained === null || retainedRoots === null
? {}
: { materialization: retained }),
});
await acquisition?.release();
return result;
}

private async _reducePatches(
Expand Down
Loading
Loading