Skip to main content

somatize_runtime/effects/
journal.rs

1//! Record-once, replay-forever — the durability half of effectful execution.
2//!
3//! A run that calls a model is not reproducible by re-running it. The answer
4//! differs, so a crash halfway through means starting over, and an experiment
5//! record of "what this agent did" cannot be checked against anything.
6//!
7//! Durable-execution engines solve this by journaling: perform each effect
8//! once, write the result down, and on replay serve the recorded result
9//! instead of performing it again. The orchestration is deterministic; only
10//! the recorded edges are not.
11//!
12//! Soma already has the storage for that — the same two-table action store
13//! the filter cache uses ([`ActionCache`] for small records, [`BlobStore`]
14//! for payloads). What this module adds is the *keying*, and the distinction
15//! the filter cache cannot express:
16//!
17//! | Effect kind | Key includes | Reused |
18//! |---|---|---|
19//! | Pure (a graph run) | the effect's content | across every run, like a filter |
20//! | Impure (a model call, a tool) | run, node, turn, effect | only when replaying *that* run |
21//!
22//! That second row is the point. Asking a model the same question twice is
23//! genuinely two events, so memoizing by content would freeze the first
24//! answer forever — the `_deterministic = false` foot-gun wearing a hat.
25//! Scoping the key to `(run, node, turn)` means a resumed run sees exactly
26//! what the original saw, while a fresh run asks afresh.
27
28use somatize_core::action::{ActionCache, ActionResult, BlobStore, ContentHash};
29use somatize_core::cache::{CacheKey, Origin};
30use somatize_core::effect::{Effect, EffectResult};
31use somatize_core::error::{Result, SomaError};
32use std::sync::Arc;
33
34/// Where an effect happened, for keying its record.
35#[derive(Debug, Clone, Copy)]
36pub struct EffectSite<'a> {
37    /// Run the effect belongs to — the scope a replay is confined to.
38    pub run_id: &'a str,
39    /// Step node that awaited the effect.
40    pub node_id: &'a str,
41    /// Turn of the step's loop the effect was awaited in.
42    pub turn: usize,
43    /// Position within the turn, since one turn may await several effects.
44    pub index: usize,
45}
46
47/// Reads and writes effect results.
48///
49/// Cloning is cheap; the stores are shared.
50#[derive(Clone)]
51pub struct EffectJournal {
52    actions: Arc<dyn ActionCache>,
53    blobs: Arc<dyn BlobStore>,
54    /// When false, nothing is written or read. Set per step by
55    /// [`somatize_core::step::StepMeta::journal`], for work whose payloads
56    /// must not reach disk.
57    enabled: bool,
58}
59
60impl EffectJournal {
61    /// A journal over the same two-table store the cache uses: action
62    /// records (kept) and content blobs (evictable).
63    pub fn new(actions: Arc<dyn ActionCache>, blobs: Arc<dyn BlobStore>) -> Self {
64        Self {
65            actions,
66            blobs,
67            enabled: true,
68        }
69    }
70
71    /// A journal that records nothing. The run still works; it just cannot
72    /// be replayed.
73    pub fn disabled(actions: Arc<dyn ActionCache>, blobs: Arc<dyn BlobStore>) -> Self {
74        Self {
75            actions,
76            blobs,
77            enabled: false,
78        }
79    }
80
81    /// Whether this journal records and replays at all.
82    pub fn is_enabled(&self) -> bool {
83        self.enabled
84    }
85
86    /// Toggle recording, e.g. per step.
87    pub fn with_enabled(mut self, enabled: bool) -> Self {
88        self.enabled = enabled;
89        self
90    }
91
92    /// The record key for an effect at a site.
93    ///
94    /// Pure effects key on content alone, so any run may reuse them. Impure
95    /// effects additionally key on the site, which is what confines their
96    /// reuse to a replay of the same run.
97    pub fn key(&self, site: EffectSite<'_>, effect: &Effect) -> Result<CacheKey> {
98        let effect_key = effect.cache_key()?;
99        Ok(if effect.is_pure() {
100            CacheKey::from_parts(&[b"soma-journal-v1", b"pure", &effect_key.0])
101        } else {
102            CacheKey::from_parts(&[
103                b"soma-journal-v1",
104                b"sited",
105                site.run_id.as_bytes(),
106                site.node_id.as_bytes(),
107                &site.turn.to_le_bytes(),
108                &site.index.to_le_bytes(),
109                &effect_key.0,
110            ])
111        })
112    }
113
114    /// Fetch a recorded result, if there is one.
115    ///
116    /// A record whose blob has been evicted reads as absent: the effect is
117    /// performed again. For a pure effect that is merely slower; for an
118    /// impure one it means a replay diverges, which is why
119    /// [`Self::record`] marks impure blobs as expensive so GC keeps them.
120    pub fn lookup(&self, site: EffectSite<'_>, effect: &Effect) -> Result<Option<EffectResult>> {
121        if !self.enabled {
122            return Ok(None);
123        }
124        let key = self.key(site, effect)?;
125        let Some(record) = self.actions.get_action(&key)? else {
126            return Ok(None);
127        };
128        let Some(hash) = record.outputs.get("effect_result") else {
129            return Ok(None);
130        };
131        let Some(bytes) = self.blobs.get_bytes(hash)? else {
132            tracing::warn!(
133                node = site.node_id,
134                turn = site.turn,
135                "journal record present but its blob is gone; performing the effect again"
136            );
137            return Ok(None);
138        };
139        let result: EffectResult = serde_json::from_slice(&bytes)
140            .map_err(|e| SomaError::Cache(format!("journal: decoding effect result: {e}")))?;
141        Ok(Some(result))
142    }
143
144    /// Write down what an effect produced.
145    pub fn record(
146        &self,
147        site: EffectSite<'_>,
148        effect: &Effect,
149        result: &EffectResult,
150        compute_ms: u64,
151    ) -> Result<()> {
152        if !self.enabled {
153            return Ok(());
154        }
155        // A failure is not worth pinning: on replay we would rather retry it
156        // than faithfully reproduce the outage that caused it.
157        if matches!(result, EffectResult::Failed { .. }) {
158            return Ok(());
159        }
160
161        let bytes = serde_json::to_vec(result)
162            .map_err(|e| SomaError::Cache(format!("journal: encoding effect result: {e}")))?;
163        let hash: ContentHash = self.blobs.put_bytes(&bytes)?;
164
165        let now = chrono::Utc::now();
166        let record = ActionResult {
167            key: self.key(site, effect)?,
168            outputs: [("effect_result".to_string(), hash)].into_iter().collect(),
169            output_bytes: bytes.len() as u64,
170            compute_ms,
171            // An impure effect's record is the *only* copy of what happened.
172            // Marking it non-deterministic tells GC and any future reader
173            // that recomputing it would not reproduce this value.
174            deterministic: effect.is_pure(),
175            origin: Origin::Computed {
176                node_id: site.node_id.to_string(),
177                run_id: site.run_id.to_string(),
178            },
179            created_at: now,
180            last_accessed: now,
181        };
182        self.actions.put_action(&record)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::cache::fs_store::FsActionStore;
190    use somatize_core::effect::{LlmRequest, LlmResponse, StopReason, Usage};
191    use somatize_core::message::Message;
192    use somatize_core::value::Value;
193
194    fn store() -> (Arc<FsActionStore>, tempfile::TempDir) {
195        let dir = tempfile::tempdir().unwrap();
196        let store = Arc::new(FsActionStore::new(dir.path()).unwrap());
197        (store, dir)
198    }
199
200    fn journal(store: Arc<FsActionStore>) -> EffectJournal {
201        EffectJournal::new(store.clone(), store)
202    }
203
204    fn site<'a>(run: &'a str, node: &'a str, turn: usize) -> EffectSite<'a> {
205        EffectSite {
206            run_id: run,
207            node_id: node,
208            turn,
209            index: 0,
210        }
211    }
212
213    fn llm(prompt: &str) -> Effect {
214        Effect::Llm(LlmRequest::new(
215            "claude-opus-5",
216            vec![Message::user(prompt)].into(),
217        ))
218    }
219
220    fn reply(text: &str) -> EffectResult {
221        EffectResult::Llm(LlmResponse {
222            message: Message::assistant(text),
223            stop_reason: StopReason::EndTurn,
224            usage: Usage::default(),
225            model: None,
226        })
227    }
228
229    #[test]
230    fn records_then_replays() {
231        let (s, _d) = store();
232        let j = journal(s);
233        let effect = llm("hello");
234
235        assert!(j.lookup(site("r1", "n", 0), &effect).unwrap().is_none());
236
237        j.record(site("r1", "n", 0), &effect, &reply("hi"), 1200)
238            .unwrap();
239
240        let got = j.lookup(site("r1", "n", 0), &effect).unwrap().unwrap();
241        match got {
242            EffectResult::Llm(r) => assert_eq!(r.message.text(), "hi"),
243            other => panic!("wrong result: {other:?}"),
244        }
245    }
246
247    /// The load-bearing property: a *different* run asking the identical
248    /// question must not be served the first run's answer.
249    #[test]
250    fn impure_effects_do_not_leak_across_runs() {
251        let (s, _d) = store();
252        let j = journal(s);
253        let effect = llm("hello");
254
255        j.record(site("r1", "n", 0), &effect, &reply("hi"), 10)
256            .unwrap();
257
258        assert!(
259            j.lookup(site("r2", "n", 0), &effect).unwrap().is_none(),
260            "a fresh run reused a recorded model answer"
261        );
262        assert!(
263            j.lookup(site("r1", "other", 0), &effect).unwrap().is_none(),
264            "another node reused a recorded model answer"
265        );
266        assert!(
267            j.lookup(site("r1", "n", 1), &effect).unwrap().is_none(),
268            "a later turn reused an earlier turn's answer"
269        );
270    }
271
272    /// Two effects in the same turn are distinct records, or a parallel
273    /// fan-out would replay one answer for all of them.
274    #[test]
275    fn effects_within_a_turn_are_distinct() {
276        let (s, _d) = store();
277        let j = journal(s);
278        let effect = llm("hello");
279
280        let first = EffectSite {
281            run_id: "r",
282            node_id: "n",
283            turn: 0,
284            index: 0,
285        };
286        let second = EffectSite { index: 1, ..first };
287
288        j.record(first, &effect, &reply("one"), 1).unwrap();
289        assert!(j.lookup(second, &effect).unwrap().is_none());
290    }
291
292    /// Pure effects are ordinary content-addressed cache entries: any run,
293    /// any node, same answer.
294    #[test]
295    fn pure_effects_are_shared() {
296        let (s, _d) = store();
297        let j = journal(s);
298        let effect = Effect::Graph {
299            graph: Box::new(somatize_core::graph::Graph::new()),
300            input: Value::tensor(vec![1.0], vec![1]),
301            mode: somatize_core::effect::GraphEffectMode::Forward,
302        };
303        assert!(effect.is_pure());
304
305        j.record(
306            site("r1", "n", 0),
307            &effect,
308            &EffectResult::Graph(Value::tensor(vec![2.0], vec![1])),
309            5,
310        )
311        .unwrap();
312
313        let got = j.lookup(site("r2", "elsewhere", 7), &effect).unwrap();
314        assert!(got.is_some(), "a pure effect should be reusable anywhere");
315    }
316
317    /// Different questions must not collide, however alike their sites.
318    #[test]
319    fn different_effects_at_the_same_site_differ() {
320        let (s, _d) = store();
321        let j = journal(s);
322
323        j.record(site("r", "n", 0), &llm("first"), &reply("A"), 1)
324            .unwrap();
325        assert!(
326            j.lookup(site("r", "n", 0), &llm("second"))
327                .unwrap()
328                .is_none(),
329            "two different prompts shared a journal record"
330        );
331    }
332
333    /// A step that opts out leaves no trace on disk — the escape hatch for
334    /// prompts that must not be persisted.
335    #[test]
336    fn a_disabled_journal_records_nothing() {
337        let (s, _d) = store();
338        let j = journal(s).with_enabled(false);
339        let effect = llm("something sensitive");
340
341        j.record(site("r", "n", 0), &effect, &reply("x"), 1)
342            .unwrap();
343        assert!(j.lookup(site("r", "n", 0), &effect).unwrap().is_none());
344    }
345
346    /// Delete every file under `dir`, keeping the directory tree — what GC
347    /// eviction does to CAS blobs (records are retained, blobs go).
348    fn delete_files_under(dir: &std::path::Path) {
349        for entry in std::fs::read_dir(dir).unwrap() {
350            let path = entry.unwrap().path();
351            if path.is_dir() {
352                delete_files_under(&path);
353            } else {
354                std::fs::remove_file(&path).unwrap();
355            }
356        }
357    }
358
359    /// An action record whose blob was evicted must read as *absent*, so
360    /// the effect is performed again — not as an error, and never as a
361    /// half-answer. This is the fallback in `lookup`; without it a GC pass
362    /// over the shared store would turn every replay into a decode failure.
363    #[test]
364    fn an_evicted_blob_reads_as_absent() {
365        let (s, dir) = store();
366        let j = journal(s);
367        let effect = llm("hello");
368
369        j.record(site("r", "n", 0), &effect, &reply("hi"), 1)
370            .unwrap();
371        assert!(j.lookup(site("r", "n", 0), &effect).unwrap().is_some());
372
373        // Evict the blob; the action record stays where it is.
374        delete_files_under(&dir.path().join("cas"));
375
376        assert!(
377            j.lookup(site("r", "n", 0), &effect).unwrap().is_none(),
378            "a record without its blob must be treated as a miss"
379        );
380    }
381
382    /// Failures are retried on replay, not faithfully reproduced.
383    #[test]
384    fn failures_are_not_recorded() {
385        let (s, _d) = store();
386        let j = journal(s);
387        let effect = llm("hello");
388
389        j.record(
390            site("r", "n", 0),
391            &effect,
392            &EffectResult::Failed {
393                message: "connection reset".into(),
394            },
395            1,
396        )
397        .unwrap();
398
399        assert!(j.lookup(site("r", "n", 0), &effect).unwrap().is_none());
400    }
401
402    // ── Keying properties ──
403    //
404    // The key is the whole safety story: a pure record shared where it must
405    // not be, or two sites colliding, silently serves one run another run's
406    // model answer. These pin the key function itself, over arbitrary sites.
407    mod keying {
408        use super::*;
409        use proptest::prelude::*;
410
411        fn pure_effect() -> Effect {
412            Effect::Graph {
413                graph: Box::new(somatize_core::graph::Graph::new()),
414                input: Value::tensor(vec![1.0], vec![1]),
415                mode: somatize_core::effect::GraphEffectMode::Forward,
416            }
417        }
418
419        fn journal() -> (EffectJournal, tempfile::TempDir) {
420            let (s, d) = store();
421            (super::journal(s), d)
422        }
423
424        proptest! {
425            /// The `pure`/`sited` namespaces are disjoint: whatever the
426            /// site, a content-keyed record can never shadow a site-keyed
427            /// one, or vice versa.
428            #[test]
429            fn pure_and_sited_keys_never_collide(
430                run in "[a-z0-9]{0,8}",
431                node in "[a-z0-9/]{0,8}",
432                turn in 0usize..64,
433                index in 0usize..8,
434            ) {
435                let (j, _d) = journal();
436                let s = EffectSite { run_id: &run, node_id: &node, turn, index };
437                prop_assert_ne!(j.key(s, &llm("q")).unwrap(), j.key(s, &pure_effect()).unwrap());
438            }
439
440            /// A sited key is a function of exactly (site, effect): change
441            /// any site component and the key changes; change nothing and
442            /// it is bit-identical. The first half is what confines an
443            /// impure record to its own run/node/turn/index; the second is
444            /// what lets a replay find it at all.
445            #[test]
446            fn a_sited_key_is_exactly_its_site_and_effect(
447                a in ("[a-z]{0,4}", "[a-z]{0,4}", 0usize..4, 0usize..4),
448                b in ("[a-z]{0,4}", "[a-z]{0,4}", 0usize..4, 0usize..4),
449            ) {
450                let (j, _d) = journal();
451                let sa = EffectSite { run_id: &a.0, node_id: &a.1, turn: a.2, index: a.3 };
452                let sb = EffectSite { run_id: &b.0, node_id: &b.1, turn: b.2, index: b.3 };
453                let effect = llm("same question");
454
455                prop_assert_eq!(j.key(sa, &effect).unwrap(), j.key(sa, &effect).unwrap());
456                if a == b {
457                    prop_assert_eq!(j.key(sa, &effect).unwrap(), j.key(sb, &effect).unwrap());
458                } else {
459                    prop_assert_ne!(j.key(sa, &effect).unwrap(), j.key(sb, &effect).unwrap());
460                }
461            }
462
463            /// Same site, different questions: distinct keys, always.
464            #[test]
465            fn a_sited_key_separates_effects(
466                run in "[a-z]{0,6}",
467                node in "[a-z]{0,6}",
468                turn in 0usize..8,
469            ) {
470                let (j, _d) = journal();
471                let s = EffectSite { run_id: &run, node_id: &node, turn, index: 0 };
472                prop_assert_ne!(j.key(s, &llm("one")).unwrap(), j.key(s, &llm("two")).unwrap());
473            }
474        }
475
476        /// The classic concatenation collision: `("ab", "c")` and
477        /// `("a", "bc")` flatten to the same bytes unless each part is
478        /// length-prefixed. `CacheKey::from_parts` prefixes; this is the
479        /// regression test that notices if that ever changes.
480        #[test]
481        fn adjacent_site_fields_do_not_blur_together() {
482            let (j, _d) = journal();
483            let effect = llm("q");
484            let key = |run: &str, node: &str| {
485                j.key(
486                    EffectSite {
487                        run_id: run,
488                        node_id: node,
489                        turn: 0,
490                        index: 0,
491                    },
492                    &effect,
493                )
494                .unwrap()
495            };
496            assert_ne!(
497                key("ab", "c"),
498                key("a", "bc"),
499                "site fields concatenated without length prefixes"
500            );
501        }
502    }
503}