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