Skip to main content

rushdb_types/
command_codec.rs

1//! The one binary encoding of a [`CommitBatch`] (SD-7.3): WAL command
2//! envelopes, retry-outcome payloads and replication records all carry these
3//! bytes.
4//!
5//! The wire format is postcard's (specified, stable since 1.0): varint
6//! integers, length-prefixed byte strings, enum variants by index, no field
7//! names. Byte keys therefore cost their length plus one or two bytes, not a
8//! JSON list of decimal numbers (the JSON command was ~3 KB per ingested
9//! record and 80% of WAL bytes). Changing a command type changes the format;
10//! [`COMMAND_VERSION`](crate::command::COMMAND_VERSION) is bumped with it.
11
12use crate::command::CommitBatch;
13
14/// Upper bound on a decoded command (the WAL's own record cap is lower);
15/// rejects a corrupt length before it allocates.
16const MAX_COMMAND_BYTES: usize = 1 << 30;
17
18/// Malformed command bytes.
19#[derive(Debug, thiserror::Error, PartialEq, Eq)]
20#[error("malformed command: {0}")]
21pub struct CommandCodecError(String);
22
23/// Encode `batch`.
24///
25/// # Panics
26/// Never in practice: every `CommitBatch` field is serializable and the
27/// output is an in-memory vector.
28#[must_use]
29pub fn encode(batch: &CommitBatch) -> Vec<u8> {
30    postcard::to_stdvec(batch).expect("a CommitBatch always encodes")
31}
32
33/// Decode command bytes produced by [`encode`]. Trailing bytes are an error:
34/// a command is always exactly one batch.
35///
36/// # Errors
37/// Returns [`CommandCodecError`] for truncated, oversized, trailing or
38/// otherwise malformed input.
39pub fn decode(bytes: &[u8]) -> Result<CommitBatch, CommandCodecError> {
40    if bytes.len() > MAX_COMMAND_BYTES {
41        return Err(CommandCodecError(format!("{} bytes", bytes.len())));
42    }
43    let (batch, rest) = postcard::take_from_bytes::<CommitBatch>(bytes)
44        .map_err(|error| CommandCodecError(error.to_string()))?;
45    if !rest.is_empty() {
46        return Err(CommandCodecError(format!("{} trailing bytes", rest.len())));
47    }
48    if batch.command_version != crate::command::COMMAND_VERSION {
49        return Err(CommandCodecError(format!(
50            "command version {} (expected {})",
51            batch.command_version,
52            crate::command::COMMAND_VERSION
53        )));
54    }
55    Ok(batch)
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::command::{IsolationMode, Mutation, Precondition};
62    use crate::ids::{OwnerKind, TenantId};
63    use crate::model::LogicalType;
64    use crate::occurrence::{OccurrenceRow, OccurrenceValue};
65
66    /// Seeded xorshift: a reproducible property test without a dependency.
67    struct Rng(u64);
68    impl Rng {
69        fn next(&mut self) -> u64 {
70            self.0 ^= self.0 << 13;
71            self.0 ^= self.0 >> 7;
72            self.0 ^= self.0 << 17;
73            self.0
74        }
75        fn bytes(&mut self, max: u64) -> Vec<u8> {
76            (0..self.next() % max)
77                .map(|_| self.next().to_le_bytes()[0])
78                .collect()
79        }
80    }
81
82    fn batch(rng: &mut Rng) -> CommitBatch {
83        let mut mutations = Vec::new();
84        for _ in 0..rng.next() % 8 {
85            mutations.push(match rng.next() % 5 {
86                0 => Mutation::CreateRecordHeader {
87                    record_id: rng.next().to_le_bytes().repeat(2).try_into().unwrap(),
88                    ordinal: rng.next(),
89                    label: format!("L{}", rng.next() % 100),
90                },
91                1 => Mutation::PutOccurrence {
92                    row: OccurrenceRow {
93                        owner_kind: OwnerKind::Record,
94                        owner_ord: rng.next() >> (rng.next() % 64),
95                        property_id: rng.next() % 1000,
96                        family_id: rng.next() % 1000,
97                        elem_ordinal: rng.next().is_multiple_of(2).then(|| rng.next() % 10),
98                        occurrence_value: OccurrenceValue::Stored {
99                            ty: LogicalType::Utf8,
100                        },
101                    },
102                    canonical_value_bytes: Some(rng.bytes(200)),
103                },
104                2 => Mutation::PutRawKey {
105                    key: rng.bytes(64),
106                    value: rng.bytes(1024),
107                },
108                3 => Mutation::SetMetaValue {
109                    key: format!("k{}", rng.next()),
110                    value: rng.next(),
111                },
112                _ => Mutation::IndexApplied {
113                    index: u32::try_from(rng.next() % 50).unwrap(),
114                    upto: rng.next(),
115                    owners: (0..rng.next() % 20).map(|_| rng.next()).collect(),
116                },
117            });
118        }
119        CommitBatch {
120            command_version: crate::command::COMMAND_VERSION,
121            tenant: TenantId::from_ordinal(rng.next() % 100 + 1),
122            transaction_id: format!("tx{}", rng.next() % 7),
123            commit_key: uuid::Uuid::from_u128(
124                u128::from(rng.next()) << 64 | u128::from(rng.next()),
125            ),
126            start_index: rng.next(),
127            isolation: [
128                IsolationMode::ReadCommitted,
129                IsolationMode::Snapshot,
130                IsolationMode::Serializable,
131            ][usize::try_from(rng.next() % 3).unwrap()],
132            preconditions: (0..rng.next() % 4)
133                .map(|i| {
134                    if i % 2 == 0 {
135                        Precondition::absent(rng.bytes(40))
136                    } else {
137                        Precondition::exists(rng.bytes(40))
138                    }
139                })
140                .collect(),
141            mutations,
142        }
143    }
144
145    #[test]
146    fn command_codec_roundtrip_proptest_and_malformed_fuzz() {
147        let mut rng = Rng(0x2545_f491_4f6c_dd1d);
148        for _ in 0..2_000 {
149            let original = batch(&mut rng);
150            let bytes = encode(&original);
151            assert_eq!(decode(&bytes).unwrap(), original);
152            // Every strict prefix is truncated input: an error, never a panic.
153            let cut = usize::try_from(rng.next()).unwrap() % bytes.len();
154            assert!(decode(&bytes[..cut]).is_err());
155            // Trailing garbage is rejected, not ignored.
156            let mut longer = bytes.clone();
157            longer.push(0);
158            assert!(decode(&longer).is_err());
159            // Random corruption decodes to an error or to some batch, never
160            // a panic or an unbounded allocation.
161            let mut corrupt = bytes;
162            let at = usize::try_from(rng.next()).unwrap() % corrupt.len();
163            corrupt[at] = rng.next().to_le_bytes()[0];
164            let _ = decode(&corrupt);
165        }
166        for _ in 0..2_000 {
167            let noise = rng.bytes(256);
168            let _ = decode(&noise);
169        }
170    }
171
172    #[test]
173    fn foreign_command_version_is_rejected() {
174        let mut rng = Rng(7);
175        let mut stale = batch(&mut rng);
176        stale.command_version -= 1;
177        assert!(decode(&encode(&stale)).is_err());
178    }
179
180    #[test]
181    fn a_threshold_policy_round_trips_after_the_earlier_variants() {
182        use crate::security::{AuthorizationMode, DataAccessPolicy};
183        let batch = CommitBatch {
184            command_version: crate::command::COMMAND_VERSION,
185            tenant: TenantId::from_ordinal(1),
186            transaction_id: String::new(),
187            commit_key: uuid::Uuid::nil(),
188            start_index: 0,
189            isolation: IsolationMode::Snapshot,
190            preconditions: Vec::new(),
191            mutations: vec![
192                Mutation::SetSecurityPolicy {
193                    policy: DataAccessPolicy::default(),
194                    mode: AuthorizationMode::PolicyEnforced,
195                },
196                Mutation::SetSecurityPolicyThreshold {
197                    policy: DataAccessPolicy::default(),
198                    mode: AuthorizationMode::PolicyEnforced,
199                    min_count: 5,
200                },
201            ],
202        };
203        let bytes = encode(&batch);
204        // The earlier variant keeps its index (0): logs written before the
205        // threshold existed decode unchanged.
206        assert_eq!(decode(&bytes).unwrap(), batch);
207    }
208}