Skip to content

Let fast-slow has() trust the fast store when configured - #2728

Open
benelser wants to merge 1 commit into
TraceMachina:mainfrom
benelser:pr/01-fast-first-has
Open

Let fast-slow has() trust the fast store when configured#2728
benelser wants to merge 1 commit into
TraceMachina:mainfrom
benelser:pr/01-fast-first-has

Conversation

@benelser

@benelser benelser commented Sep 4, 2026

Copy link
Copy Markdown

What and why

FastSlowStore::has_with_results asks the slow store about every key
and never the fast store, so a blob that exists only in the fast tier
reads as missing and the client re-uploads it to durable storage. On a
fast_slow { redis, gcs } CAS/AC this means every FindMissingBlobs
and every action-result completeness check fans out one metadata GET per
key to GCS even when the fast tier already holds the blob. Under a
warm-cache lookup storm those GETs queue on the GCS client's connection
semaphore and overrun the CAS server's 30 s per-blob deadline, which
Buck2 does not retry.

This adds FastSlowSpec::trust_fast_store_for_has (default false,
behavior identical to today). When true, the fast store answers first
and only the keys it does not hold go to the slow store. The in-flight
slow-write merge and the NoopDownloads shortcut are unchanged. The
name says what an operator opts into: a fast-store hit is trusted as
proof of existence, which is exactly what the default refuses to do.
The doc comment records the durability trade-off and the deployments
where the flag is safe.

How was this verified?

New tests in nativelink-store/tests/fast_slow_store_test.rs, one per
documented invariant, using a counting store to prove which tier each
existence check reached:

  • has_by_default_checks_slow_store_only: fast-only blob reads as
    missing, fast store asked 0 times, slow store asked once.
  • has_trusting_fast_store_hit_skips_slow_store: fast hit reports the
    size and the slow store is asked 0 times.
  • has_trusting_fast_store_miss_falls_through_to_slow_store: a mixed
    batch keeps the fast hit, fills the slow hit into the right slot,
    leaves the true miss None, and asks the slow store exactly once.
  • has_trusting_fast_store_still_sees_in_flight_slow_writes: with the
    flag on, a fast miss still blocks on and sees an in-flight slow write.

The two flag-on tests that exercise the new branch fail when the branch
is disabled. All pre-existing fast_slow_store_test tests still pass.

Measured on a production deployment (NativeLink v1.6.3, Redis fast tier,
GCS slow tier, warm-cache lookup storm): together with two GCS-client
changes, GetActionResult p99 went from 30.0 s to about 1 s and GCS
GETs per minute dropped roughly 3-6x; this flag removed the per-key
metadata GET fan-out that the other two changes could not.

Risk

Default false leaves every existing deployment unchanged. With the
flag on, a blob present only in an evicting fast tier is reported
present and is never re-uploaded; if the fast tier evicts it before it
reaches the slow tier, the blob is lost and later reads fail with
NotFound. Only enable it when the fast tier does not evict, or when
another path makes the slow tier authoritative. Fast-store has
failures now propagate from has_with_results when the flag is on,
where before only slow-store failures did. The re-upload invariant this
relaxes is the one #1999 and #2072 (LazyExistenceOnSync) build on and
#1978 touches, so a review from that area is welcome.


This change is Reviewable

## What and why

`FastSlowStore::has_with_results` asks the slow store about every key
and never the fast store, so a blob that exists only in the fast tier
reads as missing and the client re-uploads it to durable storage. On a
`fast_slow { redis, gcs }` CAS/AC this means every `FindMissingBlobs`
and every action-result completeness check fans out one metadata GET per
key to GCS even when the fast tier already holds the blob. Under a
warm-cache lookup storm those GETs queue on the GCS client's connection
semaphore and overrun the CAS server's 30 s per-blob deadline, which
Buck2 does not retry.

This adds `FastSlowSpec::trust_fast_store_for_has` (default `false`,
behavior identical to today). When `true`, the fast store answers first
and only the keys it does not hold go to the slow store. The in-flight
slow-write merge and the `NoopDownloads` shortcut are unchanged. The
name says what an operator opts into: a fast-store hit is trusted as
proof of existence, which is exactly what the default refuses to do.
The doc comment records the durability trade-off and the deployments
where the flag is safe.

## How was this verified?

New tests in `nativelink-store/tests/fast_slow_store_test.rs`, one per
documented invariant, using a counting store to prove which tier each
existence check reached:

- `has_by_default_checks_slow_store_only`: fast-only blob reads as
  missing, fast store asked 0 times, slow store asked once.
- `has_trusting_fast_store_hit_skips_slow_store`: fast hit reports the
  size and the slow store is asked 0 times.
- `has_trusting_fast_store_miss_falls_through_to_slow_store`: a mixed
  batch keeps the fast hit, fills the slow hit into the right slot,
  leaves the true miss `None`, and asks the slow store exactly once.
- `has_trusting_fast_store_still_sees_in_flight_slow_writes`: with the
  flag on, a fast miss still blocks on and sees an in-flight slow write.

The two flag-on tests that exercise the new branch fail when the branch
is disabled. All pre-existing `fast_slow_store_test` tests still pass.

Measured on a production deployment (NativeLink v1.6.3, Redis fast tier,
GCS slow tier, warm-cache lookup storm): together with two GCS-client
changes, `GetActionResult` p99 went from 30.0 s to about 1 s and GCS
GETs per minute dropped roughly 3-6x; this flag removed the per-key
metadata GET fan-out that the other two changes could not.

## Risk

Default `false` leaves every existing deployment unchanged. With the
flag on, a blob present only in an evicting fast tier is reported
present and is never re-uploaded; if the fast tier evicts it before it
reaches the slow tier, the blob is lost and later reads fail with
`NotFound`. Only enable it when the fast tier does not evict, or when
another path makes the slow tier authoritative. Fast-store `has`
failures now propagate from `has_with_results` when the flag is on,
where before only slow-store failures did. The re-upload invariant this
relaxes is the one TraceMachina#1999 and TraceMachina#2072 (`LazyExistenceOnSync`) build on and
TraceMachina#1978 touches, so a review from that area is welcome.
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
nativelink Ready Ready Preview Sep 4, 2026 8:09pm UTC
nativelink-aidm Ready Ready Preview Sep 4, 2026 8:09pm UTC

Request Review

@CLAassistant

CLAassistant commented Sep 4, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@MarcusSorealheis MarcusSorealheis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opt-in flag is a useful way to reduce slow-tier metadata requests, and keeping the default false preserves the existing existence policy. Unlike the GCS upload optimization in #2729, this changes what a positive existence result guarantees. Please tighten the operator guidance and tests below before merging. This is a source review; I have not run additional tests or performance measurements.

This change is already backend-independent because it lives in FastSlowStore. The reported latency/request improvements are from several changes together; retain that qualification, and evaluate cold-cache latency too because misses now query fast and then slow serially.

Comment on lines +1012 to +1019
/// When `false`, existence checks consult the `slow` store only. A blob
/// held solely by the `fast` tier then reads as missing, so the client
/// re-uploads it and the blob reaches durable storage. Setting this to
/// `true` gives up that guarantee in exchange for far fewer `slow` store
/// requests: if the `fast` tier evicts a blob that never reached the
/// `slow` tier, the blob is gone. Only enable this when the `fast` tier
/// does not evict, or when another path makes the `slow` tier
/// authoritative.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“The fast tier does not evict” is not sufficient safety guidance for this flag. A non-evicting memory cache can disappear on restart, and a persistent node-local cache can answer FindMissingBlobs on replica A while the subsequent read goes to replica B, whose fast tier and shared slow tier both lack the blob. No eviction is needed for that failure. “Another path makes the slow tier authoritative” also needs to specify what actually guarantees persistence and visibility; this flag itself schedules no replication.

Suggested replacement:

Suggested change
/// When `false`, existence checks consult the `slow` store only. A blob
/// held solely by the `fast` tier then reads as missing, so the client
/// re-uploads it and the blob reaches durable storage. Setting this to
/// `true` gives up that guarantee in exchange for far fewer `slow` store
/// requests: if the `fast` tier evicts a blob that never reached the
/// `slow` tier, the blob is gone. Only enable this when the `fast` tier
/// does not evict, or when another path makes the `slow` tier
/// authoritative.
/// By default, existence checks consult the slow store, except when the
/// slow store advertises that downloads are a no-op. A fast-only blob
/// is normally reported missing so clients can upload it to the slow
/// tier. Enabling this option accepts fast-tier existence without
/// confirming slow-tier persistence or waiting for an in-flight slow
/// write when the fast tier already reports a hit.
///
/// This option does not replicate fast-only blobs. Eviction, expiry,
/// restart, or loss of the fast tier can make a reported blob unavailable.
/// A different replica may also be unable to read a node-local fast hit.
/// Disable this option when slow-tier persistence is required. Otherwise,
/// ensure the fast tier meets the deployment's retention and reader
/// visibility requirements, or accept the resulting missing-blob risk.
/// Fast-store lookup errors propagate rather than falling back to slow.

Please also regenerate the configuration reference using gen:config-reference, and update web/apps/docs/content/docs/how-to/stores/compose-stores.mdx plus the narrative reference/nativelink-config/store-overview.mdx. The latter's existing fast/slow durability warning should distinguish default slow-tier existence checks from this opt-in policy. Add an explicit warning next to an opt-in example, covering shared versus node-local fast tiers and that “no eviction” does not mean persistent storage. Keep exhaustive field details in the generated reference, per AGENTS.md.

"Fast store must be consulted exactly once"
);
assert_eq!(
slow.has_calls.load(Ordering::SeqCst),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please have HasCountingStore record the keys passed to has_with_results, and assert that the slow call contains exactly the slow-hit and true-miss keys, excluding the fast-hit key. The current call-count assertion would also pass if the implementation queried the slow tier with the entire batch and then preserved fast results. That would regress the main request-volume benefit while all these assertions stayed green.

An interleaved batch with duplicate keys would also exercise reconstruction of the result slots, including distinct sizes returned by the tiers.

// Check with the slow store first.
self.slow_store.has_with_results(key, results).await?;
if self.trust_fast_store_for_has {
self.fast_store.has_with_results(key, results).await?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add failure/concurrency tests for the policy introduced here:

  • A fast-store lookup error propagates and the slow store is not queried, even if it holds the blob. This behavior is mentioned in the PR description but should also be part of the configuration documentation and test contract.
  • A fast miss followed by a slow-store lookup error propagates that error.
  • A fast hit while a slow write is gated returns immediately without waiting for that write. Then fail the slow write and verify the fast-only result remains visible under the opt-in policy.

The new in-flight-write test uses a Noop fast tier, so it verifies only the fast-miss path. The fast-hit case deliberately skips the in-flight wait because its result slot is already Some; explicitly testing that distinction would prevent the claim that the merge is “unchanged” from being read as a persistence guarantee for all hits. If that early visibility is not intended, the in-flight merge needs to take precedence over trusted fast hits.

@MarcusSorealheis MarcusSorealheis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this PR stands, it cannot be merged. It changes the behavior of the existence check so it needs a lot of consideration and analysis.

@modernmedici can you try to schedule a call?

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.

3 participants