use crate::error::LaserError;
use laser_wire::framing::{decode_named, encode_named};
pub use laser_wire::snapshot::FoldSnapshot;
use laser_wire::validate::Validate;
#[cfg(all(feature = "agent", feature = "kv"))]
pub const DEFAULT_SNAPSHOT_NAMESPACE: &str = "agent.snapshots";
#[cfg(feature = "agent")]
pub const DEFAULT_SNAPSHOT_TOPIC: &str = "agent.snapshots";
#[cfg(feature = "agent")]
const SNAPSHOT_SCAN_BATCH: u32 = 256;
#[cfg(feature = "agent")]
#[trait_variant::make(SnapshotStore: Send)]
pub trait LocalSnapshotStore {
async fn latest(
&self,
conversation: laser_wire::agent::ConversationId,
) -> Result<Option<FoldSnapshot>, LaserError>;
async fn save(&self, snapshot: &FoldSnapshot) -> Result<(), LaserError>;
}
#[cfg(all(feature = "agent", feature = "kv"))]
pub struct KvSnapshotStore {
laser: crate::laser::Laser,
namespace: String,
fold: String,
}
#[cfg(all(feature = "agent", feature = "kv"))]
impl KvSnapshotStore {
pub fn new(laser: crate::laser::Laser, fold: impl Into<String>) -> Self {
Self::in_namespace(laser, DEFAULT_SNAPSHOT_NAMESPACE, fold)
}
pub fn in_namespace(
laser: crate::laser::Laser,
namespace: impl Into<String>,
fold: impl Into<String>,
) -> Self {
Self {
laser,
namespace: namespace.into(),
fold: fold.into(),
}
}
}
#[cfg(all(feature = "agent", feature = "kv"))]
impl SnapshotStore for KvSnapshotStore {
async fn latest(
&self,
conversation: laser_wire::agent::ConversationId,
) -> Result<Option<FoldSnapshot>, LaserError> {
let kv = self.laser.kv(&self.namespace);
let key = snapshot_key(self.laser.stream_required()?, conversation, &self.fold);
match kv.get(key).await? {
Some(payload) => {
let snapshot = decode(&payload)?;
if snapshot.conversation != conversation || snapshot.fold != self.fold {
return Err(LaserError::Invalid(
"snapshot identity does not match the store".to_owned(),
));
}
Ok(Some(snapshot))
}
None => Ok(None),
}
}
async fn save(&self, snapshot: &FoldSnapshot) -> Result<(), LaserError> {
validate_store_identity(self.laser.stream_required()?, &self.fold, snapshot)?;
let payload = encode(snapshot)?;
let key = snapshot_key(&snapshot.stream, snapshot.conversation, &snapshot.fold);
self.laser
.kv(&self.namespace)
.set(key)
.bytes(payload)
.send()
.await
}
}
#[cfg(feature = "agent")]
pub struct TopicSnapshotStore {
laser: crate::laser::Laser,
topic: String,
fold: String,
}
#[cfg(feature = "agent")]
impl TopicSnapshotStore {
pub fn new(laser: crate::laser::Laser, fold: impl Into<String>) -> Self {
Self::on_topic(laser, DEFAULT_SNAPSHOT_TOPIC, fold)
}
pub fn on_topic(
laser: crate::laser::Laser,
topic: impl Into<String>,
fold: impl Into<String>,
) -> Self {
Self {
laser,
topic: topic.into(),
fold: fold.into(),
}
}
}
#[cfg(feature = "agent")]
impl SnapshotStore for TopicSnapshotStore {
async fn latest(
&self,
conversation: laser_wire::agent::ConversationId,
) -> Result<Option<FoldSnapshot>, LaserError> {
use iggy::prelude::*;
let stream_name = self.laser.stream_required()?.to_owned();
let stream = Identifier::named(&stream_name)?;
let topic = Identifier::named(&self.topic)?;
let consumer = Consumer::new(Identifier::named("laser-snapshot-reader")?);
let client = self.laser.client();
let Some(details) = client.get_topic(&stream, &topic).await? else {
return Ok(None);
};
let mut newest: Option<((u64, u32, u64), FoldSnapshot)> = None;
for partition in 0..crate::poll::bounded_partitions(details.partitions_count) {
let tail = client
.poll_messages(
&stream,
&topic,
Some(partition),
&consumer,
&PollingStrategy::last(),
1,
false,
)
.await?;
let Some(last) = tail.messages.last() else {
continue;
};
let mut end = last.header.offset.saturating_add(1);
while end > 0 {
let start = end.saturating_sub(u64::from(SNAPSHOT_SCAN_BATCH));
let window = client
.poll_messages(
&stream,
&topic,
Some(partition),
&consumer,
&PollingStrategy::offset(start),
SNAPSHOT_SCAN_BATCH,
false,
)
.await?;
let found = window
.messages
.iter()
.rev()
.filter(|message| message.header.offset < end)
.find_map(|message| {
decode(&message.payload)
.ok()
.filter(|snapshot| {
snapshot.conversation == conversation
&& snapshot.fold == self.fold
&& snapshot.stream == stream_name
})
.map(|snapshot| {
(message.header.timestamp, message.header.offset, snapshot)
})
});
if let Some((timestamp, offset, snapshot)) = found {
let position = (timestamp, partition, offset);
if newest.as_ref().is_none_or(|(best, _)| position > *best) {
newest = Some((position, snapshot));
}
break;
}
end = start;
}
}
Ok(newest.map(|(_, snapshot)| snapshot))
}
async fn save(&self, snapshot: &FoldSnapshot) -> Result<(), LaserError> {
validate_store_identity(self.laser.stream_required()?, &self.fold, snapshot)?;
let payload = encode(snapshot)?;
let partition_key = format!(
"{}:{}:{}",
snapshot.conversation,
snapshot.fold.len(),
snapshot.fold
);
self.laser
.topic(&self.topic)
.send(
payload,
std::collections::BTreeMap::new(),
Some(&partition_key),
)
.await
.map(|_| ())
}
}
pub fn encode(snapshot: &FoldSnapshot) -> Result<Vec<u8>, LaserError> {
snapshot
.validate()
.map_err(|error| LaserError::Invalid(error.to_string()))?;
encode_named(snapshot).map_err(|error| LaserError::Codec(format!("encode snapshot: {error}")))
}
pub fn decode(payload: &[u8]) -> Result<FoldSnapshot, LaserError> {
let snapshot: FoldSnapshot = decode_named(payload).map_err(LaserError::from)?;
snapshot
.validate()
.map_err(|error| LaserError::Invalid(error.to_string()))?;
Ok(snapshot)
}
#[cfg(all(feature = "agent", feature = "kv"))]
fn snapshot_key(
stream: &str,
conversation: laser_wire::agent::ConversationId,
fold: &str,
) -> String {
format!("{}:{stream}:{conversation}:{fold}", stream.len())
}
#[cfg(feature = "agent")]
fn validate_store_identity(
stream: &str,
fold: &str,
snapshot: &FoldSnapshot,
) -> Result<(), LaserError> {
if snapshot.stream != stream || snapshot.fold != fold {
return Err(LaserError::Invalid(
"snapshot identity does not match the store".to_owned(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use laser_wire::agent::ConversationId;
use laser_wire::snapshot::SnapshotOffset;
#[test]
fn given_a_snapshot_when_round_tripped_through_bytes_then_should_be_unchanged() {
let snapshot = FoldSnapshot {
stream: "agents".to_owned(),
stream_id: 0,
stream_created_at_micros: 100,
conversation: ConversationId::from_u128(7),
fold: "planner".to_owned(),
as_of: vec![
SnapshotOffset::new(2, 20, 0, 41),
SnapshotOffset::new(2, 20, 1, 9),
],
state: br#"{"folded":true}"#.to_vec(),
};
assert_eq!(
decode(&encode(&snapshot).expect("encodes")).expect("decodes"),
snapshot
);
}
#[test]
#[cfg(all(feature = "agent", feature = "kv"))]
fn given_two_folds_in_one_conversation_when_keyed_then_should_have_distinct_keys() {
let conversation = ConversationId::from_u128(7);
assert_ne!(
snapshot_key("agents", conversation, "planner"),
snapshot_key("agents", conversation, "worker")
);
assert_ne!(
snapshot_key("agents", conversation, "planner"),
snapshot_key("other", conversation, "planner")
);
}
}