Skip to main content

ant_core/node/daemon/forward/
runner.rs

1//! The background task that does the forwarding.
2//!
3//! It follows the shape of the daemon's other background workers (`spawn_eviction_monitor`,
4//! `spawn_liveness_monitor`): spawned once, driven by a poll interval, stopped by a
5//! [`CancellationToken`]. It additionally carries its own token so `disable` can stop forwarding
6//! without touching the daemon.
7//!
8//! The ordering within a cycle is deliberate: **poll, deliver, then persist offsets.** Persisting
9//! before delivery would mean a daemon killed mid-cycle had already promised never to re-read
10//! events that never left the machine. Doing it after means a crash re-reads a little, which the
11//! deterministic document ids turn into harmless duplicates that the endpoint rejects with a 409.
12
13use 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
32/// How often the forwarder looks for new log content.
33///
34/// Fast enough to satisfy "logs appear within a minute" with room to spare — including the one
35/// extra cycle the tailer spends holding a growing file's last event so continuation lines can join
36/// it — and slow enough that following a handful of quiet files costs nothing measurable.
37pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(5);
38
39/// Live view of what the forwarder is doing, shared with the status endpoint.
40///
41/// Counters only. Which nodes are being tailed is derived from the registry by
42/// [`classify_nodes`] at the point of asking, so that status never reports a stale node list
43/// from before the forwarder's first poll.
44#[derive(Debug, Clone, Default)]
45pub struct ForwarderSnapshot {
46    pub stats: ForwardStats,
47}
48
49/// Handle to a running forwarder.
50///
51/// Dropping this does not stop the task — the daemon holds it for the process's lifetime, and
52/// `disable` stops it explicitly.
53pub struct ForwarderHandle {
54    cancel: CancellationToken,
55    shared: Arc<RwLock<ForwarderSnapshot>>,
56    endpoint: String,
57    /// Retained so that stopping can be *awaited*. Without this there is no way to tell a caller
58    /// that the last request has actually finished, which is what `disable` needs to promise.
59    task: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
60}
61
62impl ForwarderHandle {
63    /// Signal the forwarder to stop, without waiting for it. Idempotent.
64    ///
65    /// Prefer [`Self::stop_and_wait`] where the caller is about to tell a user that forwarding has
66    /// stopped.
67    pub fn stop(&self) {
68        self.cancel.cancel();
69    }
70
71    /// Stop forwarding and wait until the task has actually exited.
72    ///
73    /// This is what makes `disable` a real revocation boundary rather than a request. Cancellation
74    /// is observed inside the delivery loop, and dropping the in-flight future cancels the HTTP
75    /// request with it, so this returns promptly rather than after the retry ladder plays out.
76    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    /// Where this forwarder is shipping to, for status output.
90    #[must_use]
91    pub fn endpoint(&self) -> &str {
92        &self.endpoint
93    }
94
95    /// Current counters and node lists.
96    pub async fn snapshot(&self) -> ForwarderSnapshot {
97        self.shared.read().await.clone()
98    }
99}
100
101/// Start forwarding in the background.
102pub 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        // A clean shutdown persists what was read, so the next start resumes rather than replays.
142        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
156/// Everything one running forwarder owns.
157struct 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    /// Cancelled by `disable` or by daemon shutdown. Checked between batches and raced against each
167    /// delivery, so neither an in-flight request nor a retry backoff outlives it.
168    cancel: CancellationToken,
169}
170
171impl ForwarderRun {
172    /// One poll-and-ship cycle.
173    async fn run_cycle(&mut self) -> ForwarderSnapshot {
174        self.refresh_tailers().await;
175
176        // Files seen across *all* tailers this cycle, and whether that view is complete. Pruning
177        // against a partial view would drop live positions, so a cycle that skipped a tailer --
178        // because it errored or because cancellation cut the loop short -- does not prune at all.
179        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                    // Parsing rejects unusable timestamps, so this is defensive rather than
211                    // expected; counting it keeps the totals honest either way.
212                    None => self.stats.events_dropped_by_level += 1,
213                }
214            }
215        }
216
217        // Once, against every tailer's files: a per-tailer prune would delete the other nodes'
218        // positions and send them back to the start of their current file on the next cycle.
219        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    /// Reconcile the tailer set against the registry, so nodes added or removed while forwarding is
237    /// on are picked up without an enable/disable cycle.
238    async fn refresh_tailers(&mut self) {
239        let registry = self.registry.read().await;
240        let (forwarding, _) = classify_nodes(&registry);
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 &registry.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                // Identity can change under us: an auto-upgrade replaces the binary and bumps the
253                // version, and events after that point should say so.
254                Some((_, existing_tags)) => *existing_tags = tags,
255                None => {
256                    let mut tailer = LogTailer::new(node.id, log_dir.clone());
257                    // A node whose files we already have positions for is being resumed, not newly
258                    // adopted, so it must not skip forward to the end of its log.
259                    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    /// Whether persisted offsets already mention a file in this directory.
269    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    /// Ship everything currently queued, abandoning the moment forwarding is revoked.
275    ///
276    /// Both checks matter. The loop check stops a full queue from taking further batches after
277    /// `disable`, and the `select!` drops the delivery future mid-flight — which cancels the HTTP
278    /// request with it, since a dropped `reqwest` future cancels the request, and discards any
279    /// pending retry backoff along with it. Without the second, a `disable` issued at the wrong
280    /// moment would keep uploading for the length of the retry ladder.
281    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
330/// Split the registry into nodes that can be forwarded and nodes that cannot.
331///
332/// Node file logging is off unless the user asked for it, so "cannot" is the common case on a
333/// default install. Reporting it is what stops `enable` looking like it worked while shipping
334/// nothing at all.
335pub 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        /// One entry per node, in registry order: `true` means the node has logging enabled.
379        ///
380        /// Ids are not passed in because `NodeRegistry::add` assigns its own, starting at 1 — so
381        /// the nth entry here is node `n + 1`.
382        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    /// Wait until `condition` holds, or give up after `deadline`.
448    ///
449    /// Preferred over a fixed sleep wherever a test waits on the forwarder making progress: the
450    /// work is real I/O over megabytes of log, so a duration chosen on an idle machine turns into a
451    /// flake on a loaded CI runner. Returns whether the condition was met, so the caller can still
452    /// assert on the actual value and produce a useful message.
453    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    /// Drive the forwarder for long enough to observe several poll cycles.
465    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        // Written after the forwarder has joined the file at its end.
486        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    /// A node with no log directory must not stop the forwarder doing its job for the others.
496    #[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    /// `disable` must be a revocation boundary, not a request: once `stop_and_wait` returns, no
549    /// request may still be in flight.
550    ///
551    /// The sink here hangs mid-send, standing in for a slow endpoint. Before cancellation reached
552    /// the delivery loop, `disable` returned immediately and that send carried on through the whole
553    /// retry ladder — up to three 30s request timeouts plus backoff — while the CLI had already
554    /// told the user forwarding had stopped.
555    #[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        // Wait until a send is genuinely in flight and stuck.
574        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        // The blocked send is never released; this must still return promptly.
583        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        // And nothing new may be sent afterwards.
596        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    /// Offsets are written on the way out, so the next daemon resumes instead of replaying.
634    #[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        // A second forwarder over the same offsets file must not resend what the first shipped.
656        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        // Nodes 1 and 3 have logging; node 2 does not.
704        let harness = Harness::new(&[true, false, true]).await;
705        let registry = harness.registry.read().await;
706        let (forwarding, skipped) = classify_nodes(&registry);
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    /// Reproduction of the beta cohort stall: a node that has written several megabytes since the
720    /// forwarder last caught up must have all of it shipped, not just the first chunk.
721    #[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        // Let the forwarder join the (empty) file at its end first.
736        tokio::time::sleep(Duration::from_millis(60)).await;
737
738        // Now write ~3MB, the way a busy node does over an hour.
739        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        // 3MB is three chunks, so a correct forwarder finishes in a handful of cycles.
748        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    /// Each tailer prunes the *shared* offset store against only its own files, so a second node
769    /// wipes the first node's positions every cycle.
770    #[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        // Node 1 is busy; node 2 is registered with a log dir but has written nothing.
786        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    /// Retention deleting a daily file must still drop its offset -- now done once per cycle by the
826    /// runner, against every tailer's files rather than one node's.
827    #[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}