Skip to main content

nodedb_cluster/distributed_array/
scatter.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Fan-out helpers for array distributed operations.
4//!
5//! Two entry points:
6//! - `fan_out` — broadcast one request to all listed shards (slice, agg,
7//!   delete, surrogate scan).
8//! - `fan_out_partitioned` — send a different payload to each shard
9//!   (used by `coord_put` where cells are routed by Hilbert prefix).
10//!
11//! Both functions dispatch concurrently via `FuturesUnordered`, apply a
12//! per-shard `tokio::time::timeout`, and wrap each call in the cluster
13//! `CircuitBreaker`. Any shard failure is propagated as `Err` — partial
14//! results are not silently dropped.
15
16use std::sync::Arc;
17
18use futures::StreamExt;
19
20use crate::circuit_breaker::CircuitBreaker;
21use crate::error::{ClusterError, Result};
22use crate::wire::{VShardEnvelope, VShardMessageType};
23
24/// Dispatch one shard call with one automatic retry on `WrongOwner`.
25///
26/// When a shard returns `WrongOwner` it means the coordinator's routing table
27/// was stale at the time the request was built. The `ShardRpcDispatch`
28/// implementor holds a live reference to the `RoutingTable` (updated by the
29/// `CacheApplier` on every committed `RoutingChange`), so re-issuing the same
30/// envelope lets the implementor route it to the current owner without any
31/// explicit routing-refresh API call on the coordinator side. A second
32/// `WrongOwner` propagates as an error — we do not loop.
33async fn call_with_wrong_owner_retry(
34    dispatch: &Arc<dyn ShardRpcDispatch>,
35    env: VShardEnvelope,
36    timeout_ms: u64,
37) -> std::result::Result<VShardEnvelope, ClusterError> {
38    let result = tokio::time::timeout(
39        std::time::Duration::from_millis(timeout_ms),
40        dispatch.call(env.clone(), timeout_ms),
41    )
42    .await;
43
44    match result {
45        Ok(Ok(resp)) => return Ok(resp),
46        Ok(Err(ClusterError::WrongOwner { .. })) => {
47            // Retry once: the dispatch impl will re-read the live routing table.
48        }
49        Ok(Err(e)) => return Err(e),
50        Err(_elapsed) => {
51            return Err(ClusterError::Transport {
52                detail: format!(
53                    "array shard {}: RPC timed out after {timeout_ms}ms",
54                    env.vshard_id
55                ),
56            });
57        }
58    }
59
60    // Second attempt — propagate whatever error arises, including a second WrongOwner.
61    let result = tokio::time::timeout(
62        std::time::Duration::from_millis(timeout_ms),
63        dispatch.call(env.clone(), timeout_ms),
64    )
65    .await;
66
67    match result {
68        Ok(Ok(resp)) => Ok(resp),
69        Ok(Err(e)) => Err(e),
70        Err(_elapsed) => Err(ClusterError::Transport {
71            detail: format!(
72                "array shard {}: RPC timed out after {timeout_ms}ms (retry)",
73                env.vshard_id
74            ),
75        }),
76    }
77}
78
79use super::rpc::ShardRpcDispatch;
80
81/// Classify whether a shard error should count against the peer circuit breaker.
82///
83/// `WrongOwner` is a transient routing/ownership-churn signal, not a peer-health
84/// failure: during a rebalance or leadership cut-over a perfectly healthy shard
85/// answers `WrongOwner` until the coordinator's routing table catches up (the
86/// single retry in `call_with_wrong_owner_retry` already re-reads the live
87/// table). Counting it as a liveness failure would open the shared breaker and
88/// then fast-fail healthy shards' slice/put/agg/delete with `CircuitOpen`. Only
89/// `WrongOwner` is excluded — every genuine transport/timeout/unreachable error
90/// still counts, mirroring `RetryPolicy::is_retryable`'s conservative policy.
91fn counts_against_breaker(err: &ClusterError) -> bool {
92    !matches!(err, ClusterError::WrongOwner { .. })
93}
94
95/// Parameters governing a single fan-out round.
96pub struct FanOutParams {
97    /// Shard IDs to contact (broadcast target list).
98    pub shard_ids: Vec<u32>,
99    /// Per-shard RPC timeout in milliseconds.
100    pub timeout_ms: u64,
101    /// Source node ID (used to tag outgoing envelopes).
102    pub source_node: u64,
103}
104
105/// Parameters for a partitioned fan-out where each shard receives a
106/// different payload. `per_shard` entries are `(vshard_id, payload_bytes)`.
107pub struct FanOutPartitionedParams {
108    /// Per-shard RPC timeout in milliseconds.
109    pub timeout_ms: u64,
110    /// Source node ID (used to tag outgoing envelopes).
111    pub source_node: u64,
112}
113
114/// Send `req_bytes` to every shard listed in `params` and collect responses.
115///
116/// Each shard RPC runs concurrently via `FuturesUnordered`. Per-shard
117/// timeouts are enforced by `tokio::time::timeout`. The circuit breaker
118/// is checked before each call and updated on success/failure. Any shard
119/// error causes the whole fan-out to return `Err` — the coordinator
120/// decides whether to retry.
121///
122/// Returns `Vec<(shard_id, response_payload_bytes)>` in arrival order.
123pub async fn fan_out(
124    params: &FanOutParams,
125    opcode: u32,
126    req_bytes: &[u8],
127    dispatch: &Arc<dyn ShardRpcDispatch>,
128    circuit_breaker: &CircuitBreaker,
129) -> Result<Vec<(u32, Vec<u8>)>> {
130    if params.shard_ids.is_empty() {
131        return Ok(Vec::new());
132    }
133
134    // Build one future per shard and collect via FuturesUnordered for
135    // true concurrency (no sequential .await loop).
136    let mut futs = futures::stream::FuturesUnordered::new();
137
138    for &shard_id in &params.shard_ids {
139        // Circuit-breaker gate: treat shard_id as the peer identifier.
140        circuit_breaker.check(shard_id as u64)?;
141
142        let env = VShardEnvelope::new(
143            msg_type_from_opcode(opcode)?,
144            params.source_node,
145            0, // target_node resolved by the dispatch impl
146            shard_id,
147            req_bytes.to_vec(),
148        );
149        let timeout_ms = params.timeout_ms;
150        let dispatch = Arc::clone(dispatch);
151        let cb_shard = shard_id;
152
153        futs.push(async move {
154            match call_with_wrong_owner_retry(&dispatch, env, timeout_ms).await {
155                Ok(resp) => Ok((cb_shard, resp.payload)),
156                Err(e) => Err((cb_shard, e)),
157            }
158        });
159    }
160
161    let mut results = Vec::with_capacity(params.shard_ids.len());
162    while let Some(outcome) = futs.next().await {
163        match outcome {
164            Ok((shard_id, payload)) => {
165                circuit_breaker.record_success(shard_id as u64);
166                results.push((shard_id, payload));
167            }
168            Err((shard_id, e)) => {
169                if counts_against_breaker(&e) {
170                    circuit_breaker.record_failure(shard_id as u64);
171                }
172                return Err(e);
173            }
174        }
175    }
176
177    Ok(results)
178}
179
180/// Send a distinct payload to each shard and collect responses.
181///
182/// `per_shard` — `(vshard_id, payload_bytes)` pairs, one per target shard.
183/// Returns `(shard_id, response_payload_bytes)` in arrival order.
184pub async fn fan_out_partitioned(
185    params: &FanOutPartitionedParams,
186    opcode: u32,
187    per_shard: &[(u32, Vec<u8>)],
188    dispatch: &Arc<dyn ShardRpcDispatch>,
189    circuit_breaker: &CircuitBreaker,
190) -> Result<Vec<(u32, Vec<u8>)>> {
191    if per_shard.is_empty() {
192        return Ok(Vec::new());
193    }
194
195    let mut futs = futures::stream::FuturesUnordered::new();
196
197    for (shard_id, payload) in per_shard {
198        circuit_breaker.check(*shard_id as u64)?;
199
200        let env = VShardEnvelope::new(
201            msg_type_from_opcode(opcode)?,
202            params.source_node,
203            0,
204            *shard_id,
205            payload.clone(),
206        );
207        let timeout_ms = params.timeout_ms;
208        let dispatch = Arc::clone(dispatch);
209        let cb_shard = *shard_id;
210
211        futs.push(async move {
212            match call_with_wrong_owner_retry(&dispatch, env, timeout_ms).await {
213                Ok(resp) => Ok((cb_shard, resp.payload)),
214                Err(e) => Err((cb_shard, e)),
215            }
216        });
217    }
218
219    let mut results = Vec::with_capacity(per_shard.len());
220    while let Some(outcome) = futs.next().await {
221        match outcome {
222            Ok((shard_id, payload)) => {
223                circuit_breaker.record_success(shard_id as u64);
224                results.push((shard_id, payload));
225            }
226            Err((shard_id, e)) => {
227                if counts_against_breaker(&e) {
228                    circuit_breaker.record_failure(shard_id as u64);
229                }
230                return Err(e);
231            }
232        }
233    }
234
235    Ok(results)
236}
237
238/// Map an opcode constant to a `VShardMessageType`.
239///
240/// All array opcodes are in the range 80–89. Any other value is a
241/// programming error in the coordinator, not a runtime condition.
242fn msg_type_from_opcode(opcode: u32) -> Result<VShardMessageType> {
243    match opcode {
244        80 => Ok(VShardMessageType::ArrayShardSliceReq),
245        81 => Ok(VShardMessageType::ArrayShardSliceResp),
246        82 => Ok(VShardMessageType::ArrayShardAggReq),
247        83 => Ok(VShardMessageType::ArrayShardAggResp),
248        84 => Ok(VShardMessageType::ArrayShardPutReq),
249        85 => Ok(VShardMessageType::ArrayShardPutResp),
250        86 => Ok(VShardMessageType::ArrayShardDeleteReq),
251        87 => Ok(VShardMessageType::ArrayShardDeleteResp),
252        88 => Ok(VShardMessageType::ArrayShardSurrogateBitmapReq),
253        89 => Ok(VShardMessageType::ArrayShardSurrogateBitmapResp),
254        other => Err(ClusterError::Codec {
255            detail: format!("msg_type_from_opcode: unknown array opcode {other}"),
256        }),
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use std::sync::Arc;
263
264    use async_trait::async_trait;
265
266    use crate::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
267    use crate::error::{ClusterError, Result};
268    use crate::wire::VShardEnvelope;
269
270    use super::super::rpc::ShardRpcDispatch;
271    use super::{FanOutParams, FanOutPartitionedParams, fan_out, fan_out_partitioned};
272
273    /// Mock that echoes the request payload back as the response payload,
274    /// stamped with the shard's id in the response envelope.
275    struct EchoDispatch;
276
277    #[async_trait]
278    impl ShardRpcDispatch for EchoDispatch {
279        async fn call(&self, req: VShardEnvelope, _timeout_ms: u64) -> Result<VShardEnvelope> {
280            // Echo back a response envelope of the corresponding resp opcode.
281            let resp_opcode = req.msg_type as u32 + 1;
282            let resp_type = super::msg_type_from_opcode(resp_opcode)?;
283            Ok(VShardEnvelope::new(
284                resp_type,
285                req.target_node,
286                req.source_node,
287                req.vshard_id,
288                req.payload,
289            ))
290        }
291    }
292
293    /// Mock that always returns a transport error.
294    struct FailDispatch;
295
296    #[async_trait]
297    impl ShardRpcDispatch for FailDispatch {
298        async fn call(&self, _req: VShardEnvelope, _timeout_ms: u64) -> Result<VShardEnvelope> {
299            Err(ClusterError::Transport {
300                detail: "injected failure".into(),
301            })
302        }
303    }
304
305    fn cb() -> CircuitBreaker {
306        CircuitBreaker::new(CircuitBreakerConfig::default())
307    }
308
309    #[tokio::test]
310    async fn fan_out_broadcasts_to_all_shards() {
311        let dispatch: Arc<dyn ShardRpcDispatch> = Arc::new(EchoDispatch);
312        let params = FanOutParams {
313            shard_ids: vec![0, 1, 2],
314            timeout_ms: 1000,
315            source_node: 42,
316        };
317        let req_bytes = b"test-payload";
318        let results = fan_out(
319            &params,
320            super::super::opcodes::ARRAY_SHARD_SLICE_REQ,
321            req_bytes,
322            &dispatch,
323            &cb(),
324        )
325        .await
326        .expect("fan_out should succeed");
327
328        assert_eq!(results.len(), 3);
329        // All shards should echo back the same payload.
330        for (_, payload) in &results {
331            assert_eq!(payload.as_slice(), req_bytes);
332        }
333        // All three shard IDs should be present.
334        let mut ids: Vec<u32> = results.iter().map(|(id, _)| *id).collect();
335        ids.sort_unstable();
336        assert_eq!(ids, vec![0, 1, 2]);
337    }
338
339    #[tokio::test]
340    async fn fan_out_empty_shards_returns_empty() {
341        let dispatch: Arc<dyn ShardRpcDispatch> = Arc::new(EchoDispatch);
342        let params = FanOutParams {
343            shard_ids: vec![],
344            timeout_ms: 1000,
345            source_node: 1,
346        };
347        let results = fan_out(
348            &params,
349            super::super::opcodes::ARRAY_SHARD_SLICE_REQ,
350            b"",
351            &dispatch,
352            &cb(),
353        )
354        .await
355        .expect("empty fan_out should succeed");
356        assert!(results.is_empty());
357    }
358
359    #[tokio::test]
360    async fn fan_out_propagates_shard_error() {
361        let dispatch: Arc<dyn ShardRpcDispatch> = Arc::new(FailDispatch);
362        let params = FanOutParams {
363            shard_ids: vec![0, 1],
364            timeout_ms: 1000,
365            source_node: 1,
366        };
367        let err = fan_out(
368            &params,
369            super::super::opcodes::ARRAY_SHARD_SLICE_REQ,
370            b"",
371            &dispatch,
372            &cb(),
373        )
374        .await
375        .expect_err("fan_out should propagate shard failure");
376        assert!(
377            matches!(err, ClusterError::Transport { .. }),
378            "expected Transport error, got {err:?}"
379        );
380    }
381
382    #[tokio::test]
383    async fn fan_out_partitioned_dispatches_distinct_payloads() {
384        let dispatch: Arc<dyn ShardRpcDispatch> = Arc::new(EchoDispatch);
385        let params = FanOutPartitionedParams {
386            timeout_ms: 1000,
387            source_node: 1,
388        };
389        let per_shard = vec![
390            (0u32, b"shard0-data".to_vec()),
391            (1u32, b"shard1-data".to_vec()),
392        ];
393        let results = fan_out_partitioned(
394            &params,
395            super::super::opcodes::ARRAY_SHARD_PUT_REQ,
396            &per_shard,
397            &dispatch,
398            &cb(),
399        )
400        .await
401        .expect("fan_out_partitioned should succeed");
402
403        assert_eq!(results.len(), 2);
404        let mut sorted = results.clone();
405        sorted.sort_unstable_by_key(|(id, _)| *id);
406        assert_eq!(sorted[0].1, b"shard0-data");
407        assert_eq!(sorted[1].1, b"shard1-data");
408    }
409
410    #[tokio::test]
411    async fn circuit_breaker_open_blocks_fan_out() {
412        use crate::circuit_breaker::CircuitBreakerConfig;
413        use std::time::Duration;
414
415        let dispatch: Arc<dyn ShardRpcDispatch> = Arc::new(EchoDispatch);
416        let cb = CircuitBreaker::new(CircuitBreakerConfig {
417            failure_threshold: 1,
418            cooldown: Duration::from_secs(60),
419        });
420        // Trip the breaker for shard 0.
421        cb.record_failure(0);
422
423        let params = FanOutParams {
424            shard_ids: vec![0],
425            timeout_ms: 1000,
426            source_node: 1,
427        };
428        let err = fan_out(
429            &params,
430            super::super::opcodes::ARRAY_SHARD_SLICE_REQ,
431            b"",
432            &dispatch,
433            &cb,
434        )
435        .await
436        .expect_err("open circuit should block fan_out");
437        assert!(
438            matches!(err, ClusterError::CircuitOpen { .. }),
439            "expected CircuitOpen, got {err:?}"
440        );
441    }
442
443    // ── WrongOwner retry tests ────────────────────────────────────────────
444
445    use std::sync::atomic::{AtomicU32, Ordering};
446
447    /// Dispatch that returns `WrongOwner` exactly `fail_count` times, then
448    /// echoes the request payload back as a success.
449    struct WrongOwnerThenEchoDispatch {
450        call_count: Arc<AtomicU32>,
451        fail_count: u32,
452    }
453
454    #[async_trait]
455    impl ShardRpcDispatch for WrongOwnerThenEchoDispatch {
456        async fn call(&self, req: VShardEnvelope, _timeout_ms: u64) -> Result<VShardEnvelope> {
457            let n = self.call_count.fetch_add(1, Ordering::SeqCst);
458            if n < self.fail_count {
459                return Err(ClusterError::WrongOwner {
460                    vshard_id: req.vshard_id,
461                    expected_owner_node: None,
462                });
463            }
464            let resp_opcode = req.msg_type as u32 + 1;
465            let resp_type = super::msg_type_from_opcode(resp_opcode)?;
466            Ok(VShardEnvelope::new(
467                resp_type,
468                req.target_node,
469                req.source_node,
470                req.vshard_id,
471                req.payload,
472            ))
473        }
474    }
475
476    #[tokio::test]
477    async fn wrong_owner_triggers_retry_once() {
478        // First call returns WrongOwner; retry (second call) succeeds.
479        let call_count = Arc::new(AtomicU32::new(0));
480        let dispatch: Arc<dyn ShardRpcDispatch> = Arc::new(WrongOwnerThenEchoDispatch {
481            call_count: call_count.clone(),
482            fail_count: 1,
483        });
484        let params = FanOutParams {
485            shard_ids: vec![0],
486            timeout_ms: 1000,
487            source_node: 1,
488        };
489        let result = fan_out(
490            &params,
491            super::super::opcodes::ARRAY_SHARD_SLICE_REQ,
492            b"payload",
493            &dispatch,
494            &cb(),
495        )
496        .await
497        .expect("fan_out should succeed after one WrongOwner retry");
498
499        assert_eq!(result.len(), 1);
500        assert_eq!(
501            call_count.load(Ordering::SeqCst),
502            2,
503            "must have called dispatch twice"
504        );
505    }
506
507    #[tokio::test]
508    async fn wrong_owner_twice_propagates() {
509        // Both attempts return WrongOwner → fan_out surfaces the error.
510        let call_count = Arc::new(AtomicU32::new(0));
511        let dispatch: Arc<dyn ShardRpcDispatch> = Arc::new(WrongOwnerThenEchoDispatch {
512            call_count: call_count.clone(),
513            fail_count: 2,
514        });
515        let params = FanOutParams {
516            shard_ids: vec![0],
517            timeout_ms: 1000,
518            source_node: 1,
519        };
520        let err = fan_out(
521            &params,
522            super::super::opcodes::ARRAY_SHARD_SLICE_REQ,
523            b"payload",
524            &dispatch,
525            &cb(),
526        )
527        .await
528        .expect_err("fan_out should propagate WrongOwner when both attempts fail");
529
530        assert!(
531            matches!(err, ClusterError::WrongOwner { .. }),
532            "expected WrongOwner, got {err:?}"
533        );
534        assert_eq!(
535            call_count.load(Ordering::SeqCst),
536            2,
537            "must have called dispatch twice"
538        );
539    }
540
541    #[tokio::test]
542    async fn wrong_owner_does_not_trip_breaker() {
543        use crate::circuit_breaker::CircuitState;
544
545        // Both attempts return WrongOwner so the fan-out propagates the error,
546        // exercising the failure arm that would otherwise call record_failure.
547        let call_count = Arc::new(AtomicU32::new(0));
548        let dispatch: Arc<dyn ShardRpcDispatch> = Arc::new(WrongOwnerThenEchoDispatch {
549            call_count: call_count.clone(),
550            fail_count: 5, // more than the single retry can clear
551        });
552        let cb = cb();
553        let params = FanOutParams {
554            shard_ids: vec![0],
555            timeout_ms: 1000,
556            source_node: 1,
557        };
558        let err = fan_out(
559            &params,
560            super::super::opcodes::ARRAY_SHARD_SLICE_REQ,
561            b"payload",
562            &dispatch,
563            &cb,
564        )
565        .await
566        .expect_err("fan_out should propagate WrongOwner");
567
568        assert!(
569            matches!(err, ClusterError::WrongOwner { .. }),
570            "expected WrongOwner, got {err:?}"
571        );
572        // WrongOwner is a routing signal, not a health failure: the breaker
573        // must not have recorded a failure for the shard.
574        assert_eq!(
575            cb.failure_count(0),
576            0,
577            "WrongOwner must not increment the breaker failure count"
578        );
579        assert_eq!(
580            cb.state(0),
581            CircuitState::Closed,
582            "WrongOwner must leave the breaker Closed"
583        );
584    }
585}