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