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, RecordKey, 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 pub(crate) fn validate_weights(
570 &self,
571 weights: &HashMap<SourceId, f32>,
572 ) -> Result<(), SamplerError> {
573 let known_ids: std::collections::HashSet<&str> =
574 self.sources.iter().map(|s| s.source.id()).collect();
575 for (id, &w) in weights {
576 if !known_ids.contains(id.as_str()) {
577 return Err(SamplerError::InvalidWeight {
578 source_id: id.clone(),
579 reason: "source is not registered".to_string(),
580 });
581 }
582 if w < 0.0 {
583 return Err(SamplerError::InvalidWeight {
584 source_id: id.clone(),
585 reason: format!("weight {w} is negative"),
586 });
587 }
588 }
589 Ok(())
590 }
591
592 fn refresh_all_internal(
599 &mut self,
600 force_refresh: bool,
601 step: Option<usize>,
602 weights: Option<&HashMap<SourceId, f32>>,
603 ) {
604 self.last_refreshed_sources.clear();
605 if force_refresh {
606 self.force_refresh_generation = self.force_refresh_generation.saturating_add(1);
610 }
611 let mut refresh_plan = Vec::new();
612 for (idx, state) in self.sources.iter_mut().enumerate() {
613 if force_refresh {
614 state.buffer.clear();
615 }
616 if force_refresh || state.buffer.is_empty() {
617 refresh_plan.push((idx, state.cursor.clone()));
618 }
619 }
620
621 if !refresh_plan.is_empty() {
622 self.source_refresh_generation = self.source_refresh_generation.saturating_add(1);
623 self.last_refreshed_sources = refresh_plan
624 .iter()
625 .map(|(idx, _)| self.sources[*idx].source.id().to_string())
626 .collect();
627 let mut results: Vec<
628 Option<(Result<SourceSnapshot, SamplerError>, std::time::Duration)>,
629 > = Vec::with_capacity(self.sources.len());
630 results.resize_with(self.sources.len(), || None);
631 let fetch_limit = self.max_records;
632 let sampler_config = self.sampler_config.clone();
633 let step = self.epoch_step;
634 thread::scope(|scope| {
635 let mut handles = Vec::with_capacity(refresh_plan.len());
636 for (idx, cursor) in &refresh_plan {
637 let source = &self.sources[*idx].source;
638 let cursor = cursor.clone();
639 let sampler_config = sampler_config.clone();
640 let epoch = self.epoch;
641 handles.push((
642 *idx,
643 scope.spawn(move || {
644 let start = std::time::Instant::now();
645 let epoch_config = SamplerConfig {
651 seed: derive_epoch_seed(sampler_config.seed, epoch) ^ step,
652 ..sampler_config
653 };
654 let result =
655 source.refresh(&epoch_config, cursor.as_ref(), Some(fetch_limit));
656 let elapsed = start.elapsed();
657 (result, elapsed)
658 }),
659 ));
660 }
661 for (idx, handle) in handles {
662 let result = match handle.join() {
663 Ok((result, elapsed)) => {
664 debug!(
665 source_id = %self.sources[idx].source.id(),
666 refresh_ms = elapsed.as_millis(),
667 "source refresh completed"
668 );
669 (result, elapsed)
670 }
671 Err(_) => (
672 Err(SamplerError::SourceUnavailable {
673 source_id: self.sources[idx].source.id().to_string(),
674 reason: "source refresh thread panicked".into(),
675 }),
676 std::time::Duration::from_secs(0),
677 ),
678 };
679 results[idx] = Some(result);
680 }
681 });
682
683 for (idx, _) in refresh_plan {
684 let Some((result, elapsed)) = results[idx].take() else {
685 continue;
686 };
687 match result {
688 Ok(snapshot) => {
689 let SourceSnapshot {
690 records,
691 cursor: next_cursor,
692 } = snapshot;
693 let record_count = records.len();
694 let seconds = elapsed.as_secs_f64();
695 let per_sec = if seconds > 0.0 {
696 (record_count as f64) / seconds
697 } else {
698 0.0
699 };
700 let stats = &mut self.sources[idx].stats;
701 stats.last_refresh_ms = elapsed.as_millis();
702 stats.last_record_count = record_count;
703 stats.last_records_per_sec = per_sec;
704 stats.last_error = None;
705 debug!(
706 source_id = %self.sources[idx].source.id(),
707 record_count,
708 refresh_ms = elapsed.as_millis(),
709 records_per_sec = per_sec,
710 "source refresh ingested records"
711 );
712 let source_id = self.sources[idx].source.id().to_string();
713 let normalized = records
714 .into_iter()
715 .map(|mut record| {
716 record.source = source_id.clone();
717 record
718 })
719 .collect::<Vec<_>>();
720 self.sources[idx].buffer.extend(normalized);
721 self.sources[idx].cursor = Some(next_cursor);
722 }
723 Err(err) => {
724 let stats = &mut self.sources[idx].stats;
725 stats.last_refresh_ms = elapsed.as_millis();
726 stats.last_record_count = 0;
727 stats.last_records_per_sec = 0.0;
728 stats.last_error = Some(err.to_string());
729 stats.error_count = stats.error_count.saturating_add(1);
730 warn!(
731 source = %self.sources[idx].source.id(),
732 error = %err,
733 "Source refresh failed"
734 );
735 }
736 }
737 }
738 }
739
740 if step.is_none() {
744 for state in self.sources.iter_mut() {
745 state.cache.clear();
746 }
747 }
748 if self.max_records == 0 {
749 return;
750 }
751 let target_limit = step.unwrap_or(self.max_records);
752 if let Some(weights) = weights {
753 self.weighted_drain_into_caches(target_limit, weights);
754 } else {
755 let n = self.sources.len();
760 if n > 0 {
761 let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); n];
762 let mut total_drained = 0;
763 let mut any_remaining = true;
764 while total_drained < target_limit && any_remaining {
765 any_remaining = false;
766 for offset in 0..n {
767 if total_drained >= target_limit {
768 break;
769 }
770 let idx = (self.drain_start + offset) % n;
771 if let Some(record) = self.sources[idx].buffer.pop_front() {
772 per_source[idx].push(record);
773 total_drained += 1;
774 any_remaining = true;
775 }
776 }
777 }
778 if total_drained > 0 {
782 self.drain_start = (self.drain_start + 1) % n;
783 }
784 self.global_next_version = self.global_next_version.saturating_add(1);
786 for (idx, batch) in per_source.into_iter().enumerate() {
787 if !batch.is_empty() {
788 self.sources[idx]
789 .cache
790 .ingest_with_version(batch, self.global_next_version);
791 }
792 }
793 }
794 }
795 }
796
797 fn weighted_drain_into_caches(&mut self, limit: usize, weights: &HashMap<SourceId, f32>) {
798 let len = self.sources.len();
799 if len == 0 {
800 return;
801 }
802 let mut weight_values = Vec::with_capacity(len);
803 let mut any_positive = false;
804 for state in &self.sources {
805 let weight = weights.get(state.source.id()).copied().unwrap_or(1.0);
806 if weight > 0.0 {
807 any_positive = true;
808 }
809 weight_values.push(weight);
810 }
811 if !any_positive {
812 weight_values.fill(1.0);
813 }
814
815 let mut current = vec![0.0f32; len];
816 let mut per_source: Vec<Vec<DataRecord>> = vec![Vec::new(); len];
817 let mut total = 0;
818 while total < limit {
819 let mut total_weight = 0.0f32;
820 for (idx, weight) in weight_values.iter().copied().enumerate().take(len) {
821 if weight <= 0.0 {
822 continue;
823 }
824 if self.sources[idx].buffer.is_empty() {
825 continue;
826 }
827 total_weight += weight;
828 }
829 if total_weight == 0.0 {
830 break;
831 }
832
833 let mut best_idx = None;
834 let mut best_score = f32::MIN;
835 let closer_to_start = |a: usize, b: usize| -> bool {
839 let da = (a + len - self.drain_start) % len;
840 let db = (b + len - self.drain_start) % len;
841 da < db
842 };
843 for idx in 0..len {
844 if weight_values[idx] <= 0.0 {
845 continue;
846 }
847 if self.sources[idx].buffer.is_empty() {
848 continue;
849 }
850 current[idx] += weight_values[idx];
851 let is_better = if current[idx] > best_score {
852 true
853 } else if current[idx] == best_score {
854 closer_to_start(idx, best_idx.unwrap_or(0))
855 } else {
856 false
857 };
858 if is_better {
859 best_score = current[idx];
860 best_idx = Some(idx);
861 }
862 }
863
864 let idx = match best_idx {
865 Some(idx) => idx,
866 None => break,
867 };
868 current[idx] -= total_weight;
869 if let Some(record) = self.sources[idx].buffer.pop_front() {
870 per_source[idx].push(record);
871 total += 1;
872 }
873 }
874
875 if total > 0 && len > 0 {
876 self.drain_start = (self.drain_start + 1) % len;
877 }
878
879 self.global_next_version = self.global_next_version.saturating_add(1);
881 for (idx, batch) in per_source.into_iter().enumerate() {
882 if !batch.is_empty() {
883 self.sources[idx]
884 .cache
885 .ingest_with_version(batch, self.global_next_version);
886 }
887 }
888 }
889
890 pub fn has_sources(&self) -> bool {
892 !self.sources.is_empty()
893 }
894
895 pub fn sync_delta(&mut self, last_version: u64) -> (u64, Vec<Arc<DataRecord>>, Vec<RecordKey>) {
901 let mut all_added = Vec::new();
902 let mut all_evicted = Vec::new();
903 let mut max_version = last_version;
904
905 for state in &mut self.sources {
906 let (new_version, added, evicted) = state.cache.sync_delta(last_version);
907 if new_version > max_version {
908 max_version = new_version;
909 }
910 all_added.extend(added);
911 all_evicted.extend(
912 evicted
913 .into_iter()
914 .map(|id| RecordKey::new(state.source.id(), id)),
915 );
916 }
917
918 (max_version, all_added, all_evicted)
919 }
920
921 pub fn global_version(&self) -> u64 {
923 self.global_next_version
924 }
925}
926
927struct SourceState {
929 source: Box<dyn DataSource + 'static>,
930 cursor: Option<SourceCursor>,
931 buffer: VecDeque<DataRecord>,
932 cache: RecordCache,
934 stats: SourceRefreshStats,
935}
936
937#[cfg(test)]
938#[allow(deprecated)]
943mod tests {
944 use super::*;
945 use crate::TripletSampler;
946 use crate::config::{Selector, TextRecipe, TripletRecipe};
947 use crate::data::{QualityScore, RecordSection, SectionRole};
948 use crate::sampler::Sampler;
949 use crate::splits::{DeterministicSplitStore, SamplerStateStore, SplitLabel, SplitRatios};
950 use chrono::Utc;
951 use std::collections::HashMap;
952 use std::collections::VecDeque;
953 use std::sync::atomic::{AtomicUsize, Ordering};
954 use std::sync::{Arc, Mutex};
955
956 fn make_record(id: &str, source: &str) -> DataRecord {
957 let now = Utc::now();
958 DataRecord {
959 id: id.to_string(),
960 source: source.to_string(),
961 created_at: now,
962 updated_at: now,
963 quality: QualityScore { trust: 1.0 },
964 taxonomy: Vec::new(),
965 sections: vec![RecordSection {
966 role: SectionRole::Anchor,
967 heading: None,
968 text: id.to_string(),
969 sentences: vec![id.to_string()],
970 token_count: 0,
971 embedding: None,
972 }],
973 meta_prefix: None,
974 label: None,
975 }
976 }
977
978 struct ScriptedSource {
979 id: String,
980 refreshes: Arc<AtomicUsize>,
981 script: Arc<Mutex<VecDeque<Result<SourceSnapshot, SamplerError>>>>,
982 }
983
984 impl ScriptedSource {
985 fn new(
986 id: &str,
987 refreshes: Arc<AtomicUsize>,
988 script: Vec<Result<SourceSnapshot, SamplerError>>,
989 ) -> Self {
990 Self {
991 id: id.to_string(),
992 refreshes,
993 script: Arc::new(Mutex::new(script.into_iter().collect())),
994 }
995 }
996 }
997
998 impl DataSource for ScriptedSource {
999 fn id(&self) -> &str {
1000 &self.id
1001 }
1002
1003 fn refresh(
1004 &self,
1005 _config: &SamplerConfig,
1006 _cursor: Option<&SourceCursor>,
1007 _limit: Option<usize>,
1008 ) -> Result<SourceSnapshot, SamplerError> {
1009 self.refreshes.fetch_add(1, Ordering::SeqCst);
1010 let mut guard = self.script.lock().expect("script lock poisoned");
1011 guard.pop_front().unwrap_or_else(|| {
1012 Ok(SourceSnapshot {
1013 records: Vec::new(),
1014 cursor: SourceCursor {
1015 last_seen: Utc::now(),
1016 revision: 0,
1017 },
1018 })
1019 })
1020 }
1021
1022 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1023 Ok(0)
1024 }
1025
1026 fn default_triplet_recipes(&self) -> Vec<TripletRecipe> {
1027 Vec::new()
1028 }
1029 }
1030
1031 struct PanicSource {
1032 id: String,
1033 }
1034
1035 impl DataSource for PanicSource {
1036 fn id(&self) -> &str {
1037 &self.id
1038 }
1039
1040 fn refresh(
1041 &self,
1042 _config: &SamplerConfig,
1043 _cursor: Option<&SourceCursor>,
1044 _limit: Option<usize>,
1045 ) -> Result<SourceSnapshot, SamplerError> {
1046 panic!("panic source refresh")
1047 }
1048
1049 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1050 Ok(0)
1051 }
1052 }
1053
1054 #[test]
1055 fn record_cache_waits_len_and_clear_paths_are_covered() {
1056 let cache = RecordCache::new(2);
1057 assert!(cache.is_empty());
1058 assert_eq!(cache.len(), 0);
1059 assert_eq!(cache.ingest_count(), 0);
1060
1061 cache.ingest(Vec::<DataRecord>::new());
1062 assert_eq!(cache.wait_for_ingest(0, Duration::from_millis(1)), 0);
1063
1064 let cache_for_waiter = cache.clone();
1065 let handle = std::thread::spawn(move || cache_for_waiter.wait_for_ingest_blocking(0));
1066 std::thread::sleep(Duration::from_millis(5));
1067 cache.ingest(vec![make_record("r1", "s")]);
1068 assert_eq!(handle.join().unwrap(), 1);
1069 assert_eq!(cache.ingest_count(), 1);
1070
1071 cache.ingest(vec![make_record("r2", "s"), make_record("r3", "s")]);
1072 assert_eq!(cache.len(), 2);
1073 let ids: Vec<String> = cache
1074 .snapshot()
1075 .into_iter()
1076 .map(|record| record.id.clone())
1077 .collect();
1078 assert!(ids.contains(&"r2".to_string()));
1079 assert!(ids.contains(&"r3".to_string()));
1080
1081 cache.clear();
1082 assert!(cache.is_empty());
1083 }
1084
1085 #[test]
1086 fn record_cache_zero_limit_discards_everything() {
1087 let cache = RecordCache::new(0);
1088 cache.ingest(vec![make_record("r1", "s")]);
1089 assert!(cache.is_empty());
1090 assert_eq!(cache.len(), 0);
1091 }
1092
1093 #[test]
1094 fn manager_loads_and_snapshots_cursors_and_reports_has_sources() {
1095 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1096 assert!(!manager.has_sources());
1097 manager.load_cursors(&[]);
1098
1099 let refreshes = Arc::new(AtomicUsize::new(0));
1100 manager
1101 .register_source(Box::new(ScriptedSource::new(
1102 "cursor_source",
1103 refreshes,
1104 vec![Ok(SourceSnapshot {
1105 records: vec![make_record("id_1", "original_source")],
1106 cursor: SourceCursor {
1107 last_seen: Utc::now(),
1108 revision: 33,
1109 },
1110 })],
1111 )))
1112 .unwrap();
1113 assert!(manager.has_sources());
1114
1115 manager.load_cursors(&[("cursor_source".to_string(), 7)]);
1116 let cursors = manager.snapshot_cursors();
1117 assert_eq!(cursors.len(), 1);
1118 assert_eq!(cursors[0], ("cursor_source".to_string(), 7));
1119
1120 manager.refresh_all();
1121 let updated = manager.snapshot_cursors();
1122 assert_eq!(updated.len(), 1);
1123 assert_eq!(updated[0], ("cursor_source".to_string(), 33));
1124 let records = manager.all_records_snapshot();
1125 assert_eq!(records.len(), 1);
1126 assert_eq!(records[0].source, "cursor_source");
1127 }
1128
1129 #[test]
1130 fn advance_uses_buffer_before_refreshing_again() {
1131 let refreshes = Arc::new(AtomicUsize::new(0));
1132 let mut manager = IngestionManager::new(5, SamplerConfig::default());
1133 manager
1134 .register_source(Box::new(ScriptedSource::new(
1135 "buffered",
1136 refreshes.clone(),
1137 vec![Ok(SourceSnapshot {
1138 records: vec![
1139 make_record("a", "x"),
1140 make_record("b", "x"),
1141 make_record("c", "x"),
1142 ],
1143 cursor: SourceCursor {
1144 last_seen: Utc::now(),
1145 revision: 1,
1146 },
1147 })],
1148 )))
1149 .unwrap();
1150
1151 manager.advance(1);
1152 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1153 assert_eq!(manager.all_records_len(), 1);
1154
1155 manager.advance(1);
1156 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1157 assert_eq!(manager.all_records_len(), 2);
1158 }
1159
1160 #[test]
1161 fn force_refresh_clears_buffer_and_fetches_again() {
1162 let refreshes = Arc::new(AtomicUsize::new(0));
1163 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1164 manager
1165 .register_source(Box::new(ScriptedSource::new(
1166 "force",
1167 refreshes.clone(),
1168 vec![
1169 Ok(SourceSnapshot {
1170 records: vec![
1171 make_record("r1", "x"),
1172 make_record("r2", "x"),
1173 make_record("r3", "x"),
1174 ],
1175 cursor: SourceCursor {
1176 last_seen: Utc::now(),
1177 revision: 10,
1178 },
1179 }),
1180 Ok(SourceSnapshot {
1181 records: vec![make_record("r4", "x")],
1182 cursor: SourceCursor {
1183 last_seen: Utc::now(),
1184 revision: 11,
1185 },
1186 }),
1187 ],
1188 )))
1189 .unwrap();
1190
1191 manager.advance(1);
1192 assert_eq!(manager.all_records_len(), 1);
1193 assert_eq!(refreshes.load(Ordering::SeqCst), 1);
1194
1195 manager.force_refresh_all();
1196 assert_eq!(refreshes.load(Ordering::SeqCst), 2);
1197 let records = manager.all_records_snapshot();
1198 assert_eq!(records.len(), 1);
1199 assert_eq!(records[0].id, "r4");
1200 }
1201
1202 #[test]
1203 fn weighted_drain_respects_zero_and_fallback_weights() {
1204 let mut manager = IngestionManager::new(6, SamplerConfig::default());
1205 manager
1206 .register_source(Box::new(ScriptedSource::new(
1207 "a",
1208 Arc::new(AtomicUsize::new(0)),
1209 vec![Ok(SourceSnapshot {
1210 records: vec![make_record("a1", "a"), make_record("a2", "a")],
1211 cursor: SourceCursor {
1212 last_seen: Utc::now(),
1213 revision: 1,
1214 },
1215 })],
1216 )))
1217 .unwrap();
1218 manager
1219 .register_source(Box::new(ScriptedSource::new(
1220 "b",
1221 Arc::new(AtomicUsize::new(0)),
1222 vec![Ok(SourceSnapshot {
1223 records: vec![make_record("b1", "b"), make_record("b2", "b")],
1224 cursor: SourceCursor {
1225 last_seen: Utc::now(),
1226 revision: 1,
1227 },
1228 })],
1229 )))
1230 .unwrap();
1231
1232 let mut only_b = HashMap::new();
1233 only_b.insert("a".to_string(), 0.0);
1234 only_b.insert("b".to_string(), 1.0);
1235 manager.refresh_all_with_weights(&only_b).unwrap();
1236 let ids: Vec<String> = manager
1237 .all_records_snapshot()
1238 .into_iter()
1239 .map(|record| record.id.clone())
1240 .collect();
1241 assert!(ids.iter().all(|id| id.starts_with('b')));
1242
1243 let mut manager_fallback = IngestionManager::new(6, SamplerConfig::default());
1244 manager_fallback
1245 .register_source(Box::new(ScriptedSource::new(
1246 "a",
1247 Arc::new(AtomicUsize::new(0)),
1248 vec![Ok(SourceSnapshot {
1249 records: vec![make_record("a1", "a")],
1250 cursor: SourceCursor {
1251 last_seen: Utc::now(),
1252 revision: 2,
1253 },
1254 })],
1255 )))
1256 .unwrap();
1257 manager_fallback
1258 .register_source(Box::new(ScriptedSource::new(
1259 "b",
1260 Arc::new(AtomicUsize::new(0)),
1261 vec![Ok(SourceSnapshot {
1262 records: vec![make_record("b1", "b")],
1263 cursor: SourceCursor {
1264 last_seen: Utc::now(),
1265 revision: 2,
1266 },
1267 })],
1268 )))
1269 .unwrap();
1270
1271 let mut all_zero = HashMap::new();
1272 all_zero.insert("a".to_string(), 0.0);
1273 all_zero.insert("b".to_string(), 0.0);
1274 manager_fallback
1275 .refresh_all_with_weights(&all_zero)
1276 .unwrap();
1277 let ids: Vec<String> = manager_fallback
1278 .all_records_snapshot()
1279 .into_iter()
1280 .map(|record| record.id.clone())
1281 .collect();
1282 assert!(ids.contains(&"a1".to_string()));
1283 assert!(ids.contains(&"b1".to_string()));
1284 }
1285
1286 #[test]
1287 fn refresh_errors_and_panics_update_source_stats() {
1288 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1289 manager
1290 .register_source(Box::new(ScriptedSource::new(
1291 "err_source",
1292 Arc::new(AtomicUsize::new(0)),
1293 vec![Err(SamplerError::SourceUnavailable {
1294 source_id: "err_source".to_string(),
1295 reason: "boom".to_string(),
1296 })],
1297 )))
1298 .unwrap();
1299 manager
1300 .register_source(Box::new(PanicSource {
1301 id: "panic_source".to_string(),
1302 }))
1303 .unwrap();
1304
1305 manager.refresh_all();
1306 let stats = manager.source_refresh_stats();
1307 let err_stats = stats
1308 .iter()
1309 .find(|(source, _)| source == "err_source")
1310 .map(|(_, stats)| stats)
1311 .unwrap();
1312 assert_eq!(err_stats.error_count, 1);
1313 assert!(
1314 err_stats
1315 .last_error
1316 .as_ref()
1317 .is_some_and(|msg| msg.contains("boom"))
1318 );
1319
1320 let panic_stats = stats
1321 .iter()
1322 .find(|(source, _)| source == "panic_source")
1323 .map(|(_, stats)| stats)
1324 .unwrap();
1325 assert_eq!(panic_stats.error_count, 1);
1326 assert!(
1327 panic_stats
1328 .last_error
1329 .as_ref()
1330 .is_some_and(|msg| msg.contains("panicked"))
1331 );
1332 }
1333
1334 #[test]
1335 fn force_refresh_with_weights_path_is_exercised() {
1336 let mut manager = IngestionManager::new(3, SamplerConfig::default());
1337 manager
1338 .register_source(Box::new(ScriptedSource::new(
1339 "w",
1340 Arc::new(AtomicUsize::new(0)),
1341 vec![Ok(SourceSnapshot {
1342 records: vec![make_record("w1", "w")],
1343 cursor: SourceCursor {
1344 last_seen: Utc::now(),
1345 revision: 3,
1346 },
1347 })],
1348 )))
1349 .unwrap();
1350
1351 let mut weights = HashMap::new();
1352 weights.insert("w".to_string(), 1.0);
1353 manager.force_refresh_all_with_weights(&weights).unwrap();
1354 assert_eq!(manager.all_records_len(), 1);
1355 }
1356
1357 #[test]
1358 fn advance_with_weights_rejects_unknown_source() {
1359 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1360 manager
1361 .register_source(Box::new(ScriptedSource::new(
1362 "known",
1363 Arc::new(AtomicUsize::new(0)),
1364 vec![],
1365 )))
1366 .unwrap();
1367
1368 let mut weights = HashMap::new();
1369 weights.insert("known".to_string(), 1.0);
1370 weights.insert("unknown".to_string(), 0.5);
1371
1372 let err = manager.advance_with_weights(1, &weights).unwrap_err();
1373 assert!(
1374 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "unknown"),
1375 "expected InvalidWeight for unknown source, got {err:?}"
1376 );
1377 }
1378
1379 #[test]
1380 fn refresh_all_with_weights_rejects_negative_weight() {
1381 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1382 manager
1383 .register_source(Box::new(ScriptedSource::new(
1384 "src",
1385 Arc::new(AtomicUsize::new(0)),
1386 vec![],
1387 )))
1388 .unwrap();
1389
1390 let mut weights = HashMap::new();
1391 weights.insert("src".to_string(), -1.0);
1392
1393 let err = manager.refresh_all_with_weights(&weights).unwrap_err();
1394 assert!(
1395 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "src"),
1396 "expected InvalidWeight for negative weight, got {err:?}"
1397 );
1398 }
1399
1400 #[test]
1401 fn force_refresh_all_with_weights_rejects_unknown_source() {
1402 let mut manager = IngestionManager::new(4, SamplerConfig::default());
1403 manager
1404 .register_source(Box::new(ScriptedSource::new(
1405 "real",
1406 Arc::new(AtomicUsize::new(0)),
1407 vec![],
1408 )))
1409 .unwrap();
1410
1411 let mut weights = HashMap::new();
1412 weights.insert("ghost".to_string(), 1.0);
1413
1414 let err = manager
1415 .force_refresh_all_with_weights(&weights)
1416 .unwrap_err();
1417 assert!(
1418 matches!(err, SamplerError::InvalidWeight { ref source_id, .. } if source_id == "ghost"),
1419 "expected InvalidWeight for unknown source, got {err:?}"
1420 );
1421 }
1422
1423 struct SeedCapturingSource {
1425 id: String,
1426 received_seeds: Arc<Mutex<Vec<u64>>>,
1427 }
1428
1429 impl SeedCapturingSource {
1430 fn new(id: &str, received_seeds: Arc<Mutex<Vec<u64>>>) -> Self {
1431 Self {
1432 id: id.to_string(),
1433 received_seeds,
1434 }
1435 }
1436 }
1437
1438 impl DataSource for SeedCapturingSource {
1439 fn id(&self) -> &str {
1440 &self.id
1441 }
1442
1443 fn refresh(
1444 &self,
1445 config: &SamplerConfig,
1446 _cursor: Option<&SourceCursor>,
1447 _limit: Option<usize>,
1448 ) -> Result<SourceSnapshot, SamplerError> {
1449 self.received_seeds
1450 .lock()
1451 .expect("seed lock poisoned")
1452 .push(config.seed);
1453 Ok(SourceSnapshot {
1454 records: Vec::new(),
1455 cursor: SourceCursor {
1456 last_seen: Utc::now(),
1457 revision: 0,
1458 },
1459 })
1460 }
1461
1462 fn reported_record_count(&self, _config: &SamplerConfig) -> Result<u128, SamplerError> {
1463 Ok(0)
1464 }
1465
1466 fn default_triplet_recipes(&self) -> Vec<crate::config::TripletRecipe> {
1467 Vec::new()
1468 }
1469 }
1470
1471 #[test]
1472 fn epoch_xor_changes_seed_received_by_source() {
1473 let base_seed = 0xDEAD_BEEF_u64;
1476 let config = SamplerConfig {
1477 seed: base_seed,
1478 ..SamplerConfig::default()
1479 };
1480
1481 let seeds_epoch0 = Arc::new(Mutex::new(Vec::<u64>::new()));
1482 let seeds_epoch1 = Arc::new(Mutex::new(Vec::<u64>::new()));
1483
1484 let mut manager = IngestionManager::new(4, config.clone());
1486 manager
1487 .register_source(Box::new(SeedCapturingSource::new(
1488 "src",
1489 Arc::clone(&seeds_epoch0),
1490 )))
1491 .unwrap();
1492 manager.refresh_all();
1494
1495 let mut manager2 = IngestionManager::new(4, config.clone());
1497 manager2
1498 .register_source(Box::new(SeedCapturingSource::new(
1499 "src",
1500 Arc::clone(&seeds_epoch1),
1501 )))
1502 .unwrap();
1503 manager2.set_epoch(1);
1504 manager2.refresh_all();
1505
1506 let received0 = seeds_epoch0.lock().unwrap();
1507 let received1 = seeds_epoch1.lock().unwrap();
1508
1509 assert!(!received0.is_empty(), "epoch-0 source was never refreshed");
1510 assert!(!received1.is_empty(), "epoch-1 source was never refreshed");
1511
1512 let seed_at_epoch0 = received0[0];
1513 let seed_at_epoch1 = received1[0];
1514
1515 assert_ne!(
1517 seed_at_epoch0, seed_at_epoch1,
1518 "epoch 0 and epoch 1 both produced seed {seed_at_epoch0:#x}; \
1519 derive_epoch_seed is not reaching the source"
1520 );
1521
1522 assert_eq!(
1526 seed_at_epoch0,
1527 derive_epoch_seed(base_seed, 0),
1528 "epoch-0 seed mismatch (epoch_step=0)"
1529 );
1530 assert_eq!(
1531 seed_at_epoch1,
1532 derive_epoch_seed(base_seed, 1),
1533 "epoch-1 seed mismatch (epoch_step=0)"
1534 );
1535 }
1536
1537 #[test]
1538 fn epoch_step_resets_on_epoch_change() {
1539 let config = SamplerConfig::default();
1542 let seeds = Arc::new(Mutex::new(Vec::new()));
1543
1544 let mut manager = IngestionManager::new(4, config.clone());
1545 manager
1546 .register_source(Box::new(SeedCapturingSource::new(
1547 "src",
1548 Arc::clone(&seeds),
1549 )))
1550 .unwrap();
1551
1552 manager.refresh_all();
1555 let step1_seed = seeds.lock().unwrap()[0];
1556 assert_eq!(
1557 step1_seed,
1558 derive_epoch_seed(config.seed, 0),
1559 "epoch 0 step 0 seed"
1560 );
1561
1562 manager.set_epoch(1);
1564 assert_eq!(manager.epoch_step(), 0);
1565 seeds.lock().unwrap().clear();
1566
1567 manager.refresh_all();
1569 let step1_epoch1 = seeds.lock().unwrap()[0];
1570 assert_eq!(
1571 step1_epoch1,
1572 derive_epoch_seed(config.seed, 1),
1573 "epoch 1 step 0 seed (must be ^0 since refresh_all no longer bumps step)"
1574 );
1575 }
1576
1577 #[test]
1578 fn epoch_step_survives_sampler_save_and_load_state() {
1579 let store = Arc::new(DeterministicSplitStore::new(SplitRatios::default(), 1).unwrap());
1585
1586 let sampler = TripletSampler::new(
1588 SamplerConfig {
1589 seed: 42,
1590 ..SamplerConfig::default()
1591 },
1592 Arc::clone(&store),
1593 );
1594 let refreshes = Arc::new(AtomicUsize::new(0));
1595 sampler
1596 .register_source(Box::new(ScriptedSource::new(
1597 "src",
1598 refreshes,
1599 vec![Ok(SourceSnapshot {
1600 records: vec![make_record("r1", "src")],
1601 cursor: SourceCursor {
1602 last_seen: Utc::now(),
1603 revision: 1,
1604 },
1605 })],
1606 )))
1607 .unwrap();
1608
1609 let _ = sampler.next_text_batch(SplitLabel::Train);
1613
1614 sampler.save_sampler_state(None).unwrap();
1616
1617 let loaded = store.load_sampler_state().unwrap().unwrap();
1619 let step_saved = loaded.epoch_step;
1620 assert!(
1621 step_saved > 0,
1622 "epoch_step must be >0 after batch call through TripletSampler"
1623 );
1624
1625 let mut manager2 = IngestionManager::new(4, SamplerConfig::default());
1627 manager2
1628 .register_source(Box::new(ScriptedSource::new(
1629 "src",
1630 Arc::new(AtomicUsize::new(0)),
1631 vec![Ok(SourceSnapshot {
1632 records: vec![make_record("r2", "src")],
1633 cursor: SourceCursor {
1634 last_seen: Utc::now(),
1635 revision: 2,
1636 },
1637 })],
1638 )))
1639 .unwrap();
1640 manager2.load_cursors(&loaded.source_stream_cursors);
1641 manager2.set_epoch_step(loaded.epoch_step);
1642
1643 let step_before = manager2.epoch_step();
1646 assert_eq!(
1647 step_before, step_saved,
1648 "load_cursors must restore epoch_step to saved value"
1649 );
1650
1651 manager2.refresh_all();
1653 let step_after = manager2.epoch_step();
1654 assert_eq!(
1655 step_after, step_saved,
1656 "epoch_step must survive refresh_all without increment (step is per-batch,
1657 not per-refresh): loaded {step_saved}, got {step_after}"
1658 );
1659 }
1660
1661 #[test]
1662 fn scripted_and_panic_sources_cover_default_trait_paths() {
1663 let refreshes = Arc::new(AtomicUsize::new(0));
1664 let scripted = ScriptedSource::new("scripted", refreshes, vec![]);
1665
1666 let snapshot = scripted
1668 .refresh(&SamplerConfig::default(), None, None)
1669 .expect("fallback snapshot");
1670 assert!(snapshot.records.is_empty());
1671 assert_eq!(snapshot.cursor.revision, 0);
1672
1673 assert_eq!(
1674 scripted
1675 .reported_record_count(&SamplerConfig::default())
1676 .expect("record count"),
1677 0
1678 );
1679 assert!(scripted.default_triplet_recipes().is_empty());
1680
1681 let panic_source = PanicSource {
1682 id: "panic_count".to_string(),
1683 };
1684 assert_eq!(
1685 panic_source
1686 .reported_record_count(&SamplerConfig::default())
1687 .expect("record count"),
1688 0
1689 );
1690 }
1691
1692 #[test]
1693 fn seed_capturing_source_trait_defaults_are_exercised() {
1694 let source = SeedCapturingSource::new("seed_defaults", Arc::new(Mutex::new(Vec::new())));
1695 assert_eq!(
1696 source
1697 .reported_record_count(&SamplerConfig::default())
1698 .expect("record count"),
1699 0
1700 );
1701 assert!(source.default_triplet_recipes().is_empty());
1702 }
1703
1704 #[test]
1705 fn refresh_paths_handle_zero_capacity_and_no_sources() {
1706 let mut manager = IngestionManager::new(0, SamplerConfig::default());
1707 manager
1708 .register_source(Box::new(ScriptedSource::new(
1709 "zero_capacity",
1710 Arc::new(AtomicUsize::new(0)),
1711 vec![Ok(SourceSnapshot {
1712 records: vec![make_record("r1", "zero_capacity")],
1713 cursor: SourceCursor {
1714 last_seen: Utc::now(),
1715 revision: 1,
1716 },
1717 })],
1718 )))
1719 .unwrap();
1720 manager.refresh_all();
1721 assert!(manager.all_caches_empty());
1722
1723 let mut empty_manager = IngestionManager::new(4, SamplerConfig::default());
1725 let empty_weights = HashMap::new();
1726 empty_manager
1727 .refresh_all_with_weights(&empty_weights)
1728 .expect("no sources should not error");
1729 assert!(empty_manager.all_caches_empty());
1730 }
1731
1732 #[test]
1733 fn drain_start_rotates_fairly_across_sources() {
1734 struct FairSource {
1738 id: String,
1739 refresh_count: Arc<AtomicUsize>,
1740 }
1741
1742 impl DataSource for FairSource {
1743 fn id(&self) -> &str {
1744 &self.id
1745 }
1746 fn refresh(
1747 &self,
1748 _config: &SamplerConfig,
1749 _cursor: Option<&SourceCursor>,
1750 _limit: Option<usize>,
1751 ) -> Result<SourceSnapshot, SamplerError> {
1752 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1753 Ok(SourceSnapshot {
1754 records: (0..10)
1755 .map(|i| make_record(&format!("r{i}"), &self.id))
1756 .collect(),
1757 cursor: SourceCursor {
1758 last_seen: Utc::now(),
1759 revision: 1,
1760 },
1761 })
1762 }
1763 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1764 Ok(10)
1765 }
1766 }
1767
1768 let counts = (
1769 Arc::new(AtomicUsize::new(0)),
1770 Arc::new(AtomicUsize::new(0)),
1771 Arc::new(AtomicUsize::new(0)),
1772 );
1773
1774 let mut manager = IngestionManager::new(30, SamplerConfig::default());
1775 manager
1776 .register_source(Box::new(FairSource {
1777 id: "src_0".to_string(),
1778 refresh_count: Arc::clone(&counts.0),
1779 }))
1780 .unwrap();
1781 manager
1782 .register_source(Box::new(FairSource {
1783 id: "src_1".to_string(),
1784 refresh_count: Arc::clone(&counts.1),
1785 }))
1786 .unwrap();
1787 manager
1788 .register_source(Box::new(FairSource {
1789 id: "src_2".to_string(),
1790 refresh_count: Arc::clone(&counts.2),
1791 }))
1792 .unwrap();
1793
1794 manager.refresh_all();
1796 assert_eq!(counts.0.load(Ordering::SeqCst), 1);
1798 assert_eq!(counts.1.load(Ordering::SeqCst), 1);
1799 assert_eq!(counts.2.load(Ordering::SeqCst), 1);
1800
1801 for _ in 0..33 {
1807 manager.advance(1);
1808 }
1809
1810 let r0 = counts.0.load(Ordering::SeqCst);
1811 let r1 = counts.1.load(Ordering::SeqCst);
1812 let r2 = counts.2.load(Ordering::SeqCst);
1813
1814 let min = r0.min(r1).min(r2);
1821 let max = r0.max(r1).max(r2);
1822 assert!(
1823 max <= min + 1,
1824 "sources should refresh at roughly the same rate: got {r0}/{r1}/{r2}"
1825 );
1826 }
1827
1828 #[test]
1829 fn direct_drain_start_rotates_fairly_with_batch_2_of_5() {
1830 struct SimpleSource {
1833 id: String,
1834 refresh_count: Arc<AtomicUsize>,
1835 }
1836
1837 impl DataSource for SimpleSource {
1838 fn id(&self) -> &str {
1839 &self.id
1840 }
1841 fn refresh(
1842 &self,
1843 _: &SamplerConfig,
1844 _: Option<&SourceCursor>,
1845 _: Option<usize>,
1846 ) -> Result<SourceSnapshot, SamplerError> {
1847 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1848 let now = Utc::now();
1849 let records: Vec<DataRecord> = (0..8)
1850 .map(|i| DataRecord {
1851 id: format!("{}_r{i}", self.id),
1852 source: self.id.clone(),
1853 created_at: now,
1854 updated_at: now,
1855 quality: QualityScore { trust: 1.0 },
1856 taxonomy: Vec::new(),
1857 sections: Vec::new(),
1858 meta_prefix: None,
1859 label: None,
1860 })
1861 .collect();
1862 Ok(SourceSnapshot {
1863 records,
1864 cursor: SourceCursor {
1865 last_seen: now,
1866 revision: 1,
1867 },
1868 })
1869 }
1870 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1871 Ok(8)
1872 }
1873 }
1874
1875 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1876 let mut manager = IngestionManager::new(40, SamplerConfig::default());
1877 for (i, count) in counts.iter().enumerate() {
1878 manager
1879 .register_source(Box::new(SimpleSource {
1880 id: format!("src_{i}"),
1881 refresh_count: Arc::clone(count),
1882 }))
1883 .unwrap();
1884 }
1885
1886 manager.refresh_all();
1887 for c in &counts {
1888 assert_eq!(c.load(Ordering::SeqCst), 1);
1889 }
1890
1891 for _ in 0..80 {
1892 manager.advance(2);
1893 }
1894
1895 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
1896 assert_eq!(
1900 totals,
1901 vec![5, 5, 6, 6, 6],
1902 "direct manager: unexpected refresh distribution"
1903 );
1904 }
1905
1906 fn make_five_source_sampler(
1909 counts: &[Arc<AtomicUsize>],
1910 ) -> TripletSampler<DeterministicSplitStore> {
1911 struct Tracked {
1912 id: String,
1913 refresh_count: Arc<AtomicUsize>,
1914 }
1915 impl DataSource for Tracked {
1916 fn id(&self) -> &str {
1917 &self.id
1918 }
1919 fn refresh(
1920 &self,
1921 _: &SamplerConfig,
1922 _: Option<&SourceCursor>,
1923 _: Option<usize>,
1924 ) -> Result<SourceSnapshot, SamplerError> {
1925 self.refresh_count.fetch_add(1, Ordering::SeqCst);
1926 let now = Utc::now();
1927 let records: Vec<DataRecord> = (0..8)
1928 .map(|i| DataRecord {
1929 id: format!("{}_r{i}", self.id),
1930 source: self.id.clone(),
1931 created_at: now,
1932 updated_at: now,
1933 quality: QualityScore { trust: 1.0 },
1934 taxonomy: Vec::new(),
1935 sections: vec![RecordSection {
1936 role: SectionRole::Anchor,
1937 heading: None,
1938 text: format!("{}_x{}", self.id, i),
1939 sentences: vec![format!("{}_x{}", self.id, i)],
1940 token_count: 0,
1941 embedding: None,
1942 }],
1943 meta_prefix: None,
1944 label: None,
1945 })
1946 .collect();
1947 Ok(SourceSnapshot {
1948 records,
1949 cursor: SourceCursor {
1950 last_seen: now,
1951 revision: 1,
1952 },
1953 })
1954 }
1955 fn reported_record_count(&self, _: &SamplerConfig) -> Result<u128, SamplerError> {
1956 Ok(8)
1957 }
1958 }
1959
1960 let config = SamplerConfig {
1961 batch_size: 2,
1962 text_recipes: vec![TextRecipe {
1963 name: "anchor".into(),
1964 selector: Selector::Role(SectionRole::Anchor),
1965 weight: 1.0,
1966 instruction: None,
1967 }],
1968 split: SplitRatios {
1969 train: 1.0,
1970 validation: 0.0,
1971 test: 0.0,
1972 },
1973 allowed_splits: vec![SplitLabel::Train],
1974 ingestion_max_records: 4,
1980 ..SamplerConfig::default()
1981 };
1982 let store = Arc::new(DeterministicSplitStore::new(config.split, 99).unwrap());
1983 let sampler = TripletSampler::new(config, store);
1984
1985 for (i, count) in counts.iter().enumerate() {
1986 sampler
1987 .register_source(Box::new(Tracked {
1988 id: format!("src_{i}"),
1989 refresh_count: Arc::clone(count),
1990 }))
1991 .unwrap();
1992 }
1993 sampler
1994 }
1995
1996 #[test]
1997 fn sampler_unweighted_drain_distributes_evenly() {
1998 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
1999 let sampler = make_five_source_sampler(&counts);
2000
2001 sampler.next_text_batch(SplitLabel::Train).unwrap();
2002 for _ in 0..80 {
2003 sampler.next_text_batch(SplitLabel::Train).unwrap();
2004 }
2005
2006 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2007 let min = *totals.iter().min().unwrap();
2008 let max = *totals.iter().max().unwrap();
2009 assert!(
2010 max <= min + 1,
2011 "unweighted: all sources must refresh at roughly the same rate: {totals:?}"
2012 );
2013 assert!(
2014 min >= 4,
2015 "unweighted: each source should have refreshed at least 4 times: {totals:?}"
2016 );
2017 }
2018
2019 #[test]
2020 fn sampler_weighted_drain_with_equal_weights_distributes_evenly() {
2021 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2025 let sampler = make_five_source_sampler(&counts);
2026
2027 let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
2028
2029 sampler
2030 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2031 .unwrap();
2032 for _ in 0..80 {
2033 sampler
2034 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2035 .unwrap();
2036 }
2037
2038 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2039 let min = *totals.iter().min().unwrap();
2040 let max = *totals.iter().max().unwrap();
2041 assert!(
2042 max <= min + 1,
2043 "weighted (equal): all sources must refresh at roughly the same rate: {totals:?}"
2044 );
2045 assert!(
2046 min >= 4,
2047 "weighted (equal): each source should have refreshed at least 4 times: {totals:?}"
2048 );
2049 }
2050
2051 #[test]
2052 fn sampler_unweighted_and_weighted_match_distribution() {
2053 let uc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2055 let wc: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2056 let usampler = make_five_source_sampler(&uc);
2057 let wsampler = make_five_source_sampler(&wc);
2058
2059 let weights: HashMap<String, f32> = (0..5).map(|i| (format!("src_{i}"), 1.0)).collect();
2060
2061 usampler.next_text_batch(SplitLabel::Train).unwrap();
2062 wsampler
2063 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2064 .unwrap();
2065 for _ in 0..80 {
2066 usampler.next_text_batch(SplitLabel::Train).unwrap();
2067 wsampler
2068 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2069 .unwrap();
2070 }
2071
2072 let ut: Vec<usize> = uc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2073 let wt: Vec<usize> = wc.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2074 let umax = *ut.iter().max().unwrap();
2075 let umin = *ut.iter().min().unwrap();
2076 let wmax = *wt.iter().max().unwrap();
2077 let wmin = *wt.iter().min().unwrap();
2078 assert!(umax <= umin + 1, "unweighted: {ut:?}");
2079 assert!(wmax <= wmin + 1, "weighted equal: {wt:?}");
2080 }
2081
2082 #[test]
2083 fn sampler_weighted_drain_with_unequal_weights_respects_ratios() {
2084 let counts: Vec<Arc<AtomicUsize>> = (0..5).map(|_| Arc::new(AtomicUsize::new(0))).collect();
2088 let sampler = make_five_source_sampler(&counts);
2089
2090 let mut weights = HashMap::new();
2091 for i in 0..5 {
2092 weights.insert(format!("src_{i}"), if i == 3 { 2.0f32 } else { 1.0 });
2093 }
2094
2095 sampler
2096 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2097 .unwrap();
2098 for _ in 0..200 {
2099 sampler
2100 .next_text_batch_with_weights(SplitLabel::Train, &weights)
2101 .unwrap();
2102 }
2103
2104 let totals: Vec<usize> = counts.iter().map(|c| c.load(Ordering::SeqCst)).collect();
2105 assert!(
2113 totals
2114 .iter()
2115 .enumerate()
2116 .all(|(i, &t)| i == 3 || t < totals[3]),
2117 "src_3 (w=2.0) must outpace all w=1.0 sources (totals: {totals:?})"
2118 );
2119 assert_eq!(
2128 totals,
2129 vec![6, 6, 6, 26, 11],
2130 "unequal-weights: unexpected refresh distribution"
2131 );
2132 }
2133
2134 #[test]
2135 fn register_source_rejects_reserved_id_pattern() {
2136 use crate::constants::splits::is_reserved_source_id;
2137
2138 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2139
2140 assert!(is_reserved_source_id("__meta__"));
2142 assert!(is_reserved_source_id("__anything__"));
2143 assert!(is_reserved_source_id("__x__"));
2144 assert!(!is_reserved_source_id(""));
2145 assert!(!is_reserved_source_id("__"));
2146 assert!(!is_reserved_source_id("___"));
2147 assert!(!is_reserved_source_id("normal_source"));
2148 assert!(!is_reserved_source_id("_prefix_suffix_"));
2149 assert!(!is_reserved_source_id("__unclosed"));
2150 assert!(!is_reserved_source_id("unopened__"));
2151
2152 let result = manager.register_source(Box::new(ScriptedSource::new(
2154 "__reserved__",
2155 Arc::new(AtomicUsize::new(0)),
2156 vec![],
2157 )));
2158 assert!(
2159 result.is_err(),
2160 "register_source should reject reserved source id"
2161 );
2162 let err = result.unwrap_err();
2163 assert!(
2164 matches!(&err, SamplerError::ReservedSourceId(id) if id == "__reserved__"),
2165 "expected ReservedSourceId error, got: {err}"
2166 );
2167
2168 assert!(!manager.has_sources());
2170
2171 manager
2173 .register_source(Box::new(ScriptedSource::new(
2174 "valid_source",
2175 Arc::new(AtomicUsize::new(0)),
2176 vec![],
2177 )))
2178 .unwrap();
2179 assert!(manager.has_sources());
2180 }
2181
2182 #[test]
2183 fn record_cache_ingest_with_version_empty_batch_is_noop() {
2184 let cache = RecordCache::new(4);
2185 cache.ingest_with_version(Vec::<DataRecord>::new(), 99);
2186 assert!(cache.is_empty());
2187 assert_eq!(cache.ingest_count(), 0);
2188 }
2189
2190 #[test]
2191 fn record_cache_ingest_with_version_assigns_explicit_version() {
2192 let cache = RecordCache::new(4);
2193 cache.ingest_with_version(vec![make_record("v1", "s")], 10);
2194 assert_eq!(cache.len(), 1);
2195 let (_, added, evicted) = cache.sync_delta(0);
2197 assert_eq!(added.len(), 1);
2198 assert!(evicted.is_empty());
2199 assert_eq!(added[0].id, "v1");
2200 }
2201
2202 #[test]
2203 fn record_cache_sync_delta_returns_nothing_when_fully_consumed() {
2204 let cache = RecordCache::new(4);
2205 cache.ingest_with_version(vec![make_record("a", "s")], 5);
2206 let (v1, added1, _) = cache.sync_delta(0);
2208 assert_eq!(added1.len(), 1);
2209 let (v2, added2, evicted2) = cache.sync_delta(v1);
2211 assert!(added2.is_empty());
2212 assert!(evicted2.is_empty());
2213 assert_eq!(v2, v1);
2214 }
2215
2216 #[test]
2217 fn record_cache_sync_delta_reports_evictions_from_clear() {
2218 let cache = RecordCache::new(4);
2219 cache.ingest(vec![make_record("e1", "s"), make_record("e2", "s")]);
2220 let (v1, _, _) = cache.sync_delta(0);
2222 assert!(v1 > 0);
2223 cache.clear();
2225 let (_, added, evicted) = cache.sync_delta(v1);
2226 assert!(added.is_empty());
2227 assert_eq!(evicted.len(), 2);
2228 assert!(evicted.contains(&"e1".to_string()));
2229 assert!(evicted.contains(&"e2".to_string()));
2230 }
2231
2232 #[test]
2233 fn record_cache_sync_delta_filters_evictions_by_version_watermark() {
2234 let cache = RecordCache::new(4);
2235 cache.ingest_with_version(vec![make_record("old", "s")], 1);
2236 let (v1, _, _) = cache.sync_delta(0);
2237 assert!(v1 >= 1);
2238 cache.clear();
2239 let (_, _, evicted_at_v1) = cache.sync_delta(v1);
2241 assert_eq!(evicted_at_v1.len(), 1);
2242 let (_, _, evicted_at_higher) = cache.sync_delta(v1 + 1);
2244 assert!(evicted_at_higher.is_empty());
2245 }
2246
2247 #[test]
2248 fn record_cache_enforce_limit_trims_fifo_and_logs_evictions() {
2249 let cache = RecordCache::new(2);
2250 cache.ingest(vec![
2251 make_record("first", "s"),
2252 make_record("second", "s"),
2253 make_record("third", "s"),
2254 ]);
2255 assert_eq!(cache.len(), 2);
2257 let (v1, _, _) = cache.sync_delta(0);
2259 cache.ingest(vec![make_record("fourth", "s")]);
2261 let (_, _, evicted) = cache.sync_delta(v1);
2262 assert!(!evicted.is_empty());
2265 }
2266
2267 #[test]
2268 fn manager_force_refresh_generation_and_global_version() {
2269 let refreshes = Arc::new(AtomicUsize::new(0));
2270 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2271 assert_eq!(manager.force_refresh_generation(), 0);
2272 assert_eq!(manager.global_version(), 0);
2273
2274 manager
2275 .register_source(Box::new(ScriptedSource::new(
2276 "gen_src",
2277 refreshes,
2278 vec![Ok(SourceSnapshot {
2279 records: vec![make_record("g1", "gen_src")],
2280 cursor: SourceCursor {
2281 last_seen: Utc::now(),
2282 revision: 1,
2283 },
2284 })],
2285 )))
2286 .unwrap();
2287
2288 manager.refresh_all();
2289 assert_eq!(manager.force_refresh_generation(), 0);
2290 assert!(manager.global_version() > 0);
2291
2292 let v_before = manager.global_version();
2293 manager.force_refresh_all();
2294 assert_eq!(manager.force_refresh_generation(), 1);
2295 assert!(manager.global_version() > v_before);
2296 }
2297
2298 #[test]
2299 fn manager_sync_delta_aggregates_across_sources() {
2300 let refreshes = Arc::new(AtomicUsize::new(0));
2301 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2302 manager
2303 .register_source(Box::new(ScriptedSource::new(
2304 "agg_a",
2305 refreshes.clone(),
2306 vec![Ok(SourceSnapshot {
2307 records: vec![make_record("a1", "agg_a")],
2308 cursor: SourceCursor {
2309 last_seen: Utc::now(),
2310 revision: 1,
2311 },
2312 })],
2313 )))
2314 .unwrap();
2315 manager
2316 .register_source(Box::new(ScriptedSource::new(
2317 "agg_b",
2318 refreshes,
2319 vec![Ok(SourceSnapshot {
2320 records: vec![make_record("b1", "agg_b")],
2321 cursor: SourceCursor {
2322 last_seen: Utc::now(),
2323 revision: 1,
2324 },
2325 })],
2326 )))
2327 .unwrap();
2328
2329 manager.refresh_all();
2330 let (version, added, evicted) = manager.sync_delta(0);
2331 assert!(version > 0);
2332 assert_eq!(added.len(), 2);
2333 let ids: Vec<&str> = added.iter().map(|r| r.id.as_str()).collect();
2334 assert!(ids.contains(&"a1"));
2335 assert!(ids.contains(&"b1"));
2336 assert!(evicted.is_empty());
2337
2338 let (_, added2, evicted2) = manager.sync_delta(version);
2340 assert!(added2.is_empty());
2341 assert!(evicted2.is_empty());
2342 }
2343
2344 #[test]
2345 fn manager_source_refresh_stats_returns_telemetry() {
2346 let refreshes = Arc::new(AtomicUsize::new(0));
2347 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2348 manager
2349 .register_source(Box::new(ScriptedSource::new(
2350 "stats_src",
2351 refreshes,
2352 vec![Ok(SourceSnapshot {
2353 records: vec![make_record("s1", "stats_src")],
2354 cursor: SourceCursor {
2355 last_seen: Utc::now(),
2356 revision: 1,
2357 },
2358 })],
2359 )))
2360 .unwrap();
2361
2362 let stats = manager.source_refresh_stats();
2363 assert_eq!(stats.len(), 1);
2364 assert_eq!(stats[0].0, "stats_src");
2365 assert_eq!(stats[0].1.last_refresh_ms, 0);
2366
2367 manager.refresh_all();
2368 let stats = manager.source_refresh_stats();
2369 assert_eq!(stats[0].1.last_record_count, 1);
2370 }
2371
2372 #[test]
2373 fn record_cache_ingest_batch_replaces_existing_record() {
2374 let cache = RecordCache::new(4);
2375 cache.ingest(vec![make_record("dup", "s")]);
2377 assert_eq!(cache.len(), 1);
2378 cache.ingest(vec![make_record("dup", "s")]);
2380 assert_eq!(cache.len(), 1);
2381 }
2382
2383 #[test]
2384 fn record_cache_ingest_with_version_replaces_existing_record() {
2385 let cache = RecordCache::new(4);
2386 cache.ingest_with_version(vec![make_record("vr", "s")], 5);
2387 let (v1, _, _) = cache.sync_delta(0);
2388 assert_eq!(cache.len(), 1);
2389
2390 cache.ingest_with_version(vec![make_record("vr", "s")], v1 + 5);
2392 let (_, added, evicted) = cache.sync_delta(v1);
2393 assert_eq!(added.len(), 1);
2394 assert!(evicted.is_empty());
2395 assert_eq!(cache.len(), 1);
2396 }
2397
2398 #[test]
2399 fn record_cache_enforce_limit_evicts_oldest_and_trims_eviction_log() {
2400 let cache = RecordCache::new(3);
2403 for i in 0..5 {
2404 cache.ingest(vec![make_record(&format!("r{i}"), "s")]);
2405 }
2406 assert_eq!(cache.len(), 3);
2407
2408 let (v1, _, _) = cache.sync_delta(0);
2410
2411 let (_, added, evicted) = cache.sync_delta(v1);
2414 assert!(added.len() + evicted.len() <= 5);
2417 }
2418
2419 #[test]
2420 fn weighted_drain_breaks_when_all_buffers_empty() {
2421 let refreshes = Arc::new(AtomicUsize::new(0));
2422 let mut manager = IngestionManager::new(4, SamplerConfig::default());
2423 manager
2425 .register_source(Box::new(ScriptedSource::new(
2426 "empty_buf",
2427 refreshes,
2428 vec![],
2429 )))
2430 .unwrap();
2431
2432 let mut weights = HashMap::new();
2433 weights.insert("empty_buf".to_string(), 1.0);
2434 manager.advance_with_weights(1, &weights).unwrap();
2437 assert_eq!(manager.all_records_len(), 0);
2438 }
2439
2440 #[test]
2441 fn wait_for_ingest_times_out_without_notification() {
2442 let cache = RecordCache::new(4);
2443 let result = cache.wait_for_ingest(0, Duration::from_millis(1));
2445 assert_eq!(result, 0);
2446 }
2447}