1use crate::config::SamplerConfig;
2use crate::constants::splits::is_reserved_source_id;
3use crate::data::DataRecord;
4use crate::errors::SamplerError;
5use crate::hash::derive_epoch_seed;
6use crate::source::{DataSource, SourceCursor, SourceSnapshot};
7use crate::types::{RecordId, SourceId};
8use chrono::Utc;
9use indexmap::IndexMap;
10use std::collections::HashMap;
11use std::collections::VecDeque;
12use std::sync::{Arc, Condvar, Mutex, RwLock};
13use std::thread;
14use std::time::Duration;
15use tracing::{debug, warn};
16
17#[derive(Clone)]
19pub struct RecordCache {
20 inner: Arc<RwLock<RecordCacheInner>>,
21 notifier: Arc<(Mutex<CacheStats>, Condvar)>,
22}
23
24struct RecordCacheInner {
26 records: IndexMap<RecordId, CachedRecord>,
27 order: VecDeque<RecordId>,
28 max_records: usize,
29 next_version: u64,
30 eviction_log: VecDeque<(u64, RecordId)>,
32}
33
34struct CachedRecord {
36 record: Arc<DataRecord>,
37 version: u64,
38}
39
40#[derive(Default)]
42struct CacheStats {
43 ingests: u64,
44}
45
46impl RecordCache {
47 pub fn new(max_records: usize) -> Self {
49 Self {
50 inner: Arc::new(RwLock::new(RecordCacheInner {
51 records: IndexMap::new(),
52 order: VecDeque::new(),
53 max_records,
54 next_version: 0,
55 eviction_log: VecDeque::new(),
56 })),
57 notifier: Arc::new((Mutex::new(CacheStats::default()), Condvar::new())),
58 }
59 }
60
61 pub fn ingest<I>(&self, records: I)
63 where
64 I: IntoIterator<Item = DataRecord>,
65 {
66 let mut batch: Vec<DataRecord> = records.into_iter().collect();
67 if batch.is_empty() {
68 return;
69 }
70 let mut inner = self.inner.write().expect("record cache poisoned");
71 inner.ingest_batch(&mut batch);
72 drop(inner);
73 let (lock, cvar) = &*self.notifier;
74 let mut stats = lock.lock().expect("record cache stats poisoned");
75 stats.ingests = stats.ingests.saturating_add(1);
76 cvar.notify_all();
77 }
78
79 pub fn ingest_with_version<I>(&self, records: I, version: u64)
81 where
82 I: IntoIterator<Item = DataRecord>,
83 {
84 let mut batch: Vec<DataRecord> = records.into_iter().collect();
85 if batch.is_empty() {
86 return;
87 }
88 let mut inner = self.inner.write().expect("record cache poisoned");
89 inner.ingest_batch_with_version(&mut batch, version);
90 drop(inner);
91 let (lock, cvar) = &*self.notifier;
92 let mut stats = lock.lock().expect("record cache stats poisoned");
93 stats.ingests = stats.ingests.saturating_add(1);
94 cvar.notify_all();
95 }
96
97 pub fn clear(&self) {
99 let mut inner = self.inner.write().expect("record cache poisoned");
100 if !inner.records.is_empty() {
109 inner.next_version = inner.next_version.saturating_add(1);
110 let version = inner.next_version;
111 let evicted: Vec<RecordId> = inner.records.keys().cloned().collect();
112 for id in evicted {
113 inner.eviction_log.push_back((version, id));
114 }
115 while inner.eviction_log.len() > inner.max_records {
116 inner.eviction_log.pop_front();
117 }
118 }
119 inner.records.clear();
120 inner.order.clear();
121 }
122
123 pub fn snapshot(&self) -> Vec<Arc<DataRecord>> {
125 let inner = self.inner.read().expect("record cache poisoned");
126 inner
127 .records
128 .values()
129 .map(|entry| Arc::clone(&entry.record))
130 .collect()
131 }
132
133 pub fn ingest_count(&self) -> u64 {
135 let (lock, _) = &*self.notifier;
136 lock.lock().expect("record cache stats poisoned").ingests
137 }
138
139 pub fn wait_for_ingest(&self, last_seen: u64, timeout: Duration) -> u64 {
141 let (lock, cvar) = &*self.notifier;
142 let mut stats = lock.lock().expect("record cache stats poisoned");
143 while stats.ingests <= last_seen {
144 let result = cvar
145 .wait_timeout(stats, timeout)
146 .expect("record cache stats poisoned");
147 stats = result.0;
148 if result.1.timed_out() {
149 break;
150 }
151 }
152 stats.ingests
153 }
154
155 pub fn wait_for_ingest_blocking(&self, last_seen: u64) -> u64 {
157 let (lock, cvar) = &*self.notifier;
158 let mut stats = lock.lock().expect("record cache stats poisoned");
159 while stats.ingests <= last_seen {
160 stats = cvar.wait(stats).expect("record cache stats poisoned");
161 }
162 stats.ingests
163 }
164
165 pub fn is_empty(&self) -> bool {
167 let inner = self.inner.read().expect("record cache poisoned");
168 inner.records.is_empty()
169 }
170
171 pub fn len(&self) -> usize {
173 let inner = self.inner.read().expect("record cache poisoned");
174 inner.records.len()
175 }
176
177 pub fn sync_delta(&self, last_version: u64) -> (u64, Vec<Arc<DataRecord>>, Vec<RecordId>) {
179 let mut inner = self.inner.write().expect("record cache poisoned");
180 inner.sync_delta(last_version)
181 }
182}
183
184impl RecordCacheInner {
185 fn ingest_batch(&mut self, records: &mut Vec<DataRecord>) {
186 for record in records.drain(..) {
187 self.next_version = self.next_version.saturating_add(1);
188 let record_id = record.id.clone();
189 if self.records.contains_key(&record_id) {
190 if let Some(entry) = self.records.get_mut(&record_id) {
191 entry.record = Arc::new(record);
192 entry.version = self.next_version;
193 }
194 Self::refresh_order(&mut self.order, &record_id);
195 self.order.push_back(record_id);
196 } else {
197 self.order.push_back(record_id.clone());
198 self.records.insert(
199 record_id,
200 CachedRecord {
201 record: Arc::new(record),
202 version: self.next_version,
203 },
204 );
205 }
206 self.enforce_limit();
207 }
208 }
209
210 fn ingest_batch_with_version(&mut self, records: &mut Vec<DataRecord>, version: u64) {
211 for record in records.drain(..) {
212 self.next_version = version;
213 let record_id = record.id.clone();
214 if self.records.contains_key(&record_id) {
215 if let Some(entry) = self.records.get_mut(&record_id) {
216 entry.record = Arc::new(record);
217 entry.version = version;
218 }
219 Self::refresh_order(&mut self.order, &record_id);
220 self.order.push_back(record_id);
221 } else {
222 self.order.push_back(record_id.clone());
223 self.records.insert(
224 record_id,
225 CachedRecord {
226 record: Arc::new(record),
227 version,
228 },
229 );
230 }
231 self.enforce_limit();
232 }
233 }
234
235 fn enforce_limit(&mut self) {
236 if self.max_records == 0 {
237 self.records.clear();
238 self.order.clear();
239 return;
240 }
241 while self.records.len() > self.max_records {
242 if let Some(oldest) = self.order.pop_front() {
243 self.records.swap_remove(&oldest);
244 self.eviction_log.push_back((self.next_version, oldest));
246 while self.eviction_log.len() > self.max_records {
248 self.eviction_log.pop_front();
249 }
250 } else {
251 break;
252 }
253 }
254 }
255
256 fn refresh_order(order: &mut VecDeque<RecordId>, id: &RecordId) {
257 if order.is_empty() {
258 return;
259 }
260 if let Some(pos) = order.iter().position(|existing| existing == id) {
261 order.remove(pos);
262 }
263 }
264
265 fn sync_delta(&mut self, last_version: u64) -> (u64, Vec<Arc<DataRecord>>, Vec<RecordId>) {
267 self.eviction_log.retain(|&(v, _)| v > last_version);
270
271 let added: Vec<Arc<DataRecord>> = self
273 .records
274 .values()
275 .filter(|entry| entry.version > last_version)
276 .map(|entry| Arc::clone(&entry.record))
277 .collect();
278
279 let evicted: Vec<RecordId> = self.eviction_log.iter().map(|(_, id)| id.clone()).collect();
281
282 (self.next_version, added, evicted)
287 }
288}
289
290pub struct IngestionManager {
292 sources: Vec<SourceState>,
293 max_records: usize,
294 sampler_config: SamplerConfig,
295 epoch: u64,
297 source_refresh_generation: u64,
299 last_refreshed_sources: Vec<SourceId>,
304 drain_start: usize,
309 epoch_step: u64,
312 global_next_version: u64,
315 force_refresh_generation: u64,
322}
323
324#[derive(Clone, Debug, Default)]
325pub struct SourceRefreshStats {
327 pub last_refresh_ms: u128,
329 pub last_record_count: usize,
331 pub last_records_per_sec: f64,
333 pub last_error: Option<String>,
335 pub error_count: u64,
337}
338
339impl IngestionManager {
340 pub fn new(max_records: usize, sampler_config: SamplerConfig) -> Self {
342 Self {
343 sources: Vec::new(),
344 max_records,
345 sampler_config,
346 epoch: 0,
347 source_refresh_generation: 0,
348 last_refreshed_sources: Vec::new(),
349 drain_start: 0,
350 epoch_step: 0,
351 global_next_version: 0,
352 force_refresh_generation: 0,
353 }
354 }
355
356 pub fn source_refresh_generation(&self) -> u64 {
358 self.source_refresh_generation
359 }
360
361 pub fn force_refresh_generation(&self) -> u64 {
364 self.force_refresh_generation
365 }
366
367 pub fn last_refreshed_sources(&self) -> &[SourceId] {
369 &self.last_refreshed_sources
370 }
371
372 pub(crate) fn set_epoch(&mut self, epoch: u64) {
379 self.epoch = epoch;
380 }
381
382 pub(crate) fn reset_epoch_step(&mut self) {
386 self.epoch_step = 0;
387 }
388
389 pub(crate) fn increment_epoch_step(&mut self) {
392 self.epoch_step = self.epoch_step.saturating_add(1);
393 }
394
395 pub fn epoch_step(&self) -> u64 {
397 self.epoch_step
398 }
399
400 pub(crate) fn set_epoch_step(&mut self, step: u64) {
402 self.epoch_step = step;
403 }
404
405 #[cfg(test)]
407 pub fn epoch(&self) -> u64 {
408 self.epoch
409 }
410
411 pub(crate) fn reset_stream_cursors(&mut self) {
417 for state in &mut self.sources {
418 state.cursor = None;
419 state.buffer.clear();
420 state.cache.clear();
421 }
422 }
423
424 pub fn register_source(
429 &mut self,
430 source: Box<dyn DataSource + 'static>,
431 ) -> Result<(), SamplerError> {
432 let source_id = source.id().to_string();
433 if is_reserved_source_id(&source_id) {
434 return Err(SamplerError::ReservedSourceId(source_id));
435 }
436 let cache = RecordCache::new(self.max_records);
437 self.sources.push(SourceState {
438 source,
439 cursor: None,
440 buffer: VecDeque::new(),
441 cache,
442 stats: SourceRefreshStats::default(),
443 });
444 Ok(())
445 }
446
447 pub fn load_cursors(&mut self, cursors: &[(SourceId, u64)]) {
449 if cursors.is_empty() {
450 return;
451 }
452 let mut map = std::collections::HashMap::with_capacity(cursors.len());
453 for (id, revision) in cursors {
454 map.insert(id.as_str(), *revision);
455 }
456 for state in &mut self.sources {
457 if let Some(revision) = map.get(state.source.id()) {
458 state.cursor = Some(SourceCursor {
459 last_seen: Utc::now(),
460 revision: *revision,
461 });
462 }
463 }
464 }
465
466 pub fn snapshot_cursors(&self) -> Vec<(SourceId, u64)> {
468 let mut out = Vec::new();
469 for state in &self.sources {
470 if let Some(cursor) = state.cursor.as_ref() {
471 out.push((state.source.id().to_string(), cursor.revision));
472 }
473 }
474 out
475 }
476
477 pub fn source_refresh_stats(&self) -> Vec<(SourceId, SourceRefreshStats)> {
479 self.sources
480 .iter()
481 .map(|state| (state.source.id().to_string(), state.stats.clone()))
482 .collect()
483 }
484
485 pub fn all_records_snapshot(&self) -> Vec<Arc<DataRecord>> {
490 self.sources
491 .iter()
492 .flat_map(|s| s.cache.snapshot())
493 .collect()
494 }
495
496 pub fn all_caches_empty(&self) -> bool {
498 self.sources.iter().all(|s| s.cache.is_empty())
499 }
500
501 pub fn all_records_len(&self) -> usize {
503 self.sources.iter().map(|s| s.cache.len()).sum()
504 }
505
506 pub fn total_ingest_count(&self) -> u64 {
511 self.sources.iter().map(|s| s.cache.ingest_count()).sum()
512 }
513
514 pub fn refresh_all(&mut self) {
516 self.refresh_all_internal(false, None, None);
517 }
518
519 pub fn advance(&mut self, step: usize) {
521 self.refresh_all_internal(false, Some(step), None);
522 }
523
524 pub fn advance_with_weights(
529 &mut self,
530 step: usize,
531 weights: &HashMap<SourceId, f32>,
532 ) -> Result<(), SamplerError> {
533 self.validate_weights(weights)?;
534 self.refresh_all_internal(false, Some(step), Some(weights));
535 Ok(())
536 }
537
538 pub fn force_refresh_all(&mut self) {
540 self.refresh_all_internal(true, None, None);
541 }
542
543 pub fn refresh_all_with_weights(
548 &mut self,
549 weights: &HashMap<SourceId, f32>,
550 ) -> Result<(), SamplerError> {
551 self.validate_weights(weights)?;
552 self.refresh_all_internal(false, None, Some(weights));
553 Ok(())
554 }
555
556 pub fn force_refresh_all_with_weights(
561 &mut self,
562 weights: &HashMap<SourceId, f32>,
563 ) -> Result<(), SamplerError> {
564 self.validate_weights(weights)?;
565 self.refresh_all_internal(true, None, Some(weights));
566 Ok(())
567 }
568
569 fn validate_weights(&self, weights: &HashMap<SourceId, f32>) -> Result<(), SamplerError> {
570 let known_ids: std::collections::HashSet<&str> =
571 self.sources.iter().map(|s| s.source.id()).collect();
572 for (id, &w) in weights {
573 if !known_ids.contains(id.as_str()) {
574 return Err(SamplerError::InvalidWeight {
575 source_id: id.clone(),
576 reason: "source is not registered".to_string(),
577 });
578 }
579 if w < 0.0 {
580 return Err(SamplerError::InvalidWeight {
581 source_id: id.clone(),
582 reason: format!("weight {w} is negative"),
583 });
584 }
585 }
586 Ok(())
587 }
588
589 fn refresh_all_internal(
596 &mut self,
597 force_refresh: bool,
598 step: Option<usize>,
599 weights: Option<&HashMap<SourceId, f32>>,
600 ) {
601 self.last_refreshed_sources.clear();
602 if force_refresh {
603 self.force_refresh_generation = self.force_refresh_generation.saturating_add(1);
607 }
608 let mut refresh_plan = Vec::new();
609 for (idx, state) in self.sources.iter_mut().enumerate() {
610 if force_refresh {
611 state.buffer.clear();
612 }
613 if force_refresh || state.buffer.is_empty() {
614 refresh_plan.push((idx, state.cursor.clone()));
615 }
616 }
617
618 if !refresh_plan.is_empty() {
619 self.source_refresh_generation = self.source_refresh_generation.saturating_add(1);
620 self.last_refreshed_sources = refresh_plan
621 .iter()
622 .map(|(idx, _)| self.sources[*idx].source.id().to_string())
623 .collect();
624 let mut results: Vec<
625 Option<(Result<SourceSnapshot, SamplerError>, std::time::Duration)>,
626 > = Vec::with_capacity(self.sources.len());
627 results.resize_with(self.sources.len(), || None);
628 let fetch_limit = self.max_records;
629 let sampler_config = self.sampler_config.clone();
630 let step = self.epoch_step;
631 thread::scope(|scope| {
632 let mut handles = Vec::with_capacity(refresh_plan.len());
633 for (idx, cursor) in &refresh_plan {
634 let source = &self.sources[*idx].source;
635 let cursor = cursor.clone();
636 let sampler_config = sampler_config.clone();
637 let epoch = self.epoch;
638 handles.push((
639 *idx,
640 scope.spawn(move || {
641 let start = std::time::Instant::now();
642 let epoch_config = SamplerConfig {
648 seed: derive_epoch_seed(sampler_config.seed, epoch) ^ step,
649 ..sampler_config
650 };
651 let result =
652 source.refresh(&epoch_config, cursor.as_ref(), Some(fetch_limit));
653 let elapsed = start.elapsed();
654 (result, elapsed)
655 }),
656 ));
657 }
658 for (idx, handle) in handles {
659 let result = match handle.join() {
660 Ok((result, elapsed)) => {
661 debug!(
662 source_id = %self.sources[idx].source.id(),
663 refresh_ms = elapsed.as_millis(),
664 "source refresh completed"
665 );
666 (result, elapsed)
667 }
668 Err(_) => (
669 Err(SamplerError::SourceUnavailable {
670 source_id: self.sources[idx].source.id().to_string(),
671 reason: "source refresh thread panicked".into(),
672 }),
673 std::time::Duration::from_secs(0),
674 ),
675 };
676 results[idx] = Some(result);
677 }
678 });
679
680 for (idx, _) in refresh_plan {
681 let Some((result, elapsed)) = results[idx].take() else {
682 continue;
683 };
684 match result {
685 Ok(snapshot) => {
686 let SourceSnapshot {
687 records,
688 cursor: next_cursor,
689 } = snapshot;
690 let record_count = records.len();
691 let seconds = elapsed.as_secs_f64();
692 let per_sec = if seconds > 0.0 {
693 (record_count as f64) / seconds
694 } else {
695 0.0
696 };
697 let stats = &mut self.sources[idx].stats;
698 stats.last_refresh_ms = elapsed.as_millis();
699 stats.last_record_count = record_count;
700 stats.last_records_per_sec = per_sec;
701 stats.last_error = None;
702 debug!(
703 source_id = %self.sources[idx].source.id(),
704 record_count,
705 refresh_ms = elapsed.as_millis(),
706 records_per_sec = per_sec,
707 "source refresh ingested records"
708 );
709 let source_id = self.sources[idx].source.id().to_string();
710 let normalized = records
711 .into_iter()
712 .map(|mut record| {
713 record.source = source_id.clone();
714 record
715 })
716 .collect::<Vec<_>>();
717 self.sources[idx].buffer.extend(normalized);
718 self.sources[idx].cursor = Some(next_cursor);
719 }
720 Err(err) => {
721 let stats = &mut self.sources[idx].stats;
722 stats.last_refresh_ms = elapsed.as_millis();
723 stats.last_record_count = 0;
724 stats.last_records_per_sec = 0.0;
725 stats.last_error = Some(err.to_string());
726 stats.error_count = stats.error_count.saturating_add(1);
727 warn!(
728 source = %self.sources[idx].source.id(),
729 error = %err,
730 "Source refresh failed"
731 );
732 }
733 }
734 }
735 }
736
737 if step.is_none() {
741 for state in self.sources.iter_mut() {
742 state.cache.clear();
743 }
744 }
745 if self.max_records == 0 {
746 return;
747 }
748 let target_limit = step.unwrap_or(self.max_records);
749 if let Some(weights) = weights {
750 self.weighted_drain_into_caches(target_limit, weights);
751 } else {
752 let n = self.sources.len();
757 if n > 0 {
758 let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); n];
759 let mut total_drained = 0;
760 let mut any_remaining = true;
761 while total_drained < target_limit && any_remaining {
762 any_remaining = false;
763 for offset in 0..n {
764 if total_drained >= target_limit {
765 break;
766 }
767 let idx = (self.drain_start + offset) % n;
768 if let Some(record) = self.sources[idx].buffer.pop_front() {
769 per_source[idx].push(record);
770 total_drained += 1;
771 any_remaining = true;
772 }
773 }
774 }
775 if total_drained > 0 {
779 self.drain_start = (self.drain_start + 1) % n;
780 }
781 self.global_next_version = self.global_next_version.saturating_add(1);
783 for (idx, batch) in per_source.into_iter().enumerate() {
784 if !batch.is_empty() {
785 self.sources[idx]
786 .cache
787 .ingest_with_version(batch, self.global_next_version);
788 }
789 }
790 }
791 }
792 }
793
794 fn weighted_drain_into_caches(&mut self, limit: usize, weights: &HashMap<SourceId, f32>) {
795 let len = self.sources.len();
796 if len == 0 {
797 return;
798 }
799 let mut weight_values = Vec::with_capacity(len);
800 let mut any_positive = false;
801 for state in &self.sources {
802 let weight = weights.get(state.source.id()).copied().unwrap_or(1.0);
803 if weight > 0.0 {
804 any_positive = true;
805 }
806 weight_values.push(weight);
807 }
808 if !any_positive {
809 weight_values.fill(1.0);
810 }
811
812 let mut current = vec![0.0f32; len];
813 let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); len];
814 let mut total = 0;
815 while total < limit {
816 let mut total_weight = 0.0f32;
817 for (idx, weight) in weight_values.iter().copied().enumerate().take(len) {
818 if weight <= 0.0 {
819 continue;
820 }
821 if self.sources[idx].buffer.is_empty() {
822 continue;
823 }
824 total_weight += weight;
825 }
826 if total_weight == 0.0 {
827 break;
828 }
829
830 let mut best_idx = None;
831 let mut best_score = f32::MIN;
832 let closer_to_start = |a: usize, b: usize| -> bool {
836 let da = (a + len - self.drain_start) % len;
837 let db = (b + len - self.drain_start) % len;
838 da < db
839 };
840 for idx in 0..len {
841 if weight_values[idx] <= 0.0 {
842 continue;
843 }
844 if self.sources[idx].buffer.is_empty() {
845 continue;
846 }
847 current[idx] += weight_values[idx];
848 let is_better = if current[idx] > best_score {
849 true
850 } else if current[idx] == best_score {
851 closer_to_start(idx, best_idx.unwrap_or(0))
852 } else {
853 false
854 };
855 if is_better {
856 best_score = current[idx];
857 best_idx = Some(idx);
858 }
859 }
860
861 let idx = match best_idx {
862 Some(idx) => idx,
863 None => break,
864 };
865 current[idx] -= total_weight;
866 if let Some(record) = self.sources[idx].buffer.pop_front() {
867 per_source[idx].push(record);
868 total += 1;
869 }
870 }
871
872 if total > 0 && len > 0 {
873 self.drain_start = (self.drain_start + 1) % len;
874 }
875
876 self.global_next_version = self.global_next_version.saturating_add(1);
878 for (idx, batch) in per_source.into_iter().enumerate() {
879 if !batch.is_empty() {
880 self.sources[idx]
881 .cache
882 .ingest_with_version(batch, self.global_next_version);
883 }
884 }
885 }
886
887 pub fn has_sources(&self) -> bool {
889 !self.sources.is_empty()
890 }
891
892 pub fn sync_delta(&mut self, last_version: u64) -> (u64, Vec<Arc<DataRecord>>, Vec<RecordId>) {
895 let mut all_added = Vec::new();
896 let mut all_evicted = Vec::new();
897 let mut max_version = last_version;
898
899 for state in &mut self.sources {
900 let (new_version, added, evicted) = state.cache.sync_delta(last_version);
901 if new_version > max_version {
902 max_version = new_version;
903 }
904 all_added.extend(added);
905 all_evicted.extend(evicted);
906 }
907
908 (max_version, all_added, all_evicted)
909 }
910
911 pub fn global_version(&self) -> u64 {
913 self.global_next_version
914 }
915}
916
917struct SourceState {
919 source: Box<dyn DataSource + 'static>,
920 cursor: Option<SourceCursor>,
921 buffer: VecDeque<DataRecord>,
922 cache: RecordCache,
924 stats: SourceRefreshStats,
925}
926
927#[cfg(test)]
928#[allow(deprecated)]
933mod tests {
934 use super::*;
935 use crate::TripletSampler;
936 use crate::config::{Selector, TextRecipe, TripletRecipe};
937 use crate::data::{QualityScore, RecordSection, SectionRole};
938 use crate::sampler::Sampler;
939 use crate::splits::{DeterministicSplitStore, SamplerStateStore, SplitLabel, SplitRatios};
940 use chrono::Utc;
941 use std::collections::HashMap;
942 use std::collections::VecDeque;
943 use std::sync::atomic::{AtomicUsize, Ordering};
944 use std::sync::{Arc, Mutex};
945
946 fn make_record(id: &str, source: &str) -> DataRecord {
947 let now = Utc::now();
948 DataRecord {
949 id: id.to_string(),
950 source: source.to_string(),
951 created_at: now,
952 updated_at: now,
953 quality: QualityScore { trust: 1.0 },
954 taxonomy: Vec::new(),
955 sections: vec![RecordSection {
956 role: SectionRole::Anchor,
957 heading: None,
958 text: id.to_string(),
959 sentences: vec![id.to_string()],
960 token_count: 0,
961 }],
962 meta_prefix: None,
963 label: None,
964 }
965 }
966
967 struct ScriptedSource {
968 id: String,
969 refreshes: Arc<AtomicUsize>,
970 script: Arc<Mutex<VecDeque<Result<SourceSnapshot, SamplerError>>>>,
971 }
972
973 impl ScriptedSource {
974 fn new(
975 id: &str,
976 refreshes: Arc<AtomicUsize>,
977 script: Vec<Result<SourceSnapshot, SamplerError>>,
978 ) -> Self {
979 Self {
980 id: id.to_string(),
981 refreshes,
982 script: Arc::new(Mutex::new(script.into_iter().collect())),
983 }
984 }
985 }
986
987 impl DataSource for ScriptedSource {
988 fn id(&self) -> &str {
989 &self.id
990 }
991
992 fn refresh(
993 &self,
994 _config: &SamplerConfig,
995 _cursor: Option<&SourceCursor>,
996 _limit: Option<usize>,
997 ) -> Result<SourceSnapshot, SamplerError> {
998 self.refreshes.fetch_add(1, Ordering::SeqCst);
999 let mut guard = self.script.lock().expect("script lock poisoned");
1000 guard.pop_front().unwrap_or_else(|| {
1001 Ok(SourceSnapshot {
1002 records: Vec::new(),
1003 cursor: SourceCursor {
1004 last_seen: Utc::now(),
1005 revision: 0,
1006 },
1007 })
1008 })
1009 }
1010
1011 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1012 Ok(0)
1013 }
1014
1015 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1016 Vec::new()
1017 }
1018 }
1019
1020 struct PanicSource {
1021 id: String,
1022 }
1023
1024 impl DataSource for PanicSource {
1025 fn id(&self) -> &str {
1026 &self.id
1027 }
1028
1029 fn refresh(
1030 &self,
1031 _config: &SamplerConfig,
1032 _cursor: Option<&SourceCursor>,
1033 _limit: Option<usize>,
1034 ) -> Result<SourceSnapshot, SamplerError> {
1035 panic!("panic source refresh")
1036 }
1037
1038 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1039 Ok(0)
1040 }
1041 }
1042
1043 #[test]
1044 fn record_cache_waits_len_and_clear_paths_are_covered() {
1045 let cache = RecordCache::new(2);
1046 assert!(cache.is_empty());
1047 assert_eq!(cache.len(), 0);
1048 assert_eq!(cache.ingest_count(), 0);
1049
1050 cache.ingest(Vec::<DataRecord>::new());
1051 assert_eq!(cache.wait_for_ingest(0, Duration::from_millis(1)), 0);
1052
1053 let cache_for_waiter = cache.clone();
1054 let handle = std::thread::spawn(move || cache_for_waiter.wait_for_ingest_blocking(0));
1055 std::thread::sleep(Duration::from_millis(5));
1056 cache.ingest(vec![make_record("r1", "s")]);
1057 assert_eq!(handle.join().unwrap(), 1);
1058 assert_eq!(cache.ingest_count(), 1);
1059
1060 cache.ingest(vec![make_record("r2", "s"), make_record("r3", "s")]);
1061 assert_eq!(cache.len(), 2);
1062 let ids: Vec<String> = cache
1063 .snapshot()
1064 .into_iter()
1065 .map(|record| record.id.clone())
1066 .collect();
1067 assert!(ids.contains(&"r2".to_string()));
1068 assert!(ids.contains(&"r3".to_string()));
1069
1070 cache.clear();
1071 assert!(cache.is_empty());
1072 }
1073
1074 #[test]
1075 fn record_cache_zero_limit_discards_everything() {
1076 let cache = RecordCache::new(0);
1077 cache.ingest(vec![make_record("r1", "s")]);
1078 assert!(cache.is_empty());
1079 assert_eq!(cache.len(), 0);
1080 }
1081
1082 #[test]
1083 fn manager_loads_and_snapshots_cursors_and_reports_has_sources() {
1084 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1085 assert!(!manager.has_sources());
1086 manager.load_cursors(&[]);
1087
1088 let refreshes = Arc::new(AtomicUsize::new(0));
1089 manager
1090 .register_source(Box::new(ScriptedSource::new(
1091 "cursor_source",
1092 refreshes,
1093 vec![Ok(SourceSnapshot {
1094 records: vec![make_record("id_1", "original_source")],
1095 cursor: SourceCursor {
1096 last_seen: Utc::now(),
1097 revision: 33,
1098 },
1099 })],
1100 )))
1101 .unwrap();
1102 assert!(manager.has_sources());
1103
1104 manager.load_cursors(&[("cursor_source".to_string(), 7)]);
1105 let cursors = manager.snapshot_cursors();
1106 assert_eq!(cursors.len(), 1);
1107 assert_eq!(cursors[0], ("cursor_source".to_string(), 7));
1108
1109 manager.refresh_all();
1110 let updated = manager.snapshot_cursors();
1111 assert_eq!(updated.len(), 1);
1112 assert_eq!(updated[0], ("cursor_source".to_string(), 33));
1113 let records = manager.all_records_snapshot();
1114 assert_eq!(records.len(), 1);
1115 assert_eq!(records[0].source, "cursor_source");
1116 }
1117
1118 #[test]
1119 fn advance_uses_buffer_before_refreshing_again() {
1120 let refreshes = Arc::new(AtomicUsize::new(0));
1121 let mut manager = IngestionManager::new(5, SamplerConfig::default());
1122 manager
1123 .register_source(Box::new(ScriptedSource::new(
1124 "buffered",
1125 refreshes.clone(),
1126 vec![Ok(SourceSnapshot {
1127 records: vec![
1128 make_record("a", "x"),
1129 make_record("b", "x"),
1130 make_record("c", "x"),
1131 ],
1132 cursor: SourceCursor {
1133 last_seen: Utc::now(),
1134 revision: 1,
1135 },
1136 })],
1137 )))
1138 .unwrap();
1139
1140 manager.advance(1);
1141 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1142 assert_eq!(manager.all_records_len(), 1);
1143
1144 manager.advance(1);
1145 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1146 assert_eq!(manager.all_records_len(), 2);
1147 }
1148
1149 #[test]
1150 fn force_refresh_clears_buffer_and_fetches_again() {
1151 let refreshes = Arc::new(AtomicUsize::new(0));
1152 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1153 manager
1154 .register_source(Box::new(ScriptedSource::new(
1155 "force",
1156 refreshes.clone(),
1157 vec![
1158 Ok(SourceSnapshot {
1159 records: vec![
1160 make_record("r1", "x"),
1161 make_record("r2", "x"),
1162 make_record("r3", "x"),
1163 ],
1164 cursor: SourceCursor {
1165 last_seen: Utc::now(),
1166 revision: 10,
1167 },
1168 }),
1169 Ok(SourceSnapshot {
1170 records: vec![make_record("r4", "x")],
1171 cursor: SourceCursor {
1172 last_seen: Utc::now(),
1173 revision: 11,
1174 },
1175 }),
1176 ],
1177 )))
1178 .unwrap();
1179
1180 manager.advance(1);
1181 assert_eq!(manager.all_records_len(), 1);
1182 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1183
1184 manager.force_refresh_all();
1185 assert_eq!(refreshes.load(Ordering::SeqCst), 2);
1186 let records = manager.all_records_snapshot();
1187 assert_eq!(records.len(), 1);
1188 assert_eq!(records[0].id, "r4");
1189 }
1190
1191 #[test]
1192 fn weighted_drain_respects_zero_and_fallback_weights() {
1193 let mut manager = IngestionManager::new(6, SamplerConfig::default());
1194 manager
1195 .register_source(Box::new(ScriptedSource::new(
1196 "a",
1197 Arc::new(AtomicUsize::new(0)),
1198 vec![Ok(SourceSnapshot {
1199 records: vec![make_record("a1", "a"), make_record("a2", "a")],
1200 cursor: SourceCursor {
1201 last_seen: Utc::now(),
1202 revision: 1,
1203 },
1204 })],
1205 )))
1206 .unwrap();
1207 manager
1208 .register_source(Box::new(ScriptedSource::new(
1209 "b",
1210 Arc::new(AtomicUsize::new(0)),
1211 vec![Ok(SourceSnapshot {
1212 records: vec![make_record("b1", "b"), make_record("b2", "b")],
1213 cursor: SourceCursor {
1214 last_seen: Utc::now(),
1215 revision: 1,
1216 },
1217 })],
1218 )))
1219 .unwrap();
1220
1221 let mut only_b = HashMap::new();
1222 only_b.insert("a".to_string(), 0.0);
1223 only_b.insert("b".to_string(), 1.0);
1224 manager.refresh_all_with_weights(&only_b).unwrap();
1225 let ids: Vec<String> = manager
1226 .all_records_snapshot()
1227 .into_iter()
1228 .map(|record| record.id.clone())
1229 .collect();
1230 assert!(ids.iter().all(|id| id.starts_with('b')));
1231
1232 let mut manager_fallback = IngestionManager::new(6, SamplerConfig::default());
1233 manager_fallback
1234 .register_source(Box::new(ScriptedSource::new(
1235 "a",
1236 Arc::new(AtomicUsize::new(0)),
1237 vec![Ok(SourceSnapshot {
1238 records: vec![make_record("a1", "a")],
1239 cursor: SourceCursor {
1240 last_seen: Utc::now(),
1241 revision: 2,
1242 },
1243 })],
1244 )))
1245 .unwrap();
1246 manager_fallback
1247 .register_source(Box::new(ScriptedSource::new(
1248 "b",
1249 Arc::new(AtomicUsize::new(0)),
1250 vec![Ok(SourceSnapshot {
1251 records: vec![make_record("b1", "b")],
1252 cursor: SourceCursor {
1253 last_seen: Utc::now(),
1254 revision: 2,
1255 },
1256 })],
1257 )))
1258 .unwrap();
1259
1260 let mut all_zero = HashMap::new();
1261 all_zero.insert("a".to_string(), 0.0);
1262 all_zero.insert("b".to_string(), 0.0);
1263 manager_fallback
1264 .refresh_all_with_weights(&all_zero)
1265 .unwrap();
1266 let ids: Vec<String> = manager_fallback
1267 .all_records_snapshot()
1268 .into_iter()
1269 .map(|record| record.id.clone())
1270 .collect();
1271 assert!(ids.contains(&"a1".to_string()));
1272 assert!(ids.contains(&"b1".to_string()));
1273 }
1274
1275 #[test]
1276 fn refresh_errors_and_panics_update_source_stats() {
1277 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1278 manager
1279 .register_source(Box::new(ScriptedSource::new(
1280 "err_source",
1281 Arc::new(AtomicUsize::new(0)),
1282 vec![Err(SamplerError::SourceUnavailable {
1283 source_id: "err_source".to_string(),
1284 reason: "boom".to_string(),
1285 })],
1286 )))
1287 .unwrap();
1288 manager
1289 .register_source(Box::new(PanicSource {
1290 id: "panic_source".to_string(),
1291 }))
1292 .unwrap();
1293
1294 manager.refresh_all();
1295 let stats = manager.source_refresh_stats();
1296 let err_stats = stats
1297 .iter()
1298 .find(|(source, _)| source == "err_source")
1299 .map(|(_, stats)| stats)
1300 .unwrap();
1301 assert_eq!(err_stats.error_count, 1);
1302 assert!(
1303 err_stats
1304 .last_error
1305 .as_ref()
1306 .is_some_and(|msg| msg.contains("boom"))
1307 );
1308
1309 let panic_stats = stats
1310 .iter()
1311 .find(|(source, _)| source == "panic_source")
1312 .map(|(_, stats)| stats)
1313 .unwrap();
1314 assert_eq!(panic_stats.error_count, 1);
1315 assert!(
1316 panic_stats
1317 .last_error
1318 .as_ref()
1319 .is_some_and(|msg| msg.contains("panicked"))
1320 );
1321 }
1322
1323 #[test]
1324 fn force_refresh_with_weights_path_is_exercised() {
1325 let mut manager = IngestionManager::new(3, SamplerConfig::default());
1326 manager
1327 .register_source(Box::new(ScriptedSource::new(
1328 "w",
1329 Arc::new(AtomicUsize::new(0)),
1330 vec![Ok(SourceSnapshot {
1331 records: vec![make_record("w1", "w")],
1332 cursor: SourceCursor {
1333 last_seen: Utc::now(),
1334 revision: 3,
1335 },
1336 })],
1337 )))
1338 .unwrap();
1339
1340 let mut weights = HashMap::new();
1341 weights.insert("w".to_string(), 1.0);
1342 manager.force_refresh_all_with_weights(&weights).unwrap();
1343 assert_eq!(manager.all_records_len(), 1);
1344 }
1345
1346 #[test]
1347 fn advance_with_weights_rejects_unknown_source() {
1348 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1349 manager
1350 .register_source(Box::new(ScriptedSource::new(
1351 "known",
1352 Arc::new(AtomicUsize::new(0)),
1353 vec![],
1354 )))
1355 .unwrap();
1356
1357 let mut weights = HashMap::new();
1358 weights.insert("known".to_string(), 1.0);
1359 weights.insert("unknown".to_string(), 0.5);
1360
1361 let err = manager.advance_with_weights(1, &weights).unwrap_err();
1362 assert!(
1363 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "unknown"),
1364 "expected InvalidWeight for unknown source, got {err:?}"
1365 );
1366 }
1367
1368 #[test]
1369 fn refresh_all_with_weights_rejects_negative_weight() {
1370 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1371 manager
1372 .register_source(Box::new(ScriptedSource::new(
1373 "src",
1374 Arc::new(AtomicUsize::new(0)),
1375 vec![],
1376 )))
1377 .unwrap();
1378
1379 let mut weights = HashMap::new();
1380 weights.insert("src".to_string(), -1.0);
1381
1382 let err = manager.refresh_all_with_weights(&weights).unwrap_err();
1383 assert!(
1384 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "src"),
1385 "expected InvalidWeight for negative weight, got {err:?}"
1386 );
1387 }
1388
1389 #[test]
1390 fn force_refresh_all_with_weights_rejects_unknown_source() {
1391 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1392 manager
1393 .register_source(Box::new(ScriptedSource::new(
1394 "real",
1395 Arc::new(AtomicUsize::new(0)),
1396 vec![],
1397 )))
1398 .unwrap();
1399
1400 let mut weights = HashMap::new();
1401 weights.insert("ghost".to_string(), 1.0);
1402
1403 let err = manager
1404 .force_refresh_all_with_weights(&weights)
1405 .unwrap_err();
1406 assert!(
1407 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "ghost"),
1408 "expected InvalidWeight for unknown source, got {err:?}"
1409 );
1410 }
1411
1412 struct SeedCapturingSource {
1414 id: String,
1415 received_seeds: Arc<Mutex<Vec<u64>>>,
1416 }
1417
1418 impl SeedCapturingSource {
1419 fn new(id: &str, received_seeds: Arc<Mutex<Vec<u64>>>) -> Self {
1420 Self {
1421 id: id.to_string(),
1422 received_seeds,
1423 }
1424 }
1425 }
1426
1427 impl DataSource for SeedCapturingSource {
1428 fn id(&self) -> &str {
1429 &self.id
1430 }
1431
1432 fn refresh(
1433 &self,
1434 config: &SamplerConfig,
1435 _cursor: Option<&SourceCursor>,
1436 _limit: Option<usize>,
1437 ) -> Result<SourceSnapshot, SamplerError> {
1438 self.received_seeds
1439 .lock()
1440 .expect("seed lock poisoned")
1441 .push(config.seed);
1442 Ok(SourceSnapshot {
1443 records: Vec::new(),
1444 cursor: SourceCursor {
1445 last_seen: Utc::now(),
1446 revision: 0,
1447 },
1448 })
1449 }
1450
1451 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1452 Ok(0)
1453 }
1454
1455 fn default_triplet_recipes(&self) -> Vec<crate::config::TripletRecipe> {
1456 Vec::new()
1457 }
1458 }
1459
1460 #[test]
1461 fn epoch_xor_changes_seed_received_by_source() {
1462 let base_seed = 0xDEAD_BEEF_u64;
1465 let config = SamplerConfig {
1466 seed: base_seed,
1467 ..SamplerConfig::default()
1468 };
1469
1470 let seeds_epoch0 = Arc::new(Mutex::new(Vec::<u64>::new()));
1471 let seeds_epoch1 = Arc::new(Mutex::new(Vec::<u64>::new()));
1472
1473 let mut manager = IngestionManager::new(4, config.clone());
1475 manager
1476 .register_source(Box::new(SeedCapturingSource::new(
1477 "src",
1478 Arc::clone(&seeds_epoch0),
1479 )))
1480 .unwrap();
1481 manager.refresh_all();
1483
1484 let mut manager2 = IngestionManager::new(4, config.clone());
1486 manager2
1487 .register_source(Box::new(SeedCapturingSource::new(
1488 "src",
1489 Arc::clone(&seeds_epoch1),
1490 )))
1491 .unwrap();
1492 manager2.set_epoch(1);
1493 manager2.refresh_all();
1494
1495 let received0 = seeds_epoch0.lock().unwrap();
1496 let received1 = seeds_epoch1.lock().unwrap();
1497
1498 assert!(!received0.is_empty(), "epoch-0 source was never refreshed");
1499 assert!(!received1.is_empty(), "epoch-1 source was never refreshed");
1500
1501 let seed_at_epoch0 = received0[0];
1502 let seed_at_epoch1 = received1[0];
1503
1504 assert_ne!(
1506 seed_at_epoch0, seed_at_epoch1,
1507 "epoch 0 and epoch 1 both produced seed {seed_at_epoch0:#x}; \
1508 derive_epoch_seed is not reaching the source"
1509 );
1510
1511 assert_eq!(
1515 seed_at_epoch0,
1516 derive_epoch_seed(base_seed, 0),
1517 "epoch-0 seed mismatch (epoch_step=0)"
1518 );
1519 assert_eq!(
1520 seed_at_epoch1,
1521 derive_epoch_seed(base_seed, 1),
1522 "epoch-1 seed mismatch (epoch_step=0)"
1523 );
1524 }
1525
1526 #[test]
1527 fn epoch_step_resets_on_epoch_change() {
1528 let config = SamplerConfig::default();
1531 let seeds = Arc::new(Mutex::new(Vec::new()));
1532
1533 let mut manager = IngestionManager::new(4, config.clone());
1534 manager
1535 .register_source(Box::new(SeedCapturingSource::new(
1536 "src",
1537 Arc::clone(&seeds),
1538 )))
1539 .unwrap();
1540
1541 manager.refresh_all();
1544 let step1_seed = seeds.lock().unwrap()[0];
1545 assert_eq!(
1546 step1_seed,
1547 derive_epoch_seed(config.seed, 0),
1548 "epoch 0 step 0 seed"
1549 );
1550
1551 manager.set_epoch(1);
1553 assert_eq!(manager.epoch_step(), 0);
1554 seeds.lock().unwrap().clear();
1555
1556 manager.refresh_all();
1558 let step1_epoch1 = seeds.lock().unwrap()[0];
1559 assert_eq!(
1560 step1_epoch1,
1561 derive_epoch_seed(config.seed, 1),
1562 "epoch 1 step 0 seed (must be ^0 since refresh_all no longer bumps step)"
1563 );
1564 }
1565
1566 #[test]
1567 fn epoch_step_survives_sampler_save_and_load_state() {
1568 let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 1).unwrap());
1574
1575 let sampler = TripletSampler::new(
1577 SamplerConfig {
1578 seed: 42,
1579 ..SamplerConfig::default()
1580 },
1581 Arc::clone(&store),
1582 );
1583 let refreshes = Arc::new(AtomicUsize::new(0));
1584 sampler
1585 .register_source(Box::new(ScriptedSource::new(
1586 "src",
1587 refreshes,
1588 vec![Ok(SourceSnapshot {
1589 records: vec![make_record("r1", "src")],
1590 cursor: SourceCursor {
1591 last_seen: Utc::now(),
1592 revision: 1,
1593 },
1594 })],
1595 )))
1596 .unwrap();
1597
1598 let _ = sampler.next_text_batch(SplitLabel::Train);
1602
1603 sampler.save_sampler_state(None).unwrap();
1605
1606 let loaded = store.load_sampler_state().unwrap().unwrap();
1608 let step_saved = loaded.epoch_step;
1609 assert!(
1610 step_saved > 0,
1611 "epoch_step must be >0 after batch call through TripletSampler"
1612 );
1613
1614 let mut manager2 = IngestionManager::new(4, SamplerConfig::default());
1616 manager2
1617 .register_source(Box::new(ScriptedSource::new(
1618 "src",
1619 Arc::new(AtomicUsize::new(0)),
1620 vec![Ok(SourceSnapshot {
1621 records: vec![make_record("r2", "src")],
1622 cursor: SourceCursor {
1623 last_seen: Utc::now(),
1624 revision: 2,
1625 },
1626 })],
1627 )))
1628 .unwrap();
1629 manager2.load_cursors(&loaded.source_stream_cursors);
1630 manager2.set_epoch_step(loaded.epoch_step);
1631
1632 let step_before = manager2.epoch_step();
1635 assert_eq!(
1636 step_before, step_saved,
1637 "load_cursors must restore epoch_step to saved value"
1638 );
1639
1640 manager2.refresh_all();
1642 let step_after = manager2.epoch_step();
1643 assert_eq!(
1644 step_after, step_saved,
1645 "epoch_step must survive refresh_all without increment (step is per-batch,
1646 not per-refresh): loaded {step_saved}, got {step_after}"
1647 );
1648 }
1649
1650 #[test]
1651 fn scripted_and_panic_sources_cover_default_trait_paths() {
1652 let refreshes = Arc::new(AtomicUsize::new(0));
1653 let scripted = ScriptedSource::new("scripted", refreshes, vec![]);
1654
1655 let snapshot = scripted
1657 .refresh(&SamplerConfig::default(), None, None)
1658 .expect("fallback snapshot");
1659 assert!(snapshot.records.is_empty());
1660 assert_eq!(snapshot.cursor.revision, 0);
1661
1662 assert_eq!(
1663 scripted
1664 .reported_record_count(&SamplerConfig::default())
1665 .expect("record count"),
1666 0
1667 );
1668 assert!(scripted.default_triplet_recipes().is_empty());
1669
1670 let panic_source = PanicSource {
1671 id: "panic_count".to_string(),
1672 };
1673 assert_eq!(
1674 panic_source
1675 .reported_record_count(&SamplerConfig::default())
1676 .expect("record count"),
1677 0
1678 );
1679 }
1680
1681 #[test]
1682 fn seed_capturing_source_trait_defaults_are_exercised() {
1683 let source = SeedCapturingSource::new("seed_defaults", Arc::new(Mutex::new(Vec::new())));
1684 assert_eq!(
1685 source
1686 .reported_record_count(&SamplerConfig::default())
1687 .expect("record count"),
1688 0
1689 );
1690 assert!(source.default_triplet_recipes().is_empty());
1691 }
1692
1693 #[test]
1694 fn refresh_paths_handle_zero_capacity_and_no_sources() {
1695 let mut manager = IngestionManager::new(0, SamplerConfig::default());
1696 manager
1697 .register_source(Box::new(ScriptedSource::new(
1698 "zero_capacity",
1699 Arc::new(AtomicUsize::new(0)),
1700 vec![Ok(SourceSnapshot {
1701 records: vec![make_record("r1", "zero_capacity")],
1702 cursor: SourceCursor {
1703 last_seen: Utc::now(),
1704 revision: 1,
1705 },
1706 })],
1707 )))
1708 .unwrap();
1709 manager.refresh_all();
1710 assert!(manager.all_caches_empty());
1711
1712 let mut empty_manager = IngestionManager::new(4, SamplerConfig::default());
1714 let empty_weights = HashMap::new();
1715 empty_manager
1716 .refresh_all_with_weights(&empty_weights)
1717 .expect("no sources should not error");
1718 assert!(empty_manager.all_caches_empty());
1719 }
1720
1721 #[test]
1722 fn drain_start_rotates_fairly_across_sources() {
1723 struct FairSource {
1727 id: String,
1728 refresh_count: Arc<AtomicUsize>,
1729 }
1730
1731 impl DataSource for FairSource {
1732 fn id(&self) -> &str {
1733 &self.id
1734 }
1735 fn refresh(
1736 &self,
1737 _config: &SamplerConfig,
1738 _cursor: Option<&SourceCursor>,
1739 _limit: Option<usize>,
1740 ) -> Result<SourceSnapshot, SamplerError> {
1741 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1742 Ok(SourceSnapshot {
1743 records: (0..10)
1744 .map(|i| make_record(&format!("r{i}"), &self.id))
1745 .collect(),
1746 cursor: SourceCursor {
1747 last_seen: Utc::now(),
1748 revision: 1,
1749 },
1750 })
1751 }
1752 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1753 Ok(10)
1754 }
1755 }
1756
1757 let counts = (
1758 Arc::new(AtomicUsize::new(0)),
1759 Arc::new(AtomicUsize::new(0)),
1760 Arc::new(AtomicUsize::new(0)),
1761 );
1762
1763 let mut manager = IngestionManager::new(30, SamplerConfig::default());
1764 manager
1765 .register_source(Box::new(FairSource {
1766 id: "src_0".to_string(),
1767 refresh_count: Arc::clone(&counts.0),
1768 }))
1769 .unwrap();
1770 manager
1771 .register_source(Box::new(FairSource {
1772 id: "src_1".to_string(),
1773 refresh_count: Arc::clone(&counts.1),
1774 }))
1775 .unwrap();
1776 manager
1777 .register_source(Box::new(FairSource {
1778 id: "src_2".to_string(),
1779 refresh_count: Arc::clone(&counts.2),
1780 }))
1781 .unwrap();
1782
1783 manager.refresh_all();
1785 assert_eq!(counts.0.load(Ordering::SeqCst), 1);
1787 assert_eq!(counts.1.load(Ordering::SeqCst), 1);
1788 assert_eq!(counts.2.load(Ordering::SeqCst), 1);
1789
1790 for _ in 0..33 {
1796 manager.advance(1);
1797 }
1798
1799 let r0 = counts.0.load(Ordering::SeqCst);
1800 let r1 = counts.1.load(Ordering::SeqCst);
1801 let r2 = counts.2.load(Ordering::SeqCst);
1802
1803 let min = r0.min(r1).min(r2);
1810 let max = r0.max(r1).max(r2);
1811 assert!(
1812 max <= min + 1,
1813 "sources should refresh at roughly the same rate: got {r0}/{r1}/{r2}"
1814 );
1815 }
1816
1817 #[test]
1818 fn direct_drain_start_rotates_fairly_with_batch_2_of_5() {
1819 struct SimpleSource {
1822 id: String,
1823 refresh_count: Arc<AtomicUsize>,
1824 }
1825
1826 impl DataSource for SimpleSource {
1827 fn id(&self) -> &str {
1828 &self.id
1829 }
1830 fn refresh(
1831 &self,
1832 _: &SamplerConfig,
1833 _: Option<&SourceCursor>,
1834 _: Option<usize>,
1835 ) -> Result<SourceSnapshot, SamplerError> {
1836 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1837 let now = Utc::now();
1838 let records: Vec<DataRecord> = (0..8)
1839 .map(|i| DataRecord {
1840 id: format!("{}_r{i}", self.id),
1841 source: self.id.clone(),
1842 created_at: now,
1843 updated_at: now,
1844 quality: QualityScore { trust: 1.0 },
1845 taxonomy: Vec::new(),
1846 sections: Vec::new(),
1847 meta_prefix: None,
1848 label: None,
1849 })
1850 .collect();
1851 Ok(SourceSnapshot {
1852 records,
1853 cursor: SourceCursor {
1854 last_seen: now,
1855 revision: 1,
1856 },
1857 })
1858 }
1859 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1860 Ok(8)
1861 }
1862 }
1863
1864 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1865 let mut manager = IngestionManager::new(40, SamplerConfig::default());
1866 for (i, count) in counts.iter().enumerate() {
1867 manager
1868 .register_source(Box::new(SimpleSource {
1869 id: format!("src_{i}"),
1870 refresh_count: Arc::clone(count),
1871 }))
1872 .unwrap();
1873 }
1874
1875 manager.refresh_all();
1876 for c in &counts {
1877 assert_eq!(c.load(Ordering::SeqCst), 1);
1878 }
1879
1880 for _ in 0..80 {
1881 manager.advance(2);
1882 }
1883
1884 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1885 assert_eq!(
1889 totals,
1890 vec![5, 5, 6, 6, 6],
1891 "direct manager: unexpected refresh distribution"
1892 );
1893 }
1894
1895 fn make_five_source_sampler(
1898 counts: &[Arc<AtomicUsize>],
1899 ) -> TripletSampler<DeterministicSplitStore> {
1900 struct Tracked {
1901 id: String,
1902 refresh_count: Arc<AtomicUsize>,
1903 }
1904 impl DataSource for Tracked {
1905 fn id(&self) -> &str {
1906 &self.id
1907 }
1908 fn refresh(
1909 &self,
1910 _: &SamplerConfig,
1911 _: Option<&SourceCursor>,
1912 _: Option<usize>,
1913 ) -> Result<SourceSnapshot, SamplerError> {
1914 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1915 let now = Utc::now();
1916 let records: Vec<DataRecord> = (0..8)
1917 .map(|i| DataRecord {
1918 id: format!("{}_r{i}", self.id),
1919 source: self.id.clone(),
1920 created_at: now,
1921 updated_at: now,
1922 quality: QualityScore { trust: 1.0 },
1923 taxonomy: Vec::new(),
1924 sections: vec![RecordSection {
1925 role: SectionRole::Anchor,
1926 heading: None,
1927 text: format!("{}_x{}", self.id, i),
1928 sentences: vec![format!("{}_x{}", self.id, i)],
1929 token_count: 0,
1930 }],
1931 meta_prefix: None,
1932 label: None,
1933 })
1934 .collect();
1935 Ok(SourceSnapshot {
1936 records,
1937 cursor: SourceCursor {
1938 last_seen: now,
1939 revision: 1,
1940 },
1941 })
1942 }
1943 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1944 Ok(8)
1945 }
1946 }
1947
1948 let config = SamplerConfig {
1949 batch_size: 2,
1950 text_recipes: vec![TextRecipe {
1951 name: "anchor".into(),
1952 selector: Selector::Role(SectionRole::Anchor),
1953 weight: 1.0,
1954 instruction: None,
1955 }],
1956 split: SplitRatios {
1957 train: 1.0,
1958 validation: 0.0,
1959 test: 0.0,
1960 },
1961 allowed_splits: vec![SplitLabel::Train],
1962 ingestion_max_records: 4,
1968 ..SamplerConfig::default()
1969 };
1970 let store = Arc::new(DeterministicSplitStore::new(config.split, 99).unwrap());
1971 let sampler = TripletSampler::new(config, store);
1972
1973 for (i, count) in counts.iter().enumerate() {
1974 sampler
1975 .register_source(Box::new(Tracked {
1976 id: format!("src_{i}"),
1977 refresh_count: Arc::clone(count),
1978 }))
1979 .unwrap();
1980 }
1981 sampler
1982 }
1983
1984 #[test]
1985 fn sampler_unweighted_drain_distributes_evenly() {
1986 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1987 let sampler = make_five_source_sampler(&counts);
1988
1989 sampler.next_text_batch(SplitLabel::Train).unwrap();
1990 for _ in 0..80 {
1991 sampler.next_text_batch(SplitLabel::Train).unwrap();
1992 }
1993
1994 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1995 let min = *totals.iter().min().unwrap();
1996 let max = *totals.iter().max().unwrap();
1997 assert!(
1998 max <= min + 1,
1999 "unweighted: all sources must refresh at roughly the same rate: {totals:?}"
2000 );
2001 assert!(
2002 min >= 4,
2003 "unweighted: each source should have refreshed at least 4 times: {totals:?}"
2004 );
2005 }
2006
2007 #[test]
2008 fn sampler_weighted_drain_with_equal_weights_distributes_evenly() {
2009 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2013 let sampler = make_five_source_sampler(&counts);
2014
2015 let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
2016
2017 sampler
2018 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2019 .unwrap();
2020 for _ in 0..80 {
2021 sampler
2022 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2023 .unwrap();
2024 }
2025
2026 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2027 let min = *totals.iter().min().unwrap();
2028 let max = *totals.iter().max().unwrap();
2029 assert!(
2030 max <= min + 1,
2031 "weighted (equal): all sources must refresh at roughly the same rate: {totals:?}"
2032 );
2033 assert!(
2034 min >= 4,
2035 "weighted (equal): each source should have refreshed at least 4 times: {totals:?}"
2036 );
2037 }
2038
2039 #[test]
2040 fn sampler_unweighted_and_weighted_match_distribution() {
2041 let uc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2043 let wc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2044 let usampler = make_five_source_sampler(&uc);
2045 let wsampler = make_five_source_sampler(&wc);
2046
2047 let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
2048
2049 usampler.next_text_batch(SplitLabel::Train).unwrap();
2050 wsampler
2051 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2052 .unwrap();
2053 for _ in 0..80 {
2054 usampler.next_text_batch(SplitLabel::Train).unwrap();
2055 wsampler
2056 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2057 .unwrap();
2058 }
2059
2060 let ut: Vec<usize> = uc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2061 let wt: Vec<usize> = wc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2062 let umax = *ut.iter().max().unwrap();
2063 let umin = *ut.iter().min().unwrap();
2064 let wmax = *wt.iter().max().unwrap();
2065 let wmin = *wt.iter().min().unwrap();
2066 assert!(umax <= umin + 1, "unweighted: {ut:?}");
2067 assert!(wmax <= wmin + 1, "weighted equal: {wt:?}");
2068 }
2069
2070 #[test]
2071 fn sampler_weighted_drain_with_unequal_weights_respects_ratios() {
2072 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2076 let sampler = make_five_source_sampler(&counts);
2077
2078 let mut weights = HashMap::new();
2079 for i in 0..5 {
2080 weights.insert(format!("src_{i}"), if i == 3 { 2.0f32 } else { 1.0 });
2081 }
2082
2083 sampler
2084 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2085 .unwrap();
2086 for _ in 0..200 {
2087 sampler
2088 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2089 .unwrap();
2090 }
2091
2092 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2093 assert!(
2101 totals
2102 .iter()
2103 .enumerate()
2104 .all(|(i, &t)| i == 3 || t < totals[3]),
2105 "src_3 (w=2.0) must outpace all w=1.0 sources (totals: {totals:?})"
2106 );
2107 assert_eq!(
2116 totals,
2117 vec![6, 6, 6, 26, 11],
2118 "unequal-weights: unexpected refresh distribution"
2119 );
2120 }
2121
2122 #[test]
2123 fn register_source_rejects_reserved_id_pattern() {
2124 use crate::constants::splits::is_reserved_source_id;
2125
2126 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2127
2128 assert!(is_reserved_source_id("__meta__"));
2130 assert!(is_reserved_source_id("__anything__"));
2131 assert!(is_reserved_source_id("__x__"));
2132 assert!(!is_reserved_source_id(""));
2133 assert!(!is_reserved_source_id("__"));
2134 assert!(!is_reserved_source_id("___"));
2135 assert!(!is_reserved_source_id("normal_source"));
2136 assert!(!is_reserved_source_id("_prefix_suffix_"));
2137 assert!(!is_reserved_source_id("__unclosed"));
2138 assert!(!is_reserved_source_id("unopened__"));
2139
2140 let result = manager.register_source(Box::new(ScriptedSource::new(
2142 "__reserved__",
2143 Arc::new(AtomicUsize::new(0)),
2144 vec![],
2145 )));
2146 assert!(
2147 result.is_err(),
2148 "register_source should reject reserved source id"
2149 );
2150 let err = result.unwrap_err();
2151 assert!(
2152 matches!(&err, SamplerError::ReservedSourceId(id) if id == "__reserved__"),
2153 "expected ReservedSourceId error, got: {err}"
2154 );
2155
2156 assert!(!manager.has_sources());
2158
2159 manager
2161 .register_source(Box::new(ScriptedSource::new(
2162 "valid_source",
2163 Arc::new(AtomicUsize::new(0)),
2164 vec![],
2165 )))
2166 .unwrap();
2167 assert!(manager.has_sources());
2168 }
2169
2170 #[test]
2171 fn record_cache_ingest_with_version_empty_batch_is_noop() {
2172 let cache = RecordCache::new(4);
2173 cache.ingest_with_version(Vec::<DataRecord>::new(), 99);
2174 assert!(cache.is_empty());
2175 assert_eq!(cache.ingest_count(), 0);
2176 }
2177
2178 #[test]
2179 fn record_cache_ingest_with_version_assigns_explicit_version() {
2180 let cache = RecordCache::new(4);
2181 cache.ingest_with_version(vec![make_record("v1", "s")], 10);
2182 assert_eq!(cache.len(), 1);
2183 let (_, added, evicted) = cache.sync_delta(0);
2185 assert_eq!(added.len(), 1);
2186 assert!(evicted.is_empty());
2187 assert_eq!(added[0].id, "v1");
2188 }
2189
2190 #[test]
2191 fn record_cache_sync_delta_returns_nothing_when_fully_consumed() {
2192 let cache = RecordCache::new(4);
2193 cache.ingest_with_version(vec![make_record("a", "s")], 5);
2194 let (v1, added1, _) = cache.sync_delta(0);
2196 assert_eq!(added1.len(), 1);
2197 let (v2, added2, evicted2) = cache.sync_delta(v1);
2199 assert!(added2.is_empty());
2200 assert!(evicted2.is_empty());
2201 assert_eq!(v2, v1);
2202 }
2203
2204 #[test]
2205 fn record_cache_sync_delta_reports_evictions_from_clear() {
2206 let cache = RecordCache::new(4);
2207 cache.ingest(vec![make_record("e1", "s"), make_record("e2", "s")]);
2208 let (v1, _, _) = cache.sync_delta(0);
2210 assert!(v1 > 0);
2211 cache.clear();
2213 let (_, added, evicted) = cache.sync_delta(v1);
2214 assert!(added.is_empty());
2215 assert_eq!(evicted.len(), 2);
2216 assert!(evicted.contains(&"e1".to_string()));
2217 assert!(evicted.contains(&"e2".to_string()));
2218 }
2219
2220 #[test]
2221 fn record_cache_sync_delta_filters_evictions_by_version_watermark() {
2222 let cache = RecordCache::new(4);
2223 cache.ingest_with_version(vec![make_record("old", "s")], 1);
2224 let (v1, _, _) = cache.sync_delta(0);
2225 assert!(v1 >= 1);
2226 cache.clear();
2227 let (_, _, evicted_at_v1) = cache.sync_delta(v1);
2229 assert_eq!(evicted_at_v1.len(), 1);
2230 let (_, _, evicted_at_higher) = cache.sync_delta(v1 + 1);
2232 assert!(evicted_at_higher.is_empty());
2233 }
2234
2235 #[test]
2236 fn record_cache_enforce_limit_trims_fifo_and_logs_evictions() {
2237 let cache = RecordCache::new(2);
2238 cache.ingest(vec![
2239 make_record("first", "s"),
2240 make_record("second", "s"),
2241 make_record("third", "s"),
2242 ]);
2243 assert_eq!(cache.len(), 2);
2245 let (v1, _, _) = cache.sync_delta(0);
2247 cache.ingest(vec![make_record("fourth", "s")]);
2249 let (_, _, evicted) = cache.sync_delta(v1);
2250 assert!(!evicted.is_empty());
2253 }
2254
2255 #[test]
2256 fn manager_force_refresh_generation_and_global_version() {
2257 let refreshes = Arc::new(AtomicUsize::new(0));
2258 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2259 assert_eq!(manager.force_refresh_generation(), 0);
2260 assert_eq!(manager.global_version(), 0);
2261
2262 manager
2263 .register_source(Box::new(ScriptedSource::new(
2264 "gen_src",
2265 refreshes,
2266 vec![Ok(SourceSnapshot {
2267 records: vec![make_record("g1", "gen_src")],
2268 cursor: SourceCursor {
2269 last_seen: Utc::now(),
2270 revision: 1,
2271 },
2272 })],
2273 )))
2274 .unwrap();
2275
2276 manager.refresh_all();
2277 assert_eq!(manager.force_refresh_generation(), 0);
2278 assert!(manager.global_version() > 0);
2279
2280 let v_before = manager.global_version();
2281 manager.force_refresh_all();
2282 assert_eq!(manager.force_refresh_generation(), 1);
2283 assert!(manager.global_version() > v_before);
2284 }
2285
2286 #[test]
2287 fn manager_sync_delta_aggregates_across_sources() {
2288 let refreshes = Arc::new(AtomicUsize::new(0));
2289 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2290 manager
2291 .register_source(Box::new(ScriptedSource::new(
2292 "agg_a",
2293 refreshes.clone(),
2294 vec![Ok(SourceSnapshot {
2295 records: vec![make_record("a1", "agg_a")],
2296 cursor: SourceCursor {
2297 last_seen: Utc::now(),
2298 revision: 1,
2299 },
2300 })],
2301 )))
2302 .unwrap();
2303 manager
2304 .register_source(Box::new(ScriptedSource::new(
2305 "agg_b",
2306 refreshes,
2307 vec![Ok(SourceSnapshot {
2308 records: vec![make_record("b1", "agg_b")],
2309 cursor: SourceCursor {
2310 last_seen: Utc::now(),
2311 revision: 1,
2312 },
2313 })],
2314 )))
2315 .unwrap();
2316
2317 manager.refresh_all();
2318 let (version, added, evicted) = manager.sync_delta(0);
2319 assert!(version > 0);
2320 assert_eq!(added.len(), 2);
2321 let ids: Vec<&str> = added.iter().map(|r| r.id.as_str()).collect();
2322 assert!(ids.contains(&"a1"));
2323 assert!(ids.contains(&"b1"));
2324 assert!(evicted.is_empty());
2325
2326 let (_, added2, evicted2) = manager.sync_delta(version);
2328 assert!(added2.is_empty());
2329 assert!(evicted2.is_empty());
2330 }
2331
2332 #[test]
2333 fn manager_source_refresh_stats_returns_telemetry() {
2334 let refreshes = Arc::new(AtomicUsize::new(0));
2335 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2336 manager
2337 .register_source(Box::new(ScriptedSource::new(
2338 "stats_src",
2339 refreshes,
2340 vec![Ok(SourceSnapshot {
2341 records: vec![make_record("s1", "stats_src")],
2342 cursor: SourceCursor {
2343 last_seen: Utc::now(),
2344 revision: 1,
2345 },
2346 })],
2347 )))
2348 .unwrap();
2349
2350 let stats = manager.source_refresh_stats();
2351 assert_eq!(stats.len(), 1);
2352 assert_eq!(stats[0].0, "stats_src");
2353 assert_eq!(stats[0].1.last_refresh_ms, 0);
2354
2355 manager.refresh_all();
2356 let stats = manager.source_refresh_stats();
2357 assert_eq!(stats[0].1.last_record_count, 1);
2358 }
2359
2360 #[test]
2361 fn record_cache_ingest_batch_replaces_existing_record() {
2362 let cache = RecordCache::new(4);
2363 cache.ingest(vec![make_record("dup", "s")]);
2365 assert_eq!(cache.len(), 1);
2366 cache.ingest(vec![make_record("dup", "s")]);
2368 assert_eq!(cache.len(), 1);
2369 }
2370
2371 #[test]
2372 fn record_cache_ingest_with_version_replaces_existing_record() {
2373 let cache = RecordCache::new(4);
2374 cache.ingest_with_version(vec![make_record("vr", "s")], 5);
2375 let (v1, _, _) = cache.sync_delta(0);
2376 assert_eq!(cache.len(), 1);
2377
2378 cache.ingest_with_version(vec![make_record("vr", "s")], v1 + 5);
2380 let (_, added, evicted) = cache.sync_delta(v1);
2381 assert_eq!(added.len(), 1);
2382 assert!(evicted.is_empty());
2383 assert_eq!(cache.len(), 1);
2384 }
2385
2386 #[test]
2387 fn record_cache_enforce_limit_evicts_oldest_and_trims_eviction_log() {
2388 let cache = RecordCache::new(3);
2391 for i in 0..5 {
2392 cache.ingest(vec![make_record(&format!("r{i}"), "s")]);
2393 }
2394 assert_eq!(cache.len(), 3);
2395
2396 let (v1, _, _) = cache.sync_delta(0);
2398
2399 let (_, added, evicted) = cache.sync_delta(v1);
2402 assert!(added.len() + evicted.len() <= 5);
2405 }
2406
2407 #[test]
2408 fn weighted_drain_breaks_when_all_buffers_empty() {
2409 let refreshes = Arc::new(AtomicUsize::new(0));
2410 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2411 manager
2413 .register_source(Box::new(ScriptedSource::new(
2414 "empty_buf",
2415 refreshes,
2416 vec![],
2417 )))
2418 .unwrap();
2419
2420 let mut weights = HashMap::new();
2421 weights.insert("empty_buf".to_string(), 1.0);
2422 manager.advance_with_weights(1, &weights).unwrap();
2425 assert_eq!(manager.all_records_len(), 0);
2426 }
2427
2428 #[test]
2429 fn wait_for_ingest_times_out_without_notification() {
2430 let cache = RecordCache::new(4);
2431 let result = cache.wait_for_ingest(0, Duration::from_millis(1));
2433 assert_eq!(result, 0);
2434 }
2435}