/build/source/nativelink-store/src/fast_slow_store.rs
Line | Count | Source |
1 | | // Copyright 2024-2025 The NativeLink Authors. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | use core::borrow::BorrowMut; |
16 | | use core::cmp::{max, min}; |
17 | | use core::ops::Range; |
18 | | use core::pin::Pin; |
19 | | use core::sync::atomic::{AtomicU64, Ordering}; |
20 | | use core::time::Duration; |
21 | | use std::collections::HashMap; |
22 | | use std::ffi::OsString; |
23 | | use std::sync::{Arc, Weak}; |
24 | | |
25 | | use async_trait::async_trait; |
26 | | use futures::stream::{FuturesUnordered, StreamExt}; |
27 | | use futures::{FutureExt, join, try_join}; |
28 | | use nativelink_config::stores::{FastSlowSpec, StoreDirection}; |
29 | | use nativelink_error::{Code, Error, ErrorContext, ResultExt, make_err}; |
30 | | use nativelink_metric::MetricsComponent; |
31 | | use nativelink_util::buf_channel::{ |
32 | | DropCloserReadHalf, DropCloserWriteHalf, make_buf_channel_pair, |
33 | | }; |
34 | | use nativelink_util::fs; |
35 | | use nativelink_util::health_utils::{HealthStatusIndicator, default_health_status_indicator}; |
36 | | use nativelink_util::metrics::{record_store_tier_io, record_store_tier_read}; |
37 | | use nativelink_util::store_trait::{ |
38 | | RemoveCallback, Store, StoreDriver, StoreKey, StoreLike, StoreOptimizations, UploadSizeInfo, |
39 | | slow_update_store_with_file, |
40 | | }; |
41 | | use parking_lot::Mutex; |
42 | | use tokio::sync::OnceCell; |
43 | | use tracing::{debug, info, trace, warn}; |
44 | | |
45 | | use crate::filesystem_store::FilesystemStore; |
46 | | |
47 | | // TODO(palfrey) This store needs to be evaluated for more efficient memory usage, |
48 | | // there are many copies happening internally. |
49 | | |
50 | | type Loader = Arc<OnceCell<()>>; |
51 | | type MaybeSize = Option<u64>; |
52 | | |
53 | | // TODO(palfrey) We should consider copying the data in the background to allow the |
54 | | // client to hang up while the data is buffered. An alternative is to possibly make a |
55 | | // "BufferedStore" that could be placed on the "slow" store that would hang up early |
56 | | // if data is in the buffer. |
57 | | #[derive(Debug, MetricsComponent)] |
58 | | pub struct FastSlowStore { |
59 | | #[metric(group = "fast_store")] |
60 | | fast_store: Store, |
61 | | fast_direction: StoreDirection, |
62 | | #[metric(group = "slow_store")] |
63 | | slow_store: Store, |
64 | | slow_direction: StoreDirection, |
65 | | /// See [`FastSlowSpec::bypass_dedup_threshold_bytes`]. |
66 | | bypass_dedup_threshold_bytes: u64, |
67 | | weak_self: Weak<Self>, |
68 | | #[metric] |
69 | | metrics: FastSlowStoreMetrics, |
70 | | // De-duplicate requests for the fast store, only the first streams, others |
71 | | // are blocked. This may feel like it's causing a slow down of tasks, but |
72 | | // actually it's faster because we're not downloading the file multiple |
73 | | // times are doing loads of duplicate IO. |
74 | | populating_digests: Mutex<HashMap<StoreKey<'static>, Loader>>, |
75 | | // Tracks keys whose slow-store write is currently in flight, along with |
76 | | // the best-known size. Consulted by `has_with_results` so that a |
77 | | // concurrent writer that has not yet finished pushing to the slow store |
78 | | // is still visible to a concurrent existence check, preventing redundant |
79 | | // duplicate uploads of the same blob. |
80 | | in_flight_slow_writes: Mutex<HashMap<StoreKey<'static>, Arc<tokio::sync::Mutex<MaybeSize>>>>, |
81 | | } |
82 | | |
83 | | // This guard ensures that the populating_digests is cleared even if the future |
84 | | // is dropped, it is cancel safe. |
85 | | struct LoaderGuard<'a> { |
86 | | weak_store: Weak<FastSlowStore>, |
87 | | key: StoreKey<'a>, |
88 | | loader: Option<Loader>, |
89 | | is_leader: bool, |
90 | | } |
91 | | |
92 | | impl LoaderGuard<'_> { |
93 | 1.43k | async fn get_or_try_init<E, F, Fut>(&self, f: F) -> Result<(), E> |
94 | 1.43k | where |
95 | 1.43k | F: FnOnce() -> Fut, |
96 | 1.43k | Fut: Future<Output = Result<(), E>>, |
97 | 1.43k | { |
98 | 1.43k | if let Some(loader) = &self.loader { |
99 | 1.43k | loader.get_or_try_init(f).await.map1.43k (|&()| ()) |
100 | | } else { |
101 | | // This is impossible, but we do it anyway. |
102 | 0 | f().await |
103 | | } |
104 | 1.43k | } |
105 | | } |
106 | | |
107 | | /// Cancel-safe RAII guard that removes an entry from |
108 | | /// `FastSlowStore::in_flight_slow_writes` when dropped. This ensures the |
109 | | /// map does not leak entries if the surrounding `update()` future is |
110 | | /// cancelled before the slow-store write completes. |
111 | | struct InFlightSlowWriteGuard { |
112 | | weak_store: Weak<FastSlowStore>, |
113 | | key: Option<StoreKey<'static>>, |
114 | | write_complete_guard: tokio::sync::OwnedMutexGuard<MaybeSize>, |
115 | | } |
116 | | |
117 | | impl InFlightSlowWriteGuard { |
118 | 147 | fn complete(mut self, size: MaybeSize) { |
119 | 147 | *self.write_complete_guard = size; |
120 | 147 | } |
121 | | } |
122 | | |
123 | | impl Drop for InFlightSlowWriteGuard { |
124 | 148 | fn drop(&mut self) { |
125 | 148 | let Some(store) = self.weak_store.upgrade() else { |
126 | 0 | return; |
127 | | }; |
128 | 148 | let Some(key) = self.key.take() else { |
129 | 0 | return; |
130 | | }; |
131 | 148 | store.in_flight_slow_writes.lock().remove(&key); |
132 | 148 | } |
133 | | } |
134 | | |
135 | | impl Drop for LoaderGuard<'_> { |
136 | 1.43k | fn drop(&mut self) { |
137 | 1.43k | let Some(store) = self.weak_store.upgrade() else { |
138 | | // The store has already gone away, nothing to remove from. |
139 | 0 | return; |
140 | | }; |
141 | 1.43k | let Some(loader) = self.loader.take() else { |
142 | | // This should never happen, but we do it to be safe. |
143 | 0 | return; |
144 | | }; |
145 | | |
146 | 1.43k | let mut guard = store.populating_digests.lock(); |
147 | 1.43k | if let std::collections::hash_map::Entry::Occupied(occupied_entry) = |
148 | 1.43k | guard.entry(self.key.borrow().into_owned()) |
149 | 1.43k | && Arc::ptr_eq(occupied_entry.get(), &loader) |
150 | | { |
151 | 1.43k | drop(loader); |
152 | 1.43k | if Arc::strong_count(occupied_entry.get()) == 1 { |
153 | 1.39k | // This is the last loader, so remove it. |
154 | 1.39k | occupied_entry.remove(); |
155 | 1.39k | }46 |
156 | 0 | } |
157 | 1.43k | } |
158 | | } |
159 | | |
160 | | impl FastSlowStore { |
161 | 91 | pub fn new(spec: &FastSlowSpec, fast_store: Store, slow_store: Store) -> Arc<Self> { |
162 | 91 | Arc::new_cyclic(|weak_self| Self { |
163 | 91 | fast_store, |
164 | 91 | fast_direction: spec.fast_direction, |
165 | 91 | slow_store, |
166 | 91 | slow_direction: spec.slow_direction, |
167 | | // 0 (default) disables the bypass entirely (always dedup). |
168 | 91 | bypass_dedup_threshold_bytes: spec.bypass_dedup_threshold_bytes, |
169 | 91 | weak_self: weak_self.clone(), |
170 | 91 | metrics: FastSlowStoreMetrics::default(), |
171 | 91 | populating_digests: Mutex::new(HashMap::new()), |
172 | 91 | in_flight_slow_writes: Mutex::new(HashMap::new()), |
173 | 91 | }) |
174 | 91 | } |
175 | | |
176 | 148 | fn register_in_flight_slow_write(&self, key: StoreKey<'_>) -> InFlightSlowWriteGuard { |
177 | 148 | let owned = key.into_owned(); |
178 | 148 | let write_complete = Arc::new(tokio::sync::Mutex::new(None)); |
179 | 148 | let write_complete_guard = write_complete |
180 | 148 | .clone() |
181 | 148 | .try_lock_owned() |
182 | 148 | .expect("Newly created mutex is locked"); |
183 | 148 | self.in_flight_slow_writes |
184 | 148 | .lock() |
185 | 148 | .insert(owned.borrow().into_owned(), write_complete); |
186 | 148 | InFlightSlowWriteGuard { |
187 | 148 | weak_store: self.weak_self.clone(), |
188 | 148 | key: Some(owned), |
189 | 148 | write_complete_guard, |
190 | 148 | } |
191 | 148 | } |
192 | | |
193 | | /// Digest size in bytes, or `None` for non-digest keys. |
194 | 28 | const fn digest_size_bytes(key: &StoreKey<'_>) -> Option<u64> { |
195 | 28 | match key { |
196 | 28 | StoreKey::Digest(d) => Some(d.size_bytes()), |
197 | 0 | StoreKey::Str(_) => None, |
198 | | } |
199 | 28 | } |
200 | | |
201 | | /// Whether a read should skip dedup and hit the slow store directly. A |
202 | | /// threshold of 0 disables the bypass so every read goes through dedup. |
203 | 394 | fn should_bypass_dedup(&self, key: &StoreKey<'_>) -> bool { |
204 | 394 | self.bypass_dedup_threshold_bytes != 0 |
205 | 28 | && Self::digest_size_bytes(key) |
206 | 28 | .is_some_and(|size| size >= self.bypass_dedup_threshold_bytes) |
207 | 394 | } |
208 | | |
209 | 0 | pub const fn fast_store(&self) -> &Store { |
210 | 0 | &self.fast_store |
211 | 0 | } |
212 | | |
213 | 0 | pub const fn slow_store(&self) -> &Store { |
214 | 0 | &self.slow_store |
215 | 0 | } |
216 | | |
217 | | /// Returns the filesystem-backed tiers that can evict a digest. |
218 | | /// |
219 | | /// The worker's fast store is normally a `FilesystemStore`, while the slow |
220 | | /// store may be one behind a `RefStore`. `Store::downcast_ref` follows |
221 | | /// wrappers that delegate `inner_store`; transforming wrappers intentionally |
222 | | /// keep themselves visible because their digest-to-bytes mapping may differ, |
223 | | /// so they are not recursively inspected here. |
224 | 1 | pub fn get_filesystem_stores(&self) -> Vec<Arc<FilesystemStore>> { |
225 | 1 | let mut stores = Vec::with_capacity(2); |
226 | 2 | for store in [&self.fast_store, &self.slow_store]1 { |
227 | 2 | let Some(filesystem_store1 ) = store |
228 | 2 | .downcast_ref::<FilesystemStore>(None) |
229 | 2 | .and_then(FilesystemStore::get_arc) |
230 | | else { |
231 | 1 | continue; |
232 | | }; |
233 | 1 | if !stores |
234 | 1 | .iter() |
235 | 1 | .any(|existing| Arc::ptr_eq0 (existing0 , &filesystem_store0 )) |
236 | 1 | { |
237 | 1 | stores.push(filesystem_store); |
238 | 1 | }0 |
239 | | } |
240 | 1 | stores |
241 | 1 | } |
242 | | |
243 | 2 | pub fn get_arc(&self) -> Option<Arc<Self>> { |
244 | 2 | self.weak_self.upgrade() |
245 | 2 | } |
246 | | |
247 | 1.43k | fn get_loader<'a>(&self, key: StoreKey<'a>) -> LoaderGuard<'a> { |
248 | | // Get a single loader instance that's used to populate the fast store |
249 | | // for this digest. If another request comes in then it's de-duplicated. |
250 | 1.43k | let mut is_leader = false; |
251 | 1.43k | let loader = match self |
252 | 1.43k | .populating_digests |
253 | 1.43k | .lock() |
254 | 1.43k | .entry(key.borrow().into_owned()) |
255 | | { |
256 | 46 | std::collections::hash_map::Entry::Occupied(occupied_entry) => { |
257 | 46 | occupied_entry.get().clone() |
258 | | } |
259 | 1.39k | std::collections::hash_map::Entry::Vacant(vacant_entry) => { |
260 | 1.39k | is_leader = true; |
261 | 1.39k | vacant_entry.insert(Arc::new(OnceCell::new())).clone() |
262 | | } |
263 | | }; |
264 | 1.43k | LoaderGuard { |
265 | 1.43k | weak_store: self.weak_self.clone(), |
266 | 1.43k | key, |
267 | 1.43k | loader: Some(loader), |
268 | 1.43k | is_leader, |
269 | 1.43k | } |
270 | 1.43k | } |
271 | | |
272 | 1.39k | async fn populate_and_maybe_stream( |
273 | 1.39k | self: Pin<&Self>, |
274 | 1.39k | key: StoreKey<'_>, |
275 | 1.39k | maybe_writer: Option<&mut DropCloserWriteHalf>, |
276 | 1.39k | offset: u64, |
277 | 1.39k | length: Option<u64>, |
278 | 1.39k | ) -> Result<(), Error> { |
279 | 1.39k | let reader_stream_size1.38k = if self |
280 | 1.39k | .slow_store |
281 | 1.39k | .inner_store(Some(key.borrow())) |
282 | 1.39k | .optimized_for(StoreOptimizations::LazyExistenceOnSync) |
283 | | { |
284 | 2 | trace!( |
285 | | %key, |
286 | 2 | store_name = %self.slow_store.inner_store(Some(key.borrow())).get_name(), |
287 | | "Skipping .has() check due to LazyExistenceOnSync optimization" |
288 | | ); |
289 | 2 | UploadSizeInfo::MaxSize(u64::MAX) |
290 | | } else { |
291 | 1.38k | UploadSizeInfo::ExactSize(self |
292 | 1.38k | .slow_store |
293 | 1.38k | .has(key.borrow()) |
294 | 1.38k | .await |
295 | 1.38k | .err_tip(|| "Failed to run has() on slow store")?0 |
296 | 1.38k | .ok_or_else(|| {4 |
297 | 4 | let err = make_err!( |
298 | 4 | Code::NotFound, |
299 | | "Object {} not found in either fast or slow store. \ |
300 | | If using multiple workers, ensure all workers share the same CAS storage path.", |
301 | 4 | key.as_str() |
302 | | ); |
303 | 4 | if let StoreKey::Digest(d) = key.borrow() { |
304 | 4 | err.with_context(ErrorContext::MissingDigest { |
305 | 4 | hash: d.packed_hash().to_string(), |
306 | 4 | size: d.size_bytes().try_into().unwrap_or(i64::MAX), |
307 | 4 | }) |
308 | | } else { |
309 | 0 | err |
310 | | } |
311 | 4 | })? |
312 | | ) |
313 | | }; |
314 | | |
315 | 1.38k | let send_range = offset..length.map_or(u64::MAX, |length| length212 + offset212 ); |
316 | 1.38k | let mut bytes_received: u64 = 0; |
317 | 1.38k | let mut counted_hit = false; |
318 | | |
319 | 1.38k | let (mut fast_tx, fast_rx) = make_buf_channel_pair(); |
320 | 1.38k | let (slow_tx, mut slow_rx) = make_buf_channel_pair(); |
321 | 1.38k | let data_stream_fut = async move { |
322 | 1.38k | let mut maybe_writer_pin = maybe_writer.map(Pin::new); |
323 | | loop { |
324 | 2.76k | let output_buf2.76k = slow_rx |
325 | 2.76k | .recv() |
326 | 2.76k | .await |
327 | 2.76k | .err_tip(|| "Failed to read data data buffer from slow store")?2 ; |
328 | 2.76k | if output_buf.is_empty() { |
329 | | // Write out our EOF. |
330 | | // We are dropped as soon as we send_eof to writer_pin, so |
331 | | // we wait until we've finished all of our joins to do that. |
332 | 1.38k | let fast_res = fast_tx.send_eof(); |
333 | 1.38k | return Ok::<_, Error>((fast_res, maybe_writer_pin)); |
334 | 1.37k | } |
335 | | |
336 | 1.37k | if !counted_hit { |
337 | 1.37k | self.metrics |
338 | 1.37k | .slow_store_hit_count |
339 | 1.37k | .fetch_add(1, Ordering::Acquire); |
340 | 1.37k | record_store_tier_read("slow", "hit"); |
341 | 1.37k | counted_hit = true; |
342 | 1.37k | }0 |
343 | | |
344 | 1.37k | let output_buf_len = u64::try_from(output_buf.len()) |
345 | 1.37k | .err_tip(|| "Could not output_buf.len() to u64")?0 ; |
346 | 1.37k | self.metrics |
347 | 1.37k | .slow_store_downloaded_bytes |
348 | 1.37k | .fetch_add(output_buf_len, Ordering::Acquire); |
349 | 1.37k | record_store_tier_io("slow", "read", output_buf_len); |
350 | | |
351 | 1.37k | let writer_fut = Self::calculate_range( |
352 | 1.37k | &(bytes_received..bytes_received + output_buf_len), |
353 | 1.37k | &send_range, |
354 | 0 | )? |
355 | 1.37k | .zip(maybe_writer_pin.as_mut()) |
356 | 1.37k | .map_or_else( |
357 | 1.05k | || futures::future::ready(Ok(())).left_future(), |
358 | 325 | |(range, writer_pin)| writer_pin.send(output_buf.slice(range)).right_future(), |
359 | | ); |
360 | | |
361 | 1.37k | bytes_received += output_buf_len; |
362 | | |
363 | 1.37k | let (fast_tx_res, writer_res) = join!(fast_tx.send(output_buf), writer_fut); |
364 | 1.37k | fast_tx_res.err_tip(|| "Failed to write to fast store in fast_slow store")?0 ; |
365 | 1.37k | writer_res.err_tip(|| "Failed to write result to writer in fast_slow store")?0 ; |
366 | | } |
367 | 1.38k | }; |
368 | | |
369 | 1.38k | let slow_store_fut = self.slow_store.get(key.borrow(), slow_tx); |
370 | 1.38k | let fast_store_fut = self |
371 | 1.38k | .fast_store |
372 | 1.38k | .update(key.borrow(), fast_rx, reader_stream_size); |
373 | | |
374 | 1.38k | let (data_stream_res, slow_res, fast_res) = |
375 | 1.38k | join!(data_stream_fut, slow_store_fut, fast_store_fut); |
376 | 1.38k | match data_stream_res { |
377 | 1.38k | Ok((fast_eof_res, maybe_writer_pin)) => |
378 | | // Sending the EOF will drop us almost immediately in bytestream_server |
379 | | // so we perform it as the very last action in this method. |
380 | | { |
381 | 1.38k | fast_eof_res.merge(fast_res).merge(slow_res).merge( |
382 | 1.38k | if let Some(mut writer_pin331 ) = maybe_writer_pin { |
383 | 331 | writer_pin.send_eof() |
384 | | } else { |
385 | 1.05k | Ok(()) |
386 | | }, |
387 | | ) |
388 | | } |
389 | 2 | Err(err) => match slow_res { |
390 | 2 | Err(slow_err1 ) if slow_err.code == Code::NotFound1 => Err(slow_err)1 , |
391 | 1 | _ => fast_res.merge(slow_res).merge(Err(err)), |
392 | | }, |
393 | | } |
394 | 1.39k | } |
395 | | |
396 | | /// Ensure our fast store is populated. This should be kept as a low |
397 | | /// cost function. Since the data itself is shared and not copied it should be fairly |
398 | | /// low cost to just discard the data, but does cost a few mutex locks while |
399 | | /// streaming. |
400 | 1.24k | pub async fn populate_fast_store(&self, key: StoreKey<'_>) -> Result<(), Error>0 { |
401 | 1.24k | let maybe_size_info = self |
402 | 1.24k | .fast_store |
403 | 1.24k | .has(key.borrow()) |
404 | 1.24k | .await |
405 | 1.24k | .err_tip(|| "While querying in populate_fast_store")?0 ; |
406 | 1.24k | if maybe_size_info.is_some() { |
407 | 183 | return Ok(()); |
408 | 1.06k | } |
409 | | |
410 | | // If the fast store is noop or read only or update only then this is an error. |
411 | 1.06k | if self |
412 | 1.06k | .fast_store |
413 | 1.06k | .inner_store(Some(key.borrow())) |
414 | 1.06k | .optimized_for(StoreOptimizations::NoopUpdates) |
415 | 1.06k | || self.fast_direction == StoreDirection::ReadOnly |
416 | 1.06k | || self.fast_direction == StoreDirection::Update |
417 | | { |
418 | 0 | return Err(make_err!( |
419 | 0 | Code::Internal, |
420 | 0 | "Attempt to populate fast store that is read only or noop" |
421 | 0 | )); |
422 | 1.06k | } |
423 | | |
424 | 1.06k | self.get_loader(key.borrow()) |
425 | 1.06k | .get_or_try_init(|| {1.05k |
426 | 1.05k | Pin::new(self).populate_and_maybe_stream(key.borrow(), None, 0, None) |
427 | 1.05k | }) |
428 | 1.06k | .await |
429 | 1.06k | .err_tip(|| "Failed to populate()") |
430 | 1.24k | } |
431 | | |
432 | | /// Returns the range of bytes that should be sent given a slice bounds |
433 | | /// offset so the output range maps the `received_range.start` to 0. |
434 | | // TODO(palfrey) This should be put into utils, as this logic is used |
435 | | // elsewhere in the code. |
436 | 1.38k | pub fn calculate_range( |
437 | 1.38k | received_range: &Range<u64>, |
438 | 1.38k | send_range: &Range<u64>, |
439 | 1.38k | ) -> Result<Option<Range<usize>>, Error> { |
440 | | // Protect against subtraction overflow. |
441 | 1.38k | if received_range.start >= received_range.end { |
442 | 0 | return Ok(None); |
443 | 1.38k | } |
444 | | |
445 | 1.38k | let start = max(received_range.start, send_range.start); |
446 | 1.38k | let end = min(received_range.end, send_range.end); |
447 | 1.38k | if received_range.contains(&start) && received_range1.38k .contains1.38k (&(end - 1)1.38k ) { |
448 | | // Offset both to the start of the received_range. |
449 | 1.38k | let calculated_range_start = usize::try_from(start - received_range.start) |
450 | 1.38k | .err_tip(|| "Could not convert (start - received_range.start) to usize")?0 ; |
451 | 1.38k | let calculated_range_end = usize::try_from(end - received_range.start) |
452 | 1.38k | .err_tip(|| "Could not convert (end - received_range.start) to usize")?0 ; |
453 | 1.38k | Ok(Some(calculated_range_start..calculated_range_end)) |
454 | | } else { |
455 | 4 | Ok(None) |
456 | | } |
457 | 1.38k | } |
458 | | } |
459 | | |
460 | | #[async_trait] |
461 | | impl StoreDriver for FastSlowStore { |
462 | 0 | async fn post_init(self: Arc<Self>) -> Result<(), Error> { |
463 | | try_join!( |
464 | | self.fast_store.clone().into_inner().post_init(), |
465 | | self.slow_store.clone().into_inner().post_init() |
466 | | )?; |
467 | | Ok(()) |
468 | 0 | } |
469 | | |
470 | | async fn has_with_results( |
471 | | self: Pin<&Self>, |
472 | | key: &[StoreKey<'_>], |
473 | | results: &mut [Option<u64>], |
474 | 27 | ) -> Result<(), Error> { |
475 | | // If our slow store is a noop store, it'll always return a 404, |
476 | | // so only check the fast store in such case. |
477 | | let slow_store = self.slow_store.inner_store::<StoreKey<'_>>(None); |
478 | | if slow_store.optimized_for(StoreOptimizations::NoopDownloads) { |
479 | | return self.fast_store.has_with_results(key, results).await; |
480 | | } |
481 | | |
482 | | // Check with the slow store first. |
483 | | self.slow_store.has_with_results(key, results).await?; |
484 | | |
485 | | // Check for any in-flight requests to the slow store next. |
486 | | let mut in_flight_futs = FuturesUnordered::new(); |
487 | | { |
488 | | let in_flight = self.in_flight_slow_writes.lock(); |
489 | | if !in_flight.is_empty() { |
490 | | for (i, (k, result)) in key.iter().zip(results.iter_mut()).enumerate() { |
491 | | if result.is_none() { |
492 | | let owned = k.borrow().into_owned(); |
493 | | if let Some(maybe_size) = in_flight.get(&owned) { |
494 | | let maybe_size = maybe_size.clone(); |
495 | 3 | in_flight_futs.push(async move { (i, *maybe_size.lock().await) }); |
496 | | } |
497 | | } |
498 | | } |
499 | | } |
500 | | } |
501 | | while let Some((i, size)) = in_flight_futs.next().await { |
502 | | results[i] = size; |
503 | | } |
504 | | |
505 | | // NOTE: We intentionally *NEVER* check the fast store, this is to |
506 | | // ensure that we re-upload data to the slow store if it only exists |
507 | | // in the fast store. This does not affect workers as they do not |
508 | | // check existence through `has` and instead go direct to loading the |
509 | | // data which bypasses the check and will load from the fast store if |
510 | | // it does not exist in the slow store. |
511 | | |
512 | | Ok(()) |
513 | 27 | } |
514 | | |
515 | | async fn update( |
516 | | self: Pin<&Self>, |
517 | | key: StoreKey<'_>, |
518 | | mut reader: DropCloserReadHalf, |
519 | | size_info: UploadSizeInfo, |
520 | 137 | ) -> Result<u64, Error> { |
521 | | // If either one of our stores is a noop store, bypass the multiplexing |
522 | | // and just use the store that is not a noop store. |
523 | | let ignore_slow = self |
524 | | .slow_store |
525 | | .inner_store(Some(key.borrow())) |
526 | | .optimized_for(StoreOptimizations::NoopUpdates) |
527 | | || self.slow_direction == StoreDirection::ReadOnly |
528 | | || self.slow_direction == StoreDirection::Get; |
529 | | let ignore_fast = self |
530 | | .fast_store |
531 | | .inner_store(Some(key.borrow())) |
532 | | .optimized_for(StoreOptimizations::NoopUpdates) |
533 | | || self.fast_direction == StoreDirection::ReadOnly |
534 | | || self.fast_direction == StoreDirection::Get; |
535 | | if ignore_slow && ignore_fast { |
536 | | // We need to drain the reader to avoid the writer complaining that we dropped |
537 | | // the connection prematurely. |
538 | | return reader.drain().await.err_tip(|| "In FastFlowStore::update"); |
539 | | } |
540 | | if ignore_slow { |
541 | | return self.fast_store.update(key, reader, size_info).await; |
542 | | } |
543 | | let slow_in_flight_guard = self.register_in_flight_slow_write(key.borrow()); |
544 | | if ignore_fast { |
545 | | let result = self |
546 | | .slow_store |
547 | | .update(key.borrow(), reader, size_info) |
548 | | .await; |
549 | | if let Ok(size) = &result { |
550 | | slow_in_flight_guard.complete(Some(*size)); |
551 | | } |
552 | | return result; |
553 | | } |
554 | | |
555 | | let (mut fast_tx, fast_rx) = make_buf_channel_pair(); |
556 | | let (mut slow_tx, slow_rx) = make_buf_channel_pair(); |
557 | | |
558 | | let key_debug = format!("{key:?}"); |
559 | | trace!( |
560 | | key = %key_debug, |
561 | | "FastSlowStore::update: starting dual-store upload", |
562 | | ); |
563 | | let update_start = std::time::Instant::now(); |
564 | | |
565 | 131 | let data_stream_fut = async move { |
566 | 131 | let mut bytes_sent: u64 = 0; |
567 | | loop { |
568 | 214 | let buffer = reader |
569 | 214 | .recv() |
570 | 214 | .await |
571 | 214 | .err_tip(|| "Failed to read buffer in fastslow store")?0 ; |
572 | 214 | if buffer.is_empty() { |
573 | | // EOF received. |
574 | 131 | fast_tx.send_eof().err_tip( |
575 | | || "Failed to write eof to fast store in fast_slow store update", |
576 | 0 | )?; |
577 | 131 | slow_tx |
578 | 131 | .send_eof() |
579 | 131 | .err_tip(|| "Failed to write eof to writer in fast_slow store update")?0 ; |
580 | 131 | debug!( |
581 | | total_bytes = bytes_sent, |
582 | | "FastSlowStore::update: data_stream sent EOF to both stores", |
583 | | ); |
584 | 131 | return Result::<u64, Error>::Ok(bytes_sent); |
585 | 83 | } |
586 | | |
587 | 83 | let chunk_len = buffer.len(); |
588 | 83 | let send_start = std::time::Instant::now(); |
589 | 83 | let (fast_result, slow_result) = |
590 | 83 | join!(fast_tx.send(buffer.clone()), slow_tx.send(buffer)); |
591 | 83 | let send_elapsed = send_start.elapsed(); |
592 | 83 | if send_elapsed.as_secs() >= 5 { |
593 | 0 | warn!( |
594 | | chunk_len, |
595 | 0 | send_elapsed_ms = send_elapsed.as_millis(), |
596 | | total_bytes = bytes_sent, |
597 | | "FastSlowStore::update: channel send stalled (>5s). A downstream store may be hanging", |
598 | | ); |
599 | 83 | } |
600 | 83 | bytes_sent += u64::try_from(chunk_len).unwrap_or(u64::MAX); |
601 | 83 | fast_result |
602 | 83 | .map_err(|e| {0 |
603 | 0 | make_err!( |
604 | 0 | Code::Internal, |
605 | | "Failed to send message to fast_store in fast_slow_store {:?}", |
606 | | e |
607 | | ) |
608 | 0 | }) |
609 | 83 | .merge(slow_result.map_err(|e| {0 |
610 | 0 | make_err!( |
611 | 0 | Code::Internal, |
612 | | "Failed to send message to slow_store in fast_slow store {:?}", |
613 | | e |
614 | | ) |
615 | 0 | }))?; |
616 | | } |
617 | 131 | }; |
618 | | |
619 | | let fast_store_fut = self.fast_store.update(key.borrow(), fast_rx, size_info); |
620 | | let slow_store_fut = self.slow_store.update(key.borrow(), slow_rx, size_info); |
621 | | |
622 | | let (data_stream_res, fast_res, slow_res) = |
623 | | join!(data_stream_fut, fast_store_fut, slow_store_fut); |
624 | | |
625 | | if let Ok(size) = slow_res { |
626 | | slow_in_flight_guard.complete(Some(size)); |
627 | | } else { |
628 | | drop(slow_in_flight_guard); |
629 | | } |
630 | | |
631 | | let total_elapsed = update_start.elapsed(); |
632 | | if data_stream_res.is_err() || fast_res.is_err() || slow_res.is_err() { |
633 | | let all_not_found = [&data_stream_res, &fast_res, &slow_res] |
634 | | .iter() |
635 | 0 | .all(|r| match r { |
636 | 0 | Ok(_size) => true, |
637 | 0 | Err(e) => e.code == Code::NotFound, |
638 | 0 | }); |
639 | | if all_not_found { |
640 | | info!( |
641 | | key = %key_debug, |
642 | | elapsed_ms = total_elapsed.as_millis(), |
643 | | data_stream_ok = data_stream_res.is_ok(), |
644 | | fast_store_ok = fast_res.is_ok(), |
645 | | slow_store_ok = slow_res.is_ok(), |
646 | | "FastSlowStore::update: completed with NotFound error(s)", |
647 | | ); |
648 | | } else { |
649 | | warn!( |
650 | | key = %key_debug, |
651 | | elapsed_ms = total_elapsed.as_millis(), |
652 | | data_stream_ok = data_stream_res.is_ok(), |
653 | | fast_store_ok = fast_res.is_ok(), |
654 | | slow_store_ok = slow_res.is_ok(), |
655 | | "FastSlowStore::update: completed with error(s)", |
656 | | ); |
657 | | } |
658 | | } else { |
659 | | trace!( |
660 | | key = %key_debug, |
661 | | elapsed_ms = total_elapsed.as_millis(), |
662 | | "FastSlowStore::update: completed successfully", |
663 | | ); |
664 | | } |
665 | | data_stream_res.merge(fast_res).merge(slow_res) |
666 | 137 | } |
667 | | |
668 | | /// `FastSlowStore` has optimizations for dealing with files. |
669 | 0 | fn optimized_for(&self, optimization: StoreOptimizations) -> bool { |
670 | 0 | optimization == StoreOptimizations::FileUpdates |
671 | 0 | } |
672 | | |
673 | | /// Optimized variation to consume the file if one of the stores is a |
674 | | /// filesystem store. This makes the operation a move instead of a copy |
675 | | /// dramatically increasing performance for large files. |
676 | | async fn update_with_whole_file( |
677 | | self: Pin<&Self>, |
678 | | key: StoreKey<'_>, |
679 | | path: OsString, |
680 | | mut file: fs::FileSlot, |
681 | | upload_size: UploadSizeInfo, |
682 | 13 | ) -> Result<(u64, Option<fs::FileSlot>), Error> { |
683 | | trace!( |
684 | | key = ?key, |
685 | | ?upload_size, |
686 | | "FastSlowStore::update_with_whole_file: starting", |
687 | | ); |
688 | | if self |
689 | | .fast_store |
690 | | .optimized_for(StoreOptimizations::FileUpdates) |
691 | | { |
692 | | if !self |
693 | | .slow_store |
694 | | .inner_store(Some(key.borrow())) |
695 | | .optimized_for(StoreOptimizations::NoopUpdates) |
696 | | && self.slow_direction != StoreDirection::ReadOnly |
697 | | && self.slow_direction != StoreDirection::Get |
698 | | { |
699 | | trace!("FastSlowStore::update_with_whole_file: uploading to slow_store"); |
700 | | let slow_start = std::time::Instant::now(); |
701 | | let slow_in_flight_guard = self.register_in_flight_slow_write(key.borrow()); |
702 | | let size = slow_update_store_with_file( |
703 | | self.slow_store.as_store_driver_pin(), |
704 | | key.borrow(), |
705 | | &mut file, |
706 | | upload_size, |
707 | | ) |
708 | | .await |
709 | | .err_tip(|| "In FastSlowStore::update_with_whole_file slow_store")?; |
710 | | slow_in_flight_guard.complete(Some(size)); |
711 | | trace!( |
712 | | elapsed_ms = slow_start.elapsed().as_millis(), |
713 | | "FastSlowStore::update_with_whole_file: slow_store upload completed", |
714 | | ); |
715 | | } |
716 | | if self.fast_direction == StoreDirection::ReadOnly |
717 | | || self.fast_direction == StoreDirection::Get |
718 | | { |
719 | | let file_size = match upload_size { |
720 | | UploadSizeInfo::ExactSize(size) => size, |
721 | | UploadSizeInfo::MaxSize(_) => file |
722 | | .as_ref() |
723 | | .metadata() |
724 | | .await |
725 | 0 | .err_tip(|| format!("While reading metadata for {}", path.display()))? |
726 | | .len(), |
727 | | }; |
728 | | return Ok((file_size, Some(file))); |
729 | | } |
730 | | return self |
731 | | .fast_store |
732 | | .update_with_whole_file(key, path, file, upload_size) |
733 | | .await; |
734 | | } |
735 | | |
736 | | if self |
737 | | .slow_store |
738 | | .optimized_for(StoreOptimizations::FileUpdates) |
739 | | { |
740 | | let ignore_fast = self |
741 | | .fast_store |
742 | | .inner_store(Some(key.borrow())) |
743 | | .optimized_for(StoreOptimizations::NoopUpdates) |
744 | | || self.fast_direction == StoreDirection::ReadOnly |
745 | | || self.fast_direction == StoreDirection::Get; |
746 | | let maybe_size = if ignore_fast { |
747 | | None |
748 | | } else { |
749 | | Some( |
750 | | slow_update_store_with_file( |
751 | | self.fast_store.as_store_driver_pin(), |
752 | | key.borrow(), |
753 | | &mut file, |
754 | | upload_size, |
755 | | ) |
756 | | .await |
757 | | .err_tip(|| "In FastSlowStore::update_with_whole_file fast_store")?, |
758 | | ) |
759 | | }; |
760 | | let ignore_slow = self.slow_direction == StoreDirection::ReadOnly |
761 | | || self.slow_direction == StoreDirection::Get; |
762 | | if ignore_slow { |
763 | | let size = match maybe_size { |
764 | | Some(size) => size, |
765 | | None => match upload_size { |
766 | | UploadSizeInfo::ExactSize(size) => size, |
767 | | UploadSizeInfo::MaxSize(_) => file |
768 | | .as_ref() |
769 | | .metadata() |
770 | | .await |
771 | 0 | .err_tip(|| format!("While reading metadata for {}", path.display()))? |
772 | | .len(), |
773 | | }, |
774 | | }; |
775 | | return Ok((size, Some(file))); |
776 | | } |
777 | | let slow_in_flight_guard: InFlightSlowWriteGuard = |
778 | | self.register_in_flight_slow_write(key.borrow()); |
779 | | let (size, maybe_file_slot) = self |
780 | | .slow_store |
781 | | .update_with_whole_file(key.borrow(), path, file, upload_size) |
782 | | .await?; |
783 | | slow_in_flight_guard.complete(Some(size)); |
784 | | return Ok((size, maybe_file_slot)); |
785 | | } |
786 | | |
787 | | let size = slow_update_store_with_file(self, key, &mut file, upload_size) |
788 | | .await |
789 | | .err_tip(|| "In FastSlowStore::update_with_whole_file")?; |
790 | | Ok((size, Some(file))) |
791 | 13 | } |
792 | | |
793 | | async fn get_part( |
794 | | self: Pin<&Self>, |
795 | | key: StoreKey<'_>, |
796 | | writer: &mut DropCloserWriteHalf, |
797 | | offset: u64, |
798 | | length: Option<u64>, |
799 | 607 | ) -> Result<(), Error> { |
800 | | // `has()` can report a stale map entry whose file is gone, so |
801 | | // get_part may still return NotFound; fall through to the slow |
802 | | // store unless we have already streamed bytes to the caller. |
803 | | // One existence check, reused: this is the hot read path and `has()` |
804 | | // on a filesystem fast store is a syscall. |
805 | | let in_fast_store = self.fast_store.has(key.borrow()).await?.is_some(); |
806 | | if !in_fast_store { |
807 | | record_store_tier_read("fast", "miss"); |
808 | | } |
809 | | if in_fast_store { |
810 | | let bytes_before = writer.get_bytes_written(); |
811 | | match self |
812 | | .fast_store |
813 | | .get_part(key.borrow(), writer.borrow_mut(), offset, length) |
814 | | .await |
815 | | { |
816 | | Ok(()) => { |
817 | | self.metrics |
818 | | .fast_store_hit_count |
819 | | .fetch_add(1, Ordering::Acquire); |
820 | | self.metrics |
821 | | .fast_store_downloaded_bytes |
822 | | .fetch_add(writer.get_bytes_written(), Ordering::Acquire); |
823 | | record_store_tier_read("fast", "hit"); |
824 | | record_store_tier_io("fast", "read", writer.get_bytes_written()); |
825 | | return Ok(()); |
826 | | } |
827 | | Err(e) |
828 | | if e.code == Code::NotFound && writer.get_bytes_written() == bytes_before => |
829 | | { |
830 | | self.metrics |
831 | | .fast_store_stale_map_falls_through |
832 | | .fetch_add(1, Ordering::Acquire); |
833 | | record_store_tier_read("fast", "stale"); |
834 | | warn!(%key, ?e, "Stale fast-store map entry; falling through to slow store"); |
835 | | // fall through to populate path |
836 | | } |
837 | | Err(e) => return Err(e), |
838 | | } |
839 | | } |
840 | | |
841 | | // If the fast store is noop or read only or update only then bypass it. |
842 | | if self |
843 | | .fast_store |
844 | | .inner_store(Some(key.borrow())) |
845 | | .optimized_for(StoreOptimizations::NoopUpdates) |
846 | | || self.fast_direction == StoreDirection::ReadOnly |
847 | | || self.fast_direction == StoreDirection::Update |
848 | | { |
849 | | self.metrics |
850 | | .slow_store_hit_count |
851 | | .fetch_add(1, Ordering::Acquire); |
852 | | self.slow_store |
853 | | .get_part(key, writer.borrow_mut(), offset, length) |
854 | | .await?; |
855 | | self.metrics |
856 | | .slow_store_downloaded_bytes |
857 | | .fetch_add(writer.get_bytes_written(), Ordering::Acquire); |
858 | | record_store_tier_read("slow", "hit"); |
859 | | record_store_tier_io("slow", "read", writer.get_bytes_written()); |
860 | | return Ok(()); |
861 | | } |
862 | | |
863 | | // Huge blobs: dedup is a net loss (followers time out anyway and |
864 | | // the fast tier is evicted), so read straight from the slow store. |
865 | | if self.should_bypass_dedup(&key) { |
866 | | self.metrics |
867 | | .huge_blob_dedup_bypasses |
868 | | .fetch_add(1, Ordering::Acquire); |
869 | | self.metrics |
870 | | .slow_store_hit_count |
871 | | .fetch_add(1, Ordering::Acquire); |
872 | | debug!(%key, threshold_bytes = self.bypass_dedup_threshold_bytes, "Bypassing dedup for huge blob"); |
873 | | self.slow_store |
874 | | .get_part(key, writer.borrow_mut(), offset, length) |
875 | | .await |
876 | | .err_tip(|| "In FastSlowStore::get_part huge-blob bypass")?; |
877 | | self.metrics |
878 | | .slow_store_downloaded_bytes |
879 | | .fetch_add(writer.get_bytes_written(), Ordering::Acquire); |
880 | | record_store_tier_read("slow", "hit"); |
881 | | record_store_tier_io("slow", "read", writer.get_bytes_written()); |
882 | | return Ok(()); |
883 | | } |
884 | | |
885 | | let mut writer = Some(writer); |
886 | | |
887 | | // Drive the dedup loader. Two distinct paths: |
888 | | // |
889 | | // * Leader (created the OnceCell entry): runs `populate` with |
890 | | // OUR `writer`, streaming directly to the caller while |
891 | | // filling the fast cache. No timeout: a multi-GB blob |
892 | | // legitimately takes minutes to stream, and cancelling our |
893 | | // own populate would propagate the failure to every other |
894 | | // reader of this digest. |
895 | | // |
896 | | // * Follower: bound the wait so a wedged leader does not pin |
897 | | // us until the upstream `gRPC` deadline fires. The follower |
898 | | // closure passes `None` for `writer`. This is critical: if |
899 | | // the OnceCell ever promotes our follower closure to leader |
900 | | // (because the original leader's future was dropped), and |
901 | | // our `tokio::time::timeout` then cancels it, *no* caller |
902 | | // `writer` was ever moved into the populate stream, so no |
903 | | // `gRPC` sender is dropped without EOF. The follower then |
904 | | // re-enters `get_part` below and reads from the now-warm |
905 | | // fast cache, OR falls back to the slow store on timeout. |
906 | | let needs_slow_store_fallback: bool = { |
907 | | let loader_guard = self.get_loader(key.borrow()); |
908 | | let is_leader = loader_guard.is_leader; |
909 | | if is_leader { |
910 | | loader_guard |
911 | 337 | .get_or_try_init(|| { |
912 | 337 | self.populate_and_maybe_stream(key.borrow(), writer.take(), offset, length) |
913 | 337 | }) |
914 | | .await?; |
915 | | false |
916 | | } else { |
917 | 0 | let load_fut = loader_guard.get_or_try_init(|| { |
918 | 0 | self.populate_and_maybe_stream(key.borrow(), None, offset, length) |
919 | 0 | }); |
920 | | match tokio::time::timeout(LEADER_WAIT_TIMEOUT, load_fut).await { |
921 | | Ok(result) => { |
922 | | result?; |
923 | | false |
924 | | } |
925 | | Err(_elapsed) => { |
926 | | self.metrics |
927 | | .leader_wait_timeouts |
928 | | .fetch_add(1, Ordering::Acquire); |
929 | | warn!( |
930 | | %key, |
931 | | timeout_secs = LEADER_WAIT_TIMEOUT.as_secs(), |
932 | | "FastSlowStore::get_part: leader-wait exceeded timeout, bypassing dedup and reading slow store directly", |
933 | | ); |
934 | | true |
935 | | } |
936 | | } |
937 | | } |
938 | | }; |
939 | | |
940 | | if needs_slow_store_fallback && let Some(writer) = writer.take() { |
941 | | return self |
942 | | .slow_store |
943 | | .get_part(key, writer, offset, length) |
944 | | .await |
945 | | .err_tip( |
946 | | || "In FastSlowStore::get_part slow_store fallback after leader-wait timeout", |
947 | | ); |
948 | | } |
949 | | |
950 | | // If we didn't stream then re-enter which will stream from the fast |
951 | | // store, or retry the download. We should not get in a loop here |
952 | | // because OnceCell has the good sense to retry for all callers so in |
953 | | // order to get here the fast store will have been populated. There's |
954 | | // an outside chance it was evicted, but that's slim. |
955 | | if let Some(writer) = writer.take() { |
956 | | self.get_part(key, writer, offset, length).await |
957 | | } else { |
958 | | // This was the thread that did the streaming already, lucky duck. |
959 | | Ok(()) |
960 | | } |
961 | 607 | } |
962 | | |
963 | 2 | fn inner_store(&self, _key: Option<StoreKey>) -> &dyn StoreDriver { |
964 | 2 | self |
965 | 2 | } |
966 | | |
967 | 2 | fn as_any<'a>(&'a self) -> &'a (dyn core::any::Any + Sync + Send + 'static) { |
968 | 2 | self |
969 | 2 | } |
970 | | |
971 | 0 | fn as_any_arc(self: Arc<Self>) -> Arc<dyn core::any::Any + Sync + Send + 'static> { |
972 | 0 | self |
973 | 0 | } |
974 | | |
975 | 0 | fn register_remove_callback(self: Arc<Self>, callback: RemoveCallback) -> Result<(), Error> { |
976 | 0 | self.fast_store.register_remove_callback(callback.clone())?; |
977 | 0 | self.slow_store.register_remove_callback(callback)?; |
978 | 0 | Ok(()) |
979 | 0 | } |
980 | | } |
981 | | |
982 | | #[derive(Debug, Default, MetricsComponent)] |
983 | | struct FastSlowStoreMetrics { |
984 | | #[metric(help = "Hit count for the fast store")] |
985 | | fast_store_hit_count: AtomicU64, |
986 | | #[metric(help = "Downloaded bytes from the fast store")] |
987 | | fast_store_downloaded_bytes: AtomicU64, |
988 | | #[metric(help = "Hit count for the slow store")] |
989 | | slow_store_hit_count: AtomicU64, |
990 | | #[metric(help = "Downloaded bytes from the slow store")] |
991 | | slow_store_downloaded_bytes: AtomicU64, |
992 | | #[metric( |
993 | | help = "Number of times a follower bypassed the populating-digests dedup because the leader exceeded LEADER_WAIT_TIMEOUT" |
994 | | )] |
995 | | leader_wait_timeouts: AtomicU64, |
996 | | #[metric(help = "get_part calls that bypassed dedup for huge blobs")] |
997 | | huge_blob_dedup_bypasses: AtomicU64, |
998 | | #[metric(help = "Stale fast-store map entries that fell through to the slow store")] |
999 | | fast_store_stale_map_falls_through: AtomicU64, |
1000 | | } |
1001 | | |
1002 | | /// Maximum time a follower will wait on the leader-populator before |
1003 | | /// bypassing the dedup map and reading directly from the slow store. |
1004 | | /// |
1005 | | /// Without this bound a single wedged populator would block every |
1006 | | /// concurrent reader of the same digest until each one's own `gRPC` |
1007 | | /// deadline fired (e.g. Bazel's `--remote_timeout`), turning a |
1008 | | /// single slow read into a fan-out of `DEADLINE_EXCEEDED` errors. |
1009 | | const LEADER_WAIT_TIMEOUT: Duration = Duration::from_mins(1); |
1010 | | |
1011 | | default_health_status_indicator!(FastSlowStore); |