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}