Skip to main content

shep_core/
barks.rs

1//! `barks.jsonl`: the size-capped ring of fired alerts.
2//!
3//! One [`Bark`] per line, appended by the bark dog on a rule fire and by
4//! the shepherd on a dog's exhausted restart budget, and read by `shep
5//! barks`. [`append`] evicts oldest-first under a byte cap by rewriting
6//! survivors to a temp file and `rename`ing it over the original, so a
7//! writer that dies mid-rewrite never leaves a fragment. [`read`] skips
8//! any unparseable line, since this file is read during an incident.
9//!
10//! The two writers are separate OS processes, so a [`crate::file_lock`] on
11//! a sibling `<path>.lock` serializes them.
12
13// The lock type lives in shep-core rather than shep-daemon so both writers
14// can name it.
15use core::fmt;
16use std::io::Write as _;
17use std::path::Path;
18
19use serde::{Deserialize, Serialize};
20
21use crate::file_lock::FileLock;
22
23/// Cap the ring keeps itself under when nobody configured one.
24pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024;
25
26/// One fired alert, as it lands in `$SHEP_HOME/barks.jsonl`.
27///
28/// One JSON object per line: an interrupted write then costs the reader
29/// one record, not the whole file.
30///
31/// `Debug` is derived, not redacted. Every field here is shep's own prose
32/// or a config key, never a sink's target; a field that ever carried a
33/// webhook URL or token would need its own redacted `Debug`.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Bark {
36    /// Unix millis when the alert fired.
37    pub at_ms: u64,
38    /// The rule that fired, or `daemon` when the shepherd wrote this
39    /// itself.
40    pub rule: String,
41    /// What it is about: a sheep's name, or a dog's.
42    pub subject: String,
43    /// The human-readable line. Plain English, no theme: this is read
44    /// during an incident.
45    pub message: String,
46    /// Which sinks the alert was delivered to, and whether each took it.
47    /// Empty when the shepherd wrote the record itself: it has no sinks
48    /// and no webhook code, and says so by carrying none.
49    pub sinks: Vec<SinkOutcome>,
50}
51
52/// What one sink made of one alert.
53///
54/// Names the sink by its `[dog.bark.sinks]` config key, never by its
55/// webhook URL or bearer token, so [`Bark`] stays safe to print with a
56/// derived `Debug`.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct SinkOutcome {
59    /// The sink's name from `[dog.bark.sinks]`.
60    pub sink: String,
61    /// `None` when it was delivered; the failure otherwise.
62    pub error: Option<String>,
63}
64
65/// Error type returned by [`append`] and [`read`].
66///
67/// Wraps `io::Error`/`serde_json::Error` directly so callers keep the
68/// underlying diagnostic via [`core::error::Error::source`]; this enum
69/// cannot derive `Clone`/`PartialEq`/`Eq` as a result.
70///
71/// `#[non_exhaustive]`: shep-core is a published library, so a future
72/// failure variant must not break an out-of-tree consumer's `match`.
73#[non_exhaustive]
74#[derive(Debug)]
75pub enum BarkError {
76    /// The ring file could not be read, written, or replaced.
77    Io(std::io::Error),
78    /// A [`Bark`] could not be serialized to JSON.
79    Encode(serde_json::Error),
80}
81
82impl fmt::Display for BarkError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::Io(err) => write!(f, "bark ring I/O failed: {err}"),
86            Self::Encode(err) => write!(f, "bark record failed to serialize: {err}"),
87        }
88    }
89}
90
91impl core::error::Error for BarkError {
92    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
93        match self {
94            Self::Io(err) => Some(err),
95            Self::Encode(err) => Some(err),
96        }
97    }
98}
99
100impl From<std::io::Error> for BarkError {
101    fn from(source: std::io::Error) -> Self {
102        Self::Io(source)
103    }
104}
105
106impl From<serde_json::Error> for BarkError {
107    fn from(source: serde_json::Error) -> Self {
108        Self::Encode(source)
109    }
110}
111
112/// Appends `bark` to `path`, evicting oldest-first to keep the file under
113/// `max_bytes`.
114///
115/// Eviction drops a prefix of whole lines, atomically; an oversized
116/// record is written anyway, since dropping it would silently lose the
117/// one alert too big to fit. Serialized against other appenders by an
118/// advisory lock on a sibling `<path>.lock`. Concurrent [`read`]s are not
119/// blocked: the ring is only ever replaced whole.
120///
121/// # Errors
122/// - [`BarkError::Io`]: the file or its lock could not be read, written, or replaced.
123/// - [`BarkError::Encode`]: the record could not be serialized.
124pub fn append(path: &Path, bark: &Bark, max_bytes: u64) -> Result<(), BarkError> {
125    // Held until this returns, so the read and the final rename are one
126    // transaction as far as any other writer is concerned.
127    let _lock = FileLock::acquire(path)?;
128
129    let mut lines = read_lines(path)?;
130    let new_line = serde_json::to_string(bark)?;
131    lines.push(new_line);
132
133    // Oldest-out: drop the front line until the ring fits under the cap,
134    // or only the record just appended is left.
135    loop {
136        if lines.len() <= 1 || ring_bytes(&lines) <= max_bytes {
137            break;
138        }
139        lines.remove(0);
140    }
141
142    write_ring(path, &lines)
143}
144
145/// Reads every bark in `path`, oldest first, skipping any line that will
146/// not parse.
147///
148/// A line that will not parse is a partially-written record from a writer
149/// that died mid-append, or a record from a future shep. Neither refuses
150/// the whole history during an incident, which is the one time this file
151/// is read.
152///
153/// # Errors
154/// - [`BarkError::Io`]: the file exists and could not be read. A missing
155///   file is `Ok(Vec::new())`: no barks yet is not a fault.
156pub fn read(path: &Path) -> Result<Vec<Bark>, BarkError> {
157    match std::fs::read_to_string(path) {
158        Ok(text) => Ok(text
159            .lines()
160            .filter_map(|line| serde_json::from_str(line).ok())
161            .collect()),
162        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
163        Err(err) => Err(BarkError::Io(err)),
164    }
165}
166
167/// `path`'s existing lines, raw and unparsed, or an empty ring if the
168/// file does not exist yet.
169///
170/// Does not parse: eviction operates on whole lines exactly as they sit
171/// on disk, so a line a future shep wrote, or a fragment a dead writer
172/// left, still counts toward the byte cap and survives an eviction it
173/// does not trigger. [`read`] is where an unparseable line is dropped.
174fn read_lines(path: &Path) -> Result<Vec<String>, BarkError> {
175    match std::fs::read_to_string(path) {
176        Ok(text) => Ok(text.lines().map(str::to_owned).collect()),
177        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
178        Err(err) => Err(BarkError::Io(err)),
179    }
180}
181
182/// Total on-disk size, in bytes, if `lines` were written one per line
183/// (each line plus its trailing `\n`).
184fn ring_bytes(lines: &[String]) -> u64 {
185    lines.iter().map(|line| line.len() as u64 + 1).sum()
186}
187
188/// Rewrites `path` to hold exactly `lines`: the content lands in a
189/// uniquely-named sibling temp file, is `fsync`ed, then `rename`d over
190/// `path`, so an interrupted write leaves the original untouched.
191///
192/// The name is unique per call, not a fixed `<path>.tmp`: two writers
193/// racing on a shared name can have one `rename` consume the other's
194/// staging file. [`FileLock`] already keeps two appenders apart; this is
195/// the second lock for a caller that reaches `write_ring` another way.
196fn write_ring(path: &Path, lines: &[String]) -> Result<(), BarkError> {
197    let parent = path.parent().unwrap_or_else(|| Path::new("."));
198    let mut tmp = crate::atomic_file::create_staging_file(parent, "barks", ".tmp")?;
199
200    for line in lines {
201        tmp.write_all(line.as_bytes())?;
202        tmp.write_all(b"\n")?;
203    }
204    tmp.as_file().sync_all()?;
205
206    // `persist` is `rename(2)`. On failure the `NamedTempFile` comes back
207    // inside the error and its `Drop` removes the staging file, so a
208    // failed replace does not leave one behind.
209    tmp.persist(path).map_err(|err| BarkError::Io(err.error))?;
210
211    // `sync_all` made the contents durable; this makes the rename that
212    // published them durable.
213    crate::atomic_file::sync_dir(parent)?;
214    Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    /// A representative fired alert. `at_ms` is a caller-chosen tag, not a
222    /// real timestamp: tests use it to tell records apart.
223    fn bark_for(subject: &str, at_ms: u64) -> Bark {
224        Bark {
225            at_ms,
226            rule: "watchdog".to_string(),
227            subject: subject.to_string(),
228            message: "restart budget exhausted".to_string(),
229            sinks: vec![SinkOutcome {
230                sink: "discord".to_string(),
231                error: None,
232            }],
233        }
234    }
235
236    /// The serialized length, plus its trailing newline, of one
237    /// `bark_for`-shaped line, computed here so it cannot happen to equal
238    /// the implementation's own byte count.
239    fn one_bark_len() -> u64 {
240        let line = serde_json::to_string(&bark_for("second", 1)).unwrap();
241        line.len() as u64 + 1
242    }
243
244    /// Cap set to force eviction on the third write.
245    #[test]
246    fn the_ring_drops_the_oldest_bark_to_stay_under_its_cap() {
247        let dir = tempfile::tempdir().unwrap();
248        let path = dir.path().join("barks.jsonl");
249        let cap = 2 * one_bark_len();
250
251        for (i, subject) in ["first", "second", "third"].iter().enumerate() {
252            append(&path, &bark_for(subject, i as u64), cap).unwrap();
253        }
254
255        let barks = read(&path).unwrap();
256        let subjects: Vec<&str> = barks.iter().map(|b| b.subject.as_str()).collect();
257        assert_eq!(subjects, ["second", "third"], "oldest out, newest kept");
258        assert!(
259            std::fs::metadata(&path).unwrap().len() <= cap,
260            "the cap is a cap"
261        );
262    }
263
264    #[test]
265    fn a_bark_bigger_than_the_cap_is_written_anyway() {
266        let dir = tempfile::tempdir().unwrap();
267        let path = dir.path().join("barks.jsonl");
268        let huge = Bark {
269            message: "x".repeat(4096),
270            ..bark_for("web", 0)
271        };
272        append(&path, &huge, 64).unwrap();
273        assert_eq!(read(&path).unwrap().len(), 1);
274    }
275
276    #[test]
277    fn a_line_that_will_not_parse_costs_one_record_and_not_the_file() {
278        let dir = tempfile::tempdir().unwrap();
279        let path = dir.path().join("barks.jsonl");
280        append(&path, &bark_for("web", 1), DEFAULT_MAX_BYTES).unwrap();
281        std::fs::OpenOptions::new()
282            .append(true)
283            .open(&path)
284            .unwrap()
285            .write_all(b"{\"at_ms\": 2, \"rul\n")
286            .unwrap();
287        append(&path, &bark_for("api", 3), DEFAULT_MAX_BYTES).unwrap();
288
289        let barks = read(&path).unwrap();
290        assert_eq!(
291            barks.iter().map(|b| b.subject.as_str()).collect::<Vec<_>>(),
292            ["web", "api"]
293        );
294    }
295
296    #[test]
297    fn no_file_yet_is_no_barks_rather_than_a_failure() {
298        let dir = tempfile::tempdir().unwrap();
299        assert_eq!(read(&dir.path().join("nothing.jsonl")).unwrap(), vec![]);
300    }
301
302    /// Env var naming the ring file the re-executed child should append
303    /// to. Its presence is also what tells the child it is a child.
304    #[cfg(any(unix, windows))]
305    const CHILD_PATH_VAR: &str = "SHEP_BARK_RACE_PATH";
306    /// Env var carrying the child's tag, which it stamps into every
307    /// record's `subject` so the parent can tell the two writers apart.
308    #[cfg(any(unix, windows))]
309    const CHILD_TAG_VAR: &str = "SHEP_BARK_RACE_TAG";
310    /// How many records each of the two writers appends. Large enough
311    /// that the two read-modify-rename sequences overlap many times over.
312    #[cfg(any(unix, windows))]
313    const RECORDS_PER_WRITER: u64 = 200;
314
315    /// Not a test: the child half of
316    /// [`two_writer_processes_do_not_lose_each_other_s_barks`], re-executed
317    /// as a separate OS process via `--ignored --exact`. Asserts nothing;
318    /// its job is to hammer [`append`] while the parent judges the result.
319    #[cfg(any(unix, windows))]
320    #[test]
321    #[ignore = "child process of two_writer_processes_do_not_lose_each_other_s_barks"]
322    fn bark_race_child() {
323        let Ok(path) = std::env::var(CHILD_PATH_VAR) else {
324            panic!("{CHILD_PATH_VAR} unset — this test is only run as a child process");
325        };
326        let tag = std::env::var(CHILD_TAG_VAR).expect("child needs a tag");
327        let path = std::path::PathBuf::from(path);
328
329        for i in 0..RECORDS_PER_WRITER {
330            append(&path, &bark_for(&tag, i), DEFAULT_MAX_BYTES).expect("child append");
331        }
332    }
333
334    /// Two OS processes, not threads: an in-process mutex would prove
335    /// nothing about a race that crosses address spaces via `rename`.
336    /// Covers Windows too: reverting `acquire`'s Windows arm to
337    /// `Ok(Self {})` reddens this rather than passing quietly. Without
338    /// the lock this can still pass on a lucky serial schedule.
339    #[cfg(any(unix, windows))]
340    #[test]
341    fn two_writer_processes_do_not_lose_each_other_s_barks() {
342        let dir = tempfile::tempdir().unwrap();
343        let path = dir.path().join("barks.jsonl");
344        let exe = std::env::current_exe().expect("test binary path");
345
346        let children: Vec<_> = ["alpha", "beta"]
347            .iter()
348            .map(|tag| {
349                std::process::Command::new(&exe)
350                    .args(["--exact", "--ignored", "barks::tests::bark_race_child"])
351                    .env(CHILD_PATH_VAR, &path)
352                    .env(CHILD_TAG_VAR, tag)
353                    // Piped, not inherited: a passing run should not
354                    // interleave two child harnesses' output into this
355                    // one's, and a failing child's harness output is
356                    // exactly what the assertion below needs to show.
357                    .stdout(std::process::Stdio::piped())
358                    .spawn()
359                    .expect("spawn writer")
360            })
361            .collect();
362
363        for child in children {
364            let out = child.wait_with_output().expect("wait for writer");
365            assert!(
366                out.status.success(),
367                "a writer process failed: {}\n{}",
368                out.status,
369                String::from_utf8_lossy(&out.stdout)
370            );
371        }
372
373        let barks = read(&path).unwrap();
374        for tag in ["alpha", "beta"] {
375            let mut seen: Vec<u64> = barks
376                .iter()
377                .filter(|b| b.subject == tag)
378                .map(|b| b.at_ms)
379                .collect();
380            seen.sort_unstable();
381            let expected: Vec<u64> = (0..RECORDS_PER_WRITER).collect();
382            assert_eq!(
383                seen, expected,
384                "{tag}'s records did not all survive the other writer"
385            );
386        }
387        assert_eq!(
388            barks.len() as u64,
389            2 * RECORDS_PER_WRITER,
390            "the ring holds records nobody wrote"
391        );
392    }
393
394    /// No field here is a credential today; the mode stays narrow so a
395    /// future one that is arrives already protected.
396    #[cfg(unix)]
397    #[test]
398    fn append_creates_the_ring_owner_only_on_unix() {
399        use std::os::unix::fs::PermissionsExt;
400        let dir = tempfile::tempdir().unwrap();
401        let path = dir.path().join("barks.jsonl");
402
403        append(&path, &bark_for("web", 0), DEFAULT_MAX_BYTES).unwrap();
404
405        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
406        assert_eq!(
407            mode, 0o600,
408            "barks.jsonl is not the credential file, but stays narrow anyway"
409        );
410    }
411}