krabka_remote_storage_topic/
snapshot.rs1use {
17 crate::{
18 error::{CodecError, SnapshotError},
19 serde::{MetadataEvent, Reader, read_uvarint, write_uvarint},
20 },
21 bytes::{BufMut, BytesMut},
22 krabka_remote_storage::{PartitionDump, RlmmCacheDump},
23 krabka_units::prelude::{ByteSize, ByteSizeExt as _, bytes as byte_size},
24 std::{
25 collections::BTreeMap,
26 fs::{self, File},
27 io::ErrorKind,
28 path::Path,
29 },
30 tracing::instrument,
31};
32
33pub const SNAPSHOT_FORMAT_VERSION: u16 = 0;
40
41pub const SNAPSHOT_FILE_NAME: &str = "snapshot";
43
44const ENCODE_BUFFER_HINT: ByteSize = byte_size(256);
47
48#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Snapshot {
54 pub committed_offsets: Vec<i64>,
58 pub dump: RlmmCacheDump,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
64struct PartitionKey {
65 topic_id: uuid::Uuid,
66 partition: i32,
67}
68
69struct OrderedDump {
71 ordinal: usize,
72 dump: PartitionDump,
73}
74
75impl Snapshot {
76 pub fn encode(&self) -> Result<Vec<u8>, SnapshotError> {
84 if self.committed_offsets.len() > i32::MAX as usize + 1 {
85 return Err(SnapshotError::Malformed(CodecError::LengthOverflow(
86 self.committed_offsets.len() as u64,
87 )));
88 }
89 let mut buf = BytesMut::with_capacity(ENCODE_BUFFER_HINT.bytes_usize());
90 buf.put_u16(SNAPSHOT_FORMAT_VERSION);
91 write_uvarint(self.committed_offsets.len() as u64, &mut buf);
93 for (&off, p) in self.committed_offsets.iter().zip(0..=i32::MAX) {
95 buf.put_i32(p);
96 buf.put_i64(off);
97 }
98 let mut entries: Vec<bytes::Bytes> = Vec::new();
101 for p in &self.dump.partitions {
102 for seg in &p.segments {
103 entries.push(MetadataEvent::AddSegment(seg.clone()).encode()?);
104 }
105 if let Some(state) = p.delete_state {
106 entries.push(
107 MetadataEvent::PartitionDelete(
108 krabka_remote_storage::RemotePartitionDeleteMetadata {
109 topic_id_partition: p.topic_id_partition.clone(),
110 state,
111 event_timestamp_ms: 0,
112 broker_id: 0,
113 },
114 )
115 .encode()?,
116 );
117 }
118 }
119 write_uvarint(entries.len() as u64, &mut buf);
120 for entry in entries {
121 write_uvarint(entry.len() as u64, &mut buf);
122 buf.put_slice(&entry);
123 }
124 Ok(buf.to_vec())
125 }
126
127 pub fn decode(bytes: &[u8]) -> Result<Self, SnapshotError> {
135 let mut r = Reader::new(bytes);
136 let version = read_u16(&mut r)?;
137 if version != SNAPSHOT_FORMAT_VERSION {
138 return Err(SnapshotError::UnsupportedVersion(version));
139 }
140 let n_offsets = usize::try_from(read_uvarint(&mut r)?)
141 .map_err(|_| CodecError::LengthOverflow(u64::MAX))?;
142 if n_offsets > r.remaining() / 12 {
145 return Err(CodecError::UnexpectedEof(bytes.len()).into());
146 }
147 let mut committed_offsets = vec![-1i64; n_offsets];
148 for _ in 0..n_offsets {
149 let p = r.read_i32()?;
150 let off = r.read_i64()?;
151 let idx = usize::try_from(p).map_err(|_| CodecError::LengthOverflow(u64::MAX))?;
152 let Some(slot) = committed_offsets.get_mut(idx) else {
153 return Err(SnapshotError::Malformed(CodecError::LengthOverflow(
154 idx as u64,
155 )));
156 };
157 *slot = off;
158 }
159 let n_entries = usize::try_from(read_uvarint(&mut r)?)
160 .map_err(|_| CodecError::LengthOverflow(u64::MAX))?;
161
162 let mut by_tp: BTreeMap<PartitionKey, OrderedDump> = BTreeMap::new();
164 for _ in 0..n_entries {
165 let len = usize::try_from(read_uvarint(&mut r)?)
166 .map_err(|_| CodecError::LengthOverflow(u64::MAX))?;
167 let raw = r.read_n(len)?;
168 match MetadataEvent::decode(raw)? {
169 MetadataEvent::AddSegment(md) => {
170 let tp = md.remote_log_segment_id().topic_id_partition.clone();
171 let key = PartitionKey {
172 topic_id: tp.topic_id,
173 partition: tp.partition,
174 };
175 let ordinal = by_tp.len();
176 by_tp
177 .entry(key)
178 .or_insert_with(|| OrderedDump {
179 ordinal,
180 dump: PartitionDump {
181 topic_id_partition: tp,
182 segments: Vec::new(),
183 delete_state: None,
184 },
185 })
186 .dump
187 .segments
188 .push(md);
189 }
190 MetadataEvent::PartitionDelete(d) => {
191 let key = PartitionKey {
192 topic_id: d.topic_id_partition.topic_id,
193 partition: d.topic_id_partition.partition,
194 };
195 let ordinal = by_tp.len();
196 by_tp
197 .entry(key)
198 .or_insert_with(|| OrderedDump {
199 ordinal,
200 dump: PartitionDump {
201 topic_id_partition: d.topic_id_partition,
202 segments: Vec::new(),
203 delete_state: None,
204 },
205 })
206 .dump
207 .delete_state = Some(d.state);
208 }
209 MetadataEvent::UpdateSegment(_) => {
210 return Err(SnapshotError::Malformed(CodecError::Domain(
214 "snapshot must not contain an UpdateSegment event".to_owned(),
215 )));
216 }
217 }
218 }
219 if r.remaining() != 0 {
220 return Err(SnapshotError::TrailingBytes(r.remaining()));
221 }
222 let mut ordered: Vec<_> = by_tp.into_values().collect();
223 ordered.sort_unstable_by_key(|entry| entry.ordinal);
224 let partitions = ordered.into_iter().map(|entry| entry.dump).collect();
225 Ok(Self {
226 committed_offsets,
227 dump: RlmmCacheDump { partitions },
228 })
229 }
230
231 #[instrument(
249 skip_all,
250 fields(path = %path.display(), partitions = self.dump.partitions.len()),
251 err
252 )]
253 pub fn write_atomic(&self, path: &Path) -> Result<(), SnapshotError> {
254 use std::io::Write;
255 let bytes = self.encode()?;
256 let parent = path.parent().unwrap_or_else(|| Path::new("."));
257 fs::create_dir_all(parent)?;
258 let tmp = path.with_extension("tmp");
259 let write_tmp = || -> Result<(), SnapshotError> {
260 let mut f = File::create(&tmp)?;
261 f.write_all(&bytes)?;
262 f.sync_all()?;
263 fs::rename(&tmp, path)?;
264 Ok(())
265 };
266 if let Err(e) = write_tmp() {
267 let _ = fs::remove_file(&tmp);
269 return Err(e);
270 }
271 if let Ok(dir) = File::open(parent) {
274 let _ = dir.sync_all();
275 }
276 Ok(())
277 }
278
279 #[instrument(skip_all, fields(path = %path.display()), err)]
288 pub fn load(path: &Path) -> Result<Option<Self>, SnapshotError> {
289 let bytes = match fs::read(path) {
290 Ok(b) => b,
291 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
292 Err(e) => return Err(SnapshotError::Io(e)),
293 };
294 Ok(Some(Self::decode(&bytes)?))
295 }
296}
297
298fn read_u16(r: &mut Reader<'_>) -> Result<u16, CodecError> {
299 let hi = u16::from(r.read_u8()?);
300 let lo = u16::from(r.read_u8()?);
301 Ok((hi << 8) | lo)
302}
303
304#[cfg(test)]
305mod tests {
306 use {
307 super::*,
308 crate::manager::test_support::{started, tp},
309 assert2::assert,
310 krabka_remote_storage::RemotePartitionDeleteState,
311 std::{env, fs, process},
312 };
313
314 fn sample_snapshot() -> Snapshot {
315 let dump = RlmmCacheDump {
316 partitions: vec![PartitionDump {
317 topic_id_partition: tp(),
318 segments: vec![started(10, 0, 99), started(11, 100, 199)],
319 delete_state: Some(RemotePartitionDeleteState::DeletePartitionMarked),
320 }],
321 };
322 Snapshot {
323 committed_offsets: vec![5, -1, 2],
324 dump,
325 }
326 }
327
328 #[test]
329 fn encode_decode_round_trip() {
330 let snap = sample_snapshot();
331 let bytes = snap.encode().unwrap();
332 let back = Snapshot::decode(&bytes).expect("decodes");
333 assert!(back == snap);
334 }
335
336 #[test]
337 fn decode_preserves_first_seen_partition_order() {
338 let mut snap = sample_snapshot();
339 snap.dump.partitions = [2, 0, 1]
340 .into_iter()
341 .map(|partition| {
342 let mut topic_id_partition = tp();
343 topic_id_partition.partition = partition;
344 PartitionDump {
345 topic_id_partition,
346 segments: Vec::new(),
347 delete_state: Some(RemotePartitionDeleteState::DeletePartitionMarked),
348 }
349 })
350 .collect();
351 assert!(Snapshot::decode(&snap.encode().unwrap()).unwrap() == snap);
352 }
353
354 #[test]
355 fn truncated_file_is_error_not_panic() {
356 let bytes = sample_snapshot().encode().unwrap();
357 let err = Snapshot::decode(&bytes[..bytes.len() - 3]).unwrap_err();
358 assert!(matches!(
359 err,
360 SnapshotError::Malformed(_) | SnapshotError::TrailingBytes(_)
361 ));
362 }
363
364 #[yare::parameterized(
365 one = { 1 },
366 two = { 2 },
367 maximum = { u64::MAX },
368 )]
369 fn offset_counts_are_bounded_by_the_encoded_entries(count: u64) {
370 let mut bytes = BytesMut::new();
371 bytes.put_u16(SNAPSHOT_FORMAT_VERSION);
372 write_uvarint(count, &mut bytes);
373 assert2::assert!(matches!(
374 Snapshot::decode(&bytes),
375 Err(SnapshotError::Malformed(
376 CodecError::UnexpectedEof(_) | CodecError::LengthOverflow(_)
377 ))
378 ));
379 }
380
381 #[test]
382 fn valid_snapshot_round_trips_after_malformed_count_cases() {
383 let snapshot = Snapshot {
384 committed_offsets: vec![-1],
385 dump: RlmmCacheDump::default(),
386 };
387 assert!(Snapshot::decode(&snapshot.encode().unwrap()).unwrap() == snapshot);
388 }
389
390 #[test]
391 fn garbage_bytes_are_error_not_panic() {
392 let err = Snapshot::decode(&[0xFF, 0xFF, 0xFF, 0xFF]).unwrap_err();
393 assert!(matches!(
394 err,
395 SnapshotError::UnsupportedVersion(_) | SnapshotError::Malformed(_)
396 ));
397 }
398
399 #[test]
400 fn empty_buffer_is_error_not_panic() {
401 let err = Snapshot::decode(&[]).unwrap_err();
402 assert!(matches!(err, SnapshotError::Malformed(_)));
403 }
404
405 #[test]
406 fn write_then_load_round_trips_through_a_file() {
407 let dir = env::temp_dir().join(format!("krabka-snap-{}", process::id()));
408 fs::create_dir_all(&dir).unwrap();
409 let path = dir.join("snapshot");
410 let snap = sample_snapshot();
411 snap.write_atomic(&path).expect("write");
412 let loaded = Snapshot::load(&path).expect("load").expect("present");
413 assert!(loaded == snap);
414 assert!(
416 fs::read_dir(&dir)
417 .unwrap()
418 .filter_map(Result::ok)
419 .all(|e| e.file_name() == "snapshot")
420 );
421 let _ = fs::remove_dir_all(&dir);
422 }
423
424 #[test]
425 fn load_absent_file_is_ok_none() {
426 let path = env::temp_dir().join("krabka-snap-does-not-exist-xyz");
427 let _ = fs::remove_file(&path);
428 assert!(Snapshot::load(&path).unwrap() == None);
429 }
430
431 #[test]
432 fn load_corrupt_file_is_err() {
433 let dir = env::temp_dir().join(format!("krabka-snap-corrupt-{}", process::id()));
434 fs::create_dir_all(&dir).unwrap();
435 let path = dir.join("snapshot");
436 fs::write(&path, [0xFF, 0xFF, 0x00, 0x01]).unwrap();
437 assert!(Snapshot::load(&path).is_err());
438 let _ = fs::remove_dir_all(&dir);
439 }
440}