Skip to main content

fedimint_client_module/module/init/
recovery.rs

1use std::collections::BTreeMap;
2use std::time::Duration;
3use std::{cmp, ops};
4
5use bitcoin::secp256k1::PublicKey;
6use fedimint_api_client::api::{
7    DynGlobalApi, VERSION_THAT_INTRODUCED_GET_SESSION_STATUS,
8    VERSION_THAT_INTRODUCED_GET_SESSION_STATUS_V2,
9};
10use fedimint_core::db::{AutocommitError, DatabaseTransaction};
11use fedimint_core::encoding::{Decodable, Encodable};
12use fedimint_core::module::registry::ModuleDecoderRegistry;
13use fedimint_core::module::{ApiVersion, ModuleCommon};
14use fedimint_core::session_outcome::{AcceptedItem, ConsensusItem, SessionStatus};
15use fedimint_core::task::{MaybeSend, MaybeSync, ShuttingDownError, TaskGroup};
16use fedimint_core::transaction::Transaction;
17use fedimint_core::util::FmtCompact as _;
18use fedimint_core::{Amount, OutPoint, PeerId, apply, async_trait_maybe_send};
19use fedimint_logging::LOG_CLIENT_RECOVERY;
20use futures::{Stream, StreamExt as _};
21use rand::{Rng as _, thread_rng};
22use serde::{Deserialize, Serialize};
23use tracing::{debug, trace, warn};
24
25use super::{ClientModuleInit, ClientModuleRecoverArgs};
26use crate::error::ClientModuleError;
27use crate::module::recovery::RecoveryProgress;
28use crate::module::{ClientContext, ClientModule};
29
30#[allow(clippy::struct_field_names)]
31#[derive(Debug, Clone, Eq, PartialEq, Encodable, Decodable, Serialize, Deserialize)]
32/// Common state tracked during recovery from history
33pub struct RecoveryFromHistoryCommon {
34    start_session: u64,
35    next_session: u64,
36    end_session: u64,
37}
38
39impl RecoveryFromHistoryCommon {
40    pub fn new(start_session: u64, next_session: u64, end_session: u64) -> Self {
41        Self {
42            start_session,
43            next_session,
44            end_session,
45        }
46    }
47}
48
49/// Module specific logic for [`ClientModuleRecoverArgs::recover_from_history`]
50///
51/// See [`ClientModuleRecoverArgs::recover_from_history`] for more information.
52#[apply(async_trait_maybe_send!)]
53pub trait RecoveryFromHistory: std::fmt::Debug + MaybeSend + MaybeSync + Clone {
54    /// [`ClientModuleInit`] of this recovery logic.
55    type Init: ClientModuleInit;
56
57    /// New empty state to start recovery from, and session number to start from
58    async fn new(
59        init: &Self::Init,
60        args: &ClientModuleRecoverArgs<Self::Init>,
61        snapshot: Option<&<<Self::Init as ClientModuleInit>::Module as ClientModule>::Backup>,
62    ) -> Result<(Self, u64), ClientModuleError>;
63
64    /// Try to load the existing state previously stored with
65    /// [`RecoveryFromHistory::store_dbtx`].
66    ///
67    /// Storing and restoring progress is used to save progress and
68    /// continue recovery if it was previously terminated before completion.
69    async fn load_dbtx(
70        init: &Self::Init,
71        dbtx: &mut DatabaseTransaction<'_>,
72        args: &ClientModuleRecoverArgs<Self::Init>,
73    ) -> Result<Option<(Self, RecoveryFromHistoryCommon)>, ClientModuleError>;
74
75    /// Store the current recovery state in the database
76    ///
77    /// See [`Self::load_dbtx`].
78    async fn store_dbtx(
79        &self,
80        dbtx: &mut DatabaseTransaction<'_>,
81        common: &RecoveryFromHistoryCommon,
82    );
83
84    /// Delete the recovery state from the database
85    ///
86    /// See [`Self::load_dbtx`].
87    async fn delete_dbtx(&self, dbtx: &mut DatabaseTransaction<'_>);
88
89    /// Read the finalization status
90    ///
91    /// See [`Self::load_dbtx`].
92    async fn load_finalized(dbtx: &mut DatabaseTransaction<'_>) -> Option<bool>;
93
94    /// Store finalization status
95    ///
96    /// See [`Self::load_finalized`].
97    async fn store_finalized(dbtx: &mut DatabaseTransaction<'_>, state: bool);
98
99    /// Handle session outcome, adjusting the current state
100    ///
101    /// It is expected that most implementations don't need to override this
102    /// function, and override more granular ones instead (e.g.
103    /// [`Self::handle_input`] and/or [`Self::handle_output`]).
104    ///
105    /// The default implementation will loop through items in the
106    /// `session.items` and forward them one by one to respective functions
107    /// (see [`Self::handle_transaction`]).
108    async fn handle_session(
109        &mut self,
110        client_ctx: &ClientContext<<Self::Init as ClientModuleInit>::Module>,
111        session_idx: u64,
112        session_items: &Vec<AcceptedItem>,
113    ) -> Result<(), ClientModuleError> {
114        for accepted_item in session_items {
115            if let ConsensusItem::Transaction(ref transaction) = accepted_item.item {
116                self.handle_transaction(client_ctx, transaction, session_idx)
117                    .await?;
118            }
119        }
120        Ok(())
121    }
122
123    /// Handle session outcome, adjusting the current state
124    ///
125    /// It is expected that most implementations don't need to override this
126    /// function, and override more granular ones instead (e.g.
127    /// [`Self::handle_input`] and/or [`Self::handle_output`]).
128    ///
129    /// The default implementation will loop through inputs and outputs
130    /// of the transaction, filter and downcast ones matching current module
131    /// and forward them one by one to respective functions
132    /// (e.g. [`Self::handle_input`], [`Self::handle_output`]).
133    async fn handle_transaction(
134        &mut self,
135        client_ctx: &ClientContext<<Self::Init as ClientModuleInit>::Module>,
136        transaction: &Transaction,
137        session_idx: u64,
138    ) -> Result<(), ClientModuleError> {
139        trace!(
140            target: LOG_CLIENT_RECOVERY,
141            tx_hash = %transaction.tx_hash(),
142            input_num = transaction.inputs.len(),
143            output_num = transaction.outputs.len(),
144            "processing transaction"
145        );
146
147        for (idx, input) in transaction.inputs.iter().enumerate() {
148            trace!(
149                target: LOG_CLIENT_RECOVERY,
150                tx_hash = %transaction.tx_hash(),
151                idx,
152                module_id = input.module_instance_id(),
153                "found transaction input"
154            );
155
156            if let Some(own_input) = client_ctx.input_from_dyn(input) {
157                self.handle_input(client_ctx, idx, own_input, session_idx)
158                    .await?;
159            }
160        }
161
162        for (out_idx, output) in transaction.outputs.iter().enumerate() {
163            trace!(
164                target: LOG_CLIENT_RECOVERY,
165                tx_hash = %transaction.tx_hash(),
166                idx = out_idx,
167                module_id = output.module_instance_id(),
168                "found transaction output"
169            );
170
171            if let Some(own_output) = client_ctx.output_from_dyn(output) {
172                let out_point = OutPoint {
173                    txid: transaction.tx_hash(),
174                    out_idx: out_idx as u64,
175                };
176
177                self.handle_output(client_ctx, out_point, own_output, session_idx)
178                    .await?;
179            }
180        }
181
182        Ok(())
183    }
184
185    /// Handle transaction input, adjusting the current state
186    ///
187    /// Default implementation does nothing.
188    async fn handle_input(
189        &mut self,
190        _client_ctx: &ClientContext<<Self::Init as ClientModuleInit>::Module>,
191        _idx: usize,
192        _input: &<<<Self::Init as ClientModuleInit>::Module as ClientModule>::Common as ModuleCommon>::Input,
193        _session_idx: u64,
194    ) -> Result<(), ClientModuleError> {
195        Ok(())
196    }
197
198    /// Handle transaction output, adjusting the current state
199    ///
200    /// Default implementation does nothing.
201    async fn handle_output(
202        &mut self,
203        _client_ctx: &ClientContext<<Self::Init as ClientModuleInit>::Module>,
204        _out_point: OutPoint,
205        _output: &<<<Self::Init as ClientModuleInit>::Module as ClientModule>::Common as ModuleCommon>::Output,
206        _session_idx: u64,
207    ) -> Result<(), ClientModuleError> {
208        Ok(())
209    }
210
211    /// Called before `finalize_dbtx`, to allow final state changes outside
212    /// of retriable database transaction.
213    async fn pre_finalize(&mut self) -> Result<(), ClientModuleError> {
214        Ok(())
215    }
216
217    /// Finalize the recovery converting the tracked state to final
218    /// changes in the database.
219    ///
220    /// This is the only place during recovery where module gets a chance to
221    /// create state machines, etc.
222    ///
223    /// Returns the total amount recovered from this module, if the module
224    /// tracks it (`None` otherwise), which is surfaced in the
225    /// `ModuleRecoveryCompleted` event.
226    ///
227    /// Notably this function is running in a database-autocommit wrapper, so
228    /// might be called again on database commit failure.
229    async fn finalize_dbtx(
230        &self,
231        dbtx: &mut DatabaseTransaction<'_>,
232    ) -> Result<Option<Amount>, ClientModuleError>;
233}
234
235impl<Init> ClientModuleRecoverArgs<Init>
236where
237    Init: ClientModuleInit,
238{
239    /// Run recover of a module from federation consensus history
240    ///
241    /// It is expected that most modules will implement their recovery
242    /// by following Federation consensus history to restore their
243    /// state. This function implement such a recovery by being generic
244    /// over [`RecoveryFromHistory`] trait, which provides module-specific
245    /// parts of recovery logic.
246    ///
247    /// # Errors
248    ///
249    /// Fails with whatever the [`RecoveryFromHistory`] implementation fails
250    /// with, or with [`ClientModuleError::Other`] if the federation cannot say
251    /// how many sessions to recover or the client shuts down while the
252    /// sessions are being fetched.
253    pub async fn recover_from_history<Recovery>(
254        &self,
255        init: &Init,
256        snapshot: Option<&<<Init as ClientModuleInit>::Module as ClientModule>::Backup>,
257    ) -> Result<Option<Amount>, ClientModuleError>
258    where
259        Recovery: RecoveryFromHistory<Init = Init> + std::fmt::Debug,
260    {
261        /// Fetch epochs in a given range and send them over `sender`
262        ///
263        /// Since WASM's `spawn` does not support join handles, we indicate
264        /// errors via `sender` itself.
265        fn fetch_block_stream<'a>(
266            api: DynGlobalApi,
267            core_api_version: ApiVersion,
268            decoders: ModuleDecoderRegistry,
269            epoch_range: ops::Range<u64>,
270            broadcast_public_keys: Option<BTreeMap<PeerId, PublicKey>>,
271            task_group: TaskGroup,
272        ) -> impl futures::Stream<Item = Result<(u64, Vec<AcceptedItem>), ShuttingDownError>> + 'a
273        {
274            // How many request for blocks to run in parallel (streaming).
275            let parallelism_level =
276                if core_api_version < VERSION_THAT_INTRODUCED_GET_SESSION_STATUS_V2 {
277                    64
278                } else {
279                    128
280                };
281
282            futures::stream::iter(epoch_range.clone())
283                .map(move |session_idx| {
284                    let api = api.clone();
285                    // When decoding we're only interested in items we can understand, so we don't
286                    // want to fail on a missing decoder of some unrelated module.
287                    let decoders = decoders.clone().with_fallback();
288                    let task_group = task_group.clone();
289                    let broadcast_public_keys = broadcast_public_keys.clone();
290
291                    Box::pin(async move {
292                        // NOTE: Each block is fetched in a spawned task. This avoids a footgun
293                        // of stuff in streams not making any progress when the stream itself
294                        // is not being polled, and possibly can increase the fetching performance.
295                        task_group.spawn_cancellable("recovery fetch block", async move {
296
297                            let mut retry_sleep = Duration::from_millis(10);
298                            let block = loop {
299                                trace!(target: LOG_CLIENT_RECOVERY, session_idx, "Awaiting signed block");
300
301                                let items_res = if core_api_version < VERSION_THAT_INTRODUCED_GET_SESSION_STATUS {
302                                    api.await_block(session_idx, &decoders).await.map(|s| s.items)
303                                } else {
304                                    api.get_session_status(session_idx, &decoders, core_api_version, broadcast_public_keys.as_ref()).await.map(|s| match s {
305                                        SessionStatus::Initial => panic!("Federation missing session that existed when we started recovery"),
306                                        SessionStatus::Pending(items) => items,
307                                        SessionStatus::Complete(s) => s.items,
308                                    })
309                                };
310
311                                match items_res {
312                                    Ok(block) => {
313                                        trace!(target: LOG_CLIENT_RECOVERY, session_idx, "Got signed session");
314                                        break block
315                                    },
316                                    Err(err) => {
317                                        const MAX_SLEEP: Duration = Duration::from_mins(2);
318
319                                        warn!(target: LOG_CLIENT_RECOVERY, err = %err.fmt_compact(), session_idx, "Error trying to fetch signed block");
320                                        // We don't want PARALLELISM_LEVEL tasks hammering Federation
321                                        // with requests, so max sleep is significant
322                                        if retry_sleep <= MAX_SLEEP {
323                                            retry_sleep = retry_sleep
324                                                + thread_rng().gen_range(Duration::ZERO..=retry_sleep);
325                                        }
326                                        fedimint_core::runtime::sleep(cmp::min(retry_sleep, MAX_SLEEP))
327                                            .await;
328                                    }
329                                }
330                            };
331
332                            (session_idx, block)
333                        }).await.expect("Can't fail")
334                    })
335                })
336                .buffered(parallelism_level)
337        }
338
339        /// Make enough progress to justify saving a state snapshot
340        async fn make_progress<Init, Recovery: RecoveryFromHistory<Init = Init>>(
341            client_ctx: &ClientContext<<Init as ClientModuleInit>::Module>,
342            common_state: &mut RecoveryFromHistoryCommon,
343            state: &mut Recovery,
344            block_stream: &mut (
345                     impl Stream<Item = Result<(u64, Vec<AcceptedItem>), ShuttingDownError>> + Unpin
346                 ),
347        ) -> Result<(), ClientModuleError>
348        where
349            Init: ClientModuleInit,
350        {
351            /// the amount of blocks after which we unconditionally save
352            /// progress in the database (return from this function)
353            ///
354            /// We are also bound by time inside the loop, below
355            const PROGRESS_SNAPSHOT_BLOCKS: u64 = 5000;
356
357            let start = fedimint_core::time::now();
358
359            let block_range = common_state.next_session
360                ..cmp::min(
361                    common_state
362                        .next_session
363                        .wrapping_add(PROGRESS_SNAPSHOT_BLOCKS),
364                    common_state.end_session,
365                );
366
367            for _ in block_range {
368                let Some(res) = block_stream.next().await else {
369                    break;
370                };
371
372                let (session_idx, accepted_items) = res.map_err(ClientModuleError::other)?;
373
374                assert_eq!(common_state.next_session, session_idx);
375                state
376                    .handle_session(client_ctx, session_idx, &accepted_items)
377                    .await?;
378
379                common_state.next_session += 1;
380
381                if Duration::from_secs(10)
382                    < fedimint_core::time::now()
383                        .duration_since(start)
384                        .unwrap_or_default()
385                {
386                    break;
387                }
388            }
389
390            Ok(())
391        }
392
393        let db = self.db();
394        let client_ctx = self.context();
395
396        if Recovery::load_finalized(&mut db.begin_transaction_nc().await)
397            .await
398            .unwrap_or_default()
399        {
400            // In rare circumstances, the finalization could complete, yet the completion
401            // of `recover` function not yet persisted in the database. So
402            // it's possible that `recovery` would be called again on an
403            // already finalized state. Because of this we store a
404            // finalization marker in the same dbtx as the finalization itself, detect this
405            // here and exit early.
406            //
407            // Example sequence how this happens (if `finalize_dbtx` didn't exist):
408            //
409            // 0. module recovery is complete and progress saved to the db
410            // 1. `dbtx` with finalization commits, progress deleted, completing recovery on
411            //    the client module side
412            // 2. client crashes/gets terminated (tricky corner case)
413            // 3. client starts again
414            // 4. client never observed/persisted that the module finished recovery, so
415            //    calls module recovery again
416            // 5. module doesn't see progress, starts recovery again, eventually completes
417            //    again and moves to finalization
418            // 6. module runs finalization again and probably fails because it's actually
419            //    not idempotent and doesn't expect the already existing state.
420            warn!(
421                target: LOG_CLIENT_RECOVERY,
422                "Previously finalized, exiting"
423            );
424            return Ok(None);
425        }
426        let current_session_count = client_ctx
427            .global_api()
428            .session_count()
429            .await
430            .map_err(ClientModuleError::other)?;
431        debug!(target: LOG_CLIENT_RECOVERY, session_count = current_session_count, "Current session count");
432
433        let (mut state, mut common_state) =
434            // TODO: if load fails (e.g. module didn't migrate an existing recovery state and failed to decode it),
435            // we could just ... start from scratch? at least being able to force this behavior might be useful
436            if let Some((state, common_state)) = Recovery::load_dbtx(init, &mut db.begin_transaction_nc().await, self).await? {
437                (state, common_state)
438            } else {
439                let (state, start_session) = Recovery::new(init, self, snapshot).await?;
440
441                debug!(target: LOG_CLIENT_RECOVERY, start_session, "Recovery start session");
442                (state,
443                RecoveryFromHistoryCommon {
444                    start_session,
445                    next_session: start_session,
446                    end_session: current_session_count + 1,
447                })
448            };
449
450        let block_stream_session_range = common_state.next_session..common_state.end_session;
451        debug!(target: LOG_CLIENT_RECOVERY, range = ?block_stream_session_range, "Starting block streaming");
452
453        let mut block_stream = fetch_block_stream(
454            self.api().clone(),
455            *self.core_api_version(),
456            client_ctx.decoders(),
457            block_stream_session_range,
458            client_ctx
459                .get_config()
460                .await
461                .global
462                .broadcast_public_keys
463                .clone(),
464            self.task_group().clone(),
465        );
466        let client_ctx = self.context();
467
468        while common_state.next_session < common_state.end_session {
469            make_progress(
470                &client_ctx,
471                &mut common_state,
472                &mut state,
473                &mut block_stream,
474            )
475            .await?;
476
477            let mut dbtx = db.begin_transaction().await;
478            state.store_dbtx(&mut dbtx.to_ref_nc(), &common_state).await;
479            dbtx.commit_tx().await;
480
481            self.update_recovery_progress(RecoveryProgress {
482                complete: (common_state.next_session - common_state.start_session)
483                    .try_into()
484                    .unwrap_or(u32::MAX),
485                total: (common_state.end_session - common_state.start_session)
486                    .try_into()
487                    .unwrap_or(u32::MAX),
488            });
489        }
490
491        state.pre_finalize().await?;
492
493        let mut dbtx = db.begin_transaction().await;
494        state.store_dbtx(&mut dbtx.to_ref_nc(), &common_state).await;
495        dbtx.commit_tx().await;
496
497        debug!(
498            target: LOG_CLIENT_RECOVERY,
499            ?state,
500            "Finalizing restore"
501        );
502
503        let recovered_amount = db
504            .autocommit(
505                |dbtx, _| {
506                    let state = state.clone();
507                    {
508                        Box::pin(async move {
509                            state.delete_dbtx(dbtx).await;
510                            let recovered_amount = state.finalize_dbtx(dbtx).await?;
511                            Recovery::store_finalized(dbtx, true).await;
512
513                            Ok::<_, ClientModuleError>(recovered_amount)
514                        })
515                    }
516                },
517                None,
518            )
519            .await
520            .map_err(|error| match error {
521                AutocommitError::ClosureError { error, .. } => error,
522                AutocommitError::CommitFailed { last_error, .. } => {
523                    ClientModuleError::other(last_error)
524                }
525            })?;
526
527        Ok(recovered_amount)
528    }
529}