1use 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
26const LOG_FILENAME_PREFIX: &str = "ant-node.";
28const LOG_FILENAME_SUFFIX: &str = ".log";
30
31const MAX_CHUNK_BYTES: usize = 1024 * 1024;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TailedEvent {
38 pub node_id: u32,
39 pub file_name: String,
41 pub byte_offset: u64,
43 pub event: LogEvent,
44}
45
46impl TailedEvent {
47 #[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
67pub struct LogTailer {
69 node_id: u32,
70 log_dir: PathBuf,
71 primed: bool,
75 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 pub fn mark_primed(&mut self) {
106 self.primed = true;
107 }
108
109 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 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 Err(error) => {
134 tracing::debug!(
135 "log forwarding: skipping {} this poll: {error}",
136 path.display()
137 );
138 }
139 }
140 }
141
142 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 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 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 None if !self.primed || !is_newest => len,
203 None => 0,
204 };
205
206 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 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 reached_eof && !was_growing,
251 );
252
253 offsets.set(&key, outcome.next_offset);
254 Ok(outcome.into_poll_outcome())
255 }
256
257 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 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 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 Some(_) => {}
310 None => outcome.next_offset = cursor,
311 }
312
313 outcome
314 }
315}
316
317#[derive(Debug, Default)]
319pub struct PollOutcome {
320 pub events: Vec<TailedEvent>,
321 pub dropped_by_level: u64,
323 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 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 #[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 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 #[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 #[tokio::test]
492 async fn a_node_started_after_enabling_is_captured_from_its_first_line() {
493 let fixture = Fixture::new();
494
495 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 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 #[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 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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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}