1use std::fs::{File, Metadata};
21use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom};
22use std::path::{Path, PathBuf};
23
24use chrono::{DateTime, Utc};
25
26use super::{Filter, GhCounts};
27use crate::request_log::LogRecord;
28
29const TAIL_LEN: usize = 64;
33
34const BACKSCAN_BLOCK: usize = 8 * 1024;
36
37const BASELINE_SCAN_LIMIT: u64 = 4 * 1024 * 1024;
42
43const READ_BUFFER: usize = 64 * 1024;
45
46type FileId = Option<(u64, u64)>;
50
51#[cfg(unix)]
52fn file_id(meta: &Metadata) -> FileId {
53 use std::os::unix::fs::MetadataExt;
54 Some((meta.dev(), meta.ino()))
55}
56
57#[cfg(not(unix))]
58fn file_id(_meta: &Metadata) -> FileId {
59 None
60}
61
62#[derive(Debug)]
64struct Cursor {
65 id: FileId,
67 offset: u64,
69 tail: Vec<u8>,
72}
73
74impl Cursor {
75 fn start_of(id: FileId) -> Self {
77 Self {
78 id,
79 offset: 0,
80 tail: Vec::new(),
81 }
82 }
83
84 fn is_intact(&self, file: &mut File, id: FileId, len: u64) -> io::Result<bool> {
101 if self.id != id || len < self.offset {
102 return Ok(false);
103 }
104 let mut seen = vec![0; self.tail.len()];
105 file.seek(SeekFrom::Start(self.offset - self.tail.len() as u64))?;
106 match file.read_exact(&mut seen) {
107 Ok(()) => Ok(seen == self.tail),
108 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => Ok(false),
110 Err(e) => Err(e),
111 }
112 }
113
114 fn advance(&mut self, line: &[u8]) {
116 self.offset += line.len() as u64;
117 if line.len() >= TAIL_LEN {
118 self.tail.clear();
119 self.tail.extend_from_slice(&line[line.len() - TAIL_LEN..]);
120 } else {
121 self.tail.extend_from_slice(line);
122 let excess = self.tail.len().saturating_sub(TAIL_LEN);
123 self.tail.drain(..excess);
124 }
125 }
126
127 fn baseline(path: &Path) -> Option<Self> {
139 match Self::try_baseline(path) {
140 Ok(cursor) => cursor,
141 Err(e) => {
142 tracing::warn!(
143 "github counters: cannot take a baseline of {}: {e}; the first summary \
144 will scan the whole log",
145 path.display()
146 );
147 None
148 }
149 }
150 }
151
152 fn try_baseline(path: &Path) -> io::Result<Option<Self>> {
153 let mut file = match File::open(path) {
154 Ok(file) => file,
155 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
156 Err(e) => return Err(e),
157 };
158 let meta = file.metadata()?;
159 let offset = end_of_last_line(&mut file, meta.len(), BASELINE_SCAN_LIMIT)?;
160 let tail = read_tail(&mut file, offset)?;
161 Ok(Some(Self {
162 id: file_id(&meta),
163 offset,
164 tail,
165 }))
166 }
167}
168
169fn end_of_last_line(file: &mut File, len: u64, limit: u64) -> io::Result<u64> {
178 let floor = len.saturating_sub(limit);
179 let mut block = [0u8; BACKSCAN_BLOCK];
180 let mut end = len;
181 while end > floor {
182 let start = end.saturating_sub(BACKSCAN_BLOCK as u64).max(floor);
183 let want = usize::try_from(end - start).unwrap_or(BACKSCAN_BLOCK);
184 let chunk = &mut block[..want];
185 file.seek(SeekFrom::Start(start))?;
186 file.read_exact(chunk)?;
187 if let Some(i) = chunk.iter().rposition(|&b| b == b'\n') {
188 return Ok(start + i as u64 + 1);
189 }
190 end = start;
191 }
192 Ok(if floor == 0 { 0 } else { len })
193}
194
195fn read_tail(file: &mut File, offset: u64) -> io::Result<Vec<u8>> {
197 let len = usize::try_from(offset).map_or(TAIL_LEN, |o| o.min(TAIL_LEN));
198 let mut tail = vec![0; len];
199 file.seek(SeekFrom::Start(offset - len as u64))?;
200 file.read_exact(&mut tail)?;
201 Ok(tail)
202}
203
204#[derive(Debug)]
215pub struct IncrementalCounts {
216 path: PathBuf,
217 since: DateTime<Utc>,
218 filter: Filter,
219 counts: GhCounts,
220 cursor: Option<Cursor>,
222}
223
224impl IncrementalCounts {
225 pub fn start(path: PathBuf) -> Self {
234 let cursor = Cursor::baseline(&path);
235 Self::new(path, Utc::now(), cursor)
236 }
237
238 #[cfg(test)]
241 fn start_since(path: PathBuf, since: DateTime<Utc>) -> Self {
242 let cursor = Cursor::baseline(&path);
243 Self::new(path, since, cursor)
244 }
245
246 fn new(path: PathBuf, since: DateTime<Utc>, cursor: Option<Cursor>) -> Self {
247 Self {
248 path,
249 since,
250 filter: Filter {
251 since: Some(since),
252 until: None,
253 source: None,
254 },
255 counts: GhCounts::default(),
256 cursor,
257 }
258 }
259
260 pub fn since(&self) -> DateTime<Utc> {
262 self.since
263 }
264
265 pub fn counts(&self) -> &GhCounts {
267 &self.counts
268 }
269
270 pub fn refresh(&mut self, mut stop: impl FnMut() -> bool) -> bool {
282 match self.try_refresh(&mut stop) {
283 Ok(complete) => complete,
284 Err(e) => {
285 tracing::debug!(
286 "github counters: reading {} failed: {e}",
287 self.path.display()
288 );
289 false
290 }
291 }
292 }
293
294 fn try_refresh(&mut self, stop: &mut impl FnMut() -> bool) -> io::Result<bool> {
295 let mut file = match File::open(&self.path) {
296 Ok(file) => file,
297 Err(e) if e.kind() == io::ErrorKind::NotFound => {
298 self.counts = GhCounts::default();
301 self.cursor = None;
302 return Ok(true);
303 }
304 Err(e) => return Err(e),
305 };
306 let meta = file.metadata()?;
307 let mut cursor = self.resume(&mut file, &meta);
308 let result = self.read_new_lines(&mut file, &mut cursor, stop);
309 self.cursor = Some(cursor);
312 result
313 }
314
315 fn resume(&mut self, file: &mut File, meta: &Metadata) -> Cursor {
319 let id = file_id(meta);
320 if let Some(cursor) = self.cursor.take() {
321 match cursor.is_intact(file, id, meta.len()) {
322 Ok(true) => return cursor,
323 Ok(false) => {}
324 Err(e) => tracing::debug!(
325 "github counters: cannot verify the position in {}, rescanning: {e}",
326 self.path.display()
327 ),
328 }
329 }
330 self.counts = GhCounts::default();
333 Cursor::start_of(id)
334 }
335
336 fn read_new_lines(
337 &mut self,
338 file: &mut File,
339 cursor: &mut Cursor,
340 stop: &mut impl FnMut() -> bool,
341 ) -> io::Result<bool> {
342 file.seek(SeekFrom::Start(cursor.offset))?;
343 let mut reader = BufReader::with_capacity(READ_BUFFER, file);
344 let mut line = Vec::new();
345 loop {
346 if stop() {
347 return Ok(false);
348 }
349 line.clear();
350 reader.read_until(b'\n', &mut line)?;
351 if line.last() != Some(&b'\n') {
352 return Ok(true);
354 }
355 if let Ok(rec) = serde_json::from_slice::<LogRecord>(&line) {
358 self.filter.tally(&mut self.counts, &rec);
359 }
360 cursor.advance(&line);
361 }
362 }
363
364 #[cfg(test)]
366 fn offset(&self) -> Option<u64> {
367 self.cursor.as_ref().map(|c| c.offset)
368 }
369}
370
371#[cfg(test)]
372#[allow(clippy::unwrap_used, clippy::expect_used)]
373mod tests {
374 use super::*;
375 use crate::github_metrics::aggregate;
376 use chrono::{Duration, SecondsFormat};
377 use std::io::Write;
378
379 fn since() -> DateTime<Utc> {
380 DateTime::parse_from_rfc3339("2026-07-21T10:00:00.000Z")
381 .unwrap()
382 .with_timezone(&Utc)
383 }
384
385 fn stamp(secs: i64) -> String {
387 (since() + Duration::seconds(secs)).to_rfc3339_opts(SecondsFormat::Millis, true)
388 }
389
390 fn gh(secs: i64, command: &[&str], source: &str) -> String {
392 format!(
393 "{{\"kind\":\"gh\",\"timestamp\":\"{}\",\"command\":{},\"source\":\"{source}\"}}\n",
394 stamp(secs),
395 serde_json::to_string(command).unwrap(),
396 )
397 }
398
399 struct Log {
401 _dir: tempfile::TempDir,
402 path: PathBuf,
403 }
404
405 impl Log {
406 fn new() -> Self {
407 let dir = tempfile::tempdir().unwrap();
408 let path = dir.path().join("log.jsonl");
409 Self { _dir: dir, path }
410 }
411
412 fn write(&self, text: &str) {
414 std::fs::write(&self.path, text).unwrap();
415 }
416
417 fn append(&self, text: &str) {
418 let mut file = std::fs::OpenOptions::new()
419 .append(true)
420 .create(true)
421 .open(&self.path)
422 .unwrap();
423 file.write_all(text.as_bytes()).unwrap();
424 }
425
426 fn replace(&self, text: &str) {
428 let tmp = self.path.with_extension("tmp");
429 std::fs::write(&tmp, text).unwrap();
430 std::fs::rename(&tmp, &self.path).unwrap();
431 }
432
433 fn len(&self) -> u64 {
434 std::fs::metadata(&self.path).unwrap().len()
435 }
436
437 fn full_scan(&self) -> GhCounts {
438 aggregate(&self.path, Some(since()), None, None)
439 }
440
441 fn start(&self) -> IncrementalCounts {
442 IncrementalCounts::start_since(self.path.clone(), since())
443 }
444 }
445
446 fn never() -> bool {
447 false
448 }
449
450 #[test]
451 fn records_before_the_baseline_are_never_read() {
452 let log = Log::new();
453 let pre: String = (0..5).map(|n| gh(10 + n, &["pr", "list"], "cli")).collect();
456 log.write(&pre);
457 assert_eq!(log.full_scan().total(), 5);
458
459 let mut tally = log.start();
460 assert!(tally.refresh(never));
461
462 assert_eq!(tally.counts().total(), 0);
463 assert_eq!(tally.offset(), Some(log.len()));
464 }
465
466 #[test]
467 fn appended_records_match_a_full_scan() {
468 let log = Log::new();
469 log.write(&gh(-100, &["pr", "list"], "cli")); let mut tally = log.start();
471
472 log.append(&gh(1, &["api", "graphql"], "daemon"));
473 log.append(&gh(2, &["pr", "list"], "cli"));
474 log.append(&gh(3, &["--version"], "cli"));
475 log.append("{\"kind\":\"http\",\"timestamp\":\"2026-07-21T10:00:04.000Z\"}\n");
476 log.append("not json\n\n");
477 log.append(&gh(-5, &["pr", "list"], "cli")); log.append("{\"kind\":\"gh\",\"timestamp\":\"bad\",\"command\":[\"pr\"]}\n");
479 assert!(tally.refresh(never));
480 assert_eq!(tally.counts().total(), 3);
481 assert_eq!(*tally.counts(), log.full_scan());
482
483 log.append(&gh(6, &["api", "graphql"], "mcp"));
484 assert!(tally.refresh(never));
485 assert_eq!(tally.counts().total(), 4);
486 assert_eq!(*tally.counts(), log.full_scan());
487
488 assert!(tally.refresh(never));
490 assert_eq!(*tally.counts(), log.full_scan());
491 assert_eq!(tally.offset(), Some(log.len()));
492 }
493
494 #[test]
495 fn a_partial_trailing_line_waits_for_its_newline() {
496 let log = Log::new();
497 let mut tally = log.start();
498
499 let line = gh(1, &["pr", "list"], "cli");
500 let (head, rest) = line.split_at(line.len() / 2);
501 log.append(head);
502 assert!(tally.refresh(never));
503 assert_eq!(tally.counts().total(), 0);
504 assert_eq!(tally.offset(), Some(0));
505
506 log.append(rest);
507 assert!(tally.refresh(never));
508 assert_eq!(tally.counts().total(), 1);
509 assert_eq!(*tally.counts(), log.full_scan());
510
511 let line = gh(2, &["pr", "view"], "cli");
514 log.append(line.trim_end());
515 assert!(tally.refresh(never));
516 assert_eq!(tally.counts().total(), 1);
517 log.append("\n");
518 assert!(tally.refresh(never));
519 assert_eq!(tally.counts().total(), 2);
520 assert_eq!(*tally.counts(), log.full_scan());
521 }
522
523 #[test]
524 fn a_baseline_inside_a_line_starts_at_that_line() {
525 let log = Log::new();
526 let first = gh(-1, &["pr", "list"], "cli");
527 let second = gh(2, &["pr", "view"], "cli");
528 let (head, rest) = second.split_at(second.len() / 2);
529 log.write(&format!("{first}{head}"));
530
531 let mut tally = log.start();
532 assert_eq!(tally.offset(), Some(first.len() as u64));
533
534 log.append(rest);
537 assert!(tally.refresh(never));
538 assert_eq!(tally.counts().total(), 1);
539 assert_eq!(tally.counts().by_subcommand["pr view"], 1);
540 }
541
542 #[test]
543 fn the_last_line_is_found_across_search_blocks() {
544 let log = Log::new();
545 let first = gh(1, &["pr", "list"], "cli");
546 let long = "x".repeat(3 * BACKSCAN_BLOCK + 17);
547 log.write(&format!("{first}{long}"));
548
549 let mut file = File::open(&log.path).unwrap();
550 assert_eq!(
551 end_of_last_line(&mut file, log.len(), u64::MAX).unwrap(),
552 first.len() as u64
553 );
554 assert_eq!(end_of_last_line(&mut file, 0, u64::MAX).unwrap(), 0);
555
556 log.write(&long); let mut file = File::open(&log.path).unwrap();
558 assert_eq!(end_of_last_line(&mut file, log.len(), u64::MAX).unwrap(), 0);
559 }
560
561 #[test]
562 fn the_search_for_the_last_line_gives_up_at_its_limit() {
563 let log = Log::new();
564 let first = gh(1, &["pr", "list"], "cli");
565 let long = "x".repeat(3 * BACKSCAN_BLOCK + 17);
566 log.write(&format!("{first}{long}"));
567 let mut file = File::open(&log.path).unwrap();
568
569 let limit = 2 * BACKSCAN_BLOCK as u64;
572 assert_eq!(
573 end_of_last_line(&mut file, log.len(), limit).unwrap(),
574 log.len()
575 );
576
577 let reach = long.len() as u64 + 1;
579 assert_eq!(
580 end_of_last_line(&mut file, log.len(), reach).unwrap(),
581 first.len() as u64
582 );
583 }
584
585 #[test]
586 fn a_baseline_inside_a_huge_line_skips_that_fragment_and_keeps_counting() {
587 let log = Log::new();
588 let huge = "x".repeat(usize::try_from(BASELINE_SCAN_LIMIT).unwrap() + 1024);
590 log.write(&format!("{}{huge}", gh(1, &["pr", "list"], "cli")));
591
592 let mut tally = log.start();
593 assert_eq!(tally.offset(), Some(log.len()));
594
595 log.append("\n");
597 log.append(&gh(2, &["pr", "view"], "cli"));
598 assert!(tally.refresh(never));
599 assert_eq!(tally.counts().total(), 1);
600 assert_eq!(tally.counts().by_subcommand["pr view"], 1);
601 }
602
603 #[test]
604 fn a_replaced_log_is_rescanned_from_its_first_byte() {
605 let log = Log::new();
606 let mut tally = log.start();
607 log.append(&gh(1, &["pr", "list"], "cli"));
608 log.append(&gh(2, &["pr", "list"], "cli"));
609 log.append(&gh(3, &["pr", "list"], "cli"));
610 assert!(tally.refresh(never));
611 assert_eq!(tally.counts().total(), 3);
612
613 log.replace(&format!(
616 "{}{}{}",
617 gh(4, &["api", "graphql"], "daemon"),
618 gh(5, &["api", "graphql"], "daemon"),
619 gh(-9, &["pr", "list"], "cli"),
620 ));
621 assert!(tally.refresh(never));
622 assert_eq!(tally.counts().total(), 2);
623 assert_eq!(*tally.counts(), log.full_scan());
624 }
625
626 #[test]
627 fn a_rotated_log_is_followed_to_its_replacement() {
628 let log = Log::new();
629 let mut tally = log.start();
630 log.append(&gh(1, &["pr", "list"], "cli"));
631 assert!(tally.refresh(never));
632
633 std::fs::rename(&log.path, log.path.with_extension("jsonl.1")).unwrap();
635 log.append(&gh(2, &["api", "graphql"], "daemon"));
636 assert!(tally.refresh(never));
637 assert_eq!(*tally.counts(), log.full_scan());
638 assert_eq!(tally.counts().total(), 1);
639
640 std::fs::remove_file(&log.path).unwrap();
642 assert!(tally.refresh(never));
643 assert_eq!(*tally.counts(), GhCounts::default());
644 assert_eq!(tally.offset(), None);
645
646 log.append(&gh(3, &["pr", "view"], "cli"));
647 log.append(&gh(4, &["pr", "view"], "cli"));
648 assert!(tally.refresh(never));
649 assert_eq!(tally.counts().total(), 2);
650 assert_eq!(*tally.counts(), log.full_scan());
651 }
652
653 #[test]
654 fn a_log_truncated_below_the_offset_is_rescanned() {
655 let log = Log::new();
656 let mut tally = log.start();
657 for n in 1..=4 {
658 log.append(&gh(n, &["pr", "list"], "cli"));
659 }
660 assert!(tally.refresh(never));
661 assert_eq!(tally.counts().total(), 4);
662
663 log.write(&gh(9, &["api", "graphql"], "daemon")); assert!(log.len() < tally.offset().unwrap());
665 assert!(tally.refresh(never));
666 assert_eq!(tally.counts().total(), 1);
667 assert_eq!(*tally.counts(), log.full_scan());
668 }
669
670 #[test]
671 fn a_log_rewritten_in_place_and_regrown_past_the_offset_is_rescanned() {
672 let log = Log::new();
673 let mut tally = log.start();
674 log.append(&gh(1, &["pr", "list"], "cli"));
675 log.append(&gh(2, &["pr", "list"], "cli"));
676 assert!(tally.refresh(never));
677 let stored = tally.offset().unwrap();
678
679 let rewritten: String = (10..16)
683 .map(|n| gh(n, &["api", "graphql"], "daemon"))
684 .collect();
685 log.write(&rewritten);
686 assert!(log.len() > stored);
687 assert!(tally.refresh(never));
688 assert_eq!(tally.counts().total(), 6);
689 assert_eq!(*tally.counts(), log.full_scan());
690 }
691
692 #[test]
693 fn a_log_created_after_the_baseline_is_scanned_from_its_first_byte() {
694 let log = Log::new();
695 let mut tally = log.start();
696 assert_eq!(tally.offset(), None);
697 assert!(tally.refresh(never));
698 assert_eq!(tally.counts().total(), 0);
699
700 log.append(&gh(-10, &["pr", "list"], "cli"));
701 log.append(&gh(1, &["pr", "list"], "cli"));
702 log.append(&gh(2, &["pr", "view"], "cli"));
703 assert!(tally.refresh(never));
704 assert_eq!(tally.counts().total(), 2);
705 assert_eq!(*tally.counts(), log.full_scan());
706 }
707
708 #[test]
709 fn a_stopped_refresh_resumes_where_it_stopped() {
710 let log = Log::new();
711 let mut tally = log.start();
712 for n in 1..=5 {
713 log.append(&gh(n, &["pr", "list"], "cli"));
714 }
715
716 let mut calls = 0;
718 let complete = tally.refresh(|| {
719 calls += 1;
720 calls > 2
721 });
722 assert!(!complete);
723 assert_eq!(tally.counts().total(), 2);
724
725 assert!(tally.refresh(never));
726 assert_eq!(tally.counts().total(), 5);
727 assert_eq!(*tally.counts(), log.full_scan());
728 }
729
730 #[test]
731 fn a_stopped_rebuild_keeps_what_it_read_and_continues() {
732 let log = Log::new();
733 let mut tally = log.start();
734 log.append(&gh(1, &["pr", "list"], "cli"));
735 assert!(tally.refresh(never));
736
737 let replacement: String = (10..16).map(|n| gh(n, &["pr", "view"], "cli")).collect();
739 log.replace(&replacement);
740 let mut calls = 0;
741 assert!(!tally.refresh(|| {
742 calls += 1;
743 calls > 3
744 }));
745 assert_eq!(tally.counts().total(), 3);
746
747 assert!(tally.refresh(never));
748 assert_eq!(tally.counts().total(), 6);
749 assert_eq!(*tally.counts(), log.full_scan());
750 }
751
752 #[test]
753 fn the_tail_keeps_the_last_bytes_across_short_and_long_lines() {
754 let mut cursor = Cursor::start_of(None);
755 cursor.advance(b"ab\n");
756 cursor.advance(b"cd\n");
757 assert_eq!(cursor.tail, b"ab\ncd\n");
758 assert_eq!(cursor.offset, 6);
759
760 let long: Vec<u8> = (0..200u8).collect();
761 cursor.advance(&long);
762 assert_eq!(cursor.tail, &long[long.len() - TAIL_LEN..]);
763
764 cursor.advance(b"z\n");
765 assert_eq!(cursor.tail.len(), TAIL_LEN);
766 assert!(cursor.tail.ends_with(b"z\n"));
767 assert_eq!(cursor.offset, 6 + 200 + 2);
768 }
769}