Skip to content

Commit 767ce65

Browse files
authored
Merge pull request #842 from git-stunts/fix/default-checkpoint-policy
fix!: default checkpointPolicy to { every: 64 }
2 parents 7ed1fd2 + 4764176 commit 767ce65

17 files changed

Lines changed: 425 additions & 39 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **BREAKING:** `checkpointPolicy` now defaults to `{ every: 64 }` instead of
13+
being disabled. Auto-checkpointing is what bounds replay depth, so leaving it
14+
off unless a caller opted in made unbounded growth the default: a graph opened
15+
without a policy replayed every patch since its last explicit checkpoint on
16+
each materialize, forever, and reads paid for it. Observed in practice at 262
17+
unreplayed patches, where a single read spawned 5,267 Git subprocesses and the
18+
backlog grew by two commits per write with no upper bound.
19+
20+
An omitted policy now takes the default. `checkpointPolicy: null` remains the
21+
explicit opt-out and is unchanged, so callers that genuinely want no
22+
compaction keep it by asking for it rather than by forgetting.
23+
24+
This alters observable write behaviour for any consumer that never supplied a
25+
policy: such graphs will begin writing checkpoint commits
26+
once their replay depth reaches or exceeds 64 patches. State hashes are
27+
unaffected — a checkpoint is a snapshot, not a semantic change.
28+
1029
## [19.0.2] - 2026-07-29
1130

1231
### Release notes

src/domain/RuntimeHost.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import {
9494
type RuntimeHostOpenInput,
9595
type RuntimeHostConstructionOptions,
9696
} from './warp/RuntimeHostBoot.ts';
97+
import type CheckpointPolicy from './warp/CheckpointPolicy.ts';
9798

9899
import type { NeighborEdge } from '../ports/NeighborProviderPort.ts';
99100

@@ -210,7 +211,7 @@ export default class RuntimeHost {
210211
_patchesSinceGC: number;
211212
_patchesSinceCheckpoint: number;
212213
_maxObservedLamport: number;
213-
_checkpointPolicy: { every: number } | null;
214+
_checkpointPolicy: CheckpointPolicy | null;
214215
_checkpointing: boolean;
215216
_autoMaterialize: boolean;
216217
traverse: LogicalTraversal;
@@ -321,7 +322,7 @@ export default class RuntimeHost {
321322
this._patchesSinceGC = 0;
322323
this._patchesSinceCheckpoint = 0;
323324
this._maxObservedLamport = 0;
324-
this._checkpointPolicy = checkpointPolicy || null;
325+
this._checkpointPolicy = checkpointPolicy;
325326
this._checkpointing = false;
326327
this._autoMaterialize = autoMaterialize;
327328
this._materializedGraph = null;

src/domain/WarpGraph.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import type { ExternalizationPolicy } from './types/ExternalizationPolicy.ts';
3838
import type { GCPolicyConfig } from './services/GCPolicy.ts';
3939
import type RuntimeStorageProviderPort from '../ports/RuntimeStorageProviderPort.ts';
4040
import type TrustCryptoPort from '../ports/TrustCryptoPort.ts';
41+
import type CheckpointPolicy from './warp/CheckpointPolicy.ts';
42+
import type { CheckpointPolicyConfig } from './warp/CheckpointPolicy.ts';
4143

4244
// ---------------------------------------------------------------------------
4345
// WarpGraph — frozen capability bag, organized by architectural moment
@@ -143,7 +145,7 @@ export interface WarpGraphDeps {
143145
// Governing policy
144146
readonly trust?: { mode?: TrustMode; pin?: string | null };
145147
readonly gcPolicy?: GCPolicyConfig;
146-
readonly checkpointPolicy?: { every: number };
148+
readonly checkpointPolicy?: CheckpointPolicyConfig | CheckpointPolicy | null;
147149
readonly onDeleteWithData?: 'reject' | 'cascade' | 'warn';
148150
readonly autoMaterialize?: boolean;
149151

src/domain/services/controllers/ForkController.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type AssetStoragePort from '../../../ports/AssetStoragePort.ts';
2727
import type GCPolicy from '../GCPolicy.ts';
2828
import type RuntimeStorageProviderPort from '../../../ports/RuntimeStorageProviderPort.ts';
2929
import type PatchJournalPort from '../../../ports/PatchJournalPort.ts';
30+
import type CheckpointPolicy from '../../warp/CheckpointPolicy.ts';
3031

3132
const HEX_CHARS = '0123456789abcdef';
3233
type ForkRuntimeOpenOptions = RuntimeHostOpenOptions;
@@ -49,7 +50,7 @@ type ForkHost = {
4950
_runtimeStorage: RuntimeStorageProviderPort;
5051
_graphName: string;
5152
_gcPolicy: GCPolicy;
52-
_checkpointPolicy: { every: number } | null;
53+
_checkpointPolicy: CheckpointPolicy | null;
5354
_autoMaterialize: boolean;
5455
_onDeleteWithData: 'reject' | 'cascade' | 'warn';
5556
_logger: LoggerPort | null;
@@ -123,10 +124,13 @@ export default class ForkController {
123124
forkName ?? `${host._graphName}-fork-${randomSuffix()}`;
124125
try {
125126
validateGraphName(resolvedForkName);
126-
} catch (err) {
127-
throw new ForkError(`Invalid fork name: ${(err as Error).message}`, {
127+
} catch (error) {
128+
if (!(error instanceof Error)) {
129+
throw error;
130+
}
131+
throw new ForkError(`Invalid fork name: ${error.message}`, {
128132
code: 'E_FORK_NAME_INVALID',
129-
context: { forkName: resolvedForkName, originalError: (err as Error).message },
133+
context: { forkName: resolvedForkName, originalError: error.message },
130134
});
131135
}
132136

@@ -142,10 +146,13 @@ export default class ForkController {
142146
const resolvedForkWriterId = (forkWriterId !== undefined && forkWriterId !== null && forkWriterId !== '') ? forkWriterId : generateWriterId();
143147
try {
144148
validateWriterId(resolvedForkWriterId);
145-
} catch (err) {
146-
throw new ForkError(`Invalid fork writer ID: ${(err as Error).message}`, {
149+
} catch (error) {
150+
if (!(error instanceof Error)) {
151+
throw error;
152+
}
153+
throw new ForkError(`Invalid fork writer ID: ${error.message}`, {
147154
code: 'E_FORK_WRITER_ID_INVALID',
148-
context: { forkWriterId: resolvedForkWriterId, originalError: (err as Error).message },
155+
context: { forkWriterId: resolvedForkWriterId, originalError: error.message },
149156
});
150157
}
151158

@@ -161,7 +168,7 @@ export default class ForkController {
161168
graphName: resolvedForkName,
162169
writerId: resolvedForkWriterId,
163170
gcPolicy: host._gcPolicy,
164-
...(host._checkpointPolicy ? { checkpointPolicy: host._checkpointPolicy } : {}),
171+
checkpointPolicy: host._checkpointPolicy,
165172
autoMaterialize: host._autoMaterialize,
166173
onDeleteWithData: host._onDeleteWithData,
167174
...(host._logger ? { logger: host._logger } : {}),

src/domain/services/controllers/detachedOpen.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { NormalizedTrustConfig } from '../../runtimeHelpers.ts';
1212
import type GCPolicy from '../GCPolicy.ts';
1313
import type { DetachedGraphInternalReadSurface } from '../../capabilities/DetachedGraphFactory.ts';
1414
import type RuntimeStorageProviderPort from '../../../ports/RuntimeStorageProviderPort.ts';
15+
import type CheckpointPolicy from '../../warp/CheckpointPolicy.ts';
1516

1617
export type DetachedOpenOptions = {
1718
persistence: CorePersistence;
@@ -24,7 +25,7 @@ export type DetachedOpenOptions = {
2425
crypto: CryptoPort;
2526
codec: CodecPort;
2627
audit: false;
27-
checkpointPolicy?: { every: number };
28+
checkpointPolicy?: CheckpointPolicy | null;
2829
logger?: LoggerPort;
2930
trust?: NormalizedTrustConfig;
3031
};
@@ -37,7 +38,7 @@ export type DetachedOpenHost = {
3738
_graphName: string;
3839
_writerId: string;
3940
_gcPolicy: GCPolicy;
40-
_checkpointPolicy: { every: number } | null;
41+
_checkpointPolicy: CheckpointPolicy | null;
4142
_logger: LoggerPort | null;
4243
_trustConfig: NormalizedTrustConfig;
4344
_onDeleteWithData: 'reject' | 'cascade' | 'warn';
@@ -61,7 +62,7 @@ function coreOptions(graph: DetachedOpenHost): DetachedOpenOptions {
6162
}
6263

6364
function addReadPolicy(opts: DetachedOpenOptions, g: DetachedOpenHost): void {
64-
if (g._checkpointPolicy) { opts.checkpointPolicy = g._checkpointPolicy; }
65+
opts.checkpointPolicy = g._checkpointPolicy;
6566
if (g._logger) { opts.logger = g._logger; }
6667
}
6768

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import WarpError from '../errors/WarpError.ts';
2+
3+
export type CheckpointPolicyConfig = {
4+
readonly every: number;
5+
};
6+
7+
const DEFAULT_CHECKPOINT_INTERVAL = 64;
8+
9+
/** Immutable, validated cadence for automatic replay checkpoints. */
10+
export default class CheckpointPolicy {
11+
readonly every: number;
12+
13+
constructor(every: number) {
14+
if (!Number.isInteger(every) || every <= 0) {
15+
throw new WarpError(
16+
'checkpointPolicy.every must be a positive integer',
17+
'E_CHECKPOINT_POLICY_EVERY',
18+
);
19+
}
20+
this.every = every;
21+
Object.freeze(this);
22+
}
23+
24+
static readonly DEFAULT: CheckpointPolicy = new CheckpointPolicy(
25+
DEFAULT_CHECKPOINT_INTERVAL,
26+
);
27+
28+
static from(
29+
value: CheckpointPolicyConfig | CheckpointPolicy,
30+
): CheckpointPolicy {
31+
if (value instanceof CheckpointPolicy) {
32+
return value;
33+
}
34+
if (typeof value !== 'object' || value === null) {
35+
throw new WarpError(
36+
'checkpointPolicy must be an object with { every: number }',
37+
'E_CHECKPOINT_POLICY_TYPE',
38+
);
39+
}
40+
return new CheckpointPolicy(value.every);
41+
}
42+
}

src/domain/warp/RuntimeHostBoot.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type { EffectPipeline } from '../services/EffectPipeline.ts';
4444
import type { ExternalizationPolicy } from '../types/ExternalizationPolicy.ts';
4545
import GCPolicy, { type GCPolicyConfig } from '../services/GCPolicy.ts';
4646
import type { MaterializeSessionOpener } from '../services/controllers/MaterializeSessionBridge.ts';
47+
import CheckpointPolicy, { type CheckpointPolicyConfig } from './CheckpointPolicy.ts';
4748

4849
type DeletePolicy = 'reject' | 'cascade' | 'warn';
4950
const VALID_DELETE_POLICIES: ReadonlyArray<DeletePolicy> = ['reject', 'cascade', 'warn'];
@@ -54,7 +55,7 @@ export type RuntimeHostConstructionOptions = {
5455
graphName: string;
5556
writerId: string;
5657
gcPolicy?: GCPolicyConfig | GCPolicy;
57-
checkpointPolicy?: { every: number };
58+
checkpointPolicy: CheckpointPolicy | null;
5859
autoMaterialize?: boolean;
5960
onDeleteWithData?: DeletePolicy;
6061
logger?: LoggerPort;
@@ -89,7 +90,7 @@ export type RuntimeHostOpenOptions = {
8990
graphName: string;
9091
writerId: string;
9192
gcPolicy?: GCPolicyConfig | GCPolicy;
92-
checkpointPolicy?: { every: number } | null;
93+
checkpointPolicy?: CheckpointPolicyConfig | CheckpointPolicy | null;
9394
autoMaterialize?: boolean;
9495
onDeleteWithData?: DeletePolicy;
9596
logger?: LoggerPort;
@@ -113,7 +114,7 @@ export class WarpOpenOptions {
113114
readonly graphName: string;
114115
readonly writerId: string;
115116
readonly gcPolicy: GCPolicyConfig | GCPolicy;
116-
readonly checkpointPolicy?: { every: number };
117+
readonly checkpointPolicy: CheckpointPolicy | null;
117118
readonly autoMaterialize?: boolean;
118119
readonly onDeleteWithData?: DeletePolicy;
119120
readonly logger?: LoggerPort;
@@ -148,10 +149,7 @@ export class WarpOpenOptions {
148149
if (options.codec !== undefined) { this.codec = options.codec; }
149150
if (options.trustCrypto !== undefined) { this.trustCrypto = options.trustCrypto; }
150151

151-
const checkpointPolicy = normalizeCheckpointPolicy(options.checkpointPolicy);
152-
if (checkpointPolicy !== undefined) {
153-
this.checkpointPolicy = checkpointPolicy;
154-
}
152+
this.checkpointPolicy = normalizeCheckpointPolicy(options.checkpointPolicy);
155153
if (options.autoMaterialize !== undefined) {
156154
this.autoMaterialize = normalizeBooleanOption(
157155
options.autoMaterialize,
@@ -202,6 +200,18 @@ export class WarpOpenOptions {
202200

203201
export type RuntimeHostOpenInput = RuntimeHostOpenOptions | WarpOpenOptions;
204202

203+
/**
204+
* Auto-checkpoint cadence applied when a caller supplies no `checkpointPolicy`.
205+
*
206+
* `every` is compared against the replay depth reported by a materialize (the
207+
* patch count since the last checkpoint), not against writes performed by the
208+
* current process, so short-lived callers still compact once the backlog
209+
* crosses the threshold.
210+
*
211+
* Pass `checkpointPolicy: null` to disable auto-checkpointing entirely.
212+
*/
213+
export const DEFAULT_CHECKPOINT_POLICY: CheckpointPolicy = CheckpointPolicy.DEFAULT;
214+
205215
function normalizeBooleanOption(value: boolean, label: string, code: string): boolean {
206216
if (typeof value !== 'boolean') {
207217
throw new WarpError(`${label} must be a boolean`, code);
@@ -210,18 +220,20 @@ function normalizeBooleanOption(value: boolean, label: string, code: string): bo
210220
}
211221

212222
function normalizeCheckpointPolicy(
213-
checkpointPolicy: { every: number } | null | undefined,
214-
): { every: number } | undefined {
215-
if (checkpointPolicy === null || checkpointPolicy === undefined) {
216-
return undefined;
217-
}
218-
if (typeof checkpointPolicy !== 'object') {
219-
throw new WarpError('checkpointPolicy must be an object with { every: number }', 'E_CHECKPOINT_POLICY_TYPE');
223+
checkpointPolicy: CheckpointPolicyConfig | CheckpointPolicy | null | undefined,
224+
): CheckpointPolicy | null {
225+
// An omitted policy takes the default. Auto-checkpointing is what bounds
226+
// replay depth, so leaving it off by default made every caller that never
227+
// supplied a policy replay its entire patch history on each materialize,
228+
// growing without limit. `null` remains the explicit opt-out for callers
229+
// that genuinely want no compaction.
230+
if (checkpointPolicy === undefined) {
231+
return DEFAULT_CHECKPOINT_POLICY;
220232
}
221-
if (!Number.isInteger(checkpointPolicy.every) || checkpointPolicy.every <= 0) {
222-
throw new WarpError('checkpointPolicy.every must be a positive integer', 'E_CHECKPOINT_POLICY_EVERY');
233+
if (checkpointPolicy === null) {
234+
return null;
223235
}
224-
return Object.freeze({ every: checkpointPolicy.every });
236+
return CheckpointPolicy.from(checkpointPolicy);
225237
}
226238

227239
function snapshotGCPolicy(value: GCPolicyConfig | GCPolicy | undefined): GCPolicyConfig | GCPolicy {
@@ -371,7 +383,7 @@ export async function resolveRuntimeHostConstructionOptions(
371383
graphName,
372384
writerId,
373385
gcPolicy,
374-
...(checkpointPolicy !== undefined ? { checkpointPolicy } : {}),
386+
checkpointPolicy,
375387
...(autoMaterialize !== undefined ? { autoMaterialize } : {}),
376388
...(onDeleteWithData !== undefined ? { onDeleteWithData } : {}),
377389
...(logger !== undefined ? { logger } : {}),

src/domain/warp/RuntimeHostProduct.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import type {
3838
RuntimeHostOpenInput as RuntimeHostBootOpenInput,
3939
RuntimeHostOpenOptions as RuntimeHostBootOpenOptions,
4040
} from './RuntimeHostBoot.ts';
41+
import type CheckpointPolicy from './CheckpointPolicy.ts';
4142
import { openRuntimeHost } from '../RuntimeHost.ts';
4243

4344
export type RuntimeCapabilitySurface =
@@ -143,7 +144,7 @@ export type RuntimeHostProduct = RuntimeGraphHostProduct & {
143144
_patchesSinceGC: number;
144145
_patchesSinceCheckpoint: number;
145146
_maxObservedLamport: number;
146-
_checkpointPolicy: { every: number } | null;
147+
_checkpointPolicy: CheckpointPolicy | null;
147148
_autoMaterialize: boolean;
148149
_assetStorage: AssetStoragePort;
149150
_checkpointStore: CheckpointStorePort;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { readFileSync } from 'node:fs';
2+
import { fileURLToPath } from 'node:url';
3+
import { describe, expect, it } from 'vitest';
4+
5+
const CHANGELOG_PATH = fileURLToPath(
6+
new URL('../../CHANGELOG.md', import.meta.url),
7+
);
8+
9+
describe('checkpoint policy changelog', () => {
10+
it('describes the default threshold as inclusive', () => {
11+
const changelog = readFileSync(CHANGELOG_PATH, 'utf8');
12+
13+
expect(changelog).toContain(
14+
'once their replay depth reaches or exceeds 64 patches',
15+
);
16+
});
17+
});

0 commit comments

Comments
 (0)