Skip to main content

reifydb_runtime/fatal/
report.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{env, fmt::Write as _, thread};
5
6pub const FATAL_BANNER: &str = "======================== REIFYDB FATAL ========================";
7pub const FATAL_FOOTER: &str = "==============================================================";
8pub const ISSUE_URL: &str = "https://github.com/reifydb/reifydb/issues";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum FatalKind {
12	Panic,
13	Invariant,
14	Error,
15}
16
17impl FatalKind {
18	pub fn as_str(&self) -> &'static str {
19		match self {
20			FatalKind::Panic => "panic",
21			FatalKind::Invariant => "invariant violated",
22			FatalKind::Error => "unexpected error",
23		}
24	}
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Origin {
29	pub file: String,
30	pub line: u32,
31	pub column: u32,
32}
33
34impl Origin {
35	pub fn new(file: impl Into<String>, line: u32, column: u32) -> Self {
36		Self {
37			file: file.into(),
38			line,
39			column,
40		}
41	}
42
43	pub fn error_id(&self) -> String {
44		let stem = self.file.rsplit('/').next().unwrap_or(&self.file).trim_end_matches(".rs");
45		format!("ERR-{}:{}", stem, self.line)
46	}
47}
48
49#[derive(Debug, Clone)]
50pub struct FatalReport {
51	pub kind: FatalKind,
52	pub component: Option<String>,
53	pub reason: String,
54	pub origin: Option<Origin>,
55	pub thread_name: String,
56	pub backtrace: Option<String>,
57	pub context: Vec<(String, String)>,
58}
59
60impl FatalReport {
61	pub fn new(kind: FatalKind, reason: impl Into<String>) -> Self {
62		Self {
63			kind,
64			component: None,
65			reason: reason.into(),
66			origin: None,
67			thread_name: current_thread_name(),
68			backtrace: None,
69			context: Vec::new(),
70		}
71	}
72
73	pub fn component(mut self, component: impl Into<String>) -> Self {
74		self.component = Some(component.into());
75		self
76	}
77
78	pub fn origin(mut self, origin: Origin) -> Self {
79		self.origin = Some(origin);
80		self
81	}
82
83	pub fn backtrace(mut self, backtrace: impl Into<String>) -> Self {
84		self.backtrace = Some(backtrace.into());
85		self
86	}
87
88	pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
89		self.context.push((key.into(), value.into()));
90		self
91	}
92
93	pub fn error_id(&self) -> String {
94		self.origin.as_ref().map(Origin::error_id).unwrap_or_else(|| "ERR-unknown".to_string())
95	}
96
97	pub fn render(&self) -> String {
98		let mut out = String::with_capacity(1024);
99		let _ = writeln!(out, "{}", FATAL_BANNER);
100		let _ = writeln!(out, "id:        {}", self.error_id());
101		let _ = writeln!(out, "kind:      {}", self.kind.as_str());
102		if let Some(component) = &self.component {
103			let _ = writeln!(out, "component: {}", component);
104		}
105		let _ = writeln!(out, "reason:    {}", self.reason);
106		if let Some(origin) = &self.origin {
107			let _ = writeln!(out, "location:  {}:{}:{}", origin.file, origin.line, origin.column);
108		}
109		let _ = writeln!(out, "thread:    {}", self.thread_name);
110		for (key, value) in &self.context {
111			let _ = writeln!(out, "{:<10} {}", format!("{}:", key), value);
112		}
113		let _ = writeln!(out, "version:   {}", env!("CARGO_PKG_VERSION"));
114		let _ = writeln!(
115			out,
116			"build:     {} ({})",
117			option_env!("GIT_HASH").unwrap_or("unknown"),
118			option_env!("BUILD_DATE").unwrap_or("unknown")
119		);
120		let _ = writeln!(out, "platform:  {} {}", env::consts::OS, env::consts::ARCH);
121		match &self.backtrace {
122			Some(backtrace) => {
123				let _ = writeln!(out, "backtrace:\n{}", backtrace.trim_end());
124			}
125			None => {
126				let _ = writeln!(out, "backtrace: <unavailable, set RUST_BACKTRACE=1>");
127			}
128		}
129		let _ = writeln!(out, "\nThis is a bug in ReifyDB. Please report it with everything above:");
130		let _ = writeln!(out, "{}", ISSUE_URL);
131		let _ = write!(out, "{}", FATAL_FOOTER);
132		out
133	}
134}
135
136fn current_thread_name() -> String {
137	let current = thread::current();
138	match current.name() {
139		Some(name) => format!("{} ({:?})", name, current.id()),
140		None => format!("<unnamed> ({:?})", current.id()),
141	}
142}
143
144#[cfg(test)]
145mod tests {
146	use super::*;
147
148	#[test]
149	fn the_error_id_pins_the_source_line_so_two_tickets_from_one_seam_collapse() {
150		// Deriving the id from anything unstable (a timestamp, a pointer, the thread) would make every
151		// occurrence of one bug look like a separate bug.
152		let origin = Origin::new("crates/store-operator/src/commit/buffer.rs", 214, 9);
153
154		assert_eq!(origin.error_id(), "ERR-buffer:214");
155		assert_eq!(
156			Origin::new("crates/store-operator/src/commit/buffer.rs", 214, 77).error_id(),
157			"ERR-buffer:214",
158			"the column must not enter the id, or a formatting change would fork the ticket"
159		);
160	}
161
162	#[test]
163	fn a_report_without_an_origin_still_renders_an_id_rather_than_dying() {
164		// A panic caught by the hook has no file/line of ours, so the report must degrade and never fail.
165		let report = FatalReport::new(FatalKind::Panic, "something impossible");
166
167		assert_eq!(report.error_id(), "ERR-unknown");
168		assert!(report.render().contains("id:        ERR-unknown"));
169	}
170
171	#[test]
172	fn the_render_carries_every_field_a_ticket_needs() {
173		// Each field below is one a maintainer cannot reconstruct after the fact; dropping any turns the report
174		// into "it crashed".
175		let rendered = FatalReport::new(FatalKind::Invariant, "watermark moved backwards: 9 -> 4")
176			.component("flow supervisor")
177			.origin(Origin::new("crates/flow/src/x.rs", 12, 3))
178			.backtrace("0: reifydb_flow::x::apply\n1: reifydb_runtime::pool::run")
179			.with("flow", "7")
180			.render();
181
182		assert!(rendered.contains(FATAL_BANNER), "the banner is what makes the block greppable in a log");
183		assert!(rendered.contains("id:        ERR-x:12"));
184		assert!(rendered.contains("kind:      invariant violated"));
185		assert!(rendered.contains("component: flow supervisor"));
186		assert!(rendered.contains("reason:    watermark moved backwards: 9 -> 4"));
187		assert!(rendered.contains("location:  crates/flow/src/x.rs:12:3"));
188		assert!(rendered.contains("flow:      7"), "caller context must survive into the report");
189		assert!(rendered.contains("0: reifydb_flow::x::apply"));
190		assert!(rendered.contains(ISSUE_URL), "a report nobody can file is not a report");
191		assert!(rendered.contains(env!("CARGO_PKG_VERSION")));
192		assert!(rendered.trim_end().ends_with(FATAL_FOOTER));
193	}
194
195	#[test]
196	fn a_missing_backtrace_says_how_to_get_one_instead_of_going_silent() {
197		// A blank line here would read as "no stack exists" rather than "you did not ask for one".
198		let rendered = FatalReport::new(FatalKind::Error, "storage flush failed").render();
199
200		assert!(rendered.contains("RUST_BACKTRACE=1"));
201	}
202
203	#[test]
204	fn the_thread_is_recorded_because_the_hook_fires_far_from_the_caller() {
205		// The bug class this exists for is a background actor dying quietly, and without the thread name the
206		// report cannot say which one.
207		let rendered = FatalReport::new(FatalKind::Panic, "boom").render();
208
209		assert!(rendered.contains("thread:    "));
210	}
211}