use serde::{Deserialize, Serialize};
use crate::options::Options;
pub(crate) const MAGIC: &[u8; 8] = b"LOGDRAIN";
pub(crate) const VERSION: u32 = 3;
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ClusterSnapshot {
pub id: u64,
pub tokens: Vec<TokenSnapshot>,
pub size: u64,
pub created_at_ms: u64,
pub updated_at_ms: u64,
pub event_first_ms: u64,
pub event_last_ms: u64,
pub suffix: Option<String>,
pub members: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct TokenSnapshot {
pub text: String,
pub leading_delim: Option<char>,
pub trailing_delim: Option<char>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct SnapshotV1 {
pub options: Options,
pub counter: u64,
pub clusters: Vec<ClusterSnapshot>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ClusterSnapshotV2 {
id: u64,
tokens: Vec<TokenSnapshot>,
size: u64,
created_at_ms: u64,
updated_at_ms: u64,
suffix: Option<String>,
members: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct SnapshotBodyV2 {
options: Options,
counter: u64,
clusters: Vec<ClusterSnapshotV2>,
}
impl SnapshotBodyV2 {
fn upgrade(self) -> SnapshotV1 {
SnapshotV1 {
options: self.options,
counter: self.counter,
clusters: self
.clusters
.into_iter()
.map(|c| ClusterSnapshot {
id: c.id,
tokens: c.tokens,
size: c.size,
created_at_ms: c.created_at_ms,
updated_at_ms: c.updated_at_ms,
event_first_ms: crate::cluster::EVENT_UNSET,
event_last_ms: 0,
suffix: c.suffix,
members: c.members,
})
.collect(),
}
}
}
pub(crate) fn encode(body: &SnapshotV1) -> Vec<u8> {
let mut out = Vec::with_capacity(64);
out.extend_from_slice(MAGIC);
out.extend_from_slice(&VERSION.to_le_bytes());
let payload = bincode::serialize(body).expect("bincode serialize cannot fail for owned data");
out.extend_from_slice(&payload);
out
}
pub(crate) fn decode(bytes: &[u8]) -> Result<SnapshotV1, crate::LogdrainError> {
if bytes.len() < MAGIC.len() + 4 {
return Err(crate::LogdrainError::BadMagic);
}
if &bytes[..MAGIC.len()] != MAGIC {
return Err(crate::LogdrainError::BadMagic);
}
let mut v = [0u8; 4];
v.copy_from_slice(&bytes[MAGIC.len()..MAGIC.len() + 4]);
let found = u32::from_le_bytes(v);
let payload = &bytes[MAGIC.len() + 4..];
match found {
VERSION => {
bincode::deserialize(payload).map_err(|e| crate::LogdrainError::Decode(e.to_string()))
}
2 => bincode::deserialize::<SnapshotBodyV2>(payload)
.map(SnapshotBodyV2::upgrade)
.map_err(|e| crate::LogdrainError::Decode(e.to_string())),
_ => Err(crate::LogdrainError::UnsupportedSnapshotVersion {
found,
max: VERSION,
}),
}
}
#[cfg(test)]
mod tests {
use crate::Miner;
use crate::MinerBuilder;
fn miner() -> Miner {
Miner::from_options(MinerBuilder::new().build_options().unwrap())
}
#[test]
fn round_trip_preserves_clusters_and_ids() {
let m = miner();
m.add("user 42 logged in");
m.add("user 99 logged in");
m.add("disk full on /dev/sda");
let next_id_before = m.add("a brand new shape here").cluster_id;
let bytes = m.snapshot();
let m2 = miner();
m2.restore(&bytes).unwrap();
assert_eq!(m2.len(), m.len());
let id = m2.match_only("user 7 logged in").unwrap();
assert_eq!(m2.cluster(id).unwrap().template(), "user <*> logged in");
let ms =
|t: std::time::SystemTime| t.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis();
let before = m.cluster(id).unwrap();
let after_c = m2.cluster(id).unwrap();
assert_eq!(ms(before.created_at()), ms(after_c.created_at()));
assert_eq!(ms(before.updated_at()), ms(after_c.updated_at()));
assert!(
ms(after_c.created_at()) > 0,
"timestamp must be a real epoch-ms value"
);
let after = m2.add("yet another different shape entirely").cluster_id;
assert!(after > next_id_before, "{after} !> {next_id_before}");
}
#[test]
fn bad_magic_is_rejected() {
let m = miner();
let err = m.restore(b"NOTMAGIC....").unwrap_err();
assert!(matches!(err, crate::LogdrainError::BadMagic));
}
#[test]
fn short_input_is_rejected() {
let m = miner();
assert!(m.restore(b"AB").is_err());
}
#[test]
fn future_version_is_rejected() {
let mut bytes = crate::snapshot::MAGIC.to_vec();
bytes.extend_from_slice(&999u32.to_le_bytes());
let err = miner().restore(&bytes).unwrap_err();
assert!(matches!(
err,
crate::LogdrainError::UnsupportedSnapshotVersion { found: 999, .. }
));
}
#[test]
fn older_version_is_rejected() {
let mut bytes = crate::snapshot::MAGIC.to_vec();
bytes.extend_from_slice(&1u32.to_le_bytes());
let err = miner().restore(&bytes).unwrap_err();
assert!(matches!(
err,
crate::LogdrainError::UnsupportedSnapshotVersion { found: 1, .. }
));
}
#[test]
fn reads_v2_snapshot_and_defaults_event_time_unset() {
use super::{ClusterSnapshotV2, SnapshotBodyV2, TokenSnapshot, MAGIC};
let tok = |t: &str| TokenSnapshot {
text: t.to_string(),
leading_delim: None,
trailing_delim: None,
};
let body = SnapshotBodyV2 {
options: crate::MinerBuilder::new().build_options().unwrap(),
counter: 9,
clusters: vec![ClusterSnapshotV2 {
id: 1,
tokens: vec![tok("user"), tok("<*>"), tok("in")],
size: 3,
created_at_ms: 1000,
updated_at_ms: 2000,
suffix: None,
members: vec![],
}],
};
let mut bytes = MAGIC.to_vec();
bytes.extend_from_slice(&2u32.to_le_bytes());
bytes.extend_from_slice(&bincode::serialize(&body).unwrap());
let m = miner();
m.restore(&bytes).unwrap(); assert_eq!(m.len(), 1);
let id = m.match_only("user 7 in").unwrap();
let c = m.cluster(id).unwrap();
assert_eq!(c.template(), "user <*> in");
assert!(c.event_first_seen().is_none()); assert!(m.add("a totally different shape entirely now").cluster_id > 9);
}
#[test]
fn round_trip_preserves_suffix_and_members() {
let m = Miner::from_options(
MinerBuilder::new()
.path_delimiters(&['/'])
.first_line_only(true)
.build_options()
.unwrap(),
);
m.add_with_member("GET /servers/409/foo\ntrace line a\ntrace line b", "svc-a");
m.add_with_member("GET /servers/410/foo\ntrace line c", "svc-b");
let bytes = m.snapshot();
let m2 = Miner::from_options(
MinerBuilder::new()
.path_delimiters(&['/'])
.first_line_only(true)
.build_options()
.unwrap(),
);
m2.restore(&bytes).unwrap();
assert_eq!(m2.len(), 1);
let id = m2.match_only("GET /servers/999/foo").unwrap();
let c = m2.cluster(id).unwrap();
assert_eq!(c.template(), "GET /servers/<*>/foo");
assert_eq!(c.suffix(), Some("trace line a\ntrace line b"));
let members: Vec<&str> = c.members().iter().map(|m| &**m).collect();
assert_eq!(members, vec!["svc-a", "svc-b"]);
}
}