Skip to main content

nodedb_codec/
crdt_compress.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! CRDT state compression for Loro deltas.
4//!
5//! CRDT operations have a specific data signature:
6//! - **Lamport timestamps**: monotonically increasing → Delta → FastLanes
7//! - **Actor IDs**: purely entropic, but few unique actors → dictionary dedup
8//! - **Content (text edits, JSON)**: contiguous → RLE + FSST for strings
9//!
10//! This module compresses CRDT operation batches for:
11//! - Sync bandwidth reduction (Pattern B)
12//! - Long-term storage efficiency (Pattern C)
13
14use std::collections::HashSet;
15use std::mem::size_of;
16
17use crate::bounds::{
18    checked_add, checked_capacity, checked_mul, checked_range, decoded_len, encode_input_len,
19    encode_u32_len, u32_to_usize,
20};
21use crate::error::CodecError;
22
23/// A CRDT operation for compression.
24#[derive(Debug, Clone)]
25pub struct CrdtOp {
26    /// Lamport timestamp.
27    pub lamport: u64,
28    /// Actor ID (hash or index into actor dictionary).
29    pub actor_id: u64,
30    /// Operation payload (text content, JSON fragment, etc.).
31    pub content: Vec<u8>,
32}
33
34/// Compressed CRDT operation batch.
35///
36/// Wire format:
37/// ```text
38/// [4 bytes] op count (LE u32)
39/// [2 bytes] actor dictionary size (LE u16)
40/// [actor_count × 8 bytes] actor IDs (LE u64)
41/// [N bytes] Delta-encoded Lamport timestamps (nodedb-codec delta format)
42/// [4 bytes] actor_index block size (LE u32)
43/// [M bytes] actor indices (u8 if ≤256 actors, u16 otherwise)
44/// [4 bytes] content block size (LE u32)
45/// [K bytes] FSST-compressed content (newline-delimited)
46/// ```
47pub fn encode(ops: &[CrdtOp]) -> Result<Vec<u8>, CodecError> {
48    if ops.is_empty() {
49        return Ok(0u32.to_le_bytes().to_vec());
50    }
51
52    let count = encode_input_len(ops.len(), "CRDT operation count")?;
53
54    // Build actor dictionary.
55    let mut actor_dict: Vec<u64> = Vec::new();
56    let mut actor_map: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
57    for op in ops {
58        actor_map.entry(op.actor_id).or_insert_with(|| {
59            let idx = actor_dict.len();
60            actor_dict.push(op.actor_id);
61            idx
62        });
63    }
64
65    if actor_dict.len() > u16::MAX as usize {
66        return Err(CodecError::ResourceLimit {
67            resource: "CRDT actor dictionary entries".into(),
68            requested: actor_dict.len(),
69            limit: u16::MAX as usize,
70        });
71    }
72
73    // Delta-encode Lamport timestamps.
74    let lamports: Vec<i64> = ops.iter().map(|op| op.lamport as i64).collect();
75    let lamport_block = crate::delta::encode(&lamports)?;
76
77    // Actor indices.
78    let use_u8 = actor_dict.len() <= 256;
79    let actor_indices: Vec<u8> = if use_u8 {
80        ops.iter().map(|op| actor_map[&op.actor_id] as u8).collect()
81    } else {
82        ops.iter()
83            .flat_map(|op| (actor_map[&op.actor_id] as u16).to_le_bytes())
84            .collect()
85    };
86
87    // FSST-compress content (treat each op's content as a separate string).
88    let content_refs: Vec<&[u8]> = ops.iter().map(|op| op.content.as_slice()).collect();
89    let content_block = crate::fsst::encode(&content_refs)?;
90
91    // Build output.
92    let actor_bytes = checked_mul(actor_dict.len(), 8, "CRDT actor dictionary")?;
93    let capacity = checked_add(
94        checked_add(
95            checked_add(6, actor_bytes, "CRDT output")?,
96            checked_add(4, lamport_block.len(), "CRDT output")?,
97            "CRDT output",
98        )?,
99        checked_add(
100            checked_add(5, actor_indices.len(), "CRDT output")?,
101            checked_add(4, content_block.len(), "CRDT output")?,
102            "CRDT output",
103        )?,
104        "CRDT output",
105    )?;
106    decoded_len(capacity, "CRDT output")?;
107    let mut out = Vec::with_capacity(capacity);
108    out.extend_from_slice(&count.to_le_bytes());
109    out.extend_from_slice(
110        &u16::try_from(actor_dict.len())
111            .map_err(|_| CodecError::ResourceLimit {
112                resource: "CRDT actor dictionary entries".into(),
113                requested: actor_dict.len(),
114                limit: u16::MAX as usize,
115            })?
116            .to_le_bytes(),
117    );
118    for &actor in &actor_dict {
119        out.extend_from_slice(&actor.to_le_bytes());
120    }
121    out.extend_from_slice(
122        &encode_u32_len(lamport_block.len(), "CRDT Lamport block")?.to_le_bytes(),
123    );
124    out.extend_from_slice(&lamport_block);
125    out.push(if use_u8 { 1 } else { 2 }); // index width marker
126    out.extend_from_slice(
127        &encode_u32_len(actor_indices.len(), "CRDT actor-index block")?.to_le_bytes(),
128    );
129    out.extend_from_slice(&actor_indices);
130    out.extend_from_slice(
131        &encode_u32_len(content_block.len(), "CRDT content block")?.to_le_bytes(),
132    );
133    out.extend_from_slice(&content_block);
134
135    Ok(out)
136}
137
138/// Decode compressed CRDT operations.
139pub fn decode(data: &[u8]) -> Result<Vec<CrdtOp>, CodecError> {
140    if data.len() < 4 {
141        return Err(CodecError::Truncated {
142            expected: 4,
143            actual: data.len(),
144        });
145    }
146
147    let count = u32_to_usize(
148        u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
149        "CRDT operation count",
150    )?;
151    let per_operation_overhead = checked_add(
152        size_of::<CrdtOp>(),
153        checked_add(
154            size_of::<i64>(),
155            size_of::<usize>(),
156            "CRDT temporary operation overhead",
157        )?,
158        "CRDT decoded operation overhead",
159    )?;
160    decoded_len(
161        checked_mul(count, per_operation_overhead, "CRDT decoded operations")?,
162        "CRDT",
163    )?;
164    if count == 0 {
165        if data.len() != 4 {
166            return Err(CodecError::Corrupt {
167                detail: "non-canonical empty CRDT frame".into(),
168            });
169        }
170        return Ok(Vec::new());
171    }
172
173    let mut pos = 4;
174    let actor_count = usize::from(u16::from_le_bytes(
175        checked_range(data, pos, 2, "CRDT actor count")?
176            .try_into()
177            .map_err(|_| CodecError::Corrupt {
178                detail: "invalid CRDT actor count".into(),
179            })?,
180    ));
181    if actor_count == 0 || actor_count > count {
182        return Err(CodecError::Corrupt {
183            detail: "CRDT actor count is invalid".into(),
184        });
185    }
186    pos = checked_add(pos, 2, "CRDT actor cursor")?;
187    let actor_bytes = checked_mul(actor_count, 8, "CRDT actor dictionary")?;
188    let actor_data = checked_range(data, pos, actor_bytes, "CRDT actor dictionary")?;
189    // `as_chunks` yields fixed-size arrays, so the length is proven by the
190    // type and the previous fallible `try_into` has nothing left to reject.
191    let actor_dict: Vec<u64> = actor_data
192        .as_chunks::<8>()
193        .0
194        .iter()
195        .map(|c| u64::from_le_bytes(*c))
196        .collect();
197    let unique_actors: HashSet<u64> = actor_dict.iter().copied().collect();
198    if unique_actors.len() != actor_dict.len() {
199        return Err(CodecError::Corrupt {
200            detail: "duplicate CRDT actor dictionary entry".into(),
201        });
202    }
203    pos = checked_add(pos, actor_bytes, "CRDT actor cursor")?;
204    let lamport_size = u32_to_usize(
205        u32::from_le_bytes(
206            checked_range(data, pos, 4, "CRDT Lamport size")?
207                .try_into()
208                .map_err(|_| CodecError::Corrupt {
209                    detail: "invalid CRDT Lamport size".into(),
210                })?,
211        ),
212        "CRDT Lamport block",
213    )?;
214    pos = checked_add(pos, 4, "CRDT Lamport cursor")?;
215    let lamports = crate::delta::decode(checked_range(
216        data,
217        pos,
218        lamport_size,
219        "CRDT Lamport block",
220    )?)?;
221    if lamports.len() != count {
222        return Err(CodecError::Corrupt {
223            detail: "CRDT Lamport count mismatch".into(),
224        });
225    }
226    pos = checked_add(pos, lamport_size, "CRDT Lamport cursor")?;
227    let index_width = checked_range(data, pos, 1, "CRDT actor-index width")?[0];
228    let expected_width = if actor_count <= 256 { 1 } else { 2 };
229    if index_width != expected_width {
230        return Err(CodecError::Corrupt {
231            detail: "CRDT actor-index width is non-canonical".into(),
232        });
233    }
234    pos = checked_add(pos, 1, "CRDT actor-index cursor")?;
235    let index_size = u32_to_usize(
236        u32::from_le_bytes(
237            checked_range(data, pos, 4, "CRDT actor-index size")?
238                .try_into()
239                .map_err(|_| CodecError::Corrupt {
240                    detail: "invalid CRDT actor-index size".into(),
241                })?,
242        ),
243        "CRDT actor-index block",
244    )?;
245    pos = checked_add(pos, 4, "CRDT actor-index cursor")?;
246    let expected_index_size = checked_mul(count, usize::from(index_width), "CRDT actor indices")?;
247    if index_size != expected_index_size {
248        return Err(CodecError::Corrupt {
249            detail: "CRDT actor-index count mismatch".into(),
250        });
251    }
252    let index_data = checked_range(data, pos, index_size, "CRDT actor-index block")?;
253    let actor_indices: Vec<usize> = if index_width == 1 {
254        index_data.iter().map(|&index| usize::from(index)).collect()
255    } else {
256        index_data
257            .as_chunks::<2>()
258            .0
259            .iter()
260            .map(|chunk| usize::from(u16::from_le_bytes(*chunk)))
261            .collect()
262    };
263    if actor_indices.iter().any(|&index| index >= actor_count) {
264        return Err(CodecError::Corrupt {
265            detail: "CRDT actor index out of range".into(),
266        });
267    }
268    let actor_capacity = checked_capacity(
269        actor_count,
270        size_of::<bool>(),
271        "CRDT actor first-seen allocation",
272    )?;
273    let mut first_seen = vec![false; actor_capacity];
274    let mut next_actor = 0usize;
275    for &index in &actor_indices {
276        if !first_seen[index] {
277            if index != next_actor {
278                return Err(CodecError::Corrupt {
279                    detail: "CRDT actor dictionary is not in first-use order".into(),
280                });
281            }
282            first_seen[index] = true;
283            next_actor += 1;
284        }
285    }
286    if next_actor != actor_count {
287        return Err(CodecError::Corrupt {
288            detail: "CRDT actor dictionary contains unused entries".into(),
289        });
290    }
291    pos = checked_add(pos, index_size, "CRDT actor-index cursor")?;
292    let content_size = u32_to_usize(
293        u32::from_le_bytes(
294            checked_range(data, pos, 4, "CRDT content size")?
295                .try_into()
296                .map_err(|_| CodecError::Corrupt {
297                    detail: "invalid CRDT content size".into(),
298                })?,
299        ),
300        "CRDT content block",
301    )?;
302    pos = checked_add(pos, 4, "CRDT content cursor")?;
303    let contents = crate::fsst::decode(checked_range(
304        data,
305        pos,
306        content_size,
307        "CRDT content block",
308    )?)?;
309    if contents.len() != count || checked_add(pos, content_size, "CRDT frame end")? != data.len() {
310        return Err(CodecError::Corrupt {
311            detail: "CRDT content count or frame length mismatch".into(),
312        });
313    }
314
315    // Reconstruct ops.
316    let operation_capacity = checked_capacity(count, size_of::<CrdtOp>(), "CRDT operations")?;
317    let mut ops = Vec::with_capacity(operation_capacity);
318    for i in 0..count {
319        ops.push(CrdtOp {
320            lamport: lamports[i] as u64,
321            actor_id: actor_dict[actor_indices[i]],
322            content: contents[i].clone(),
323        });
324    }
325
326    Ok(ops)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn empty_roundtrip() {
335        let encoded = encode(&[]).unwrap();
336        let decoded = decode(&encoded).unwrap();
337        assert!(decoded.is_empty());
338    }
339
340    #[test]
341    fn basic_roundtrip() {
342        let ops = vec![
343            CrdtOp {
344                lamport: 1,
345                actor_id: 100,
346                content: b"insert 'hello'".to_vec(),
347            },
348            CrdtOp {
349                lamport: 2,
350                actor_id: 100,
351                content: b"insert ' world'".to_vec(),
352            },
353            CrdtOp {
354                lamport: 3,
355                actor_id: 200,
356                content: b"delete [0..5]".to_vec(),
357            },
358        ];
359        let encoded = encode(&ops).unwrap();
360        let decoded = decode(&encoded).unwrap();
361
362        assert_eq!(decoded.len(), 3);
363        assert_eq!(decoded[0].lamport, 1);
364        assert_eq!(decoded[0].actor_id, 100);
365        assert_eq!(decoded[0].content, b"insert 'hello'");
366        assert_eq!(decoded[2].actor_id, 200);
367    }
368
369    #[test]
370    fn compression_with_many_ops() {
371        let mut ops = Vec::new();
372        for i in 0..1000 {
373            ops.push(CrdtOp {
374                lamport: i,
375                actor_id: i % 5, // 5 actors
376                content: format!("op-{i}: set key_{} = value_{}", i % 50, i).into_bytes(),
377            });
378        }
379        let encoded = encode(&ops).unwrap();
380        let decoded = decode(&encoded).unwrap();
381
382        assert_eq!(decoded.len(), 1000);
383        for (orig, dec) in ops.iter().zip(decoded.iter()) {
384            assert_eq!(orig.lamport, dec.lamport);
385            assert_eq!(orig.actor_id, dec.actor_id);
386            assert_eq!(orig.content, dec.content);
387        }
388
389        // Should compress well — monotonic lamports + few actors + repetitive content.
390        let raw_size: usize = ops.iter().map(|op| 16 + op.content.len()).sum();
391        let ratio = raw_size as f64 / encoded.len() as f64;
392        assert!(
393            ratio > 1.2,
394            "CRDT ops should compress >1.2x, got {ratio:.2}x"
395        );
396    }
397
398    #[test]
399    fn rejects_count_mismatch_invalid_indices_and_trailing_bytes() {
400        let ops = vec![CrdtOp {
401            lamport: 1,
402            actor_id: 7,
403            content: b"x".to_vec(),
404        }];
405        let mut trailing = encode(&ops).unwrap();
406        trailing.push(0);
407        assert!(matches!(decode(&trailing), Err(CodecError::Corrupt { .. })));
408
409        let mut malformed = encode(&ops).unwrap();
410        let actor_count = usize::from(u16::from_le_bytes([malformed[4], malformed[5]]));
411        let mut cursor = 6 + actor_count * 8;
412        let lamport_size =
413            u32::from_le_bytes(malformed[cursor..cursor + 4].try_into().unwrap()) as usize;
414        cursor += 4 + lamport_size + 1 + 4;
415        malformed[cursor] = 1;
416        assert!(matches!(
417            decode(&malformed),
418            Err(CodecError::Corrupt { .. })
419        ));
420    }
421
422    #[test]
423    fn wide_actor_indices_roundtrip() {
424        let ops: Vec<CrdtOp> = (0..300)
425            .map(|i| CrdtOp {
426                lamport: i,
427                actor_id: i,
428                content: vec![b'x'],
429            })
430            .collect();
431        let encoded = encode(&ops).unwrap();
432        let decoded = decode(&encoded).unwrap();
433        assert_eq!(decoded.len(), ops.len());
434        for (expected, actual) in ops.iter().zip(&decoded) {
435            assert_eq!(actual.actor_id, expected.actor_id);
436        }
437    }
438
439    #[test]
440    fn actor_dictionary_dedup() {
441        let ops: Vec<CrdtOp> = (0..100)
442            .map(|i| CrdtOp {
443                lamport: i,
444                actor_id: 42, // single actor
445                content: b"x".to_vec(),
446            })
447            .collect();
448        let encoded = encode(&ops).unwrap();
449        let decoded = decode(&encoded).unwrap();
450
451        for op in &decoded {
452            assert_eq!(op.actor_id, 42);
453        }
454    }
455}