Skip to content

Prefetch directory-cache tree protos with one GetTree stream#2546

Open
erneestoc wants to merge 2 commits into
TraceMachina:mainfrom
erneestoc:ec/dircache-gettree-on-subtree
Open

Prefetch directory-cache tree protos with one GetTree stream#2546
erneestoc wants to merge 2 commits into
TraceMachina:mainfrom
erneestoc:ec/dircache-gettree-on-subtree

Conversation

@erneestoc

@erneestoc erneestoc commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Prefetch directory-cache tree protos with one GetTree stream

Stacked on #2541 (subtree caching): this branch is based on
ec/dircache-subtree-caching and should be reviewed/merged after it. The
diff to review is the top commit only.

Problem

On a directory-cache miss, the worker discovers Directory protos level by
level: a child's digest is only known once its parent's proto has arrived,
so a cold tree pays one worker→CAS round trip per tree depth before
file fetching can even begin. Real input trees are commonly 10+ levels
deep; at millisecond RTTs this serialization costs tens to hundreds of
milliseconds per cold action. REAPI's GetTree exists precisely for this:
one RPC streams every directory proto of a tree.

Change

Opt-in DirectoryCacheConfig.experimental_get_tree_prefetch: on a
root-level cold miss, issue a single GetTree stream (one round trip,
one fetch permit) and key the returned protos by their re-computed digest —
the stream carries protos, not digests, so each is hashed with the caller's
digest function. Construction consults the map and falls back to the
existing per-level fetch for any missing proto, any stream failure, or a
non-gRPC slow tier: the feature can only degrade to today's path, never
fail because of it.

Interaction with subtree caching (#2541): prefetch triggers only when a
full tree construction begins. Subtree cache hits never construct and never
prefetch; subtree misses reached during a construction reuse the map
already fetched for the root.

"directory_cache": {
  "experimental_subtree_caching": true,
  "experimental_get_tree_prefetch": true,
}

Default off, for an honest reason: the serving CAS answers GetTree by
walking the tree against its own backing store, so deployments should
enable this only when directory protos are served from a fast tier
(memory/filesystem), which is the common scheduler-side setup.

Measurements

RPC-shape A/B (real CasServer over TCP, client-injected RTT, identical
proto counts on both paths):

tree shape RTT per-level walk (today) one GetTree speedup
deep chain, depth 32 25 ms 877 ms 26 ms 33.7×
deep chain, depth 32 5 ms 221 ms 7 ms 31.6×
wide 221-proto, depth 3 25 ms 164 ms 30 ms 5.5×
wide 221-proto, depth 3 5 ms 43 ms 9 ms 4.8×

End-to-end validated against a real server: a 4,400-file / 221-proto tree
constructs entirely from one prefetched stream (zero per-level fetches),
byte-identical output.

Tests

  • Flag off: default behavior unchanged (existing suite).
  • Flag on with a non-gRPC slow tier: prefetch returns None, construction
    falls back and produces correct results (new test).
  • Full worker + config suites green under bazel (clippy + rustfmt aspects)
    and cargo.

Notes for reviewers

  • The prefetch future is Box::pin'd at call sites deliberately: inlined,
    it enlarges the callers' futures enough to overflow the stack in
    small-stack (fastbuild) test builds.
  • GetTree pagination is honored via the streaming client; an incomplete
    map is tolerated per-proto by the fallback.

This change is Reviewable

erneestoc and others added 2 commits July 9, 2026 09:18
The directory cache is keyed only on the root Directory digest, so one
changed file out of thousands forces a full re-materialization (measured
1400ms of tree walk + per-file hardlinks + proto decodes) even though a
warm full-root hit is 69ms and nearly every blob is already in the fast
tier. REAPI Merkle nodes are content-addressed, so unchanged subtrees are
byte-identical across roots and can be reused wholesale.

With `experimental_subtree_caching` enabled (default: false),
`create_subdirectory` routes each subdirectory through the same core
get-or-create flow root materializations use, keyed by the subtree's own
Directory digest: a hit is one `hardlink_directory_tree` call; a miss
constructs the subtree once into its own cache entry (single-flighted
through the shared `construction_locks` map; deadlock-free since flights
only wait on strict Merkle descendants) and recursion caches nested
subtrees too. New `subtree_hits`/`subtree_misses`/`evictions` counters in
`CacheStats` surface reuse and thrash. To keep eviction meaningful, an
entry's recorded size covers only bytes not owned by a descendant entry,
so the map's size total approximates unique materialized bytes instead of
counting each file once per ancestor level.

Because subtree caching turns several previously rare root-path hazards
into routine events, the shared flow is also hardened (these fixes apply
to root-only caching as well):

- Unified core: `get_or_create_entry` + `try_materialize_from_cache` +
  `construct_and_materialize` now carry ONE copy of the hit/miss logic
  for both root and subtree materializations.
- RAII refcount pins: `CachedDirectoryMetadata.ref_count` is an
  `Arc<AtomicUsize>` released by `EntryPin::drop`, so futures cancelled
  mid-await (e.g. `buffer_unordered` dropping siblings on the first
  error) can no longer leak pins and leave entries permanently
  unevictable. `last_access` is an atomic too, letting the hit path run
  under the cache READ lock instead of convoying on the write lock.
- Temp+rename publish: constructions build into a unique
  `cache_root/.tmp-N` and atomically rename to the canonical path, so a
  failed construction can never poison its (stable, shared) digest with
  a partial tree; failures clean up inline and a `ScratchGuard` sweeps
  temps of cancelled futures.
- Tombstone eviction: victims are renamed to `cache_root/.del-N` under
  the same write-lock critical section that removed them from the map
  (rename is metadata-cheap; deletion stays off-lock in the background),
  so background deletion can never race a re-construction publishing to
  the canonical path. `DirectoryCache::new` sweeps pre-existing
  `cache_root` content the same way (the map starts empty, so anything
  on disk is orphaned and would poison re-constructions).
- Dead-entry invalidation + clean retry: when hardlinking from an entry
  fails, the entry is invalidated (guarded by refcount-handle identity
  so a freshly rebuilt entry is never nuked) and the partially populated
  destination is removed before reconstruction — previously the retry
  hit "destination already exists"/AlreadyExists and the dead map entry
  kept failing its digest forever.
- `hardlink_directory_tree` now chmods created directories to 0o755
  (unix), keeping the documented invariant under restrictive umasks.

Tests cover one-file-changed churn reuse (exact hit/miss accounting plus
byte verification), the default-off regression guard, cross-root subtree
sharing, eviction safety under concurrent construction, construction-
failure retry (no digest poisoning, no temp leftovers), pin drain after
cancelled sibling futures, damaged-entry recovery, and non-depth-
multiplied size accounting.

Observability: DirectoryCache implements MetricsComponent (clonefile_hits,
hardlink_hits, subtree_hits, subtree_misses, evictions, entries,
total_size_bytes) and is wired into the worker metrics tree via a new
directory_cache field on the running-actions-manager Metrics component
(Weak ref, absent when the cache is disabled). entries/total_size_bytes
are lock-free atomic mirrors maintained at insert/evict/invalidate. A
rate-limited (1/min) tracing::info! "DirectoryCache summary" line from
the hit/miss path covers deployments without a metrics pipeline.

Config: the cache-wide fetch semaphore size (previously the hardcoded
CONSTRUCT_DIRECTORY_MAX_FETCHES=64 from TraceMachina#2526) is now
DirectoryCacheConfig::max_concurrent_fetches (serde default 64 —
behavior-preserving; validated > 0 at construction with
InvalidArgument). Low values fragment coalesced read batches, so
deployments using experimental_read_batching on the underlying grpc
store can raise it substantially (e.g. 512).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a directory-cache miss, the worker fetches Directory protos level by
level: each level's protos are only discoverable from its parents, so a
cold tree pays one worker->CAS round trip per tree DEPTH before file
fetching can even start. Real input trees are commonly 10+ levels deep,
so at millisecond RTTs this serialization alone costs tens to hundreds
of milliseconds per cold action, and GetTree exists in REAPI precisely
to avoid it.

Add opt-in DirectoryCacheConfig.experimental_get_tree_prefetch: on a
cache miss, issue a single GetTree stream (one round trip, one fetch
permit) and key the returned protos by their re-computed digest (the
stream carries protos, not digests; hashing uses the caller's digest
function). construct_directory consults the map and falls back to the
existing per-level fetch for any missing proto, any stream failure, or
when the slow tier is not a grpc store — so behavior can only degrade
to today's path, never fail because of the prefetch.

Measured RPC-shape A/B (real CasServer over TCP, client-injected RTT,
identical proto counts): depth-32 chain at 25ms RTT: 877ms -> 26ms
(33.7x); wide 221-proto depth-3 tree at 25ms RTT: 164ms -> 30ms (5.5x).
End-to-end validated against a real server: a 4,400-file / 221-proto
tree constructs entirely from one prefetched stream (zero per-level
fetches) with byte-identical output.

Off by default: the serving CAS walks the tree against its own backing
store to answer GetTree, so deployments should enable this only when
directory protos are served from a fast tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXVtatcR9YMecBiu9RjwpC
@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nativelink Ready Ready Preview, Comment Jul 10, 2026 6:26pm
nativelink-aidm Ready Ready Preview, Comment Jul 10, 2026 6:26pm

Request Review

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