Skip to content

Block cache, coalescing, and single-flight - #12

Merged
snkmcb merged 4 commits into
mainfrom
v0.3.0/block-cache
Aug 21, 2026
Merged

Block cache, coalescing, and single-flight#12
snkmcb merged 4 commits into
mainfrom
v0.3.0/block-cache

Conversation

@snkmcb

@snkmcb snkmcb commented Aug 20, 2026

Copy link
Copy Markdown
Member

Phase 3. libs/usd-asset-cache sits between the resolver and a backend as an
AssetReader wrapping an AssetReader, so nothing above it knows it is there
and nothing below it changes.

What it moves

Against the table v0.2.0 recorded as this
release's definition of success — BASELINE.md § What the next release has to
move
:

Row v0.2.0 Here
Header and index read, requests 18 3
Parallel readers, requests 152 25
Bounded query, amplification 1.000000 4.543210, with bytesOverFetched non-zero and honest about it
Full sequential read 33 requests, 1.000000 33 requests, 1.000000 — unchanged

The last row is the point. A cache that improves the first three and quietly
damages the worst case has not improved anything; the 1 MiB bypass threshold is
what keeps it, by sending a large read straight to the backend rather than
through a store it would evict itself out of.

The constants are measured

tests/cache-tuning sweeps four access patterns across five block sizes and
four coalescing gaps — sixty-six runs, each against a fresh store so no row is
warmed by the one above it — and
docs/reference/BLOCK_POLICY.md is the
record. 64 KiB blocks and a gap of one block are chosen from request and byte
counts alone, because those are exact on every machine and a loopback wall clock
is a fact about the runner.

Two of the five constants were not measured and say so in the table: the
request ceiling is a safety bound, the budget is a residency policy. And the one
premise the harness cannot measure is stated outright rather than assumed
quietly — that one round trip costs more than one block of bytes — which is what
makes "fewer requests, more bytes" the better trade. It gets its own measurement
in v0.5.0.

How it is checked

The cache enters the shared boundary suite as a row, for the third time:
boundary_cached_local, the cached backend over the local one, which is the
configuration where the naive oracle can still say what every byte should be. A
cache that returns a byte from the wrong block fails there.

Verified locally on Windows, MSVC, Release, with the bundle: 29 of 29 tests
pass
, up from 22 — the new ones being the cache's own unit tests, the
single-flight test, the cached boundary row, and the block-policy sweep.

Not in this PR

  • CHANGELOG.md and docs/roadmap/implementation-status.md are not yet updated
    for phase 3.
  • The sanitizer lanes were not run locally for this branch; CI covers them, and
    single-flight is exactly the code TSan exists for.
  • tests/cache-tuning/tuning_main.cpp emits MSVC C4819: it has an em dash
    inside a printf format string, and unlike the non-ASCII elsewhere in this
    tree — which lives in comments — a string literal has to be converted to the
    execution charset. Cosmetic, and left alone here.

🤖 Generated with Claude Code

snkmcb and others added 4 commits August 20, 2026 21:43
Phase 3, and the first release that changes the recorded numbers on purpose.
libs/usd-asset-cache sits between the resolver and a backend as an AssetReader
that wraps an AssetReader, so nothing above it knows it is there and nothing
below it changes: block alignment, coalescing, single-flight, and eviction, over
a CacheKey that carries the validator from the first commit.

What it moves, against the table v0.2.0 recorded as the definition of success
(docs/reference/BASELINE.md, "What the next release has to move"):

  header and index read     18 requests -> 3
  parallel readers         152 requests -> 25
  bounded query            amplification 1.000000 -> 4.543210, and
                           bytesOverFetched now non-zero and honest about it
  full sequential read      33 requests, amplification 1.000000 -- unchanged,
                           which is the row that had to not regress

The fourth line is the one worth stating twice. A cache that improves the first
three and quietly damages the worst case has not improved anything, and the
bypass threshold is what keeps it: a read above 1 MiB goes straight to the
backend rather than through a store it would evict itself out of.

**The constants are measured, and the measurement is recorded rather than
implied.** tests/cache-tuning sweeps four access patterns across five block
sizes and four coalescing gaps -- sixty-six runs, each against a fresh store so
no row is warmed by the row above it -- and docs/reference/BLOCK_POLICY.md is
the record. 64 KiB blocks and a gap of one block are chosen from request and
byte counts alone, because those are exact on every machine and a wall clock on
loopback is a fact about the runner. Two of the five constants were not measured
and say so in the table: the request ceiling is a safety bound and the budget is
a residency policy. A number in a table under a heading about measurement, which
was not measured, is the failure that file exists to prevent.

The premise the harness cannot measure is stated outright rather than assumed
quietly: on any link this project targets, one round trip costs more than one
block of bytes. That is CACHE.md §2 as an assumption, and it is what makes fewer
requests and more bytes the better trade. It gets its own measurement in v0.5.0,
when a consumer puts real distance between the reader and the origin. Until then
the direction of the trade is assumed and only its magnitude is measured.

The cache enters the shared boundary suite as a row rather than as a suite, for
the third time: boundary_cached_local, the cached backend over the local one,
which is the configuration where the oracle can still say what every byte should
be. A cache that returns a byte from the wrong block fails there against the
same naive oracle usdAssetLocal has been checked against since v0.1.0.

Cache counters join the metrics that already existed, so the block cache reports
hits, misses, over-fetch, and requests saved by single-flight in the same dump
every other counter appears in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ebuild

Eight findings from the review of this branch. Two are defects that a test
cannot reach, three are counters that were wrong, and three are a comment or a
bound saying something the code does not.

**The store's rebuild raced its own admission check.** `ConfigureProcess` took
`identityMutex`, read `liveBindings == 0`, released it, and *then* called
`Reset`, which clears and rebuilds `shards` with no lock held. A `Bind` that had
already passed the same check could hand back a binding whose stripe index
pointed into the vector the rebuild was about to free. The check and the rebuild
are one critical section now; `Reset` split into a locking wrapper and a
`ResetLocked` the configure path calls with the lock already held. What makes it
sufficient is the binding destructor's ordering, which was already right:
`liveBindings` drops as its last act, after the binding has finished touching
every stripe, so zero under this lock means nobody is inside `shards`.

**An exception left blocks pending for the life of the process.** `Abandon`
documents the invariant -- every `Owned` acquisition ends in exactly one
`Publish` or one `Abandon` -- and the release paths kept it across every
`return` and across no `throw`. Both allocations on that path can throw: the
transfer buffer is sized by the run and may be `maxRequestBytes`, and `Publish`
copies the block. A `bad_alloc` out of either left every unpublished block of
that read pending forever, and `Await` has no deadline, so the next reader of
one of those blocks waits forever too -- a hang, in a process that never saw an
error. Ownership is a destructor now. `Publish` allocates before it touches the
store, so a throw from it leaves the block still owned and the guard still
correct.

**`bytesOverFetched` did not count the gap, which is most of what it exists to
count.** It was charged as the bytes of a run falling outside the caller's byte
range. Coalescing across a block that is already resident re-transfers that
block, and those bytes sit *inside* the range -- so the counter called them
wanted and charged nothing, while the wire moved them and the caller read that
block from the store. It is charged against what the caller took out of each
transfer now, which counts both halves: the alignment slack outside the range
and the gap inside it.

The recorded numbers move with it, in exactly one row of one table, and that row
is the one the coalescing constant is chosen from. BLOCK_POLICY.md's interleaved
re-read at 4 KiB blocks recorded a gap of one block as costing 0 bytes; it costs
28672. The prose two sections below it already said 28672 -- it was worked out by
hand -- so the file has been disagreeing with itself since it was written, and
the generated half was the wrong half. The trade the constant is chosen on is
unchanged and now legible: seven fewer round trips for 28 KiB.

**A read served entirely by single-flight was invisible.** Blocks obtained
through `Await` bumped `bytesFromCache` and `requestsSavedBySingleFlight` and
neither of the two counters that classify a read, so such a read landed in none
of `blockHits`, `blockMisses`, or `partialHits`. They count as what they are --
blocks this reader did not fetch -- which is what `blockMisses` deliberately
excludes them from. BASELINE.md's parallel row moves with it: `blockHits` 16 to
127, and `requestsSavedBySingleFlight` 153 to 156, which is that row's
documented run-to-run variance rather than the fix.

**The gap ceiling was off by one.** Merging across G blocks puts G + 2 in the
request, so a ceiling of N blocks admits a gap of N - 2, not N - 1. With a 4 KiB
block and a 16 KiB ceiling the normalizer resolved the gap to 3, and a gap of 3
needs 20 KiB -- a resolved option `PlanRuns` can never take, which is the exact
thing the cap exists to prevent. The shipped defaults are unaffected: 128 blocks
per request against a gap of 1.

**Two clamps were silent and one comment described a bound that is not there.**
The budget's floor is `kMinBlockSize`, not one block of the configured size, so
a large block size and a small budget passed the parse and were then raised by
the normalizer with nothing reported -- unlike the block size and the gap, which
both report. The budget and the request ceiling report now, and the comment says
what the floor is.

**The resolver's comment named the guarded entry point and the code called the
unguarded one.** `WrapAsset` checks `supportsRandomAccess` and passes such a
reader through undecorated; `Wrap` cannot, because it returns a
`CachedAssetReader` and has nothing to pass through -- and its own doc comment
claimed the check anyway, which is a third statement of the same confusion. The
resolver calls `WrapAsset`, and `Wrap`'s doc says what it does. Latent rather
than live: ADR-0002 makes range support a hard error at open, so every reader
reaching that line has it, which is how long a missing guard stays invisible.

**And `ParseByteRange` was pasted into the middle of `TestRangeRead`'s doc
comment**, leaving §4's sentence attached to the parser and "out of an asset
costs the window." dangling above the test. Reunited.

Verified: 29/29 on Windows with the bundle, and 25/25 under ASan+UBSan and 25/25
under TSan in WSL under GCC 15.2 -- the lanes that are the only real check on the
first two findings. The over-fetch fix is pinned by an assertion in the gap test
that the old accounting fails, and the recorded tables were regenerated and
diffed rather than edited by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code for the block cache landed in the two commits before this one; what
was left is every document that had said the cache was planned, and one that
turned out to be asserting something narrower than the contract it is the
executable form of.

That one is worth naming. The boundary suite's mid-read revision case asserted
`AssetChanged` on a re-read of a range the reader had already read. A block
cache answers that read from bytes it captured under the same binding, observes
nothing, and returns the revision the reader is bound to -- which is the
guarantee rather than an exception to it: ASSET_READER.md §2.1 already says
*observes*, and CACHE.md §6 already admits in-memory caching for the reader's
lifetime. The case now asserts `AssetChanged` at an offset the reader has not
read, and for a repeated range asserts either `AssetChanged` or byte-for-byte
what the first read returned, never the new revision. It is a strengthening: the
byte comparison is new, and the old case would have passed a backend that
rebound *and* reported `AssetChanged`.

The rest is bookkeeping that the invariants require:

- WORKSPACE.md gains the fifth reverse edge onto the fixture server, for
  tests/cache-tuning, and says why that one needs a server for a reason of its
  own -- the constants it measures are about round trips, and a sweep over a
  local file would be a sweep over a cost that does not exist there.
- CONFIGURATION.md fills in the four cache defaults with the measured values
  and states the two rules the code follows: a block size that is not a power
  of two is rounded down and the rounding is reported, and a value outside the
  bounds is refused rather than clamped.
- libs/usd-asset-cache/README.md, which invariant 10 requires and which states
  what the module refuses to own -- no transport, no revalidation, no validator
  interpretation, no read-ahead.
- METRICS.md records the rule the decorator forced: a decorated stack has one
  counter set, because the two ends disagree about what `bytesRequested` means.
- CAPABILITY_MATRIX.md, the roadmap, the design policy's assessment, the
  changelog, and the CI comment that counted three libraries.

Both lanes are green on this tree: 25 of 25 under core-msvc, 29 of 29 in the
plugin lane including httpResolver_stage, and 25 of 25 under each of core-asan
and core-tsan on GCC 15.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two bundle cells have been failing since the cache landed, and the two
local lanes and core-ci.yml have been green throughout, which is the whole
shape of the defect. A bundle declares its library edges twice: to the build
graph, which resolves them as in-tree targets, and in openstrata.plugin.yaml,
which is the list `ost plugin build` installs into the workspace prefix before
it configures the bundle standalone. usdAssetCache was added to the first and
not the second.

usdAssetHttp carries usdAssetIo transitively, so the bundle never had to name
it and the descriptor's one-line list had never been wrong before. usdAssetCache
is that library's sibling over usdAssetIo rather than anything above it -- the
cache is a decorator and knows no transport concept -- so nothing carries it and
it has to be named.

Reproduced and fixed against the path the cells take, not against the in-tree
build: `ost plugin build plugins/http-resolver` now installs usdAssetCache into
the workspace prefix and configures, and `ost plugin test --up-to 1` is 8 pass,
0 fail, 4 skip. The Windows leg of that still needs CMAKE_PREFIX_PATH in the
environment for libcurl, which is blocking item 3 and unrelated.

`verify: graph` is the rung that would have caught this, and it counts the edge:
it now reports 5 library edges where it reported 4. Both places that state the
edge list say so -- WORKSPACE.md §2 next to the list a reader would otherwise
trust, and the cell's comment in openstrata.ci.yaml, which had the old count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@snkmcb
snkmcb merged commit 93b104a into main Aug 21, 2026
12 checks passed
@snkmcb
snkmcb deleted the v0.3.0/block-cache branch August 21, 2026 03:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant