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}