Skip to content

Commit 1a10e3f

Browse files
authored
memory_store: skip writes larger than max_bytes instead of buffering them (TraceMachina#2473)
`MemoryStore::update` read the entire payload into memory (`consume(None)`, plus a defensive full copy) before inserting into the evicting map and running eviction. For a write whose size already meets the store's `max_bytes`, that's wasted work: the eviction loop drops the entry the instant it's inserted (a single item over budget), so it was never retainable. Worse, under concurrent large writes it's an OOM vector. A `memory` fast tier inside a `fast_slow` store fronting CAS receives every blob regardless of size, so a burst of large ByteStream uploads each materializes its whole payload before eviction runs — dozens of multi-hundred-MB uploads in parallel can blow the cgroup limit and OOMKill the CAS. Fix: when the eviction policy caps size (`max_bytes != 0`) and the write's `ExactSize` is >= it, drain the stream and return without buffering. The `>=` matches the eviction comparator (`sum_store_size >= max_bytes`), so a blob of exactly `max_bytes` is skipped too. Only `ExactSize` is trusted — a `MaxSize` upper bound could over-estimate and wrongly skip a blob that fits. Because the rejected write never enters the map, the insert-then-evict removal callbacks don't fire on their own. A wrapping `ExistenceCacheStore` relies on those to drop a just-written-then-evicted key, so the skip path fires them explicitly via a new `EvictingMap::fire_remove_callbacks` (a no-op when no callbacks are registered), preserving the "copes with dropped items" guarantee. Scope: this closes the single-blob-exceeds-cap vector. It does NOT address N concurrent writes each under `max_bytes` but collectively over the cgroup limit — each still materializes fully before eviction. The general fix is incremental/streaming admission with mid-stream size accounting (which would also cover `MaxSize` and unknown-size writes); this is an interim step. Tests (nativelink-store/tests/memory_store_test.rs): * skips_writes_larger_than_max_bytes — an oversized write is skipped, and a within-budget entry survives it (the old path would have evicted it). * oversized_skip_fires_remove_callbacks — the skip fires the remove callback so a wrapping existence cache can invalidate. * max_size_over_budget_with_small_actual_is_stored — a `MaxSize` over the budget with small actual content is still cached (only `ExactSize` skips).
1 parent ccc01eb commit 1a10e3f

3 files changed

Lines changed: 213 additions & 2 deletions

File tree

nativelink-store/src/memory_store.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use nativelink_util::health_utils::{
3333
use nativelink_util::store_trait::{
3434
RemoveItemCallback, StoreDriver, StoreKey, StoreKeyBorrow, StoreOptimizations, UploadSizeInfo,
3535
};
36+
use tracing::warn;
3637

3738
use crate::callback_utils::RemoveItemCallbackHolder;
3839
use crate::cas_utils::is_zero_digest;
@@ -68,13 +69,19 @@ pub struct MemoryStore {
6869
SystemTime,
6970
RemoveItemCallbackHolder,
7071
>,
72+
/// The eviction policy's `max_bytes` (0 = unbounded). Cached here so `update`
73+
/// can skip writes larger than the entire store budget without buffering
74+
/// them — see the note in `update`.
75+
#[metric(help = "Maximum bytes this store will hold before eviction (0 = unbounded)")]
76+
max_bytes: u64,
7177
}
7278

7379
impl MemoryStore {
7480
pub fn new(spec: &MemorySpec) -> Arc<Self> {
7581
let empty_policy = nativelink_config::stores::EvictionPolicy::default();
7682
let eviction_policy = spec.eviction_policy.as_ref().unwrap_or(&empty_policy);
7783
Arc::new(Self {
84+
max_bytes: eviction_policy.max_bytes as u64,
7885
evicting_map: EvictingMap::new(eviction_policy, SystemTime::now()),
7986
})
8087
}
@@ -138,8 +145,51 @@ impl StoreDriver for MemoryStore {
138145
self: Pin<&Self>,
139146
key: StoreKey<'_>,
140147
mut reader: DropCloserReadHalf,
141-
_size_info: UploadSizeInfo,
148+
size_info: UploadSizeInfo,
142149
) -> Result<u64, Error> {
150+
// A write whose exact size is at least this store's `max_bytes` can never
151+
// be usefully cached: the moment it's inserted, eviction drops it, since one
152+
// entry alone meets the budget. Buffering it into memory first is therefore
153+
// pure waste — and under concurrent large writes (e.g. a `memory` fast tier
154+
// inside a `fast_slow` store fronting CAS) it is a real OOM vector, because
155+
// each in-flight write materializes its whole payload before eviction ever
156+
// runs. Drain the stream and skip instead.
157+
//
158+
// `>=` deliberately matches the eviction comparator: `EvictingMap` evicts
159+
// while `sum_store_size >= max_bytes`, so a blob of exactly `max_bytes` is
160+
// also unstorable and must be skipped rather than buffered-then-evicted.
161+
// Only `ExactSize` is trusted; a `MaxSize` upper bound could over-estimate
162+
// and wrongly skip a blob that would actually fit.
163+
//
164+
// For CAS digest keys the size is part of the key, so a given key is either
165+
// always oversized or never — there is no "small write for this key" that the
166+
// removal callbacks fired below could spuriously invalidate.
167+
if self.max_bytes != 0
168+
&& let UploadSizeInfo::ExactSize(sz) = size_info
169+
&& sz >= self.max_bytes
170+
{
171+
let drained = reader
172+
.drain()
173+
.await
174+
.err_tip(|| "Failed to drain oversized write in memory_store::update")?;
175+
warn!(
176+
?key,
177+
size = sz,
178+
max_bytes = self.max_bytes,
179+
"Write is larger than this memory store's max_bytes; skipping it \
180+
(it would be evicted immediately). If this store is a cache, large \
181+
blobs are served from the backing store — raise max_bytes to cache \
182+
them, or route large blobs around the memory tier.",
183+
);
184+
// The write never enters the map, so the insert-then-evict removal
185+
// callbacks (which a wrapping `ExistenceCacheStore` relies on to drop
186+
// a just-written-then-evicted key) don't fire on their own. Fire them
187+
// explicitly so downstream listeners don't keep a stale "exists"
188+
// entry for a blob we didn't store.
189+
let owned_key = key.into_owned();
190+
self.evicting_map.fire_remove_callbacks(&owned_key).await;
191+
return Ok(drained);
192+
}
143193
// Internally Bytes might hold a reference to more data than just our data. To prevent
144194
// this potential case, we make a full copy of our data for long-term storage.
145195
let final_buffer = {

nativelink-store/tests/memory_store_test.rs

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
use core::future::Future;
1516
use core::ops::RangeBounds;
1617
use core::pin::Pin;
18+
use core::sync::atomic::{AtomicUsize, Ordering};
19+
use std::sync::Arc;
1720

1821
use bytes::{BufMut, Bytes, BytesMut};
1922
use memory_stats::memory_stats;
@@ -24,7 +27,9 @@ use nativelink_store::memory_store::MemoryStore;
2427
use nativelink_util::buf_channel::make_buf_channel_pair;
2528
use nativelink_util::common::DigestInfo;
2629
use nativelink_util::spawn;
27-
use nativelink_util::store_trait::{StoreKey, StoreLike};
30+
use nativelink_util::store_trait::{
31+
RemoveItemCallback, Store, StoreKey, StoreLike, UploadSizeInfo,
32+
};
2833
use pretty_assertions::assert_eq;
2934
use sha2::{Digest, Sha256};
3035

@@ -82,6 +87,143 @@ async fn insert_one_item_then_update() -> Result<(), Error> {
8287
Ok(())
8388
}
8489

90+
// Streams `data` into the store via the streaming `update` path. Tests must use
91+
// this (not `update_oneshot`, which takes the bytes already in memory and hits
92+
// MemoryStore's optimized direct-insert path, bypassing the oversized-skip logic
93+
// these tests exercise).
94+
async fn update_streamed(
95+
store: &Store,
96+
digest: DigestInfo,
97+
data: &'static [u8],
98+
size_info: UploadSizeInfo,
99+
) -> Result<(), Error> {
100+
let (mut tx, rx) = make_buf_channel_pair();
101+
let send_fut = async move {
102+
tx.send(Bytes::from_static(data)).await?;
103+
tx.send_eof()
104+
};
105+
let pinned_store = Pin::new(store);
106+
let (res1, res2) = futures::join!(send_fut, pinned_store.update(digest, rx, size_info));
107+
// `merge` returns its argument's Ok value (the byte count); discard it.
108+
res1.merge(res2).map(|_| ())
109+
}
110+
111+
fn store_with_max_bytes(max_bytes: usize) -> Store {
112+
Store::new(MemoryStore::new(&MemorySpec {
113+
eviction_policy: Some(nativelink_config::stores::EvictionPolicy {
114+
max_bytes,
115+
..Default::default()
116+
}),
117+
}))
118+
}
119+
120+
// A write whose exact size is >= max_bytes is skipped (drained, never buffered)
121+
// rather than materialized-then-evicted, and — crucially — it leaves the rest of
122+
// the cache untouched (the old buffer-then-evict path would have evicted the
123+
// within-budget entry trying to make room for an unstorable blob).
124+
#[nativelink_test]
125+
async fn skips_writes_larger_than_max_bytes() -> Result<(), Error> {
126+
const SMALL: &[u8] = b"ab"; // 2 bytes, within the 4-byte budget
127+
const BIG: &[u8] = b"0123456789"; // 10 bytes, over budget
128+
129+
let store = store_with_max_bytes(4);
130+
let small_digest = DigestInfo::try_new(VALID_HASH1, SMALL.len() as u64)?;
131+
let big_digest = DigestInfo::try_new(VALID_HASH2, BIG.len() as u64)?;
132+
133+
// Within budget → stored.
134+
update_streamed(
135+
&store,
136+
small_digest,
137+
SMALL,
138+
UploadSizeInfo::ExactSize(SMALL.len() as u64),
139+
)
140+
.await?;
141+
assert_eq!(store.has(small_digest).await, Ok(Some(SMALL.len() as u64)));
142+
143+
// Over budget → skipped, not stored.
144+
update_streamed(
145+
&store,
146+
big_digest,
147+
BIG,
148+
UploadSizeInfo::ExactSize(BIG.len() as u64),
149+
)
150+
.await?;
151+
assert_eq!(
152+
store.has(big_digest).await,
153+
Ok(None),
154+
"oversized write should be skipped, not stored",
155+
);
156+
157+
// The skip must leave the within-budget entry intact.
158+
assert_eq!(
159+
store.has(small_digest).await,
160+
Ok(Some(SMALL.len() as u64)),
161+
"within-budget entry must survive an oversized write",
162+
);
163+
164+
Ok(())
165+
}
166+
167+
#[derive(Debug)]
168+
struct CountingRemoveCallback(Arc<AtomicUsize>);
169+
170+
impl RemoveItemCallback for CountingRemoveCallback {
171+
fn callback<'a>(
172+
&'a self,
173+
_store_key: StoreKey<'a>,
174+
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
175+
let count = self.0.clone();
176+
Box::pin(async move {
177+
count.fetch_add(1, Ordering::SeqCst);
178+
})
179+
}
180+
}
181+
182+
// Skipping an oversized write must still fire the remove callbacks, so a wrapping
183+
// store that tracks existence (e.g. `ExistenceCacheStore`) doesn't keep a stale
184+
// entry for a blob that was never stored.
185+
#[nativelink_test]
186+
async fn oversized_skip_fires_remove_callbacks() -> Result<(), Error> {
187+
const BIG: &[u8] = b"0123456789"; // 10 bytes, over the 4-byte budget
188+
189+
let store = store_with_max_bytes(4);
190+
let count = Arc::new(AtomicUsize::new(0));
191+
store.register_remove_callback(Arc::new(CountingRemoveCallback(count.clone())))?;
192+
193+
update_streamed(
194+
&store,
195+
DigestInfo::try_new(VALID_HASH1, BIG.len() as u64)?,
196+
BIG,
197+
UploadSizeInfo::ExactSize(BIG.len() as u64),
198+
)
199+
.await?;
200+
201+
assert_eq!(
202+
count.load(Ordering::SeqCst),
203+
1,
204+
"skipping an oversized write must fire the remove callback",
205+
);
206+
Ok(())
207+
}
208+
209+
// `MaxSize` is an upper bound, not the real size, so it must NOT trigger the skip:
210+
// a MaxSize over max_bytes whose actual content fits is still cached.
211+
#[nativelink_test]
212+
async fn max_size_over_budget_with_small_actual_is_stored() -> Result<(), Error> {
213+
const DATA: &[u8] = b"ab"; // 2 bytes, well within the 4-byte budget
214+
215+
let store = store_with_max_bytes(4);
216+
let digest = DigestInfo::try_new(VALID_HASH1, DATA.len() as u64)?;
217+
update_streamed(&store, digest, DATA, UploadSizeInfo::MaxSize(100)).await?;
218+
219+
assert_eq!(
220+
store.has(digest).await,
221+
Ok(Some(DATA.len() as u64)),
222+
"MaxSize over budget but small actual content must still be cached",
223+
);
224+
Ok(())
225+
}
226+
85227
// Regression test for: https://github.com/TraceMachina/nativelink/issues/289.
86228
#[nativelink_test]
87229
async fn ensure_full_copy_of_bytes_is_made_test() -> Result<(), Error> {

nativelink-util/src/evicting_map.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,25 @@ where
490490
while callbacks.next().await.is_some() {}
491491
}
492492

493+
/// Fires the registered remove callbacks for `key` without touching the map.
494+
/// A store uses this to invalidate downstream listeners (e.g. an
495+
/// `ExistenceCacheStore`) when a write is rejected outright and never
496+
/// inserted — mirroring the callbacks an insert-then-immediate-evict would
497+
/// otherwise have fired. It only *notifies*; it does not remove anything from
498+
/// the map (there is nothing to remove). A no-op when no callbacks are
499+
/// registered.
500+
pub async fn fire_remove_callbacks(&self, key: &Q) {
501+
let mut callbacks: FuturesUnordered<_> = {
502+
let state = self.state.lock();
503+
state
504+
.remove_callbacks
505+
.iter()
506+
.map(|callback| callback.callback(key))
507+
.collect()
508+
};
509+
while callbacks.next().await.is_some() {}
510+
}
511+
493512
/// Returns the value for `key` if present and not expired, refreshing
494513
/// its LRU/atime position. If the entry is present but TTL- or
495514
/// count-expired, it is reaped and `None` is returned.

0 commit comments

Comments
 (0)