#1378

bug: rejected overwrite on flush buffer overflow exposes stale disk value after drain

Opensomfornotopened 10d ago

Summary

With HybridCachePolicy::WriteOnEviction, an overwrite rejected by the BlockEngine flush buffer can expose the previous SSD value again. This persists after storage().wait().await has drained background work: the final HybridCache::get returns v0 even though the latest inserted value was v1.

A cache miss is acceptable when a new value cannot be cached. Returning the overwritten value is the correctness problem reported here.

Related: #1334 and its open PR #1335 address dropping entries that do not fit the flush buffer tail. This issue isolates the stale-value consequence for overwrites. #743 / merged #1270 fixed a similar problem for admission-filter rejection, but the buffer rejection occurs later in the write path.

Tested configuration and evidence boundary

  • Source base: 0fb762837b38a50681eff9c37c5f1f8b1893af8b.
  • Linux; RAM + BlockEngine hybrid cache; WriteOnEviction; RecoverMode::None.
  • 64 KiB values; one RAM shard, LRU, memory weight capacity 65,552 bytes (one value plus a 16-byte key).
  • 64 MiB filesystem device, 1 MiB blocks, Psync I/O, Direct I/O enabled and verified on the actual device file descriptors.
  • One flusher; configured buffer pool size 68 KiB; submission queue threshold 16 MiB.
  • The existing test_utils::Switch holds flushing to deterministically fill the ordinary write buffer. It does not replace serialization, Keeper, index, or rejection logic.

The recorded runs used a local checkout based on the revision above, with unrelated small-object-engine work present; the BlockEngine flusher, buffer, indexer, engine, Store and Keeper paths involved here were not modified for the reproduction. This is a focused runtime reproduction, not a claim that every concurrent update or recovery scenario has been tested.

Reproduction sequence

Use keys A=1, B=2, C=3, D=4. Drop all insertion handles so entries are evictable.

  1. Insert A=v0, then B. This evicts A. Drain storage and verify A=v0 is on disk and absent from RAM.
  2. Hold flushing using the existing test switch.
  3. Insert C, evicting B into the held buffer. Wait until B has been processed and occupies the buffer.
  4. Insert A=v1, evicting C. Verify A=v1 is in RAM. C's write cannot fit the remaining buffer space.
  5. Insert D, evicting A=v1. Its write also cannot fit and is rejected.
  6. Resume flushing and await storage().wait().
  7. Read A through both storage().load() and HybridCache::get().

Expected: A=v1 or a miss. Observed: A=v0 from disk and then from the hybrid cache. There is no pending-write timing ambiguity at the final observation.

Controlled results

Each case was repeated three times, with the same outcome in all three runs.

CaseConfigured buffer bytesBuffer-overflow deltaChannel-overflow deltaA after drainingRepeats
Baseline overflow69,63220v0 (stale)3/3
Only increase buffer capacity524,28800v13/3
Small buffer, explicitly delete old SSD index before inserting v169,63220miss3/3

The two overflows in each small-buffer run are C and A=v1. The explicit-delete case is a controlled diagnostic, not a proposed concurrency-safe application workaround.

Source path

  • Store::enqueue registers the piece in Keeper and submits it to the engine. Admission rejection already calls delete, as fixed by #1270.
  • Runner::recv calls Buffer::push. On failure it increments the overflow counter and drops the piece without invalidating the old disk index.
  • PieceRef::drop removes the current pending Keeper registration.
  • Store::load then falls through from Keeper to the engine, where the previous disk address remains readable.

Suggested correctness requirement

Rejected overwrites must leave the old value invalid, independently of whether the new value reaches SSD. Retrying after buffer rotation, as proposed in #1335, addresses temporary buffer pressure; paths that ultimately discard an overwrite still need safe old-version invalidation.

Any fix should account for older in-flight writes/reinsertions finishing late, newer writes succeeding before an older failure is handled, and pending Keeper values. A blind delete(key) on delayed failure could invalidate a newer version; sequence-aware invalidation and safe tombstone retirement need consideration.

Suggested regression coverage: this deterministic overflow case, channel rejection, an older write completing after invalidation, and an older failure arriving after a newer successful write. Only the buffer-overflow case and the controls above have been reproduced here; the other cases are proposed coverage.

Complete reproduction harness

The source below is the harness used for the recorded runs. It intentionally asserts the observed buggy behavior in the baseline case and prints each outcome; exit status 0 therefore means the reproduction matched the observations, not that cache correctness passed.

The manifest below replaces the original local path dependencies with the pinned upstream revision for portability. That dependency-source change has not been rerun separately. Use a Rust toolchain supported by that revision and a fresh absolute directory on a filesystem that supports Direct I/O. The harness creates nine 64 MiB cache directories (three cases times three repetitions).

Run the standalone project with cargo run --release -- /absolute/path/to/fresh-repro-data.

Cargo.toml
[package]
name = "hybrid-foyer-stale-repro"
version = "0.1.0"
edition = "2024"

[dependencies]
foyer-storage = { git = "https://github.com/foyer-rs/foyer", rev = "0fb762837b38a50681eff9c37c5f1f8b1893af8b", features = ["test_utils"] }
foyer = { git = "https://github.com/foyer-rs/foyer", rev = "0fb762837b38a50681eff9c37c5f1f8b1893af8b", features = ["test_utils"] }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
prometheus = "0.14"
mixtrics = { version = "0.2", features = ["prometheus"] }
libc = "0.2"

[profile.release]
debug = 1
src/main.rs
use foyer::*;
use foyer_storage::test_utils::Switch;
use mixtrics::registry::prometheus::PrometheusMetricsRegistry;
use serde_json::{json,Value};
use std::time::{Duration,Instant};
type C=HybridCache<u128,Vec<u8>>;
const SIZE:usize=64*1024;
fn value(key:u64,version:u64)->Vec<u8>{let mut v=vec![7;SIZE];v[..8].copy_from_slice(&key.to_le_bytes());v[8..16].copy_from_slice(&version.to_le_bytes());v}
fn counts(r:&prometheus::Registry)->(u64,u64,u64){
 let(mut ser,mut buf,mut chan)=(0,0,0);
 for f in r.gather(){for m in f.get_metric(){
  if f.name().contains("entry_serde_duration") && m.get_label().iter().any(|l|l.value()=="serialize"){ser+=m.get_histogram().get_sample_count();}
  if f.name()=="foyer_storage_inner_op_total"{for l in m.get_label(){match l.value(){"buffer_overflow"=>buf+=m.get_counter().value() as u64,"channel_overflow"=>chan+=m.get_counter().value() as u64,_=>{}}}}
 }}(ser,buf,chan)
}
async fn processed(r:&prometheus::Registry,n:u64){let start=Instant::now();loop{let(a,b,c)=counts(r);if a+b+c>=n{return}assert!(start.elapsed()<Duration::from_secs(5),"receiver did not reach {n}: {:?}",counts(r));tokio::time::sleep(Duration::from_millis(1)).await;}}
async fn disk(c:&C)->Value {match c.storage().load(&1u128).await.unwrap(){Load::Entry{value,..}=>json!({"source":"disk","key":u64::from_le_bytes(value[..8].try_into().unwrap()),"version":u64::from_le_bytes(value[8..16].try_into().unwrap())}),Load::Piece{piece,..}=>json!({"source":"keeper","version":u64::from_le_bytes(piece.value()[8..16].try_into().unwrap())}),Load::Miss=>json!({"source":"miss"}),Load::Throttled=>panic!("unexpected throttling")}}
async fn case(root:&str,name:&str,buffer:usize,invalidate:bool){
 let dir=format!("{root}/{name}");std::fs::create_dir_all(&dir).unwrap();let registry=prometheus::Registry::new();let sw=Switch::default();
 let device=FsDeviceBuilder::new(&dir).with_capacity(64<<20).with_direct(true).build().unwrap();
 let c:C=HybridCacheBuilder::new().with_name(name.to_owned()).with_policy(HybridCachePolicy::WriteOnEviction)
  .with_metrics_registry(Box::new(PrometheusMetricsRegistry::new(registry.clone())))
  .memory(SIZE+16).with_shards(1).with_eviction_config(LruConfig{high_priority_pool_ratio:0.0}).with_weighter(|_:&u128,v:&Vec<u8>|v.len()+16)
  .storage().with_io_engine_config(PsyncIoEngineConfig::new())
  .with_engine_config(BlockEngineConfig::new(device).with_block_size(1<<20).with_buffer_pool_size(buffer).with_flushers(1).with_reclaimers(1).with_clean_block_threshold(1).with_submit_queue_size_threshold(16<<20).with_flush_switch(sw.clone()).with_eviction_pickers(vec![Box::new(FifoPicker::new(0.0))]))
  .with_recover_mode(RecoverMode::None).build().await.unwrap();
 let mut fds=0;
 for e in std::fs::read_dir("/proc/self/fd").unwrap().flatten(){if let Ok(p)=std::fs::read_link(e.path()){if p.to_string_lossy().starts_with(&dir){let fd=e.file_name().to_string_lossy().parse::<i32>().unwrap();assert_ne!(unsafe{libc::fcntl(fd,libc::F_GETFL)}&libc::O_DIRECT,0);fds+=1;}}}assert!(fds>0);
 drop(c.insert(1,value(1,0)));drop(c.insert(2,value(2,0)));c.storage().wait().await;
 let initial=disk(&c).await;assert_eq!(initial["version"],0);assert!(c.memory().get(&1u128).is_none());
 let before=counts(&registry);let start=before.0+before.1+before.2;
 sw.on();
 drop(c.insert(3,value(3,0)));processed(&registry,start+1).await; // Evict B and fill the held buffer.
 if invalidate{c.storage().delete(&1u128);}
 drop(c.insert(1,value(1,1)));processed(&registry,start+2).await; // Evict C; A's RAM value is now v1.
 assert_eq!(u64::from_le_bytes(c.memory().get(&1u128).unwrap().value()[8..16].try_into().unwrap()),1);
 drop(c.insert(4,value(4,0)));processed(&registry,start+3).await; // Evict A v1 into a full buffer.
 assert!(c.memory().get(&1u128).is_none());
 // Resume and drain all background work before observing: no pending-write timing ambiguity.
 sw.off();c.storage().wait().await;
 let stored=disk(&c).await;
 let observed=c.get(&1u128).await.unwrap().map(|e|u64::from_le_bytes(e.value()[8..16].try_into().unwrap()));
 let after=counts(&registry);
 if buffer<2*(SIZE+4096){assert_eq!(after.1-before.1,2);assert_eq!(observed,if invalidate{None}else{Some(0)});}else{assert_eq!(after.1-before.1,0);assert_eq!(observed,Some(1));}
 println!("{}",json!({"case":name,"direct_fds_verified":fds,"object_bytes":SIZE,"memory_weight_capacity":SIZE+16,"buffer_bytes":buffer,"initial":initial,"latest_inserted_version":1,"after_drain_storage":stored,"hybrid_get_version":observed,"buffer_overflow_delta":after.1-before.1,"channel_overflow_delta":after.2-before.2,"old_index_explicitly_invalidated":invalidate,"all_assertions_passed":true}));
 c.close().await.unwrap();
}
fn main(){let root=std::env::args().nth(1).unwrap();let rt=tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();rt.block_on(async{
 for repeat in 0..3 {
  case(&root,&format!("overflow-{repeat}"),SIZE+4096,false).await;
  case(&root,&format!("no-overflow-{repeat}"),512<<10,false).await;
  case(&root,&format!("invalidate-old-{repeat}"),SIZE+4096,true).await;
 }
});}

Activity

0

No activity yet.