Skip to content
Open
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
17 changes: 17 additions & 0 deletions nativelink-config/src/stores.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,23 @@ pub struct FastSlowSpec {
/// Default: disabled (0)
#[serde(default, deserialize_with = "convert_data_size_with_shellexpand")]
pub bypass_dedup_threshold_bytes: u64,

/// Treat a hit in the `fast` store as proof that a blob exists when
/// answering existence checks (`FindMissingBlobs`, action result
/// completeness checks). The `fast` store is queried first and only the
/// keys it does not hold go to the `slow` store.
///
/// 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.
Comment on lines +1012 to +1019

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.

/// Default: false
#[serde(default, deserialize_with = "convert_boolean_with_shellexpand")]
pub trust_fast_store_for_has: bool,
}

#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy)]
Expand Down
52 changes: 43 additions & 9 deletions nativelink-store/src/fast_slow_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pub struct FastSlowStore {
slow_direction: StoreDirection,
/// See [`FastSlowSpec::bypass_dedup_threshold_bytes`].
bypass_dedup_threshold_bytes: u64,
/// See [`FastSlowSpec::trust_fast_store_for_has`].
trust_fast_store_for_has: bool,
weak_self: Weak<Self>,
#[metric]
metrics: FastSlowStoreMetrics,
Expand Down Expand Up @@ -166,6 +168,7 @@ impl FastSlowStore {
slow_direction: spec.slow_direction,
// 0 (default) disables the bypass entirely (always dedup).
bypass_dedup_threshold_bytes: spec.bypass_dedup_threshold_bytes,
trust_fast_store_for_has: spec.trust_fast_store_for_has,
weak_self: weak_self.clone(),
metrics: FastSlowStoreMetrics::default(),
populating_digests: Mutex::new(HashMap::new()),
Expand Down Expand Up @@ -206,6 +209,34 @@ impl FastSlowStore {
.is_some_and(|size| size >= self.bypass_dedup_threshold_bytes)
}

/// Asks the slow store only about the keys still unresolved in
/// `results` and writes its answers back into the matching slots. Used
/// by `has_with_results` when a fast-store hit is trusted as existence.
async fn has_in_slow_store_for_misses(
&self,
keys: &[StoreKey<'_>],
results: &mut [Option<u64>],
) -> Result<(), Error> {
let (miss_indexes, miss_keys): (Vec<usize>, Vec<StoreKey<'_>>) = keys
.iter()
.zip(results.iter())
.enumerate()
.filter(|(_, (_, result))| result.is_none())
.map(|(i, (key, _))| (i, key.borrow()))
.unzip();
if miss_keys.is_empty() {
return Ok(());
}
let mut slow_results = vec![None; miss_keys.len()];
self.slow_store
.has_with_results(&miss_keys, &mut slow_results)
.await?;
for (i, size) in miss_indexes.into_iter().zip(slow_results) {
results[i] = size;
}
Ok(())
}

pub const fn fast_store(&self) -> &Store {
&self.fast_store
}
Expand Down Expand Up @@ -479,8 +510,18 @@ impl StoreDriver for FastSlowStore {
return self.fast_store.has_with_results(key, results).await;
}

// 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.

self.has_in_slow_store_for_misses(key, results).await?;
} else {
// NOTE: By default we intentionally *NEVER* check the fast store,
// this is to ensure that we re-upload data to the slow store if
// it only exists in the fast store. This does not affect workers
// as they do not check existence through `has` and instead go
// direct to loading the data which bypasses the check and will
// load from the fast store if it does not exist in the slow store.
self.slow_store.has_with_results(key, results).await?;
}

// Check for any in-flight requests to the slow store next.
let mut in_flight_futs = FuturesUnordered::new();
Expand All @@ -502,13 +543,6 @@ impl StoreDriver for FastSlowStore {
results[i] = size;
}

// NOTE: We intentionally *NEVER* check the fast store, this is to
// ensure that we re-upload data to the slow store if it only exists
// in the fast store. This does not affect workers as they do not
// check existence through `has` and instead go direct to loading the
// data which bypasses the check and will load from the fast store if
// it does not exist in the slow store.

Ok(())
}

Expand Down
Loading
Loading