Skip to main content

ant_core/node/daemon/forward/
tail.rs

1//! Following a node's log files as they are written and rotated.
2//!
3//! ant-node rotates **daily by filename** (`ant-node.YYYY-MM-DD.log`) and prunes to
4//! `--log-max-files`, so "rotation" here is a new file appearing beside the old one rather than a
5//! cursor moving — which makes the tailer's job mostly bookkeeping over a sorted file list.
6//!
7//! Two behaviours are worth knowing about before reading the code:
8//!
9//! - **A partially written line is never emitted.** Only bytes up to the last newline in a chunk
10//!   are consumed, so an event that is still being written is picked up whole on the next poll.
11//! - **A multi-line event is not split across polls.** The last event of a chunk is held back and
12//!   the stored offset stays at its first byte, so its continuation lines — a panic and its
13//!   backtrace, most importantly — join it rather than becoming orphans. It is released once the
14//!   file stops growing.
15
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19use tokio::io::{AsyncReadExt, AsyncSeekExt};
20
21use super::config::LogLevel;
22use super::offsets::OffsetStore;
23use super::parse::{parse_line, LogEvent};
24use crate::error::Result;
25
26/// Filename prefix ant-node's rolling appender uses.
27const LOG_FILENAME_PREFIX: &str = "ant-node.";
28/// Filename suffix ant-node's rolling appender uses.
29const LOG_FILENAME_SUFFIX: &str = ".log";
30
31/// Most bytes read from one file in one poll, bounding the forwarder's memory when it is catching
32/// up on a node that logged heavily while the daemon was down.
33const MAX_CHUNK_BYTES: usize = 1024 * 1024;
34
35/// An event together with where it came from — everything the sink needs to build a stable `_id`.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TailedEvent {
38    pub node_id: u32,
39    /// Bare log filename, e.g. `ant-node.2026-08-19.log`.
40    pub file_name: String,
41    /// Byte position of the event's first line within that file.
42    pub byte_offset: u64,
43    pub event: LogEvent,
44}
45
46impl TailedEvent {
47    /// The document `_id` this event will be written under.
48    ///
49    /// Deterministic in the four things that identify the event uniquely — which installation,
50    /// which node, which file, which byte — so that replaying a batch after a transport failure
51    /// lands on the same `_id` and is rejected as a duplicate instead of writing a second copy.
52    ///
53    /// `installation_id` is not optional padding. Node id, filename and byte offset are all local
54    /// values: every participant has a node `1`, writing the same daily filename, whose first line
55    /// starts at offset 0. Since all of them write into one shared daily index and a 409 counts as
56    /// delivered, omitting the installation namespace would make the first event of each day from
57    /// each node collide across the whole cohort, and every loser would be silently discarded.
58    #[must_use]
59    pub fn document_id(&self, installation_id: &str) -> String {
60        format!(
61            "{}-{}-{}-{}",
62            installation_id, self.node_id, self.file_name, self.byte_offset
63        )
64    }
65}
66
67/// Follows one node's log directory.
68pub struct LogTailer {
69    node_id: u32,
70    log_dir: PathBuf,
71    /// False until the first poll has run. On that first poll, files already on disk are joined at
72    /// their end: enabling forwarding is a forward-looking consent, not a request to upload up to a
73    /// week of retained history.
74    primed: bool,
75    /// Length each file had at the previous poll, used to tell "still being written" from
76    /// "finished", so a held-back multi-line event is released once the file goes quiet.
77    last_seen_len: HashMap<String, u64>,
78}
79
80impl LogTailer {
81    #[must_use]
82    pub fn new(node_id: u32, log_dir: PathBuf) -> Self {
83        Self {
84            node_id,
85            log_dir,
86            primed: false,
87            last_seen_len: HashMap::new(),
88        }
89    }
90
91    #[must_use]
92    pub fn node_id(&self) -> u32 {
93        self.node_id
94    }
95
96    #[must_use]
97    pub fn log_dir(&self) -> &Path {
98        &self.log_dir
99    }
100
101    /// Adopt existing offsets rather than joining at the end.
102    ///
103    /// Called when the persisted offsets already mention this node, i.e. the daemon is restarting
104    /// rather than the user enabling forwarding for the first time.
105    pub fn mark_primed(&mut self) {
106        self.primed = true;
107    }
108
109    /// Read whatever has been appended since the last poll.
110    ///
111    /// Events below `min_level` are dropped here rather than downstream, so they never occupy queue
112    /// space: the endpoint discards them on arrival anyway, and shipping them would spend the
113    /// user's bandwidth for nothing.
114    pub async fn poll(
115        &mut self,
116        offsets: &mut OffsetStore,
117        min_level: LogLevel,
118    ) -> Result<PollOutcome> {
119        let files = self.discover_files().await?;
120        let mut outcome = PollOutcome::default();
121
122        for (index, path) in files.iter().enumerate() {
123            // Only the file currently being written is a candidate for backfilling from its start;
124            // see `poll_file`.
125            let is_newest = index + 1 == files.len();
126            match self.poll_file(path, offsets, min_level, is_newest).await {
127                Ok(mut file_outcome) => {
128                    outcome.events.append(&mut file_outcome.events);
129                    outcome.dropped_by_level += file_outcome.dropped_by_level;
130                }
131                // A file vanishing mid-poll is retention doing its job, not an error worth
132                // stopping the whole forwarder for.
133                Err(error) => {
134                    tracing::debug!(
135                        "log forwarding: skipping {} this poll: {error}",
136                        path.display()
137                    );
138                }
139            }
140        }
141
142        // Pruning is deliberately *not* done here. The offset store is shared by every tailer, so
143        // pruning it against one node's files would delete every other node's positions — leaving
144        // them to restart their current file from byte zero on the next cycle, forever. The live
145        // set is reported upwards instead, and the runner prunes once against the union.
146        let live: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
147        self.last_seen_len.retain(|key, _| live.contains(key));
148        self.primed = true;
149
150        outcome.live_files = live;
151        Ok(outcome)
152    }
153
154    /// List this node's log files, oldest first.
155    ///
156    /// The rolling appender's `ant-node.YYYY-MM-DD.log` names sort chronologically under a plain
157    /// lexicographic sort, so no date parsing is needed to get the order right.
158    async fn discover_files(&self) -> Result<Vec<PathBuf>> {
159        let mut entries = match tokio::fs::read_dir(&self.log_dir).await {
160            Ok(entries) => entries,
161            // The directory not existing yet is normal: it is created when the node first starts.
162            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
163            Err(error) => return Err(error.into()),
164        };
165
166        let mut files = Vec::new();
167        while let Some(entry) = entries.next_entry().await? {
168            let name = entry.file_name().to_string_lossy().to_string();
169            if name.starts_with(LOG_FILENAME_PREFIX) && name.ends_with(LOG_FILENAME_SUFFIX) {
170                files.push(entry.path());
171            }
172        }
173        files.sort();
174        Ok(files)
175    }
176
177    async fn poll_file(
178        &mut self,
179        path: &Path,
180        offsets: &mut OffsetStore,
181        min_level: LogLevel,
182        is_newest: bool,
183    ) -> Result<PollOutcome> {
184        let key = path.display().to_string();
185        let file_name = path
186            .file_name()
187            .map(|n| n.to_string_lossy().to_string())
188            .unwrap_or_else(|| key.clone());
189
190        let mut file = tokio::fs::File::open(path).await?;
191        let len = file.metadata().await?.len();
192
193        let mut start = match offsets.get(&key) {
194            Some(offset) => offset,
195            // A file we have never read. Joining at its end is the default, for two cases that
196            // amount to the same thing: the very first poll after enabling (forward-looking
197            // consent, not a request to upload the retained backlog), and any *older* daily that
198            // retention still holds. The latter matters after a fix or a config change leaves a
199            // tailer resuming with positions for some files and not others -- without it, a
200            // `--log-max-files` window of retained dailies (7 by default) would all be uploaded
201            // from byte zero at once. Only the file currently being written is backfilled.
202            None if !self.primed || !is_newest => len,
203            None => 0,
204        };
205
206        // The file shrank, so it was truncated or replaced under us. Anything we thought we had
207        // read is gone; the only safe cursor is the beginning.
208        if len < start {
209            tracing::debug!("log forwarding: {key} shrank; restarting from the beginning");
210            start = 0;
211        }
212
213        let was_growing = self.last_seen_len.insert(key.clone(), len) != Some(len);
214
215        if len == start {
216            offsets.set(&key, start);
217            return Ok(PollOutcome::default());
218        }
219
220        let to_read = usize::try_from(len - start)
221            .unwrap_or(MAX_CHUNK_BYTES)
222            .min(MAX_CHUNK_BYTES);
223        file.seek(std::io::SeekFrom::Start(start)).await?;
224        let mut buffer = vec![0u8; to_read];
225        let read = file.read_exact(&mut buffer).await.map(|_| to_read)?;
226        buffer.truncate(read);
227
228        let reached_eof = start + read as u64 >= len;
229
230        // Never emit a half-written line. If the chunk has no newline at all we are either mid-line
231        // or looking at a single line longer than the read cap; in the latter case, waiting forever
232        // would stall the file, so an over-long line is taken as-is.
233        let usable = match buffer.iter().rposition(|b| *b == b'\n') {
234            Some(index) => index + 1,
235            None if read == MAX_CHUNK_BYTES => read,
236            None => {
237                offsets.set(&key, start);
238                return Ok(PollOutcome::default());
239            }
240        };
241
242        let text = String::from_utf8_lossy(&buffer[..usable]).to_string();
243        let outcome = self.collect_events(
244            &text,
245            start,
246            &file_name,
247            min_level,
248            // Hold the final event back while the file is still growing, so its continuation lines
249            // can join it on the next poll.
250            reached_eof && !was_growing,
251        );
252
253        offsets.set(&key, outcome.next_offset);
254        Ok(outcome.into_poll_outcome())
255    }
256
257    /// Split a chunk into events, attaching continuation lines to the event above them.
258    fn collect_events(
259        &self,
260        text: &str,
261        chunk_start: u64,
262        file_name: &str,
263        min_level: LogLevel,
264        release_final_event: bool,
265    ) -> CollectOutcome {
266        let mut outcome = CollectOutcome {
267            next_offset: chunk_start,
268            ..CollectOutcome::default()
269        };
270        let mut pending: Option<TailedEvent> = None;
271        let mut cursor = chunk_start;
272
273        for line in text.split_inclusive('\n') {
274            let line_start = cursor;
275            cursor += line.len() as u64;
276            let content = line.trim_end_matches(['\n', '\r']);
277
278            match parse_line(content) {
279                Some(event) => {
280                    if let Some(previous) = pending.take() {
281                        outcome.push(previous, min_level);
282                    }
283                    // Everything before this event has now been emitted, so the cursor may safely
284                    // advance to its first byte — and no further, until it too is released.
285                    outcome.next_offset = line_start;
286                    pending = Some(TailedEvent {
287                        node_id: self.node_id,
288                        file_name: file_name.to_string(),
289                        byte_offset: line_start,
290                        event,
291                    });
292                }
293                None => match pending.as_mut() {
294                    Some(held) => held.event.push_continuation(content),
295                    // A continuation with nothing above it: the parent was emitted in an earlier
296                    // poll, or the file began mid-event. Nothing useful to attach it to.
297                    None => outcome.next_offset = cursor,
298                },
299            }
300        }
301
302        match pending {
303            Some(event) if release_final_event => {
304                outcome.push(event, min_level);
305                outcome.next_offset = cursor;
306            }
307            // Left pending: `next_offset` still points at its first byte, so the next poll re-reads
308            // it along with whatever continuation lines have since arrived.
309            Some(_) => {}
310            None => outcome.next_offset = cursor,
311        }
312
313        outcome
314    }
315}
316
317/// What one poll produced.
318#[derive(Debug, Default)]
319pub struct PollOutcome {
320    pub events: Vec<TailedEvent>,
321    /// Events discarded for being below the configured minimum level.
322    pub dropped_by_level: u64,
323    /// Log files this tailer can currently see, so the caller can prune the shared offset store
324    /// against every tailer's files at once rather than one node's view of them.
325    pub live_files: Vec<String>,
326}
327
328#[derive(Debug, Default)]
329struct CollectOutcome {
330    events: Vec<TailedEvent>,
331    dropped_by_level: u64,
332    next_offset: u64,
333}
334
335impl CollectOutcome {
336    fn push(&mut self, event: TailedEvent, min_level: LogLevel) {
337        if event.event.level < min_level {
338            self.dropped_by_level += 1;
339        } else {
340            self.events.push(event);
341        }
342    }
343
344    fn into_poll_outcome(self) -> PollOutcome {
345        PollOutcome {
346            events: self.events,
347            dropped_by_level: self.dropped_by_level,
348            live_files: Vec::new(),
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use std::io::Write;
357
358    fn line(level: &str, message: &str) -> String {
359        format!("2026-08-19T20:50:00.123456Z  {level} ant_node::node: {message}\n")
360    }
361
362    struct Fixture {
363        _dir: tempfile::TempDir,
364        log_dir: PathBuf,
365        offsets_path: PathBuf,
366    }
367
368    impl Fixture {
369        fn new() -> Self {
370            let dir = tempfile::tempdir().unwrap();
371            let log_dir = dir.path().join("logs");
372            std::fs::create_dir_all(&log_dir).unwrap();
373            let offsets_path = dir.path().join("offsets.json");
374            Self {
375                _dir: dir,
376                log_dir,
377                offsets_path,
378            }
379        }
380
381        fn append(&self, file_name: &str, contents: &str) {
382            let mut file = std::fs::OpenOptions::new()
383                .create(true)
384                .append(true)
385                .open(self.log_dir.join(file_name))
386                .unwrap();
387            file.write_all(contents.as_bytes()).unwrap();
388        }
389
390        fn offsets(&self) -> OffsetStore {
391            OffsetStore::load(&self.offsets_path)
392        }
393
394        fn tailer(&self) -> LogTailer {
395            LogTailer::new(7, self.log_dir.clone())
396        }
397    }
398
399    /// Poll until the file stops growing, merging what comes out.
400    ///
401    /// The tailer holds a growing file's final event back for one poll so that continuation lines
402    /// written just after it can join it, so observing an event that was only just appended takes
403    /// two polls. That is the intended trade — one poll interval of latency on the tail of a burst,
404    /// in exchange for panics arriving as one document instead of twenty.
405    async fn drain(
406        tailer: &mut LogTailer,
407        offsets: &mut OffsetStore,
408        min_level: LogLevel,
409    ) -> PollOutcome {
410        let mut merged = tailer.poll(offsets, min_level).await.unwrap();
411        let mut second = tailer.poll(offsets, min_level).await.unwrap();
412        merged.events.append(&mut second.events);
413        merged.dropped_by_level += second.dropped_by_level;
414        merged
415    }
416
417    /// Enabling forwarding must not upload the retained backlog: the first poll joins at the end.
418    #[tokio::test]
419    async fn the_first_poll_joins_existing_files_at_their_end() {
420        let fixture = Fixture::new();
421        fixture.append("ant-node.2026-08-19.log", &line("INFO", "historic"));
422
423        let mut tailer = fixture.tailer();
424        let mut offsets = fixture.offsets();
425        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
426
427        assert!(outcome.events.is_empty(), "history must not be shipped");
428
429        fixture.append("ant-node.2026-08-19.log", &line("INFO", "fresh"));
430        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
431        assert_eq!(outcome.events.len(), 1);
432        assert_eq!(outcome.events[0].event.message, "fresh");
433    }
434
435    #[tokio::test]
436    async fn a_restart_resumes_from_the_persisted_offset_without_duplicating() {
437        let fixture = Fixture::new();
438        fixture.append("ant-node.2026-08-19.log", &line("INFO", "first"));
439
440        let mut tailer = fixture.tailer();
441        let mut offsets = fixture.offsets();
442        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
443        fixture.append("ant-node.2026-08-19.log", &line("INFO", "second"));
444        let before = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
445        assert_eq!(before.events.len(), 1);
446        offsets.save().unwrap();
447
448        // A new daemon: fresh tailer, offsets reloaded from disk.
449        let mut restarted = fixture.tailer();
450        restarted.mark_primed();
451        let mut reloaded = fixture.offsets();
452
453        let outcome = drain(&mut restarted, &mut reloaded, LogLevel::Info).await;
454        assert!(outcome.events.is_empty(), "nothing new, nothing re-sent");
455
456        fixture.append("ant-node.2026-08-19.log", &line("INFO", "third"));
457        let outcome = drain(&mut restarted, &mut reloaded, LogLevel::Info).await;
458        assert_eq!(outcome.events.len(), 1);
459        assert_eq!(outcome.events[0].event.message, "third");
460    }
461
462    /// The gap half of "no duplication and no large gaps": lines written while the daemon was down
463    /// are still delivered, because the offset is behind them.
464    #[tokio::test]
465    async fn lines_written_while_the_daemon_was_down_are_not_lost() {
466        let fixture = Fixture::new();
467        fixture.append("ant-node.2026-08-19.log", &line("INFO", "before"));
468
469        let mut tailer = fixture.tailer();
470        let mut offsets = fixture.offsets();
471        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
472        offsets.save().unwrap();
473
474        fixture.append("ant-node.2026-08-19.log", &line("INFO", "during downtime"));
475
476        let mut restarted = fixture.tailer();
477        restarted.mark_primed();
478        let mut reloaded = fixture.offsets();
479        let outcome = drain(&mut restarted, &mut reloaded, LogLevel::Info).await;
480
481        assert_eq!(outcome.events.len(), 1);
482        assert_eq!(outcome.events[0].event.message, "during downtime");
483    }
484
485    /// Enabling forwarding *before* the node starts captures its whole first log file, including
486    /// the startup line carrying version, commit and peer id.
487    ///
488    /// The end-join rule only applies to files that already existed when forwarding was switched
489    /// on. A node that has not run yet has no files, so nothing is joined at the end, and the file
490    /// it later creates is read from byte zero like any other new file.
491    #[tokio::test]
492    async fn a_node_started_after_enabling_is_captured_from_its_first_line() {
493        let fixture = Fixture::new();
494
495        // Forwarding is enabled while the node has never run: the log directory is empty.
496        let mut tailer = fixture.tailer();
497        let mut offsets = fixture.offsets();
498        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
499        assert!(outcome.events.is_empty());
500
501        // The node now starts and writes its startup line.
502        fixture.append(
503            "ant-node.2026-08-19.log",
504            &format!(
505                "{}{}",
506                line("INFO", "starting version=0.17.2 commit=abc1234"),
507                line("INFO", "listening for connections"),
508            ),
509        );
510        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
511
512        let messages: Vec<&str> = outcome
513            .events
514            .iter()
515            .map(|e| e.event.message.as_str())
516            .collect();
517        assert_eq!(
518            messages,
519            vec![
520                "starting version=0.17.2 commit=abc1234",
521                "listening for connections"
522            ],
523            "the node's first line must not be skipped"
524        );
525        assert_eq!(outcome.events[0].byte_offset, 0);
526        assert_eq!(outcome.events[0].event.version.as_deref(), Some("0.17.2"));
527        assert_eq!(outcome.events[0].event.commit.as_deref(), Some("abc1234"));
528    }
529
530    /// The converse: enabling *after* the node is already running skips whatever it logged before
531    /// consent — including its startup line, and so the version/commit fields that come with it.
532    #[tokio::test]
533    async fn enabling_after_the_node_started_skips_its_startup_line() {
534        let fixture = Fixture::new();
535        fixture.append(
536            "ant-node.2026-08-19.log",
537            &line("INFO", "starting version=0.17.2 commit=abc1234"),
538        );
539
540        let mut tailer = fixture.tailer();
541        let mut offsets = fixture.offsets();
542        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
543        assert!(
544            outcome.events.is_empty(),
545            "pre-consent lines are not uploaded"
546        );
547
548        fixture.append("ant-node.2026-08-19.log", &line("INFO", "later activity"));
549        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
550
551        let messages: Vec<&str> = outcome
552            .events
553            .iter()
554            .map(|e| e.event.message.as_str())
555            .collect();
556        assert_eq!(messages, vec!["later activity"]);
557    }
558
559    #[tokio::test]
560    async fn a_new_days_file_is_read_from_the_beginning() {
561        let fixture = Fixture::new();
562        fixture.append("ant-node.2026-08-19.log", &line("INFO", "yesterday"));
563
564        let mut tailer = fixture.tailer();
565        let mut offsets = fixture.offsets();
566        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
567
568        fixture.append("ant-node.2026-08-20.log", &line("INFO", "today"));
569        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
570
571        assert_eq!(outcome.events.len(), 1);
572        assert_eq!(outcome.events[0].event.message, "today");
573        assert_eq!(outcome.events[0].file_name, "ant-node.2026-08-20.log");
574    }
575
576    #[tokio::test]
577    async fn a_truncated_file_restarts_from_the_beginning() {
578        let fixture = Fixture::new();
579        fixture.append("ant-node.2026-08-19.log", &line("INFO", "original content"));
580
581        let mut tailer = fixture.tailer();
582        let mut offsets = fixture.offsets();
583        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
584
585        std::fs::write(
586            fixture.log_dir.join("ant-node.2026-08-19.log"),
587            line("INFO", "new"),
588        )
589        .unwrap();
590        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
591
592        assert_eq!(outcome.events.len(), 1);
593        assert_eq!(outcome.events[0].event.message, "new");
594    }
595
596    #[tokio::test]
597    async fn a_partially_written_line_is_held_until_it_is_complete() {
598        let fixture = Fixture::new();
599        fixture.append("ant-node.2026-08-19.log", &line("INFO", "complete"));
600
601        let mut tailer = fixture.tailer();
602        let mut offsets = fixture.offsets();
603        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
604
605        fixture.append(
606            "ant-node.2026-08-19.log",
607            "2026-08-19T20:50:01.000000Z  INFO ant_node::node: half a li",
608        );
609        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
610        assert!(outcome.events.is_empty(), "a partial line is not an event");
611
612        fixture.append("ant-node.2026-08-19.log", "ne here\n");
613        // One poll observes the new length; the next releases the now-quiet final event.
614        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
615        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
616
617        assert_eq!(outcome.events.len(), 1);
618        assert_eq!(outcome.events[0].event.message, "half a line here");
619    }
620
621    #[tokio::test]
622    async fn a_panic_and_its_backtrace_stay_one_event() {
623        let fixture = Fixture::new();
624        fixture.append("ant-node.2026-08-19.log", &line("INFO", "before the panic"));
625
626        let mut tailer = fixture.tailer();
627        let mut offsets = fixture.offsets();
628        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
629
630        fixture.append(
631            "ant-node.2026-08-19.log",
632            &format!(
633                "{}thread 'main' panicked\n  at src/node.rs:42\n",
634                line("ERROR", "it broke")
635            ),
636        );
637        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
638        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
639
640        assert_eq!(outcome.events.len(), 1);
641        assert_eq!(
642            outcome.events[0].event.message,
643            "it broke\nthread 'main' panicked\n  at src/node.rs:42"
644        );
645    }
646
647    #[tokio::test]
648    async fn events_below_the_minimum_level_are_dropped_and_counted() {
649        let fixture = Fixture::new();
650        fixture.append("ant-node.2026-08-19.log", &line("INFO", "seed"));
651
652        let mut tailer = fixture.tailer();
653        let mut offsets = fixture.offsets();
654        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
655
656        fixture.append(
657            "ant-node.2026-08-19.log",
658            &format!(
659                "{}{}{}",
660                line("DEBUG", "chatter"),
661                line("TRACE", "more chatter"),
662                line("WARN", "worth keeping")
663            ),
664        );
665        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
666
667        let messages: Vec<&str> = outcome
668            .events
669            .iter()
670            .map(|e| e.event.message.as_str())
671            .collect();
672        assert_eq!(messages, vec!["worth keeping"]);
673        assert_eq!(outcome.dropped_by_level, 2);
674    }
675
676    #[tokio::test]
677    async fn a_missing_log_directory_is_not_an_error() {
678        let dir = tempfile::tempdir().unwrap();
679        let mut tailer = LogTailer::new(1, dir.path().join("never-created"));
680        let mut offsets = OffsetStore::load(&dir.path().join("offsets.json"));
681
682        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
683        assert!(outcome.events.is_empty());
684    }
685
686    #[tokio::test]
687    async fn unrelated_files_in_the_log_directory_are_ignored() {
688        let fixture = Fixture::new();
689        fixture.append("ant-node.2026-08-19.log", &line("INFO", "seed"));
690        fixture.append("notes.txt", "not a log file\n");
691        fixture.append("ant-node.2026-08-19.log.gz", "compressed\n");
692
693        let mut tailer = fixture.tailer();
694        let mut offsets = fixture.offsets();
695        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
696
697        assert_eq!(offsets.len(), 1, "only the rolling log file is tracked");
698    }
699
700    /// The tailer reports what it can see; pruning against that view is the runner's job, because
701    /// only the runner sees every node's files. See
702    /// `runner::tests::offsets_for_retention_deleted_files_are_pruned`.
703    #[tokio::test]
704    async fn a_retention_deleted_file_drops_out_of_the_reported_live_set() {
705        let fixture = Fixture::new();
706        fixture.append("ant-node.2026-08-18.log", &line("INFO", "old"));
707        fixture.append("ant-node.2026-08-19.log", &line("INFO", "current"));
708
709        let mut tailer = fixture.tailer();
710        let mut offsets = fixture.offsets();
711        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
712        assert_eq!(outcome.live_files.len(), 2);
713        assert_eq!(offsets.len(), 2);
714
715        std::fs::remove_file(fixture.log_dir.join("ant-node.2026-08-18.log")).unwrap();
716        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
717
718        assert_eq!(
719            outcome.live_files.len(),
720            1,
721            "the deleted file is no longer live"
722        );
723        assert!(outcome.live_files[0].ends_with("ant-node.2026-08-19.log"));
724    }
725
726    /// A tailer must not evict positions from the shared store: with several nodes forwarding, the
727    /// store holds files this tailer has never heard of.
728    #[tokio::test]
729    async fn polling_does_not_evict_another_nodes_offsets() {
730        let fixture = Fixture::new();
731        fixture.append("ant-node.2026-08-19.log", &line("INFO", "mine"));
732
733        let mut tailer = fixture.tailer();
734        let mut offsets = fixture.offsets();
735        offsets.set("/some/other/node/logs/ant-node.2026-08-19.log", 4242);
736
737        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
738
739        assert_eq!(
740            offsets.get("/some/other/node/logs/ant-node.2026-08-19.log"),
741            Some(4242),
742            "another node's position must survive this tailer's poll"
743        );
744    }
745
746    const INSTALL_A: &str = "0123456789abcdef";
747    const INSTALL_B: &str = "fedcba9876543210";
748
749    #[test]
750    fn the_document_id_is_stable_and_position_derived() {
751        let event = TailedEvent {
752            node_id: 7,
753            file_name: "ant-node.2026-08-19.log".to_string(),
754            byte_offset: 104_857,
755            event: parse_line(&line("INFO", "hello")).unwrap(),
756        };
757
758        assert_eq!(
759            event.document_id(INSTALL_A),
760            "0123456789abcdef-7-ant-node.2026-08-19.log-104857"
761        );
762        assert_eq!(
763            event.document_id(INSTALL_A),
764            event.clone().document_id(INSTALL_A)
765        );
766        assert!(
767            event.document_id(INSTALL_A).len() <= 512,
768            "Elasticsearch caps _id at 512 bytes"
769        );
770    }
771
772    /// The collision this namespace exists to prevent. Two participants each run a node `1` whose
773    /// daily log file has the same name and whose first line is at offset 0; without the
774    /// installation prefix both produce the same `_id`, and because every participant writes into
775    /// one shared daily index and the sink counts a 409 as delivered, the second one's event would
776    /// be dropped on the floor rather than stored.
777    #[test]
778    fn identical_positions_on_two_installations_do_not_collide() {
779        let same_event = || TailedEvent {
780            node_id: 1,
781            file_name: "ant-node.2026-08-19.log".to_string(),
782            byte_offset: 0,
783            event: parse_line(&line("INFO", "starting version=0.17.2")).unwrap(),
784        };
785
786        assert_ne!(
787            same_event().document_id(INSTALL_A),
788            same_event().document_id(INSTALL_B),
789            "the same local position on two machines must not share a document id"
790        );
791    }
792
793    #[test]
794    fn document_ids_differ_across_nodes_files_and_positions() {
795        let base = TailedEvent {
796            node_id: 7,
797            file_name: "ant-node.2026-08-19.log".to_string(),
798            byte_offset: 100,
799            event: parse_line(&line("INFO", "hello")).unwrap(),
800        };
801        let other_node = TailedEvent {
802            node_id: 8,
803            ..base.clone()
804        };
805        let other_file = TailedEvent {
806            file_name: "ant-node.2026-08-20.log".to_string(),
807            ..base.clone()
808        };
809        let other_offset = TailedEvent {
810            byte_offset: 200,
811            ..base.clone()
812        };
813
814        let ids = [
815            base.document_id(INSTALL_A),
816            other_node.document_id(INSTALL_A),
817            other_file.document_id(INSTALL_A),
818            other_offset.document_id(INSTALL_A),
819        ];
820        let unique: std::collections::HashSet<&String> = ids.iter().collect();
821        assert_eq!(unique.len(), 4);
822    }
823
824    /// Reproduction: a day's file larger than MAX_CHUNK_BYTES must be shipped in full, not
825    /// stalled after the first chunk.
826    #[tokio::test]
827    async fn a_file_larger_than_one_chunk_is_read_to_the_end() {
828        let fixture = Fixture::new();
829        let mut tailer = fixture.tailer();
830        tailer.mark_primed();
831        let mut offsets = fixture.offsets();
832
833        // Build a file of ~3 chunks.
834        let one = line(
835            "INFO",
836            "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
837        );
838        let per_chunk = (MAX_CHUNK_BYTES / one.len()) + 1;
839        let total = per_chunk * 3;
840        let mut blob = String::with_capacity(total * one.len());
841        for i in 0..total {
842            blob.push_str(&format!(
843                "2026-08-19T20:50:00.123456Z  INFO ant_node::node: event {i}\n"
844            ));
845        }
846        let file_len = blob.len() as u64;
847        fixture.append("ant-node.2026-08-19.log", &blob);
848
849        // Poll repeatedly; a correct tailer reaches the end of the file.
850        let mut seen = 0usize;
851        for _ in 0..50 {
852            let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
853            seen += outcome.events.len();
854        }
855
856        let key = fixture
857            .log_dir
858            .join("ant-node.2026-08-19.log")
859            .display()
860            .to_string();
861        let final_offset = offsets.get(&key).unwrap_or(0);
862        eprintln!(
863            "file_len={file_len} final_offset={final_offset} events_seen={seen} expected={total} MAX_CHUNK_BYTES={MAX_CHUNK_BYTES}"
864        );
865        assert_eq!(
866            seen, total,
867            "every event must be shipped; stalled at offset {final_offset} of {file_len}"
868        );
869    }
870
871    /// A tailer resuming with positions for some files but not others must not upload the whole
872    /// retained window. Only the file being written is backfilled; older dailies join at their end.
873    #[tokio::test]
874    async fn older_unseen_dailies_are_not_backfilled() {
875        let fixture = Fixture::new();
876        for day in ["16", "17", "18"] {
877            fixture.append(
878                &format!("ant-node.2026-08-{day}.log"),
879                &line("INFO", &format!("retained history from the {day}th")),
880            );
881        }
882        fixture.append("ant-node.2026-08-19.log", &line("INFO", "today"));
883
884        // Primed: the daemon is resuming, not adopting these logs for the first time.
885        let mut tailer = fixture.tailer();
886        tailer.mark_primed();
887        let mut offsets = fixture.offsets();
888
889        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
890
891        let messages: Vec<&str> = outcome
892            .events
893            .iter()
894            .map(|event| event.event.message.as_str())
895            .collect();
896        assert_eq!(
897            messages,
898            vec!["today"],
899            "only the newest daily is backfilled; the retained window must be left alone"
900        );
901    }
902
903    /// The complement: a genuinely new day's file is still read from its first line, because it is
904    /// the newest.
905    #[tokio::test]
906    async fn the_newest_daily_is_still_backfilled_from_its_start() {
907        let fixture = Fixture::new();
908        fixture.append("ant-node.2026-08-18.log", &line("INFO", "yesterday"));
909
910        let mut tailer = fixture.tailer();
911        tailer.mark_primed();
912        let mut offsets = fixture.offsets();
913        drain(&mut tailer, &mut offsets, LogLevel::Info).await;
914
915        fixture.append("ant-node.2026-08-19.log", &line("INFO", "today"));
916        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
917
918        assert_eq!(outcome.events.len(), 1);
919        assert_eq!(outcome.events[0].event.message, "today");
920    }
921}