Skip to main content

fedimint_client_module/transaction/
sm.rs

1//! State machine for submitting transactions
2
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::time::Duration;
5
6use fedimint_core::TransactionId;
7use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
8use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
9use fedimint_core::time::duration_since_epoch;
10use fedimint_core::transaction::{Transaction, TransactionSubmissionOutcome};
11use fedimint_core::util::backoff_util::custom_backoff;
12use fedimint_core::util::retry;
13use fedimint_logging::LOG_CLIENT_NET_API;
14use tokio::sync::watch;
15use tracing::{debug, warn};
16
17use crate::sm::{Context, DynContext, State, StateTransition};
18use crate::{
19    DynGlobalClientContext, DynState, TxAcceptedEvent, TxRejectedEvent, TxSubmissionStalledEvent,
20};
21
22// TODO: how to prevent collisions? Generally reserve some range for custom IDs?
23/// Reserved module instance id used for client-internal state machines
24pub const TRANSACTION_SUBMISSION_MODULE_INSTANCE: ModuleInstanceId = 0xffff;
25
26/// How long a submission may keep being re-attempted within a single client
27/// run, without the transaction being accepted or rejected, before it is
28/// reported as stalled.
29///
30/// Comfortably above the submission backoff's own maximum delay, so a healthy
31/// but slow submission does not warn.
32const SUBMISSION_STALL_WARN_AFTER: Duration = Duration::from_mins(30);
33
34/// How often to repeat the stall report while the condition persists.
35const SUBMISSION_STALL_WARN_INTERVAL: Duration = Duration::from_mins(30);
36
37#[derive(Debug, Clone)]
38pub struct TxSubmissionContext;
39
40impl Context for TxSubmissionContext {
41    const KIND: Option<ModuleKind> = None;
42}
43
44impl IntoDynInstance for TxSubmissionContext {
45    type DynType = DynContext;
46
47    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
48        DynContext::from_typed(instance_id, self)
49    }
50}
51
52/// State machine to (re-)submit a transaction until it is either accepted or
53/// rejected by the federation
54///
55/// ```mermaid
56/// flowchart LR
57///     Created -- tx is accepted by consensus --> Accepted
58///     Created -- tx is rejected on submission --> Rejected
59/// ```
60// NOTE: This struct needs to retain the same encoding as [`crate::sm::OperationState`],
61// because it was used to replace it, and clients already have it persisted.
62#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
63pub struct TxSubmissionStatesSM {
64    pub operation_id: OperationId,
65    pub state: TxSubmissionStates,
66}
67
68#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
69pub enum TxSubmissionStates {
70    /// The transaction has been created and potentially already been submitted,
71    /// but no rejection or acceptance happened so far
72    Created(Transaction),
73    /// The transaction has been accepted in consensus
74    ///
75    /// **This state is final**
76    Accepted(TransactionId),
77    /// The transaction has been rejected by a quorum on submission
78    ///
79    /// **This state is final**
80    Rejected(TransactionId, String),
81    // Ideally this would be uncommented:
82    // #[deprecated(since = "0.2.2", note = "all errors should be retried")]
83    // but due to some rust bug/limitation it seem impossible to prevent
84    // existing usages from spamming compilation output with warnings.
85    NonRetryableError(String),
86}
87
88impl State for TxSubmissionStatesSM {
89    type ModuleContext = TxSubmissionContext;
90
91    fn transitions(
92        &self,
93        _context: &Self::ModuleContext,
94        global_context: &DynGlobalClientContext,
95    ) -> Vec<StateTransition<Self>> {
96        let operation_id = self.operation_id;
97        // There is no point awaiting tx until it was submitted, so
98        // `trigger_created_rejected` which does the submitting will use this
99        // channel to let the `trigger_created_accepted` which does the awaiting
100        // know when it did the submission.
101        //
102        // Submitting tx does not guarantee that it will get into consensus, so the
103        // submitting need to continue.
104        let (tx_submitted_sender, tx_submitted_receiver) = watch::channel(false);
105        match self.state.clone() {
106            TxSubmissionStates::Created(transaction) => {
107                let txid = transaction.tx_hash();
108                vec![
109                    StateTransition::new(
110                        TxSubmissionStates::trigger_created_rejected(
111                            transaction.clone(),
112                            global_context.clone(),
113                            tx_submitted_sender,
114                            operation_id,
115                        ),
116                        {
117                            let global_context = global_context.clone();
118                            move |sm_dbtx, error, _| {
119                                let global_context = global_context.clone();
120                                Box::pin(async move {
121                                    global_context
122                                        .log_event(
123                                            sm_dbtx,
124                                            TxRejectedEvent {
125                                                txid,
126                                                operation_id,
127                                                error: error.clone(),
128                                            },
129                                        )
130                                        .await;
131                                    TxSubmissionStatesSM {
132                                        state: TxSubmissionStates::Rejected(txid, error),
133                                        operation_id,
134                                    }
135                                })
136                            }
137                        },
138                    ),
139                    StateTransition::new(
140                        TxSubmissionStates::trigger_created_accepted(
141                            txid,
142                            global_context.clone(),
143                            tx_submitted_receiver,
144                        ),
145                        {
146                            let global_context = global_context.clone();
147                            move |sm_dbtx, (), _| {
148                                let global_context = global_context.clone();
149                                Box::pin(async move {
150                                    global_context
151                                        .log_event(sm_dbtx, TxAcceptedEvent { txid, operation_id })
152                                        .await;
153                                    TxSubmissionStatesSM {
154                                        state: TxSubmissionStates::Accepted(txid),
155                                        operation_id,
156                                    }
157                                })
158                            }
159                        },
160                    ),
161                ]
162            }
163            TxSubmissionStates::Accepted(..)
164            | TxSubmissionStates::Rejected(..)
165            | TxSubmissionStates::NonRetryableError(..) => {
166                vec![]
167            }
168        }
169    }
170
171    fn operation_id(&self) -> OperationId {
172        self.operation_id
173    }
174
175    fn fmt_visualization(&self, f: &mut dyn std::fmt::Write, indent: &str) -> std::fmt::Result {
176        match &self.state {
177            TxSubmissionStates::Created(tx) => {
178                let txid = tx.tx_hash();
179                write!(
180                    f,
181                    "{indent}TxSubmissionStatesSM\n{indent}  state: Created  txid={}  inputs={}  outputs={}",
182                    txid.fmt_short(),
183                    tx.inputs.len(),
184                    tx.outputs.len(),
185                )
186            }
187            TxSubmissionStates::Accepted(txid) => {
188                write!(
189                    f,
190                    "{indent}TxSubmissionStatesSM\n{indent}  state: Accepted  txid={}",
191                    txid.fmt_short(),
192                )
193            }
194            TxSubmissionStates::Rejected(txid, err) => {
195                write!(
196                    f,
197                    "{indent}TxSubmissionStatesSM\n{indent}  state: Rejected  txid={}  error={err}",
198                    txid.fmt_short(),
199                )
200            }
201            TxSubmissionStates::NonRetryableError(err) => {
202                write!(
203                    f,
204                    "{indent}TxSubmissionStatesSM\n{indent}  state: NonRetryableError  error={err}",
205                )
206            }
207        }
208    }
209}
210
211impl TxSubmissionStates {
212    async fn trigger_created_rejected(
213        transaction: Transaction,
214        context: DynGlobalClientContext,
215        tx_submitted: watch::Sender<bool>,
216        operation_id: OperationId,
217    ) -> String {
218        let txid = transaction.tx_hash();
219        debug!(target: LOG_CLIENT_NET_API, %txid, "Submitting transaction");
220
221        let started_s = duration_since_epoch().as_secs();
222        let attempts = AtomicU64::new(0);
223        // Unix second at which the next stall report is due. Starts one
224        // `SUBMISSION_STALL_WARN_AFTER` out and advances by
225        // `SUBMISSION_STALL_WARN_INTERVAL` after each report.
226        let next_alert_deadline_s =
227            AtomicU64::new(started_s + SUBMISSION_STALL_WARN_AFTER.as_secs());
228
229        retry(
230            "tx-submit-sm",
231            custom_backoff(Duration::from_secs(2), Duration::from_mins(10), None),
232            || async {
233                let attempt = attempts.fetch_add(1, Ordering::Relaxed).saturating_add(1);
234                if let TransactionSubmissionOutcome(Err(transaction_error)) = context
235                    .api()
236                    .submit_transaction(transaction.clone())
237                    .await
238                    .try_into_inner(context.decoders())?
239                {
240                    Ok(transaction_error.to_string())
241                } else {
242                    debug!(
243                        target: LOG_CLIENT_NET_API,
244                        %txid,
245                        "Transaction submission accepted by peer, awaiting consensus",
246                    );
247                    tx_submitted.send_replace(true);
248
249                    // Re-submitting until the transaction is accepted or rejected is
250                    // intentional: submission does not guarantee the transaction reaches
251                    // consensus. But a submission stuck in this branch keeps its state
252                    // machine alive indefinitely, and at default log levels nothing
253                    // reports that it is happening, so surface it.
254                    //
255                    // Elapsed is measured per client run off a wall clock: a restart
256                    // resets it, and a large clock step can skew it. That is acceptable
257                    // for a diagnostic, which this is - it changes no behaviour.
258                    let now_s = duration_since_epoch().as_secs();
259                    if next_alert_deadline_s.load(Ordering::Relaxed) <= now_s {
260                        next_alert_deadline_s.store(
261                            now_s + SUBMISSION_STALL_WARN_INTERVAL.as_secs(),
262                            Ordering::Relaxed,
263                        );
264                        let elapsed_s = now_s.saturating_sub(started_s);
265                        warn!(
266                            target: LOG_CLIENT_NET_API,
267                            %txid,
268                            operation_id = %operation_id.fmt_short(),
269                            %attempt,
270                            %elapsed_s,
271                            "Transaction neither accepted nor rejected; still re-submitting",
272                        );
273                        // Surface the same condition to integrators as a transient
274                        // (non-persisted) event. No dbtx is in scope here, so this
275                        // opens its own.
276                        context
277                            .log_event_no_dbtx(TxSubmissionStalledEvent {
278                                txid,
279                                operation_id,
280                                attempt,
281                                elapsed_s,
282                            })
283                            .await;
284                    }
285
286                    Err(SubmitAttemptError::StillValid)
287                }
288            },
289        )
290        .await
291        .expect("Number of retries is has no limit")
292    }
293
294    async fn trigger_created_accepted(
295        txid: TransactionId,
296        context: DynGlobalClientContext,
297        mut tx_submitted: watch::Receiver<bool>,
298    ) {
299        let _ = tx_submitted.wait_for(|submitted| *submitted).await;
300        context.api().await_transaction(txid).await;
301        debug!(target: LOG_CLIENT_NET_API, %txid, "Transaction accepted in consensus");
302    }
303}
304
305/// Why one attempt to submit a transaction did not settle it, so that it is
306/// submitted again.
307#[derive(Debug, thiserror::Error)]
308enum SubmitAttemptError {
309    /// A peer's answer to the submission could not be decoded.
310    #[error(transparent)]
311    Decode(#[from] DecodeError),
312
313    /// The peer accepted the submission, so the transaction is still valid;
314    /// it is submitted again until it is rejected or accepted in consensus.
315    #[error("Transaction is still valid")]
316    StillValid,
317}
318
319impl IntoDynInstance for TxSubmissionStatesSM {
320    type DynType = DynState;
321
322    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
323        DynState::from_typed(instance_id, self)
324    }
325}
326
327pub fn tx_submission_sm_decoder() -> Decoder {
328    let mut decoder_builder = Decoder::builder_system();
329    decoder_builder.with_decodable_type::<TxSubmissionStatesSM>();
330    decoder_builder.build()
331}