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 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 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 async fn refresh_tailers(&mut self) {
224 let registry = self.registry.read().await;
225 let (forwarding, _) = classify_nodes(®istry);
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 ®istry.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 Some((_, existing_tags)) => *existing_tags = tags,
240 None => {
241 let mut tailer = LogTailer::new(node.id, log_dir.clone());
242 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 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 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
315pub 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 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 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 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 #[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 #[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 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 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 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 #[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 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 let harness = Harness::new(&[true, false, true]).await;
673 let registry = harness.registry.read().await;
674 let (forwarding, skipped) = classify_nodes(®istry);
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}