1use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::time::Duration;
17
18use tokio::sync::RwLock;
19use tokio_util::sync::CancellationToken;
20
21use super::config::LogForwardConfig;
22use super::document::{ForwardDocument, NodeTags};
23use super::offsets::OffsetStore;
24use super::sink::{
25 deliver, DocumentQueue, LogSink, RetryPolicy, DEFAULT_BATCH_BYTES, DEFAULT_BATCH_DOCUMENTS,
26 DEFAULT_QUEUE_CAPACITY,
27};
28use super::tail::LogTailer;
29use super::{ForwardStats, ForwardingNode, SkippedNode};
30use crate::node::registry::NodeRegistry;
31
32pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(5);
38
39#[derive(Debug, Clone, Default)]
45pub struct ForwarderSnapshot {
46 pub stats: ForwardStats,
47}
48
49pub struct ForwarderHandle {
54 cancel: CancellationToken,
55 shared: Arc<RwLock<ForwarderSnapshot>>,
56 endpoint: String,
57 task: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
60}
61
62impl ForwarderHandle {
63 pub fn stop(&self) {
68 self.cancel.cancel();
69 }
70
71 pub async fn stop_and_wait(&self) {
77 self.cancel.cancel();
78 let task = self.task.lock().await.take();
79 if let Some(task) = task {
80 let _ = task.await;
81 }
82 }
83
84 #[must_use]
85 pub fn is_stopped(&self) -> bool {
86 self.cancel.is_cancelled()
87 }
88
89 #[must_use]
91 pub fn endpoint(&self) -> &str {
92 &self.endpoint
93 }
94
95 pub async fn snapshot(&self) -> ForwarderSnapshot {
97 self.shared.read().await.clone()
98 }
99}
100
101pub fn spawn_log_forwarder(
103 registry: Arc<RwLock<NodeRegistry>>,
104 config: LogForwardConfig,
105 sink: Arc<dyn LogSink>,
106 offsets_path: PathBuf,
107 poll_interval: Duration,
108 shutdown: CancellationToken,
109) -> ForwarderHandle {
110 let cancel = CancellationToken::new();
111 let shared = Arc::new(RwLock::new(ForwarderSnapshot::default()));
112
113 let endpoint = sink.describe();
114 let task_cancel = cancel.clone();
115 let task_shared = shared.clone();
116
117 let task = tokio::spawn(async move {
118 let mut state = ForwarderRun {
119 registry,
120 config,
121 sink,
122 offsets: OffsetStore::load(&offsets_path),
123 tailers: HashMap::new(),
124 queue: DocumentQueue::new(DEFAULT_QUEUE_CAPACITY),
125 stats: ForwardStats::default(),
126 retry: RetryPolicy::default(),
127 cancel: task_cancel.clone(),
128 };
129
130 loop {
131 tokio::select! {
132 () = shutdown.cancelled() => break,
133 () = task_cancel.cancelled() => break,
134 () = tokio::time::sleep(poll_interval) => {}
135 }
136
137 let snapshot = state.run_cycle().await;
138 *task_shared.write().await = snapshot;
139 }
140
141 if let Err(error) = state.offsets.save() {
143 tracing::warn!("log forwarding: could not persist tail offsets: {error}");
144 }
145 tracing::info!("log forwarding: stopped");
146 });
147
148 ForwarderHandle {
149 cancel,
150 shared,
151 endpoint,
152 task: tokio::sync::Mutex::new(Some(task)),
153 }
154}
155
156struct ForwarderRun {
158 registry: Arc<RwLock<NodeRegistry>>,
159 config: LogForwardConfig,
160 sink: Arc<dyn LogSink>,
161 offsets: OffsetStore,
162 tailers: HashMap<u32, (LogTailer, NodeTags)>,
163 queue: DocumentQueue,
164 stats: ForwardStats,
165 retry: RetryPolicy,
166 cancel: CancellationToken,
169}
170
171impl ForwarderRun {
172 async fn run_cycle(&mut self) -> ForwarderSnapshot {
174 self.refresh_tailers().await;
175
176 let mut live_files: Vec<String> = Vec::new();
180 let mut complete_view = true;
181
182 for (tailer, tags) in self.tailers.values_mut() {
183 if self.cancel.is_cancelled() {
184 complete_view = false;
185 break;
186 }
187 let outcome = match tailer.poll(&mut self.offsets, self.config.min_level).await {
188 Ok(outcome) => outcome,
189 Err(error) => {
190 tracing::debug!(
191 "log forwarding: node {} could not be read this cycle: {error}",
192 tailer.node_id()
193 );
194 complete_view = false;
195 continue;
196 }
197 };
198
199 self.stats.events_dropped_by_level += outcome.dropped_by_level;
200 live_files.extend(outcome.live_files.iter().cloned());
201
202 for event in &outcome.events {
203 match ForwardDocument::build(
204 event,
205 tags,
206 &self.config.index_prefix,
207 &self.config.installation_id,
208 ) {
209 Some(document) => self.queue.push(document),
210 None => self.stats.events_dropped_by_level += 1,
213 }
214 }
215 }
216
217 if complete_view {
220 self.offsets.prune(&live_files);
221 }
222
223 self.flush_queue().await;
224
225 if let Err(error) = self.offsets.save() {
226 tracing::warn!("log forwarding: could not persist tail offsets: {error}");
227 }
228
229 self.stats.events_dropped_by_overflow = self.queue.dropped();
230
231 ForwarderSnapshot {
232 stats: self.stats.clone(),
233 }
234 }
235
236 async fn refresh_tailers(&mut self) {
239 let registry = self.registry.read().await;
240 let (forwarding, _) = classify_nodes(®istry);
241
242 let live: Vec<u32> = forwarding.iter().map(|node| node.node_id).collect();
243 self.tailers.retain(|id, _| live.contains(id));
244
245 for node in ®istry.list() {
246 let Some(log_dir) = node.log_dir.clone() else {
247 continue;
248 };
249 let tags = NodeTags::from_config(node);
250
251 match self.tailers.get_mut(&node.id) {
252 Some((_, existing_tags)) => *existing_tags = tags,
255 None => {
256 let mut tailer = LogTailer::new(node.id, log_dir.clone());
257 if self.has_offsets_for(&log_dir) {
260 tailer.mark_primed();
261 }
262 self.tailers.insert(node.id, (tailer, tags));
263 }
264 }
265 }
266 }
267
268 fn has_offsets_for(&self, log_dir: &std::path::Path) -> bool {
270 let prefix = log_dir.display().to_string();
271 self.offsets.keys().any(|key| key.starts_with(&prefix))
272 }
273
274 async fn flush_queue(&mut self) {
282 while !self.queue.is_empty() {
283 if self.cancel.is_cancelled() {
284 return;
285 }
286
287 let batch = self
288 .queue
289 .take_batch(DEFAULT_BATCH_DOCUMENTS, DEFAULT_BATCH_BYTES);
290 if batch.is_empty() {
291 break;
292 }
293
294 let count = batch.len() as u64;
295 let delivery = deliver(self.sink.as_ref(), batch, self.retry, |delay| {
296 Box::pin(tokio::time::sleep(delay))
297 });
298
299 let report = tokio::select! {
300 biased;
301 () = self.cancel.cancelled() => {
302 tracing::debug!(
303 "log forwarding: revoked mid-delivery; abandoning {count} document(s)"
304 );
305 return;
306 }
307 report = delivery => report,
308 };
309
310 self.stats.events_forwarded += report.delivered;
311
312 if report.is_complete_success() {
313 self.stats.batches_sent += 1;
314 self.stats.last_success_unix = Some(now_unix_secs());
315 self.stats.last_error = None;
316 } else {
317 self.stats.batches_failed += 1;
318 self.stats.last_error = report.error.clone();
319 tracing::debug!(
320 "log forwarding: {} of {count} documents did not reach {}: {}",
321 report.rejected + report.abandoned,
322 self.sink.describe(),
323 report.error.as_deref().unwrap_or("no detail"),
324 );
325 }
326 }
327 }
328}
329
330pub fn classify_nodes(registry: &NodeRegistry) -> (Vec<ForwardingNode>, Vec<SkippedNode>) {
336 let mut forwarding = Vec::new();
337 let mut skipped = Vec::new();
338
339 let mut nodes = registry.list();
340 nodes.sort_by_key(|node| node.id);
341
342 for node in nodes {
343 match &node.log_dir {
344 Some(log_dir) => forwarding.push(ForwardingNode {
345 node_id: node.id,
346 service: node.service_name.clone(),
347 log_dir: log_dir.display().to_string(),
348 }),
349 None => skipped.push(SkippedNode::no_logging(node.id, node.service_name.clone())),
350 }
351 }
352
353 (forwarding, skipped)
354}
355
356fn now_unix_secs() -> u64 {
357 std::time::SystemTime::now()
358 .duration_since(std::time::UNIX_EPOCH)
359 .map(|d| d.as_secs())
360 .unwrap_or(0)
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::node::daemon::forward::sink::mock::MockSink;
367 use crate::node::types::{EvmNetwork, NodeConfig, UpgradeChannel};
368 use std::collections::HashMap as StdHashMap;
369 use std::io::Write;
370
371 struct Harness {
372 _dir: tempfile::TempDir,
373 root: PathBuf,
374 registry: Arc<RwLock<NodeRegistry>>,
375 }
376
377 impl Harness {
378 async fn new(nodes: &[bool]) -> Self {
383 let dir = tempfile::tempdir().unwrap();
384 let root = dir.path().to_path_buf();
385 let mut registry = NodeRegistry::load(&root.join("node_registry.json")).unwrap();
386
387 for (index, with_logging) in nodes.iter().enumerate() {
388 let id = index as u32 + 1;
389 let log_dir = with_logging.then(|| root.join(format!("logs-{id}")));
390 if let Some(ref path) = log_dir {
391 std::fs::create_dir_all(path).unwrap();
392 }
393 registry.add(NodeConfig {
394 id,
395 service_name: format!("node{id}"),
396 rewards_address: "0xabc".to_string(),
397 data_dir: root.join(format!("data-{id}")),
398 log_dir,
399 node_port: None,
400 binary_path: root.join("antnode"),
401 version: "0.17.2-beta.1".to_string(),
402 env_variables: StdHashMap::new(),
403 bootstrap_peers: Vec::new(),
404 upgrade_channel: Some(UpgradeChannel::Beta),
405 evm_network: EvmNetwork::default(),
406 eviction: None,
407 });
408 }
409
410 Self {
411 _dir: dir,
412 root,
413 registry: Arc::new(RwLock::new(registry)),
414 }
415 }
416
417 fn append(&self, node_id: u32, contents: &str) {
418 let path = self
419 .root
420 .join(format!("logs-{node_id}"))
421 .join("ant-node.2026-08-19.log");
422 let mut file = std::fs::OpenOptions::new()
423 .create(true)
424 .append(true)
425 .open(path)
426 .unwrap();
427 file.write_all(contents.as_bytes()).unwrap();
428 }
429
430 fn config(&self) -> LogForwardConfig {
431 LogForwardConfig {
432 enabled: true,
433 token: "test-key".to_string(),
434 ..LogForwardConfig::disabled()
435 }
436 }
437
438 fn offsets_path(&self) -> PathBuf {
439 self.root.join("offsets.json")
440 }
441 }
442
443 fn line(level: &str, message: &str) -> String {
444 format!("2026-08-19T20:50:00.123456Z {level} ant_node::node: {message}\n")
445 }
446
447 async fn wait_until(deadline: Duration, mut condition: impl FnMut() -> bool) -> bool {
454 let started = tokio::time::Instant::now();
455 while started.elapsed() < deadline {
456 if condition() {
457 return true;
458 }
459 tokio::time::sleep(Duration::from_millis(20)).await;
460 }
461 condition()
462 }
463
464 async fn run_briefly(handle: &ForwarderHandle) {
466 tokio::time::sleep(Duration::from_millis(220)).await;
467 handle.stop();
468 tokio::time::sleep(Duration::from_millis(60)).await;
469 }
470
471 #[tokio::test]
472 async fn forwards_a_nodes_log_lines_to_the_sink() {
473 let harness = Harness::new(&[true]).await;
474 let sink = Arc::new(MockSink::accepting());
475
476 let handle = spawn_log_forwarder(
477 harness.registry.clone(),
478 harness.config(),
479 sink.clone(),
480 harness.offsets_path(),
481 Duration::from_millis(30),
482 CancellationToken::new(),
483 );
484
485 tokio::time::sleep(Duration::from_millis(60)).await;
487 harness.append(1, &line("INFO", "hello from node one"));
488 run_briefly(&handle).await;
489
490 let ids = sink.submitted_ids();
491 assert!(!ids.is_empty(), "nothing was forwarded");
492 assert!(handle.snapshot().await.stats.events_forwarded >= 1);
493 }
494
495 #[tokio::test]
497 async fn a_node_without_a_log_directory_does_not_disturb_the_rest() {
498 let harness = Harness::new(&[true, false]).await;
499 let sink = Arc::new(MockSink::accepting());
500
501 let handle = spawn_log_forwarder(
502 harness.registry.clone(),
503 harness.config(),
504 sink.clone(),
505 harness.offsets_path(),
506 Duration::from_millis(30),
507 CancellationToken::new(),
508 );
509
510 tokio::time::sleep(Duration::from_millis(60)).await;
511 harness.append(1, &line("INFO", "from the node that does log"));
512 run_briefly(&handle).await;
513
514 assert!(!sink.submitted_ids().is_empty());
515 assert!(handle.snapshot().await.stats.events_forwarded >= 1);
516 }
517
518 #[tokio::test]
519 async fn stopping_the_handle_ends_forwarding() {
520 let harness = Harness::new(&[true]).await;
521 let sink = Arc::new(MockSink::accepting());
522
523 let handle = spawn_log_forwarder(
524 harness.registry.clone(),
525 harness.config(),
526 sink.clone(),
527 harness.offsets_path(),
528 Duration::from_millis(30),
529 CancellationToken::new(),
530 );
531
532 tokio::time::sleep(Duration::from_millis(60)).await;
533 handle.stop();
534 assert!(handle.is_stopped());
535 tokio::time::sleep(Duration::from_millis(60)).await;
536
537 let batches_after_stop = sink.batch_count();
538 harness.append(1, &line("INFO", "written after disable"));
539 tokio::time::sleep(Duration::from_millis(120)).await;
540
541 assert_eq!(
542 sink.batch_count(),
543 batches_after_stop,
544 "disable must stop the flow entirely"
545 );
546 }
547
548 #[tokio::test]
556 async fn stopping_returns_only_once_delivery_has_actually_stopped() {
557 let harness = Harness::new(&[true]).await;
558 let release = Arc::new(tokio::sync::Notify::new());
559 let sink = Arc::new(MockSink::blocking(release.clone()));
560
561 let handle = spawn_log_forwarder(
562 harness.registry.clone(),
563 harness.config(),
564 sink.clone(),
565 harness.offsets_path(),
566 Duration::from_millis(30),
567 CancellationToken::new(),
568 );
569
570 tokio::time::sleep(Duration::from_millis(60)).await;
571 harness.append(1, &line("INFO", "caught mid-flight"));
572
573 let mut waited = 0;
575 while sink.batch_count() == 0 && waited < 60 {
576 tokio::time::sleep(Duration::from_millis(20)).await;
577 waited += 1;
578 }
579 assert_eq!(sink.batch_count(), 1, "a send should be in flight");
580 assert_eq!(sink.completed_count(), 0, "and still blocked");
581
582 let stopped = tokio::time::timeout(Duration::from_secs(5), handle.stop_and_wait()).await;
584 assert!(
585 stopped.is_ok(),
586 "stop_and_wait must not sit through the retry ladder"
587 );
588
589 assert_eq!(
590 sink.completed_count(),
591 0,
592 "the in-flight request must have been dropped, not allowed to finish"
593 );
594
595 let batches_at_stop = sink.batch_count();
597 harness.append(1, &line("INFO", "written after disable"));
598 tokio::time::sleep(Duration::from_millis(150)).await;
599 assert_eq!(
600 sink.batch_count(),
601 batches_at_stop,
602 "no request may start after disable has returned"
603 );
604 }
605
606 #[tokio::test]
607 async fn the_daemon_shutdown_token_also_stops_forwarding() {
608 let harness = Harness::new(&[true]).await;
609 let sink = Arc::new(MockSink::accepting());
610 let shutdown = CancellationToken::new();
611
612 let handle = spawn_log_forwarder(
613 harness.registry.clone(),
614 harness.config(),
615 sink.clone(),
616 harness.offsets_path(),
617 Duration::from_millis(30),
618 shutdown.clone(),
619 );
620
621 tokio::time::sleep(Duration::from_millis(60)).await;
622 shutdown.cancel();
623 tokio::time::sleep(Duration::from_millis(60)).await;
624 let batches = sink.batch_count();
625
626 harness.append(1, &line("INFO", "after shutdown"));
627 tokio::time::sleep(Duration::from_millis(120)).await;
628
629 assert_eq!(sink.batch_count(), batches);
630 drop(handle);
631 }
632
633 #[tokio::test]
635 async fn offsets_are_persisted_across_a_forwarder_restart() {
636 let harness = Harness::new(&[true]).await;
637 harness.append(1, &line("INFO", "before"));
638
639 let sink = Arc::new(MockSink::accepting());
640 let handle = spawn_log_forwarder(
641 harness.registry.clone(),
642 harness.config(),
643 sink.clone(),
644 harness.offsets_path(),
645 Duration::from_millis(30),
646 CancellationToken::new(),
647 );
648 tokio::time::sleep(Duration::from_millis(60)).await;
649 harness.append(1, &line("INFO", "first run"));
650 run_briefly(&handle).await;
651
652 let first_ids = sink.submitted_ids();
653 assert!(harness.offsets_path().exists(), "offsets must be persisted");
654
655 let second_sink = Arc::new(MockSink::accepting());
657 let second = spawn_log_forwarder(
658 harness.registry.clone(),
659 harness.config(),
660 second_sink.clone(),
661 harness.offsets_path(),
662 Duration::from_millis(30),
663 CancellationToken::new(),
664 );
665 run_briefly(&second).await;
666
667 let resent: Vec<String> = second_sink
668 .submitted_ids()
669 .into_iter()
670 .filter(|id| first_ids.contains(id))
671 .collect();
672 assert!(
673 resent.is_empty(),
674 "the second run re-sent documents the first had already delivered: {resent:?}"
675 );
676 }
677
678 #[tokio::test]
679 async fn events_below_the_minimum_level_never_reach_the_sink() {
680 let harness = Harness::new(&[true]).await;
681 let sink = Arc::new(MockSink::accepting());
682
683 let handle = spawn_log_forwarder(
684 harness.registry.clone(),
685 harness.config(),
686 sink.clone(),
687 harness.offsets_path(),
688 Duration::from_millis(30),
689 CancellationToken::new(),
690 );
691
692 tokio::time::sleep(Duration::from_millis(60)).await;
693 harness.append(1, &line("DEBUG", "chatter"));
694 harness.append(1, &line("TRACE", "more chatter"));
695 run_briefly(&handle).await;
696
697 assert!(sink.submitted_ids().is_empty());
698 assert!(handle.snapshot().await.stats.events_dropped_by_level >= 2);
699 }
700
701 #[tokio::test]
702 async fn classify_nodes_orders_by_id_and_separates_by_logging() {
703 let harness = Harness::new(&[true, false, true]).await;
705 let registry = harness.registry.read().await;
706 let (forwarding, skipped) = classify_nodes(®istry);
707
708 assert_eq!(
709 forwarding.iter().map(|n| n.node_id).collect::<Vec<_>>(),
710 vec![1, 3],
711 "forwarding nodes are listed in id order"
712 );
713 assert_eq!(
714 skipped.iter().map(|n| n.node_id).collect::<Vec<_>>(),
715 vec![2]
716 );
717 }
718
719 #[tokio::test]
722 async fn a_multi_megabyte_backlog_is_shipped_in_full() {
723 let harness = Harness::new(&[true]).await;
724 let sink = Arc::new(MockSink::accepting());
725
726 let handle = spawn_log_forwarder(
727 harness.registry.clone(),
728 harness.config(),
729 sink.clone(),
730 harness.offsets_path(),
731 Duration::from_millis(20),
732 CancellationToken::new(),
733 );
734
735 tokio::time::sleep(Duration::from_millis(60)).await;
737
738 let mut blob = String::new();
740 let mut expected = 0usize;
741 while blob.len() < 3 * 1024 * 1024 {
742 blob.push_str(&line("INFO", &format!("replication event {expected} aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")));
743 expected += 1;
744 }
745 harness.append(1, &blob);
746
747 wait_until(Duration::from_secs(30), || {
749 sink.submitted_ids().len() >= expected
750 })
751 .await;
752 handle.stop();
753 tokio::time::sleep(Duration::from_millis(100)).await;
754
755 let stats = handle.snapshot().await.stats;
756 let shipped = sink.submitted_ids().len();
757 eprintln!(
758 "blob_bytes={} expected_events={expected} shipped={shipped} forwarded={} dropped_overflow={} dropped_level={} batches_sent={} batches_failed={} last_error={:?}",
759 blob.len(), stats.events_forwarded, stats.events_dropped_by_overflow,
760 stats.events_dropped_by_level, stats.batches_sent, stats.batches_failed, stats.last_error
761 );
762 assert_eq!(
763 shipped, expected,
764 "the whole backlog must be shipped, not just the first chunk"
765 );
766 }
767
768 #[tokio::test]
771 async fn a_second_node_does_not_wipe_the_first_nodes_offsets() {
772 let harness = Harness::new(&[true, true]).await;
773 let sink = Arc::new(MockSink::accepting());
774
775 let handle = spawn_log_forwarder(
776 harness.registry.clone(),
777 harness.config(),
778 sink.clone(),
779 harness.offsets_path(),
780 Duration::from_millis(20),
781 CancellationToken::new(),
782 );
783 tokio::time::sleep(Duration::from_millis(60)).await;
784
785 let mut blob = String::new();
787 let mut expected = 0usize;
788 while blob.len() < 3 * 1024 * 1024 {
789 blob.push_str(&line("INFO", &format!("event {expected} aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")));
790 expected += 1;
791 }
792 harness.append(1, &blob);
793
794 wait_until(Duration::from_secs(30), || {
795 sink.submitted_ids()
796 .iter()
797 .collect::<std::collections::HashSet<_>>()
798 .len()
799 >= expected
800 })
801 .await;
802 handle.stop();
803 tokio::time::sleep(Duration::from_millis(100)).await;
804
805 let ids = sink.submitted_ids();
806 let unique: std::collections::HashSet<_> = ids.iter().collect();
807 let stats = handle.snapshot().await.stats;
808 eprintln!(
809 "expected={expected} submitted={} unique={} forwarded={} batches={}",
810 ids.len(),
811 unique.len(),
812 stats.events_forwarded,
813 stats.batches_sent
814 );
815 assert_eq!(
816 unique.len(),
817 expected,
818 "node 1's whole backlog must ship; it stalled after {} distinct events \
819 (re-sent {} times over)",
820 unique.len(),
821 ids.len() / unique.len().max(1)
822 );
823 }
824
825 #[tokio::test]
828 async fn offsets_for_retention_deleted_files_are_pruned() {
829 let harness = Harness::new(&[true]).await;
830 let sink = Arc::new(MockSink::accepting());
831 let log_dir = harness.root.join("logs-1");
832
833 for name in ["ant-node.2026-08-18.log", "ant-node.2026-08-19.log"] {
834 std::fs::write(log_dir.join(name), line("INFO", "hello")).unwrap();
835 }
836
837 let handle = spawn_log_forwarder(
838 harness.registry.clone(),
839 harness.config(),
840 sink.clone(),
841 harness.offsets_path(),
842 Duration::from_millis(20),
843 CancellationToken::new(),
844 );
845 let offsets_path = harness.offsets_path();
846 wait_until(Duration::from_secs(30), || {
847 OffsetStore::load(&offsets_path).len() == 2
848 })
849 .await;
850 assert_eq!(
851 OffsetStore::load(&offsets_path).len(),
852 2,
853 "both dailies are tracked"
854 );
855
856 std::fs::remove_file(log_dir.join("ant-node.2026-08-18.log")).unwrap();
857 wait_until(Duration::from_secs(30), || {
858 OffsetStore::load(&offsets_path).len() == 1
859 })
860 .await;
861 handle.stop();
862 tokio::time::sleep(Duration::from_millis(80)).await;
863
864 assert_eq!(
865 OffsetStore::load(&offsets_path).len(),
866 1,
867 "the deleted daily is pruned"
868 );
869 }
870}