use core::fmt;
use std::fs::File;
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::file_lock::FileLock;
pub const DEFAULT_MAX_BYTES: u64 = 1024 * 1024;
const TAIL_WINDOW_BYTES: u64 = 64 * 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);
let mut ring = ring_bytes(&lines);
let mut evicted = 0;
while ring > max_bytes && lines.len() - evicted > 1 {
ring -= line_bytes(&lines[evicted]);
evicted += 1;
}
lines.drain(..evicted);
write_ring(path, &lines)
}
pub fn read(path: &Path) -> Result<Vec<Bark>, BarkError> {
Ok(read_text(path)?
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect())
}
pub fn read_last(path: &Path, limit: usize) -> Result<Vec<Bark>, BarkError> {
if limit == 0 {
return Ok(Vec::new());
}
let mut file = match File::open(path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(BarkError::Io(err)),
};
let len = file.metadata()?.len();
let mut window = TAIL_WINDOW_BYTES.min(len);
loop {
let mut barks = read_window(&mut file, len, window)?;
if barks.len() >= limit || window >= len {
let keep_from = barks.len().saturating_sub(limit);
barks.drain(..keep_from);
return Ok(barks);
}
window = window.saturating_mul(2).min(len);
}
}
fn read_window(file: &mut File, len: u64, window: u64) -> Result<Vec<Bark>, BarkError> {
let start = len - window;
let probe = u64::from(start > 0);
file.seek(SeekFrom::Start(start - probe))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let text = if start > 0 {
match bytes.iter().position(|&b| b == b'\n') {
Some(newline) => String::from_utf8_lossy(&bytes[newline + 1..]),
None => return Ok(Vec::new()),
}
} else {
String::from_utf8_lossy(&bytes)
};
Ok(text
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect())
}
fn read_lines(path: &Path) -> Result<Vec<String>, BarkError> {
Ok(read_text(path)?.lines().map(str::to_owned).collect())
}
fn read_text(path: &Path) -> Result<String, BarkError> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(text),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(err) => Err(BarkError::Io(err)),
}
}
fn line_bytes(line: &str) -> u64 {
line.len() as u64 + 1
}
fn ring_bytes(lines: &[String]) -> u64 {
lines.iter().map(|line| line_bytes(line)).sum()
}
fn write_ring(path: &Path, lines: &[String]) -> Result<(), BarkError> {
let parent = crate::atomic_file::parent_of(path);
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_cap_crossed_by_many_lines_at_once_takes_the_prefix_and_no_more() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
for i in 0..20 {
append(&path, &bark_for("web", i), DEFAULT_MAX_BYTES).unwrap();
}
let mut expected = read_lines(&path).unwrap();
expected.push(serde_json::to_string(&bark_for("web", 20)).unwrap());
let evicted = expected.len() - 4;
expected.drain(..evicted);
let cap = ring_bytes(&expected);
append(&path, &bark_for("web", 20), cap).unwrap();
let kept = read_lines(&path).unwrap();
assert_eq!(kept, expected, "the whole prefix goes, and nothing past it");
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
cap,
"the byte accounting stays exact"
);
}
#[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 an_oversized_bark_evicts_every_line_before_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
append(&path, &bark_for("web", 0), DEFAULT_MAX_BYTES).unwrap();
let huge = Bark {
message: "x".repeat(4096),
..bark_for("api", 1)
};
append(&path, &huge, 64).unwrap();
assert_eq!(
read(&path).unwrap(),
vec![huge],
"the one record too big to fit, alone"
);
}
#[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![]);
}
#[test]
fn a_window_starting_on_a_boundary_keeps_its_first_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
for i in 0..3u64 {
append(&path, &bark_for(&format!("s{i}"), i), DEFAULT_MAX_BYTES).unwrap();
}
let bytes = std::fs::read(&path).unwrap();
let len = bytes.len() as u64;
let first = bytes.iter().position(|&b| b == b'\n').unwrap() as u64;
let window = len - (first + 1);
let mut file = File::open(&path).unwrap();
let barks = read_window(&mut file, len, window).unwrap();
assert_eq!(
barks.iter().map(|b| b.subject.as_str()).collect::<Vec<_>>(),
["s1", "s2"]
);
}
#[test]
fn read_last_on_a_missing_file_is_no_barks_rather_than_a_failure() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
read_last(&dir.path().join("nothing.jsonl"), 20).unwrap(),
vec![]
);
}
#[test]
fn read_last_of_zero_records_is_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
append(&path, &bark_for("web", 1), DEFAULT_MAX_BYTES).unwrap();
assert_eq!(read_last(&path, 0).unwrap(), vec![]);
}
#[test]
fn read_last_returns_the_newest_records_oldest_first() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
for i in 0..10u64 {
append(&path, &bark_for(&format!("s{i}"), i), DEFAULT_MAX_BYTES).unwrap();
}
let barks = read_last(&path, 3).unwrap();
assert_eq!(
barks.iter().map(|b| b.subject.as_str()).collect::<Vec<_>>(),
["s7", "s8", "s9"]
);
}
#[test]
fn read_last_grows_the_window_until_it_holds_enough_records() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
let big = |subject: &str, at_ms: u64| Bark {
message: "x".repeat(20 * 1024),
..bark_for(subject, at_ms)
};
for (i, subject) in ["s0", "s1", "s2", "s3", "s4", "s5"].iter().enumerate() {
append(&path, &big(subject, i as u64), DEFAULT_MAX_BYTES).unwrap();
}
assert_eq!(read(&path).unwrap().len(), 6, "the fixture is six records");
let barks = read_last(&path, 5).unwrap();
assert_eq!(
barks.iter().map(|b| b.subject.as_str()).collect::<Vec<_>>(),
["s1", "s2", "s3", "s4", "s5"]
);
}
#[test]
fn read_last_answers_what_a_full_read_would_for_every_limit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("barks.jsonl");
let mut file = std::fs::File::create(&path).unwrap();
for i in 0..12u64 {
let bark = Bark {
message: "x".repeat(8 * 1024),
..bark_for(&format!("s{i}"), i)
};
writeln!(file, "{}", serde_json::to_string(&bark).unwrap()).unwrap();
if i % 3 == 0 {
writeln!(file, "{{\"at_ms\": 99, \"subj").unwrap();
}
if i % 4 == 0 {
writeln!(file, "{{\"at_ms\": 99, \"future\": []}}").unwrap();
}
}
drop(file);
let everything = read(&path).unwrap();
assert_eq!(
everything.len(),
12,
"the fixture is twelve parseable records"
);
for limit in 0..everything.len() + 3 {
let keep_from = everything.len().saturating_sub(limit);
assert_eq!(
read_last(&path, limit).unwrap(),
&everything[keep_from..],
"limit {limit} must answer exactly what a full read plus a drain answers"
);
}
}
#[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"
);
}
}