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        for (tailer, tags) in self.tailers.values_mut() {
177            if self.cancel.is_cancelled() {
178                break;
179            }
180            let outcome = match tailer.poll(&mut self.offsets, self.config.min_level).await {
181                Ok(outcome) => outcome,
182                Err(error) => {
183                    tracing::debug!(
184                        "log forwarding: node {} could not be read this cycle: {error}",
185                        tailer.node_id()
186                    );
187                    continue;
188                }
189            };
190
191            self.stats.events_dropped_by_level += outcome.dropped_by_level;
192
193            for event in &outcome.events {
194                match ForwardDocument::build(
195                    event,
196                    tags,
197                    &self.config.index_prefix,
198                    &self.config.installation_id,
199                ) {
200                    Some(document) => self.queue.push(document),
201                    // Parsing rejects unusable timestamps, so this is defensive rather than
202                    // expected; counting it keeps the totals honest either way.
203                    None => self.stats.events_dropped_by_level += 1,
204                }
205            }
206        }
207
208        self.flush_queue().await;
209
210        if let Err(error) = self.offsets.save() {
211            tracing::warn!("log forwarding: could not persist tail offsets: {error}");
212        }
213
214        self.stats.events_dropped_by_overflow = self.queue.dropped();
215
216        ForwarderSnapshot {
217            stats: self.stats.clone(),
218        }
219    }
220
221    /// Reconcile the tailer set against the registry, so nodes added or removed while forwarding is
222    /// on are picked up without an enable/disable cycle.
223    async fn refresh_tailers(&mut self) {
224        let registry = self.registry.read().await;
225        let (forwarding, _) = classify_nodes(&registry);
226
227        let live: Vec<u32> = forwarding.iter().map(|node| node.node_id).collect();
228        self.tailers.retain(|id, _| live.contains(id));
229
230        for node in &registry.list() {
231            let Some(log_dir) = node.log_dir.clone() else {
232                continue;
233            };
234            let tags = NodeTags::from_config(node);
235
236            match self.tailers.get_mut(&node.id) {
237                // Identity can change under us: an auto-upgrade replaces the binary and bumps the
238                // version, and events after that point should say so.
239                Some((_, existing_tags)) => *existing_tags = tags,
240                None => {
241                    let mut tailer = LogTailer::new(node.id, log_dir.clone());
242                    // A node whose files we already have positions for is being resumed, not newly
243                    // adopted, so it must not skip forward to the end of its log.
244                    if self.has_offsets_for(&log_dir) {
245                        tailer.mark_primed();
246                    }
247                    self.tailers.insert(node.id, (tailer, tags));
248                }
249            }
250        }
251    }
252
253    /// Whether persisted offsets already mention a file in this directory.
254    fn has_offsets_for(&self, log_dir: &std::path::Path) -> bool {
255        let prefix = log_dir.display().to_string();
256        self.offsets.keys().any(|key| key.starts_with(&prefix))
257    }
258
259    /// Ship everything currently queued, abandoning the moment forwarding is revoked.
260    ///
261    /// Both checks matter. The loop check stops a full queue from taking further batches after
262    /// `disable`, and the `select!` drops the delivery future mid-flight — which cancels the HTTP
263    /// request with it, since a dropped `reqwest` future cancels the request, and discards any
264    /// pending retry backoff along with it. Without the second, a `disable` issued at the wrong
265    /// moment would keep uploading for the length of the retry ladder.
266    async fn flush_queue(&mut self) {
267        while !self.queue.is_empty() {
268            if self.cancel.is_cancelled() {
269                return;
270            }
271
272            let batch = self
273                .queue
274                .take_batch(DEFAULT_BATCH_DOCUMENTS, DEFAULT_BATCH_BYTES);
275            if batch.is_empty() {
276                break;
277            }
278
279            let count = batch.len() as u64;
280            let delivery = deliver(self.sink.as_ref(), batch, self.retry, |delay| {
281                Box::pin(tokio::time::sleep(delay))
282            });
283
284            let report = tokio::select! {
285                biased;
286                () = self.cancel.cancelled() => {
287                    tracing::debug!(
288                        "log forwarding: revoked mid-delivery; abandoning {count} document(s)"
289                    );
290                    return;
291                }
292                report = delivery => report,
293            };
294
295            self.stats.events_forwarded += report.delivered;
296
297            if report.is_complete_success() {
298                self.stats.batches_sent += 1;
299                self.stats.last_success_unix = Some(now_unix_secs());
300                self.stats.last_error = None;
301            } else {
302                self.stats.batches_failed += 1;
303                self.stats.last_error = report.error.clone();
304                tracing::debug!(
305                    "log forwarding: {} of {count} documents did not reach {}: {}",
306                    report.rejected + report.abandoned,
307                    self.sink.describe(),
308                    report.error.as_deref().unwrap_or("no detail"),
309                );
310            }
311        }
312    }
313}
314
315/// Split the registry into nodes that can be forwarded and nodes that cannot.
316///
317/// Node file logging is off unless the user asked for it, so "cannot" is the common case on a
318/// default install. Reporting it is what stops `enable` looking like it worked while shipping
319/// nothing at all.
320pub fn classify_nodes(registry: &NodeRegistry) -> (Vec<ForwardingNode>, Vec<SkippedNode>) {
321    let mut forwarding = Vec::new();
322    let mut skipped = Vec::new();
323
324    let mut nodes = registry.list();
325    nodes.sort_by_key(|node| node.id);
326
327    for node in nodes {
328        match &node.log_dir {
329            Some(log_dir) => forwarding.push(ForwardingNode {
330                node_id: node.id,
331                service: node.service_name.clone(),
332                log_dir: log_dir.display().to_string(),
333            }),
334            None => skipped.push(SkippedNode::no_logging(node.id, node.service_name.clone())),
335        }
336    }
337
338    (forwarding, skipped)
339}
340
341fn now_unix_secs() -> u64 {
342    std::time::SystemTime::now()
343        .duration_since(std::time::UNIX_EPOCH)
344        .map(|d| d.as_secs())
345        .unwrap_or(0)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::node::daemon::forward::sink::mock::MockSink;
352    use crate::node::types::{EvmNetwork, NodeConfig, UpgradeChannel};
353    use std::collections::HashMap as StdHashMap;
354    use std::io::Write;
355
356    struct Harness {
357        _dir: tempfile::TempDir,
358        root: PathBuf,
359        registry: Arc<RwLock<NodeRegistry>>,
360    }
361
362    impl Harness {
363        /// One entry per node, in registry order: `true` means the node has logging enabled.
364        ///
365        /// Ids are not passed in because `NodeRegistry::add` assigns its own, starting at 1 — so
366        /// the nth entry here is node `n + 1`.
367        async fn new(nodes: &[bool]) -> Self {
368            let dir = tempfile::tempdir().unwrap();
369            let root = dir.path().to_path_buf();
370            let mut registry = NodeRegistry::load(&root.join("node_registry.json")).unwrap();
371
372            for (index, with_logging) in nodes.iter().enumerate() {
373                let id = index as u32 + 1;
374                let log_dir = with_logging.then(|| root.join(format!("logs-{id}")));
375                if let Some(ref path) = log_dir {
376                    std::fs::create_dir_all(path).unwrap();
377                }
378                registry.add(NodeConfig {
379                    id,
380                    service_name: format!("node{id}"),
381                    rewards_address: "0xabc".to_string(),
382                    data_dir: root.join(format!("data-{id}")),
383                    log_dir,
384                    node_port: None,
385                    binary_path: root.join("antnode"),
386                    version: "0.17.2-beta.1".to_string(),
387                    env_variables: StdHashMap::new(),
388                    bootstrap_peers: Vec::new(),
389                    upgrade_channel: Some(UpgradeChannel::Beta),
390                    evm_network: EvmNetwork::default(),
391                    eviction: None,
392                });
393            }
394
395            Self {
396                _dir: dir,
397                root,
398                registry: Arc::new(RwLock::new(registry)),
399            }
400        }
401
402        fn append(&self, node_id: u32, contents: &str) {
403            let path = self
404                .root
405                .join(format!("logs-{node_id}"))
406                .join("ant-node.2026-08-19.log");
407            let mut file = std::fs::OpenOptions::new()
408                .create(true)
409                .append(true)
410                .open(path)
411                .unwrap();
412            file.write_all(contents.as_bytes()).unwrap();
413        }
414
415        fn config(&self) -> LogForwardConfig {
416            LogForwardConfig {
417                enabled: true,
418                token: "test-key".to_string(),
419                ..LogForwardConfig::disabled()
420            }
421        }
422
423        fn offsets_path(&self) -> PathBuf {
424            self.root.join("offsets.json")
425        }
426    }
427
428    fn line(level: &str, message: &str) -> String {
429        format!("2026-08-19T20:50:00.123456Z  {level} ant_node::node: {message}\n")
430    }
431
432    /// Drive the forwarder for long enough to observe several poll cycles.
433    async fn run_briefly(handle: &ForwarderHandle) {
434        tokio::time::sleep(Duration::from_millis(220)).await;
435        handle.stop();
436        tokio::time::sleep(Duration::from_millis(60)).await;
437    }
438
439    #[tokio::test]
440    async fn forwards_a_nodes_log_lines_to_the_sink() {
441        let harness = Harness::new(&[true]).await;
442        let sink = Arc::new(MockSink::accepting());
443
444        let handle = spawn_log_forwarder(
445            harness.registry.clone(),
446            harness.config(),
447            sink.clone(),
448            harness.offsets_path(),
449            Duration::from_millis(30),
450            CancellationToken::new(),
451        );
452
453        // Written after the forwarder has joined the file at its end.
454        tokio::time::sleep(Duration::from_millis(60)).await;
455        harness.append(1, &line("INFO", "hello from node one"));
456        run_briefly(&handle).await;
457
458        let ids = sink.submitted_ids();
459        assert!(!ids.is_empty(), "nothing was forwarded");
460        assert!(handle.snapshot().await.stats.events_forwarded >= 1);
461    }
462
463    /// A node with no log directory must not stop the forwarder doing its job for the others.
464    #[tokio::test]
465    async fn a_node_without_a_log_directory_does_not_disturb_the_rest() {
466        let harness = Harness::new(&[true, false]).await;
467        let sink = Arc::new(MockSink::accepting());
468
469        let handle = spawn_log_forwarder(
470            harness.registry.clone(),
471            harness.config(),
472            sink.clone(),
473            harness.offsets_path(),
474            Duration::from_millis(30),
475            CancellationToken::new(),
476        );
477
478        tokio::time::sleep(Duration::from_millis(60)).await;
479        harness.append(1, &line("INFO", "from the node that does log"));
480        run_briefly(&handle).await;
481
482        assert!(!sink.submitted_ids().is_empty());
483        assert!(handle.snapshot().await.stats.events_forwarded >= 1);
484    }
485
486    #[tokio::test]
487    async fn stopping_the_handle_ends_forwarding() {
488        let harness = Harness::new(&[true]).await;
489        let sink = Arc::new(MockSink::accepting());
490
491        let handle = spawn_log_forwarder(
492            harness.registry.clone(),
493            harness.config(),
494            sink.clone(),
495            harness.offsets_path(),
496            Duration::from_millis(30),
497            CancellationToken::new(),
498        );
499
500        tokio::time::sleep(Duration::from_millis(60)).await;
501        handle.stop();
502        assert!(handle.is_stopped());
503        tokio::time::sleep(Duration::from_millis(60)).await;
504
505        let batches_after_stop = sink.batch_count();
506        harness.append(1, &line("INFO", "written after disable"));
507        tokio::time::sleep(Duration::from_millis(120)).await;
508
509        assert_eq!(
510            sink.batch_count(),
511            batches_after_stop,
512            "disable must stop the flow entirely"
513        );
514    }
515
516    /// `disable` must be a revocation boundary, not a request: once `stop_and_wait` returns, no
517    /// request may still be in flight.
518    ///
519    /// The sink here hangs mid-send, standing in for a slow endpoint. Before cancellation reached
520    /// the delivery loop, `disable` returned immediately and that send carried on through the whole
521    /// retry ladder — up to three 30s request timeouts plus backoff — while the CLI had already
522    /// told the user forwarding had stopped.
523    #[tokio::test]
524    async fn stopping_returns_only_once_delivery_has_actually_stopped() {
525        let harness = Harness::new(&[true]).await;
526        let release = Arc::new(tokio::sync::Notify::new());
527        let sink = Arc::new(MockSink::blocking(release.clone()));
528
529        let handle = spawn_log_forwarder(
530            harness.registry.clone(),
531            harness.config(),
532            sink.clone(),
533            harness.offsets_path(),
534            Duration::from_millis(30),
535            CancellationToken::new(),
536        );
537
538        tokio::time::sleep(Duration::from_millis(60)).await;
539        harness.append(1, &line("INFO", "caught mid-flight"));
540
541        // Wait until a send is genuinely in flight and stuck.
542        let mut waited = 0;
543        while sink.batch_count() == 0 && waited < 60 {
544            tokio::time::sleep(Duration::from_millis(20)).await;
545            waited += 1;
546        }
547        assert_eq!(sink.batch_count(), 1, "a send should be in flight");
548        assert_eq!(sink.completed_count(), 0, "and still blocked");
549
550        // The blocked send is never released; this must still return promptly.
551        let stopped = tokio::time::timeout(Duration::from_secs(5), handle.stop_and_wait()).await;
552        assert!(
553            stopped.is_ok(),
554            "stop_and_wait must not sit through the retry ladder"
555        );
556
557        assert_eq!(
558            sink.completed_count(),
559            0,
560            "the in-flight request must have been dropped, not allowed to finish"
561        );
562
563        // And nothing new may be sent afterwards.
564        let batches_at_stop = sink.batch_count();
565        harness.append(1, &line("INFO", "written after disable"));
566        tokio::time::sleep(Duration::from_millis(150)).await;
567        assert_eq!(
568            sink.batch_count(),
569            batches_at_stop,
570            "no request may start after disable has returned"
571        );
572    }
573
574    #[tokio::test]
575    async fn the_daemon_shutdown_token_also_stops_forwarding() {
576        let harness = Harness::new(&[true]).await;
577        let sink = Arc::new(MockSink::accepting());
578        let shutdown = CancellationToken::new();
579
580        let handle = spawn_log_forwarder(
581            harness.registry.clone(),
582            harness.config(),
583            sink.clone(),
584            harness.offsets_path(),
585            Duration::from_millis(30),
586            shutdown.clone(),
587        );
588
589        tokio::time::sleep(Duration::from_millis(60)).await;
590        shutdown.cancel();
591        tokio::time::sleep(Duration::from_millis(60)).await;
592        let batches = sink.batch_count();
593
594        harness.append(1, &line("INFO", "after shutdown"));
595        tokio::time::sleep(Duration::from_millis(120)).await;
596
597        assert_eq!(sink.batch_count(), batches);
598        drop(handle);
599    }
600
601    /// Offsets are written on the way out, so the next daemon resumes instead of replaying.
602    #[tokio::test]
603    async fn offsets_are_persisted_across_a_forwarder_restart() {
604        let harness = Harness::new(&[true]).await;
605        harness.append(1, &line("INFO", "before"));
606
607        let sink = Arc::new(MockSink::accepting());
608        let handle = spawn_log_forwarder(
609            harness.registry.clone(),
610            harness.config(),
611            sink.clone(),
612            harness.offsets_path(),
613            Duration::from_millis(30),
614            CancellationToken::new(),
615        );
616        tokio::time::sleep(Duration::from_millis(60)).await;
617        harness.append(1, &line("INFO", "first run"));
618        run_briefly(&handle).await;
619
620        let first_ids = sink.submitted_ids();
621        assert!(harness.offsets_path().exists(), "offsets must be persisted");
622
623        // A second forwarder over the same offsets file must not resend what the first shipped.
624        let second_sink = Arc::new(MockSink::accepting());
625        let second = spawn_log_forwarder(
626            harness.registry.clone(),
627            harness.config(),
628            second_sink.clone(),
629            harness.offsets_path(),
630            Duration::from_millis(30),
631            CancellationToken::new(),
632        );
633        run_briefly(&second).await;
634
635        let resent: Vec<String> = second_sink
636            .submitted_ids()
637            .into_iter()
638            .filter(|id| first_ids.contains(id))
639            .collect();
640        assert!(
641            resent.is_empty(),
642            "the second run re-sent documents the first had already delivered: {resent:?}"
643        );
644    }
645
646    #[tokio::test]
647    async fn events_below_the_minimum_level_never_reach_the_sink() {
648        let harness = Harness::new(&[true]).await;
649        let sink = Arc::new(MockSink::accepting());
650
651        let handle = spawn_log_forwarder(
652            harness.registry.clone(),
653            harness.config(),
654            sink.clone(),
655            harness.offsets_path(),
656            Duration::from_millis(30),
657            CancellationToken::new(),
658        );
659
660        tokio::time::sleep(Duration::from_millis(60)).await;
661        harness.append(1, &line("DEBUG", "chatter"));
662        harness.append(1, &line("TRACE", "more chatter"));
663        run_briefly(&handle).await;
664
665        assert!(sink.submitted_ids().is_empty());
666        assert!(handle.snapshot().await.stats.events_dropped_by_level >= 2);
667    }
668
669    #[tokio::test]
670    async fn classify_nodes_orders_by_id_and_separates_by_logging() {
671        // Nodes 1 and 3 have logging; node 2 does not.
672        let harness = Harness::new(&[true, false, true]).await;
673        let registry = harness.registry.read().await;
674        let (forwarding, skipped) = classify_nodes(&registry);
675
676        assert_eq!(
677            forwarding.iter().map(|n| n.node_id).collect::<Vec<_>>(),
678            vec![1, 3],
679            "forwarding nodes are listed in id order"
680        );
681        assert_eq!(
682            skipped.iter().map(|n| n.node_id).collect::<Vec<_>>(),
683            vec![2]
684        );
685    }
686}