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}