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#[cfg(all(feature = "agent", feature = "kv"))]
8pub const DEFAULT_SNAPSHOT_NAMESPACE: &str = "agent.snapshots";
9
10#[cfg(feature = "agent")]
12pub const DEFAULT_SNAPSHOT_TOPIC: &str = "agent.snapshots";
13
14#[cfg(feature = "agent")]
17const SNAPSHOT_SCAN_BATCH: u32 = 256;
18
19#[cfg(feature = "agent")]
26#[trait_variant::make(SnapshotStore: Send)]
27pub trait LocalSnapshotStore {
28 async fn latest(
31 &self,
32 conversation: laser_wire::agent::ConversationId,
33 ) -> Result<Option<FoldSnapshot>, LaserError>;
34 async fn save(&self, snapshot: &FoldSnapshot) -> Result<(), LaserError>;
36}
37
38#[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 pub fn new(laser: crate::laser::Laser, fold: impl Into<String>) -> Self {
53 Self::in_namespace(laser, DEFAULT_SNAPSHOT_NAMESPACE, fold)
54 }
55
56 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#[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 pub fn new(laser: crate::laser::Laser, fold: impl Into<String>) -> Self {
125 Self::on_topic(laser, DEFAULT_SNAPSHOT_TOPIC, fold)
126 }
127
128 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 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 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
242pub 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
251pub 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}