Skip to main content

rch_common/
incident_ledger.rs

1//! Append-only, bounded, corruption-tolerant incident ledger.
2//!
3//! A durable JSONL log of [`IncidentEvent`]s that survives process restarts and
4//! is cheap enough to write from the hook/daemon hot paths (one buffered append
5//! per event; a single `stat` to decide whether retention compaction is due).
6//!
7//! Design:
8//! - **Append-only**: each event is one JSON line appended to the file. A line
9//!   shorter than `PIPE_BUF` is written atomically by the OS, so concurrent
10//!   hook + daemon writers interleave whole lines rather than corrupting one
11//!   another. Torn/garbage lines are tolerated on read.
12//! - **Bounded retention**: when the file grows past `max_bytes`, it is
13//!   compacted to the most-recent `max_entries` events via an atomic
14//!   temp-file + rename, so a long-running fleet cannot grow the ledger without
15//!   limit. Compaction also drops any unparseable lines.
16//! - **Privacy-aware**: the ledger persists whatever [`IncidentEvent`] carries;
17//!   that schema uses a `command_fingerprint` (not the raw command) and has no
18//!   secret fields, so the on-disk ledger never contains raw command text or
19//!   credentials.
20//! - **Queryable**: [`IncidentLedger::query`] filters by project, command
21//!   fingerprint, worker, and reason code.
22
23use std::fs::{self, OpenOptions};
24use std::io::{BufRead, BufReader, Write};
25use std::path::{Path, PathBuf};
26
27use crate::incident::{IncidentEvent, IncidentReasonCode};
28
29/// Default retained-event count after a compaction.
30const DEFAULT_MAX_ENTRIES: usize = 5_000;
31/// Default file-size compaction trigger (4 MiB).
32const DEFAULT_MAX_BYTES: u64 = 4 * 1024 * 1024;
33
34/// Ledger configuration.
35#[derive(Debug, Clone)]
36pub struct IncidentLedgerConfig {
37    /// Path to the JSONL ledger file.
38    pub path: PathBuf,
39    /// Events retained after a compaction (most-recent wins).
40    pub max_entries: usize,
41    /// File size that triggers a compaction, in bytes.
42    pub max_bytes: u64,
43}
44
45impl IncidentLedgerConfig {
46    /// Config for an explicit ledger path with default retention bounds.
47    #[must_use]
48    pub fn with_path(path: impl Into<PathBuf>) -> Self {
49        Self {
50            path: path.into(),
51            max_entries: DEFAULT_MAX_ENTRIES,
52            max_bytes: DEFAULT_MAX_BYTES,
53        }
54    }
55}
56
57impl Default for IncidentLedgerConfig {
58    fn default() -> Self {
59        Self {
60            path: default_ledger_path(),
61            max_entries: DEFAULT_MAX_ENTRIES,
62            max_bytes: DEFAULT_MAX_BYTES,
63        }
64    }
65}
66
67/// Resolve the default ledger path: `${RCH_STATE_HOME}/incidents.jsonl`, else
68/// `${XDG_STATE_HOME}/rch/...`, else `~/.local/state/rch/...`, else
69/// `/tmp/rch/...`.
70#[must_use]
71pub fn default_ledger_path() -> PathBuf {
72    let base = std::env::var_os("RCH_STATE_HOME")
73        .map(PathBuf::from)
74        .filter(|p| !p.as_os_str().is_empty())
75        .or_else(|| {
76            std::env::var_os("XDG_STATE_HOME")
77                .map(|d| PathBuf::from(d).join("rch"))
78                .filter(|p| p.as_os_str().len() > "rch".len())
79        })
80        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state/rch")))
81        .unwrap_or_else(|| PathBuf::from("/tmp/rch"));
82    base.join("incidents.jsonl")
83}
84
85/// Outcome counts from a corruption-tolerant read.
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
87pub struct LedgerReadStats {
88    /// Events parsed successfully.
89    pub parsed: usize,
90    /// Lines skipped because they did not parse (corruption-tolerant).
91    pub skipped: usize,
92}
93
94/// Filter for [`IncidentLedger::query`]. `None` fields match anything.
95#[derive(Debug, Clone, Default)]
96pub struct IncidentFilter {
97    pub project_id: Option<String>,
98    pub command_fingerprint: Option<String>,
99    pub worker_id: Option<String>,
100    pub reason_code: Option<IncidentReasonCode>,
101}
102
103impl IncidentFilter {
104    #[must_use]
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    #[must_use]
110    pub fn project(mut self, project_id: impl Into<String>) -> Self {
111        self.project_id = Some(project_id.into());
112        self
113    }
114
115    #[must_use]
116    pub fn command_fingerprint(mut self, fp: impl Into<String>) -> Self {
117        self.command_fingerprint = Some(fp.into());
118        self
119    }
120
121    #[must_use]
122    pub fn worker(mut self, worker_id: impl Into<String>) -> Self {
123        self.worker_id = Some(worker_id.into());
124        self
125    }
126
127    #[must_use]
128    pub fn reason(mut self, reason: IncidentReasonCode) -> Self {
129        self.reason_code = Some(reason);
130        self
131    }
132
133    /// Does `event` satisfy every set field?
134    #[must_use]
135    pub fn matches(&self, event: &IncidentEvent) -> bool {
136        if let Some(p) = &self.project_id
137            && *p != event.project_id
138        {
139            return false;
140        }
141        if let Some(fp) = &self.command_fingerprint
142            && *fp != event.command_fingerprint
143        {
144            return false;
145        }
146        if let Some(w) = &self.worker_id
147            && event.worker_id.as_deref() != Some(w.as_str())
148        {
149            return false;
150        }
151        if let Some(r) = self.reason_code
152            && r != event.reason_code
153        {
154            return false;
155        }
156        true
157    }
158}
159
160/// Append-only incident ledger.
161#[derive(Debug, Clone)]
162pub struct IncidentLedger {
163    config: IncidentLedgerConfig,
164}
165
166impl IncidentLedger {
167    /// Construct from explicit config.
168    #[must_use]
169    pub fn new(config: IncidentLedgerConfig) -> Self {
170        Self { config }
171    }
172
173    /// Construct for an explicit path with default bounds.
174    #[must_use]
175    pub fn with_path(path: impl Into<PathBuf>) -> Self {
176        Self::new(IncidentLedgerConfig::with_path(path))
177    }
178
179    /// The ledger file path.
180    #[must_use]
181    pub fn path(&self) -> &Path {
182        &self.config.path
183    }
184
185    /// Append one event. Creates the parent directory on first write. Triggers
186    /// a retention compaction when the file exceeds `max_bytes`.
187    ///
188    /// Best-effort durability: a write failure is returned, not swallowed, so
189    /// callers on non-critical paths can log-and-continue (incident logging
190    /// must never break a build).
191    pub fn append(&self, event: &IncidentEvent) -> std::io::Result<()> {
192        if let Some(parent) = self.config.path.parent()
193            && !parent.as_os_str().is_empty()
194        {
195            fs::create_dir_all(parent)?;
196        }
197        // One JSON object per line. serde_json::to_string never embeds a raw
198        // newline, so line framing stays intact. Free-form `details` values are
199        // routed through the shared secret redactor at write time (bd-53ga7) so
200        // an injected secret never reaches the persisted ledger.
201        let mut line = serde_json::to_string(&event.redacted())
202            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
203        line.push('\n');
204
205        let oversized = {
206            let lock = self.open_lock()?;
207            lock.lock_shared()?;
208            let mut file = OpenOptions::new()
209                .create(true)
210                .append(true)
211                .open(&self.config.path)?;
212            file.write_all(line.as_bytes())?;
213            file.flush()?;
214            // Cheap stat-based retention trigger (no full read on the hot path).
215            fs::metadata(&self.config.path).is_ok_and(|meta| meta.len() > self.config.max_bytes)
216        };
217        // The shared lock is released first: compaction takes it exclusively,
218        // and every appender that saw the oversize re-checks under that lock.
219        if oversized {
220            let lock = self.open_lock()?;
221            lock.lock()?;
222            if fs::metadata(&self.config.path).is_ok_and(|meta| meta.len() > self.config.max_bytes)
223            {
224                self.rewrite_retained()?;
225            }
226        }
227        Ok(())
228    }
229
230    /// Stable sidecar lock. Appends share it; compaction holds it exclusively,
231    /// so no append lands on the inode a compaction is about to replace.
232    fn open_lock(&self) -> std::io::Result<fs::File> {
233        let mut path = self.config.path.clone().into_os_string();
234        path.push(".lock");
235        OpenOptions::new()
236            .create(true)
237            .truncate(false)
238            .read(true)
239            .write(true)
240            .open(PathBuf::from(path))
241    }
242
243    /// Read every parseable event, tolerating (and counting) corrupt lines.
244    #[must_use]
245    pub fn read_all(&self) -> Vec<IncidentEvent> {
246        self.read_all_with_stats().0
247    }
248
249    /// Read with parse/skip statistics. A missing file yields an empty result.
250    #[must_use]
251    pub fn read_all_with_stats(&self) -> (Vec<IncidentEvent>, LedgerReadStats) {
252        let mut events = Vec::new();
253        let mut stats = LedgerReadStats::default();
254        let file = match fs::File::open(&self.config.path) {
255            Ok(f) => f,
256            Err(_) => return (events, stats),
257        };
258        // Split on bytes: one line of invalid UTF-8 is a corrupt line to skip,
259        // not the end of the ledger (compaction would then drop the rest).
260        for line in BufReader::new(file).split(b'\n') {
261            let Ok(line) = line else {
262                // A real I/O error mid-stream: count and stop.
263                stats.skipped += 1;
264                break;
265            };
266            let trimmed = line.trim_ascii();
267            if trimmed.is_empty() {
268                continue;
269            }
270            match serde_json::from_slice::<IncidentEvent>(trimmed) {
271                Ok(event) => {
272                    events.push(event);
273                    stats.parsed += 1;
274                }
275                Err(_) => stats.skipped += 1,
276            }
277        }
278        (events, stats)
279    }
280
281    /// Read events matching `filter`, newest-last (file order).
282    #[must_use]
283    pub fn query(&self, filter: &IncidentFilter) -> Vec<IncidentEvent> {
284        self.read_all()
285            .into_iter()
286            .filter(|e| filter.matches(e))
287            .collect()
288    }
289
290    /// Compact the ledger to the most-recent `max_entries` parseable events,
291    /// dropping corrupt lines. Atomic via temp-file + rename so a reader never
292    /// observes a partial file.
293    pub fn compact(&self) -> std::io::Result<()> {
294        let lock = self.open_lock()?;
295        lock.lock()?;
296        self.rewrite_retained()
297    }
298
299    /// Caller holds the exclusive sidecar lock.
300    fn rewrite_retained(&self) -> std::io::Result<()> {
301        let (mut events, _) = self.read_all_with_stats();
302        if events.len() > self.config.max_entries {
303            let drop = events.len() - self.config.max_entries;
304            events.drain(0..drop);
305        }
306
307        let parent = self.config.path.parent().unwrap_or_else(|| Path::new("."));
308        // Unique temp name avoids clobbering a concurrent compactor.
309        let tmp = parent.join(format!(".incidents.{}.tmp", std::process::id(),));
310        {
311            let mut tmp_file = OpenOptions::new()
312                .create(true)
313                .write(true)
314                .truncate(true)
315                .open(&tmp)?;
316            for event in &events {
317                let mut line = serde_json::to_string(event)
318                    .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
319                line.push('\n');
320                tmp_file.write_all(line.as_bytes())?;
321            }
322            tmp_file.flush()?;
323        }
324        match fs::rename(&tmp, &self.config.path) {
325            Ok(()) => Ok(()),
326            Err(e) => {
327                let _ = fs::remove_file(&tmp);
328                Err(e)
329            }
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::incident::{IncidentEventType, IncidentSource, SelectedMode};
338
339    fn event(
340        reason: IncidentReasonCode,
341        project: &str,
342        worker: Option<&str>,
343        ts: u64,
344    ) -> IncidentEvent {
345        let mut e = IncidentEvent::new(
346            IncidentEventType::Selection,
347            reason,
348            IncidentSource::Daemon,
349            project,
350            "cargo build",
351            SelectedMode::Remote,
352            true,
353            ts,
354        );
355        if let Some(w) = worker {
356            e = e.with_worker_id(w);
357        }
358        e
359    }
360
361    fn temp_ledger() -> (IncidentLedger, tempfile::TempDir) {
362        let dir = tempfile::tempdir().unwrap();
363        let ledger = IncidentLedger::with_path(dir.path().join("incidents.jsonl"));
364        (ledger, dir)
365    }
366
367    #[test]
368    fn append_then_read_roundtrips() {
369        let (ledger, _dir) = temp_ledger();
370        let e = event(IncidentReasonCode::LocalFallback, "p1", Some("css"), 100);
371        ledger.append(&e).unwrap();
372        let read = ledger.read_all();
373        assert_eq!(read.len(), 1);
374        assert_eq!(read[0], e);
375    }
376
377    #[test]
378    fn missing_file_reads_empty() {
379        let dir = tempfile::tempdir().unwrap();
380        let ledger = IncidentLedger::with_path(dir.path().join("nope.jsonl"));
381        assert!(ledger.read_all().is_empty());
382    }
383
384    #[test]
385    fn survives_reopen_across_instances() {
386        let dir = tempfile::tempdir().unwrap();
387        let path = dir.path().join("incidents.jsonl");
388        {
389            let ledger = IncidentLedger::with_path(&path);
390            ledger
391                .append(&event(IncidentReasonCode::CircuitOpen, "p1", None, 1))
392                .unwrap();
393            ledger
394                .append(&event(IncidentReasonCode::DiskFull, "p2", None, 2))
395                .unwrap();
396        }
397        // A fresh instance (simulating a process restart) sees prior events.
398        let reopened = IncidentLedger::with_path(&path);
399        assert_eq!(reopened.read_all().len(), 2);
400    }
401
402    #[test]
403    fn invalid_utf8_line_is_skipped_not_the_end_of_the_ledger() {
404        let (ledger, _dir) = temp_ledger();
405        ledger
406            .append(&event(IncidentReasonCode::LocalFallback, "p1", None, 1))
407            .unwrap();
408        let mut file = OpenOptions::new().append(true).open(ledger.path()).unwrap();
409        file.write_all(b"\xff\xfe torn\n").unwrap();
410        ledger
411            .append(&event(IncidentReasonCode::LocalFallback, "p2", None, 2))
412            .unwrap();
413        let (events, stats) = ledger.read_all_with_stats();
414        assert_eq!(events.len(), 2);
415        assert_eq!(stats.skipped, 1);
416        ledger.compact().unwrap();
417        assert_eq!(
418            ledger.read_all().len(),
419            2,
420            "compaction dropped later events"
421        );
422    }
423
424    #[test]
425    fn concurrent_appends_survive_repeated_compaction() {
426        let dir = tempfile::tempdir().unwrap();
427        let ledger = IncidentLedger::new(IncidentLedgerConfig {
428            path: dir.path().join("incidents.jsonl"),
429            max_entries: 100_000,
430            // Every few appends trips compaction, which retains everything.
431            max_bytes: 2_048,
432        });
433        std::thread::scope(|scope| {
434            for thread in 0..8_u64 {
435                let ledger = &ledger;
436                scope.spawn(move || {
437                    for i in 0..50_u64 {
438                        let e = event(
439                            IncidentReasonCode::LocalFallback,
440                            "p",
441                            None,
442                            thread * 1_000 + i,
443                        );
444                        ledger.append(&e).unwrap();
445                    }
446                });
447            }
448        });
449        assert_eq!(ledger.read_all().len(), 400, "appends lost to compaction");
450    }
451
452    #[test]
453    fn corrupt_lines_are_tolerated_and_counted() {
454        let dir = tempfile::tempdir().unwrap();
455        let path = dir.path().join("incidents.jsonl");
456        let ledger = IncidentLedger::with_path(&path);
457        ledger
458            .append(&event(IncidentReasonCode::ArtifactMiss, "p1", None, 1))
459            .unwrap();
460        // Inject a torn/garbage line and a blank line between valid events.
461        {
462            use std::io::Write;
463            let mut f = OpenOptions::new().append(true).open(&path).unwrap();
464            writeln!(f, "{{ this is not valid json").unwrap();
465            writeln!(f).unwrap();
466            writeln!(f, "garbage").unwrap();
467        }
468        ledger
469            .append(&event(IncidentReasonCode::QueueAmbiguity, "p2", None, 2))
470            .unwrap();
471
472        let (events, stats) = ledger.read_all_with_stats();
473        assert_eq!(events.len(), 2, "two valid events survive");
474        assert_eq!(stats.parsed, 2);
475        assert_eq!(
476            stats.skipped, 2,
477            "two garbage lines skipped (blank ignored)"
478        );
479    }
480
481    #[test]
482    fn query_filters_by_project_worker_and_reason() {
483        let (ledger, _dir) = temp_ledger();
484        ledger
485            .append(&event(
486                IncidentReasonCode::LocalFallback,
487                "p1",
488                Some("css"),
489                1,
490            ))
491            .unwrap();
492        ledger
493            .append(&event(
494                IncidentReasonCode::CircuitOpen,
495                "p1",
496                Some("bil"),
497                2,
498            ))
499            .unwrap();
500        ledger
501            .append(&event(
502                IncidentReasonCode::CircuitOpen,
503                "p2",
504                Some("css"),
505                3,
506            ))
507            .unwrap();
508
509        assert_eq!(ledger.query(&IncidentFilter::new().project("p1")).len(), 2);
510        assert_eq!(ledger.query(&IncidentFilter::new().worker("css")).len(), 2);
511        assert_eq!(
512            ledger
513                .query(&IncidentFilter::new().reason(IncidentReasonCode::CircuitOpen))
514                .len(),
515            2
516        );
517        // Combined filter narrows to one.
518        let combined = IncidentFilter::new()
519            .project("p1")
520            .reason(IncidentReasonCode::CircuitOpen);
521        let hits = ledger.query(&combined);
522        assert_eq!(hits.len(), 1);
523        assert_eq!(hits[0].worker_id.as_deref(), Some("bil"));
524    }
525
526    #[test]
527    fn retention_compacts_to_max_entries_keeping_newest() {
528        let dir = tempfile::tempdir().unwrap();
529        let path = dir.path().join("incidents.jsonl");
530        // Tiny bounds so compaction triggers deterministically.
531        let ledger = IncidentLedger::new(IncidentLedgerConfig {
532            path,
533            max_entries: 3,
534            max_bytes: 1, // any content exceeds this → compact every append
535        });
536        for i in 0..10u64 {
537            ledger
538                .append(&event(IncidentReasonCode::LocalFallback, "p", None, i))
539                .unwrap();
540        }
541        let events = ledger.read_all();
542        assert_eq!(events.len(), 3, "retained at most max_entries");
543        // Newest events kept (timestamps 7,8,9).
544        let timestamps: Vec<u64> = events.iter().map(|e| e.occurred_at_unix_ms).collect();
545        assert_eq!(timestamps, vec![7, 8, 9]);
546    }
547
548    #[test]
549    fn explicit_compact_drops_corrupt_lines() {
550        let dir = tempfile::tempdir().unwrap();
551        let path = dir.path().join("incidents.jsonl");
552        let ledger = IncidentLedger::with_path(&path);
553        ledger
554            .append(&event(IncidentReasonCode::DiskFull, "p1", None, 1))
555            .unwrap();
556        {
557            use std::io::Write;
558            let mut f = OpenOptions::new().append(true).open(&path).unwrap();
559            writeln!(f, "not json").unwrap();
560        }
561        ledger.compact().unwrap();
562        // Corrupt line is gone; the file now round-trips cleanly with no skips.
563        let (events, stats) = ledger.read_all_with_stats();
564        assert_eq!(events.len(), 1);
565        assert_eq!(stats.skipped, 0);
566    }
567
568    #[test]
569    fn default_ledger_path_honors_state_home() {
570        // Use an explicit config rather than mutating process env (unsafe in
571        // edition 2024). default_ledger_path falls through to a sane location.
572        let p = default_ledger_path();
573        assert!(p.ends_with("incidents.jsonl"));
574    }
575}