cache_kit/serialization/mod.rs
1//! Postcard-based cache serialization with versioned envelopes.
2//!
3//! This module provides the canonical serialization format for all cache storage
4//! in cache-kit. It uses Postcard for performance and wraps all cache entries in
5//! versioned envelopes for schema evolution safety.
6//!
7//! # Architecture
8//!
9//! Every cache entry follows this format:
10//! ```text
11//! ┌─────────────────┬─────────────────┬──────────────────────────┐
12//! │ MAGIC (4 bytes)│VERSION (4 bytes)│POSTCARD PAYLOAD (N bytes)│
13//! └─────────────────┴─────────────────┴──────────────────────────┘
14//! "CKIT" u32 (LE) postcard::to_allocvec(T)
15//! ```
16//!
17//! # Safety Guarantees
18//!
19//! - **Deterministic:** Same value always produces identical bytes
20//! - **Validated:** Magic and version checked on every deserialization
21//! - **Versioned:** Schema changes force cache eviction, not silent migration
22//! - **Type-safe:** Postcard preserves exact Rust types
23//!
24//! # Example
25//!
26//! ```rust
27//! use cache_kit::serialization::{serialize_for_cache, deserialize_from_cache};
28//! use serde::{Serialize, Deserialize};
29//!
30//! #[derive(Serialize, Deserialize, PartialEq, Debug)]
31//! struct User {
32//! id: u64,
33//! name: String,
34//! }
35//!
36//! # fn main() -> cache_kit::Result<()> {
37//! let user = User { id: 1, name: "Alice".to_string() };
38//!
39//! // Serialize with envelope
40//! let bytes = serialize_for_cache(&user)?;
41//!
42//! // Deserialize with validation
43//! let deserialized: User = deserialize_from_cache(&bytes)?;
44//! assert_eq!(user, deserialized);
45//! # Ok(())
46//! # }
47//! ```
48
49use crate::error::{Error, Result};
50use serde::{Deserialize, Serialize};
51
52/// Magic header for cache-kit entries: b"CKIT"
53///
54/// This 4-byte signature identifies valid cache-kit cache entries.
55/// Any entry without this magic is rejected during deserialization.
56pub const CACHE_MAGIC: [u8; 4] = *b"CKIT";
57
58/// Current schema version.
59///
60/// **CRITICAL:** Increment this constant when making breaking changes to cached types:
61/// - Adding/removing struct fields
62/// - Changing field types
63/// - Reordering fields
64/// - Changing enum variants
65///
66/// When deployed with a new version, old cache entries will be automatically
67/// evicted and recomputed from the source of truth.
68pub const CURRENT_SCHEMA_VERSION: u32 = 1;
69
70/// Versioned envelope for cache entries.
71///
72/// Every cache entry is wrapped in this envelope to enable:
73/// - **Corruption detection:** Invalid magic → reject entry
74/// - **Schema evolution:** Version mismatch → evict and recompute
75/// - **Observability:** Track version mismatches in metrics
76///
77/// # Format
78///
79/// ```text
80/// ┌─────────────────┬─────────────────┬──────────────────────────┐
81/// │ magic: [u8; 4] │ version: u32 │ payload: T │
82/// └─────────────────┴─────────────────┴──────────────────────────┘
83/// ```
84///
85/// # Example
86///
87/// ```rust
88/// use cache_kit::serialization::CacheEnvelope;
89///
90/// let envelope = CacheEnvelope::new("data");
91/// assert_eq!(envelope.magic, *b"CKIT");
92/// ```
93#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
94pub struct CacheEnvelope<T> {
95 /// Magic header: must be b"CKIT"
96 pub magic: [u8; 4],
97 /// Schema version: must match CURRENT_SCHEMA_VERSION
98 pub version: u32,
99 /// The actual cached data
100 pub payload: T,
101}
102
103impl<T> CacheEnvelope<T> {
104 /// Create a new envelope with current magic and version.
105 ///
106 /// # Example
107 ///
108 /// ```rust
109 /// use cache_kit::serialization::CacheEnvelope;
110 ///
111 /// let envelope = CacheEnvelope::new(42);
112 /// assert_eq!(envelope.payload, 42);
113 /// ```
114 pub fn new(payload: T) -> Self {
115 Self {
116 magic: CACHE_MAGIC,
117 version: CURRENT_SCHEMA_VERSION,
118 payload,
119 }
120 }
121}
122
123/// Serialize a value with envelope for cache storage.
124///
125/// This is the canonical way to serialize data for cache storage in cache-kit.
126/// All cache backends (InMemory, Redis, Memcached) use this function.
127///
128/// # Format
129///
130/// ```text
131/// [MAGIC: 4 bytes] [VERSION: 4 bytes] [POSTCARD PAYLOAD: N bytes]
132/// ```
133///
134/// # Performance
135///
136/// Postcard serialization is approximately:
137/// - **8-12x faster** than JSON serialization
138/// - **50-70% smaller** than JSON payloads
139///
140/// # Example
141///
142/// ```rust
143/// use cache_kit::serialization::serialize_for_cache;
144/// use serde::Serialize;
145///
146/// #[derive(Serialize)]
147/// struct User { id: u64, name: String }
148///
149/// # fn main() -> cache_kit::Result<()> {
150/// let user = User { id: 1, name: "Alice".to_string() };
151/// let bytes = serialize_for_cache(&user)?;
152///
153/// // Verify envelope structure
154/// assert_eq!(&bytes[0..4], b"CKIT");
155/// # Ok(())
156/// # }
157/// ```
158///
159/// # Errors
160///
161/// Returns `Error::SerializationError` if Postcard serialization fails.
162pub fn serialize_for_cache<T: Serialize>(value: &T) -> Result<Vec<u8>> {
163 let envelope = CacheEnvelope::new(value);
164 postcard::to_allocvec(&envelope).map_err(|e| {
165 log::error!("Cache serialization failed: {}", e);
166 Error::SerializationError(e.to_string())
167 })
168}
169
170/// Deserialize a value from cache storage with validation.
171///
172/// This function performs strict validation:
173/// 1. Checks magic header matches b"CKIT"
174/// 2. Checks version matches CURRENT_SCHEMA_VERSION
175/// 3. Deserializes Postcard payload
176///
177/// # Validation Strategy
178///
179/// **On magic mismatch:** Returns `Error::InvalidCacheEntry`
180/// - Indicates corrupted cache entry or non-cache-kit data
181/// - Cache entry should be evicted
182///
183/// **On version mismatch:** Returns `Error::VersionMismatch`
184/// - Indicates schema change between code versions
185/// - Cache entry should be evicted and recomputed
186///
187/// **On Postcard error:** Returns `Error::DeserializationError`
188/// - Indicates corrupted payload
189/// - Cache entry should be evicted
190///
191/// # Example
192///
193/// ```rust
194/// use cache_kit::serialization::{serialize_for_cache, deserialize_from_cache};
195/// use serde::{Serialize, Deserialize};
196///
197/// #[derive(Serialize, Deserialize, PartialEq, Debug)]
198/// struct User { id: u64, name: String }
199///
200/// # fn main() -> cache_kit::Result<()> {
201/// let user = User { id: 1, name: "Alice".to_string() };
202/// let bytes = serialize_for_cache(&user)?;
203///
204/// let deserialized: User = deserialize_from_cache(&bytes)?;
205/// assert_eq!(user, deserialized);
206/// # Ok(())
207/// # }
208/// ```
209///
210/// # Errors
211///
212/// - `Error::InvalidCacheEntry`: Invalid magic header
213/// - `Error::VersionMismatch`: Schema version mismatch
214/// - `Error::DeserializationError`: Corrupted Postcard payload
215pub fn deserialize_from_cache<'de, T: Deserialize<'de>>(bytes: &'de [u8]) -> Result<T> {
216 // Attempt to deserialize envelope
217 let envelope: CacheEnvelope<T> = postcard::from_bytes(bytes).map_err(|e| {
218 log::error!("Cache deserialization failed: {}", e);
219 Error::DeserializationError(e.to_string())
220 })?;
221
222 // Validate magic header
223 if envelope.magic != CACHE_MAGIC {
224 log::warn!(
225 "Invalid cache entry: expected magic {:?}, got {:?}",
226 CACHE_MAGIC,
227 envelope.magic
228 );
229 return Err(Error::InvalidCacheEntry(format!(
230 "Invalid magic: expected {:?}, got {:?}",
231 CACHE_MAGIC, envelope.magic
232 )));
233 }
234
235 // Validate schema version
236 if envelope.version != CURRENT_SCHEMA_VERSION {
237 log::warn!(
238 "Cache version mismatch: expected {}, got {}",
239 CURRENT_SCHEMA_VERSION,
240 envelope.version
241 );
242 return Err(Error::VersionMismatch {
243 expected: CURRENT_SCHEMA_VERSION,
244 found: envelope.version,
245 });
246 }
247
248 Ok(envelope.payload)
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
256 struct TestData {
257 id: u64,
258 name: String,
259 active: bool,
260 }
261
262 #[test]
263 fn test_roundtrip() {
264 let data = TestData {
265 id: 123,
266 name: "test".to_string(),
267 active: true,
268 };
269
270 let bytes = serialize_for_cache(&data).unwrap();
271 let deserialized: TestData = deserialize_from_cache(&bytes).unwrap();
272
273 assert_eq!(data, deserialized);
274 }
275
276 #[test]
277 fn test_envelope_structure() {
278 let data = TestData {
279 id: 123,
280 name: "test".to_string(),
281 active: true,
282 };
283
284 let bytes = serialize_for_cache(&data).unwrap();
285
286 // Deserialize the envelope to verify its structure
287 // (postcard uses variable-length encoding, so we can't rely on fixed byte positions)
288 let envelope: CacheEnvelope<TestData> = postcard::from_bytes(&bytes).unwrap();
289
290 // Verify envelope magic
291 assert_eq!(envelope.magic, CACHE_MAGIC);
292
293 // Verify envelope version
294 assert_eq!(envelope.version, CURRENT_SCHEMA_VERSION);
295
296 // Verify payload matches original data
297 assert_eq!(envelope.payload, data);
298 }
299
300 #[test]
301 fn test_envelope_new() {
302 let envelope = CacheEnvelope::new(42);
303 assert_eq!(envelope.magic, CACHE_MAGIC);
304 assert_eq!(envelope.version, CURRENT_SCHEMA_VERSION);
305 assert_eq!(envelope.payload, 42);
306 }
307
308 #[test]
309 fn test_invalid_magic_rejected() {
310 let mut bytes = vec![0u8; 100];
311 bytes[0..4].copy_from_slice(b"XXXX"); // Wrong magic
312 bytes[4..8].copy_from_slice(&1u32.to_le_bytes()); // Valid version
313
314 let result: Result<TestData> = deserialize_from_cache(&bytes);
315 assert!(result.is_err());
316 match result.unwrap_err() {
317 Error::InvalidCacheEntry(_) => {} // Expected
318 e => panic!("Expected InvalidCacheEntry, got {:?}", e),
319 }
320 }
321
322 #[test]
323 fn test_version_mismatch_rejected() {
324 let data = TestData {
325 id: 123,
326 name: "test".to_string(),
327 active: true,
328 };
329
330 let mut envelope = CacheEnvelope::new(&data);
331 envelope.version = 999; // Future version
332
333 let bytes = postcard::to_allocvec(&envelope).unwrap();
334 let result: Result<TestData> = deserialize_from_cache(&bytes);
335
336 assert!(result.is_err());
337 match result.unwrap_err() {
338 Error::VersionMismatch { expected, found } => {
339 assert_eq!(expected, CURRENT_SCHEMA_VERSION);
340 assert_eq!(found, 999);
341 }
342 e => panic!("Expected VersionMismatch, got {:?}", e),
343 }
344 }
345
346 #[test]
347 fn test_deterministic_serialization() {
348 let data1 = TestData {
349 id: 123,
350 name: "test".to_string(),
351 active: true,
352 };
353 let data2 = data1.clone();
354
355 let bytes1 = serialize_for_cache(&data1).unwrap();
356 let bytes2 = serialize_for_cache(&data2).unwrap();
357
358 // Must produce identical bytes
359 assert_eq!(bytes1, bytes2);
360 }
361
362 #[test]
363 fn test_corrupted_payload_rejected() {
364 // Create a valid envelope first
365 let data = TestData {
366 id: 123,
367 name: "test".to_string(),
368 active: true,
369 };
370 let mut bytes = serialize_for_cache(&data).unwrap();
371
372 // Corrupt the payload by truncating aggressively
373 // With postcard's compact encoding, we need to truncate enough to ensure
374 // the data structure is incomplete
375 let original_len = bytes.len();
376 bytes.truncate(original_len / 2); // Truncate to half the size
377
378 let result: Result<TestData> = deserialize_from_cache(&bytes);
379 assert!(result.is_err());
380 match result.unwrap_err() {
381 Error::DeserializationError(_) => {} // Expected
382 e => panic!("Expected DeserializationError, got {:?}", e),
383 }
384 }
385
386 #[test]
387 fn test_empty_data_roundtrip() {
388 let data = TestData {
389 id: 0,
390 name: String::new(),
391 active: false,
392 };
393
394 let bytes = serialize_for_cache(&data).unwrap();
395 let deserialized: TestData = deserialize_from_cache(&bytes).unwrap();
396
397 assert_eq!(data, deserialized);
398 }
399
400 #[test]
401 fn test_large_data_roundtrip() {
402 let data = TestData {
403 id: u64::MAX,
404 name: "x".repeat(10000),
405 active: true,
406 };
407
408 let bytes = serialize_for_cache(&data).unwrap();
409 let deserialized: TestData = deserialize_from_cache(&bytes).unwrap();
410
411 assert_eq!(data, deserialized);
412 }
413
414 #[test]
415 fn test_postcard_smaller_than_json() {
416 let data = TestData {
417 id: 123,
418 name: "test".to_string(),
419 active: true,
420 };
421
422 let postcard_bytes = serialize_for_cache(&data).unwrap();
423 let json_bytes = serde_json::to_vec(&data).unwrap();
424
425 // Postcard should be smaller (including envelope overhead)
426 assert!(
427 postcard_bytes.len() < json_bytes.len(),
428 "Postcard ({} bytes) should be smaller than JSON ({} bytes)",
429 postcard_bytes.len(),
430 json_bytes.len()
431 );
432 }
433}