Skip to main content

p2panda_stream/ingest/
ooo.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3use std::hash::Hash as StdHash;
4use std::sync::Arc;
5
6use indexmap::IndexMap;
7use p2panda_core::{AnyHeader, Extensions, Hash, LogId, Operation};
8use tokio::sync::Mutex;
9
10/// Result of processing an operation with the [`OooBuffer`].
11#[derive(Debug)]
12pub enum OooResult<'a, E> {
13    /// Operation is already in-order and doesn't need buffering.
14    InOrder(&'a Operation<E>),
15
16    /// Operation freed buffered items which are now in-order.
17    ///
18    /// The incoming operation itself is also included in the array.
19    Ordered(Vec<Operation<E>>),
20
21    /// Operation is out-of-order and will be buffered.
22    OutOfOrder,
23
24    /// Operation is from before a pruning point and thus outdated.
25    Outdated,
26}
27
28/// Out-of-order (ooo) buffer allowing a configurable window for handling operations with no
29/// predecessors yet.
30///
31/// For every operation this buffer checks if it arrived out-of-order. If yes, it is stored in the
32/// internal ring-buffer. If the buffer runs full the oldest item gets evicted first.
33///
34/// ## Example
35///
36/// If a log is at log height `[1]` (frontier) and an operation of sequence number `[3]` arrives, it
37/// can not be appended to the log due to the strict nature of an append-only log. It will be pushed
38/// into the buffer, awaiting the missing `[2]` operation:
39///
40/// ```text
41///          [0] <- [1] <- Log in database
42///
43/// [3] <- Incoming operation
44///
45/// => Push into ooo-Buffer.
46/// ```
47///
48/// Operation `[2]` arrives which will "free" the out-of-order items in the buffer, making them "in
49/// order". It will release them from the buffer and forward to the user for further validation and
50/// finally insertion into the database:
51///
52/// ```text
53///          [0] <- [1] <- Log in database
54///
55///          [3] <- Operation in ooo-Buffer
56///
57/// [2] <- Incoming operation
58///
59/// => Return [2, 3]
60/// ```
61///
62/// ## Assumptions
63///
64/// Please make sure to only process items which have been checked before against:
65///
66/// 1. Incoming operations from tombstoned logs / topics were filtered out before.
67/// 2. Duplicate, already ingested operations have been filtered out before.
68#[derive(Clone, Debug)]
69pub struct OooBuffer<L, E>
70where
71    L: LogId,
72    E: Extensions,
73{
74    buffer: Arc<Mutex<ChainRing<Hash, L, Operation<E>>>>,
75}
76
77impl<L, E> Default for OooBuffer<L, E>
78where
79    L: LogId,
80    E: Extensions,
81{
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl<L, E> OooBuffer<L, E>
88where
89    L: LogId,
90    E: Extensions,
91{
92    pub fn new() -> Self {
93        Self::with_capacity(128)
94    }
95
96    pub fn with_capacity(capacity: usize) -> Self {
97        Self {
98            buffer: Arc::new(Mutex::new(ChainRing::with_capacity(capacity))),
99        }
100    }
101
102    pub async fn len(&self) -> usize {
103        self.buffer.lock().await.len()
104    }
105
106    pub async fn is_empty(&self) -> bool {
107        self.buffer.lock().await.is_empty()
108    }
109
110    pub async fn clear(&self) {
111        self.buffer.lock().await.clear();
112    }
113
114    // TODO: Use AnyOperation when OperationStore is ready.
115    pub async fn process<'a>(
116        &self,
117        operation: &'a Operation<E>,
118        latest_header: Option<&AnyHeader>,
119        log_id: &L,
120        prune_flag: bool,
121    ) -> OooResult<'a, E> {
122        // Operation marks a prune point and all operations before that point become redundant,
123        // including the ones which would theoretically be "freed" by it.
124        //
125        // ```text
126        //          [0] <- Log in database
127        //
128        //          [2] <- Operation in ooo-Buffer
129        //
130        // [4] <- Incoming ooo-Operation
131        //  ^
132        //  prune_flag=true
133        //
134        // => Return [4]
135        // ```
136        //
137        // Note that processing [4] will delete [0] in the database. [2] will remain in the
138        // ooo-Buffer unused, until it gets evicted.
139        if prune_flag {
140            return OooResult::InOrder(operation);
141        }
142
143        match latest_header {
144            Some(latest_header) => {
145                if operation.header.seq_num < latest_header.seq_num {
146                    // Operation is from _before_ the log frontier and thus redundant.
147                    //
148                    // Since we assumed that checks for duplicates already taken place, this case
149                    // can only occur if the log was pruned and this operation belongs to the
150                    // removed log-prefix.
151                    //
152                    // ```text
153                    //          [7] <- [8] <- [9] <- Log in database
154                    //           ^
155                    //         pruned
156                    //
157                    // [4] <- Incoming ooo-Operation
158                    //
159                    // => Return nothing
160                    // ```
161                    OooResult::Outdated
162                } else if latest_header.seq_num == operation.header.seq_num.saturating_sub_signed(1)
163                {
164                    // Operation is regular, next expected item in log, return it directly.
165                    //
166                    // ```text
167                    // [0] <- [1] <- Log in database
168                    //         ^
169                    //      Frontier
170                    //
171                    // [2] <- Incoming Operation
172                    //
173                    // => Return [2]
174                    // ```
175                    OooResult::InOrder(operation)
176                } else {
177                    // Operation is _after_ the log frontier and thus out-of-order / can't be
178                    // appended to log yet.
179                    //
180                    // ```text
181                    //          [0] <- [1] <- Log in database
182                    //
183                    // [3] <- Incoming ooo-Operation
184                    //
185                    // => Push into ooo-Buffer.
186                    // ```
187                    //
188                    // We then check if this item freed any operations in ring-buffer.
189                    self.push_and_pop_from(operation, latest_header.backlink, log_id)
190                        .await
191                }
192            }
193            None => {
194                // There's no log yet. We keep items in the buffer until it runs full or we're
195                // building a valid log in memory.
196                //
197                // ```text
198                //          [ ] <- Log in database (empty)
199                //
200                //          [1] <- Operation in ooo-Buffer
201                //
202                // [0] <- Incoming ooo-Operation
203                //
204                // => Return [0, 1]
205                // ```
206                //
207                // Push and then check if this item freed any operations in ring-buffer.
208                //
209                // We set the expected backlink to `None`, indicating that we are looking for the
210                // whole log / from seq_num=0.
211                self.push_and_pop_from(operation, None, log_id).await
212            }
213        }
214    }
215
216    async fn push_and_pop_from<'a>(
217        &self,
218        operation: &'a Operation<E>,
219        expected_backlink: Option<Hash>,
220        log_id: &L,
221    ) -> OooResult<'a, E> {
222        let mut buffer = self.buffer.lock().await;
223
224        // Push item to ring-buffer, this will eventually evict old items when full.
225        buffer.push(
226            operation.hash,
227            operation.header.backlink,
228            log_id.clone(),
229            operation.clone(),
230        );
231
232        // We should check if this item freed any operations in ring-buffer / made them "in-order".
233        // The check takes place from the current log frontier (`expected_backlink`) in the
234        // database. If it's `None` we don't have any items for the log yet in the database.
235        //
236        // ```text
237        //          [0] <- [1] <- Log in database
238        //
239        //          [3] <- Operation in ooo-Buffer
240        //
241        // [2] <- Incoming operation
242        //
243        // => Return [2, 3]
244        // ```
245        let result = buffer.pop_from(expected_backlink, log_id.clone());
246        if result.is_empty() {
247            OooResult::OutOfOrder
248        } else {
249            OooResult::Ordered(result)
250        }
251    }
252}
253
254#[derive(Debug)]
255struct ChainRing<ID, L, T>
256where
257    ID: Copy + Eq + StdHash,
258    L: Clone + Eq + StdHash,
259{
260    buffer: IndexMap<ChainRingKey<ID, L>, ChainRingValue<ID, T>>,
261    capacity: usize,
262}
263
264#[derive(Debug, Eq, PartialEq, StdHash)]
265struct ChainRingKey<ID, L>
266where
267    ID: Copy + Eq + StdHash,
268    L: Clone + Eq + StdHash,
269{
270    backlink: Option<ID>,
271    log_id: L,
272}
273
274#[derive(Debug)]
275struct ChainRingValue<ID, T> {
276    id: ID,
277    item: T,
278}
279
280impl<ID, L, T> ChainRing<ID, L, T>
281where
282    ID: Copy + Eq + StdHash,
283    L: Clone + Eq + StdHash,
284{
285    pub fn with_capacity(capacity: usize) -> Self {
286        Self {
287            buffer: IndexMap::with_capacity(capacity),
288            capacity,
289        }
290    }
291
292    pub fn push(&mut self, id: ID, backlink: Option<ID>, log_id: L, item: T) {
293        if self.buffer.len() >= self.capacity {
294            self.buffer.pop();
295        }
296
297        self.buffer.insert(
298            ChainRingKey { backlink, log_id },
299            ChainRingValue { id, item },
300        );
301    }
302
303    /// Pop all items which have a complete chain from given position.
304    ///
305    /// The position is the id of the item _before_ the to-be-popped range:
306    ///
307    /// ```text
308    /// [3] <- [4] <- [5] <- [6]
309    ///  ^
310    /// pop_from(3) -> [4, 5, 6]
311    /// ```
312    pub fn pop_from(&mut self, backlink: Option<ID>, log_id: L) -> Vec<T> {
313        let mut result = Vec::new();
314
315        let mut next = ChainRingKey {
316            backlink,
317            log_id: log_id.clone(),
318        };
319
320        while let Some(ChainRingValue { id, item }) = self.buffer.shift_remove(&next) {
321            result.push(item);
322            next = ChainRingKey {
323                backlink: Some(id),
324                log_id: log_id.clone(),
325            };
326        }
327
328        result
329    }
330
331    pub fn len(&self) -> usize {
332        self.buffer.len()
333    }
334
335    pub fn is_empty(&self) -> bool {
336        self.buffer.is_empty()
337    }
338
339    pub fn clear(&mut self) {
340        self.buffer.clear()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::ChainRing;
347
348    #[test]
349    fn push_and_pop_from() {
350        let mut ring = ChainRing::with_capacity(64);
351
352        // Form a chain: 4 <- [5] <- [6] <- [7]
353        ring.push(5, Some(4), "test-log", 5);
354        ring.push(6, Some(5), "test-log", 6);
355        ring.push(7, Some(6), "test-log", 7);
356        assert_eq!(ring.len(), 3);
357
358        // Try to pop chain range from 3 on, but item [4] is missing.
359        assert!(ring.pop_from(Some(3), "test-log").is_empty());
360
361        // Add item [4] to chain: 3 <- [4] <- [5] <- [6] <- [7]
362        ring.push(4, Some(3), "test-log", 4);
363        assert_eq!(ring.len(), 4);
364
365        // Pop chain range from 3 on.
366        assert_eq!(ring.pop_from(Some(3), "test-log"), vec![4, 5, 6, 7]);
367        assert_eq!(ring.len(), 0);
368    }
369
370    #[test]
371    fn log_from_beginning() {
372        let mut ring = ChainRing::with_capacity(64);
373        ring.push(0, None, "test-log", 0);
374        ring.push(1, Some(0), "test-log", 1);
375        ring.push(2, Some(1), "test-log", 2);
376        assert_eq!(ring.len(), 3);
377        assert_eq!(ring.pop_from(None, "test-log"), vec![0, 1, 2]);
378    }
379
380    #[test]
381    fn ring_buffer() {
382        let mut ring = ChainRing::with_capacity(2);
383        ring.push(1, Some(0), "test-log", 1);
384        ring.push(2, Some(1), "test-log", 2);
385        ring.push(3, Some(2), "test-log", 3);
386        assert_eq!(ring.len(), 2);
387    }
388}