Skip to main content

actordb_protocol/write_concern/
evidence.rs

1//! Bounded, independently validated certificates of a processing version floor.
2use super::{
3    invalid, meta, varint, WriteConcernPolicyError, WriteConcernReceipt, MAX_WRITE_CONCERN_REPLICAS,
4};
5use crate::RecordVersion;
6use prost::Message;
7use sha2::{Digest, Sha256};
8
9/// Actual minimum fence reached. Buffered store commit does not imply fsync.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
11#[non_exhaustive]
12#[repr(i32)]
13pub enum WriteDurability {
14    #[default]
15    MemoryApplied = 0,
16    StoreCommitted = 1,
17    StableCommitted = 2,
18}
19impl WriteDurability {
20    pub(super) fn decode(value: i32) -> Result<Self, WriteConcernPolicyError> {
21        match value {
22            0 => Ok(Self::MemoryApplied),
23            1 => Ok(Self::StoreCommitted),
24            2 => Ok(Self::StableCommitted),
25            _ => Err(invalid("unknown durability evidence")),
26        }
27    }
28}
29
30/// Submitted operation identity. ACKs certify an equal or newer LWW winner,
31/// not immutable payload/log presence or a distributed atomic snapshot.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct WriteOperationEvidence {
34    wire: meta::WriteOperationEvidence,
35}
36impl WriteOperationEvidence {
37    /// SDK correlation digests without retaining a second request payload.
38    pub fn application_write_digests(table: &str, key: &str, value: &[u8]) -> ([u8; 32], [u8; 32]) {
39        let mut digest = Sha256::new();
40        digest.update(b"\x1eactordb.table.v1:");
41        const HEX: &[u8; 16] = b"0123456789abcdef";
42        for byte in table.bytes().map(|byte| byte.to_ascii_uppercase()) {
43            digest.update([HEX[(byte >> 4) as usize], HEX[(byte & 15) as usize]]);
44        }
45        digest.update(b":");
46        for byte in key.as_bytes() {
47            digest.update([HEX[(byte >> 4) as usize], HEX[(byte & 15) as usize]]);
48        }
49        (digest.finalize().into(), Sha256::digest(value).into())
50    }
51    pub fn validate_application_digests(
52        &self,
53        key_digest: &[u8],
54        value_digest: &[u8],
55        is_tombstone: bool,
56    ) -> Result<(), WriteConcernPolicyError> {
57        if self.wire.key_digest != key_digest
58            || self.wire.value_digest != value_digest
59            || self.wire.is_tombstone != is_tombstone
60        {
61            return Err(invalid("receipt certifies a different application write"));
62        }
63        Ok(())
64    }
65    #[allow(clippy::too_many_arguments)]
66    pub fn new(
67        fragment: u32,
68        key: &str,
69        value: &[u8],
70        version: RecordVersion,
71        node_id: String,
72        is_tombstone: bool,
73        expires_at_ms: Option<u64>,
74    ) -> Result<Self, WriteConcernPolicyError> {
75        let mut wire = meta::WriteOperationEvidence {
76            fragment,
77            key_digest: Sha256::digest(key.as_bytes()).to_vec(),
78            value_digest: Sha256::digest(value).to_vec(),
79            timestamp_ms: version.timestamp_ms,
80            logical_counter: version.logical_counter,
81            node_id,
82            is_tombstone,
83            expires_at_ms,
84            operation_digest: Vec::new(),
85        };
86        wire.operation_digest = Sha256::digest(wire.encode_to_vec()).to_vec();
87        let evidence = Self { wire };
88        evidence.validate()?;
89        Ok(evidence)
90    }
91    pub fn fragment(&self) -> u32 {
92        self.wire.fragment
93    }
94    pub fn version(&self) -> RecordVersion {
95        RecordVersion::new(self.wire.timestamp_ms, self.wire.logical_counter)
96    }
97    pub fn originating_node(&self) -> &str {
98        &self.wire.node_id
99    }
100    pub fn is_tombstone(&self) -> bool {
101        self.wire.is_tombstone
102    }
103    pub fn expires_at_ms(&self) -> Option<u64> {
104        self.wire.expires_at_ms
105    }
106    pub fn key_digest(&self) -> &[u8] {
107        &self.wire.key_digest
108    }
109    pub fn value_digest(&self) -> &[u8] {
110        &self.wire.value_digest
111    }
112    pub fn operation_digest(&self) -> &[u8] {
113        &self.wire.operation_digest
114    }
115    /// Correlate SDK evidence with the selected table, user key and payload.
116    /// Version/absolute TTL are minted by the receiving node; callers can save
117    /// the returned certificate for later comparison.
118    pub fn validate_application_write(
119        &self,
120        table: &str,
121        key: &str,
122        value: &[u8],
123        is_tombstone: bool,
124    ) -> Result<(), WriteConcernPolicyError> {
125        let (key_digest, value_digest) = Self::application_write_digests(table, key, value);
126        self.validate_application_digests(&key_digest, &value_digest, is_tombstone)
127    }
128    fn validate(&self) -> Result<(), WriteConcernPolicyError> {
129        let mut unsigned = self.wire.clone();
130        unsigned.operation_digest.clear();
131        if self.wire.node_id.is_empty()
132            || self.wire.node_id.len() > 255
133            || self.wire.key_digest.len() != 32
134            || self.wire.value_digest.len() != 32
135            || self.wire.operation_digest != Sha256::digest(unsigned.encode_to_vec()).as_slice()
136            || self.wire.expires_at_ms == Some(0)
137        {
138            return Err(invalid("invalid operation certificate"));
139        }
140        Ok(())
141    }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct WriteAcknowledgement {
146    node: String,
147    durability: WriteDurability,
148}
149impl WriteAcknowledgement {
150    pub fn new(node: String, durability: WriteDurability) -> Self {
151        Self { node, durability }
152    }
153    pub fn node(&self) -> &str {
154        &self.node
155    }
156    pub fn durability(&self) -> WriteDurability {
157        self.durability
158    }
159}
160
161/// Exact frozen eligible identities and reached fences for one submitted floor.
162/// The deterministic digest identifies the set, not a placement epoch or lease.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct WriteConcernEvidence {
165    operation: WriteOperationEvidence,
166    eligible_nodes: Vec<String>,
167    replica_set_digest: Vec<u8>,
168    acknowledgements: Vec<WriteAcknowledgement>,
169}
170fn set_digest(nodes: &[String]) -> Vec<u8> {
171    let mut hash = Sha256::new();
172    hash.update(b"actordb.write-concern.replica-set.v1");
173    hash.update((nodes.len() as u64).to_be_bytes());
174    for node in nodes {
175        hash.update((node.len() as u64).to_be_bytes());
176        hash.update(node.as_bytes());
177    }
178    hash.finalize().to_vec()
179}
180impl WriteConcernEvidence {
181    pub fn new(
182        operation: WriteOperationEvidence,
183        mut eligible_nodes: Vec<String>,
184        acknowledgements: Vec<WriteAcknowledgement>,
185    ) -> Result<Self, WriteConcernPolicyError> {
186        eligible_nodes.sort();
187        let evidence = Self {
188            operation,
189            replica_set_digest: set_digest(&eligible_nodes),
190            eligible_nodes,
191            acknowledgements,
192        };
193        evidence.validate()?;
194        Ok(evidence)
195    }
196    pub fn operation(&self) -> &WriteOperationEvidence {
197        &self.operation
198    }
199    pub fn eligible_nodes(&self) -> &[String] {
200        &self.eligible_nodes
201    }
202    pub fn replica_set_digest(&self) -> &[u8] {
203        &self.replica_set_digest
204    }
205    pub fn acknowledgements(&self) -> &[WriteAcknowledgement] {
206        &self.acknowledgements
207    }
208    pub fn durability_at(&self, node: &str) -> Option<WriteDurability> {
209        self.acknowledgements
210            .iter()
211            .find(|ack| ack.node == node)
212            .map(|ack| ack.durability)
213    }
214    fn validate(&self) -> Result<(), WriteConcernPolicyError> {
215        self.operation.validate()?;
216        if self.eligible_nodes.is_empty()
217            || self.eligible_nodes.len() > MAX_WRITE_CONCERN_REPLICAS
218            || self
219                .eligible_nodes
220                .iter()
221                .any(|node| node.is_empty() || node.len() > 255)
222            || self
223                .eligible_nodes
224                .windows(2)
225                .any(|pair| pair[0] >= pair[1])
226            || self.replica_set_digest != set_digest(&self.eligible_nodes)
227            || self.acknowledgements.len() > self.eligible_nodes.len()
228        {
229            return Err(invalid("invalid replica-set evidence"));
230        }
231        let mut seen = std::collections::HashSet::new();
232        for ack in &self.acknowledgements {
233            if self.eligible_nodes.binary_search(&ack.node).is_err() || !seen.insert(&ack.node) {
234                return Err(invalid("ineligible or duplicate durability evidence"));
235            }
236        }
237        Ok(())
238    }
239    pub(super) fn validate_receipt(
240        &self,
241        receipt: &WriteConcernReceipt,
242    ) -> Result<(), WriteConcernPolicyError> {
243        self.validate()?;
244        let count = receipt.replica_count().map_or(1, |n| n as usize);
245        if count != self.eligible_nodes.len()
246            || self.acknowledgements.len() != receipt.acknowledged_nodes().len()
247            || receipt
248                .acknowledged_nodes()
249                .iter()
250                .any(|node| self.durability_at(node).is_none())
251        {
252            return Err(invalid("evidence disagrees with acknowledgement progress"));
253        }
254        Ok(())
255    }
256    pub(super) fn encode(&self) -> meta::WriteConcernEvidence {
257        meta::WriteConcernEvidence {
258            operation: Some(self.operation.wire.clone()),
259            eligible_nodes: self.eligible_nodes.clone(),
260            replica_set_digest: self.replica_set_digest.clone(),
261            acknowledgements: self
262                .acknowledgements
263                .iter()
264                .map(|ack| meta::WriteAcknowledgement {
265                    replica_index: self.eligible_nodes.binary_search(&ack.node).unwrap() as u32,
266                    durability: ack.durability as i32,
267                })
268                .collect(),
269        }
270    }
271    pub(super) fn decode(
272        wire: meta::WriteConcernEvidence,
273    ) -> Result<Self, WriteConcernPolicyError> {
274        let acknowledgements = wire
275            .acknowledgements
276            .iter()
277            .map(|ack| {
278                let node = wire
279                    .eligible_nodes
280                    .get(ack.replica_index as usize)
281                    .ok_or_else(|| invalid("invalid replica evidence index"))?
282                    .clone();
283                Ok(WriteAcknowledgement::new(
284                    node,
285                    WriteDurability::decode(ack.durability)?,
286                ))
287            })
288            .collect::<Result<Vec<_>, WriteConcernPolicyError>>()?;
289        let evidence = Self {
290            operation: WriteOperationEvidence {
291                wire: wire
292                    .operation
293                    .ok_or_else(|| invalid("missing operation certificate"))?,
294            },
295            eligible_nodes: wire.eligible_nodes,
296            replica_set_digest: wire.replica_set_digest,
297            acknowledgements,
298        };
299        evidence.validate()?;
300        Ok(evidence)
301    }
302}
303
304/// Bound repeated allocation amplification before Prost allocates evidence.
305pub(super) fn preflight(bytes: &[u8]) -> Result<(), WriteConcernPolicyError> {
306    let mut pos = 0;
307    let mut counts = [0usize; 5];
308    while pos < bytes.len() {
309        let tag = varint(bytes, &mut pos)?;
310        let field = (tag >> 3) as usize;
311        if !(1..=4).contains(&field) || tag & 7 != 2 {
312            return Err(invalid("invalid evidence field"));
313        }
314        counts[field] += 1;
315        if counts[field]
316            > if field == 2 || field == 4 {
317                MAX_WRITE_CONCERN_REPLICAS
318            } else {
319                1
320            }
321        {
322            return Err(invalid("duplicate or excessive evidence field"));
323        }
324        let len = usize::try_from(varint(bytes, &mut pos)?)
325            .map_err(|_| invalid("evidence length overflow"))?;
326        let limit = match field {
327            1 => 1024,
328            2 => 255,
329            3 => 32,
330            _ => 16,
331        };
332        if len > limit {
333            return Err(invalid("evidence field exceeds limit"));
334        }
335        pos = pos
336            .checked_add(len)
337            .filter(|pos| *pos <= bytes.len())
338            .ok_or_else(|| invalid("truncated evidence"))?;
339    }
340    Ok(())
341}