use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use crate::util::UnwrapPoison;
const ERROR_LOG_NAME: &str = "error.log";
pub(crate) const UNKNOWN_STORE: &str = "store: unknown (not obtainable on this path)";
pub(crate) const UNKNOWN_DB_PATH: &str = "db path: unresolvable (storage root unavailable)";
const ARTIFACT_STATE_UNAVAILABLE: &str = "artifact state: not obtainable (no -wal size measured)";
pub(crate) fn artifact_state_line(wal_bytes: Option<u64>) -> String {
match wal_bytes {
Some(bytes) => format!("artifact state: wal_size={bytes}"),
None => ARTIFACT_STATE_UNAVAILABLE.to_string(),
}
}
pub(crate) const ENVIRONMENT_CAUSE: &str = "cause: environment — not store damage";
#[derive(Debug)]
pub(crate) enum FailureKind {
StartUpRefusal,
StartUpFailure,
CheckpointFailure,
ExitCheckpointFailure,
RuntimeIntegrityFailure,
ShrinkRefused,
}
impl FailureKind {
fn header(&self) -> &'static str {
match self {
Self::StartUpRefusal => "MahBot start-up refusal — ",
Self::StartUpFailure => "MahBot start-up failure — ",
Self::CheckpointFailure => "MahBot checkpoint failure — ",
Self::ExitCheckpointFailure => "MahBot exit checkpoint failure — ",
Self::RuntimeIntegrityFailure => "MahBot runtime integrity failure — ",
Self::ShrinkRefused => "MahBot store shrink refused — ",
}
}
}
#[derive(Debug)]
pub(crate) struct FailureReport {
kind: FailureKind,
store: Option<&'static str>,
db_path: Option<PathBuf>,
reason: Option<String>,
environment: bool,
extra: Vec<String>,
}
impl FailureReport {
#[must_use]
pub(crate) fn new(kind: FailureKind) -> Self {
Self {
kind,
store: None,
db_path: None,
reason: None,
environment: false,
extra: Vec::new(),
}
}
#[must_use]
pub(crate) fn store(mut self, store: &'static str) -> Self {
self.store = Some(store);
self
}
#[must_use]
pub(crate) fn db_path(mut self, db_path: PathBuf) -> Self {
self.db_path = Some(db_path);
self
}
#[must_use]
pub(crate) fn reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
#[must_use]
pub(crate) fn environment(mut self, environment: bool) -> Self {
self.environment = environment;
self
}
#[must_use]
pub(crate) fn extra(mut self, line: impl Into<String>) -> Self {
self.extra.push(line.into());
self
}
#[must_use]
pub(crate) fn render(&self) -> String {
use std::fmt::Write;
let mut body = String::new();
let _ = writeln!(
body,
"{}{}",
self.kind.header(),
chrono::Utc::now().to_rfc3339()
);
match self.store {
Some(store) => {
let _ = writeln!(body, "store: {store}");
}
None => {
let _ = writeln!(body, "{UNKNOWN_STORE}");
}
}
match &self.db_path {
Some(db_path) => {
let _ = writeln!(body, "db path: {}", db_path.display());
}
None => {
let _ = writeln!(body, "{UNKNOWN_DB_PATH}");
}
}
if self.environment {
let _ = writeln!(body, "{ENVIRONMENT_CAUSE}");
}
if let Some(reason) = &self.reason {
let _ = writeln!(body, "reason: {reason}");
}
for line in &self.extra {
let _ = writeln!(body, "{line}");
}
body
}
}
pub(crate) struct RoundCounter(LazyLock<Mutex<HashMap<String, u64>>>);
impl RoundCounter {
pub(crate) const fn new() -> Self {
Self(LazyLock::new(|| Mutex::new(HashMap::new())))
}
pub(crate) fn prior_rounds(&self, key: &str) -> u64 {
let mut rounds = self.0.lock().unwrap_poison();
let count = rounds.entry(key.to_string()).or_insert(0);
*count += 1;
*count - 1
}
}
fn append_failure_record(root: &Path, report: &str) -> std::io::Result<std::path::PathBuf> {
use std::io::Write;
let path = root.join(ERROR_LOG_NAME);
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
file.write_all(format!("{report}\n").as_bytes())?;
Ok(path)
}
pub(crate) fn record(root: Option<&Path>, block: &str) -> Option<std::path::PathBuf> {
let Some(root) = root else {
crate::boot::timestamped_stderr(&unfiled_block(
block,
&format!(
"the storage root is unresolvable — this failure could not be filed in \
{ERROR_LOG_NAME}"
),
));
return None;
};
match append_failure_record(root, block) {
Ok(path) => Some(path),
Err(e) => {
crate::boot::timestamped_stderr(&unfiled_block(
block,
&format!(
"the failure could not be filed in {}: {e:#}",
root.join(ERROR_LOG_NAME).display(),
),
));
None
}
}
}
pub(crate) fn recorded_pointer(what: &str, path: Option<PathBuf>) -> Option<String> {
path.map(|path| format!("{what} recorded in {}", path.display()))
}
pub(crate) fn record_and_point(root: Option<&Path>, what: &str, report: &FailureReport) {
let filed = record(root, &report.render());
if let Some(pointer) = recorded_pointer(what, filed) {
tracing::info!("{pointer}");
}
}
fn unfiled_block(block: &str, reason: &str) -> String {
format!("{block}\nnote: {reason}")
}
pub(crate) fn start_up_report(
kind: FailureKind,
store: Option<(&'static str, PathBuf)>,
reason: String,
environment: bool,
) -> FailureReport {
let report = FailureReport::new(kind)
.reason(reason)
.environment(environment);
match store {
Some((name, db_path)) => report.store(name).db_path(db_path),
None => report,
}
}
pub(crate) fn record_startup_refusal(
root: &Path,
store: &'static str,
db_path: &Path,
reason: &str,
environment: bool,
) -> Option<PathBuf> {
let report = start_up_report(
FailureKind::StartUpRefusal,
Some((store, db_path.to_path_buf())),
reason.to_string(),
environment,
);
record(Some(root), &report.render())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_emits_every_field_in_order() {
let report = FailureReport::new(FailureKind::RuntimeIntegrityFailure)
.store("core")
.db_path(PathBuf::from("/tmp/core.db"))
.environment(true)
.reason("the check could not run")
.extra("artifact state: wal_size=0")
.render();
let lines: Vec<&str> = report.lines().collect();
let (header, timestamp) = lines[0]
.split_once(" — ")
.expect("the block's first line must be a header and an em-dashed timestamp");
assert_eq!(
header, "MahBot runtime integrity failure",
"the header must name the failure kind: {report}"
);
assert!(
!timestamp.is_empty() && timestamp.starts_with("20"),
"the header must carry an RFC 3339 UTC timestamp: {report}"
);
assert_eq!(lines[1], "store: core", "got: {report}");
assert_eq!(lines[2], "db path: /tmp/core.db", "got: {report}");
assert_eq!(
lines[3], ENVIRONMENT_CAUSE,
"an environment-caused failure must say so: {report}"
);
assert_eq!(lines[4], "reason: the check could not run", "got: {report}");
assert_eq!(
lines[5], "artifact state: wal_size=0",
"extras must follow verbatim: {report}"
);
let bare = FailureReport::new(FailureKind::StartUpFailure).render();
assert!(
bare.contains(UNKNOWN_STORE) && bare.contains(UNKNOWN_DB_PATH),
"an unobtainable field must be rendered, never omitted: {bare}"
);
assert!(
!bare.contains(ENVIRONMENT_CAUSE) && !bare.contains("reason:"),
"absent optional fields must not be rendered: {bare}"
);
}
#[test]
fn startup_refusal_block_is_written_with_its_environment_note() {
let tmp = tempfile::TempDir::new().expect("temp dir for test");
let db_path = tmp.path().join("db/core.db");
record_startup_refusal(tmp.path(), "core", &db_path, "permission denied", true);
let body = std::fs::read_to_string(tmp.path().join(ERROR_LOG_NAME)).expect("error.log");
assert!(
body.starts_with("MahBot start-up refusal — ")
&& body.contains("store: core\n")
&& body.contains(&format!("db path: {}\n", db_path.display()))
&& body.contains(&format!("{ENVIRONMENT_CAUSE}\n"))
&& body.contains("reason: permission denied\n"),
"got: {body}"
);
}
#[test]
fn record_files_the_block_with_the_file_terminator() {
let tmp = tempfile::TempDir::new().expect("temp dir for test");
record(
Some(tmp.path()),
"MahBot start-up failure — probe\nstore: unknown\n",
);
let body = std::fs::read_to_string(tmp.path().join(ERROR_LOG_NAME)).expect("error.log");
assert_eq!(
body, "MahBot start-up failure — probe\nstore: unknown\n\n",
"the block and the file's blank-line terminator must be written: {body:?}"
);
}
#[test]
fn unfiled_block_carries_the_block_and_the_reason() {
let text = unfiled_block(
"MahBot start-up failure — probe\nstore: core\n",
"the storage root is unresolvable",
);
assert!(
text.starts_with("MahBot start-up failure — probe\nstore: core\n"),
"the whole block must reach stderr: {text:?}"
);
assert!(
text.contains("note: the storage root is unresolvable"),
"the fallback must say why it is on stderr: {text:?}"
);
}
}