Skip to main content

dynomite/cluster/
dispatch.rs

1//! Cluster-aware [`Dispatcher`].
2//!
3//! Routes parsed [`Msg`]s based on the configured consistency level
4//! and the [`crate::cluster::pool::ServerPool`] topology:
5//!
6//! * `DC_ONE` reads pick the rack-local replica via the snitch.
7//! * `DC_ONE` writes fan out to every replica in the local DC.
8//! * `DC_QUORUM` / `DC_SAFE_QUORUM` reads fan out to every replica
9//!   in the local DC.
10//! * `DC_EACH_SAFE_QUORUM` writes fan out per-DC, walking the
11//!   per-DC racks via the preselected rack from
12//!   [`crate::cluster::pool::ServerPool::preselect_remote_racks`].
13//!
14//! The actual outbound delivery happens through the per-peer
15//! [`crate::net::ConnPool`]s; this module produces a
16//! [`DispatchPlan`] (the list of replica peers a request must be
17//! routed to) and exposes the planning logic so it can be tested
18//! independently of the runtime fan-out.
19//!
20//! # Examples
21//!
22//! ```
23//! use dynomite::cluster::dispatch::{ClusterDispatcher, DispatchPlan};
24//! use dynomite::cluster::pool::{PoolConfig, ServerPool};
25//! use dynomite::cluster::peer::{Peer, PeerEndpoint};
26//! use dynomite::hashkit::DynToken;
27//! use dynomite::msg::{Msg, MsgType};
28//! use std::sync::Arc;
29//!
30//! let cfg = PoolConfig {
31//!     dc: "d".into(), rack: "r".into(),
32//!     ..PoolConfig::default()
33//! };
34//! let local = Peer::new(
35//!     0, PeerEndpoint::tcp("h".into(), 1), "r".into(), "d".into(),
36//!     vec![DynToken::from_u32(0)], true, true, false,
37//! );
38//! let pool = Arc::new(ServerPool::new(cfg, vec![local]));
39//! let disp = ClusterDispatcher::new(pool);
40//! let req = Msg::new(1, MsgType::ReqRedisGet, true);
41//! let plan = disp.plan(&req, b"foo");
42//! assert!(matches!(plan, DispatchPlan::LocalDatastore));
43//! ```
44
45use std::sync::atomic::{AtomicU64, Ordering};
46use std::sync::Arc;
47
48use tokio::sync::mpsc;
49
50use crate::cluster::pool::ServerPool;
51use crate::cluster::snitch::{rack_distance, RackDistance};
52use crate::cluster::vnode;
53use crate::conf::HashType as ConfHashType;
54use crate::hashkit::{self, HashType};
55use crate::io::mbuf::MbufPool;
56use crate::msg::{ConsistencyLevel, Msg, MsgRouting, MsgType};
57use crate::net::dispatcher::{DispatchOutcome, Dispatcher, OutboundEnvelope, ServerSink};
58use crate::net::server::OutboundRequest;
59
60/// Process-global Prometheus-friendly counter of
61/// shadow-distribution disagreements. The dispatcher bumps this
62/// counter every time the configured `distribution` and the
63/// configured `distribution_shadow` choose different peers for
64/// the same key. Exposed for both the stats endpoint and the
65/// integration test that exercises shadow mode.
66///
67/// # Examples
68///
69/// ```
70/// use dynomite::cluster::dispatch::distribution_shadow_disagreement_total;
71/// let _seen = distribution_shadow_disagreement_total();
72/// ```
73#[must_use]
74pub fn distribution_shadow_disagreement_total() -> u64 {
75    SHADOW_DISAGREEMENTS.load(Ordering::Relaxed)
76}
77
78/// Reset the shadow-disagreement counter. Used by integration
79/// tests that need a clean baseline; never called from
80/// production code.
81///
82/// # Examples
83///
84/// ```
85/// use dynomite::cluster::dispatch::reset_distribution_shadow_disagreement_total;
86/// reset_distribution_shadow_disagreement_total();
87/// ```
88pub fn reset_distribution_shadow_disagreement_total() {
89    SHADOW_DISAGREEMENTS.store(0, Ordering::Relaxed);
90}
91
92static SHADOW_DISAGREEMENTS: AtomicU64 = AtomicU64::new(0);
93
94fn bump_shadow_disagreement() {
95    SHADOW_DISAGREEMENTS.fetch_add(1, Ordering::Relaxed);
96}
97
98/// Build the `dispatch.plan` info span and enter it. Returns the
99/// originating client request span (captured before the plan
100/// span was entered) plus the entered plan-span guard. Factored
101/// out so [`ClusterDispatcher::dispatch`] stays inside the
102/// project's per-function line budget.
103fn enter_plan_span(
104    req_id: u64,
105    plan: &DispatchPlan,
106) -> (tracing::Span, tracing::span::EnteredSpan) {
107    let req_span = tracing::Span::current();
108    let kind: &'static str = match plan {
109        DispatchPlan::Drop => "drop",
110        DispatchPlan::NoTargets => "no_targets",
111        DispatchPlan::LocalDatastore => "local_datastore",
112        DispatchPlan::Replicas { .. } => "replicas",
113    };
114    let targets = match plan {
115        DispatchPlan::Replicas { targets, .. } => targets.len(),
116        _ => 0,
117    };
118    let span = tracing::info_span!("dispatch.plan", req_id, plan = kind, targets,).entered();
119    (req_span, span)
120}
121
122fn map_hash(h: ConfHashType) -> HashType {
123    match h {
124        ConfHashType::OneAtATime => HashType::OneAtATime,
125        ConfHashType::Md5 => HashType::Md5,
126        ConfHashType::Crc16 => HashType::Crc16,
127        ConfHashType::Crc32 => HashType::Crc32,
128        ConfHashType::Crc32a => HashType::Crc32a,
129        ConfHashType::Fnv1_64 => HashType::Fnv1_64,
130        ConfHashType::Fnv1a64 => HashType::Fnv1a_64,
131        ConfHashType::Fnv1_32 => HashType::Fnv1_32,
132        ConfHashType::Fnv1a32 => HashType::Fnv1a_32,
133        ConfHashType::Hsieh => HashType::Hsieh,
134        ConfHashType::Murmur => HashType::Murmur,
135        ConfHashType::Jenkins => HashType::Jenkins,
136        ConfHashType::Murmur3 => HashType::Murmur3,
137        ConfHashType::Murmur3X64_64 => HashType::Murmur3X64_64,
138    }
139}
140
141/// One replica target produced by [`ClusterDispatcher::plan`].
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct ReplicaTarget {
144    /// Index of the target peer in the pool's peer array.
145    pub peer_idx: u32,
146    /// Datacenter name.
147    pub dc: String,
148    /// Rack name.
149    pub rack: String,
150    /// True when the target is the local node.
151    pub is_local: bool,
152}
153
154/// Dispatch plan produced by the cluster dispatcher.
155///
156/// `LocalDatastore` is the early-return branch the reference
157/// engine takes when the routing tag is `ROUTING_LOCAL_NODE_ONLY`
158/// (or when the request is destined for the local node and the
159/// topology has only one peer); the per-connection driver then
160/// hands the request off to its server-side connection pool.
161#[derive(Clone, Debug, Eq, PartialEq)]
162pub enum DispatchPlan {
163    /// Hand the request straight to the local datastore.
164    LocalDatastore,
165    /// Forward to one or more peer replicas. The carried
166    /// consistency level is the one the planner resolved for
167    /// this request (after applying any bucket-type override),
168    /// so the dispatcher's reply coalescer does not have to
169    /// re-resolve it.
170    Replicas {
171        /// Replica peers the request must be routed to.
172        targets: Vec<ReplicaTarget>,
173        /// Resolved consistency level.
174        consistency: ConsistencyLevel,
175    },
176    /// Reply with an error: the cluster has no quorum-eligible
177    /// targets.
178    NoTargets,
179    /// Drop the request (`QUIT`-style swallow).
180    Drop,
181}
182
183/// Cluster-aware dispatcher.
184#[derive(Clone)]
185pub struct ClusterDispatcher {
186    pool: Arc<ServerPool>,
187    /// Outbound channel feeding the local datastore driver. When
188    /// `None`, `LocalDatastore` plans short-circuit to `Pending`
189    /// without forwarding (used by tests that do not need a real
190    /// backend). When set, requests for the local node are
191    /// encoded onto the wire and shipped to the [`crate::net::ServerConn`]
192    /// task that drives the redis / memcache backend.
193    backend: Option<mpsc::Sender<OutboundRequest>>,
194    /// Per-peer outbound channel for cross-DC fan-out. Keyed by
195    /// `Peer::idx`. When a `DispatchPlan::Replicas` plan names a
196    /// non-local peer, the dispatcher forwards via the matching
197    /// channel to a `DnodeServerConn` task. Peers without a
198    /// wired channel are skipped (`Pending`); when no replica
199    /// is reachable for the consistency level the dispatcher
200    /// falls back to a `DynomiteNoQuorumAchieved` error response.
201    peer_backends: std::collections::HashMap<u32, mpsc::Sender<OutboundRequest>>,
202    /// Mbuf pool used to render synthetic error payloads.
203    /// `MbufPool` already wraps an `Arc`, so cloning the
204    /// dispatcher (and the pool with it) shares the same free
205    /// list across every cluster handle.
206    mbuf_pool: MbufPool,
207    /// Optional node-local hint store. When set AND the pool's
208    /// `enable_hinted_handoff` flag is true, write requests
209    /// targeted at peers in [`crate::cluster::peer::PeerState::Down`]
210    /// (or at peers whose outbound channel is closed / full) are
211    /// recorded as hints and counted toward the consistency
212    /// threshold, instead of being silently skipped.
213    hint_store: Option<Arc<crate::cluster::hints::HintStore>>,
214    /// Optional failure-cause metrics handle. When wired, every
215    /// error-producing branch in the dispatcher increments the
216    /// matching counter via the [`crate::stats::FailureMetrics`]
217    /// accumulator. When `None`, the dispatcher's behaviour is
218    /// unchanged.
219    failure_metrics: Option<Arc<crate::stats::FailureMetrics>>,
220    /// Optional command-dispatch extension. When set, the
221    /// dispatcher offers FT.* / HSET requests to the extension
222    /// before the routing planner runs; the extension may
223    /// short-circuit with a synthesised reply
224    /// ([`crate::net::DispatchOutcome::Inline`]), reject the
225    /// request with a structured error
226    /// ([`crate::net::DispatchOutcome::Error`]), or fall
227    /// through to the standard storage path. When `None`, the
228    /// dispatcher's behaviour is unchanged. See
229    /// [`crate::embed::CommandExtension`] for the trait shape.
230    command_extension: Option<Arc<dyn crate::embed::CommandExtension>>,
231    /// Optional in-process local datastore hook. When set, a
232    /// [`DispatchPlan::LocalDatastore`] request is handed to this
233    /// [`crate::embed::Datastore`] (the parsed request `Msg`,
234    /// asynchronously) instead of being relayed over the
235    /// [`Self::backend`] byte channel. The embedded server wires
236    /// this so a connection accepted on its `listen:` socket is
237    /// served through the same `Datastore` hook that
238    /// `ServerHandle::inject_request` uses. When `None`, the
239    /// backend byte channel is used (the standalone proxy path).
240    local_datastore: Option<Arc<dyn crate::embed::hooks::Datastore>>,
241}
242
243impl std::fmt::Debug for ClusterDispatcher {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        // `Datastore` is not `Debug` (it is a public embedding
246        // trait and must stay object-safe without a Debug bound),
247        // so the local-datastore hook is reported as a presence
248        // flag rather than its contents.
249        f.debug_struct("ClusterDispatcher")
250            .field("backend", &self.backend.is_some())
251            .field("peer_backends", &self.peer_backends.len())
252            .field("hint_store", &self.hint_store.is_some())
253            .field("failure_metrics", &self.failure_metrics.is_some())
254            .field("command_extension", &self.command_extension)
255            .field("local_datastore", &self.local_datastore.is_some())
256            .finish_non_exhaustive()
257    }
258}
259
260impl ClusterDispatcher {
261    /// Wrap a [`ServerPool`] in a dispatcher.
262    ///
263    /// # Examples
264    ///
265    /// ```
266    /// # use dynomite::cluster::dispatch::ClusterDispatcher;
267    /// # use dynomite::cluster::pool::{PoolConfig, ServerPool};
268    /// # use dynomite::cluster::peer::{Peer, PeerEndpoint};
269    /// # use dynomite::hashkit::DynToken;
270    /// # use std::sync::Arc;
271    /// # let cfg = PoolConfig::default();
272    /// # let local = Peer::new(
273    /// #    0, PeerEndpoint::tcp("h".into(), 1), "r".into(), "d".into(),
274    /// #    vec![DynToken::from_u32(0)], true, true, false,
275    /// # );
276    /// let pool = Arc::new(ServerPool::new(cfg, vec![local]));
277    /// let disp = ClusterDispatcher::new(pool);
278    /// let _ = disp.pool();
279    /// ```
280    #[must_use]
281    pub fn new(pool: Arc<ServerPool>) -> Self {
282        Self {
283            pool,
284            backend: None,
285            peer_backends: std::collections::HashMap::new(),
286            mbuf_pool: MbufPool::default(),
287            hint_store: None,
288            failure_metrics: None,
289            command_extension: None,
290            local_datastore: None,
291        }
292    }
293
294    /// Override the dispatcher's mbuf pool. Useful when the
295    /// embedding wants every synthetic error payload to come from
296    /// the same recycled buffers as the rest of the engine.
297    ///
298    /// # Examples
299    ///
300    /// ```
301    /// # use dynomite::cluster::dispatch::ClusterDispatcher;
302    /// # use dynomite::cluster::pool::{PoolConfig, ServerPool};
303    /// # use dynomite::cluster::peer::{Peer, PeerEndpoint};
304    /// # use dynomite::hashkit::DynToken;
305    /// # use dynomite::io::mbuf::MbufPool;
306    /// # use std::sync::Arc;
307    /// # let cfg = PoolConfig::default();
308    /// # let local = Peer::new(
309    /// #    0, PeerEndpoint::tcp("h".into(), 1), "r".into(), "d".into(),
310    /// #    vec![DynToken::from_u32(0)], true, true, false,
311    /// # );
312    /// let pool = Arc::new(ServerPool::new(cfg, vec![local]));
313    /// let _disp = ClusterDispatcher::new(pool).with_mbuf_pool(MbufPool::default());
314    /// ```
315    #[must_use]
316    pub fn with_mbuf_pool(mut self, pool: MbufPool) -> Self {
317        self.mbuf_pool = pool;
318        self
319    }
320
321    /// Borrow the dispatcher's mbuf pool. Exposed so embedders
322    /// can reuse the same pool when building synthetic responses
323    /// outside the dispatcher's own code paths.
324    #[must_use]
325    pub fn mbuf_pool(&self) -> &MbufPool {
326        &self.mbuf_pool
327    }
328
329    /// Attach a backend request channel. Calls to [`Self::dispatch`]
330    /// that produce a [`DispatchPlan::LocalDatastore`] plan will
331    /// forward the request bytes onto this channel for the local
332    /// datastore driver to write to the backend.
333    ///
334    /// The channel sender must be the request side of a
335    /// [`crate::net::ServerConn`] task; multiple senders cloned from
336    /// the same channel are fine.
337    #[must_use]
338    pub fn with_backend(mut self, backend: mpsc::Sender<OutboundRequest>) -> Self {
339        self.backend = Some(backend);
340        self
341    }
342
343    /// Attach an in-process local datastore hook. A
344    /// [`DispatchPlan::LocalDatastore`] request is then handed to
345    /// the supplied [`crate::embed::hooks::Datastore`] (the parsed
346    /// request `Msg`) and its reply is delivered on the
347    /// connection's responder, instead of being relayed over the
348    /// [`Self::with_backend`] byte channel. The embedded server
349    /// wires this so a client accepted on its `listen:` socket is
350    /// served through the same `Datastore` hook that
351    /// `ServerHandle::inject_request` uses. Takes precedence over
352    /// the backend channel for local requests.
353    #[must_use]
354    pub fn with_local_datastore(
355        mut self,
356        datastore: Arc<dyn crate::embed::hooks::Datastore>,
357    ) -> Self {
358        self.local_datastore = Some(datastore);
359        self
360    }
361
362    /// Attach an outbound channel for a single peer (by
363    /// `Peer::idx`). The supplied sender feeds a
364    /// [`crate::net::DnodeServerConn`] task that writes
365    /// dnode-framed requests to the peer's `dyn_listen` and
366    /// routes the response back through the per-request
367    /// responder channel.
368    ///
369    /// Wiring is additive: call this once per non-local peer.
370    /// Calling it again with the same `peer_idx` replaces the
371    /// previous sender (used by reconnect supervisors that
372    /// rebuild channels on restart).
373    #[must_use]
374    pub fn with_peer_backend(
375        mut self,
376        peer_idx: u32,
377        sender: mpsc::Sender<OutboundRequest>,
378    ) -> Self {
379        self.peer_backends.insert(peer_idx, sender);
380        self
381    }
382
383    /// Whether a backend channel is wired.
384    #[must_use]
385    pub fn has_backend(&self) -> bool {
386        self.backend.is_some()
387    }
388
389    /// Number of peer-backend channels wired.
390    #[must_use]
391    pub fn peer_backend_count(&self) -> usize {
392        self.peer_backends.len()
393    }
394
395    /// Borrow the underlying pool.
396    #[must_use]
397    pub fn pool(&self) -> &Arc<ServerPool> {
398        &self.pool
399    }
400
401    /// Attach a [`crate::cluster::hints::HintStore`].
402    ///
403    /// When set AND the pool's `enable_hinted_handoff` flag is
404    /// `true`, write requests targeted at peers in
405    /// [`crate::cluster::peer::PeerState::Down`] (or at peers
406    /// whose outbound channel is closed / full) are stored as
407    /// hints and counted toward the consistency threshold. The
408    /// background drainer task in `dynomited` is responsible
409    /// for shipping the hints back to the peer once it returns
410    /// to [`crate::cluster::peer::PeerState::Normal`]. Without
411    /// this builder call (or with `enable_hinted_handoff: false`)
412    /// the dispatcher behaviour is unchanged: a Down or
413    /// unreachable target is silently skipped.
414    ///
415    /// # Examples
416    ///
417    /// ```
418    /// # use std::sync::Arc;
419    /// # use dynomite::cluster::dispatch::ClusterDispatcher;
420    /// # use dynomite::cluster::hints::HintStore;
421    /// # use dynomite::cluster::peer::{Peer, PeerEndpoint};
422    /// # use dynomite::cluster::pool::{PoolConfig, ServerPool};
423    /// # use dynomite::hashkit::DynToken;
424    /// let cfg = PoolConfig { enable_hinted_handoff: true, ..PoolConfig::default() };
425    /// let local = Peer::new(
426    ///     0, PeerEndpoint::tcp("h".into(), 1), "r".into(), "d".into(),
427    ///     vec![DynToken::from_u32(0)], true, true, false,
428    /// );
429    /// let pool = Arc::new(ServerPool::new(cfg, vec![local]));
430    /// let store = Arc::new(HintStore::new(64 * 1024 * 1024));
431    /// let _disp = ClusterDispatcher::new(pool).with_hint_store(store);
432    /// ```
433    #[must_use]
434    pub fn with_hint_store(mut self, store: Arc<crate::cluster::hints::HintStore>) -> Self {
435        self.hint_store = Some(store);
436        self
437    }
438
439    /// Borrow the wired hint store, if any.
440    #[must_use]
441    pub fn hint_store(&self) -> Option<&Arc<crate::cluster::hints::HintStore>> {
442        self.hint_store.as_ref()
443    }
444
445    /// Attach a [`crate::stats::FailureMetrics`] handle.
446    ///
447    /// When wired, each error-producing branch in the
448    /// dispatcher (no-targets, peer-channel-full,
449    /// peer-channel-closed, backend-channel-full,
450    /// backend-channel-closed, response-timeout) increments
451    /// the matching counter so an operator can pull the
452    /// per-cause histogram off the `/stats` and `/metrics`
453    /// endpoints. The default behaviour is unchanged when no
454    /// metrics handle is supplied.
455    ///
456    /// # Examples
457    ///
458    /// ```
459    /// # use std::sync::Arc;
460    /// # use dynomite::cluster::dispatch::ClusterDispatcher;
461    /// # use dynomite::cluster::peer::{Peer, PeerEndpoint};
462    /// # use dynomite::cluster::pool::{PoolConfig, ServerPool};
463    /// # use dynomite::hashkit::DynToken;
464    /// # use dynomite::stats::FailureMetrics;
465    /// let cfg = PoolConfig::default();
466    /// let local = Peer::new(
467    ///     0, PeerEndpoint::tcp("h".into(), 1), "r".into(), "d".into(),
468    ///     vec![DynToken::from_u32(0)], true, true, false,
469    /// );
470    /// let pool = Arc::new(ServerPool::new(cfg, vec![local]));
471    /// let metrics = Arc::new(FailureMetrics::new());
472    /// let _disp = ClusterDispatcher::new(pool).with_failure_metrics(metrics);
473    /// ```
474    #[must_use]
475    pub fn with_failure_metrics(mut self, metrics: Arc<crate::stats::FailureMetrics>) -> Self {
476        self.failure_metrics = Some(metrics);
477        self
478    }
479
480    /// Borrow the wired failure-metrics handle, if any.
481    #[must_use]
482    pub fn failure_metrics(&self) -> Option<&Arc<crate::stats::FailureMetrics>> {
483        self.failure_metrics.as_ref()
484    }
485
486    /// Attach a [`crate::embed::CommandExtension`].
487    ///
488    /// When wired, parsed FT.* requests and HSET requests are
489    /// offered to the extension before the routing planner
490    /// runs. The extension may produce a synthesised RESP
491    /// reply (returned to the client as
492    /// [`crate::net::DispatchOutcome::Inline`]), surface a
493    /// structured `-ERR ...` reply
494    /// ([`crate::net::DispatchOutcome::Error`]), or fall
495    /// through. Without this builder call the dispatcher's
496    /// behaviour is unchanged: FT.* keywords are forwarded to
497    /// the local datastore (which typically rejects them with
498    /// `-ERR unknown command`) and HSETs proceed unmodified.
499    ///
500    /// The standard RediSearch implementation lives in the
501    /// `dynomite-search` crate; the trait itself is part of
502    /// the engine so embedders can plug their own.
503    ///
504    /// # Examples
505    ///
506    /// ```
507    /// # use std::sync::Arc;
508    /// # use dynomite::cluster::dispatch::ClusterDispatcher;
509    /// # use dynomite::cluster::peer::{Peer, PeerEndpoint};
510    /// # use dynomite::cluster::pool::{PoolConfig, ServerPool};
511    /// # use dynomite::embed::{CommandExtension, HsetOutcome};
512    /// # use dynomite::hashkit::DynToken;
513    /// # use dynomite::msg::MsgType;
514    /// #[derive(Debug)]
515    /// struct NoOp;
516    /// impl CommandExtension for NoOp {
517    ///     fn handles_msg_type(&self, _: MsgType) -> bool { false }
518    ///     fn try_dispatch(&self, _: &[&[u8]]) -> Option<Vec<u8>> { None }
519    /// }
520    /// let cfg = PoolConfig::default();
521    /// let local = Peer::new(
522    ///     0, PeerEndpoint::tcp("h".into(), 1), "r".into(), "d".into(),
523    ///     vec![DynToken::from_u32(0)], true, true, false,
524    /// );
525    /// let pool = Arc::new(ServerPool::new(cfg, vec![local]));
526    /// let _disp = ClusterDispatcher::new(pool).with_command_extension(Arc::new(NoOp));
527    /// ```
528    #[must_use]
529    pub fn with_command_extension(mut self, ext: Arc<dyn crate::embed::CommandExtension>) -> Self {
530        self.command_extension = Some(ext);
531        self
532    }
533
534    /// Borrow the wired command extension, if any.
535    #[must_use]
536    pub fn command_extension(&self) -> Option<&Arc<dyn crate::embed::CommandExtension>> {
537        self.command_extension.as_ref()
538    }
539
540    /// True when both the hint store is wired AND the pool has
541    /// `enable_hinted_handoff: true`. Hot-path predicate.
542    #[must_use]
543    pub fn hinted_handoff_active(&self) -> bool {
544        self.hint_store.is_some() && self.pool.config().enable_hinted_handoff
545    }
546
547    /// Compute the routing plan for `req` with the supplied key.
548    ///
549    /// `key` is the primary key of the request (the first key
550    /// returned by [`Msg::keys`] for parsed redis / memcache
551    /// commands, or an empty slice for argument-less commands).
552    ///
553    /// The function never panics; it consults the live peer table
554    /// behind the pool's `RwLock` and returns
555    /// [`DispatchPlan::NoTargets`] when the topology cannot
556    /// satisfy the request.
557    ///
558    /// # Examples
559    ///
560    /// See the module-level example.
561    #[must_use]
562    pub fn plan(&self, req: &Msg, key: &[u8]) -> DispatchPlan {
563        let cfg = self.pool.config();
564        let peers = self.pool.peers().read();
565        if peers.is_empty() {
566            self.record_no_targets_metric(cfg, ConsistencyLevel::default());
567            return DispatchPlan::NoTargets;
568        }
569        if matches!(req.routing(), MsgRouting::LocalNodeOnly) {
570            return DispatchPlan::LocalDatastore;
571        }
572        if key.is_empty() {
573            return DispatchPlan::LocalDatastore;
574        }
575        let token = hashkit::hash(map_hash(cfg.hash), key);
576        let key_hash64 = hashkit::hash64(map_hash(cfg.hash), key);
577        let bucket = crate::proto::redis::bucket_name(key);
578        let bucket_type = cfg.resolve_bucket_type(bucket);
579        let is_read = matches!(req.ty(), MsgType::Unknown) || req.flags().is_read;
580        let consistency = match (bucket_type, is_read) {
581            (Some(bt), true) => bt.read_consistency,
582            (Some(bt), false) => bt.write_consistency,
583            (None, true) => cfg.read_consistency,
584            (None, false) => cfg.write_consistency,
585        };
586        let n_val_cap = bucket_type.map_or(0, |bt| bt.n_val);
587        let dcs = self.pool.datacenters().read();
588        // When hinted handoff is active and the request is a
589        // write, peers in `Down` are kept in the routable set
590        // so the dispatcher can hint them at fan-out time. The
591        // hint counts toward the consistency threshold; the
592        // background drainer ships the hint once the peer
593        // returns to `Normal`. For reads (and for writes when
594        // handoff is off), Down peers are filtered out as
595        // before.
596        let include_down = self.hinted_handoff_active() && !is_read;
597        let routable = collect_routable(
598            &dcs,
599            &peers,
600            &token,
601            key_hash64,
602            cfg.distribution,
603            include_down,
604        );
605        if let Some(shadow) = cfg.distribution_shadow {
606            if shadow != cfg.distribution {
607                let shadow_routable =
608                    collect_routable(&dcs, &peers, &token, key_hash64, shadow, include_down);
609                if !plans_agree(&routable, &shadow_routable) {
610                    bump_shadow_disagreement();
611                    tracing::debug!(
612                        target: "dynomite::dispatch::shadow",
613                        live = cfg.distribution.as_str(),
614                        shadow = shadow.as_str(),
615                        "shadow distribution disagreed on key route"
616                    );
617                }
618            }
619        }
620        if routable.is_empty() {
621            self.record_no_targets_metric(cfg, consistency);
622            return DispatchPlan::NoTargets;
623        }
624        let (local, remote): (Vec<_>, Vec<_>) = routable
625            .into_iter()
626            .partition(|(dc_idx, _, _)| dcs[*dc_idx].name() == cfg.dc);
627        let plan =
628            plan_with_consistency(cfg, &dcs, &peers, consistency, req.routing(), local, remote);
629        let plan = cap_replicas(plan, n_val_cap);
630        if matches!(plan, DispatchPlan::NoTargets) {
631            self.record_no_targets_metric(cfg, consistency);
632        }
633        plan
634    }
635
636    /// Record a `dispatch_no_targets_total` metric tick using
637    /// the local-DC labels, when a metrics handle is wired.
638    fn record_no_targets_metric(
639        &self,
640        cfg: &crate::cluster::pool::PoolConfig,
641        consistency: ConsistencyLevel,
642    ) {
643        if let Some(m) = self.failure_metrics.as_ref() {
644            m.record_no_targets(&cfg.dc, &cfg.rack, consistency);
645        }
646    }
647
648    /// Resolve the destination DC of a peer (for per-peer
649    /// failure metrics). Local peers and unknown indexes both
650    /// fall back to the configured local DC.
651    fn peer_dc_label(&self, peer_idx: u32) -> String {
652        let peers = self.pool.peers().read();
653        peers
654            .get(peer_idx as usize)
655            .map_or_else(|| self.pool.config().dc.clone(), |p| p.dc().to_string())
656    }
657}
658
659/// Apply the bucket-type `n_val` fan-out cap to a freshly
660/// computed plan. Only `DispatchPlan::Replicas` is affected; the
661/// other variants pass through unchanged. `cap == 0` means "no
662/// cap" and is the no-op used for keys without a matching bucket
663/// type.
664fn cap_replicas(plan: DispatchPlan, cap: u8) -> DispatchPlan {
665    if cap == 0 {
666        return plan;
667    }
668    let cap = cap as usize;
669    match plan {
670        DispatchPlan::Replicas {
671            mut targets,
672            consistency,
673        } if targets.len() > cap => {
674            targets.truncate(cap);
675            DispatchPlan::Replicas {
676                targets,
677                consistency,
678            }
679        }
680        other => other,
681    }
682}
683
684fn plans_agree(a: &[(usize, usize, u32)], b: &[(usize, usize, u32)]) -> bool {
685    if a.len() != b.len() {
686        return false;
687    }
688    let mut a_idx: Vec<u32> = a.iter().map(|t| t.2).collect();
689    let mut b_idx: Vec<u32> = b.iter().map(|t| t.2).collect();
690    a_idx.sort_unstable();
691    b_idx.sort_unstable();
692    a_idx == b_idx
693}
694
695fn collect_routable(
696    dcs: &[crate::cluster::Datacenter],
697    peers: &[crate::cluster::peer::Peer],
698    token: &crate::hashkit::DynToken,
699    hash64: u64,
700    distribution: crate::conf::Distribution,
701    include_down: bool,
702) -> Vec<(usize, usize, u32)> {
703    let mut routable: Vec<(usize, usize, u32)> = Vec::new();
704    for (dc_idx, dc) in dcs.iter().enumerate() {
705        for (rack_idx, rack) in dc.racks().iter().enumerate() {
706            let candidate = match (distribution, rack.random_slices()) {
707                (crate::conf::Distribution::RandomSlicing, Some(slices)) => {
708                    // Map the chosen claimant name back onto a
709                    // peer index. The slice table holds peer
710                    // pname strings (host:port) so the
711                    // resolution is a linear scan over the
712                    // rack's peer set, which is small (peers
713                    // per rack, not the whole pool).
714                    slices.claimant_for(hash64).and_then(|name| {
715                        peers.iter().find_map(|p| {
716                            if p.dc() == dc.name()
717                                && p.rack() == rack.name()
718                                && p.endpoint().pname() == name
719                            {
720                                Some(p.idx())
721                            } else {
722                                None
723                            }
724                        })
725                    })
726                }
727                _ => vnode::dispatch(rack.continuums(), token),
728            };
729            if let Some(peer_idx) = candidate {
730                if let Some(peer) = peers.get(peer_idx as usize) {
731                    let state = peer.state();
732                    let accept = state.is_routable()
733                        || (include_down && matches!(state, crate::cluster::peer::PeerState::Down));
734                    if accept {
735                        routable.push((dc_idx, rack_idx, peer_idx));
736                    }
737                }
738            }
739        }
740    }
741    routable
742}
743
744fn build_target(
745    dcs: &[crate::cluster::Datacenter],
746    peers: &[crate::cluster::peer::Peer],
747    dc_idx: usize,
748    rack_idx: usize,
749    peer_idx: u32,
750) -> ReplicaTarget {
751    let dc_name = dcs[dc_idx].name().to_string();
752    let rack_name = dcs[dc_idx].racks()[rack_idx].name().to_string();
753    let is_local = peers
754        .get(peer_idx as usize)
755        .is_some_and(crate::cluster::peer::Peer::is_local);
756    ReplicaTarget {
757        peer_idx,
758        dc: dc_name,
759        rack: rack_name,
760        is_local,
761    }
762}
763
764fn plan_with_consistency(
765    cfg: &crate::cluster::pool::PoolConfig,
766    dcs: &[crate::cluster::Datacenter],
767    peers: &[crate::cluster::peer::Peer],
768    consistency: ConsistencyLevel,
769    routing: MsgRouting,
770    local: Vec<(usize, usize, u32)>,
771    remote: Vec<(usize, usize, u32)>,
772) -> DispatchPlan {
773    let want_per_dc_fanout = matches!(consistency, ConsistencyLevel::DcEachSafeQuorum)
774        || matches!(routing, MsgRouting::AllNodesAllRacksAllDcs);
775    let mut targets: Vec<ReplicaTarget> = Vec::new();
776    match consistency {
777        ConsistencyLevel::DcOne => {
778            if local.is_empty() {
779                return DispatchPlan::NoTargets;
780            }
781            let mut best: Option<(RackDistance, (usize, usize, u32))> = None;
782            for (dc_idx, rack_idx, peer_idx) in local {
783                let rack_name = dcs[dc_idx].racks()[rack_idx].name();
784                let d = rack_distance(&cfg.dc, &cfg.rack, &cfg.dc, rack_name);
785                let take = match best {
786                    None => true,
787                    Some((bd, _)) => d.cost() < bd.cost(),
788                };
789                if take {
790                    best = Some((d, (dc_idx, rack_idx, peer_idx)));
791                }
792            }
793            if let Some((_, (dc_idx, rack_idx, peer_idx))) = best {
794                let is_local_node = peers
795                    .get(peer_idx as usize)
796                    .is_some_and(crate::cluster::peer::Peer::is_local);
797                if is_local_node {
798                    return DispatchPlan::LocalDatastore;
799                }
800                targets.push(build_target(dcs, peers, dc_idx, rack_idx, peer_idx));
801            }
802        }
803        ConsistencyLevel::DcQuorum | ConsistencyLevel::DcSafeQuorum => {
804            if local.is_empty() {
805                return DispatchPlan::NoTargets;
806            }
807            for (dc_idx, rack_idx, peer_idx) in local {
808                targets.push(build_target(dcs, peers, dc_idx, rack_idx, peer_idx));
809            }
810        }
811        ConsistencyLevel::DcEachSafeQuorum => {
812            if local.is_empty() && remote.is_empty() {
813                return DispatchPlan::NoTargets;
814            }
815            for (dc_idx, rack_idx, peer_idx) in local.iter().chain(remote.iter()) {
816                targets.push(build_target(dcs, peers, *dc_idx, *rack_idx, *peer_idx));
817            }
818        }
819    }
820    if want_per_dc_fanout && !remote.is_empty() {
821        for (dc_idx, rack_idx, peer_idx) in remote {
822            if !targets.iter().any(|t| t.peer_idx == peer_idx) {
823                targets.push(build_target(dcs, peers, dc_idx, rack_idx, peer_idx));
824            }
825        }
826    }
827    if targets.is_empty() {
828        return DispatchPlan::LocalDatastore;
829    }
830    DispatchPlan::Replicas {
831        targets,
832        consistency,
833    }
834}
835
836impl Dispatcher for ClusterDispatcher {
837    #[allow(
838        clippy::too_many_lines,
839        reason = "single dispatch fn must enumerate every plan; splitting hides the planner-to-effect mapping"
840    )]
841    fn dispatch(&self, req: Msg, responder: ServerSink) -> DispatchOutcome {
842        if req.flags().quit {
843            return DispatchOutcome::Drop;
844        }
845        // FT.* / HSET interception. Runs before the routing
846        // planner so vector-index commands never visit the
847        // backend, and HSETs against an indexed prefix get the
848        // vector mirrored into the in-process registry before
849        // the standard storage write fans out.
850        if let Some(ext) = self.command_extension.as_ref() {
851            if let Some(outcome) = self.intercept_command(ext.as_ref(), &req) {
852                return outcome;
853            }
854        }
855        // Inspect the request without consuming it: pull the routing
856        // bytes from the first parsed key. `KeyPos::tag_bytes` returns
857        // the hash-tag-aware sub-range when one was parsed and the full
858        // key otherwise, which is the slice shape `plan` expects.
859        // Requests with no parsed keys (e.g. PING, INFO) fall through
860        // with an empty slice; `plan` handles that by routing to the
861        // local datastore.
862        let key: Vec<u8> = req
863            .keys()
864            .first()
865            .map(|kp| kp.tag_bytes().to_vec())
866            .unwrap_or_default();
867        let plan = self.plan(&req, &key);
868        let (req_span, _plan_span) = enter_plan_span(req.id(), &plan);
869        match plan {
870            DispatchPlan::Drop => DispatchOutcome::Drop,
871            DispatchPlan::NoTargets => {
872                let err_type = if matches!(req.ty(), MsgType::ReqRedisGet | MsgType::ReqRedisSet) {
873                    MsgType::RspRedisError
874                } else {
875                    MsgType::RspMcServerError
876                };
877                let rsp = crate::msg::response::make_error(
878                    &req,
879                    err_type,
880                    0,
881                    crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
882                    &self.mbuf_pool,
883                );
884                DispatchOutcome::Error(rsp)
885            }
886            DispatchPlan::LocalDatastore => {
887                // In-process datastore hook takes precedence: hand
888                // the parsed request to the embedder's `Datastore`
889                // and deliver its reply on the connection's
890                // responder. The hook is async and `dispatch` is
891                // sync, so spawn the await and return `Pending`
892                // (the same contract the backend-channel and
893                // replica paths use).
894                if let Some(ds) = self.local_datastore.as_ref() {
895                    let ds = Arc::clone(ds);
896                    let req_id = req.id();
897                    let span = req_span.clone();
898                    tokio::spawn(async move {
899                        if let Ok(rsp) = ds.dispatch(req).await {
900                            let _ = responder
901                                .send(OutboundEnvelope {
902                                    req_id,
903                                    rsp,
904                                    span,
905                                    source_peer_idx: None,
906                                })
907                                .await;
908                        }
909                    });
910                    return DispatchOutcome::Pending;
911                }
912                if let Some(tx) = self.backend.as_ref() {
913                    // Snapshot the wire bytes from the parsed mbuf
914                    // chain. The chain is the original on-the-wire
915                    // sequence the parser walked, so this is a
916                    // faithful relay rather than a re-encode.
917                    let bytes: Vec<u8> = req
918                        .mbufs()
919                        .iter()
920                        .flat_map(|b| b.readable().to_vec())
921                        .collect();
922                    if bytes.is_empty() {
923                        // Parsed request with no replayable bytes
924                        // (e.g. a synthetic `Msg`) - drop rather
925                        // than enqueue a no-op on the backend.
926                        return DispatchOutcome::Drop;
927                    }
928                    let env = OutboundRequest {
929                        bytes,
930                        req_id: req.id(),
931                        responder,
932                        span: req_span.clone(),
933                        ty: crate::proto::dnode::DmsgType::Req,
934                        target_peer_idx: None,
935                    };
936                    if let Err(err) = tx.try_send(env) {
937                        // Backend channel full or closed: surface
938                        // an error to the client immediately.
939                        if let Some(m) = self.failure_metrics.as_ref() {
940                            match err {
941                                tokio::sync::mpsc::error::TrySendError::Full(_) => {
942                                    m.record_backend_send_full();
943                                }
944                                tokio::sync::mpsc::error::TrySendError::Closed(_) => {
945                                    m.record_backend_send_closed();
946                                }
947                            }
948                        }
949                        let err_type =
950                            if matches!(req.ty(), MsgType::ReqRedisGet | MsgType::ReqRedisSet) {
951                                MsgType::RspRedisError
952                            } else {
953                                MsgType::RspMcServerError
954                            };
955                        let rsp = crate::msg::response::make_error(
956                            &req,
957                            err_type,
958                            0,
959                            crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
960                            &self.mbuf_pool,
961                        );
962                        return DispatchOutcome::Error(rsp);
963                    }
964                }
965                DispatchOutcome::Pending
966            }
967            DispatchPlan::Replicas {
968                targets,
969                consistency,
970            } => self.dispatch_replicas(&req, &req_span, &targets, consistency, responder),
971        }
972    }
973}
974
975impl ClusterDispatcher {
976    /// Fan a request out across replicas and install the per-
977    /// request reply coalescer.
978    ///
979    /// The single-target case short-circuits to a direct
980    /// forward (no coalescer needed). The multi-target case
981    /// spawns a coalescer task on the ambient tokio runtime; the
982    /// task drains the per-target replies, picks one according
983    /// to the consistency level, and forwards the chosen reply
984    /// to the original `responder`. Divergent replicas are
985    /// scheduled for read-repair writes via the same
986    /// `peer_backends` channels.
987    ///
988    /// When [`hinted_handoff_active`](Self::hinted_handoff_active)
989    /// reports true and the request is a write, targets that
990    /// are currently in [`crate::cluster::peer::PeerState::Down`]
991    /// are recorded in the hint store instead of being sent.
992    /// A synthetic `+OK\r\n` reply is fed to the coalescer on
993    /// the hinted target's behalf so the consistency threshold
994    /// can be met by the surviving replicas plus the hint(s).
995    /// On `try_send` failure (channel closed or full) for a
996    /// non-Down peer, the dispatcher likewise falls back to
997    /// hinting before declaring the target lost.
998    fn dispatch_replicas(
999        &self,
1000        req: &Msg,
1001        req_span: &tracing::Span,
1002        targets: &[ReplicaTarget],
1003        consistency: ConsistencyLevel,
1004        responder: ServerSink,
1005    ) -> DispatchOutcome {
1006        if targets.is_empty() {
1007            return DispatchOutcome::Drop;
1008        }
1009        // Snapshot the wire bytes once. Each target gets its
1010        // own clone (the ServerConn / DnodeServerConn takes
1011        // ownership of `bytes`).
1012        let bytes: Vec<u8> = req
1013            .mbufs()
1014            .iter()
1015            .flat_map(|b| b.readable().to_vec())
1016            .collect();
1017        if bytes.is_empty() {
1018            return DispatchOutcome::Drop;
1019        }
1020        // Snapshot the per-target current state so we do not
1021        // re-acquire the peer-table lock inside the per-target
1022        // loop. Local peers are always treated as Normal.
1023        let peer_states = self.snapshot_peer_states(targets);
1024        let is_read = matches!(req.ty(), MsgType::Unknown) || req.flags().is_read;
1025        let is_write = !is_read;
1026        let handoff_active = self.hinted_handoff_active() && is_write;
1027        // Single-target path: no coalescing needed; forward
1028        // directly to the original responder.
1029        if targets.len() == 1 {
1030            return self.dispatch_replicas_direct(
1031                req,
1032                req_span,
1033                targets,
1034                &bytes,
1035                &responder,
1036                &HandoffCtx {
1037                    handoff_active,
1038                    peer_states: &peer_states,
1039                },
1040            );
1041        }
1042        // Multi-target path: install the coalescer.
1043        let cfg = self.pool.config();
1044        let local_dc = cfg.dc.clone();
1045        // Channel each replica's reply lands on. Sized to
1046        // `targets.len() + 1`: every target produces at most one
1047        // envelope (real or hint-synthesised) and we leave a
1048        // spare so a late repair reply never blocks the actor.
1049        let (intermediate_tx, intermediate_rx) =
1050            mpsc::channel::<OutboundEnvelope>(targets.len() + 1);
1051        // Build the tracker's target list and capture the
1052        // per-target dispatch state.
1053        let target_pairs: Vec<(u32, String)> =
1054            targets.iter().map(|t| (t.peer_idx, t.dc.clone())).collect();
1055        // Read repair context: the original primary key (single-
1056        // key requests only) and the request type.
1057        let repair_key: Option<Vec<u8>> = req
1058            .keys()
1059            .first()
1060            .map(|kp| kp.tag_bytes().to_vec())
1061            .filter(|k| !k.is_empty());
1062        let repair_ctx = repair_key.map(|key| ReadRepairContext {
1063            req_id: req.id(),
1064            req_ty: req.ty(),
1065            key,
1066            mbuf_pool: self.mbuf_pool.clone(),
1067            peer_backends: self.peer_backends.clone(),
1068            local_backend: self.backend.clone(),
1069            target_is_local: targets.iter().map(|t| (t.peer_idx, t.is_local)).collect(),
1070        });
1071        // Fan out: each per-target outbound feeds the coalescer
1072        // channel, NOT the client's responder.
1073        let mut sent = 0usize;
1074        let mut hinted = 0usize;
1075        for target in targets {
1076            let action = Self::choose_target_action(target, handoff_active, &peer_states);
1077            match action {
1078                TargetAction::Send => {
1079                    if self.fanout_send(target, req, req_span, &bytes, &intermediate_tx) {
1080                        sent += 1;
1081                    } else if handoff_active
1082                        && self.hint_target(target, &bytes, req, req_span, &intermediate_tx)
1083                    {
1084                        hinted += 1;
1085                    }
1086                }
1087                TargetAction::Hint => {
1088                    if self.hint_target(target, &bytes, req, req_span, &intermediate_tx) {
1089                        hinted += 1;
1090                    }
1091                }
1092            }
1093        }
1094        // Drop the local clone of the intermediate sender so the
1095        // coalescer task observes RX close once every per-target
1096        // sender has been dropped. (`OutboundRequest` owns one
1097        // sender each; once they finish they drop it.)
1098        drop(intermediate_tx);
1099        if sent + hinted == 0 {
1100            return DispatchOutcome::Error(self.no_quorum_error(req));
1101        }
1102        let req_id = req.id();
1103        let req_ty = req.ty();
1104        let mbuf_pool = self.mbuf_pool.clone();
1105        let failure_metrics = self.failure_metrics.clone();
1106        tokio::spawn(coalesce_actor(
1107            req_id,
1108            req_ty,
1109            consistency,
1110            target_pairs,
1111            local_dc,
1112            intermediate_rx,
1113            responder,
1114            mbuf_pool,
1115            repair_ctx,
1116            failure_metrics,
1117        ));
1118        DispatchOutcome::Pending
1119    }
1120
1121    /// Capture the current `PeerState` for each target so the
1122    /// per-target loop does not re-acquire the read lock on
1123    /// every iteration. Local-node targets are reported as
1124    /// `Normal` regardless of the on-disk peer entry.
1125    fn snapshot_peer_states(
1126        &self,
1127        targets: &[ReplicaTarget],
1128    ) -> std::collections::HashMap<u32, crate::cluster::peer::PeerState> {
1129        use crate::cluster::peer::PeerState;
1130        let peers = self.pool.peers().read();
1131        let mut out = std::collections::HashMap::with_capacity(targets.len());
1132        for t in targets {
1133            let state = if t.is_local {
1134                PeerState::Normal
1135            } else {
1136                peers
1137                    .get(t.peer_idx as usize)
1138                    .map_or(PeerState::Unknown, crate::cluster::peer::Peer::state)
1139            };
1140            out.insert(t.peer_idx, state);
1141        }
1142        out
1143    }
1144
1145    fn choose_target_action(
1146        target: &ReplicaTarget,
1147        handoff_active: bool,
1148        peer_states: &std::collections::HashMap<u32, crate::cluster::peer::PeerState>,
1149    ) -> TargetAction {
1150        use crate::cluster::peer::PeerState;
1151        if !handoff_active {
1152            return TargetAction::Send;
1153        }
1154        let state = peer_states
1155            .get(&target.peer_idx)
1156            .copied()
1157            .unwrap_or(PeerState::Unknown);
1158        match state {
1159            PeerState::Down => TargetAction::Hint,
1160            _ => TargetAction::Send,
1161        }
1162    }
1163
1164    /// Forward one target via its outbound channel. Returns
1165    /// `true` on a successful `try_send`.
1166    fn fanout_send(
1167        &self,
1168        target: &ReplicaTarget,
1169        req: &Msg,
1170        req_span: &tracing::Span,
1171        bytes: &[u8],
1172        intermediate_tx: &mpsc::Sender<OutboundEnvelope>,
1173    ) -> bool {
1174        let env = OutboundRequest {
1175            bytes: bytes.to_vec(),
1176            req_id: req.id(),
1177            responder: intermediate_tx.clone(),
1178            span: req_span.clone(),
1179            ty: crate::proto::dnode::DmsgType::Req,
1180            target_peer_idx: Some(target.peer_idx),
1181        };
1182        let send_result = if target.is_local {
1183            self.backend.as_ref().map(|tx| tx.try_send(env))
1184        } else {
1185            self.peer_backends
1186                .get(&target.peer_idx)
1187                .map(|tx| tx.try_send(env))
1188        };
1189        match send_result {
1190            Some(Ok(())) => true,
1191            Some(Err(err)) => {
1192                self.observe_send_error(target, &err);
1193                false
1194            }
1195            None => false,
1196        }
1197    }
1198
1199    /// Convert a `tokio::sync::mpsc::error::TrySendError` into a
1200    /// failure-metrics observation. Local targets bump the
1201    /// backend counters; peer targets bump the per-peer
1202    /// counters labelled with the peer's DC.
1203    fn observe_send_error(
1204        &self,
1205        target: &ReplicaTarget,
1206        err: &tokio::sync::mpsc::error::TrySendError<OutboundRequest>,
1207    ) {
1208        let Some(m) = self.failure_metrics.as_ref() else {
1209            return;
1210        };
1211        if target.is_local {
1212            match err {
1213                tokio::sync::mpsc::error::TrySendError::Full(_) => m.record_backend_send_full(),
1214                tokio::sync::mpsc::error::TrySendError::Closed(_) => {
1215                    m.record_backend_send_closed();
1216                }
1217            }
1218        } else {
1219            let peer_dc = self.peer_dc_label(target.peer_idx);
1220            match err {
1221                tokio::sync::mpsc::error::TrySendError::Full(_) => {
1222                    m.record_peer_send_full(target.peer_idx, &peer_dc);
1223                }
1224                tokio::sync::mpsc::error::TrySendError::Closed(_) => {
1225                    m.record_peer_send_closed(target.peer_idx, &peer_dc);
1226                }
1227            }
1228        }
1229    }
1230
1231    /// Record `bytes` as a hint for `target`'s peer and feed the
1232    /// coalescer a synthetic success reply. Returns `true` when
1233    /// both the enqueue and the synth-push succeeded.
1234    fn hint_target(
1235        &self,
1236        target: &ReplicaTarget,
1237        bytes: &[u8],
1238        req: &Msg,
1239        req_span: &tracing::Span,
1240        intermediate_tx: &mpsc::Sender<OutboundEnvelope>,
1241    ) -> bool {
1242        let Some(store) = self.hint_store.as_ref() else {
1243            return false;
1244        };
1245        let cfg = self.pool.config();
1246        let ttl = std::time::Duration::from_secs(cfg.hint_ttl_seconds.max(1));
1247        match store.enqueue(target.peer_idx, bytes.to_vec(), ttl) {
1248            Ok(()) => {}
1249            Err(e) => {
1250                tracing::debug!(
1251                    target: "dynomite::hints",
1252                    peer_idx = target.peer_idx,
1253                    error = %e,
1254                    "hint enqueue failed"
1255                );
1256                return false;
1257            }
1258        }
1259        let synth = synth_hint_reply(req, &self.mbuf_pool);
1260        let env = OutboundEnvelope {
1261            req_id: req.id(),
1262            rsp: synth,
1263            span: req_span.clone(),
1264            source_peer_idx: Some(target.peer_idx),
1265        };
1266        if intermediate_tx.try_send(env).is_err() {
1267            // The channel is sized for one envelope per target;
1268            // an immediate full means the coalescer task has
1269            // exited. Still report success so the hint sits in
1270            // the store for the drainer.
1271            tracing::debug!(
1272                target: "dynomite::hints",
1273                peer_idx = target.peer_idx,
1274                "hint synth-reply could not be queued; coalescer absent"
1275            );
1276        }
1277        tracing::debug!(
1278            target: "dynomite::hints",
1279            peer_idx = target.peer_idx,
1280            bytes = bytes.len(),
1281            "stored hint for down peer"
1282        );
1283        true
1284    }
1285
1286    fn dispatch_replicas_direct(
1287        &self,
1288        req: &Msg,
1289        req_span: &tracing::Span,
1290        targets: &[ReplicaTarget],
1291        bytes: &[u8],
1292        responder: &ServerSink,
1293        ctx: &HandoffCtx<'_>,
1294    ) -> DispatchOutcome {
1295        debug_assert_eq!(targets.len(), 1);
1296        let target = &targets[0];
1297        // If the target is Down and handoff is active, hint it
1298        // and feed the responder a synth `+OK\r\n` so the
1299        // client sees the write as having succeeded.
1300        if let TargetAction::Hint =
1301            Self::choose_target_action(target, ctx.handoff_active, ctx.peer_states)
1302        {
1303            if self.hint_single_target_direct(target, bytes, req, req_span, responder) {
1304                return DispatchOutcome::Pending;
1305            }
1306            return DispatchOutcome::Error(self.no_quorum_error(req));
1307        }
1308        let env = OutboundRequest {
1309            bytes: bytes.to_vec(),
1310            req_id: req.id(),
1311            responder: responder.clone(),
1312            span: req_span.clone(),
1313            ty: crate::proto::dnode::DmsgType::Req,
1314            target_peer_idx: Some(target.peer_idx),
1315        };
1316        let send_result = if target.is_local {
1317            self.backend.as_ref().map(|tx| tx.try_send(env))
1318        } else {
1319            self.peer_backends
1320                .get(&target.peer_idx)
1321                .map(|tx| tx.try_send(env))
1322        };
1323        let sent = match send_result {
1324            Some(Ok(())) => true,
1325            Some(Err(ref err)) => {
1326                self.observe_send_error(target, err);
1327                false
1328            }
1329            None => false,
1330        };
1331        if sent {
1332            return DispatchOutcome::Pending;
1333        }
1334        if ctx.handoff_active
1335            && self.hint_single_target_direct(target, bytes, req, req_span, responder)
1336        {
1337            return DispatchOutcome::Pending;
1338        }
1339        DispatchOutcome::Error(self.no_quorum_error(req))
1340    }
1341
1342    /// Single-target hint path. Records the hint in the store
1343    /// and pushes a `+OK\r\n` envelope onto the responder. The
1344    /// client sees the write as having succeeded; the drainer
1345    /// will replay the request to the peer when it returns.
1346    fn hint_single_target_direct(
1347        &self,
1348        target: &ReplicaTarget,
1349        bytes: &[u8],
1350        req: &Msg,
1351        req_span: &tracing::Span,
1352        responder: &ServerSink,
1353    ) -> bool {
1354        let Some(store) = self.hint_store.as_ref() else {
1355            return false;
1356        };
1357        let cfg = self.pool.config();
1358        let ttl = std::time::Duration::from_secs(cfg.hint_ttl_seconds.max(1));
1359        if let Err(e) = store.enqueue(target.peer_idx, bytes.to_vec(), ttl) {
1360            tracing::debug!(
1361                target: "dynomite::hints",
1362                peer_idx = target.peer_idx,
1363                error = %e,
1364                "hint enqueue failed (single-target)"
1365            );
1366            return false;
1367        }
1368        let synth = synth_hint_reply(req, &self.mbuf_pool);
1369        let env = OutboundEnvelope {
1370            req_id: req.id(),
1371            rsp: synth,
1372            span: req_span.clone(),
1373            source_peer_idx: Some(target.peer_idx),
1374        };
1375        let _ = responder.try_send(env);
1376        true
1377    }
1378
1379    fn no_quorum_error(&self, req: &Msg) -> Msg {
1380        let err_type = if matches!(req.ty(), MsgType::ReqRedisGet | MsgType::ReqRedisSet) {
1381            MsgType::RspRedisError
1382        } else {
1383            MsgType::RspMcServerError
1384        };
1385        crate::msg::response::make_error(
1386            req,
1387            err_type,
1388            0,
1389            crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
1390            &self.mbuf_pool,
1391        )
1392    }
1393
1394    /// FT.* / HSET interception. Returns `Some(outcome)` when the
1395    /// command was fully handled (FT.* keyword, or an HSET that
1396    /// references an indexed prefix with a malformed payload);
1397    /// returns `None` to let the caller fall through to the
1398    /// standard dispatch path. The HSET success path returns
1399    /// `None` after the extension records the side-effect so
1400    /// the standard storage write still goes to the backend.
1401    fn intercept_command(
1402        &self,
1403        ext: &dyn crate::embed::CommandExtension,
1404        req: &Msg,
1405    ) -> Option<DispatchOutcome> {
1406        if ext.handles_msg_type(req.ty()) {
1407            return Some(self.run_extension_command(ext, req));
1408        }
1409        if matches!(req.ty(), MsgType::ReqRedisHset) {
1410            return self.intercept_extension_hset(ext, req);
1411        }
1412        None
1413    }
1414
1415    /// Drive an FT.* command through the registered extension
1416    /// and wrap the RESP bytes in a [`DispatchOutcome::Inline`].
1417    /// When the extension declines (`try_dispatch` returns
1418    /// `None`) the outcome is rendered as a `-ERR not
1419    /// supported in this build` reply so the dispatcher does
1420    /// not silently drop the request.
1421    fn run_extension_command(
1422        &self,
1423        ext: &dyn crate::embed::CommandExtension,
1424        req: &Msg,
1425    ) -> DispatchOutcome {
1426        // For typed FT.* variants the keyword is unambiguous; for
1427        // [`MsgType::ReqRedisFtUnknown`] we recover the original
1428        // wire keyword from the request's mbuf chain (the parser
1429        // case-folds for table lookup but the wire bytes are
1430        // preserved verbatim) so the extension can decide
1431        // whether we recognise the keyword and either execute
1432        // it or surface a structured `-ERR ...` reply.
1433        let recovered_kw: Vec<u8>;
1434        let keyword: &[u8] = match req.ty() {
1435            MsgType::ReqRedisFtCreate => b"FT.CREATE",
1436            MsgType::ReqRedisFtSearch => b"FT.SEARCH",
1437            MsgType::ReqRedisFtInfo => b"FT.INFO",
1438            MsgType::ReqRedisFtList => b"FT.LIST",
1439            MsgType::ReqRedisFtDropindex => b"FT.DROPINDEX",
1440            MsgType::ReqRedisFtRegex => b"FT.REGEX",
1441            MsgType::ReqRedisFtSugadd => b"FT.SUGADD",
1442            MsgType::ReqRedisFtSugget => b"FT.SUGGET",
1443            MsgType::ReqRedisFtSugdel => b"FT.SUGDEL",
1444            MsgType::ReqRedisFtSuglen => b"FT.SUGLEN",
1445            MsgType::ReqRedisFtUnknown => {
1446                recovered_kw = first_bulk_token(req).unwrap_or_else(|| b"FT.UNKNOWN".to_vec());
1447                recovered_kw.as_slice()
1448            }
1449            // The dispatcher only enters this branch from the
1450            // FT.* arm in [`Self::intercept_command`], so the
1451            // catch-all is unreachable unless the MsgType set
1452            // drifts out of sync with that arm.
1453            _ => return DispatchOutcome::Drop,
1454        };
1455        let mut args: Vec<&[u8]> = Vec::with_capacity(1 + req.keys().len() + req.args().len());
1456        args.push(keyword);
1457        for k in req.keys() {
1458            args.push(k.key());
1459        }
1460        for a in req.args() {
1461            args.push(a.bytes());
1462        }
1463        let bytes = ext.try_dispatch(&args).unwrap_or_else(|| {
1464            let kw = String::from_utf8_lossy(keyword);
1465            format!("-ERR not supported in this build: {kw}\r\n").into_bytes()
1466        });
1467        DispatchOutcome::Inline(synthetic_redis_reply(req, &self.mbuf_pool, &bytes))
1468    }
1469
1470    /// HSET interception via the registered extension.
1471    /// Returns `Some(Error(...))` when the extension reports a
1472    /// malformed payload against a registered prefix; returns
1473    /// `None` (so the dispatcher falls through to the backend)
1474    /// on success or when no registered prefix matches.
1475    fn intercept_extension_hset(
1476        &self,
1477        ext: &dyn crate::embed::CommandExtension,
1478        req: &Msg,
1479    ) -> Option<DispatchOutcome> {
1480        let mut args: Vec<&[u8]> = Vec::with_capacity(req.keys().len() + req.args().len());
1481        for k in req.keys() {
1482            args.push(k.key());
1483        }
1484        for a in req.args() {
1485            args.push(a.bytes());
1486        }
1487        match ext.try_intercept_hset(&args) {
1488            crate::embed::HsetOutcome::Absorbed | crate::embed::HsetOutcome::NotIndexed => None,
1489            crate::embed::HsetOutcome::Error(message) => {
1490                let payload = format!("-ERR {message}\r\n");
1491                Some(DispatchOutcome::Error(synthetic_redis_reply(
1492                    req,
1493                    &self.mbuf_pool,
1494                    payload.as_bytes(),
1495                )))
1496            }
1497        }
1498    }
1499}
1500
1501/// Wrap an arbitrary RESP byte sequence as a synthetic Redis
1502/// response [`Msg`]. The response inherits the request's id (so
1503/// the FSM can pair it with the originating request), is marked
1504/// `is_request = false`, and the supplied bytes are copied into
1505/// one or more mbufs drawn from `pool`.
1506fn synthetic_redis_reply(req: &Msg, pool: &MbufPool, payload: &[u8]) -> Msg {
1507    let mut rsp = Msg::new(req.id(), MsgType::RspRedisStatus, false);
1508    rsp.set_parent_id(req.id());
1509    let mut written = 0usize;
1510    while written < payload.len() {
1511        let mut buf = pool.get();
1512        let n = buf.recv(&payload[written..]);
1513        debug_assert!(
1514            n > 0,
1515            "MbufPool returned a buffer with zero writable capacity"
1516        );
1517        rsp.mbufs_mut().push_back(buf);
1518        written += n;
1519    }
1520    rsp.recompute_mlen();
1521    rsp
1522}
1523
1524/// Recover the first bulk-string token from a parsed RESP
1525/// request's mbuf chain. The parser case-folds the keyword for
1526/// the command-table lookup but stores the original wire bytes
1527/// in the mbufs; this helper reads them back so the dispatcher
1528/// can render structured error replies that quote the actual
1529/// keyword the client sent.
1530///
1531/// Returns `None` when the mbuf chain does not begin with a
1532/// well-formed `*N\r\n$M\r\n<token>\r\n` sequence.
1533fn first_bulk_token(req: &Msg) -> Option<Vec<u8>> {
1534    let mut wire: Vec<u8> = Vec::new();
1535    for buf in req.mbufs() {
1536        wire.extend_from_slice(buf.readable());
1537        if wire.len() > 256 {
1538            break;
1539        }
1540    }
1541    let mut p = 0usize;
1542    if wire.first() == Some(&b'*') {
1543        let cr = wire.iter().position(|&b| b == b'\r')?;
1544        if wire.get(cr + 1) != Some(&b'\n') {
1545            return None;
1546        }
1547        p = cr + 2;
1548    }
1549    if wire.get(p) != Some(&b'$') {
1550        return None;
1551    }
1552    let header_start = p + 1;
1553    let header_cr = wire[header_start..]
1554        .iter()
1555        .position(|&b| b == b'\r')
1556        .map(|i| header_start + i)?;
1557    if wire.get(header_cr + 1) != Some(&b'\n') {
1558        return None;
1559    }
1560    let len_str = std::str::from_utf8(&wire[header_start..header_cr]).ok()?;
1561    let len: usize = len_str.parse().ok()?;
1562    let body_start = header_cr + 2;
1563    let body_end = body_start.checked_add(len)?;
1564    if wire.len() < body_end + 2 {
1565        return None;
1566    }
1567    Some(wire[body_start..body_end].to_vec())
1568}
1569
1570/// Context required to schedule read-repair writes once the
1571/// coalescer has identified a winner and a divergent set.
1572#[derive(Clone)]
1573struct ReadRepairContext {
1574    req_id: crate::core::types::MsgId,
1575    req_ty: MsgType,
1576    /// Original primary key (single-key requests only). The v1
1577    /// repair scheduler operates over single-key Redis reads;
1578    /// multi-key fragmentation goes through a separate path.
1579    key: Vec<u8>,
1580    mbuf_pool: MbufPool,
1581    peer_backends: std::collections::HashMap<u32, mpsc::Sender<OutboundRequest>>,
1582    local_backend: Option<mpsc::Sender<OutboundRequest>>,
1583    target_is_local: std::collections::HashMap<u32, bool>,
1584}
1585
1586/// Per-fan-out coalescer task body.
1587#[allow(
1588    clippy::too_many_arguments,
1589    reason = "actor task captures the entire dispatch context; bundling into a struct adds churn for no callsite gain"
1590)]
1591async fn coalesce_actor(
1592    req_id: crate::core::types::MsgId,
1593    req_ty: MsgType,
1594    consistency: ConsistencyLevel,
1595    targets: Vec<(u32, String)>,
1596    local_dc: String,
1597    mut intermediate_rx: mpsc::Receiver<OutboundEnvelope>,
1598    client_tx: ServerSink,
1599    mbuf_pool: MbufPool,
1600    repair_ctx: Option<ReadRepairContext>,
1601    failure_metrics: Option<Arc<crate::stats::FailureMetrics>>,
1602) {
1603    use crate::proto::redis::{CoalesceOutcome, CoalesceTracker};
1604    let mut tracker = CoalesceTracker::new(req_id, consistency, targets, &local_dc);
1605    let mut emitted = false;
1606    while let Some(env) = intermediate_rx.recv().await {
1607        let source = env.source_peer_idx.unwrap_or(u32::MAX);
1608        let span = env.span.clone();
1609        let outcome = tracker.record_reply(source, env.rsp);
1610        match outcome {
1611            CoalesceOutcome::Pending => {}
1612            CoalesceOutcome::Ready {
1613                winner,
1614                divergent_targets,
1615            } => {
1616                if !emitted {
1617                    let winner_bytes: Vec<u8> = winner
1618                        .mbufs()
1619                        .iter()
1620                        .flat_map(|b| b.readable().to_vec())
1621                        .collect();
1622                    let out_env = OutboundEnvelope {
1623                        req_id,
1624                        rsp: *winner,
1625                        span: span.clone(),
1626                        source_peer_idx: None,
1627                    };
1628                    let _ = client_tx.send(out_env).await;
1629                    emitted = true;
1630                    if !divergent_targets.is_empty() {
1631                        if let Some(ctx) = repair_ctx.as_ref() {
1632                            schedule_read_repair(ctx, &divergent_targets, &winner_bytes, &span);
1633                        }
1634                    }
1635                }
1636            }
1637            CoalesceOutcome::Error(reason) => {
1638                if !emitted {
1639                    let err_type = if matches!(req_ty, MsgType::ReqRedisGet | MsgType::ReqRedisSet)
1640                    {
1641                        MsgType::RspRedisError
1642                    } else {
1643                        MsgType::RspMcServerError
1644                    };
1645                    let anchor = Msg::new(req_id, req_ty, true);
1646                    let rsp = crate::msg::response::make_error(
1647                        &anchor,
1648                        err_type,
1649                        0,
1650                        crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
1651                        &mbuf_pool,
1652                    );
1653                    let _ = client_tx
1654                        .send(OutboundEnvelope {
1655                            req_id,
1656                            rsp,
1657                            span: span.clone(),
1658                            source_peer_idx: None,
1659                        })
1660                        .await;
1661                    emitted = true;
1662                }
1663                tracing::debug!(target: "dynomite::coalesce", req_id, reason = %reason, "coalesce error");
1664            }
1665        }
1666    }
1667    if !emitted {
1668        // No reply was emitted and the channel closed (every
1669        // per-target sender dropped without producing a reply).
1670        // From the dispatcher's perspective the request has
1671        // timed out at the coalescer layer; surface a
1672        // quorum-unreachable error so the client does not hang
1673        // and bump the response-timeout counter.
1674        if let Some(m) = failure_metrics.as_ref() {
1675            m.record_response_timeout(consistency);
1676        }
1677        let err_type = if matches!(req_ty, MsgType::ReqRedisGet | MsgType::ReqRedisSet) {
1678            MsgType::RspRedisError
1679        } else {
1680            MsgType::RspMcServerError
1681        };
1682        let anchor = Msg::new(req_id, req_ty, true);
1683        let rsp = crate::msg::response::make_error(
1684            &anchor,
1685            err_type,
1686            0,
1687            crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
1688            &mbuf_pool,
1689        );
1690        let _ = client_tx
1691            .send(OutboundEnvelope {
1692                req_id,
1693                rsp,
1694                span: tracing::Span::none(),
1695                source_peer_idx: None,
1696            })
1697            .await;
1698    }
1699}
1700
1701/// Build a sink the read-repair task can drop replies into. The
1702/// scheduler is fire-and-forget: every reply is discarded.
1703fn repair_sink() -> ServerSink {
1704    let (tx, mut rx) = mpsc::channel::<OutboundEnvelope>(8);
1705    tokio::spawn(async move {
1706        while rx.recv().await.is_some() {
1707            // Drop the envelope; the original client already
1708            // received its reply on the main responder.
1709        }
1710    });
1711    tx
1712}
1713
1714/// Decode a winning RESP reply into the bytes we want to write
1715/// back to divergent replicas.
1716///
1717/// Returns `Some(bytes)` for a bulk-string winner (we ship a
1718/// `SET key value` to the divergent replica) or for a nil reply
1719/// (we ship a `DEL key`). Returns `None` for any other shape
1720/// (errors, integers, multibulk, ...) since the v1 repair
1721/// scheduler only handles single-bulk Redis GET-style winners.
1722fn decode_winner_for_repair(payload: &[u8]) -> Option<RepairAction> {
1723    if payload == b"$-1\r\n" {
1724        return Some(RepairAction::Delete);
1725    }
1726    if !payload.starts_with(b"$") {
1727        return None;
1728    }
1729    // `$<len>\r\n<value>\r\n`
1730    let crlf = payload.iter().position(|&b| b == b'\r')?;
1731    if payload.get(crlf + 1).copied() != Some(b'\n') {
1732        return None;
1733    }
1734    let len_str = std::str::from_utf8(&payload[1..crlf]).ok()?;
1735    let len: usize = len_str.parse().ok()?;
1736    let body_start = crlf + 2;
1737    let body_end = body_start.checked_add(len)?;
1738    if payload.len() < body_end + 2 {
1739        return None;
1740    }
1741    if &payload[body_end..body_end + 2] != b"\r\n" {
1742        return None;
1743    }
1744    Some(RepairAction::Write(payload[body_start..body_end].to_vec()))
1745}
1746
1747/// Bundle of handoff state passed into
1748/// [`ClusterDispatcher::dispatch_replicas_direct`]. Avoids a
1749/// noisy parameter list while keeping the per-call decisions
1750/// ("is handoff on?", "what state is each target in?") in one
1751/// place.
1752struct HandoffCtx<'a> {
1753    handoff_active: bool,
1754    peer_states: &'a std::collections::HashMap<u32, crate::cluster::peer::PeerState>,
1755}
1756
1757/// Per-target dispatch action chosen by
1758/// [`ClusterDispatcher::choose_target_action`].
1759#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1760enum TargetAction {
1761    /// Forward the request via the per-peer outbound channel.
1762    Send,
1763    /// Record a hint and feed the coalescer a synth success
1764    /// reply.
1765    Hint,
1766}
1767
1768/// Synthetic reply pushed into the coalescer on behalf of a
1769/// hinted target. Always a `+OK\r\n` Redis status reply: the
1770/// dispatcher decouples the hint from the eventual peer reply
1771/// (the drainer will fire-and-forget the hint when the peer
1772/// returns), so the synth shape only needs to satisfy the
1773/// coalescer for SET-style writes which is the dominant case
1774/// for hinted handoff. Mismatched-shape writes (DEL with one
1775/// hinted target) coalesce to the surviving real reply via the
1776/// plurality / quorum branch and the divergence is harmless
1777/// because read-repair only fires for `MsgType::ReqRedisGet`.
1778fn synth_hint_reply(req: &Msg, pool: &MbufPool) -> Msg {
1779    crate::msg::response::make_simple_redis(req, pool, b"+OK\r\n")
1780}
1781
1782/// Action a read-repair task should take against a divergent
1783/// replica.
1784enum RepairAction {
1785    /// Ship `SET key <bytes>` to overwrite the stale value.
1786    Write(Vec<u8>),
1787    /// Ship `DEL key` to drop the stale value (winning reply
1788    /// was a nil bulk).
1789    Delete,
1790}
1791
1792/// Build the wire bytes for a Redis repair write.
1793fn build_repair_bytes(action: &RepairAction, key: &[u8]) -> Vec<u8> {
1794    match action {
1795        RepairAction::Write(value) => {
1796            let mut out = Vec::with_capacity(key.len() + value.len() + 32);
1797            out.extend_from_slice(b"*3\r\n$3\r\nSET\r\n$");
1798            out.extend_from_slice(key.len().to_string().as_bytes());
1799            out.extend_from_slice(b"\r\n");
1800            out.extend_from_slice(key);
1801            out.extend_from_slice(b"\r\n$");
1802            out.extend_from_slice(value.len().to_string().as_bytes());
1803            out.extend_from_slice(b"\r\n");
1804            out.extend_from_slice(value);
1805            out.extend_from_slice(b"\r\n");
1806            out
1807        }
1808        RepairAction::Delete => {
1809            let mut out = Vec::with_capacity(key.len() + 24);
1810            out.extend_from_slice(b"*2\r\n$3\r\nDEL\r\n$");
1811            out.extend_from_slice(key.len().to_string().as_bytes());
1812            out.extend_from_slice(b"\r\n");
1813            out.extend_from_slice(key);
1814            out.extend_from_slice(b"\r\n");
1815            out
1816        }
1817    }
1818}
1819
1820/// Schedule fire-and-forget read-repair writes to every
1821/// divergent target. The function only awaits a bounded mpsc
1822/// permit; it never blocks for the repair to complete or for
1823/// the divergent replica to ack.
1824///
1825/// The repair shape is decoded from `winner_bytes`:
1826///
1827/// * Bulk-string winner -> `SET key <value>`.
1828/// * Nil bulk winner -> `DEL key`.
1829/// * Anything else -> skipped (entropy reconciliation will
1830///   handle it later). This v1 limitation is documented in the
1831///   dispatcher tests and in `docs/parity.md`.
1832///
1833/// Repair writes are tagged with `DmsgType::ReqForward` so the
1834/// receiving peer's `dnode_client_loop` rewrites the parsed
1835/// request's routing tag to `LocalNodeOnly`, preventing a
1836/// recursive multi-replica fan-out at the divergent peer.
1837fn schedule_read_repair(
1838    ctx: &ReadRepairContext,
1839    divergent: &[u32],
1840    winner_bytes: &[u8],
1841    span: &tracing::Span,
1842) {
1843    if !matches!(ctx.req_ty, MsgType::ReqRedisGet) {
1844        return;
1845    }
1846    let Some(action) = decode_winner_for_repair(winner_bytes) else {
1847        return;
1848    };
1849    let bytes = build_repair_bytes(&action, &ctx.key);
1850    let sink = repair_sink();
1851    for &peer_idx in divergent {
1852        let is_local = ctx.target_is_local.get(&peer_idx).copied().unwrap_or(false);
1853        let env = OutboundRequest {
1854            bytes: bytes.clone(),
1855            req_id: ctx.req_id,
1856            responder: sink.clone(),
1857            span: span.clone(),
1858            ty: crate::proto::dnode::DmsgType::ReqForward,
1859            target_peer_idx: Some(peer_idx),
1860        };
1861        let sent = if is_local {
1862            ctx.local_backend
1863                .as_ref()
1864                .is_some_and(|tx| tx.try_send(env).is_ok())
1865        } else {
1866            ctx.peer_backends
1867                .get(&peer_idx)
1868                .is_some_and(|tx| tx.try_send(env).is_ok())
1869        };
1870        if sent {
1871            let _ = &ctx.mbuf_pool;
1872            tracing::debug!(
1873                target: "dynomite::read_repair",
1874                req_id = ctx.req_id,
1875                peer_idx,
1876                bytes = bytes.len(),
1877                "scheduled read-repair write",
1878            );
1879        } else {
1880            tracing::debug!(
1881                target: "dynomite::read_repair",
1882                req_id = ctx.req_id,
1883                peer_idx,
1884                "read-repair drop: backend channel unavailable or full",
1885            );
1886        }
1887    }
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892    use super::*;
1893    use crate::cluster::peer::{Peer, PeerEndpoint, PeerState};
1894    use crate::conf::DataStore;
1895    use crate::hashkit::DynToken;
1896
1897    fn cfg(read: ConsistencyLevel, write: ConsistencyLevel) -> crate::cluster::PoolConfig {
1898        crate::cluster::PoolConfig {
1899            read_consistency: read,
1900            write_consistency: write,
1901            dc: "dc1".into(),
1902            rack: "rA".into(),
1903            ..crate::cluster::PoolConfig::default()
1904        }
1905    }
1906
1907    fn peer(idx: u32, dc: &str, rack: &str, tok: u32, is_local: bool, is_same: bool) -> Peer {
1908        let mut p = Peer::new(
1909            idx,
1910            PeerEndpoint::tcp("h".into(), 8101 + u16::try_from(idx).unwrap_or(0)),
1911            rack.into(),
1912            dc.into(),
1913            vec![DynToken::from_u32(tok)],
1914            is_local,
1915            is_same,
1916            false,
1917        );
1918        p.set_state(PeerState::Normal, 0);
1919        p
1920    }
1921
1922    fn pool(read: ConsistencyLevel, write: ConsistencyLevel, peers: Vec<Peer>) -> Arc<ServerPool> {
1923        let pool = ServerPool::new(cfg(read, write), peers);
1924        pool.preselect_remote_racks();
1925        Arc::new(pool)
1926    }
1927
1928    #[test]
1929    fn local_node_only_short_circuits() {
1930        let p = pool(
1931            ConsistencyLevel::DcOne,
1932            ConsistencyLevel::DcOne,
1933            vec![peer(0, "dc1", "rA", 10, true, true)],
1934        );
1935        let mut req = Msg::new(1, MsgType::ReqRedisGet, true);
1936        req.set_routing(MsgRouting::LocalNodeOnly);
1937        assert_eq!(
1938            ClusterDispatcher::new(p).plan(&req, b"k"),
1939            DispatchPlan::LocalDatastore,
1940        );
1941    }
1942
1943    #[test]
1944    fn dc_one_read_targets_local_rack_when_present() {
1945        let p = pool(
1946            ConsistencyLevel::DcOne,
1947            ConsistencyLevel::DcOne,
1948            vec![
1949                peer(0, "dc1", "rA", 10, true, true),
1950                peer(1, "dc1", "rB", 20, false, true),
1951                peer(2, "dc2", "rA", 30, false, false),
1952            ],
1953        );
1954        let req = Msg::new(1, MsgType::ReqRedisGet, true);
1955        // Any key resolves to peer 0 in rack rA (single-token continuum).
1956        let plan = ClusterDispatcher::new(p).plan(&req, b"hello");
1957        assert!(matches!(plan, DispatchPlan::LocalDatastore));
1958    }
1959
1960    #[test]
1961    fn dc_quorum_fans_out_local_dc() {
1962        let p = pool(
1963            ConsistencyLevel::DcQuorum,
1964            ConsistencyLevel::DcQuorum,
1965            vec![
1966                peer(0, "dc1", "rA", 10, true, true),
1967                peer(1, "dc1", "rB", 20, false, true),
1968                peer(2, "dc2", "rA", 30, false, false),
1969            ],
1970        );
1971        let req = Msg::new(1, MsgType::ReqRedisGet, true);
1972        let plan = ClusterDispatcher::new(p).plan(&req, b"k");
1973        match plan {
1974            DispatchPlan::Replicas { targets: rs, .. } => {
1975                assert_eq!(rs.len(), 2);
1976                for r in rs {
1977                    assert_eq!(r.dc, "dc1");
1978                }
1979            }
1980            _ => panic!("expected replicas"),
1981        }
1982    }
1983
1984    #[test]
1985    fn dc_each_safe_quorum_fans_out_per_dc() {
1986        let p = pool(
1987            ConsistencyLevel::DcEachSafeQuorum,
1988            ConsistencyLevel::DcEachSafeQuorum,
1989            vec![
1990                peer(0, "dc1", "rA", 10, true, true),
1991                peer(1, "dc2", "rA", 20, false, false),
1992            ],
1993        );
1994        let req = Msg::new(1, MsgType::ReqRedisGet, true);
1995        let plan = ClusterDispatcher::new(p).plan(&req, b"k");
1996        match plan {
1997            DispatchPlan::Replicas { targets: rs, .. } => {
1998                assert_eq!(rs.len(), 2);
1999                let dcs: Vec<&str> = rs.iter().map(|r| r.dc.as_str()).collect();
2000                assert!(dcs.contains(&"dc1"));
2001                assert!(dcs.contains(&"dc2"));
2002            }
2003            _ => panic!("expected replicas"),
2004        }
2005    }
2006
2007    #[test]
2008    fn no_routable_peers_returns_no_targets() {
2009        let mut p0 = peer(0, "dc1", "rA", 10, true, true);
2010        p0.set_state(PeerState::Down, 0);
2011        let p = pool(
2012            ConsistencyLevel::DcQuorum,
2013            ConsistencyLevel::DcQuorum,
2014            vec![p0],
2015        );
2016        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2017        let plan = ClusterDispatcher::new(p).plan(&req, b"k");
2018        assert_eq!(plan, DispatchPlan::NoTargets);
2019    }
2020
2021    /// Regression: any code path that returns
2022    /// `DispatchOutcome::Error` used to send 0 wire bytes to the
2023    /// client because [`crate::msg::response::make_error`] never
2024    /// attached the wire-format error string. The client then
2025    /// hung until its read timeout. After the fix, the error
2026    /// response carries a parseable `-Dynomite: ...` reply that
2027    /// the client can render as an error.
2028    #[test]
2029    fn no_targets_error_response_carries_dynomite_wire_bytes() {
2030        let mut p0 = peer(0, "dc1", "rA", 10, true, true);
2031        p0.set_state(PeerState::Down, 0);
2032        let p = pool(
2033            ConsistencyLevel::DcQuorum,
2034            ConsistencyLevel::DcQuorum,
2035            vec![p0],
2036        );
2037        let disp = ClusterDispatcher::new(p);
2038        let mut req = Msg::new(1, MsgType::ReqRedisGet, true);
2039        req.push_key(crate::msg::keypos::KeyPos::without_tag(b"k".to_vec()));
2040        let (tx, _rx) = mpsc::channel(1);
2041        let outcome = disp.dispatch(req, tx);
2042        match outcome {
2043            DispatchOutcome::Error(rsp) => {
2044                assert_eq!(rsp.ty(), MsgType::RspRedisError);
2045                assert!(rsp.flags().is_error);
2046                let bytes: Vec<u8> = rsp
2047                    .mbufs()
2048                    .iter()
2049                    .flat_map(|b| b.readable().to_vec())
2050                    .collect();
2051                assert!(
2052                    !bytes.is_empty(),
2053                    "NoTargets must produce on-wire bytes, not a 0-byte hang"
2054                );
2055                assert!(bytes.starts_with(b"-Dynomite: "));
2056                assert!(bytes.ends_with(b"\r\n"));
2057                assert_eq!(rsp.mlen() as usize, bytes.len());
2058            }
2059            other => panic!("expected DispatchOutcome::Error, got {other:?}"),
2060        }
2061    }
2062
2063    /// Memcache traffic with no quorum-eligible target must
2064    /// surface a `SERVER_ERROR ...\r\n` reply rather than
2065    /// hanging the client.
2066    #[test]
2067    fn no_targets_error_response_memcache_wire_bytes() {
2068        // Build a memcache pool so the dispatcher's err_type
2069        // selection lands on `RspMcServerError` (the dispatcher
2070        // currently keys off the request `MsgType`, so a
2071        // memcache request flows through the memcache wire
2072        // shape).
2073        let mut cfg = cfg(ConsistencyLevel::DcQuorum, ConsistencyLevel::DcQuorum);
2074        cfg.data_store = DataStore::Memcache;
2075        let mut p0 = peer(0, "dc1", "rA", 10, true, true);
2076        p0.set_state(PeerState::Down, 0);
2077        let pool_arc = ServerPool::new(cfg, vec![p0]);
2078        pool_arc.preselect_remote_racks();
2079        let disp = ClusterDispatcher::new(Arc::new(pool_arc));
2080        let mut req = Msg::new(1, MsgType::ReqMcGet, true);
2081        req.push_key(crate::msg::keypos::KeyPos::without_tag(b"k".to_vec()));
2082        let (tx, _rx) = mpsc::channel(1);
2083        let outcome = disp.dispatch(req, tx);
2084        match outcome {
2085            DispatchOutcome::Error(rsp) => {
2086                assert_eq!(rsp.ty(), MsgType::RspMcServerError);
2087                let bytes: Vec<u8> = rsp
2088                    .mbufs()
2089                    .iter()
2090                    .flat_map(|b| b.readable().to_vec())
2091                    .collect();
2092                assert!(
2093                    !bytes.is_empty(),
2094                    "NoTargets must produce on-wire bytes, not a 0-byte hang"
2095                );
2096                assert!(bytes.starts_with(b"SERVER_ERROR "));
2097                assert!(bytes.ends_with(b"\r\n"));
2098            }
2099            other => panic!("expected DispatchOutcome::Error, got {other:?}"),
2100        }
2101    }
2102
2103    use crate::cluster::pool::{BucketType, PoolConfig};
2104
2105    fn pool_with_bucket_types(
2106        pool_read: ConsistencyLevel,
2107        pool_write: ConsistencyLevel,
2108        bucket_types: Vec<BucketType>,
2109        default_bucket_type: Option<&str>,
2110        peers: Vec<Peer>,
2111    ) -> Arc<ServerPool> {
2112        let cfg = PoolConfig {
2113            read_consistency: pool_read,
2114            write_consistency: pool_write,
2115            dc: "dc1".into(),
2116            rack: "rA".into(),
2117            bucket_types,
2118            default_bucket_type: default_bucket_type.map(str::to_string),
2119            ..PoolConfig::default()
2120        };
2121        let pool = ServerPool::new(cfg, peers);
2122        pool.preselect_remote_racks();
2123        Arc::new(pool)
2124    }
2125
2126    fn three_local_peers() -> Vec<Peer> {
2127        vec![
2128            peer(0, "dc1", "rA", 10, true, true),
2129            peer(1, "dc1", "rB", 20, false, true),
2130            peer(2, "dc1", "rC", 30, false, true),
2131        ]
2132    }
2133
2134    #[test]
2135    fn bucket_type_overrides_pool_consistency() {
2136        // Pool default is DC_ONE, the bucket forces DC_QUORUM.
2137        let bts = vec![BucketType {
2138            name: "hot".into(),
2139            read_consistency: ConsistencyLevel::DcQuorum,
2140            write_consistency: ConsistencyLevel::DcQuorum,
2141            n_val: 0,
2142        }];
2143        let p = pool_with_bucket_types(
2144            ConsistencyLevel::DcOne,
2145            ConsistencyLevel::DcOne,
2146            bts,
2147            None,
2148            three_local_peers(),
2149        );
2150        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2151        let plan = ClusterDispatcher::new(p).plan(&req, b"hot/key1");
2152        match plan {
2153            DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2154            other => panic!("expected DC_QUORUM fan-out, got {other:?}"),
2155        }
2156    }
2157
2158    #[test]
2159    fn slashless_key_falls_back_to_pool_default() {
2160        let bts = vec![BucketType {
2161            name: "hot".into(),
2162            read_consistency: ConsistencyLevel::DcQuorum,
2163            write_consistency: ConsistencyLevel::DcQuorum,
2164            n_val: 0,
2165        }];
2166        let p = pool_with_bucket_types(
2167            ConsistencyLevel::DcOne,
2168            ConsistencyLevel::DcOne,
2169            bts,
2170            None,
2171            three_local_peers(),
2172        );
2173        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2174        let plan = ClusterDispatcher::new(p).plan(&req, b"plain-key");
2175        // No slash and no default_bucket_type -> pool DC_ONE.
2176        // The local rack hosts peer 0 so the plan short-circuits.
2177        assert!(matches!(plan, DispatchPlan::LocalDatastore));
2178    }
2179
2180    #[test]
2181    fn unknown_bucket_uses_default_bucket_type_when_set() {
2182        let bts = vec![BucketType {
2183            name: "safe".into(),
2184            read_consistency: ConsistencyLevel::DcQuorum,
2185            write_consistency: ConsistencyLevel::DcQuorum,
2186            n_val: 0,
2187        }];
2188        let p = pool_with_bucket_types(
2189            ConsistencyLevel::DcOne,
2190            ConsistencyLevel::DcOne,
2191            bts,
2192            Some("safe"),
2193            three_local_peers(),
2194        );
2195        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2196        // Slashless key: bucket is None, default_bucket_type=safe applies
2197        // so we get the bucket-type's DC_QUORUM fan-out.
2198        let plan = ClusterDispatcher::new(p.clone()).plan(&req, b"plain-key");
2199        match plan {
2200            DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2201            other => panic!("expected DC_QUORUM via default bucket, got {other:?}"),
2202        }
2203        // Slashed key with an unknown bucket prefix also falls
2204        // through to the default bucket type.
2205        let plan = ClusterDispatcher::new(p).plan(&req, b"unknown-bucket/key");
2206        match plan {
2207            DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2208            other => panic!("expected DC_QUORUM via default bucket, got {other:?}"),
2209        }
2210    }
2211
2212    #[test]
2213    fn unknown_bucket_with_no_default_uses_pool_default() {
2214        let bts = vec![BucketType {
2215            name: "safe".into(),
2216            read_consistency: ConsistencyLevel::DcQuorum,
2217            write_consistency: ConsistencyLevel::DcQuorum,
2218            n_val: 0,
2219        }];
2220        let p = pool_with_bucket_types(
2221            ConsistencyLevel::DcOne,
2222            ConsistencyLevel::DcOne,
2223            bts,
2224            None,
2225            three_local_peers(),
2226        );
2227        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2228        let plan = ClusterDispatcher::new(p).plan(&req, b"unknown-bucket/key");
2229        assert!(matches!(plan, DispatchPlan::LocalDatastore));
2230    }
2231
2232    #[test]
2233    fn n_val_one_caps_replicas_to_first_target() {
2234        let bts = vec![BucketType {
2235            name: "thin".into(),
2236            read_consistency: ConsistencyLevel::DcQuorum,
2237            write_consistency: ConsistencyLevel::DcQuorum,
2238            n_val: 1,
2239        }];
2240        let p = pool_with_bucket_types(
2241            ConsistencyLevel::DcOne,
2242            ConsistencyLevel::DcOne,
2243            bts,
2244            None,
2245            three_local_peers(),
2246        );
2247        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2248        let plan = ClusterDispatcher::new(p).plan(&req, b"thin/key");
2249        match plan {
2250            DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 1),
2251            other => panic!("expected single-target plan, got {other:?}"),
2252        }
2253    }
2254
2255    #[test]
2256    fn n_val_two_caps_replicas_to_first_two_targets() {
2257        let bts = vec![BucketType {
2258            name: "medium".into(),
2259            read_consistency: ConsistencyLevel::DcQuorum,
2260            write_consistency: ConsistencyLevel::DcQuorum,
2261            n_val: 2,
2262        }];
2263        let p = pool_with_bucket_types(
2264            ConsistencyLevel::DcOne,
2265            ConsistencyLevel::DcOne,
2266            bts,
2267            None,
2268            three_local_peers(),
2269        );
2270        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2271        let plan = ClusterDispatcher::new(p).plan(&req, b"medium/key");
2272        match plan {
2273            DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 2),
2274            other => panic!("expected two-target plan, got {other:?}"),
2275        }
2276    }
2277
2278    #[test]
2279    fn n_val_zero_does_not_cap() {
2280        let bts = vec![BucketType {
2281            name: "any".into(),
2282            read_consistency: ConsistencyLevel::DcQuorum,
2283            write_consistency: ConsistencyLevel::DcQuorum,
2284            n_val: 0,
2285        }];
2286        let p = pool_with_bucket_types(
2287            ConsistencyLevel::DcOne,
2288            ConsistencyLevel::DcOne,
2289            bts,
2290            None,
2291            three_local_peers(),
2292        );
2293        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2294        let plan = ClusterDispatcher::new(p).plan(&req, b"any/key");
2295        match plan {
2296            DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2297            other => panic!("expected uncapped plan, got {other:?}"),
2298        }
2299    }
2300
2301    #[test]
2302    fn n_val_larger_than_replicas_is_a_no_op() {
2303        let bts = vec![BucketType {
2304            name: "big".into(),
2305            read_consistency: ConsistencyLevel::DcQuorum,
2306            write_consistency: ConsistencyLevel::DcQuorum,
2307            n_val: 7,
2308        }];
2309        let p = pool_with_bucket_types(
2310            ConsistencyLevel::DcOne,
2311            ConsistencyLevel::DcOne,
2312            bts,
2313            None,
2314            three_local_peers(),
2315        );
2316        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2317        let plan = ClusterDispatcher::new(p).plan(&req, b"big/key");
2318        match plan {
2319            DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2320            other => panic!("expected uncapped plan, got {other:?}"),
2321        }
2322    }
2323
2324    /// Smoke test for the failure-cause counter wiring: a
2325    /// `NoTargets` plan increments the labelled counter
2326    /// exactly once.
2327    #[test]
2328    fn no_targets_records_failure_metric() {
2329        let mut p0 = peer(0, "dc1", "rA", 10, true, true);
2330        p0.set_state(PeerState::Down, 0);
2331        let p = pool(
2332            ConsistencyLevel::DcQuorum,
2333            ConsistencyLevel::DcQuorum,
2334            vec![p0],
2335        );
2336        let metrics = Arc::new(crate::stats::FailureMetrics::new());
2337        let disp = ClusterDispatcher::new(p).with_failure_metrics(metrics.clone());
2338        let req = Msg::new(1, MsgType::ReqRedisGet, true);
2339        assert_eq!(disp.plan(&req, b"k"), DispatchPlan::NoTargets);
2340        let snap = metrics.snapshot();
2341        assert_eq!(snap.no_targets.len(), 1);
2342        let entry = &snap.no_targets[0];
2343        assert_eq!(entry.dc, "dc1");
2344        assert_eq!(entry.rack, "rA");
2345        assert_eq!(entry.consistency, ConsistencyLevel::DcQuorum);
2346        assert_eq!(entry.count, 1);
2347    }
2348
2349    /// A closed mpsc channel returns `Closed` from
2350    /// `try_send`. Wire the dispatcher's local-datastore path
2351    /// to such a channel, fire one request, and assert the
2352    /// `dispatch_backend_send_closed_total` counter ticks by
2353    /// exactly one.
2354    #[tokio::test]
2355    async fn closed_backend_channel_records_closed_metric() {
2356        let p = pool(
2357            ConsistencyLevel::DcOne,
2358            ConsistencyLevel::DcOne,
2359            vec![peer(0, "dc1", "rA", 10, true, true)],
2360        );
2361        let (tx, rx) = mpsc::channel::<crate::net::server::OutboundRequest>(4);
2362        drop(rx);
2363        let metrics = Arc::new(crate::stats::FailureMetrics::new());
2364        let disp = ClusterDispatcher::new(p)
2365            .with_backend(tx)
2366            .with_failure_metrics(metrics.clone());
2367        let mut req = Msg::new(1, MsgType::ReqRedisGet, true);
2368        // Attach a non-empty mbuf so the dispatcher actually
2369        // attempts the try_send (empty bytes short-circuit
2370        // to Drop before the channel is touched).
2371        let pool_buf = crate::io::mbuf::MbufPool::default();
2372        let mut buf = pool_buf.get();
2373        buf.copy_from_slice(b"PING\r\n");
2374        req.mbufs_mut().push_back(buf);
2375        let (resp_tx, _resp_rx) = mpsc::channel(1);
2376        let outcome = disp.dispatch(req, resp_tx);
2377        assert!(matches!(outcome, DispatchOutcome::Error(_)));
2378        let snap = metrics.snapshot();
2379        assert_eq!(snap.backend_send_closed, 1);
2380        assert_eq!(snap.backend_send_full, 0);
2381    }
2382
2383    /// Integration-style unit test: build a two-peer pool,
2384    /// mark one peer Down, drive 100 dispatches across the
2385    /// ring, and assert the `dispatch_no_targets_total`
2386    /// counter reflects every observed `NoTargets` plan.
2387    #[test]
2388    fn two_peer_pool_with_one_down_records_per_key_no_targets() {
2389        let cfg = crate::cluster::PoolConfig {
2390            dc: "dc1".into(),
2391            rack: "rA".into(),
2392            read_consistency: ConsistencyLevel::DcQuorum,
2393            write_consistency: ConsistencyLevel::DcQuorum,
2394            ..crate::cluster::PoolConfig::default()
2395        };
2396        // Single-rack two-peer ring: peer 0 owns the upper
2397        // half via wrap-around (token 2_147_483_648), peer 1
2398        // owns the lower half (token 0 plus the boundary up to
2399        // 2_147_483_648). With both peers in rack `rA` the
2400        // continuum has two entries, so each key maps to
2401        // exactly one peer; marking peer 1 Down causes its
2402        // arc to produce `NoTargets`.
2403        let p0 = peer(0, "dc1", "rA", 2_147_483_648, true, true);
2404        let mut p1 = peer(1, "dc1", "rA", 0, false, true);
2405        p1.set_state(PeerState::Down, 0);
2406        let pool_arc = ServerPool::new(cfg, vec![p0, p1]);
2407        pool_arc.preselect_remote_racks();
2408        let metrics = Arc::new(crate::stats::FailureMetrics::new());
2409        let disp = ClusterDispatcher::new(Arc::new(pool_arc)).with_failure_metrics(metrics.clone());
2410        let mut planned_no_targets = 0u64;
2411        let mut planned_routable = 0u64;
2412        for i in 0..100u32 {
2413            let key = format!("k{i:03}");
2414            let req = Msg::new(u64::from(i), MsgType::ReqRedisGet, true);
2415            match disp.plan(&req, key.as_bytes()) {
2416                DispatchPlan::NoTargets => planned_no_targets += 1,
2417                DispatchPlan::Replicas { .. } | DispatchPlan::LocalDatastore => {
2418                    planned_routable += 1;
2419                }
2420                DispatchPlan::Drop => panic!("unexpected Drop in plan"),
2421            }
2422        }
2423        assert!(planned_no_targets > 0, "expected some NoTargets dispatches");
2424        assert!(planned_routable > 0, "expected some routable dispatches");
2425        let snap = metrics.snapshot();
2426        let counter_total: u64 = snap.no_targets.iter().map(|e| e.count).sum();
2427        assert_eq!(
2428            counter_total, planned_no_targets,
2429            "dispatch_no_targets_total must match observed NoTargets count",
2430        );
2431        // No `Closed`/`Full` channel errors expected: we did
2432        // not wire any backends.
2433        assert_eq!(snap.backend_send_full, 0);
2434        assert_eq!(snap.backend_send_closed, 0);
2435        assert!(snap.peer_send_full.is_empty());
2436        assert!(snap.peer_send_closed.is_empty());
2437    }
2438}