kumo_jsonl/backlog.rs
1//! Observe, from outside the consumer processes, how far each consumer trails
2//! the newest data in a log directory.
3
4use crate::checkpoint::{CheckpointData, CHECKPOINT_TEMP_PREFIX};
5use camino::{Utf8Path, Utf8PathBuf};
6use filenamegen::Glob;
7use serde::Serialize;
8use std::time::{Duration, SystemTime};
9
10/// How far one consumer's checkpoint trails the newest data in the log
11/// directory.
12#[derive(Debug, Clone, PartialEq, Serialize)]
13pub struct ConsumerBacklog {
14 /// The `checkpoint_name` with which the consumer was configured
15 pub checkpoint_name: String,
16 /// Where the consumer's reading has reached.
17 pub checkpoint: CheckpointData,
18 /// Count of whole segments ordered after the segment defined by `checkpoint`.
19 pub segments_behind: usize,
20 /// Total compressed size, in bytes, of the segments counted by
21 /// `segments_behind`. Excludes the remainder of the segment that contains
22 /// the checkpoint, because measuring that would require decompressing it to
23 /// find the recorded line.
24 pub bytes_behind: u64,
25 /// Age of the oldest segment not started by the consumer, measured from its
26 /// modification time, approximating how long the oldest unread data has
27 /// waited. `None` when the consumer has reached the newest segment.
28 pub lag: Option<Duration>,
29}
30
31/// Scan `directory` and report the backlog of each consumer relative to the
32/// newest segment matching `pattern`. Consumers are discovered by their
33/// `.<checkpoint_name>` files. Segment contents are never decompressed. Only
34/// their directory listing and file metadata are read. A nonexistent directory
35/// yields an empty report.
36pub fn scan_backlog(directory: &Utf8Path, pattern: &str) -> anyhow::Result<Vec<ConsumerBacklog>> {
37 let segments = sorted_segments(directory, pattern)?;
38
39 let read_dir = match std::fs::read_dir(directory.as_std_path()) {
40 Ok(read_dir) => read_dir,
41 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
42 Err(err) => return Err(err.into()),
43 };
44
45 let mut reports = vec![];
46 for entry in read_dir {
47 // An unreadable or racily-removed entry must not abort the whole scan.
48 // Skip it.
49 let Ok(entry) = entry else {
50 continue;
51 };
52 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
53 continue;
54 }
55 let file_name = entry.file_name();
56 let Some(name) = file_name.to_str() else {
57 continue;
58 };
59 // In-progress checkpoint writes are dotfiles too. The reserved prefix
60 // distinguishes them from committed checkpoints.
61 if name.starts_with(CHECKPOINT_TEMP_PREFIX) {
62 continue;
63 }
64 let Some(checkpoint_name) = name.strip_prefix('.') else {
65 continue;
66 };
67 // Includes only parseable dotfiles, excluding editor swap files and
68 // stray dotfiles.
69 let Ok(bytes) = std::fs::read(entry.path()) else {
70 continue;
71 };
72 let Ok(checkpoint) = serde_json::from_slice::<CheckpointData>(&bytes) else {
73 continue;
74 };
75
76 reports.push(backlog_for(
77 checkpoint_name.to_string(),
78 checkpoint,
79 &segments,
80 ));
81 }
82
83 reports.sort_by(|a, b| a.checkpoint_name.cmp(&b.checkpoint_name));
84 Ok(reports)
85}
86
87/// Caches the segment metadata needed to calculate backlog without reading
88/// segment contents.
89struct Segment {
90 path: Utf8PathBuf,
91 len: u64,
92 modified: Option<SystemTime>,
93}
94
95/// Return the sorted segments in `directory` matching `pattern`, excluding the
96/// dotfiles that hold checkpoints and in-progress writes.
97fn sorted_segments(directory: &Utf8Path, pattern: &str) -> anyhow::Result<Vec<Segment>> {
98 let glob = Glob::new(pattern)?;
99 let mut segments = vec![];
100 for path in glob.walk(directory) {
101 // A non-UTF-8 name cannot be one of our segments. Skip it rather than
102 // aborting the scan.
103 let Ok(rel) = Utf8PathBuf::try_from(path) else {
104 continue;
105 };
106 let path = directory.join(&rel);
107 if path.file_name().is_some_and(|n| n.starts_with('.')) {
108 continue;
109 }
110 let metadata = match path.metadata() {
111 Ok(metadata) if metadata.is_file() => metadata,
112 Ok(_) => continue,
113 // We exclude a segment we can't stat from bytes_behind rather
114 // than guess its size, trusting an undercount over a fabricated
115 // one.
116 Err(err) => {
117 tracing::debug!("skipping log segment {path}: {err:#}");
118 continue;
119 }
120 };
121 segments.push(Segment {
122 len: metadata.len(),
123 modified: metadata.modified().ok(),
124 path,
125 });
126 }
127 segments.sort_by(|a, b| a.path.cmp(&b.path));
128 Ok(segments)
129}
130
131/// Derive the backlog for a checkpoint against the sorted segments.
132fn backlog_for(
133 checkpoint_name: String,
134 checkpoint: CheckpointData,
135 segments: &[Segment],
136) -> ConsumerBacklog {
137 // Use the filename as the segment identity because equivalent directory
138 // paths can differ in absolute form, symlink resolution, or trailing slash.
139 let cp_name = Utf8Path::new(&checkpoint.file)
140 .file_name()
141 .unwrap_or(checkpoint.file.as_str());
142 // The segments that sort strictly after the checkpoint segment remain
143 // unread by the consumer. Comparing filenames, rather than requiring the
144 // checkpoint segment to still be present in `segments`, keeps this correct
145 // even after that segment has been rotated away.
146 let later = segments
147 .iter()
148 .filter(|s| s.path.file_name().is_some_and(|n| n > cp_name));
149
150 let mut segments_behind = 0;
151 let mut bytes_behind = 0;
152 let mut oldest_unread: Option<&Segment> = None;
153 for seg in later {
154 segments_behind += 1;
155 bytes_behind += seg.len;
156 if oldest_unread.is_none() {
157 oldest_unread = Some(seg);
158 }
159 }
160
161 let lag = oldest_unread.and_then(|seg| SystemTime::now().duration_since(seg.modified?).ok());
162
163 ConsumerBacklog {
164 checkpoint_name,
165 checkpoint,
166 segments_behind,
167 bytes_behind,
168 lag,
169 }
170}