Skip to main content

fedimint_client/
oplog.rs

1use std::collections::HashSet;
2use std::fmt::Debug;
3use std::ops::Range;
4use std::sync::Arc;
5use std::time::{Duration, SystemTime};
6
7use fedimint_client_module::oplog::{
8    IOperationLog, JsonStringed, OperationLogEntry, OperationOutcome, UpdateStreamOrOutcome,
9};
10use fedimint_core::core::OperationId;
11use fedimint_core::db::{Database, DatabaseTransaction, IDatabaseTransactionOpsCoreTyped as _};
12use fedimint_core::task::{MaybeSend, MaybeSync};
13use fedimint_core::time::now;
14use fedimint_core::util::{BoxStream, FmtCompact as _};
15use fedimint_core::{apply, async_trait_maybe_send, maybe_add_send, maybe_add_send_sync};
16use fedimint_logging::LOG_CLIENT;
17use futures::StreamExt as _;
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20use tokio::sync::RwLock;
21use tracing::{error, instrument, warn};
22
23use crate::db::{ChronologicalOperationLogKey, OperationLogKey};
24
25#[cfg(test)]
26mod tests;
27
28#[derive(Debug, Clone)]
29pub struct OperationLog {
30    db: Database,
31    oldest_entry: Arc<RwLock<Option<ChronologicalOperationLogKey>>>,
32}
33
34impl OperationLog {
35    pub fn new(db: Database) -> Self {
36        Self {
37            db,
38            oldest_entry: Arc::new(RwLock::new(None)),
39        }
40    }
41
42    /// Will return the oldest operation log key in the database and cache the
43    /// result. If no entry exists yet the DB will be queried on each call till
44    /// an entry is present.
45    async fn get_oldest_operation_log_key(&self) -> Option<ChronologicalOperationLogKey> {
46        if let Some(oldest_entry) = *self.oldest_entry.read().await {
47            return Some(oldest_entry);
48        }
49
50        let mut dbtx = self.db.begin_transaction_nc().await;
51        let oldest_entry = dbtx
52            .find_by_prefix(&crate::db::ChronologicalOperationLogKeyPrefix)
53            .await
54            .map(|(key, ())| key)
55            .next()
56            .await;
57
58        if let Some(oldest_entry) = oldest_entry {
59            *self.oldest_entry.write().await = Some(oldest_entry);
60        }
61
62        oldest_entry
63    }
64
65    pub async fn add_operation_log_entry_dbtx(
66        &self,
67        dbtx: &mut DatabaseTransaction<'_>,
68        operation_id: OperationId,
69        operation_type: &str,
70        operation_meta: impl serde::Serialize,
71    ) {
72        self.add_operation_log_entry_dbtx_with_creation_time(
73            dbtx,
74            operation_id,
75            operation_type,
76            operation_meta,
77            now(),
78        )
79        .await;
80    }
81
82    pub async fn add_operation_log_entry_dbtx_with_creation_time(
83        &self,
84        dbtx: &mut DatabaseTransaction<'_>,
85        operation_id: OperationId,
86        operation_type: &str,
87        operation_meta: impl serde::Serialize,
88        creation_time: SystemTime,
89    ) {
90        dbtx.insert_new_entry(
91            &OperationLogKey { operation_id },
92            &OperationLogEntry::new(
93                operation_type.to_string(),
94                JsonStringed(
95                    serde_json::to_value(operation_meta)
96                        .expect("Can only fail if meta is not serializable"),
97                ),
98                None,
99            ),
100        )
101        .await;
102        let chronological_operation_log_key = ChronologicalOperationLogKey {
103            creation_time,
104            operation_id,
105        };
106        dbtx.insert_new_entry(&chronological_operation_log_key, &())
107            .await;
108
109        let mut oldest_entry = self.oldest_entry.write().await;
110        if let Some(oldest_entry_key) = &mut *oldest_entry
111            && chronological_operation_log_key_is_older(
112                &chronological_operation_log_key,
113                oldest_entry_key,
114            )
115        {
116            *oldest_entry_key = chronological_operation_log_key;
117        }
118    }
119
120    #[deprecated(since = "0.6.0", note = "Use `paginate_operations_rev` instead")]
121    pub async fn list_operations(
122        &self,
123        limit: usize,
124        last_seen: Option<ChronologicalOperationLogKey>,
125    ) -> Vec<(ChronologicalOperationLogKey, OperationLogEntry)> {
126        self.paginate_operations_rev(limit, last_seen).await
127    }
128
129    /// Returns the last `limit` operations. To fetch the next page, pass the
130    /// last operation's [`ChronologicalOperationLogKey`] as `start_after`.
131    pub async fn paginate_operations_rev(
132        &self,
133        limit: usize,
134        last_seen: Option<ChronologicalOperationLogKey>,
135    ) -> Vec<(ChronologicalOperationLogKey, OperationLogEntry)> {
136        const EPOCH_DURATION: Duration = Duration::from_secs(60 * 60 * 24 * 7);
137
138        let start_after_key = last_seen.unwrap_or_else(|| ChronologicalOperationLogKey {
139            // We don't expect any operations from the future to exist, since SystemTime isn't
140            // monotone and CI can be overloaded at times we add a small buffer to avoid flakiness
141            // in tests.
142            creation_time: now() + Duration::from_secs(30),
143            operation_id: OperationId([0; 32]),
144        });
145
146        let Some(oldest_entry_key) = self.get_oldest_operation_log_key().await else {
147            return vec![];
148        };
149
150        let mut dbtx = self.db.begin_transaction_nc().await;
151        let mut operation_log_keys = Vec::with_capacity(32);
152
153        // Find all the operation log keys in the requested window. Since we decided to
154        // not introduce a find_by_range_rev function we have to jump through some
155        // hoops, see also the comments in rev_epoch_ranges.
156        // TODO: Implement using find_by_range_rev if ever introduced
157        'outer: for key_range_rev in
158            rev_epoch_ranges(start_after_key, oldest_entry_key, EPOCH_DURATION)
159        {
160            let epoch_operation_log_keys_rev = dbtx
161                .find_by_range(key_range_rev)
162                .await
163                .map(|(key, ())| key)
164                .collect::<Vec<_>>()
165                .await;
166
167            for operation_log_key in epoch_operation_log_keys_rev.into_iter().rev() {
168                operation_log_keys.push(operation_log_key);
169                if operation_log_keys.len() >= limit {
170                    break 'outer;
171                }
172            }
173        }
174
175        debug_assert!(
176            operation_log_keys.iter().collect::<HashSet<_>>().len() == operation_log_keys.len(),
177            "Operation log keys returned are not unique"
178        );
179
180        let mut operation_log_entries = Vec::with_capacity(operation_log_keys.len());
181        for operation_log_key in operation_log_keys {
182            let operation_log_entry = dbtx
183                .get_value(&OperationLogKey {
184                    operation_id: operation_log_key.operation_id,
185                })
186                .await
187                .expect("Inconsistent DB");
188            operation_log_entries.push((operation_log_key, operation_log_entry));
189        }
190
191        operation_log_entries
192    }
193
194    pub async fn get_operation(&self, operation_id: OperationId) -> Option<OperationLogEntry> {
195        Self::get_operation_dbtx(
196            &mut self.db.begin_transaction_nc().await.into_nc(),
197            operation_id,
198        )
199        .await
200    }
201
202    pub async fn get_operation_dbtx(
203        dbtx: &mut DatabaseTransaction<'_>,
204        operation_id: OperationId,
205    ) -> Option<OperationLogEntry> {
206        dbtx.get_value(&OperationLogKey { operation_id }).await
207    }
208
209    /// Sets the outcome of an operation
210    #[instrument(target = LOG_CLIENT, skip(db), level = "debug")]
211    pub async fn set_operation_outcome(
212        db: &Database,
213        operation_id: OperationId,
214        outcome: &(impl Serialize + Debug),
215    ) -> anyhow::Result<()> {
216        let outcome_json =
217            JsonStringed(serde_json::to_value(outcome).expect("Outcome is not serializable"));
218
219        let mut dbtx = db.begin_transaction().await;
220        let mut operation = Self::get_operation_dbtx(&mut dbtx.to_ref_nc(), operation_id)
221            .await
222            .expect("Operation exists");
223        operation.set_outcome(OperationOutcome {
224            time: fedimint_core::time::now(),
225            outcome: outcome_json,
226        });
227        dbtx.insert_entry(&OperationLogKey { operation_id }, &operation)
228            .await;
229        dbtx.commit_tx_result().await?;
230
231        Ok(())
232    }
233
234    /// Returns an a [`UpdateStreamOrOutcome`] enum that can be converted into
235    /// an update stream for easier handling using
236    /// [`UpdateStreamOrOutcome::into_stream`] but can also be matched over to
237    /// shortcut the handling of final outcomes.
238    ///
239    /// `is_terminal` reports whether a given update is a final state of the
240    /// operation (no further update will ever follow it). Only terminal
241    /// updates are cached as the operation's outcome — an update stream that
242    /// ends early (a consumer-side error path) must not persist a
243    /// non-terminal update as the durable outcome, since later subscribers
244    /// would then be short-circuited to it forever. The same predicate also
245    /// validates outcomes READ from the cache: a cached outcome that no
246    /// longer deserializes (e.g. written by an older version with a different
247    /// update type) or that is non-terminal (frozen by a previous version
248    /// that cached whatever update a stream ended on) is discarded with a
249    /// warning and the outcome is rebuilt from the update stream — whose
250    /// terminal end then overwrites the stale cache — instead of
251    /// short-circuiting subscribers to it or panicking.
252    pub fn outcome_or_updates<U, S>(
253        db: &Database,
254        operation_id: OperationId,
255        operation_log_entry: &OperationLogEntry,
256        is_terminal: impl Fn(&U) -> bool + MaybeSend + MaybeSync + 'static,
257        stream_gen: impl FnOnce() -> S,
258    ) -> UpdateStreamOrOutcome<U>
259    where
260        U: Clone + Serialize + DeserializeOwned + Debug + MaybeSend + MaybeSync + 'static,
261        S: futures::Stream<Item = U> + MaybeSend + 'static,
262    {
263        match operation_log_entry.try_outcome::<U>() {
264            Ok(Some(outcome)) if is_terminal(&outcome) => {
265                return UpdateStreamOrOutcome::Outcome(outcome);
266            }
267            Ok(Some(_non_terminal)) => {
268                warn!(
269                    target: LOG_CLIENT,
270                    "Cached operation outcome is not a terminal update (cached by a previous \
271                     version); rebuilding it from the update stream"
272                );
273            }
274            Ok(None) => {}
275            Err(err) => {
276                warn!(
277                    target: LOG_CLIENT,
278                    err = %err.fmt_compact(),
279                    "Cached operation outcome failed to deserialize; rebuilding it from the update stream"
280                );
281            }
282        }
283        UpdateStreamOrOutcome::UpdateStream(caching_operation_update_stream(
284            db.clone(),
285            operation_id,
286            stream_gen(),
287            is_terminal,
288        ))
289    }
290
291    /// Tries to set the outcome of an operation, but only logs an error if it
292    /// fails and does not return it. Since the outcome can always be recomputed
293    /// from an update stream, failing to save it isn't a problem in cases where
294    /// we do this merely for caching.
295    pub async fn optimistically_set_operation_outcome(
296        db: &Database,
297        operation_id: OperationId,
298        outcome: &(impl Serialize + Debug),
299    ) {
300        if let Err(e) = Self::set_operation_outcome(db, operation_id, outcome).await {
301            warn!(
302                target: LOG_CLIENT,
303                "Error setting operation outcome: {e}"
304            );
305        }
306    }
307}
308
309#[apply(async_trait_maybe_send!)]
310impl IOperationLog for OperationLog {
311    async fn get_operation(&self, operation_id: OperationId) -> Option<OperationLogEntry> {
312        OperationLog::get_operation(self, operation_id).await
313    }
314
315    async fn get_operation_dbtx(
316        &self,
317        dbtx: &mut DatabaseTransaction<'_>,
318        operation_id: OperationId,
319    ) -> Option<OperationLogEntry> {
320        OperationLog::get_operation_dbtx(dbtx, operation_id).await
321    }
322
323    async fn add_operation_log_entry_dbtx(
324        &self,
325        dbtx: &mut DatabaseTransaction<'_>,
326        operation_id: OperationId,
327        operation_type: &str,
328        operation_meta: serde_json::Value,
329    ) {
330        OperationLog::add_operation_log_entry_dbtx(
331            self,
332            dbtx,
333            operation_id,
334            operation_type,
335            operation_meta,
336        )
337        .await
338    }
339
340    fn caching_operation_update_stream(
341        &self,
342        operation_id: OperationId,
343        stream_gen: Box<maybe_add_send!(dyn FnOnce() -> BoxStream<'static, serde_json::Value>)>,
344        is_terminal: Box<maybe_add_send_sync!(dyn Fn(&serde_json::Value) -> bool)>,
345    ) -> BoxStream<'static, serde_json::Value> {
346        caching_operation_update_stream(self.db.clone(), operation_id, stream_gen(), is_terminal)
347    }
348}
349
350fn chronological_operation_log_key_is_older(
351    lhs: &ChronologicalOperationLogKey,
352    rhs: &ChronologicalOperationLogKey,
353) -> bool {
354    (lhs.creation_time, lhs.operation_id) < (rhs.creation_time, rhs.operation_id)
355}
356
357/// Returns an iterator over the ranges of operation log keys, starting from the
358/// most recent range and going backwards in time till slightly later than
359/// `last_entry`.
360///
361/// Simplifying keys to integers and assuming a `start_after` of 100, a
362/// `last_entry` of 55 and an `epoch_duration` of 10 the ranges would be:
363/// ```text
364/// [90..100, 80..90, 70..80, 60..70, 50..60]
365/// ```
366fn rev_epoch_ranges(
367    start_after: ChronologicalOperationLogKey,
368    last_entry: ChronologicalOperationLogKey,
369    epoch_duration: Duration,
370) -> impl Iterator<Item = Range<ChronologicalOperationLogKey>> {
371    // We want to fetch all operations that were created before `start_after`, going
372    // backwards in time. This means "start" generally means a later time than
373    // "end". Only when creating a rust Range we have to swap the terminology (see
374    // comment there).
375    (0..)
376        .map(move |epoch| start_after.creation_time - epoch * epoch_duration)
377        // We want to get all operation log keys in the range [last_key, start_after). So as
378        // long as the start time is greater than the last key's creation time, we have to
379        // keep going.
380        .take_while(move |&start_time| start_time >= last_entry.creation_time)
381        .map(move |start_time| {
382            let end_time = start_time - epoch_duration;
383
384            // In the edge case that there were two events logged at exactly the same time
385            // we need to specify the correct operation_id for the first key. Otherwise, we
386            // could miss entries.
387            let start_key = if start_time == start_after.creation_time {
388                start_after
389            } else {
390                ChronologicalOperationLogKey {
391                    creation_time: start_time,
392                    operation_id: OperationId([0; 32]),
393                }
394            };
395
396            // We could also special-case the last key here, but it's not necessary, making
397            // it last_key if end_time < last_key.creation_time. We know there are no
398            // entries beyond last_key though, so the range query will be equivalent either
399            // way.
400            let end_key = ChronologicalOperationLogKey {
401                creation_time: end_time,
402                operation_id: OperationId([0; 32]),
403            };
404
405            // We want to go backwards using a forward range query. This means we have to
406            // swap the start and end keys and then reverse the vector returned by the
407            // query.
408            Range {
409                start: end_key,
410                end: start_key,
411            }
412        })
413}
414
415/// Wraps an operation update stream such that the last update before it closes
416/// is tried to be written to the operation log entry as its outcome — but only
417/// if `is_terminal` reports it as a final state of the operation. A stream
418/// that ends on a non-terminal update (e.g. an error-path early return in the
419/// stream generator) must not have that update cached as the outcome:
420/// [`OperationLog::outcome_or_updates`] short-circuits every later subscriber
421/// to the cached value, so caching a non-terminal update would freeze the
422/// operation's observable state before its actual end.
423pub fn caching_operation_update_stream<'a, U, S>(
424    db: Database,
425    operation_id: OperationId,
426    stream: S,
427    is_terminal: impl Fn(&U) -> bool + MaybeSend + 'a,
428) -> BoxStream<'a, U>
429where
430    U: Clone + Serialize + Debug + MaybeSend + MaybeSync + 'static,
431    S: futures::Stream<Item = U> + MaybeSend + 'a,
432{
433    let mut stream = Box::pin(stream);
434    Box::pin(async_stream::stream! {
435        let mut last_update = None;
436        while let Some(update) = stream.next().await {
437            yield update.clone();
438            last_update = Some(update);
439        }
440
441        let Some(last_update) = last_update else {
442            error!(
443                target: LOG_CLIENT,
444                "Stream ended without any updates, this should not happen!"
445            );
446            return;
447        };
448
449        if !is_terminal(&last_update) {
450            warn!(
451                target: LOG_CLIENT,
452                ?last_update,
453                "Operation update stream ended on a non-terminal update; not caching an outcome"
454            );
455            return;
456        }
457
458        OperationLog::optimistically_set_operation_outcome(&db, operation_id, &last_update).await;
459    })
460}