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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Performance

- **Bounded immutable page payload reuse** - repeated `pages.get()` calls now
share successful in-flight and completed payload reads through an LRU bounded
by both entry count and aggregate bytes. Callers receive copies, failures
remain retryable, oversized values do not displace unrelated residents, and
`pages.open()` remains uncached and streaming.

## [6.5.0] — 2026-07-18

### Added
Expand Down
6 changes: 6 additions & 0 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ lookup reads only the descriptor path and selected payload; retention and
publication perform full bounded graph validation. Identical page bytes and
identically constructed bundles produce identical handles.

Repeated `pages.get()` calls reuse immutable payload reads within the store's
bounded page cache. The defaults retain at most 128 payloads and 8 MiB; use
`pageCacheEntries` and `pageCacheBytes` to tune that ceiling. Every result is a
caller-owned copy. `pages.open()` remains uncached and streaming, and neither
form of access establishes retention.

### Generic Application Publication

Publication is disabled until the facade receives an explicit application-ref
Expand Down
15 changes: 15 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ new ContentAddressableStore(options);
- `options.maxRestoreBufferSize` (optional): Max bytes for buffered encrypted/compressed restore (default: 536870912 / 512 MiB)
- `options.maxBlobSize` (optional): Max bytes for metadata blob reads (default: 10485760 / 10 MiB)
- `options.maxPageSize` (optional): Maximum immutable page bytes (default: 16777216 / 16 MiB)
- `options.pageCacheEntries` (optional): Maximum immutable page payloads retained in memory (default: 128)
- `options.pageCacheBytes` (optional): Maximum immutable page payload bytes retained in memory (default: 8388608 / 8 MiB)
- `options.bundleLimits` (optional): Repository maximums for bundle members, path bytes, descriptor bytes, fanout entries, and fanout depth
- `options.maxBundleNestingDepth` (optional): Maximum nested bundle depth (default: 32)
- `options.compressionAdapter` (optional): CompressionPort implementation (default: NodeCompressionAdapter)
Expand Down Expand Up @@ -1025,6 +1027,19 @@ metadata before streaming it. `get()` additionally collects the page under its
effective byte bound. An imported handle above the configured maximum fails
with `PAGE_TOO_LARGE` without materializing the blob.

Successful `get()` payloads share in-flight and completed immutable reads inside
one store instance. The default LRU retains at most 128 payloads and 8 MiB of
payload bytes; `pageCacheEntries` and `pageCacheBytes` may lower or raise those
bounds. Every result is copied for its caller, failed reads remain retryable,
and a payload larger than the byte budget is returned without remaining
resident. Cold misses validate object type and size before reading. Resident or
in-flight hits reuse that immutable OID validation and still enforce the current
caller's `maxBytes`. In-flight work is coalesced separately from completed LRU
residence, so its transient memory follows caller concurrency and the per-page
size bound. `open()` remains streaming and does not enter the payload cache.
This reuse is not retention evidence; callers must keep the page reachable for
the duration of use.

The canonical page token is:

```text
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
schema: 'method.design/v1'
cycle: '0051'
slug: 'bounded-page-payload-reuse'
status: 'active'
sponsor_human: 'James Ross'
sponsor_agent: 'Codex'
created: '2026-07-18'
updated: '2026-07-18'
issue: 'https://github.com/git-stunts/git-cas/issues/85'
milestone: 'v6.5.1'
---

# PERF-0051 - Bounded Page Payload Reuse

## Linked Issue

- [#85 - perf: bound and reuse immutable page payload reads](https://github.com/git-stunts/git-cas/issues/85)

## Decision Summary

Reuse successful `pages.get()` payload reads in a process-local LRU keyed by
immutable page OID. Residence is bounded independently by entry count and total
payload bytes. Every caller receives a copy, rejected reads are removed, and a
value larger than the byte budget is returned to its current caller without
remaining resident or evicting unrelated entries.

`pages.open()` remains an uncached streaming operation. The cache is an
acceleration of the already bounded collecting API, not a reason to buffer the
streaming API.

## Current Truth

- v6.5.0 coalesces immutable Git object metadata, exact tree entries, and bundle
descriptor bytes.
- `PageService.get()` still starts a fresh `cat-file blob` stream for every call.
- A git-warp retained-property fixture performed 16 identical reads in 2.61
seconds, including 32 blob reads and a 1.80-second time to first reading.
- Page OIDs are immutable, and `get()` already enforces both the configured page
limit and the caller's lower operation limit.

## Scope

- Add page payload cache entry and byte bounds to `ContentAddressableStore`.
- Share successful in-flight and completed `get()` work by page OID.
- Preserve per-call `maxBytes` checks for warm and cold reads.
- Clone cached bytes before returning them.
- Prove rejection retry, LRU eviction, byte-budget behavior, and real-Git warm
command elimination.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- Document the residency and lifetime assumptions.

## Non-Goals

- Caching `pages.open()` streams.
- Caching unbounded assets, arbitrary Git blobs, refs, or mutable collection
state.
- Establishing retention. A cache hit is not a retention witness.
- Surviving destructive external pruning that races an active store instance.
- Solving path-local persistent bundle updates; that work is tracked in
[#86](https://github.com/git-stunts/git-cas/issues/86).

## Runtime Contract

```ts
interface ContentAddressableStoreOptions {
maxPageSize?: number; // default 16 MiB
pageCacheEntries?: number; // default 128
pageCacheBytes?: number; // default 8 MiB
}
```

The cache allocates no fixed arena. Its actual residence is the sum of retained
payload byte lengths plus bounded completed-entry overhead. In-flight reads are
tracked separately so capacity pressure cannot break coalescing; their memory
follows caller concurrency and the configured per-page size bound. A configured
byte bound of zero disables non-empty completed payload residence while
preserving in-flight coalescing.

## Correctness Invariants

1. Cache identity is the immutable Git blob OID carried by a validated
`PageHandle`.
2. Every call parses its `PageHandle`. A cold payload miss validates object type
and size before reading; a resident or in-flight hit reuses that immutable
OID validation without another metadata access.
3. Cold misses check the caller's effective byte limit before starting a payload
read. Every resolved payload, including warm and shared in-flight hits, is
checked again against the current caller's limit.
4. Returned `Uint8Array` values never alias resident bytes.
5. Rejected work is absent before its returned promise settles.
6. An individually oversized resolved value cannot evict unrelated residents.
7. Streaming page reads never enter the payload cache.

## Residency Posture

The defaults retain at most 128 page payloads and 8 MiB of payload bytes. Both
bounds are configurable downward. Payloads larger than the byte budget remain
readable but are not retained. This keeps correctness independent of cache
capacity and lets low-heap consumers explicitly choose their residency ceiling.

## Compatibility

No persisted formats, handles, refs, witnesses, or existing method signatures
change. Constructor options are additive. `pages.get()` returns the same bytes
and errors as before; only repeat-read command count changes.

## Alternatives Considered

### Cache page bytes in git-warp

Rejected. git-cas owns immutable CAS access and cache policy. A consumer-local
cache would duplicate authority and produce inconsistent residency controls.

### Cache all blob streams in GitPersistenceAdapter

Rejected. Persistence serves assets and metadata with different boundedness and
streaming contracts. `PageService` is the layer that knows a payload is an
admitted bounded page.

### Cache `pages.open()`

Rejected. It would silently turn a streaming API into a collecting API and make
memory behavior depend on access history.

### Retain no payloads

Rejected by measurement. Immutable metadata reuse alone leaves one Git process
and full payload read per repeated exact observation.

## Proof Surface

- Unit tests cover concurrent coalescing, caller-copy isolation, entry LRU,
aggregate byte bounds, oversized-value behavior, rejected-read retry, and
option validation.
- A Docker real-Git test proves an identical warm `pages.get()` performs zero
additional Git commands.
- git-warp reruns its retained-property CPU, wall, command-count, and bounded
memory scenarios against the published patch release.

## Acceptance Criteria

- `pages.get()` warm reads issue no payload Git command.
- Residence is bounded by entries and bytes.
- Streaming semantics and retention authority are unchanged.
- Public declarations and API documentation expose exact defaults.
- Node, Bun, and Deno release verification remains green.
1 change: 1 addition & 0 deletions docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ process in [docs/method/process.md](../method/process.md).

## Active METHOD Cycles

- [0051-bounded-page-payload-reuse - bounded-page-payload-reuse](./0051-bounded-page-payload-reuse/bounded-page-payload-reuse.md)
- [0050-lazy-bundle-reference-reads - lazy-bundle-reference-reads](./0050-lazy-bundle-reference-reads/lazy-bundle-reference-reads.md)
- [0049-scoped-staging-workspaces — scoped-staging-workspaces](./0049-scoped-staging-workspaces/scoped-staging-workspaces.md)
- [0048-scoped-cache-acquisitions — scoped-cache-acquisitions](./0048-scoped-cache-acquisitions/scoped-cache-acquisitions.md)
Expand Down
35 changes: 35 additions & 0 deletions docs/releases/v6.5.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# git-cas v6.5.1 Release Notes

v6.5.1 completes the bounded immutable read path introduced in v6.5.0 by
coalescing repeated `pages.get()` payload reads. Exact application reads no
longer start another Git blob process when the same retained page is already
resident in the store's bounded payload cache.

## Bounded Payload Reuse

The page cache defaults to at most 128 entries and 8 MiB of payload bytes. It
allocates no fixed arena. Applications can configure the limits with
`pageCacheEntries` and `pageCacheBytes` when constructing the store.

Every `get()` result is a caller-owned copy. Concurrent reads share in-flight
work, rejected reads are immediately retryable, and values larger than the byte
budget are returned to the current caller without remaining resident or
evicting unrelated entries. In-flight work is tracked separately from completed
LRU residence, so capacity pressure cannot break coalescing; transient memory
still follows caller concurrency and the configured per-page size bound.

`pages.open()` is unchanged: it remains a streaming operation and never enters
the payload cache. Cache residence is not a retention witness, so applications
must continue to keep handles reachable through the documented lifecycle APIs.

## Performance Evidence

The real-Git command-count integration contract proves that an identical warm
`pages.get()` performs zero additional Git commands. git-warp will publish its
end-to-end CPU, wall-clock, Git-command, and bounded-memory deltas after adopting
this release.

## Compatibility

This release changes no persisted formats, handles, refs, witnesses, or existing
method signatures. The two constructor options are additive and optional.
4 changes: 4 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,10 @@ export interface ContentAddressableStoreOptions {
maxBlobSize?: number;
/** Maximum immutable page size in bytes. @default 16777216 (16 MiB) */
maxPageSize?: number;
/** Maximum immutable page payloads retained in memory. @default 128 */
pageCacheEntries?: number;
/** Maximum immutable page payload bytes retained in memory. @default 8388608 (8 MiB) */
pageCacheBytes?: number;
/** Repository-wide maximum bundle admission limits. */
bundleLimits?: Partial<BundleLimits>;
/** Maximum nested bundle depth. @default 32 */
Expand Down
19 changes: 18 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export default class ContentAddressableStore {
* @param {number} [options.maxRestoreBufferSize=536870912] - Max buffered restore size in bytes for encrypted/compressed restores (default 512 MiB).
* @param {number} [options.maxBlobSize=10485760] - Safety limit for readBlob metadata in bytes (default 10 MiB).
* @param {number} [options.maxPageSize=16777216] - Maximum immutable page size in bytes (default 16 MiB).
* @param {number} [options.pageCacheEntries=128] - Maximum immutable page payloads retained in memory.
* @param {number} [options.pageCacheBytes=8388608] - Maximum immutable page payload bytes retained in memory.
* @param {object} [options.bundleLimits] - Repository-wide maximum bundle admission limits.
* @param {number} [options.maxBundleNestingDepth=32] - Maximum nested bundle depth.
* @param {import('./src/ports/CompressionPort.js').default} [options.compressionAdapter] - Compression adapter (default NodeCompressionAdapter).
Expand All @@ -153,6 +155,8 @@ export default class ContentAddressableStore {
maxRestoreBufferSize,
maxBlobSize,
maxPageSize,
pageCacheEntries,
pageCacheBytes,
bundleLimits,
maxBundleNestingDepth,
compressionAdapter,
Expand All @@ -173,6 +177,8 @@ export default class ContentAddressableStore {
maxRestoreBufferSize,
maxBlobSize,
maxPageSize,
pageCacheEntries,
pageCacheBytes,
bundleLimits,
maxBundleNestingDepth,
compressionAdapter,
Expand Down Expand Up @@ -231,7 +237,7 @@ export default class ContentAddressableStore {
});
}

/** @type {{ plumbing: *, chunkSize?: number, codec?: *, policy?: *, crypto?: *, observability?: *, merkleThreshold?: number, concurrency?: number, chunking?: *, chunker?: *, maxRestoreBufferSize?: number, maxBlobSize?: number, maxPageSize?: number, bundleLimits?: object, maxBundleNestingDepth?: number, compressionAdapter?: *, applicationRefPrefixes?: string[], clock?: { now(): Date } }} */
/** @type {{ plumbing: *, chunkSize?: number, codec?: *, policy?: *, crypto?: *, observability?: *, merkleThreshold?: number, concurrency?: number, chunking?: *, chunker?: *, maxRestoreBufferSize?: number, maxBlobSize?: number, maxPageSize?: number, pageCacheEntries?: number, pageCacheBytes?: number, bundleLimits?: object, maxBundleNestingDepth?: number, compressionAdapter?: *, applicationRefPrefixes?: string[], clock?: { now(): Date } }} */
#config;
/** @type {AssetService|null} */
#assetService = null;
Expand Down Expand Up @@ -369,6 +375,8 @@ export default class ContentAddressableStore {
this.#pageService = new PageService({
persistence: this.service.persistence,
maxPageSize: cfg.maxPageSize,
pageCacheEntries: cfg.pageCacheEntries,
pageCacheBytes: cfg.pageCacheBytes,
clock: cfg.clock,
});
this.#bundleService = new BundleService({
Expand Down Expand Up @@ -620,6 +628,9 @@ export default class ContentAddressableStore {
* @param {import('./src/ports/ChunkingPort.js').default} [options.chunker] - Pre-built ChunkingPort instance.
* @param {number} [options.maxRestoreBufferSize=536870912] - Max buffered restore size in bytes.
* @param {number} [options.maxBlobSize=10485760] - Safety limit for readBlob metadata in bytes.
* @param {number} [options.maxPageSize=16777216] - Maximum immutable page size in bytes.
* @param {number} [options.pageCacheEntries=128] - Maximum immutable page payloads retained in memory.
* @param {number} [options.pageCacheBytes=8388608] - Maximum immutable page payload bytes retained in memory.
* @param {import('./src/ports/CompressionPort.js').default} [options.compressionAdapter] - Compression adapter.
* @returns {Promise<ContentAddressableStore>}
*/
Expand All @@ -644,6 +655,9 @@ export default class ContentAddressableStore {
* @param {import('./src/ports/ChunkingPort.js').default} [options.chunker] - Pre-built ChunkingPort instance.
* @param {number} [options.maxRestoreBufferSize=536870912] - Max buffered restore size in bytes.
* @param {number} [options.maxBlobSize=10485760] - Safety limit for readBlob metadata in bytes.
* @param {number} [options.maxPageSize=16777216] - Maximum immutable page size in bytes.
* @param {number} [options.pageCacheEntries=128] - Maximum immutable page payloads retained in memory.
* @param {number} [options.pageCacheBytes=8388608] - Maximum immutable page payload bytes retained in memory.
* @param {import('./src/ports/CompressionPort.js').default} [options.compressionAdapter] - Compression adapter.
* @returns {ContentAddressableStore}
*/
Expand All @@ -665,6 +679,9 @@ export default class ContentAddressableStore {
* @param {import('./src/ports/ChunkingPort.js').default} [options.chunker] - Pre-built ChunkingPort instance.
* @param {number} [options.maxRestoreBufferSize=536870912] - Max buffered restore size in bytes.
* @param {number} [options.maxBlobSize=10485760] - Safety limit for readBlob metadata in bytes.
* @param {number} [options.maxPageSize=16777216] - Maximum immutable page size in bytes.
* @param {number} [options.pageCacheEntries=128] - Maximum immutable page payloads retained in memory.
* @param {number} [options.pageCacheBytes=8388608] - Maximum immutable page payload bytes retained in memory.
* @param {import('./src/ports/CompressionPort.js').default} [options.compressionAdapter] - Compression adapter.
* @returns {ContentAddressableStore}
*/
Expand Down
Loading
Loading