Skip to main content

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}