traverse_runtime/trace/durable.rs
1//! Durable, append-only, per-workspace persistence for execution traces.
2//!
3//! Governed by spec `079-durable-trace-journal` (`specs/518-durable-trace-journal/spec.md`,
4//! ADR-0017). Reuses the existing [`crate::events::DurableEventJournal`]
5//! rather than a second storage engine: trace records are canonical JSON
6//! Lines, appended and `fsync`-committed exactly like domain events (FR-001),
7//! and inherit that journal's existing recovery semantics (FR-003, spec 066
8//! FR-009: discard only an incomplete final record, fail loudly on any other
9//! corruption) and deterministic oldest-first, whole-segment pruning
10//! (FR-005) unchanged. "Per workspace" retention (FR-005) is achieved by
11//! opening one journal per workspace root -- a caller opens a
12//! [`DurableTraceJournal`] at a workspace-scoped directory, so pruning is
13//! inherently workspace-isolated without threading a `workspace_id` field
14//! through every trace record, mirroring how [`crate::data_store`] is
15//! host-opened at an explicit root rather than auto-wired by [`crate::Runtime`].
16//!
17//! This is an additive durability layer alongside [`super::TraceStore`]
18//! (spec `012-execution-trace-tiered`'s process-local, in-memory store) and
19//! [`super::store`]/`517-embedded-trace-api`'s process-local query surface --
20//! it does not replace or change either.
21
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex};
24
25use serde::{Deserialize, Serialize};
26
27use crate::events::{
28 BrokerClock, DurableEventJournal, JournalConfig, JournalError, LifecycleStatus, SystemClock,
29 TraverseEvent,
30};
31
32use super::{PrivateTraceEntry, PublicTraceEntry};
33
34const TRACE_RECORD_EVENT_TYPE: &str = "dev.traverse.trace.recorded";
35const TRACE_RECORD_OWNER: &str = "traverse-runtime";
36const TRACE_RECORD_VERSION: &str = "1.0.0";
37const RECOVERY_REPLAY_PAGE_SIZE: usize = 256;
38
39/// Errors surfaced by the durable trace journal.
40#[derive(Debug, PartialEq, Eq)]
41pub enum TraceJournalError {
42 /// The underlying event journal failed.
43 Journal(JournalError),
44 /// A trace record could not be serialized for durable persistence.
45 Serialize(String),
46 /// A durably-recorded trace record could not be deserialized during
47 /// recovery.
48 Deserialize(String),
49 /// A durable-trace lock was poisoned by a prior panic.
50 LockPoisoned,
51}
52
53impl std::fmt::Display for TraceJournalError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 Self::Journal(error) => write!(f, "trace journal failure: {error}"),
57 Self::Serialize(message) => write!(f, "trace record serialization failed: {message}"),
58 Self::Deserialize(message) => {
59 write!(f, "durable trace record deserialization failed: {message}")
60 }
61 Self::LockPoisoned => write!(f, "durable trace journal lock is poisoned"),
62 }
63 }
64}
65
66impl std::error::Error for TraceJournalError {}
67
68impl From<JournalError> for TraceJournalError {
69 fn from(error: JournalError) -> Self {
70 Self::Journal(error)
71 }
72}
73
74/// One durable trace record: the same public/private pair [`super::TraceStore`]
75/// holds in memory, persisted together so recovery never has to guess
76/// whether a private entry's matching public entry survived. Still carries
77/// only non-sensitive metadata and hashes (FR-004) -- exactly what
78/// [`PublicTraceEntry`]/[`PrivateTraceEntry`] already guarantee by
79/// construction; this wrapper adds no new fields.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81struct DurableTraceRecord {
82 public: PublicTraceEntry,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 private: Option<PrivateTraceEntry>,
85}
86
87/// Deterministic evidence of what survived recovery when a
88/// [`DurableTraceJournal`] was opened (FR-003). A frozen snapshot taken at
89/// open time -- it is not updated by later [`DurableTraceJournal::record`]
90/// calls in the same session, so it always answers "what did recovery find,"
91/// not "what has this session written since." An incomplete final record
92/// left by a crash mid-write is silently absent here (discarded by the
93/// underlying journal's recovery) rather than reconstructed: recovery MUST
94/// NOT invent trace evidence (ADR-0017).
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
96pub struct TraceRecoveryReport {
97 pub recovered_trace_ids: Vec<String>,
98}
99
100/// Evidence produced by [`DurableTraceJournal::prune`] (FR-005).
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub struct TracePrunedEvidence {
103 pub workspace_root: PathBuf,
104 pub deleted_segment_paths: Vec<PathBuf>,
105}
106
107/// Durable, per-workspace persistence layer for execution traces.
108pub struct DurableTraceJournal {
109 root: PathBuf,
110 journal: DurableEventJournal,
111 recovery: TraceRecoveryReport,
112}
113
114impl DurableTraceJournal {
115 /// Opens (or creates) a durable trace journal rooted at `root`, using the
116 /// real system clock for segment rollover/retention timing.
117 ///
118 /// # Errors
119 ///
120 /// See [`Self::open_with_clock`].
121 pub fn open(root: &Path, config: JournalConfig) -> Result<Self, TraceJournalError> {
122 Self::open_with_clock(root, config, Arc::new(SystemClock))
123 }
124
125 /// As [`Self::open`], with an injectable clock for deterministic tests.
126 ///
127 /// # Errors
128 ///
129 /// Returns [`TraceJournalError::Journal`] when the journal cannot be
130 /// opened, including when a completed (non-final) on-disk record is
131 /// corrupt (spec 066 FR-009: fail loudly rather than silently discard).
132 /// Returns [`TraceJournalError::Deserialize`] when a durably-recorded
133 /// trace record cannot be parsed back during the open-time recovery scan.
134 pub fn open_with_clock(
135 root: &Path,
136 config: JournalConfig,
137 clock: Arc<dyn BrokerClock>,
138 ) -> Result<Self, TraceJournalError> {
139 let journal = DurableEventJournal::open(root, config, clock)?;
140 let recovery = Self::compute_recovery_report(&journal)?;
141 Ok(Self {
142 root: root.to_path_buf(),
143 journal,
144 recovery,
145 })
146 }
147
148 fn compute_recovery_report(
149 journal: &DurableEventJournal,
150 ) -> Result<TraceRecoveryReport, TraceJournalError> {
151 let mut recovered_trace_ids = Vec::new();
152 let mut cursor = "0".to_string();
153 loop {
154 let page = journal.replay_from(&cursor, RECOVERY_REPLAY_PAGE_SIZE)?;
155 if page.is_empty() {
156 break;
157 }
158 for (next_cursor, event) in &page {
159 cursor.clone_from(next_cursor);
160 let record: DurableTraceRecord = serde_json::from_value(event.data.clone())
161 .map_err(|error| TraceJournalError::Deserialize(error.to_string()))?;
162 recovered_trace_ids.push(record.public.id);
163 }
164 }
165 Ok(TraceRecoveryReport {
166 recovered_trace_ids,
167 })
168 }
169
170 /// Deterministic evidence of what survived recovery when this journal was
171 /// opened (FR-003).
172 #[must_use]
173 pub fn recovery_report(&self) -> &TraceRecoveryReport {
174 &self.recovery
175 }
176
177 /// Durably appends one trace record, `fsync`-committed before returning
178 /// (FR-001). Returns the journal cursor for the appended record.
179 ///
180 /// # Errors
181 ///
182 /// Returns [`TraceJournalError`] when the durable write fails.
183 pub fn record(
184 &mut self,
185 public: &PublicTraceEntry,
186 private: Option<&PrivateTraceEntry>,
187 ) -> Result<String, TraceJournalError> {
188 let record = DurableTraceRecord {
189 public: public.clone(),
190 private: private.cloned(),
191 };
192 let data = serde_json::to_value(&record)
193 .map_err(|error| TraceJournalError::Serialize(error.to_string()))?;
194 let event = TraverseEvent {
195 id: public.id.clone(),
196 source: public.source.clone(),
197 event_type: TRACE_RECORD_EVENT_TYPE.to_string(),
198 datacontenttype: "application/json".to_string(),
199 time: public.time.clone(),
200 data,
201 owner: TRACE_RECORD_OWNER.to_string(),
202 version: TRACE_RECORD_VERSION.to_string(),
203 lifecycle_status: LifecycleStatus::Active,
204 deduplication_id: None,
205 ordering_scope: None,
206 correlation_id: None,
207 causation_id: None,
208 subject_id: None,
209 actor_id: None,
210 };
211 Ok(self.journal.append(&event)?)
212 }
213
214 /// Reclaims retained trace history past the configured age/size bounds,
215 /// deterministic oldest segment first, never touching the active
216 /// (currently-being-written) segment (FR-005).
217 ///
218 /// # Errors
219 ///
220 /// Returns [`TraceJournalError`] when a segment cannot be removed.
221 pub fn prune(&mut self) -> Result<TracePrunedEvidence, TraceJournalError> {
222 let deleted_segment_paths = self.journal.prune()?;
223 Ok(TracePrunedEvidence {
224 workspace_root: self.root.clone(),
225 deleted_segment_paths,
226 })
227 }
228}
229
230/// Durable persistence sink for execution traces. Implemented for
231/// `Mutex<DurableTraceJournal>` for real persistence; test doubles may
232/// implement this directly to exercise a caller's fail-closed behavior
233/// (FR-002) without real filesystem fault injection.
234pub trait TraceDurabilitySink: Send + Sync {
235 /// # Errors
236 ///
237 /// Returns [`TraceJournalError`] when the durable write fails.
238 fn record(
239 &self,
240 public: &PublicTraceEntry,
241 private: Option<&PrivateTraceEntry>,
242 ) -> Result<String, TraceJournalError>;
243}
244
245impl TraceDurabilitySink for Mutex<DurableTraceJournal> {
246 fn record(
247 &self,
248 public: &PublicTraceEntry,
249 private: Option<&PrivateTraceEntry>,
250 ) -> Result<String, TraceJournalError> {
251 let mut journal = self.lock().map_err(|_| TraceJournalError::LockPoisoned)?;
252 journal.record(public, private)
253 }
254}
255
256/// A durable-trace sink plus the caller's audit posture for it (FR-002).
257pub struct DurableTraceConfig {
258 pub sink: Arc<dyn TraceDurabilitySink>,
259 /// When `true`, a durable-write failure fails the whole execution rather
260 /// than continuing with an in-memory-only trace ("auditable execution
261 /// MUST fail before returning success when its trace cannot be durably
262 /// written," FR-002). Callers set this from their own audit posture --
263 /// e.g. `RuntimeSecurityMode::Production` -- non-audited local
264 /// development work may opt out by setting this `false` (ADR-0017).
265 pub fail_closed: bool,
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn trace_journal_error_display_covers_every_variant() {
274 let cases: Vec<TraceJournalError> = vec![
275 TraceJournalError::Journal(JournalError::InvalidCursor("bad cursor".to_string())),
276 TraceJournalError::Serialize("boom".to_string()),
277 TraceJournalError::Deserialize("boom".to_string()),
278 TraceJournalError::LockPoisoned,
279 ];
280 for error in &cases {
281 assert!(
282 !error.to_string().is_empty(),
283 "Display must produce a non-empty string for {error:?}"
284 );
285 }
286 }
287}