Skip to main content

p2panda_stream/ingest/
operation.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Methods to handle p2panda operations.
4use p2panda_core::prune::validate_prunable_backlink;
5use p2panda_core::{
6    AnyHeader, AnyOperation, Extensions, Hash, LogId, Operation, SeqNum, VerifyingKey,
7};
8use p2panda_store::Transaction;
9use p2panda_store::logs::LogStore;
10use p2panda_store::operations::OperationStore;
11use p2panda_store::topics::TopicStore;
12use thiserror::Error;
13
14use crate::ingest::ooo::{OooBuffer, OooResult};
15
16/// Result of _ingesting_ an operation (validation, de-duplication, optional ooo-buffering and
17/// writing to store) using [`ingest_operation`].
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum IngestResult<E> {
20    /// Validated and inserted operation into store.
21    Inserted,
22
23    /// Duplicate operation which was ignored.
24    AlreadyExists,
25
26    /// Operation freed buffered items which are now in-order.
27    ///
28    /// The incoming operation itself is also included in the array.
29    Ordered(Vec<Operation<E>>),
30
31    /// Out-of-order operation which was moved to internal buffer.
32    OutOfOrder,
33
34    /// Operation was from before a pruning point and was ignored.
35    Outdated,
36}
37
38/// Checks an incoming operation to ensure correct formatting and log integrity before persisting it
39/// into the store when valid. This function is idempotent; duplicate operations are ignored.
40///
41/// See [`validate_operation`] for an alternative method to validate an operation without
42/// persistence.
43///
44/// Can optionally be extended with an [`OooBuffer`] (Out-Of-Order) for offering a configurable
45/// window for incoming operations to wait in memory if they can't be validated yet due to missing
46/// predecessors.
47pub async fn ingest_operation<S, L, E, TP>(
48    store: &S,
49    ooo: Option<&OooBuffer<L, E>>,
50    // TODO: We probably want to use AnyOperation here and convert to Operation<E> in the ingest
51    // processor (and not inside of this method).
52    operation: &Operation<E>,
53    log_id: &L,
54    topic: &TP,
55    prune_flag: bool,
56) -> Result<IngestResult<E>, IngestError>
57where
58    S: Transaction
59        + OperationStore<Operation<E>, Hash>
60        + LogStore<AnyOperation, VerifyingKey, L, SeqNum, Hash>
61        + TopicStore<TP, VerifyingKey, L>,
62    L: LogId,
63    E: Extensions,
64{
65    // 1. Operation format validation
66    // ==============================
67
68    // Check if hash associated to struct ("checksum") is matching the header's digest.
69    if operation.hash != operation.header.hash() {
70        return Err(IngestError::HashMismatch);
71    }
72
73    // Validate operation format.
74    p2panda_core::validate_operation(operation).map_err(IngestError::InvalidOperation)?;
75
76    let permit = store
77        .begin()
78        .await
79        .map_err(|err| IngestError::StoreError(err.to_string()))?;
80
81    // 2. Deduplication
82    // ================
83
84    // Ignore insertion if operation already exists.
85    let already_exists = store
86        .has_operation_tx(&operation.hash)
87        .await
88        .map_err(|err| IngestError::StoreError(err.to_string()))?;
89
90    if already_exists {
91        // Every operation should have it's author and log associated with the topic. This is to
92        // account for the case where a single operation is processed on multiple topics.
93        let verifying_key = operation.header.verifying_key;
94        <S as TopicStore<TP, VerifyingKey, L>>::associate_tx(store, topic, &verifying_key, log_id)
95            .await
96            .map_err(|err| IngestError::StoreError(err.to_string()))?;
97
98        store
99            .commit(permit)
100            .await
101            .map_err(|err| IngestError::StoreError(err.to_string()))?;
102
103        return Ok(IngestResult::AlreadyExists);
104    }
105
106    // 3. Out-of-order buffering (optional)
107    // ====================================
108
109    let result = if let Some(ooo) = ooo {
110        // Get log frontier.
111        let latest_header = store
112            .get_latest_entry_tx(&operation.header.verifying_key, log_id)
113            .await
114            .map_err(|err| IngestError::StoreError(err.to_string()))?
115            .map(|operation| operation.header);
116
117        // Handle out-of-order operations. This gives us a bounded window of buffering
118        // ooo-operations until they are in-order without rejecting them.
119        match ooo
120            .process(operation, latest_header.as_ref(), log_id, prune_flag)
121            .await
122        {
123            // Operation is in-order, process it normally.
124            OooResult::InOrder(operation) => {
125                check_log_and_insert(store, operation, log_id, topic, prune_flag).await?;
126                IngestResult::Inserted
127            }
128
129            // Buffered operations are now in order, we process them all in bulk.
130            OooResult::Ordered(operations) => {
131                for operation in &operations {
132                    check_log_and_insert(store, operation, log_id, topic, prune_flag).await?;
133                }
134
135                IngestResult::Ordered(operations)
136            }
137
138            OooResult::OutOfOrder => return Ok(IngestResult::OutOfOrder),
139            OooResult::Outdated => return Ok(IngestResult::Outdated),
140        }
141    } else {
142        // We don't handle out-of-order operations, continue to validate and insert if operation is
143        // in-order, otherwise reject it.
144        check_log_and_insert(store, operation, log_id, topic, prune_flag).await?;
145        IngestResult::Inserted
146    };
147
148    store
149        .commit(permit)
150        .await
151        .map_err(|err| IngestError::StoreError(err.to_string()))?;
152
153    Ok(result)
154}
155
156async fn check_log_and_insert<S, L, E, TP>(
157    store: &S,
158    operation: &Operation<E>,
159    log_id: &L,
160    topic: &TP,
161    prune_flag: bool,
162) -> Result<(), IngestError>
163where
164    S: Transaction
165        + OperationStore<Operation<E>, Hash>
166        + LogStore<AnyOperation, VerifyingKey, L, SeqNum, Hash>
167        + TopicStore<TP, VerifyingKey, L>,
168    L: LogId,
169    E: Extensions,
170{
171    // 4. Log integrity checks
172    // =======================
173
174    let latest_header = store
175        .get_latest_entry_tx(&operation.header.verifying_key, log_id)
176        .await
177        .map_err(|err| IngestError::StoreError(err.to_string()))?
178        .map(|operation| operation.header);
179
180    // If no pruning flag is set, we expect the log to have integrity with the previously given
181    // operation.
182    //
183    // TODO: We can remove the header Clone and Into here once we update OperationStore to use
184    // AnyOperation. See issue: https://github.com/p2panda/p2panda/issues/1018.
185    validate_prunable_backlink(
186        latest_header.as_ref(),
187        &operation.header.clone().into(),
188        prune_flag,
189    )
190    .map_err(IngestError::InvalidOperation)?;
191
192    // 5. Write to database
193    // ====================
194
195    // Insert operation into store and associate its log with the given topic.
196    let verifying_key = operation.header.verifying_key;
197
198    store
199        .insert_operation_tx(&operation.hash, operation, log_id)
200        .await
201        .map_err(|err| IngestError::StoreError(err.to_string()))?;
202
203    <S as TopicStore<TP, VerifyingKey, L>>::associate_tx(store, topic, &verifying_key, log_id)
204        .await
205        .map_err(|err| IngestError::StoreError(err.to_string()))?;
206
207    Ok(())
208}
209
210/// Checks an incoming operation to ensure correct formatting and log integrity.
211pub async fn validate_operation<S, L, E, TP>(
212    store: &S,
213    operation: &Operation<E>,
214    log_id: &L,
215    prune_flag: bool,
216) -> Result<(), IngestError>
217where
218    S: Transaction
219        + OperationStore<Operation<E>, Hash>
220        + LogStore<AnyOperation, VerifyingKey, L, SeqNum, Hash>
221        + TopicStore<TP, VerifyingKey, L>,
222    L: LogId,
223    E: Extensions,
224{
225    // Check if hash associated to struct ("checksum") is matching the header's digest.
226    if operation.hash != operation.header.hash() {
227        return Err(IngestError::HashMismatch);
228    }
229
230    // Validate operation format.
231    p2panda_core::validate_operation(operation).map_err(IngestError::InvalidOperation)?;
232
233    let permit = store
234        .begin()
235        .await
236        .map_err(|err| IngestError::StoreError(err.to_string()))?;
237
238    let latest_header = store
239        .get_latest_entry_tx(&operation.header.verifying_key, log_id)
240        .await
241        .map_err(|err| IngestError::StoreError(err.to_string()))?
242        .map(|operation| operation.header);
243
244    // If no pruning flag is set, we expect the log to have integrity with the previously given
245    // operation.
246    //
247    // TODO: We can remove the header Clone and Into here once we update OperationStore to use
248    // AnyOperation. See issue: https://github.com/p2panda/p2panda/issues/1018.
249    let header: AnyHeader = operation.header.clone().into();
250    validate_prunable_backlink(latest_header.as_ref(), &header, prune_flag)
251        .map_err(IngestError::InvalidOperation)?;
252
253    store
254        .commit(permit)
255        .await
256        .map_err(|err| IngestError::StoreError(err.to_string()))?;
257
258    Ok(())
259}
260
261/// Errors which can occur due to invalid operations or critical storage failures.
262#[derive(Clone, Debug, PartialEq, Error)]
263pub enum IngestError {
264    /// Operation can not be authenticated, has broken log- or payload integrity or doesn't follow
265    /// the p2panda specification.
266    #[error("invalid operation: {0}")]
267    InvalidOperation(#[from] p2panda_core::OperationError),
268
269    /// Hash delivered with operation ("checksum") does not match digest of header.
270    #[error("hash associated with operation does not match header digest")]
271    HashMismatch,
272
273    /// Critical storage failure occurred. This is usually a reason to panic.
274    #[error("critical storage failure: {0}")]
275    StoreError(String),
276}
277
278#[cfg(test)]
279mod tests {
280    use std::assert_matches;
281
282    use p2panda_core::test_utils::TestLog;
283    use p2panda_core::{Hash, Header, Operation, SigningKey, Topic, VerifyingKey};
284    use p2panda_store::logs::LogStore;
285    use p2panda_store::topics::TopicStore;
286    use p2panda_store::{SqliteStore, tx_unwrap};
287
288    use crate::ingest::ooo::OooBuffer;
289
290    use super::{IngestResult, ingest_operation};
291
292    #[tokio::test]
293    async fn valid_log() {
294        let store = SqliteStore::temporary().await;
295        let log = TestLog::new();
296
297        for i in 0..128 {
298            let operation = log.operation(format!("{i}").as_bytes(), ());
299            let result = ingest_operation(&store, None, &operation, &1, &1, false).await;
300            assert!(result.is_ok());
301        }
302    }
303
304    #[tokio::test]
305    async fn deduplicate_operations() {
306        let store = SqliteStore::temporary().await;
307        let log = TestLog::new();
308        let operation = log.operation(b"same same", ());
309
310        let result = ingest_operation(&store, None, &operation, &1, &1, false)
311            .await
312            .unwrap();
313        std::assert_matches!(result, IngestResult::<()>::Inserted);
314
315        // Inserting duplicates is ok and are silently ignored.
316        let result = ingest_operation(&store, None, &operation, &1, &1, false)
317            .await
318            .unwrap();
319        std::assert_matches!(result, IngestResult::AlreadyExists);
320    }
321
322    #[tokio::test]
323    async fn topic_association() {
324        let store = SqliteStore::temporary().await;
325
326        let log_0 = TestLog::new();
327        let log_1 = TestLog::new();
328        let log_2 = TestLog::new();
329
330        let dogs = [2; 32];
331        let cats = [3; 32];
332
333        ingest_operation(&store, None, &log_0.operation(b"Do", ()), &0, &dogs, false)
334            .await
335            .unwrap();
336
337        ingest_operation(&store, None, &log_0.operation(b"Re", ()), &0, &dogs, false)
338            .await
339            .unwrap();
340
341        ingest_operation(&store, None, &log_1.operation(b"Mi", ()), &1, &dogs, false)
342            .await
343            .unwrap();
344
345        ingest_operation(&store, None, &log_2.operation(b"Fa", ()), &2, &cats, false)
346            .await
347            .unwrap();
348
349        ingest_operation(&store, None, &log_2.operation(b"So", ()), &2, &cats, false)
350            .await
351            .unwrap();
352
353        ingest_operation(&store, None, &log_2.operation(b"La", ()), &2, &cats, false)
354            .await
355            .unwrap();
356
357        // Topic "dogs" contains two logs: 0 with two operations and 1 with one operation.
358        let authors =
359            <SqliteStore as TopicStore<[u8; 32], VerifyingKey, usize>>::resolve(&store, &dogs)
360                .await
361                .unwrap();
362        assert_eq!(*authors.get(&log_0.author()).unwrap(), [0]);
363        assert_eq!(*authors.get(&log_1.author()).unwrap(), [1]);
364
365        let operation = tx_unwrap!(
366            store,
367            store
368                .get_latest_entry_tx(&log_0.author(), &0)
369                .await
370                .unwrap()
371                .unwrap()
372        );
373        assert_eq!(operation.header.seq_num, 1);
374
375        // Topic "cats" contains one log: 2 with four operations.
376        let authors =
377            <SqliteStore as TopicStore<[u8; 32], VerifyingKey, usize>>::resolve(&store, &cats)
378                .await
379                .unwrap();
380        assert_eq!(*authors.get(&log_2.author()).unwrap(), [2]);
381
382        let operation = tx_unwrap!(
383            store,
384            store
385                .get_latest_entry_tx(&log_2.author(), &2)
386                .await
387                .unwrap()
388                .unwrap()
389        );
390        assert_eq!(operation.header.seq_num, 2);
391    }
392
393    #[tokio::test]
394    async fn missing_prefix() {
395        let store = SqliteStore::temporary().await;
396        let signing_key = SigningKey::generate();
397
398        // Create an operation which has already advanced in the log (it has a backlink and higher
399        // sequence number).
400        let header = Header::builder()
401            // we'll be missing 11 operations between the first and this one
402            .chain(12, Hash::digest(b"mock operation"))
403            .build(&signing_key, ());
404
405        let operation = Operation::from_parts(header, None);
406        let result = ingest_operation(&store, None, &operation, &1, &1, false).await;
407
408        assert!(result.is_err());
409    }
410
411    #[tokio::test]
412    async fn ignore_outdated_pruned_operations() {
413        let store = SqliteStore::temporary().await;
414        let signing_key = SigningKey::generate();
415
416        // 1. Create an advanced operation in a log which assumes that all previous operations have
417        //    been pruned.
418        let header = Header::builder()
419            .chain(1, Hash::digest(b"mock operation"))
420            .build(&signing_key, ());
421        let operation = Operation::from_parts(header, None);
422
423        let prune_flag = true; // Ingest does not do any pruning, but the flag affects validation.
424        let result = ingest_operation(&store, None, &operation, &1, &1, prune_flag).await;
425        assert!(result.is_ok());
426
427        // 2. Create an operation which is from an "outdated" seq from before the log was pruned.
428        let header = Header::builder().build(&signing_key, ());
429        let operation = Operation::from_parts(header, None);
430
431        let result = ingest_operation(&store, None, &operation, &1, &1, false).await;
432        assert!(result.is_err());
433    }
434
435    #[tokio::test]
436    async fn ooo_operation_before_pruning_point() {
437        let log = TestLog::new();
438
439        let store = SqliteStore::temporary().await;
440        let ooo = OooBuffer::with_capacity(32);
441
442        let operation_0 = log.operation(b"This is a poopy message.", ());
443        let operation_1 = log.operation(b"There's nothing to see.", ());
444
445        let log_id = 0;
446        let topic = Topic::random();
447
448        // Ingest second operation in log (seq_num=1) which has a pruning point. We expect this to
449        // be a valid operation and successfully ingested.
450        let result =
451            ingest_operation(&store, Some(&ooo), &operation_1, &log_id, &topic, true).await;
452        assert_matches!(result, Ok(IngestResult::<()>::Inserted));
453
454        // The next operation is "from the past" (out-of-order) and already redundant due to pruning
455        // of the log before.
456        let result =
457            ingest_operation(&store, Some(&ooo), &operation_0, &log_id, &topic, false).await;
458        assert_matches!(result, Ok(IngestResult::Outdated));
459    }
460
461    #[tokio::test]
462    async fn ooo_operations() {
463        let log = TestLog::new();
464
465        let store = SqliteStore::temporary().await;
466        let ooo = OooBuffer::with_capacity(32);
467
468        let operation_0 = log.operation(b"Order", ());
469        let operation_1 = log.operation(b"Please", ());
470        let operation_2 = log.operation(b"!", ());
471
472        let log_id = 0;
473        let topic = Topic::random();
474
475        let result =
476            ingest_operation(&store, Some(&ooo), &operation_1, &log_id, &topic, false).await;
477        assert_matches!(result, Ok(IngestResult::OutOfOrder));
478
479        let result =
480            ingest_operation(&store, Some(&ooo), &operation_2, &log_id, &topic, false).await;
481        assert_matches!(result, Ok(IngestResult::OutOfOrder));
482
483        let result =
484            ingest_operation(&store, Some(&ooo), &operation_0, &log_id, &topic, false).await;
485        assert_eq!(
486            result,
487            Ok(IngestResult::Ordered(vec![
488                operation_0,
489                operation_1,
490                operation_2
491            ]))
492        );
493    }
494}