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