use core::fmt;
use std::io::Write as _;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::file_lock::FileLock;
pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Bark {
pub at_ms: u64,
pub rule: String,
pub subject: String,
pub message: String,
pub sinks: Vec<SinkOutcome>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SinkOutcome {
pub sink: String,
pub error: Option<String>,
}
#[non_exhaustive]
#[derive(Debug)]
pub enum BarkError {
Io(std::io::Error),
Encode(serde_json::Error),
}
impl fmt::Display for BarkError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "bark ring I/O failed: {err}"),
Self::Encode(err) => write!(f, "bark record failed to serialize: {err}"),
}
}
}
impl core::error::Error for BarkError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Encode(err) => Some(err),
}
}
}
impl From<std::io::Error> for BarkError {
fn from(source: std::io::Error) -> Self {
Self::Io(source)
}
}
impl From<serde_json::Error> for BarkError {
fn from(source: serde_json::Error) -> Self {
Self::Encode(source)
}
}
pub fn append(path: &Path, bark: &Bark, max_bytes: u64) -> Result<(), BarkError> {
let _lock = FileLock::acquire(path)?;
let mut lines = read_lines(path)?;
let new_line = serde_json::to_string(bark)?;
lines.push(new_line);
loop {
if lines.len() <= 1 || ring_bytes(&lines) <= max_bytes {
break;
}
lines.remove(0);
}
write_ring(path, &lines)
}
pub fn read(path: &Path) -> Result<Vec<Bark>, BarkError> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(text
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(err) => Err(BarkError::Io(err)),
}
}
fn read_lines(path: &Path) -> Result<Vec<String>, BarkError> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(text.lines().map(str::to_owned).collect()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(err) => Err(BarkError::Io(err)),
}
}
fn ring_bytes(lines: &[String]) -> u64 {
lines.iter().map(|line| line.len() as u64 + 1).sum()
}
fn write_ring(path: &Path, lines: &[String]) -> Result<(), BarkError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = crate::atomic_file::create_staging_file(parent, "barks", ".tmp")?;
for line in lines {
tmp.write_all(line.as_bytes())?;
tmp.write_all(b"\n")?;
}
crate::atomic_file::publish(tmp, path).map_err(BarkError::Io)
}
#[cfg(test)]
mod tests {
use super::*;
fn bark_for(subject: &str, at_ms: u64) -> Bark {
Bark {
at_ms,
rule: "watchdog".to_string(),
subject: subject.to_string(),
message: "restart budget exhausted".to_string(),
sinks: vec![SinkOutcome {
sink: "discord".to_string(),
error: None,
}],
}
}
fn one_bark_len() -> u64 {
let line = serde_json::to_string(&bark_for("second", 1)).unwrap();
line.len() as u64 + 1
}
#[test]
fn the_ring_drops_the_oldest_bark_to_stay_under_its_cap() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
let cap = 2 * one_bark_len();
for (i, subject) in ["first", "second", "third"].iter().enumerate() {
append(&path, &bark_for(subject, i as u64), cap).unwrap();
}
let barks = read(&path).unwrap();
let subjects: Vec<&str> = barks.iter().map(|b| b.subject.as_str()).collect();
assert_eq!(subjects, ["second", "third"], "oldest out, newest kept");
assert!(
std::fs::metadata(&path).unwrap().len() <= cap,
"the cap is a cap"
);
}
#[test]
fn a_bark_bigger_than_the_cap_is_written_anyway() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
let huge = Bark {
message: "x".repeat(4096),
..bark_for("web", 0)
};
append(&path, &huge, 64).unwrap();
assert_eq!(read(&path).unwrap().len(), 1);
}
#[test]
fn a_line_that_will_not_parse_costs_one_record_and_not_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
append(&path, &bark_for("web", 1), DEFAULT_MAX_BYTES).unwrap();
std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap()
.write_all(b"{\"at_ms\": 2, \"rul\n")
.unwrap();
append(&path, &bark_for("api", 3), DEFAULT_MAX_BYTES).unwrap();
let barks = read(&path).unwrap();
assert_eq!(
barks.iter().map(|b| b.subject.as_str()).collect::<Vec<_>>(),
["web", "api"]
);
}
#[test]
fn no_file_yet_is_no_barks_rather_than_a_failure() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read(&dir.path().join("nothing.jsonl")).unwrap(), vec![]);
}
#[cfg(any(unix, windows))]
const CHILD_PATH_VAR: &str = "SHEP_BARK_RACE_PATH";
#[cfg(any(unix, windows))]
const CHILD_TAG_VAR: &str = "SHEP_BARK_RACE_TAG";
#[cfg(any(unix, windows))]
const RECORDS_PER_WRITER: u64 = 200;
#[cfg(any(unix, windows))]
#[test]
#[ignore = "child process of two_writer_processes_do_not_lose_each_other_s_barks"]
fn bark_race_child() {
let Ok(path) = std::env::var(CHILD_PATH_VAR) else {
panic!("{CHILD_PATH_VAR} unset — this test is only run as a child process");
};
let tag = std::env::var(CHILD_TAG_VAR).expect("child needs a tag");
let path = std::path::PathBuf::from(path);
for i in 0..RECORDS_PER_WRITER {
append(&path, &bark_for(&tag, i), DEFAULT_MAX_BYTES).expect("child append");
}
}
#[cfg(any(unix, windows))]
#[test]
fn two_writer_processes_do_not_lose_each_other_s_barks() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
let exe = std::env::current_exe().expect("test binary path");
let children: Vec<_> = ["alpha", "beta"]
.iter()
.map(|tag| {
std::process::Command::new(&exe)
.args(["--exact", "--ignored", "barks::tests::bark_race_child"])
.env(CHILD_PATH_VAR, &path)
.env(CHILD_TAG_VAR, tag)
.stdout(std::process::Stdio::piped())
.spawn()
.expect("spawn writer")
})
.collect();
for child in children {
let out = child.wait_with_output().expect("wait for writer");
assert!(
out.status.success(),
"a writer process failed: {}\n{}",
out.status,
String::from_utf8_lossy(&out.stdout)
);
}
let barks = read(&path).unwrap();
for tag in ["alpha", "beta"] {
let mut seen: Vec<u64> = barks
.iter()
.filter(|b| b.subject == tag)
.map(|b| b.at_ms)
.collect();
seen.sort_unstable();
let expected: Vec<u64> = (0..RECORDS_PER_WRITER).collect();
assert_eq!(
seen, expected,
"{tag}'s records did not all survive the other writer"
);
}
assert_eq!(
barks.len() as u64,
2 * RECORDS_PER_WRITER,
"the ring holds records nobody wrote"
);
}
#[cfg(unix)]
#[test]
fn append_creates_the_ring_owner_only_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
append(&path, &bark_for("web", 0), DEFAULT_MAX_BYTES).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"barks.jsonl is not the credential file, but stays narrow anyway"
);
}
}