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 path in &files {
123            match self.poll_file(path, offsets, min_level).await {
124                Ok(mut file_outcome) => {
125                    outcome.events.append(&mut file_outcome.events);
126                    outcome.dropped_by_level += file_outcome.dropped_by_level;
127                }
128                // A file vanishing mid-poll is retention doing its job, not an error worth
129                // stopping the whole forwarder for.
130                Err(error) => {
131                    tracing::debug!(
132                        "log forwarding: skipping {} this poll: {error}",
133                        path.display()
134                    );
135                }
136            }
137        }
138
139        let live: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
140        offsets.prune(&live);
141        self.last_seen_len.retain(|key, _| live.contains(key));
142        self.primed = true;
143
144        Ok(outcome)
145    }
146
147    /// List this node's log files, oldest first.
148    ///
149    /// The rolling appender's `ant-node.YYYY-MM-DD.log` names sort chronologically under a plain
150    /// lexicographic sort, so no date parsing is needed to get the order right.
151    async fn discover_files(&self) -> Result<Vec<PathBuf>> {
152        let mut entries = match tokio::fs::read_dir(&self.log_dir).await {
153            Ok(entries) => entries,
154            // The directory not existing yet is normal: it is created when the node first starts.
155            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
156            Err(error) => return Err(error.into()),
157        };
158
159        let mut files = Vec::new();
160        while let Some(entry) = entries.next_entry().await? {
161            let name = entry.file_name().to_string_lossy().to_string();
162            if name.starts_with(LOG_FILENAME_PREFIX) && name.ends_with(LOG_FILENAME_SUFFIX) {
163                files.push(entry.path());
164            }
165        }
166        files.sort();
167        Ok(files)
168    }
169
170    async fn poll_file(
171        &mut self,
172        path: &Path,
173        offsets: &mut OffsetStore,
174        min_level: LogLevel,
175    ) -> Result<PollOutcome> {
176        let key = path.display().to_string();
177        let file_name = path
178            .file_name()
179            .map(|n| n.to_string_lossy().to_string())
180            .unwrap_or_else(|| key.clone());
181
182        let mut file = tokio::fs::File::open(path).await?;
183        let len = file.metadata().await?.len();
184
185        let mut start = match offsets.get(&key) {
186            Some(offset) => offset,
187            // A file we have never read: join at the end on the very first poll after enabling,
188            // otherwise (a new day's file, or a node added later) read it from the beginning.
189            None if !self.primed => len,
190            None => 0,
191        };
192
193        // The file shrank, so it was truncated or replaced under us. Anything we thought we had
194        // read is gone; the only safe cursor is the beginning.
195        if len < start {
196            tracing::debug!("log forwarding: {key} shrank; restarting from the beginning");
197            start = 0;
198        }
199
200        let was_growing = self.last_seen_len.insert(key.clone(), len) != Some(len);
201
202        if len == start {
203            offsets.set(&key, start);
204            return Ok(PollOutcome::default());
205        }
206
207        let to_read = usize::try_from(len - start)
208            .unwrap_or(MAX_CHUNK_BYTES)
209            .min(MAX_CHUNK_BYTES);
210        file.seek(std::io::SeekFrom::Start(start)).await?;
211        let mut buffer = vec![0u8; to_read];
212        let read = file.read_exact(&mut buffer).await.map(|_| to_read)?;
213        buffer.truncate(read);
214
215        let reached_eof = start + read as u64 >= len;
216
217        // Never emit a half-written line. If the chunk has no newline at all we are either mid-line
218        // or looking at a single line longer than the read cap; in the latter case, waiting forever
219        // would stall the file, so an over-long line is taken as-is.
220        let usable = match buffer.iter().rposition(|b| *b == b'\n') {
221            Some(index) => index + 1,
222            None if read == MAX_CHUNK_BYTES => read,
223            None => {
224                offsets.set(&key, start);
225                return Ok(PollOutcome::default());
226            }
227        };
228
229        let text = String::from_utf8_lossy(&buffer[..usable]).to_string();
230        let outcome = self.collect_events(
231            &text,
232            start,
233            &file_name,
234            min_level,
235            // Hold the final event back while the file is still growing, so its continuation lines
236            // can join it on the next poll.
237            reached_eof && !was_growing,
238        );
239
240        offsets.set(&key, outcome.next_offset);
241        Ok(outcome.into_poll_outcome())
242    }
243
244    /// Split a chunk into events, attaching continuation lines to the event above them.
245    fn collect_events(
246        &self,
247        text: &str,
248        chunk_start: u64,
249        file_name: &str,
250        min_level: LogLevel,
251        release_final_event: bool,
252    ) -> CollectOutcome {
253        let mut outcome = CollectOutcome {
254            next_offset: chunk_start,
255            ..CollectOutcome::default()
256        };
257        let mut pending: Option<TailedEvent> = None;
258        let mut cursor = chunk_start;
259
260        for line in text.split_inclusive('\n') {
261            let line_start = cursor;
262            cursor += line.len() as u64;
263            let content = line.trim_end_matches(['\n', '\r']);
264
265            match parse_line(content) {
266                Some(event) => {
267                    if let Some(previous) = pending.take() {
268                        outcome.push(previous, min_level);
269                    }
270                    // Everything before this event has now been emitted, so the cursor may safely
271                    // advance to its first byte — and no further, until it too is released.
272                    outcome.next_offset = line_start;
273                    pending = Some(TailedEvent {
274                        node_id: self.node_id,
275                        file_name: file_name.to_string(),
276                        byte_offset: line_start,
277                        event,
278                    });
279                }
280                None => match pending.as_mut() {
281                    Some(held) => held.event.push_continuation(content),
282                    // A continuation with nothing above it: the parent was emitted in an earlier
283                    // poll, or the file began mid-event. Nothing useful to attach it to.
284                    None => outcome.next_offset = cursor,
285                },
286            }
287        }
288
289        match pending {
290            Some(event) if release_final_event => {
291                outcome.push(event, min_level);
292                outcome.next_offset = cursor;
293            }
294            // Left pending: `next_offset` still points at its first byte, so the next poll re-reads
295            // it along with whatever continuation lines have since arrived.
296            Some(_) => {}
297            None => outcome.next_offset = cursor,
298        }
299
300        outcome
301    }
302}
303
304/// What one poll produced.
305#[derive(Debug, Default)]
306pub struct PollOutcome {
307    pub events: Vec<TailedEvent>,
308    /// Events discarded for being below the configured minimum level.
309    pub dropped_by_level: u64,
310}
311
312#[derive(Debug, Default)]
313struct CollectOutcome {
314    events: Vec<TailedEvent>,
315    dropped_by_level: u64,
316    next_offset: u64,
317}
318
319impl CollectOutcome {
320    fn push(&mut self, event: TailedEvent, min_level: LogLevel) {
321        if event.event.level < min_level {
322            self.dropped_by_level += 1;
323        } else {
324            self.events.push(event);
325        }
326    }
327
328    fn into_poll_outcome(self) -> PollOutcome {
329        PollOutcome {
330            events: self.events,
331            dropped_by_level: self.dropped_by_level,
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use std::io::Write;
340
341    fn line(level: &str, message: &str) -> String {
342        format!("2026-08-19T20:50:00.123456Z  {level} ant_node::node: {message}\n")
343    }
344
345    struct Fixture {
346        _dir: tempfile::TempDir,
347        log_dir: PathBuf,
348        offsets_path: PathBuf,
349    }
350
351    impl Fixture {
352        fn new() -> Self {
353            let dir = tempfile::tempdir().unwrap();
354            let log_dir = dir.path().join("logs");
355            std::fs::create_dir_all(&log_dir).unwrap();
356            let offsets_path = dir.path().join("offsets.json");
357            Self {
358                _dir: dir,
359                log_dir,
360                offsets_path,
361            }
362        }
363
364        fn append(&self, file_name: &str, contents: &str) {
365            let mut file = std::fs::OpenOptions::new()
366                .create(true)
367                .append(true)
368                .open(self.log_dir.join(file_name))
369                .unwrap();
370            file.write_all(contents.as_bytes()).unwrap();
371        }
372
373        fn offsets(&self) -> OffsetStore {
374            OffsetStore::load(&self.offsets_path)
375        }
376
377        fn tailer(&self) -> LogTailer {
378            LogTailer::new(7, self.log_dir.clone())
379        }
380    }
381
382    /// Poll until the file stops growing, merging what comes out.
383    ///
384    /// The tailer holds a growing file's final event back for one poll so that continuation lines
385    /// written just after it can join it, so observing an event that was only just appended takes
386    /// two polls. That is the intended trade — one poll interval of latency on the tail of a burst,
387    /// in exchange for panics arriving as one document instead of twenty.
388    async fn drain(
389        tailer: &mut LogTailer,
390        offsets: &mut OffsetStore,
391        min_level: LogLevel,
392    ) -> PollOutcome {
393        let mut merged = tailer.poll(offsets, min_level).await.unwrap();
394        let mut second = tailer.poll(offsets, min_level).await.unwrap();
395        merged.events.append(&mut second.events);
396        merged.dropped_by_level += second.dropped_by_level;
397        merged
398    }
399
400    /// Enabling forwarding must not upload the retained backlog: the first poll joins at the end.
401    #[tokio::test]
402    async fn the_first_poll_joins_existing_files_at_their_end() {
403        let fixture = Fixture::new();
404        fixture.append("ant-node.2026-08-19.log", &line("INFO", "historic"));
405
406        let mut tailer = fixture.tailer();
407        let mut offsets = fixture.offsets();
408        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
409
410        assert!(outcome.events.is_empty(), "history must not be shipped");
411
412        fixture.append("ant-node.2026-08-19.log", &line("INFO", "fresh"));
413        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
414        assert_eq!(outcome.events.len(), 1);
415        assert_eq!(outcome.events[0].event.message, "fresh");
416    }
417
418    #[tokio::test]
419    async fn a_restart_resumes_from_the_persisted_offset_without_duplicating() {
420        let fixture = Fixture::new();
421        fixture.append("ant-node.2026-08-19.log", &line("INFO", "first"));
422
423        let mut tailer = fixture.tailer();
424        let mut offsets = fixture.offsets();
425        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
426        fixture.append("ant-node.2026-08-19.log", &line("INFO", "second"));
427        let before = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
428        assert_eq!(before.events.len(), 1);
429        offsets.save().unwrap();
430
431        // A new daemon: fresh tailer, offsets reloaded from disk.
432        let mut restarted = fixture.tailer();
433        restarted.mark_primed();
434        let mut reloaded = fixture.offsets();
435
436        let outcome = drain(&mut restarted, &mut reloaded, LogLevel::Info).await;
437        assert!(outcome.events.is_empty(), "nothing new, nothing re-sent");
438
439        fixture.append("ant-node.2026-08-19.log", &line("INFO", "third"));
440        let outcome = drain(&mut restarted, &mut reloaded, LogLevel::Info).await;
441        assert_eq!(outcome.events.len(), 1);
442        assert_eq!(outcome.events[0].event.message, "third");
443    }
444
445    /// The gap half of "no duplication and no large gaps": lines written while the daemon was down
446    /// are still delivered, because the offset is behind them.
447    #[tokio::test]
448    async fn lines_written_while_the_daemon_was_down_are_not_lost() {
449        let fixture = Fixture::new();
450        fixture.append("ant-node.2026-08-19.log", &line("INFO", "before"));
451
452        let mut tailer = fixture.tailer();
453        let mut offsets = fixture.offsets();
454        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
455        offsets.save().unwrap();
456
457        fixture.append("ant-node.2026-08-19.log", &line("INFO", "during downtime"));
458
459        let mut restarted = fixture.tailer();
460        restarted.mark_primed();
461        let mut reloaded = fixture.offsets();
462        let outcome = drain(&mut restarted, &mut reloaded, LogLevel::Info).await;
463
464        assert_eq!(outcome.events.len(), 1);
465        assert_eq!(outcome.events[0].event.message, "during downtime");
466    }
467
468    /// Enabling forwarding *before* the node starts captures its whole first log file, including
469    /// the startup line carrying version, commit and peer id.
470    ///
471    /// The end-join rule only applies to files that already existed when forwarding was switched
472    /// on. A node that has not run yet has no files, so nothing is joined at the end, and the file
473    /// it later creates is read from byte zero like any other new file.
474    #[tokio::test]
475    async fn a_node_started_after_enabling_is_captured_from_its_first_line() {
476        let fixture = Fixture::new();
477
478        // Forwarding is enabled while the node has never run: the log directory is empty.
479        let mut tailer = fixture.tailer();
480        let mut offsets = fixture.offsets();
481        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
482        assert!(outcome.events.is_empty());
483
484        // The node now starts and writes its startup line.
485        fixture.append(
486            "ant-node.2026-08-19.log",
487            &format!(
488                "{}{}",
489                line("INFO", "starting version=0.17.2 commit=abc1234"),
490                line("INFO", "listening for connections"),
491            ),
492        );
493        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
494
495        let messages: Vec<&str> = outcome
496            .events
497            .iter()
498            .map(|e| e.event.message.as_str())
499            .collect();
500        assert_eq!(
501            messages,
502            vec![
503                "starting version=0.17.2 commit=abc1234",
504                "listening for connections"
505            ],
506            "the node's first line must not be skipped"
507        );
508        assert_eq!(outcome.events[0].byte_offset, 0);
509        assert_eq!(outcome.events[0].event.version.as_deref(), Some("0.17.2"));
510        assert_eq!(outcome.events[0].event.commit.as_deref(), Some("abc1234"));
511    }
512
513    /// The converse: enabling *after* the node is already running skips whatever it logged before
514    /// consent — including its startup line, and so the version/commit fields that come with it.
515    #[tokio::test]
516    async fn enabling_after_the_node_started_skips_its_startup_line() {
517        let fixture = Fixture::new();
518        fixture.append(
519            "ant-node.2026-08-19.log",
520            &line("INFO", "starting version=0.17.2 commit=abc1234"),
521        );
522
523        let mut tailer = fixture.tailer();
524        let mut offsets = fixture.offsets();
525        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
526        assert!(
527            outcome.events.is_empty(),
528            "pre-consent lines are not uploaded"
529        );
530
531        fixture.append("ant-node.2026-08-19.log", &line("INFO", "later activity"));
532        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
533
534        let messages: Vec<&str> = outcome
535            .events
536            .iter()
537            .map(|e| e.event.message.as_str())
538            .collect();
539        assert_eq!(messages, vec!["later activity"]);
540    }
541
542    #[tokio::test]
543    async fn a_new_days_file_is_read_from_the_beginning() {
544        let fixture = Fixture::new();
545        fixture.append("ant-node.2026-08-19.log", &line("INFO", "yesterday"));
546
547        let mut tailer = fixture.tailer();
548        let mut offsets = fixture.offsets();
549        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
550
551        fixture.append("ant-node.2026-08-20.log", &line("INFO", "today"));
552        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
553
554        assert_eq!(outcome.events.len(), 1);
555        assert_eq!(outcome.events[0].event.message, "today");
556        assert_eq!(outcome.events[0].file_name, "ant-node.2026-08-20.log");
557    }
558
559    #[tokio::test]
560    async fn a_truncated_file_restarts_from_the_beginning() {
561        let fixture = Fixture::new();
562        fixture.append("ant-node.2026-08-19.log", &line("INFO", "original content"));
563
564        let mut tailer = fixture.tailer();
565        let mut offsets = fixture.offsets();
566        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
567
568        std::fs::write(
569            fixture.log_dir.join("ant-node.2026-08-19.log"),
570            line("INFO", "new"),
571        )
572        .unwrap();
573        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
574
575        assert_eq!(outcome.events.len(), 1);
576        assert_eq!(outcome.events[0].event.message, "new");
577    }
578
579    #[tokio::test]
580    async fn a_partially_written_line_is_held_until_it_is_complete() {
581        let fixture = Fixture::new();
582        fixture.append("ant-node.2026-08-19.log", &line("INFO", "complete"));
583
584        let mut tailer = fixture.tailer();
585        let mut offsets = fixture.offsets();
586        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
587
588        fixture.append(
589            "ant-node.2026-08-19.log",
590            "2026-08-19T20:50:01.000000Z  INFO ant_node::node: half a li",
591        );
592        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
593        assert!(outcome.events.is_empty(), "a partial line is not an event");
594
595        fixture.append("ant-node.2026-08-19.log", "ne here\n");
596        // One poll observes the new length; the next releases the now-quiet final event.
597        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
598        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
599
600        assert_eq!(outcome.events.len(), 1);
601        assert_eq!(outcome.events[0].event.message, "half a line here");
602    }
603
604    #[tokio::test]
605    async fn a_panic_and_its_backtrace_stay_one_event() {
606        let fixture = Fixture::new();
607        fixture.append("ant-node.2026-08-19.log", &line("INFO", "before the panic"));
608
609        let mut tailer = fixture.tailer();
610        let mut offsets = fixture.offsets();
611        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
612
613        fixture.append(
614            "ant-node.2026-08-19.log",
615            &format!(
616                "{}thread 'main' panicked\n  at src/node.rs:42\n",
617                line("ERROR", "it broke")
618            ),
619        );
620        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
621        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
622
623        assert_eq!(outcome.events.len(), 1);
624        assert_eq!(
625            outcome.events[0].event.message,
626            "it broke\nthread 'main' panicked\n  at src/node.rs:42"
627        );
628    }
629
630    #[tokio::test]
631    async fn events_below_the_minimum_level_are_dropped_and_counted() {
632        let fixture = Fixture::new();
633        fixture.append("ant-node.2026-08-19.log", &line("INFO", "seed"));
634
635        let mut tailer = fixture.tailer();
636        let mut offsets = fixture.offsets();
637        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
638
639        fixture.append(
640            "ant-node.2026-08-19.log",
641            &format!(
642                "{}{}{}",
643                line("DEBUG", "chatter"),
644                line("TRACE", "more chatter"),
645                line("WARN", "worth keeping")
646            ),
647        );
648        let outcome = drain(&mut tailer, &mut offsets, LogLevel::Info).await;
649
650        let messages: Vec<&str> = outcome
651            .events
652            .iter()
653            .map(|e| e.event.message.as_str())
654            .collect();
655        assert_eq!(messages, vec!["worth keeping"]);
656        assert_eq!(outcome.dropped_by_level, 2);
657    }
658
659    #[tokio::test]
660    async fn a_missing_log_directory_is_not_an_error() {
661        let dir = tempfile::tempdir().unwrap();
662        let mut tailer = LogTailer::new(1, dir.path().join("never-created"));
663        let mut offsets = OffsetStore::load(&dir.path().join("offsets.json"));
664
665        let outcome = tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
666        assert!(outcome.events.is_empty());
667    }
668
669    #[tokio::test]
670    async fn unrelated_files_in_the_log_directory_are_ignored() {
671        let fixture = Fixture::new();
672        fixture.append("ant-node.2026-08-19.log", &line("INFO", "seed"));
673        fixture.append("notes.txt", "not a log file\n");
674        fixture.append("ant-node.2026-08-19.log.gz", "compressed\n");
675
676        let mut tailer = fixture.tailer();
677        let mut offsets = fixture.offsets();
678        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
679
680        assert_eq!(offsets.len(), 1, "only the rolling log file is tracked");
681    }
682
683    #[tokio::test]
684    async fn offsets_for_retention_deleted_files_are_pruned() {
685        let fixture = Fixture::new();
686        fixture.append("ant-node.2026-08-18.log", &line("INFO", "old"));
687        fixture.append("ant-node.2026-08-19.log", &line("INFO", "current"));
688
689        let mut tailer = fixture.tailer();
690        let mut offsets = fixture.offsets();
691        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
692        assert_eq!(offsets.len(), 2);
693
694        std::fs::remove_file(fixture.log_dir.join("ant-node.2026-08-18.log")).unwrap();
695        tailer.poll(&mut offsets, LogLevel::Info).await.unwrap();
696
697        assert_eq!(offsets.len(), 1);
698    }
699
700    const INSTALL_A: &str = "0123456789abcdef";
701    const INSTALL_B: &str = "fedcba9876543210";
702
703    #[test]
704    fn the_document_id_is_stable_and_position_derived() {
705        let event = TailedEvent {
706            node_id: 7,
707            file_name: "ant-node.2026-08-19.log".to_string(),
708            byte_offset: 104_857,
709            event: parse_line(&line("INFO", "hello")).unwrap(),
710        };
711
712        assert_eq!(
713            event.document_id(INSTALL_A),
714            "0123456789abcdef-7-ant-node.2026-08-19.log-104857"
715        );
716        assert_eq!(
717            event.document_id(INSTALL_A),
718            event.clone().document_id(INSTALL_A)
719        );
720        assert!(
721            event.document_id(INSTALL_A).len() <= 512,
722            "Elasticsearch caps _id at 512 bytes"
723        );
724    }
725
726    /// The collision this namespace exists to prevent. Two participants each run a node `1` whose
727    /// daily log file has the same name and whose first line is at offset 0; without the
728    /// installation prefix both produce the same `_id`, and because every participant writes into
729    /// one shared daily index and the sink counts a 409 as delivered, the second one's event would
730    /// be dropped on the floor rather than stored.
731    #[test]
732    fn identical_positions_on_two_installations_do_not_collide() {
733        let same_event = || TailedEvent {
734            node_id: 1,
735            file_name: "ant-node.2026-08-19.log".to_string(),
736            byte_offset: 0,
737            event: parse_line(&line("INFO", "starting version=0.17.2")).unwrap(),
738        };
739
740        assert_ne!(
741            same_event().document_id(INSTALL_A),
742            same_event().document_id(INSTALL_B),
743            "the same local position on two machines must not share a document id"
744        );
745    }
746
747    #[test]
748    fn document_ids_differ_across_nodes_files_and_positions() {
749        let base = TailedEvent {
750            node_id: 7,
751            file_name: "ant-node.2026-08-19.log".to_string(),
752            byte_offset: 100,
753            event: parse_line(&line("INFO", "hello")).unwrap(),
754        };
755        let other_node = TailedEvent {
756            node_id: 8,
757            ..base.clone()
758        };
759        let other_file = TailedEvent {
760            file_name: "ant-node.2026-08-20.log".to_string(),
761            ..base.clone()
762        };
763        let other_offset = TailedEvent {
764            byte_offset: 200,
765            ..base.clone()
766        };
767
768        let ids = [
769            base.document_id(INSTALL_A),
770            other_node.document_id(INSTALL_A),
771            other_file.document_id(INSTALL_A),
772            other_offset.document_id(INSTALL_A),
773        ];
774        let unique: std::collections::HashSet<&String> = ids.iter().collect();
775        assert_eq!(unique.len(), 4);
776    }
777}