Skip to main content

salamander/projection/
mod.rs

1//! DESIGN.md §5 — the projection contract. This trait is the product;
2//! everything else serves it.
3
4use crate::event::{Body, Event};
5use crate::format::{CodecId, OwnedStoredRecord};
6use crate::log::Log;
7use crate::{Result, SalamanderError};
8
9/// A deterministic fold of the log into derived state. Rebuild,
10/// time-travel, and fork are all the same fold stopped at a different
11/// point (INV-1).
12pub trait Projection {
13    /// The payload type this projection folds. Ties the projection to a
14    /// `Salamander<B>` with the same `B`: you can only build a projection
15    /// from a log whose events carry the payload it knows how to apply.
16    /// Expressed as an associated type (not a trait parameter) so the
17    /// generic replay helpers below read as `P: Projection` with no extra
18    /// `B` to thread through.
19    type Body: Body;
20
21    /// The derived state this projection maintains.
22    type State;
23
24    /// Apply one event. MUST be deterministic and infallible on valid
25    /// input: same events in same order => same state, every time, on
26    /// every machine (DESIGN.md §6, INV-1).
27    fn apply(&mut self, event: &Event<Self::Body>);
28
29    /// Current cursor: all events with offset < cursor have been applied.
30    fn cursor(&self) -> u64;
31
32    /// Read access to derived state.
33    fn state(&self) -> &Self::State;
34}
35
36/// Projections that need to know their namespace before any events are
37/// applied (DESIGN.md §2) — e.g. `SessionProjection` filters to one
38/// namespace, so it can't be built via plain `Default` the way
39/// `KvProjection` can.
40pub trait NamespaceScoped: Projection {
41    /// Constructs the projection already scoped to `namespace`.
42    fn new_for(namespace: &str) -> Self;
43}
44
45/// Deserializes one record's payload into an `Event<B>`, then stamps the
46/// log-assigned `offset` onto it. The log's own offset is authoritative
47/// (DESIGN.md §2: "Global, never reused") — the writer can't actually know
48/// the real offset until *after* `Log::append` returns, so whatever offset
49/// it serialized into the payload is at best a same-process prediction,
50/// not a durable fact. Shared by `replay_into` and `introspect::replay` so
51/// this invariant only has to be gotten right in one place.
52pub(crate) fn decode_event<B: Body>(offset: u64, bytes: &[u8]) -> Result<Event<B>> {
53    let mut event: Event<B> =
54        bincode::deserialize(bytes).map_err(|e| SalamanderError::Corrupt {
55            offset,
56            reason: e.to_string(),
57        })?;
58    event.offset = offset;
59    Ok(event)
60}
61
62pub(crate) fn decode_stored_event<B: Body>(record: &OwnedStoredRecord) -> Result<Event<B>> {
63    if record.envelope.event_type.as_str() == "salamander.raw-v2" {
64        return decode_event(record.position, &record.payload);
65    }
66    if record.envelope.codec != CodecId::RUST_BINCODE_V1 {
67        return Err(SalamanderError::Codec(format!(
68            "typed Rust replay does not support codec {}",
69            record.envelope.codec.0
70        )));
71    }
72    let body: B =
73        bincode::deserialize(&record.payload).map_err(|error| SalamanderError::Corrupt {
74            offset: record.position,
75            reason: format!("payload decode: {error}"),
76        })?;
77    let namespace = record
78        .envelope
79        .metadata
80        .get("salamander.stream_name")
81        .ok_or_else(|| SalamanderError::Corrupt {
82            offset: record.position,
83            reason: "v2 event is missing salamander.stream_name metadata".into(),
84        })
85        .and_then(|bytes| {
86            std::str::from_utf8(bytes)
87                .map(str::to_owned)
88                .map_err(|_| SalamanderError::Corrupt {
89                    offset: record.position,
90                    reason: "v2 stream name is not UTF-8".into(),
91                })
92        })?;
93    Ok(Event {
94        offset: record.position,
95        timestamp_ms: u64::try_from(record.envelope.timestamp_unix_nanos.max(0))
96            .unwrap_or(u64::MAX)
97            / 1_000_000,
98        namespace,
99        body,
100    })
101}
102
103/// Folds `log[p.cursor(), upto)` into `p` (DESIGN.md §6, INV-1). Rebuild,
104/// time-travel, and fork (DESIGN.md §5) are all this function with a
105/// different `upto`. The payload type is inferred from the projection's
106/// `Body` associated type — the log stays bytes-only.
107pub fn replay_into<P: Projection>(p: &mut P, log: &Log, upto: u64) -> Result<()> {
108    for item in log.records_from(p.cursor()) {
109        let record = item?;
110        if record.position >= upto {
111            break;
112        }
113        let event = decode_stored_event::<P::Body>(&record)?;
114        p.apply(&event);
115    }
116    Ok(())
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::agent::{EventBody, KvProjection};
123    use tempfile::tempdir;
124
125    fn put(namespace: &str, key: &str, value: &[u8]) -> Event<EventBody> {
126        Event {
127            offset: 0, // placeholder -- replay_into stamps the real one on read
128            timestamp_ms: 0,
129            namespace: namespace.to_string(),
130            body: EventBody::Put {
131                key: key.to_string(),
132                value: value.to_vec(),
133            },
134        }
135    }
136
137    fn delete(namespace: &str, key: &str) -> Event<EventBody> {
138        Event {
139            offset: 0,
140            timestamp_ms: 0,
141            namespace: namespace.to_string(),
142            body: EventBody::Delete {
143                key: key.to_string(),
144            },
145        }
146    }
147
148    fn append_bincode(log: &mut Log, event: &Event<EventBody>) -> u64 {
149        let bytes = bincode::serialize(event).unwrap();
150        log.append(&bytes).unwrap()
151    }
152
153    #[test]
154    fn replay_into_folds_put_and_delete_events() {
155        let dir = tempdir().unwrap();
156        let mut log = Log::open(dir.path()).unwrap();
157        append_bincode(&mut log, &put("ns", "a", b"1"));
158        append_bincode(&mut log, &put("ns", "b", b"2"));
159        append_bincode(&mut log, &delete("ns", "a"));
160        log.commit().unwrap();
161
162        let mut proj = KvProjection::default();
163        replay_into(&mut proj, &log, log.head()).unwrap();
164
165        assert_eq!(proj.state().get("a"), None);
166        assert_eq!(proj.state().get("b"), Some(&b"2".to_vec()));
167        assert_eq!(proj.cursor(), log.head());
168    }
169
170    #[test]
171    fn two_replays_of_same_log_are_identical() {
172        let dir = tempdir().unwrap();
173        let mut log = Log::open(dir.path()).unwrap();
174        for i in 0..5u8 {
175            append_bincode(&mut log, &put("ns", &format!("k{i}"), &[i]));
176        }
177        log.commit().unwrap();
178
179        let mut proj_a = KvProjection::default();
180        let mut proj_b = KvProjection::default();
181        replay_into(&mut proj_a, &log, log.head()).unwrap();
182        replay_into(&mut proj_b, &log, log.head()).unwrap();
183
184        assert_eq!(proj_a.state(), proj_b.state());
185        assert_eq!(proj_a.cursor(), proj_b.cursor());
186    }
187
188    #[test]
189    fn rebuild_after_reopen_is_identical() {
190        let dir = tempdir().unwrap();
191        {
192            let mut log = Log::open(dir.path()).unwrap();
193            for i in 0..5u8 {
194                append_bincode(&mut log, &put("ns", &format!("k{i}"), &[i]));
195            }
196            log.commit().unwrap();
197        }
198
199        let log = Log::open(dir.path()).unwrap();
200        let mut proj = KvProjection::default();
201        replay_into(&mut proj, &log, log.head()).unwrap();
202
203        for i in 0..5u8 {
204            assert_eq!(proj.state().get(&format!("k{i}")), Some(&vec![i]));
205        }
206    }
207
208    #[test]
209    fn replay_into_stops_before_upto() {
210        let dir = tempdir().unwrap();
211        let mut log = Log::open(dir.path()).unwrap();
212        for i in 0..5u8 {
213            append_bincode(&mut log, &put("ns", &format!("k{i}"), &[i]));
214        }
215        log.commit().unwrap();
216
217        let mut proj = KvProjection::default();
218        replay_into(&mut proj, &log, 3).unwrap();
219
220        assert_eq!(proj.cursor(), 3);
221        assert_eq!(proj.state().len(), 3);
222        assert!(proj.state().contains_key("k2"));
223        assert!(!proj.state().contains_key("k3"));
224    }
225
226    #[test]
227    fn replay_into_resumes_from_existing_cursor() {
228        let dir = tempdir().unwrap();
229        let mut log = Log::open(dir.path()).unwrap();
230        for i in 0..5u8 {
231            append_bincode(&mut log, &put("ns", &format!("k{i}"), &[i]));
232        }
233        log.commit().unwrap();
234
235        let mut incremental = KvProjection::default();
236        replay_into(&mut incremental, &log, 2).unwrap();
237        replay_into(&mut incremental, &log, log.head()).unwrap();
238
239        let mut one_shot = KvProjection::default();
240        replay_into(&mut one_shot, &log, log.head()).unwrap();
241
242        assert_eq!(incremental.state(), one_shot.state());
243        assert_eq!(incremental.cursor(), one_shot.cursor());
244    }
245
246    #[test]
247    fn replay_into_stamps_log_offset_even_if_event_embeds_a_different_one() {
248        let dir = tempdir().unwrap();
249        let mut log = Log::open(dir.path()).unwrap();
250        for i in 0..3u8 {
251            // Deliberately wrong embedded offset -- replay_into must not
252            // trust it (see the comment on replay_into itself).
253            let event = Event {
254                offset: 999,
255                timestamp_ms: 0,
256                namespace: "ns".to_string(),
257                body: EventBody::Put {
258                    key: format!("k{i}"),
259                    value: vec![i],
260                },
261            };
262            append_bincode(&mut log, &event);
263        }
264        log.commit().unwrap();
265
266        let mut proj = KvProjection::default();
267        replay_into(&mut proj, &log, log.head()).unwrap();
268
269        // If replay_into had trusted event.offset (999) instead of the
270        // log's real offset, the cursor would be stuck near 1000 and a
271        // second incremental replay would silently see nothing new.
272        assert_eq!(proj.cursor(), 3);
273        assert_eq!(proj.state().len(), 3);
274    }
275
276    #[test]
277    fn replay_into_surfaces_corrupt_payload_as_error() {
278        let dir = tempdir().unwrap();
279        let mut log = Log::open(dir.path()).unwrap();
280        append_bincode(&mut log, &put("ns", "a", b"1"));
281        log.append(b"not a bincode-encoded Event").unwrap();
282        log.commit().unwrap();
283
284        let mut proj = KvProjection::default();
285        let err = replay_into(&mut proj, &log, log.head()).unwrap_err();
286
287        assert!(matches!(err, SalamanderError::Corrupt { offset: 1, .. }));
288        // The good record before the bad one was still applied, and the
289        // cursor stopped exactly at the failing record, not past it.
290        assert_eq!(proj.state().get("a"), Some(&b"1".to_vec()));
291        assert_eq!(proj.cursor(), 1);
292    }
293}