Skip to main content

laser_sdk/
snapshot.rs

1use crate::error::LaserError;
2use laser_wire::framing::{decode_named, encode_named};
3pub use laser_wire::snapshot::FoldSnapshot;
4use laser_wire::validate::Validate;
5
6/// The key-value namespace [`KvSnapshotStore::new`] uses.
7#[cfg(all(feature = "agent", feature = "kv"))]
8pub const DEFAULT_SNAPSHOT_NAMESPACE: &str = "agent.snapshots";
9
10/// The topic [`TopicSnapshotStore::new`] uses.
11#[cfg(feature = "agent")]
12pub const DEFAULT_SNAPSHOT_TOPIC: &str = "agent.snapshots";
13
14/// How many records each backward scan window reads while looking for a
15/// conversation's newest checkpoint.
16#[cfg(feature = "agent")]
17const SNAPSHOT_SCAN_BATCH: u32 = 256;
18
19/// Where fold snapshots live, so a long conversation resumes from its last
20/// checkpoint instead of replaying every record (the bounded-reads law). Same
21/// seam pattern as `StateStore`: one trait, honest backends, no behavior
22/// difference visible to the caller. [`KvSnapshotStore`] is the managed
23/// point-state form, [`TopicSnapshotStore`] the log-native one that works on
24/// Apache Iggy.
25#[cfg(feature = "agent")]
26#[trait_variant::make(SnapshotStore: Send)]
27pub trait LocalSnapshotStore {
28    /// The newest snapshot for `conversation`, or `None` when it has never
29    /// been snapshotted (the fold starts from offset zero).
30    async fn latest(
31        &self,
32        conversation: laser_wire::agent::ConversationId,
33    ) -> Result<Option<FoldSnapshot>, LaserError>;
34    /// Persist `snapshot` as the conversation's newest checkpoint.
35    async fn save(&self, snapshot: &FoldSnapshot) -> Result<(), LaserError>;
36}
37
38/// Fold snapshots in the managed key-value store, one key per conversation in
39/// a dedicated namespace (default `agent.snapshots`). Point read, point write,
40/// no scan. Managed: against Apache Iggy every call returns the kv
41/// surface's unsupported error, so pick [`TopicSnapshotStore`] there.
42#[cfg(all(feature = "agent", feature = "kv"))]
43pub struct KvSnapshotStore {
44    laser: crate::laser::Laser,
45    namespace: String,
46    fold: String,
47}
48
49#[cfg(all(feature = "agent", feature = "kv"))]
50impl KvSnapshotStore {
51    /// A store over the default `agent.snapshots` namespace.
52    pub fn new(laser: crate::laser::Laser, fold: impl Into<String>) -> Self {
53        Self::in_namespace(laser, DEFAULT_SNAPSHOT_NAMESPACE, fold)
54    }
55
56    /// A store over `namespace`, for keeping several folds' snapshots apart.
57    pub fn in_namespace(
58        laser: crate::laser::Laser,
59        namespace: impl Into<String>,
60        fold: impl Into<String>,
61    ) -> Self {
62        Self {
63            laser,
64            namespace: namespace.into(),
65            fold: fold.into(),
66        }
67    }
68}
69
70#[cfg(all(feature = "agent", feature = "kv"))]
71impl SnapshotStore for KvSnapshotStore {
72    async fn latest(
73        &self,
74        conversation: laser_wire::agent::ConversationId,
75    ) -> Result<Option<FoldSnapshot>, LaserError> {
76        let kv = self.laser.kv(&self.namespace);
77        let key = snapshot_key(self.laser.stream_required()?, conversation, &self.fold);
78        match kv.get(key).await? {
79            Some(payload) => {
80                let snapshot = decode(&payload)?;
81                if snapshot.conversation != conversation || snapshot.fold != self.fold {
82                    return Err(LaserError::Invalid(
83                        "snapshot identity does not match the store".to_owned(),
84                    ));
85                }
86                Ok(Some(snapshot))
87            }
88            None => Ok(None),
89        }
90    }
91
92    async fn save(&self, snapshot: &FoldSnapshot) -> Result<(), LaserError> {
93        validate_store_identity(self.laser.stream_required()?, &self.fold, snapshot)?;
94        let payload = encode(snapshot)?;
95        let key = snapshot_key(&snapshot.stream, snapshot.conversation, &snapshot.fold);
96        self.laser
97            .kv(&self.namespace)
98            .set(key)
99            .bytes(payload)
100            .send()
101            .await
102    }
103}
104
105/// Fold snapshots as records on a dedicated snapshots topic (default
106/// `agent.snapshots`), partitioned by conversation so one conversation's
107/// checkpoints stay ordered. Log-native: works on Apache Iggy.
108///
109/// `latest` walks the topic backward from each partition's tail and stops at
110/// the first (newest) record for the conversation, so a hit costs the tail
111/// distance to the last checkpoint. A conversation with no snapshot walks the
112/// topic fully before answering `None`: keep the snapshots topic on retention
113/// (its history is checkpoints, not truth) so that walk stays bounded.
114#[cfg(feature = "agent")]
115pub struct TopicSnapshotStore {
116    laser: crate::laser::Laser,
117    topic: String,
118    fold: String,
119}
120
121#[cfg(feature = "agent")]
122impl TopicSnapshotStore {
123    /// A store over the default `agent.snapshots` topic.
124    pub fn new(laser: crate::laser::Laser, fold: impl Into<String>) -> Self {
125        Self::on_topic(laser, DEFAULT_SNAPSHOT_TOPIC, fold)
126    }
127
128    /// A store over `topic`, for keeping several folds' snapshots apart.
129    pub fn on_topic(
130        laser: crate::laser::Laser,
131        topic: impl Into<String>,
132        fold: impl Into<String>,
133    ) -> Self {
134        Self {
135            laser,
136            topic: topic.into(),
137            fold: fold.into(),
138        }
139    }
140}
141
142#[cfg(feature = "agent")]
143impl SnapshotStore for TopicSnapshotStore {
144    async fn latest(
145        &self,
146        conversation: laser_wire::agent::ConversationId,
147    ) -> Result<Option<FoldSnapshot>, LaserError> {
148        use iggy::prelude::*;
149        let stream_name = self.laser.stream_required()?.to_owned();
150        let stream = Identifier::named(&stream_name)?;
151        let topic = Identifier::named(&self.topic)?;
152        let consumer = Consumer::new(Identifier::named("laser-snapshot-reader")?);
153        let client = self.laser.client();
154        let Some(details) = client.get_topic(&stream, &topic).await? else {
155            // No topic yet means nothing was ever saved.
156            return Ok(None);
157        };
158        let mut newest: Option<((u64, u32, u64), FoldSnapshot)> = None;
159        for partition in 0..crate::poll::bounded_partitions(details.partitions_count) {
160            let tail = client
161                .poll_messages(
162                    &stream,
163                    &topic,
164                    Some(partition),
165                    &consumer,
166                    &PollingStrategy::last(),
167                    1,
168                    false,
169                )
170                .await?;
171            let Some(last) = tail.messages.last() else {
172                continue;
173            };
174            let mut end = last.header.offset.saturating_add(1);
175            // Walk backward in windows: newest window first, and within a
176            // window the highest matching offset wins, so the scan stops at
177            // the conversation's most recent checkpoint.
178            while end > 0 {
179                let start = end.saturating_sub(u64::from(SNAPSHOT_SCAN_BATCH));
180                let window = client
181                    .poll_messages(
182                        &stream,
183                        &topic,
184                        Some(partition),
185                        &consumer,
186                        &PollingStrategy::offset(start),
187                        SNAPSHOT_SCAN_BATCH,
188                        false,
189                    )
190                    .await?;
191                let found = window
192                    .messages
193                    .iter()
194                    .rev()
195                    .filter(|message| message.header.offset < end)
196                    .find_map(|message| {
197                        decode(&message.payload)
198                            .ok()
199                            .filter(|snapshot| {
200                                snapshot.conversation == conversation
201                                    && snapshot.fold == self.fold
202                                    && snapshot.stream == stream_name
203                            })
204                            .map(|snapshot| {
205                                (message.header.timestamp, message.header.offset, snapshot)
206                            })
207                    });
208                if let Some((timestamp, offset, snapshot)) = found {
209                    let position = (timestamp, partition, offset);
210                    if newest.as_ref().is_none_or(|(best, _)| position > *best) {
211                        newest = Some((position, snapshot));
212                    }
213                    break;
214                }
215                end = start;
216            }
217        }
218        Ok(newest.map(|(_, snapshot)| snapshot))
219    }
220
221    async fn save(&self, snapshot: &FoldSnapshot) -> Result<(), LaserError> {
222        validate_store_identity(self.laser.stream_required()?, &self.fold, snapshot)?;
223        let payload = encode(snapshot)?;
224        let partition_key = format!(
225            "{}:{}:{}",
226            snapshot.conversation,
227            snapshot.fold.len(),
228            snapshot.fold
229        );
230        self.laser
231            .topic(&self.topic)
232            .send(
233                payload,
234                std::collections::BTreeMap::new(),
235                Some(&partition_key),
236            )
237            .await
238            .map(|_| ())
239    }
240}
241
242/// Encode a fold snapshot to its canonical bytes, for storage as a key-value
243/// value or a snapshot-topic body. The inverse of [`decode`].
244pub fn encode(snapshot: &FoldSnapshot) -> Result<Vec<u8>, LaserError> {
245    snapshot
246        .validate()
247        .map_err(|error| LaserError::Invalid(error.to_string()))?;
248    encode_named(snapshot).map_err(|error| LaserError::Codec(format!("encode snapshot: {error}")))
249}
250
251/// Decode a fold snapshot from stored bytes. The inverse of [`encode`].
252pub fn decode(payload: &[u8]) -> Result<FoldSnapshot, LaserError> {
253    let snapshot: FoldSnapshot = decode_named(payload).map_err(LaserError::from)?;
254    snapshot
255        .validate()
256        .map_err(|error| LaserError::Invalid(error.to_string()))?;
257    Ok(snapshot)
258}
259
260#[cfg(all(feature = "agent", feature = "kv"))]
261fn snapshot_key(
262    stream: &str,
263    conversation: laser_wire::agent::ConversationId,
264    fold: &str,
265) -> String {
266    format!("{}:{stream}:{conversation}:{fold}", stream.len())
267}
268
269#[cfg(feature = "agent")]
270fn validate_store_identity(
271    stream: &str,
272    fold: &str,
273    snapshot: &FoldSnapshot,
274) -> Result<(), LaserError> {
275    if snapshot.stream != stream || snapshot.fold != fold {
276        return Err(LaserError::Invalid(
277            "snapshot identity does not match the store".to_owned(),
278        ));
279    }
280    Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use laser_wire::agent::ConversationId;
287    use laser_wire::snapshot::SnapshotOffset;
288
289    #[test]
290    fn given_a_snapshot_when_round_tripped_through_bytes_then_should_be_unchanged() {
291        let snapshot = FoldSnapshot {
292            stream: "agents".to_owned(),
293            stream_id: 0,
294            stream_created_at_micros: 100,
295            conversation: ConversationId::from_u128(7),
296            fold: "planner".to_owned(),
297            as_of: vec![
298                SnapshotOffset::new(2, 20, 0, 41),
299                SnapshotOffset::new(2, 20, 1, 9),
300            ],
301            state: br#"{"folded":true}"#.to_vec(),
302        };
303        assert_eq!(
304            decode(&encode(&snapshot).expect("encodes")).expect("decodes"),
305            snapshot
306        );
307    }
308
309    #[test]
310    #[cfg(all(feature = "agent", feature = "kv"))]
311    fn given_two_folds_in_one_conversation_when_keyed_then_should_have_distinct_keys() {
312        let conversation = ConversationId::from_u128(7);
313        assert_ne!(
314            snapshot_key("agents", conversation, "planner"),
315            snapshot_key("agents", conversation, "worker")
316        );
317        assert_ne!(
318            snapshot_key("agents", conversation, "planner"),
319            snapshot_key("other", conversation, "planner")
320        );
321    }
322}