Close stream setup races with SymbolGuard ownership model

stream_*_windowed loaded the first file and spawned a prefetch before
retaining the symbol, leaving loaded files at refcount 0 in a window
where a concurrent release could evict them (redundant reload), and a
prefetch thread could insert after an evict, leaving a refcount-0
orphan (leak).

Introduce a SymbolGuard RAII type: creating it retains the symbol,
dropping it releases. ensure_*_loaded now takes &SymbolGuard, making
"retain before load" a compile-time precondition that closes the
reload race structurally. prefetch_* clones a guard synchronously
before spawning and holds it across the insert, so the refcount
cannot reach zero between load and insert, closing the orphan leak.

Add an always-on load counter (DataServer::files_loaded) and a
deterministic regression test asserting each file is read from disk
exactly once across prefetch and a second consumer, locking the
no-redundant-I/O performance contract the fix must not regress.

closes #1
This commit is contained in:
2026-06-04 17:44:37 +02:00
parent 20c2ff1d47
commit a31e26d3c1
2 changed files with 226 additions and 36 deletions
+101
View File
@@ -7,6 +7,7 @@
use super::records::{DataFormat, M1Parsed, TickParsed}; use super::records::{DataFormat, M1Parsed, TickParsed};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, RwLock}; use std::sync::{Arc, Condvar, Mutex, RwLock};
/// Per-symbol reference count for cache eviction. /// Per-symbol reference count for cache eviction.
@@ -46,6 +47,11 @@ pub struct FileCache {
/// Per-symbol reference counts. When a count drops to zero, all /// Per-symbol reference counts. When a count drops to zero, all
/// cached files for that symbol are evicted from both maps. /// cached files for that symbol are evicted from both maps.
symbol_refs: SymbolRefCounts, symbol_refs: SymbolRefCounts,
/// Counts real disk-load attempts, incremented once per loader
/// invocation. Observability for the no-redundant-I/O contract:
/// each file should be loaded exactly once even under prefetch and
/// concurrent consumers.
loads: AtomicUsize,
} }
impl Default for FileCache { impl Default for FileCache {
@@ -62,9 +68,23 @@ impl FileCache {
loading: Mutex::new(HashSet::new()), loading: Mutex::new(HashSet::new()),
loaded: Condvar::new(), loaded: Condvar::new(),
symbol_refs: Mutex::new(HashMap::new()), symbol_refs: Mutex::new(HashMap::new()),
loads: AtomicUsize::new(0),
} }
} }
// -- Load counter (observability) ---------------------------------------
/// Records a single real disk-load attempt. Called once per loader
/// invocation (success or failure), before the load runs.
pub(crate) fn record_load(&self) {
self.loads.fetch_add(1, Ordering::Relaxed);
}
/// Returns the number of disk-load attempts made so far.
pub(crate) fn load_count(&self) -> usize {
self.loads.load(Ordering::Relaxed)
}
// -- M1 ----------------------------------------------------------------- // -- M1 -----------------------------------------------------------------
/// Returns cached M1 chunks for the given key, or `None` if not yet loaded. /// Returns cached M1 chunks for the given key, or `None` if not yet loaded.
@@ -181,6 +201,50 @@ impl FileCache {
} }
} }
/// RAII reference to a symbol's cached data.
///
/// Creating a guard retains the symbol; dropping it releases the symbol.
/// Cloning a guard takes an additional reference, so the cached data stays
/// alive until the *last* clone drops. This makes "retain before load" a
/// compile-time precondition and lets a background prefetch hold its own
/// reference across a cache insert.
///
/// Each live guard corresponds to exactly one symbol reference count: `new`
/// retains, `clone` retains again (an additional reference), and `Drop`
/// releases. Holding a guard is the compile-time precondition for loading a
/// symbol's files (Race 1: no load without a reference) and lets a background
/// prefetch keep a reference alive across its `insert_*` (Race 2: no
/// refcount-0 orphan).
pub(crate) struct SymbolGuard {
cache: Arc<FileCache>,
symbol: Arc<str>,
}
impl SymbolGuard {
/// Creates a guard, retaining the symbol for the lifetime of the guard.
pub fn new(cache: Arc<FileCache>, symbol: Arc<str>) -> Self {
cache.retain_symbol(&symbol);
Self { cache, symbol }
}
}
impl Clone for SymbolGuard {
fn clone(&self) -> Self {
// Take an additional reference so each live guard == one refcount.
self.cache.retain_symbol(&self.symbol);
Self {
cache: Arc::clone(&self.cache),
symbol: Arc::clone(&self.symbol),
}
}
}
impl Drop for SymbolGuard {
fn drop(&mut self) {
self.cache.release_symbol(&self.symbol);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -315,4 +379,41 @@ mod tests {
// GBPUSD is untouched // GBPUSD is untouched
assert!(cache.is_cached(&other)); assert!(cache.is_cached(&other));
} }
/// Property (Race-2 invariant): a reference held across a cache insert
/// prevents a refcount-0 orphan. A second `SymbolGuard` reference — what
/// a prefetch holds — kept alive across an `insert_*` keeps the inserted
/// data alive past the original holder's release, and the data is evicted
/// only once the LAST reference drops.
#[test]
fn test_guard_held_across_insert_prevents_orphan() {
let cache = Arc::new(FileCache::new());
let sym: Arc<str> = Arc::from("EURUSD");
let key = test_key("EURUSD", 2017, 1, DataFormat::M1);
// An original consumer holds the symbol.
let original = SymbolGuard::new(Arc::clone(&cache), Arc::clone(&sym));
// A prefetch clones a second reference, then inserts data while still
// holding it — the insert is covered by the prefetch's own reference.
let prefetch = original.clone();
cache.insert_m1(key.clone(), vec![]);
assert!(cache.is_cached(&key));
// The original consumer releases. The prefetch reference still covers
// the entry, so the data must NOT be evicted into a refcount-0 orphan.
drop(original);
assert!(
cache.is_cached(&key),
"data must survive while the prefetch reference is still held"
);
// Only when the LAST reference (the prefetch's) drops is the symbol
// evicted.
drop(prefetch);
assert!(
!cache.is_cached(&key),
"data must be evicted once the last reference drops"
);
}
} }
+125 -36
View File
@@ -22,7 +22,7 @@ pub mod cache;
pub mod loader; pub mod loader;
pub mod records; pub mod records;
use cache::{ChunkVec, FileCache, FileKey}; use cache::{ChunkVec, FileCache, FileKey, SymbolGuard};
use records::{DataFormat, HasTimestamp, M1Parsed, TickParsed}; use records::{DataFormat, HasTimestamp, M1Parsed, TickParsed};
use regex::Regex; use regex::Regex;
@@ -147,7 +147,7 @@ impl SymbolIndex {
/// then served from the in-memory cache. /// then served from the in-memory cache.
pub struct DataServer { pub struct DataServer {
index: SymbolIndex, index: SymbolIndex,
cache: FileCache, cache: Arc<FileCache>,
} }
impl std::fmt::Debug for DataServer { impl std::fmt::Debug for DataServer {
@@ -166,7 +166,7 @@ impl DataServer {
pub fn new(base_path: impl AsRef<Path>) -> Self { pub fn new(base_path: impl AsRef<Path>) -> Self {
Self { Self {
index: SymbolIndex::scan(base_path.as_ref()), index: SymbolIndex::scan(base_path.as_ref()),
cache: FileCache::new(), cache: Arc::new(FileCache::new()),
} }
} }
@@ -175,6 +175,15 @@ impl DataServer {
self.index.symbols.contains_key(symbol) self.index.symbols.contains_key(symbol)
} }
/// Returns the number of real disk-load attempts made so far.
///
/// Observability for the no-redundant-I/O contract: each data file
/// should be read from disk and parsed exactly once, then shared via
/// the cache, even under prefetch and concurrent consumers.
pub fn files_loaded(&self) -> usize {
self.cache.load_count()
}
/// Returns a sorted list of all known symbol names. /// Returns a sorted list of all known symbol names.
pub fn symbols(&self) -> Vec<Arc<str>> { pub fn symbols(&self) -> Vec<Arc<str>> {
let mut syms: Vec<_> = self.index.symbols.keys().cloned().collect(); let mut syms: Vec<_> = self.index.symbols.keys().cloned().collect();
@@ -211,17 +220,19 @@ impl DataServer {
let symbol_arc = self.index.symbols.get_key_value(symbol)?.0.clone(); let symbol_arc = self.index.symbols.get_key_value(symbol)?.0.clone();
let files = self.filter_files(symbol, DataFormat::M1, from_ms, to_ms)?; let files = self.filter_files(symbol, DataFormat::M1, from_ms, to_ms)?;
// Eagerly load the first file and prefetch the second // Retain before any load: the guard makes "reference held" a
self.ensure_m1_loaded(&files[0]); // compile-time precondition for loading (Race 1 closed).
if files.len() > 1 { let guard = SymbolGuard::new(Arc::clone(&self.cache), symbol_arc);
self.prefetch_m1(&files[1]);
}
self.cache.retain_symbol(&symbol_arc); // Eagerly load the first file and prefetch the second
self.ensure_m1_loaded(&files[0], &guard);
if files.len() > 1 {
self.prefetch_m1(&files[1], &guard);
}
Some(SymbolChunkIter { Some(SymbolChunkIter {
server: Arc::clone(self), server: Arc::clone(self),
symbol: symbol_arc, guard,
files, files,
file_idx: 0, file_idx: 0,
chunk_idx: 0, chunk_idx: 0,
@@ -247,16 +258,17 @@ impl DataServer {
let symbol_arc = self.index.symbols.get_key_value(symbol)?.0.clone(); let symbol_arc = self.index.symbols.get_key_value(symbol)?.0.clone();
let files = self.filter_files(symbol, DataFormat::Tick, from_ms, to_ms)?; let files = self.filter_files(symbol, DataFormat::Tick, from_ms, to_ms)?;
self.ensure_tick_loaded(&files[0]); // Retain before any load (Race 1 closed): see `stream_m1_windowed`.
if files.len() > 1 { let guard = SymbolGuard::new(Arc::clone(&self.cache), symbol_arc);
self.prefetch_tick(&files[1]);
}
self.cache.retain_symbol(&symbol_arc); self.ensure_tick_loaded(&files[0], &guard);
if files.len() > 1 {
self.prefetch_tick(&files[1], &guard);
}
Some(SymbolChunkIter { Some(SymbolChunkIter {
server: Arc::clone(self), server: Arc::clone(self),
symbol: symbol_arc, guard,
files, files,
file_idx: 0, file_idx: 0,
chunk_idx: 0, chunk_idx: 0,
@@ -307,13 +319,30 @@ impl DataServer {
if files.is_empty() { None } else { Some(files) } if files.is_empty() { None } else { Some(files) }
} }
// -- Load helpers --------------------------------------------------------
/// Records a disk-load attempt and runs the M1 loader. The single point
/// where M1 files are read from disk, so the load counter stays in sync
/// with actual I/O.
fn load_m1_counted(&self, path: &Path) -> std::io::Result<Vec<Arc<[M1Parsed]>>> {
self.cache.record_load();
loader::load_m1_file(path)
}
/// Records a disk-load attempt and runs the tick loader. See
/// [`Self::load_m1_counted`].
fn load_tick_counted(&self, path: &Path) -> std::io::Result<Vec<Arc<[TickParsed]>>> {
self.cache.record_load();
loader::load_tick_file(path)
}
// -- M1 loading ---------------------------------------------------------- // -- M1 loading ----------------------------------------------------------
/// Ensures the given M1 file is loaded into the cache (blocking). /// Ensures the given M1 file is loaded into the cache (blocking).
/// ///
/// If another thread is already loading this file, blocks on a `Condvar` /// If another thread is already loading this file, blocks on a `Condvar`
/// until the load completes. If no one is loading, loads synchronously. /// until the load completes. If no one is loading, loads synchronously.
fn ensure_m1_loaded(&self, key: &FileKey) { fn ensure_m1_loaded(&self, key: &FileKey, _guard: &SymbolGuard) {
if self.cache.is_cached(key) { if self.cache.is_cached(key) {
return; return;
} }
@@ -324,7 +353,7 @@ impl DataServer {
} }
// We own the load — do it now. // We own the load — do it now.
let path = self.index.file_path(key); let path = self.index.file_path(key);
match loader::load_m1_file(&path) { match self.load_m1_counted(&path) {
Ok(chunks) => self.cache.insert_m1(key.clone(), chunks), Ok(chunks) => self.cache.insert_m1(key.clone(), chunks),
Err(e) => { Err(e) => {
eprintln!("Warning: failed to load {}: {}", path.display(), e); eprintln!("Warning: failed to load {}: {}", path.display(), e);
@@ -337,28 +366,33 @@ impl DataServer {
/// Prefetches an M1 file in a background thread. /// Prefetches an M1 file in a background thread.
/// ///
/// Requires `Arc<Self>` to safely share the server with the spawned thread. /// Requires `Arc<Self>` to safely share the server with the spawned thread.
fn prefetch_m1(self: &Arc<Self>, key: &FileKey) { fn prefetch_m1(self: &Arc<Self>, key: &FileKey, guard: &SymbolGuard) {
if self.cache.is_cached(key) || !self.cache.try_claim_loading(key) { if self.cache.is_cached(key) || !self.cache.try_claim_loading(key) {
return; return;
} }
let path = self.index.file_path(key); let path = self.index.file_path(key);
let cache_key = key.clone(); let cache_key = key.clone();
let server = Arc::clone(self); let server = Arc::clone(self);
// Hold a guard reference across the insert so the symbol cannot drop
// to refcount 0 between the load and the insert (Race 2 closed). `g`
// drops at the end of the closure, AFTER the insert completes.
let g = guard.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
match loader::load_m1_file(&path) { match server.load_m1_counted(&path) {
Ok(chunks) => server.cache.insert_m1(cache_key, chunks), Ok(chunks) => server.cache.insert_m1(cache_key, chunks),
Err(e) => { Err(e) => {
eprintln!("Warning: prefetch failed for {}: {}", path.display(), e); eprintln!("Warning: prefetch failed for {}: {}", path.display(), e);
server.cache.insert_m1(cache_key, vec![]); server.cache.insert_m1(cache_key, vec![]);
} }
} }
drop(g);
}); });
} }
// -- Tick loading -------------------------------------------------------- // -- Tick loading --------------------------------------------------------
/// Ensures the given tick file is loaded into the cache (blocking). /// Ensures the given tick file is loaded into the cache (blocking).
fn ensure_tick_loaded(&self, key: &FileKey) { fn ensure_tick_loaded(&self, key: &FileKey, _guard: &SymbolGuard) {
if self.cache.is_cached(key) { if self.cache.is_cached(key) {
return; return;
} }
@@ -367,7 +401,7 @@ impl DataServer {
return; return;
} }
let path = self.index.file_path(key); let path = self.index.file_path(key);
match loader::load_tick_file(&path) { match self.load_tick_counted(&path) {
Ok(chunks) => self.cache.insert_tick(key.clone(), chunks), Ok(chunks) => self.cache.insert_tick(key.clone(), chunks),
Err(e) => { Err(e) => {
eprintln!("Warning: failed to load {}: {}", path.display(), e); eprintln!("Warning: failed to load {}: {}", path.display(), e);
@@ -377,21 +411,25 @@ impl DataServer {
} }
/// Prefetches a tick file in a background thread. /// Prefetches a tick file in a background thread.
fn prefetch_tick(self: &Arc<Self>, key: &FileKey) { fn prefetch_tick(self: &Arc<Self>, key: &FileKey, guard: &SymbolGuard) {
if self.cache.is_cached(key) || !self.cache.try_claim_loading(key) { if self.cache.is_cached(key) || !self.cache.try_claim_loading(key) {
return; return;
} }
let path = self.index.file_path(key); let path = self.index.file_path(key);
let cache_key = key.clone(); let cache_key = key.clone();
let server = Arc::clone(self); let server = Arc::clone(self);
// Hold a guard reference across the insert (Race 2 closed): see
// `prefetch_m1`. `g` drops at the end of the closure, after the insert.
let g = guard.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
match loader::load_tick_file(&path) { match server.load_tick_counted(&path) {
Ok(chunks) => server.cache.insert_tick(cache_key, chunks), Ok(chunks) => server.cache.insert_tick(cache_key, chunks),
Err(e) => { Err(e) => {
eprintln!("Warning: prefetch failed for {}: {}", path.display(), e); eprintln!("Warning: prefetch failed for {}: {}", path.display(), e);
server.cache.insert_tick(cache_key, vec![]); server.cache.insert_tick(cache_key, vec![]);
} }
} }
drop(g);
}); });
} }
} }
@@ -406,8 +444,10 @@ impl DataServer {
/// `LoadDataFile(nextFileInfo)` prefetch call. /// `LoadDataFile(nextFileInfo)` prefetch call.
pub struct SymbolChunkIter<T> { pub struct SymbolChunkIter<T> {
server: Arc<DataServer>, server: Arc<DataServer>,
/// Symbol name, held for `Drop`-based cache release. /// RAII guard holding the symbol reference; its `Drop` releases the
symbol: Arc<str>, /// symbol from the cache. Also the compile-time witness passed into
/// `ensure_*_loaded` / `prefetch_*` so loads only happen while held.
guard: SymbolGuard,
files: Vec<FileKey>, files: Vec<FileKey>,
file_idx: usize, file_idx: usize,
chunk_idx: usize, chunk_idx: usize,
@@ -485,12 +525,6 @@ fn filter_chunk<T: HasTimestamp + Clone>(
if filtered.is_empty() { None } else { Some(Arc::from(filtered)) } if filtered.is_empty() { None } else { Some(Arc::from(filtered)) }
} }
impl<T> Drop for SymbolChunkIter<T> {
fn drop(&mut self) {
self.server.cache.release_symbol(&self.symbol);
}
}
impl SymbolChunkIter<M1Parsed> { impl SymbolChunkIter<M1Parsed> {
/// Returns the next chunk of M1 records, or `None` when exhausted. /// Returns the next chunk of M1 records, or `None` when exhausted.
/// ///
@@ -505,7 +539,7 @@ impl SymbolChunkIter<M1Parsed> {
// Try to get chunks for the current file // Try to get chunks for the current file
if self.current_file_chunks.is_none() && self.file_idx < self.files.len() { if self.current_file_chunks.is_none() && self.file_idx < self.files.len() {
let key = &self.files[self.file_idx]; let key = &self.files[self.file_idx];
self.server.ensure_m1_loaded(key); self.server.ensure_m1_loaded(key, &self.guard);
self.current_file_chunks = self.server.cache.get_m1(key); self.current_file_chunks = self.server.cache.get_m1(key);
self.chunk_idx = 0; self.chunk_idx = 0;
} }
@@ -534,7 +568,7 @@ impl SymbolChunkIter<M1Parsed> {
// Prefetch the file AFTER the next one (N+2), since N+1 was // Prefetch the file AFTER the next one (N+2), since N+1 was
// already prefetched when we started N. // already prefetched when we started N.
if self.file_idx + 1 < self.files.len() { if self.file_idx + 1 < self.files.len() {
self.server.prefetch_m1(&self.files[self.file_idx + 1]); self.server.prefetch_m1(&self.files[self.file_idx + 1], &self.guard);
} }
} }
} }
@@ -550,7 +584,7 @@ impl SymbolChunkIter<TickParsed> {
if self.current_file_chunks.is_none() && self.file_idx < self.files.len() { if self.current_file_chunks.is_none() && self.file_idx < self.files.len() {
let key = &self.files[self.file_idx]; let key = &self.files[self.file_idx];
self.server.ensure_tick_loaded(key); self.server.ensure_tick_loaded(key, &self.guard);
self.current_file_chunks = self.server.cache.get_tick(key); self.current_file_chunks = self.server.cache.get_tick(key);
self.chunk_idx = 0; self.chunk_idx = 0;
} }
@@ -576,7 +610,7 @@ impl SymbolChunkIter<TickParsed> {
} }
if self.file_idx + 1 < self.files.len() { if self.file_idx + 1 < self.files.len() {
self.server.prefetch_tick(&self.files[self.file_idx + 1]); self.server.prefetch_tick(&self.files[self.file_idx + 1], &self.guard);
} }
} }
} }
@@ -585,6 +619,61 @@ impl SymbolChunkIter<TickParsed> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use records::RawM1Record;
use std::io::Write;
/// Writes a single-record M1 zip to `dir/SYMBOL_YYYY_MM.m1` so the
/// directory scan picks it up as a real data file.
fn write_m1_file(dir: &Path, symbol: &str, year: u16, month: u8) {
let path = dir.join(format!("{symbol}_{year:04}_{month:02}.m1"));
let file = std::fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file::<String, ()>("TEST.bin".into(), Default::default())
.unwrap();
let rec = RawM1Record {
time: 42795.0,
open: 1.05,
high: 1.06,
low: 1.04,
close: 1.055,
spread: 0.5,
volume: 100,
};
let bytes: &[u8] =
unsafe { std::slice::from_raw_parts(&rec as *const RawM1Record as *const u8, 48) };
zip.write_all(bytes).unwrap();
zip.finish().unwrap();
}
/// Regression guard for the no-redundant-I/O contract: each data file is
/// read from disk exactly once and shared via the cache — no redundant
/// I/O — across prefetch and a second concurrent consumer of the same
/// symbol. Locks the current performance baseline ahead of the redesign.
#[test]
fn test_no_redundant_io_across_prefetch_and_second_consumer() {
let dir = tempfile::tempdir().unwrap();
write_m1_file(dir.path(), "EURUSD", 2017, 1);
write_m1_file(dir.path(), "EURUSD", 2017, 2);
write_m1_file(dir.path(), "EURUSD", 2017, 3);
let server = Arc::new(DataServer::new(dir.path()));
// First consumer: drain fully, then keep alive so nothing is evicted.
let mut iter1 = server.stream_m1("EURUSD").unwrap();
while let Some(_c) = iter1.next_chunk() {}
// Each of the 3 files loaded exactly once, despite prefetch.
assert_eq!(server.files_loaded(), 3);
// Second consumer while iter1 is still alive: cache sharing means no
// reload — the load count must not move.
let mut iter2 = server.stream_m1("EURUSD").unwrap();
while let Some(_c) = iter2.next_chunk() {}
assert_eq!(server.files_loaded(), 3);
drop(iter1);
drop(iter2);
}
#[test] #[test]
fn test_symbol_index_scan() { fn test_symbol_index_scan() {