Skip to main content

fedimint_api_client/
query.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::Debug;
3use std::mem;
4
5use fedimint_connectors::ServerResult;
6use fedimint_connectors::error::ServerError;
7use fedimint_core::task::{MaybeSend, MaybeSync};
8use fedimint_core::{NumPeers, PeerId, maybe_add_send_sync};
9
10/// Fedimint query strategy
11///
12/// Due to federated security model each Fedimint client API call to the
13/// Federation might require a different way to process one or more required
14/// responses from the Federation members. This trait abstracts away the details
15/// of each specific strategy for the generic client Api code.
16pub trait QueryStrategy<IR, OR = IR> {
17    fn process(&mut self, peer_id: PeerId, response: IR) -> QueryStep<OR>;
18
19    /// Called when a peer's request fails, so a strategy can tell "still
20    /// waiting" apart from "will never answer".
21    ///
22    /// Defaults to [`QueryStep::Continue`], leaving failed peers entirely to
23    /// the caller's error accounting, which is what every strategy other than
24    /// [`ThresholdAgreement`] wants.
25    fn process_error(&mut self, _peer_id: PeerId, _error: &ServerError) -> QueryStep<OR> {
26        QueryStep::Continue
27    }
28}
29
30/// Results from the strategy handling a response from a peer
31///
32/// Note that the implementation driving the [`QueryStrategy`] returning
33/// [`QueryStep`] is responsible from remembering and collecting errors
34/// for each peer.
35#[derive(Debug)]
36pub enum QueryStep<R> {
37    /// Retry requests to this peers
38    Retry(BTreeSet<PeerId>),
39    /// Do nothing yet, keep waiting for requests
40    Continue,
41    /// Return the successful result
42    Success(R),
43    /// A non-retryable failure has occurred
44    Failure(ServerError),
45}
46
47/// Returns when we obtain the first valid responses. RPC call errors or
48/// invalid responses are not retried.
49pub struct FilterMap<R, T> {
50    filter_map: Box<maybe_add_send_sync!(dyn Fn(R) -> ServerResult<T>)>,
51}
52
53impl<R, T> FilterMap<R, T> {
54    pub fn new(
55        filter_map: impl Fn(R) -> ServerResult<T> + MaybeSend + MaybeSync + 'static,
56    ) -> Self {
57        Self {
58            filter_map: Box::new(filter_map),
59        }
60    }
61}
62
63impl<R, T> QueryStrategy<R, T> for FilterMap<R, T> {
64    fn process(&mut self, _peer: PeerId, response: R) -> QueryStep<T> {
65        match (self.filter_map)(response) {
66            Ok(value) => QueryStep::Success(value),
67            Err(e) => QueryStep::Failure(e),
68        }
69    }
70}
71
72/// Returns when we obtain a threshold of valid responses. RPC call errors or
73/// invalid responses are not retried.
74pub struct FilterMapThreshold<R, T> {
75    filter_map: Box<maybe_add_send_sync!(dyn Fn(PeerId, R) -> ServerResult<T>)>,
76    filtered_responses: BTreeMap<PeerId, T>,
77    threshold: usize,
78}
79
80impl<R, T> FilterMapThreshold<R, T> {
81    pub fn new(
82        verifier: impl Fn(PeerId, R) -> ServerResult<T> + MaybeSend + MaybeSync + 'static,
83        num_peers: NumPeers,
84    ) -> Self {
85        Self {
86            filter_map: Box::new(verifier),
87            filtered_responses: BTreeMap::new(),
88            threshold: num_peers.threshold(),
89        }
90    }
91}
92
93impl<R, T> QueryStrategy<R, BTreeMap<PeerId, T>> for FilterMapThreshold<R, T> {
94    fn process(&mut self, peer: PeerId, response: R) -> QueryStep<BTreeMap<PeerId, T>> {
95        match (self.filter_map)(peer, response) {
96            Ok(response) => {
97                self.filtered_responses.insert(peer, response);
98
99                if self.filtered_responses.len() == self.threshold {
100                    QueryStep::Success(mem::take(&mut self.filtered_responses))
101                } else {
102                    QueryStep::Continue
103                }
104            }
105            Err(e) => QueryStep::Failure(e),
106        }
107    }
108}
109
110/// Returns when we obtain a threshold of identical responses. Responses are not
111/// assumed to be static and may be updated by the peers; on failure to
112/// establish consensus with a threshold of responses, we retry the requests.
113/// RPC call errors are not retried.
114pub struct ThresholdConsensus<R> {
115    responses: BTreeMap<PeerId, R>,
116    retry: BTreeSet<PeerId>,
117    threshold: usize,
118}
119
120impl<R> ThresholdConsensus<R> {
121    pub fn new(num_peers: NumPeers) -> Self {
122        Self {
123            responses: BTreeMap::new(),
124            retry: BTreeSet::new(),
125            threshold: num_peers.threshold(),
126        }
127    }
128}
129
130impl<R: Eq + Clone> QueryStrategy<R> for ThresholdConsensus<R> {
131    fn process(&mut self, peer: PeerId, response: R) -> QueryStep<R> {
132        self.responses.insert(peer, response.clone());
133
134        if self.responses.values().filter(|r| **r == response).count() == self.threshold {
135            return QueryStep::Success(response);
136        }
137
138        assert!(self.retry.insert(peer));
139
140        if self.retry.len() == self.threshold {
141            QueryStep::Retry(mem::take(&mut self.retry))
142        } else {
143            QueryStep::Continue
144        }
145    }
146}
147
148#[cfg(test)]
149fn dead_peer() -> ServerError {
150    ServerError::Connection(anyhow::anyhow!("peer is unreachable"))
151}
152
153#[test]
154fn threshold_agreement_counts_every_peer() {
155    use assert_matches::assert_matches;
156
157    // The case `FilterMapThreshold` gets wrong: peer 0 lags, and the agreement
158    // among 1, 2 and 3 only becomes visible once the fourth answer lands. A
159    // strategy that stopped at `threshold` responses would have reported a
160    // divergence that does not exist.
161    let mut agreement = ThresholdAgreement::<u64>::new(NumPeers::from(4));
162
163    assert_matches!(agreement.process(PeerId::from(0), 0), QueryStep::Continue);
164    assert_matches!(agreement.process(PeerId::from(1), 1), QueryStep::Continue);
165    assert_matches!(agreement.process(PeerId::from(3), 1), QueryStep::Continue);
166    assert_matches!(
167        agreement.process(PeerId::from(2), 1),
168        QueryStep::Success(Ok(1))
169    );
170}
171
172#[test]
173fn threshold_agreement_reports_divergence_once_every_peer_has_answered() {
174    use assert_matches::assert_matches;
175
176    let mut agreement = ThresholdAgreement::<u64>::new(NumPeers::from(4));
177
178    assert_matches!(agreement.process(PeerId::from(0), 0), QueryStep::Continue);
179    assert_matches!(agreement.process(PeerId::from(1), 1), QueryStep::Continue);
180    assert_matches!(agreement.process(PeerId::from(2), 2), QueryStep::Continue);
181
182    let QueryStep::Success(Err(responses)) = agreement.process(PeerId::from(3), 3) else {
183        panic!("expected a divergence carrying every response");
184    };
185    assert_eq!(responses.len(), 4);
186}
187
188#[test]
189fn threshold_agreement_does_not_wait_on_a_peer_that_errored() {
190    use assert_matches::assert_matches;
191
192    // A failed peer completes the picture just as a response does, so the
193    // divergence is reported rather than waiting on an answer that is never
194    // coming - the hang this strategy exists to avoid.
195    let mut agreement = ThresholdAgreement::<u64>::new(NumPeers::from(4));
196
197    assert_matches!(agreement.process(PeerId::from(0), 0), QueryStep::Continue);
198    assert_matches!(agreement.process(PeerId::from(1), 1), QueryStep::Continue);
199    assert_matches!(agreement.process(PeerId::from(2), 1), QueryStep::Continue);
200
201    let QueryStep::Success(Err(responses)) = agreement.process_error(PeerId::from(3), &dead_peer())
202    else {
203        panic!("expected a divergence once every peer has answered");
204    };
205    assert_eq!(responses.len(), 3);
206}
207
208#[test]
209fn threshold_agreement_defers_to_peer_errors_when_too_few_answered() {
210    use assert_matches::assert_matches;
211
212    // Two of four unreachable leaves fewer responses than the threshold. The
213    // useful complaint is that peers are down, which the caller reports from
214    // its own error accounting, so stay quiet.
215    let mut agreement = ThresholdAgreement::<u64>::new(NumPeers::from(4));
216
217    assert_matches!(agreement.process(PeerId::from(0), 0), QueryStep::Continue);
218    assert_matches!(agreement.process(PeerId::from(1), 1), QueryStep::Continue);
219    assert_matches!(
220        agreement.process_error(PeerId::from(2), &dead_peer()),
221        QueryStep::Continue
222    );
223    assert_matches!(
224        agreement.process_error(PeerId::from(3), &dead_peer()),
225        QueryStep::Continue
226    );
227}
228
229/// Returns the response a threshold of peers agree on, or - when they do not
230/// converge - every answer received, as `Err`.
231///
232/// Like [`ThresholdConsensus`] it counts identical responses across *all*
233/// peers rather than the first `threshold` to reply, so one lagging peer
234/// cannot mask an agreement that exists among the others.
235///
236/// Unlike it, a disagreement is never retried. Values worth querying this way
237/// are a pure function of the ordered consensus log, so a peer that has fallen
238/// behind never converges, and re-requesting it renews the transport timeout
239/// indefinitely. Asking each peer exactly once is what bounds the call.
240pub struct ThresholdAgreement<R> {
241    responses: BTreeMap<PeerId, R>,
242    errors: usize,
243    threshold: usize,
244    total: usize,
245}
246
247impl<R> ThresholdAgreement<R> {
248    pub fn new(num_peers: NumPeers) -> Self {
249        Self {
250            responses: BTreeMap::new(),
251            errors: 0,
252            threshold: num_peers.threshold(),
253            total: num_peers.total(),
254        }
255    }
256
257    /// Every peer has answered one way or the other without any value reaching
258    /// a threshold, so waiting longer cannot help.
259    fn diverged(&mut self) -> Option<QueryStep<Result<R, BTreeMap<PeerId, R>>>> {
260        if self.responses.len() + self.errors < self.total {
261            return None;
262        }
263
264        // Below a threshold of responses the useful complaint is that too few
265        // peers answered, not that they disagreed. Stay quiet and let the
266        // caller report the peer errors it collected.
267        if self.responses.len() < self.threshold {
268            return None;
269        }
270
271        Some(QueryStep::Success(Err(mem::take(&mut self.responses))))
272    }
273}
274
275impl<R: Eq + Clone> QueryStrategy<R, Result<R, BTreeMap<PeerId, R>>> for ThresholdAgreement<R> {
276    fn process(&mut self, peer: PeerId, response: R) -> QueryStep<Result<R, BTreeMap<PeerId, R>>> {
277        self.responses.insert(peer, response.clone());
278
279        if self.responses.values().filter(|r| **r == response).count() == self.threshold {
280            return QueryStep::Success(Ok(response));
281        }
282
283        self.diverged().unwrap_or(QueryStep::Continue)
284    }
285
286    fn process_error(
287        &mut self,
288        _peer: PeerId,
289        _error: &ServerError,
290    ) -> QueryStep<Result<R, BTreeMap<PeerId, R>>> {
291        self.errors += 1;
292
293        self.diverged().unwrap_or(QueryStep::Continue)
294    }
295}
296
297#[test]
298fn test_threshold_consensus() {
299    use assert_matches::assert_matches;
300
301    let mut consensus = ThresholdConsensus::<u64>::new(NumPeers::from(4));
302
303    assert_matches!(consensus.process(PeerId::from(0), 1), QueryStep::Continue);
304    assert_matches!(consensus.process(PeerId::from(1), 1), QueryStep::Continue);
305    assert_matches!(consensus.process(PeerId::from(2), 0), QueryStep::Retry(..));
306
307    assert_matches!(consensus.process(PeerId::from(0), 1), QueryStep::Continue);
308    assert_matches!(consensus.process(PeerId::from(1), 1), QueryStep::Continue);
309    assert_matches!(consensus.process(PeerId::from(2), 1), QueryStep::Success(1));
310}