1use 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#[must_use]
74pub fn distribution_shadow_disagreement_total() -> u64 {
75 SHADOW_DISAGREEMENTS.load(Ordering::Relaxed)
76}
77
78pub 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
98fn 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
122#[must_use]
129pub fn map_hash(h: ConfHashType) -> HashType {
130 match h {
131 ConfHashType::OneAtATime => HashType::OneAtATime,
132 ConfHashType::Md5 => HashType::Md5,
133 ConfHashType::Crc16 => HashType::Crc16,
134 ConfHashType::Crc32 => HashType::Crc32,
135 ConfHashType::Crc32a => HashType::Crc32a,
136 ConfHashType::Fnv1_64 => HashType::Fnv1_64,
137 ConfHashType::Fnv1a64 => HashType::Fnv1a_64,
138 ConfHashType::Fnv1_32 => HashType::Fnv1_32,
139 ConfHashType::Fnv1a32 => HashType::Fnv1a_32,
140 ConfHashType::Hsieh => HashType::Hsieh,
141 ConfHashType::Murmur => HashType::Murmur,
142 ConfHashType::Jenkins => HashType::Jenkins,
143 ConfHashType::Murmur3 => HashType::Murmur3,
144 ConfHashType::Murmur3X64_64 => HashType::Murmur3X64_64,
145 }
146}
147
148#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct ReplicaTarget {
151 pub peer_idx: u32,
153 pub dc: String,
155 pub rack: String,
157 pub is_local: bool,
159}
160
161#[derive(Clone, Debug, Eq, PartialEq)]
169pub enum DispatchPlan {
170 LocalDatastore,
172 Replicas {
178 targets: Vec<ReplicaTarget>,
180 consistency: ConsistencyLevel,
182 },
183 NoTargets,
186 Drop,
188}
189
190#[derive(Clone)]
192pub struct ClusterDispatcher {
193 pool: Arc<ServerPool>,
194 backend: Option<mpsc::Sender<OutboundRequest>>,
201 peer_backends: std::collections::HashMap<u32, mpsc::Sender<OutboundRequest>>,
209 mbuf_pool: MbufPool,
214 hint_store: Option<Arc<crate::cluster::hints::HintStore>>,
221 failure_metrics: Option<Arc<crate::stats::FailureMetrics>>,
227 command_extension: Option<Arc<dyn crate::embed::CommandExtension>>,
238 local_datastore: Option<Arc<dyn crate::embed::hooks::Datastore>>,
248}
249
250impl std::fmt::Debug for ClusterDispatcher {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 f.debug_struct("ClusterDispatcher")
257 .field("backend", &self.backend.is_some())
258 .field("peer_backends", &self.peer_backends.len())
259 .field("hint_store", &self.hint_store.is_some())
260 .field("failure_metrics", &self.failure_metrics.is_some())
261 .field("command_extension", &self.command_extension)
262 .field("local_datastore", &self.local_datastore.is_some())
263 .finish_non_exhaustive()
264 }
265}
266
267impl ClusterDispatcher {
268 #[must_use]
288 pub fn new(pool: Arc<ServerPool>) -> Self {
289 Self {
290 pool,
291 backend: None,
292 peer_backends: std::collections::HashMap::new(),
293 mbuf_pool: MbufPool::default(),
294 hint_store: None,
295 failure_metrics: None,
296 command_extension: None,
297 local_datastore: None,
298 }
299 }
300
301 #[must_use]
323 pub fn with_mbuf_pool(mut self, pool: MbufPool) -> Self {
324 self.mbuf_pool = pool;
325 self
326 }
327
328 #[must_use]
332 pub fn mbuf_pool(&self) -> &MbufPool {
333 &self.mbuf_pool
334 }
335
336 #[must_use]
345 pub fn with_backend(mut self, backend: mpsc::Sender<OutboundRequest>) -> Self {
346 self.backend = Some(backend);
347 self
348 }
349
350 #[must_use]
361 pub fn with_local_datastore(
362 mut self,
363 datastore: Arc<dyn crate::embed::hooks::Datastore>,
364 ) -> Self {
365 self.local_datastore = Some(datastore);
366 self
367 }
368
369 #[must_use]
381 pub fn with_peer_backend(
382 mut self,
383 peer_idx: u32,
384 sender: mpsc::Sender<OutboundRequest>,
385 ) -> Self {
386 self.peer_backends.insert(peer_idx, sender);
387 self
388 }
389
390 #[must_use]
392 pub fn has_backend(&self) -> bool {
393 self.backend.is_some()
394 }
395
396 #[must_use]
398 pub fn peer_backend_count(&self) -> usize {
399 self.peer_backends.len()
400 }
401
402 #[must_use]
404 pub fn pool(&self) -> &Arc<ServerPool> {
405 &self.pool
406 }
407
408 #[must_use]
441 pub fn with_hint_store(mut self, store: Arc<crate::cluster::hints::HintStore>) -> Self {
442 self.hint_store = Some(store);
443 self
444 }
445
446 #[must_use]
448 pub fn hint_store(&self) -> Option<&Arc<crate::cluster::hints::HintStore>> {
449 self.hint_store.as_ref()
450 }
451
452 #[must_use]
482 pub fn with_failure_metrics(mut self, metrics: Arc<crate::stats::FailureMetrics>) -> Self {
483 self.failure_metrics = Some(metrics);
484 self
485 }
486
487 #[must_use]
489 pub fn failure_metrics(&self) -> Option<&Arc<crate::stats::FailureMetrics>> {
490 self.failure_metrics.as_ref()
491 }
492
493 #[must_use]
536 pub fn with_command_extension(mut self, ext: Arc<dyn crate::embed::CommandExtension>) -> Self {
537 self.command_extension = Some(ext);
538 self
539 }
540
541 #[must_use]
543 pub fn command_extension(&self) -> Option<&Arc<dyn crate::embed::CommandExtension>> {
544 self.command_extension.as_ref()
545 }
546
547 #[must_use]
550 pub fn hinted_handoff_active(&self) -> bool {
551 self.hint_store.is_some() && self.pool.config().enable_hinted_handoff
552 }
553
554 #[must_use]
569 pub fn plan(&self, req: &Msg, key: &[u8]) -> DispatchPlan {
570 let cfg = self.pool.config();
571 let peers = self.pool.peers().read();
572 if peers.is_empty() {
573 self.record_no_targets_metric(cfg, ConsistencyLevel::default());
574 return DispatchPlan::NoTargets;
575 }
576 if matches!(req.routing(), MsgRouting::LocalNodeOnly) {
577 return DispatchPlan::LocalDatastore;
578 }
579 if key.is_empty() {
580 return DispatchPlan::LocalDatastore;
581 }
582 let token = hashkit::hash(map_hash(cfg.hash), key);
583 let key_hash64 = hashkit::hash64(map_hash(cfg.hash), key);
584 let bucket = crate::proto::redis::bucket_name(key);
585 let bucket_type = cfg.resolve_bucket_type(bucket);
586 let is_read = matches!(req.ty(), MsgType::Unknown) || req.flags().is_read;
587 let consistency = match (bucket_type, is_read) {
588 (Some(bt), true) => bt.read_consistency,
589 (Some(bt), false) => bt.write_consistency,
590 (None, true) => cfg.read_consistency,
591 (None, false) => cfg.write_consistency,
592 };
593 let n_val_cap = bucket_type.map_or(0, |bt| bt.n_val);
594 let dcs = self.pool.datacenters().read();
595 let include_down = self.hinted_handoff_active() && !is_read;
604 let routable = collect_routable(
605 &dcs,
606 &peers,
607 &token,
608 key_hash64,
609 cfg.distribution,
610 include_down,
611 );
612 if let Some(shadow) = cfg.distribution_shadow {
613 if shadow != cfg.distribution {
614 let shadow_routable =
615 collect_routable(&dcs, &peers, &token, key_hash64, shadow, include_down);
616 if !plans_agree(&routable, &shadow_routable) {
617 bump_shadow_disagreement();
618 tracing::debug!(
619 target: "dynomite::dispatch::shadow",
620 live = cfg.distribution.as_str(),
621 shadow = shadow.as_str(),
622 "shadow distribution disagreed on key route"
623 );
624 }
625 }
626 }
627 if routable.is_empty() {
628 self.record_no_targets_metric(cfg, consistency);
629 return DispatchPlan::NoTargets;
630 }
631 let (local, remote): (Vec<_>, Vec<_>) = routable
632 .into_iter()
633 .partition(|(dc_idx, _, _)| dcs[*dc_idx].name() == cfg.dc);
634 let plan = plan_with_consistency(
635 cfg,
636 &dcs,
637 &peers,
638 consistency,
639 req.routing(),
640 is_read,
641 RoutablePartition { local, remote },
642 );
643 let plan = cap_replicas(plan, n_val_cap);
644 if matches!(plan, DispatchPlan::NoTargets) {
645 self.record_no_targets_metric(cfg, consistency);
646 }
647 plan
648 }
649
650 fn record_no_targets_metric(
653 &self,
654 cfg: &crate::cluster::pool::PoolConfig,
655 consistency: ConsistencyLevel,
656 ) {
657 if let Some(m) = self.failure_metrics.as_ref() {
658 m.record_no_targets(&cfg.dc, &cfg.rack, consistency);
659 }
660 }
661
662 fn peer_dc_label(&self, peer_idx: u32) -> String {
666 let peers = self.pool.peers().read();
667 peers
668 .get(peer_idx as usize)
669 .map_or_else(|| self.pool.config().dc.clone(), |p| p.dc().to_string())
670 }
671}
672
673fn cap_replicas(plan: DispatchPlan, cap: u8) -> DispatchPlan {
679 if cap == 0 {
680 return plan;
681 }
682 let cap = cap as usize;
683 match plan {
684 DispatchPlan::Replicas {
685 mut targets,
686 consistency,
687 } if targets.len() > cap => {
688 targets.truncate(cap);
689 DispatchPlan::Replicas {
690 targets,
691 consistency,
692 }
693 }
694 other => other,
695 }
696}
697
698fn plans_agree(a: &[(usize, usize, u32)], b: &[(usize, usize, u32)]) -> bool {
699 if a.len() != b.len() {
700 return false;
701 }
702 let mut a_idx: Vec<u32> = a.iter().map(|t| t.2).collect();
703 let mut b_idx: Vec<u32> = b.iter().map(|t| t.2).collect();
704 a_idx.sort_unstable();
705 b_idx.sort_unstable();
706 a_idx == b_idx
707}
708
709fn collect_routable(
710 dcs: &[crate::cluster::Datacenter],
711 peers: &[crate::cluster::peer::Peer],
712 token: &crate::hashkit::DynToken,
713 hash64: u64,
714 distribution: crate::conf::Distribution,
715 include_down: bool,
716) -> Vec<(usize, usize, u32)> {
717 let mut routable: Vec<(usize, usize, u32)> = Vec::new();
718 for (dc_idx, dc) in dcs.iter().enumerate() {
719 for (rack_idx, rack) in dc.racks().iter().enumerate() {
720 let candidate = match (distribution, rack.random_slices()) {
721 (crate::conf::Distribution::RandomSlicing, Some(slices)) => {
722 slices.claimant_for(hash64).and_then(|name| {
729 peers.iter().find_map(|p| {
730 if p.dc() == dc.name()
731 && p.rack() == rack.name()
732 && p.endpoint().pname() == name
733 {
734 Some(p.idx())
735 } else {
736 None
737 }
738 })
739 })
740 }
741 _ => vnode::dispatch(rack.continuums(), token),
742 };
743 if let Some(peer_idx) = candidate {
744 if let Some(peer) = peers.get(peer_idx as usize) {
745 let state = peer.state();
746 let accept = state.is_routable()
747 || (include_down && matches!(state, crate::cluster::peer::PeerState::Down));
748 if accept {
749 routable.push((dc_idx, rack_idx, peer_idx));
750 }
751 }
752 }
753 }
754 }
755 routable
756}
757
758fn build_target(
759 dcs: &[crate::cluster::Datacenter],
760 peers: &[crate::cluster::peer::Peer],
761 dc_idx: usize,
762 rack_idx: usize,
763 peer_idx: u32,
764) -> ReplicaTarget {
765 let dc_name = dcs[dc_idx].name().to_string();
766 let rack_name = dcs[dc_idx].racks()[rack_idx].name().to_string();
767 let is_local = peers
768 .get(peer_idx as usize)
769 .is_some_and(crate::cluster::peer::Peer::is_local);
770 ReplicaTarget {
771 peer_idx,
772 dc: dc_name,
773 rack: rack_name,
774 is_local,
775 }
776}
777
778struct RoutablePartition {
781 local: Vec<(usize, usize, u32)>,
782 remote: Vec<(usize, usize, u32)>,
783}
784
785fn plan_with_consistency(
786 cfg: &crate::cluster::pool::PoolConfig,
787 dcs: &[crate::cluster::Datacenter],
788 peers: &[crate::cluster::peer::Peer],
789 consistency: ConsistencyLevel,
790 routing: MsgRouting,
791 is_read: bool,
792 partition: RoutablePartition,
793) -> DispatchPlan {
794 let RoutablePartition { local, remote } = partition;
795 let want_per_dc_fanout = matches!(consistency, ConsistencyLevel::DcEachSafeQuorum)
796 || matches!(routing, MsgRouting::AllNodesAllRacksAllDcs);
797 let mut targets: Vec<ReplicaTarget> = Vec::new();
798 match consistency {
799 ConsistencyLevel::DcOne => {
800 if local.is_empty() {
801 return DispatchPlan::NoTargets;
802 }
803 if is_read {
804 let mut best: Option<(RackDistance, (usize, usize, u32))> = None;
808 for (dc_idx, rack_idx, peer_idx) in local {
809 let rack_name = dcs[dc_idx].racks()[rack_idx].name();
810 let d = rack_distance(&cfg.dc, &cfg.rack, &cfg.dc, rack_name);
811 let take = match best {
812 None => true,
813 Some((bd, _)) => d.cost() < bd.cost(),
814 };
815 if take {
816 best = Some((d, (dc_idx, rack_idx, peer_idx)));
817 }
818 }
819 if let Some((_, (dc_idx, rack_idx, peer_idx))) = best {
820 let is_local_node = peers
821 .get(peer_idx as usize)
822 .is_some_and(crate::cluster::peer::Peer::is_local);
823 if is_local_node {
824 return DispatchPlan::LocalDatastore;
825 }
826 targets.push(build_target(dcs, peers, dc_idx, rack_idx, peer_idx));
827 }
828 } else {
829 for (dc_idx, rack_idx, peer_idx) in local {
835 targets.push(build_target(dcs, peers, dc_idx, rack_idx, peer_idx));
836 }
837 }
838 }
839 ConsistencyLevel::DcQuorum | ConsistencyLevel::DcSafeQuorum => {
840 if local.is_empty() {
841 return DispatchPlan::NoTargets;
842 }
843 for (dc_idx, rack_idx, peer_idx) in local {
844 targets.push(build_target(dcs, peers, dc_idx, rack_idx, peer_idx));
845 }
846 }
847 ConsistencyLevel::DcEachSafeQuorum => {
848 if local.is_empty() && remote.is_empty() {
849 return DispatchPlan::NoTargets;
850 }
851 for (dc_idx, rack_idx, peer_idx) in local.iter().chain(remote.iter()) {
852 targets.push(build_target(dcs, peers, *dc_idx, *rack_idx, *peer_idx));
853 }
854 }
855 }
856 if want_per_dc_fanout && !remote.is_empty() {
857 for (dc_idx, rack_idx, peer_idx) in remote {
858 if !targets.iter().any(|t| t.peer_idx == peer_idx) {
859 targets.push(build_target(dcs, peers, dc_idx, rack_idx, peer_idx));
860 }
861 }
862 }
863 if targets.is_empty() {
864 return DispatchPlan::LocalDatastore;
865 }
866 DispatchPlan::Replicas {
867 targets,
868 consistency,
869 }
870}
871
872impl Dispatcher for ClusterDispatcher {
873 #[allow(
874 clippy::too_many_lines,
875 reason = "single dispatch fn must enumerate every plan; splitting hides the planner-to-effect mapping"
876 )]
877 fn dispatch(&self, req: Msg, responder: ServerSink) -> DispatchOutcome {
878 if req.flags().quit {
879 return DispatchOutcome::Drop;
880 }
881 if let Some(ext) = self.command_extension.as_ref() {
887 if let Some(outcome) = self.intercept_command(ext.as_ref(), &req) {
888 return outcome;
889 }
890 }
891 let key: Vec<u8> = req
899 .keys()
900 .first()
901 .map(|kp| kp.tag_bytes().to_vec())
902 .unwrap_or_default();
903 let plan = self.plan(&req, &key);
904 let (req_span, _plan_span) = enter_plan_span(req.id(), &plan);
905 match plan {
906 DispatchPlan::Drop => DispatchOutcome::Drop,
907 DispatchPlan::NoTargets => {
908 let err_type = if matches!(req.ty(), MsgType::ReqRedisGet | MsgType::ReqRedisSet) {
909 MsgType::RspRedisError
910 } else {
911 MsgType::RspMcServerError
912 };
913 let rsp = crate::msg::response::make_error(
914 &req,
915 err_type,
916 0,
917 crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
918 &self.mbuf_pool,
919 );
920 DispatchOutcome::Error(rsp)
921 }
922 DispatchPlan::LocalDatastore => {
923 if let Some(ds) = self.local_datastore.as_ref() {
931 let ds = Arc::clone(ds);
932 let req_id = req.id();
933 let span = req_span.clone();
934 tokio::spawn(async move {
935 if let Ok(rsp) = ds.dispatch(req).await {
936 let _ = responder
937 .send(OutboundEnvelope {
938 req_id,
939 rsp,
940 span,
941 source_peer_idx: None,
942 })
943 .await;
944 }
945 });
946 return DispatchOutcome::Pending;
947 }
948 if let Some(tx) = self.backend.as_ref() {
949 let bytes: Vec<u8> = req
954 .mbufs()
955 .iter()
956 .flat_map(|b| b.readable().to_vec())
957 .collect();
958 if bytes.is_empty() {
959 return DispatchOutcome::Drop;
963 }
964 let env = OutboundRequest {
965 bytes,
966 req_id: req.id(),
967 responder,
968 span: req_span.clone(),
969 ty: crate::proto::dnode::DmsgType::Req,
970 target_peer_idx: None,
971 };
972 if let Err(err) = tx.try_send(env) {
973 if let Some(m) = self.failure_metrics.as_ref() {
976 match err {
977 tokio::sync::mpsc::error::TrySendError::Full(_) => {
978 m.record_backend_send_full();
979 }
980 tokio::sync::mpsc::error::TrySendError::Closed(_) => {
981 m.record_backend_send_closed();
982 }
983 }
984 }
985 let err_type =
986 if matches!(req.ty(), MsgType::ReqRedisGet | MsgType::ReqRedisSet) {
987 MsgType::RspRedisError
988 } else {
989 MsgType::RspMcServerError
990 };
991 let rsp = crate::msg::response::make_error(
992 &req,
993 err_type,
994 0,
995 crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
996 &self.mbuf_pool,
997 );
998 return DispatchOutcome::Error(rsp);
999 }
1000 }
1001 DispatchOutcome::Pending
1002 }
1003 DispatchPlan::Replicas {
1004 targets,
1005 consistency,
1006 } => self.dispatch_replicas(&req, &req_span, &targets, consistency, responder),
1007 }
1008 }
1009}
1010
1011impl ClusterDispatcher {
1012 fn dispatch_replicas(
1035 &self,
1036 req: &Msg,
1037 req_span: &tracing::Span,
1038 targets: &[ReplicaTarget],
1039 consistency: ConsistencyLevel,
1040 responder: ServerSink,
1041 ) -> DispatchOutcome {
1042 if targets.is_empty() {
1043 return DispatchOutcome::Drop;
1044 }
1045 let bytes: Vec<u8> = req
1049 .mbufs()
1050 .iter()
1051 .flat_map(|b| b.readable().to_vec())
1052 .collect();
1053 if bytes.is_empty() {
1054 return DispatchOutcome::Drop;
1055 }
1056 let peer_states = self.snapshot_peer_states(targets);
1060 let is_read = matches!(req.ty(), MsgType::Unknown) || req.flags().is_read;
1061 let is_write = !is_read;
1062 let handoff_active = self.hinted_handoff_active() && is_write;
1063 if targets.len() == 1 {
1066 return self.dispatch_replicas_direct(
1067 req,
1068 req_span,
1069 targets,
1070 &bytes,
1071 &responder,
1072 &HandoffCtx {
1073 handoff_active,
1074 peer_states: &peer_states,
1075 },
1076 );
1077 }
1078 let cfg = self.pool.config();
1080 let local_dc = cfg.dc.clone();
1081 let (intermediate_tx, intermediate_rx) =
1086 mpsc::channel::<OutboundEnvelope>(targets.len() + 1);
1087 let target_pairs: Vec<(u32, String)> =
1090 targets.iter().map(|t| (t.peer_idx, t.dc.clone())).collect();
1091 let repair_key: Option<Vec<u8>> = req
1094 .keys()
1095 .first()
1096 .map(|kp| kp.tag_bytes().to_vec())
1097 .filter(|k| !k.is_empty());
1098 let repair_ctx = repair_key.map(|key| ReadRepairContext {
1099 req_id: req.id(),
1100 req_ty: req.ty(),
1101 key,
1102 mbuf_pool: self.mbuf_pool.clone(),
1103 peer_backends: self.peer_backends.clone(),
1104 local_backend: self.backend.clone(),
1105 target_is_local: targets.iter().map(|t| (t.peer_idx, t.is_local)).collect(),
1106 });
1107 let mut sent = 0usize;
1110 let mut hinted = 0usize;
1111 for target in targets {
1112 let action = Self::choose_target_action(target, handoff_active, &peer_states);
1113 match action {
1114 TargetAction::Send => {
1115 if self.fanout_send(target, req, req_span, &bytes, &intermediate_tx) {
1116 sent += 1;
1117 } else if handoff_active
1118 && self.hint_target(target, &bytes, req, req_span, &intermediate_tx)
1119 {
1120 hinted += 1;
1121 }
1122 }
1123 TargetAction::Hint => {
1124 if self.hint_target(target, &bytes, req, req_span, &intermediate_tx) {
1125 hinted += 1;
1126 }
1127 }
1128 }
1129 }
1130 drop(intermediate_tx);
1135 if sent + hinted == 0 {
1136 return DispatchOutcome::Error(self.no_quorum_error(req));
1137 }
1138 let req_id = req.id();
1139 let req_ty = req.ty();
1140 let mbuf_pool = self.mbuf_pool.clone();
1141 let failure_metrics = self.failure_metrics.clone();
1142 tokio::spawn(coalesce_actor(
1143 req_id,
1144 req_ty,
1145 consistency,
1146 target_pairs,
1147 local_dc,
1148 intermediate_rx,
1149 responder,
1150 mbuf_pool,
1151 repair_ctx,
1152 failure_metrics,
1153 ));
1154 DispatchOutcome::Pending
1155 }
1156
1157 fn snapshot_peer_states(
1162 &self,
1163 targets: &[ReplicaTarget],
1164 ) -> std::collections::HashMap<u32, crate::cluster::peer::PeerState> {
1165 use crate::cluster::peer::PeerState;
1166 let peers = self.pool.peers().read();
1167 let mut out = std::collections::HashMap::with_capacity(targets.len());
1168 for t in targets {
1169 let state = if t.is_local {
1170 PeerState::Normal
1171 } else {
1172 peers
1173 .get(t.peer_idx as usize)
1174 .map_or(PeerState::Unknown, crate::cluster::peer::Peer::state)
1175 };
1176 out.insert(t.peer_idx, state);
1177 }
1178 out
1179 }
1180
1181 fn choose_target_action(
1182 target: &ReplicaTarget,
1183 handoff_active: bool,
1184 peer_states: &std::collections::HashMap<u32, crate::cluster::peer::PeerState>,
1185 ) -> TargetAction {
1186 use crate::cluster::peer::PeerState;
1187 if !handoff_active {
1188 return TargetAction::Send;
1189 }
1190 let state = peer_states
1191 .get(&target.peer_idx)
1192 .copied()
1193 .unwrap_or(PeerState::Unknown);
1194 match state {
1195 PeerState::Down => TargetAction::Hint,
1196 _ => TargetAction::Send,
1197 }
1198 }
1199
1200 fn fanout_send(
1203 &self,
1204 target: &ReplicaTarget,
1205 req: &Msg,
1206 req_span: &tracing::Span,
1207 bytes: &[u8],
1208 intermediate_tx: &mpsc::Sender<OutboundEnvelope>,
1209 ) -> bool {
1210 let ty = if target.is_local {
1216 crate::proto::dnode::DmsgType::Req
1217 } else {
1218 crate::proto::dnode::DmsgType::ReqForward
1219 };
1220 let env = OutboundRequest {
1221 bytes: bytes.to_vec(),
1222 req_id: req.id(),
1223 responder: intermediate_tx.clone(),
1224 span: req_span.clone(),
1225 ty,
1226 target_peer_idx: Some(target.peer_idx),
1227 };
1228 let send_result = if target.is_local {
1229 self.backend.as_ref().map(|tx| tx.try_send(env))
1230 } else {
1231 self.peer_backends
1232 .get(&target.peer_idx)
1233 .map(|tx| tx.try_send(env))
1234 };
1235 match send_result {
1236 Some(Ok(())) => true,
1237 Some(Err(err)) => {
1238 self.observe_send_error(target, &err);
1239 false
1240 }
1241 None => false,
1242 }
1243 }
1244
1245 fn observe_send_error(
1250 &self,
1251 target: &ReplicaTarget,
1252 err: &tokio::sync::mpsc::error::TrySendError<OutboundRequest>,
1253 ) {
1254 let Some(m) = self.failure_metrics.as_ref() else {
1255 return;
1256 };
1257 if target.is_local {
1258 match err {
1259 tokio::sync::mpsc::error::TrySendError::Full(_) => m.record_backend_send_full(),
1260 tokio::sync::mpsc::error::TrySendError::Closed(_) => {
1261 m.record_backend_send_closed();
1262 }
1263 }
1264 } else {
1265 let peer_dc = self.peer_dc_label(target.peer_idx);
1266 match err {
1267 tokio::sync::mpsc::error::TrySendError::Full(_) => {
1268 m.record_peer_send_full(target.peer_idx, &peer_dc);
1269 }
1270 tokio::sync::mpsc::error::TrySendError::Closed(_) => {
1271 m.record_peer_send_closed(target.peer_idx, &peer_dc);
1272 }
1273 }
1274 }
1275 }
1276
1277 fn hint_target(
1281 &self,
1282 target: &ReplicaTarget,
1283 bytes: &[u8],
1284 req: &Msg,
1285 req_span: &tracing::Span,
1286 intermediate_tx: &mpsc::Sender<OutboundEnvelope>,
1287 ) -> bool {
1288 let Some(store) = self.hint_store.as_ref() else {
1289 return false;
1290 };
1291 let cfg = self.pool.config();
1292 let ttl = std::time::Duration::from_secs(cfg.hint_ttl_seconds.max(1));
1293 match store.enqueue(target.peer_idx, bytes.to_vec(), ttl) {
1294 Ok(()) => {}
1295 Err(e) => {
1296 tracing::debug!(
1297 target: "dynomite::hints",
1298 peer_idx = target.peer_idx,
1299 error = %e,
1300 "hint enqueue failed"
1301 );
1302 return false;
1303 }
1304 }
1305 let synth = synth_hint_reply(req, &self.mbuf_pool);
1306 let env = OutboundEnvelope {
1307 req_id: req.id(),
1308 rsp: synth,
1309 span: req_span.clone(),
1310 source_peer_idx: Some(target.peer_idx),
1311 };
1312 if intermediate_tx.try_send(env).is_err() {
1313 tracing::debug!(
1318 target: "dynomite::hints",
1319 peer_idx = target.peer_idx,
1320 "hint synth-reply could not be queued; coalescer absent"
1321 );
1322 }
1323 tracing::debug!(
1324 target: "dynomite::hints",
1325 peer_idx = target.peer_idx,
1326 bytes = bytes.len(),
1327 "stored hint for down peer"
1328 );
1329 true
1330 }
1331
1332 fn dispatch_replicas_direct(
1333 &self,
1334 req: &Msg,
1335 req_span: &tracing::Span,
1336 targets: &[ReplicaTarget],
1337 bytes: &[u8],
1338 responder: &ServerSink,
1339 ctx: &HandoffCtx<'_>,
1340 ) -> DispatchOutcome {
1341 debug_assert_eq!(targets.len(), 1);
1342 let target = &targets[0];
1343 if let TargetAction::Hint =
1347 Self::choose_target_action(target, ctx.handoff_active, ctx.peer_states)
1348 {
1349 if self.hint_single_target_direct(target, bytes, req, req_span, responder) {
1350 return DispatchOutcome::Pending;
1351 }
1352 return DispatchOutcome::Error(self.no_quorum_error(req));
1353 }
1354 let env = OutboundRequest {
1355 bytes: bytes.to_vec(),
1356 req_id: req.id(),
1357 responder: responder.clone(),
1358 span: req_span.clone(),
1359 ty: if target.is_local {
1363 crate::proto::dnode::DmsgType::Req
1364 } else {
1365 crate::proto::dnode::DmsgType::ReqForward
1366 },
1367 target_peer_idx: Some(target.peer_idx),
1368 };
1369 let send_result = if target.is_local {
1370 self.backend.as_ref().map(|tx| tx.try_send(env))
1371 } else {
1372 self.peer_backends
1373 .get(&target.peer_idx)
1374 .map(|tx| tx.try_send(env))
1375 };
1376 let sent = match send_result {
1377 Some(Ok(())) => true,
1378 Some(Err(ref err)) => {
1379 self.observe_send_error(target, err);
1380 false
1381 }
1382 None => false,
1383 };
1384 if sent {
1385 return DispatchOutcome::Pending;
1386 }
1387 if ctx.handoff_active
1388 && self.hint_single_target_direct(target, bytes, req, req_span, responder)
1389 {
1390 return DispatchOutcome::Pending;
1391 }
1392 DispatchOutcome::Error(self.no_quorum_error(req))
1393 }
1394
1395 fn hint_single_target_direct(
1400 &self,
1401 target: &ReplicaTarget,
1402 bytes: &[u8],
1403 req: &Msg,
1404 req_span: &tracing::Span,
1405 responder: &ServerSink,
1406 ) -> bool {
1407 let Some(store) = self.hint_store.as_ref() else {
1408 return false;
1409 };
1410 let cfg = self.pool.config();
1411 let ttl = std::time::Duration::from_secs(cfg.hint_ttl_seconds.max(1));
1412 if let Err(e) = store.enqueue(target.peer_idx, bytes.to_vec(), ttl) {
1413 tracing::debug!(
1414 target: "dynomite::hints",
1415 peer_idx = target.peer_idx,
1416 error = %e,
1417 "hint enqueue failed (single-target)"
1418 );
1419 return false;
1420 }
1421 let synth = synth_hint_reply(req, &self.mbuf_pool);
1422 let env = OutboundEnvelope {
1423 req_id: req.id(),
1424 rsp: synth,
1425 span: req_span.clone(),
1426 source_peer_idx: Some(target.peer_idx),
1427 };
1428 let _ = responder.try_send(env);
1429 true
1430 }
1431
1432 fn no_quorum_error(&self, req: &Msg) -> Msg {
1433 let err_type = if matches!(req.ty(), MsgType::ReqRedisGet | MsgType::ReqRedisSet) {
1434 MsgType::RspRedisError
1435 } else {
1436 MsgType::RspMcServerError
1437 };
1438 crate::msg::response::make_error(
1439 req,
1440 err_type,
1441 0,
1442 crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
1443 &self.mbuf_pool,
1444 )
1445 }
1446
1447 fn intercept_command(
1455 &self,
1456 ext: &dyn crate::embed::CommandExtension,
1457 req: &Msg,
1458 ) -> Option<DispatchOutcome> {
1459 if ext.handles_msg_type(req.ty()) {
1460 return Some(self.run_extension_command(ext, req));
1461 }
1462 if matches!(req.ty(), MsgType::ReqRedisHset) {
1463 return self.intercept_extension_hset(ext, req);
1464 }
1465 None
1466 }
1467
1468 fn run_extension_command(
1475 &self,
1476 ext: &dyn crate::embed::CommandExtension,
1477 req: &Msg,
1478 ) -> DispatchOutcome {
1479 let recovered_kw: Vec<u8>;
1487 let keyword: &[u8] = match req.ty() {
1488 MsgType::ReqRedisFtCreate => b"FT.CREATE",
1489 MsgType::ReqRedisFtSearch => b"FT.SEARCH",
1490 MsgType::ReqRedisFtInfo => b"FT.INFO",
1491 MsgType::ReqRedisFtList => b"FT.LIST",
1492 MsgType::ReqRedisFtDropindex => b"FT.DROPINDEX",
1493 MsgType::ReqRedisFtRegex => b"FT.REGEX",
1494 MsgType::ReqRedisFtSugadd => b"FT.SUGADD",
1495 MsgType::ReqRedisFtSugget => b"FT.SUGGET",
1496 MsgType::ReqRedisFtSugdel => b"FT.SUGDEL",
1497 MsgType::ReqRedisFtSuglen => b"FT.SUGLEN",
1498 MsgType::ReqRedisFtUnknown => {
1499 recovered_kw = first_bulk_token(req).unwrap_or_else(|| b"FT.UNKNOWN".to_vec());
1500 recovered_kw.as_slice()
1501 }
1502 _ => return DispatchOutcome::Drop,
1507 };
1508 let mut args: Vec<&[u8]> = Vec::with_capacity(1 + req.keys().len() + req.args().len());
1509 args.push(keyword);
1510 for k in req.keys() {
1511 args.push(k.key());
1512 }
1513 for a in req.args() {
1514 args.push(a.bytes());
1515 }
1516 let bytes = ext.try_dispatch(&args).unwrap_or_else(|| {
1517 let kw = String::from_utf8_lossy(keyword);
1518 format!("-ERR not supported in this build: {kw}\r\n").into_bytes()
1519 });
1520 DispatchOutcome::Inline(synthetic_redis_reply(req, &self.mbuf_pool, &bytes))
1521 }
1522
1523 fn intercept_extension_hset(
1529 &self,
1530 ext: &dyn crate::embed::CommandExtension,
1531 req: &Msg,
1532 ) -> Option<DispatchOutcome> {
1533 let mut args: Vec<&[u8]> = Vec::with_capacity(req.keys().len() + req.args().len());
1534 for k in req.keys() {
1535 args.push(k.key());
1536 }
1537 for a in req.args() {
1538 args.push(a.bytes());
1539 }
1540 match ext.try_intercept_hset(&args) {
1541 crate::embed::HsetOutcome::Absorbed | crate::embed::HsetOutcome::NotIndexed => None,
1542 crate::embed::HsetOutcome::Error(message) => {
1543 let payload = format!("-ERR {message}\r\n");
1544 Some(DispatchOutcome::Error(synthetic_redis_reply(
1545 req,
1546 &self.mbuf_pool,
1547 payload.as_bytes(),
1548 )))
1549 }
1550 }
1551 }
1552}
1553
1554fn synthetic_redis_reply(req: &Msg, pool: &MbufPool, payload: &[u8]) -> Msg {
1560 let mut rsp = Msg::new(req.id(), MsgType::RspRedisStatus, false);
1561 rsp.set_parent_id(req.id());
1562 let mut written = 0usize;
1563 while written < payload.len() {
1564 let mut buf = pool.get();
1565 let n = buf.recv(&payload[written..]);
1566 debug_assert!(
1567 n > 0,
1568 "MbufPool returned a buffer with zero writable capacity"
1569 );
1570 rsp.mbufs_mut().push_back(buf);
1571 written += n;
1572 }
1573 rsp.recompute_mlen();
1574 rsp
1575}
1576
1577fn first_bulk_token(req: &Msg) -> Option<Vec<u8>> {
1587 let mut wire: Vec<u8> = Vec::new();
1588 for buf in req.mbufs() {
1589 wire.extend_from_slice(buf.readable());
1590 if wire.len() > 256 {
1591 break;
1592 }
1593 }
1594 let mut p = 0usize;
1595 if wire.first() == Some(&b'*') {
1596 let cr = wire.iter().position(|&b| b == b'\r')?;
1597 if wire.get(cr + 1) != Some(&b'\n') {
1598 return None;
1599 }
1600 p = cr + 2;
1601 }
1602 if wire.get(p) != Some(&b'$') {
1603 return None;
1604 }
1605 let header_start = p + 1;
1606 let header_cr = wire[header_start..]
1607 .iter()
1608 .position(|&b| b == b'\r')
1609 .map(|i| header_start + i)?;
1610 if wire.get(header_cr + 1) != Some(&b'\n') {
1611 return None;
1612 }
1613 let len_str = std::str::from_utf8(&wire[header_start..header_cr]).ok()?;
1614 let len: usize = len_str.parse().ok()?;
1615 let body_start = header_cr + 2;
1616 let body_end = body_start.checked_add(len)?;
1617 if wire.len() < body_end + 2 {
1618 return None;
1619 }
1620 Some(wire[body_start..body_end].to_vec())
1621}
1622
1623#[derive(Clone)]
1626struct ReadRepairContext {
1627 req_id: crate::core::types::MsgId,
1628 req_ty: MsgType,
1629 key: Vec<u8>,
1633 mbuf_pool: MbufPool,
1634 peer_backends: std::collections::HashMap<u32, mpsc::Sender<OutboundRequest>>,
1635 local_backend: Option<mpsc::Sender<OutboundRequest>>,
1636 target_is_local: std::collections::HashMap<u32, bool>,
1637}
1638
1639#[allow(
1641 clippy::too_many_arguments,
1642 reason = "actor task captures the entire dispatch context; bundling into a struct adds churn for no callsite gain"
1643)]
1644async fn coalesce_actor(
1645 req_id: crate::core::types::MsgId,
1646 req_ty: MsgType,
1647 consistency: ConsistencyLevel,
1648 targets: Vec<(u32, String)>,
1649 local_dc: String,
1650 mut intermediate_rx: mpsc::Receiver<OutboundEnvelope>,
1651 client_tx: ServerSink,
1652 mbuf_pool: MbufPool,
1653 repair_ctx: Option<ReadRepairContext>,
1654 failure_metrics: Option<Arc<crate::stats::FailureMetrics>>,
1655) {
1656 use crate::proto::redis::{CoalesceOutcome, CoalesceTracker};
1657 let mut tracker = CoalesceTracker::new(req_id, consistency, targets, &local_dc);
1658 let mut emitted = false;
1659 while let Some(env) = intermediate_rx.recv().await {
1660 let source = env.source_peer_idx.unwrap_or(u32::MAX);
1661 let span = env.span.clone();
1662 let outcome = tracker.record_reply(source, env.rsp);
1663 match outcome {
1664 CoalesceOutcome::Pending => {}
1665 CoalesceOutcome::Ready {
1666 winner,
1667 divergent_targets,
1668 } => {
1669 if !emitted {
1670 let winner_bytes: Vec<u8> = winner
1671 .mbufs()
1672 .iter()
1673 .flat_map(|b| b.readable().to_vec())
1674 .collect();
1675 let out_env = OutboundEnvelope {
1676 req_id,
1677 rsp: *winner,
1678 span: span.clone(),
1679 source_peer_idx: None,
1680 };
1681 let _ = client_tx.send(out_env).await;
1682 emitted = true;
1683 if !divergent_targets.is_empty() {
1684 if let Some(ctx) = repair_ctx.as_ref() {
1685 schedule_read_repair(ctx, &divergent_targets, &winner_bytes, &span);
1686 }
1687 }
1688 }
1689 }
1690 CoalesceOutcome::Error(reason) => {
1691 if !emitted {
1692 let err_type = if matches!(req_ty, MsgType::ReqRedisGet | MsgType::ReqRedisSet)
1693 {
1694 MsgType::RspRedisError
1695 } else {
1696 MsgType::RspMcServerError
1697 };
1698 let anchor = Msg::new(req_id, req_ty, true);
1699 let rsp = crate::msg::response::make_error(
1700 &anchor,
1701 err_type,
1702 0,
1703 crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
1704 &mbuf_pool,
1705 );
1706 let _ = client_tx
1707 .send(OutboundEnvelope {
1708 req_id,
1709 rsp,
1710 span: span.clone(),
1711 source_peer_idx: None,
1712 })
1713 .await;
1714 emitted = true;
1715 }
1716 tracing::debug!(target: "dynomite::coalesce", req_id, reason = %reason, "coalesce error");
1717 }
1718 }
1719 }
1720 if !emitted {
1721 if let Some(m) = failure_metrics.as_ref() {
1728 m.record_response_timeout(consistency);
1729 }
1730 let err_type = if matches!(req_ty, MsgType::ReqRedisGet | MsgType::ReqRedisSet) {
1731 MsgType::RspRedisError
1732 } else {
1733 MsgType::RspMcServerError
1734 };
1735 let anchor = Msg::new(req_id, req_ty, true);
1736 let rsp = crate::msg::response::make_error(
1737 &anchor,
1738 err_type,
1739 0,
1740 crate::msg::DynErrorCode::DynomiteNoQuorumAchieved,
1741 &mbuf_pool,
1742 );
1743 let _ = client_tx
1744 .send(OutboundEnvelope {
1745 req_id,
1746 rsp,
1747 span: tracing::Span::none(),
1748 source_peer_idx: None,
1749 })
1750 .await;
1751 }
1752}
1753
1754fn repair_sink() -> ServerSink {
1757 let (tx, mut rx) = mpsc::channel::<OutboundEnvelope>(8);
1758 tokio::spawn(async move {
1759 while rx.recv().await.is_some() {
1760 }
1763 });
1764 tx
1765}
1766
1767fn decode_winner_for_repair(payload: &[u8]) -> Option<RepairAction> {
1776 if payload == b"$-1\r\n" {
1777 return Some(RepairAction::Delete);
1778 }
1779 if !payload.starts_with(b"$") {
1780 return None;
1781 }
1782 let crlf = payload.iter().position(|&b| b == b'\r')?;
1784 if payload.get(crlf + 1).copied() != Some(b'\n') {
1785 return None;
1786 }
1787 let len_str = std::str::from_utf8(&payload[1..crlf]).ok()?;
1788 let len: usize = len_str.parse().ok()?;
1789 let body_start = crlf + 2;
1790 let body_end = body_start.checked_add(len)?;
1791 if payload.len() < body_end + 2 {
1792 return None;
1793 }
1794 if &payload[body_end..body_end + 2] != b"\r\n" {
1795 return None;
1796 }
1797 Some(RepairAction::Write(payload[body_start..body_end].to_vec()))
1798}
1799
1800struct HandoffCtx<'a> {
1806 handoff_active: bool,
1807 peer_states: &'a std::collections::HashMap<u32, crate::cluster::peer::PeerState>,
1808}
1809
1810#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1813enum TargetAction {
1814 Send,
1816 Hint,
1819}
1820
1821fn synth_hint_reply(req: &Msg, pool: &MbufPool) -> Msg {
1832 crate::msg::response::make_simple_redis(req, pool, b"+OK\r\n")
1833}
1834
1835enum RepairAction {
1838 Write(Vec<u8>),
1840 Delete,
1843}
1844
1845fn build_repair_bytes(action: &RepairAction, key: &[u8]) -> Vec<u8> {
1847 match action {
1848 RepairAction::Write(value) => {
1849 let mut out = Vec::with_capacity(key.len() + value.len() + 32);
1850 out.extend_from_slice(b"*3\r\n$3\r\nSET\r\n$");
1851 out.extend_from_slice(key.len().to_string().as_bytes());
1852 out.extend_from_slice(b"\r\n");
1853 out.extend_from_slice(key);
1854 out.extend_from_slice(b"\r\n$");
1855 out.extend_from_slice(value.len().to_string().as_bytes());
1856 out.extend_from_slice(b"\r\n");
1857 out.extend_from_slice(value);
1858 out.extend_from_slice(b"\r\n");
1859 out
1860 }
1861 RepairAction::Delete => {
1862 let mut out = Vec::with_capacity(key.len() + 24);
1863 out.extend_from_slice(b"*2\r\n$3\r\nDEL\r\n$");
1864 out.extend_from_slice(key.len().to_string().as_bytes());
1865 out.extend_from_slice(b"\r\n");
1866 out.extend_from_slice(key);
1867 out.extend_from_slice(b"\r\n");
1868 out
1869 }
1870 }
1871}
1872
1873fn schedule_read_repair(
1891 ctx: &ReadRepairContext,
1892 divergent: &[u32],
1893 winner_bytes: &[u8],
1894 span: &tracing::Span,
1895) {
1896 if !matches!(ctx.req_ty, MsgType::ReqRedisGet) {
1897 return;
1898 }
1899 let Some(action) = decode_winner_for_repair(winner_bytes) else {
1900 return;
1901 };
1902 let bytes = build_repair_bytes(&action, &ctx.key);
1903 let sink = repair_sink();
1904 for &peer_idx in divergent {
1905 let is_local = ctx.target_is_local.get(&peer_idx).copied().unwrap_or(false);
1906 let env = OutboundRequest {
1907 bytes: bytes.clone(),
1908 req_id: ctx.req_id,
1909 responder: sink.clone(),
1910 span: span.clone(),
1911 ty: crate::proto::dnode::DmsgType::ReqForward,
1912 target_peer_idx: Some(peer_idx),
1913 };
1914 let sent = if is_local {
1915 ctx.local_backend
1916 .as_ref()
1917 .is_some_and(|tx| tx.try_send(env).is_ok())
1918 } else {
1919 ctx.peer_backends
1920 .get(&peer_idx)
1921 .is_some_and(|tx| tx.try_send(env).is_ok())
1922 };
1923 if sent {
1924 let _ = &ctx.mbuf_pool;
1925 tracing::debug!(
1926 target: "dynomite::read_repair",
1927 req_id = ctx.req_id,
1928 peer_idx,
1929 bytes = bytes.len(),
1930 "scheduled read-repair write",
1931 );
1932 } else {
1933 tracing::debug!(
1934 target: "dynomite::read_repair",
1935 req_id = ctx.req_id,
1936 peer_idx,
1937 "read-repair drop: backend channel unavailable or full",
1938 );
1939 }
1940 }
1941}
1942
1943#[cfg(test)]
1944mod tests {
1945 use super::*;
1946 use crate::cluster::peer::{Peer, PeerEndpoint, PeerState};
1947 use crate::conf::DataStore;
1948 use crate::hashkit::DynToken;
1949
1950 fn cfg(read: ConsistencyLevel, write: ConsistencyLevel) -> crate::cluster::PoolConfig {
1951 crate::cluster::PoolConfig {
1952 read_consistency: read,
1953 write_consistency: write,
1954 dc: "dc1".into(),
1955 rack: "rA".into(),
1956 ..crate::cluster::PoolConfig::default()
1957 }
1958 }
1959
1960 fn peer(idx: u32, dc: &str, rack: &str, tok: u32, is_local: bool, is_same: bool) -> Peer {
1961 let mut p = Peer::new(
1962 idx,
1963 PeerEndpoint::tcp("h".into(), 8101 + u16::try_from(idx).unwrap_or(0)),
1964 rack.into(),
1965 dc.into(),
1966 vec![DynToken::from_u32(tok)],
1967 is_local,
1968 is_same,
1969 false,
1970 );
1971 p.set_state(PeerState::Normal, 0);
1972 p
1973 }
1974
1975 fn pool(read: ConsistencyLevel, write: ConsistencyLevel, peers: Vec<Peer>) -> Arc<ServerPool> {
1976 let pool = ServerPool::new(cfg(read, write), peers);
1977 pool.preselect_remote_racks();
1978 Arc::new(pool)
1979 }
1980
1981 #[test]
1982 fn local_node_only_short_circuits() {
1983 let p = pool(
1984 ConsistencyLevel::DcOne,
1985 ConsistencyLevel::DcOne,
1986 vec![peer(0, "dc1", "rA", 10, true, true)],
1987 );
1988 let mut req = Msg::new(1, MsgType::ReqRedisGet, true);
1989 req.set_routing(MsgRouting::LocalNodeOnly);
1990 assert_eq!(
1991 ClusterDispatcher::new(p).plan(&req, b"k"),
1992 DispatchPlan::LocalDatastore,
1993 );
1994 }
1995
1996 #[test]
1997 fn dc_one_read_targets_local_rack_when_present() {
1998 let p = pool(
1999 ConsistencyLevel::DcOne,
2000 ConsistencyLevel::DcOne,
2001 vec![
2002 peer(0, "dc1", "rA", 10, true, true),
2003 peer(1, "dc1", "rB", 20, false, true),
2004 peer(2, "dc2", "rA", 30, false, false),
2005 ],
2006 );
2007 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2008 let plan = ClusterDispatcher::new(p).plan(&req, b"hello");
2010 assert!(matches!(plan, DispatchPlan::LocalDatastore));
2011 }
2012
2013 #[test]
2021 fn dc_one_write_fans_out_to_all_local_replicas() {
2022 let p = pool(
2026 ConsistencyLevel::DcOne,
2027 ConsistencyLevel::DcOne,
2028 vec![
2029 peer(0, "dc1", "rA", 0, true, true),
2030 peer(1, "dc1", "rB", 0, false, true),
2031 peer(2, "dc1", "rC", 0, false, true),
2032 ],
2033 );
2034 let mut req = Msg::new(1, MsgType::ReqRedisSet, false);
2036 req.flags_mut().is_read = false;
2037 req.push_key(crate::msg::keypos::KeyPos::without_tag(b"k".to_vec()));
2038 match ClusterDispatcher::new(p).plan(&req, b"k") {
2039 DispatchPlan::Replicas { targets, .. } => {
2040 assert_eq!(
2041 targets.len(),
2042 3,
2043 "DC_ONE write fans out to all 3 local-DC replicas"
2044 );
2045 for t in &targets {
2046 assert_eq!(t.dc, "dc1");
2047 }
2048 }
2049 other => panic!("expected a 3-replica fan-out for a DC_ONE write, got {other:?}"),
2050 }
2051 }
2052
2053 #[test]
2056 fn dc_one_read_picks_one_replica() {
2057 let p = pool(
2058 ConsistencyLevel::DcOne,
2059 ConsistencyLevel::DcOne,
2060 vec![
2061 peer(0, "dc1", "rA", 0, true, true),
2062 peer(1, "dc1", "rB", 0, false, true),
2063 peer(2, "dc1", "rC", 0, false, true),
2064 ],
2065 );
2066 let mut req = Msg::new(1, MsgType::ReqRedisGet, true);
2067 req.push_key(crate::msg::keypos::KeyPos::without_tag(b"k".to_vec()));
2068 assert!(matches!(
2070 ClusterDispatcher::new(p).plan(&req, b"k"),
2071 DispatchPlan::LocalDatastore
2072 ));
2073 }
2074
2075 #[test]
2076 fn dc_one_partitions_the_ring_within_a_shared_rack() {
2077 let p = pool(
2085 ConsistencyLevel::DcOne,
2086 ConsistencyLevel::DcOne,
2087 vec![
2088 peer(0, "dc1", "rA", 0, true, true),
2089 peer(1, "dc1", "rA", 1_431_655_765, false, true),
2090 peer(2, "dc1", "rA", 2_863_311_530, false, true),
2091 ],
2092 );
2093 let disp = ClusterDispatcher::new(p);
2094 let mut saw_remote = false;
2098 let mut saw_local = false;
2099 for i in 0..200u32 {
2100 let key = format!("key-{i}");
2101 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2102 match disp.plan(&req, key.as_bytes()) {
2103 DispatchPlan::LocalDatastore => saw_local = true,
2104 DispatchPlan::Replicas { targets, .. } => {
2105 assert_eq!(targets.len(), 1, "DC_ONE picks one owner");
2106 assert_eq!(targets[0].dc, "dc1");
2107 saw_remote = true;
2108 }
2109 other => panic!("unexpected plan {other:?}"),
2110 }
2111 }
2112 assert!(
2113 saw_remote,
2114 "keys owned by a remote same-rack node must route to it, not stay local"
2115 );
2116 assert!(saw_local, "keys owned by the local node stay local");
2117 }
2118
2119 #[tokio::test]
2127 async fn remote_forward_is_tagged_req_forward() {
2128 let p = pool(
2132 ConsistencyLevel::DcOne,
2133 ConsistencyLevel::DcOne,
2134 vec![
2135 peer(0, "dc1", "rA", 0, true, true),
2136 peer(1, "dc1", "rA", 2_147_483_648, false, true),
2137 ],
2138 );
2139 let (tx, mut rx) = mpsc::channel::<crate::net::server::OutboundRequest>(64);
2140 let disp = ClusterDispatcher::new(p).with_peer_backend(1, tx);
2141 let pool_buf = crate::io::mbuf::MbufPool::default();
2143 let mut forwarded_ty = None;
2144 for i in 0..500u32 {
2145 let key = format!("k{i}");
2146 let mut req = Msg::new(1, MsgType::ReqRedisSet, false);
2147 req.push_key(crate::msg::keypos::KeyPos::without_tag(key.into_bytes()));
2148 let mut buf = pool_buf.get();
2149 buf.copy_from_slice(b"*3\r\n$3\r\nSET\r\n$1\r\nx\r\n$1\r\ny\r\n");
2150 req.mbufs_mut().push_back(buf);
2151 let (resp_tx, _resp_rx) = mpsc::channel(1);
2152 let _ = disp.dispatch(req, resp_tx);
2153 if let Ok(env) = rx.try_recv() {
2154 forwarded_ty = Some(env.ty);
2155 break;
2156 }
2157 }
2158 assert_eq!(
2159 forwarded_ty,
2160 Some(crate::proto::dnode::DmsgType::ReqForward),
2161 "a forward to a remote peer must be ReqForward so the target serves it locally"
2162 );
2163 }
2164
2165 #[test]
2166 fn dc_quorum_fans_out_local_dc() {
2167 let p = pool(
2168 ConsistencyLevel::DcQuorum,
2169 ConsistencyLevel::DcQuorum,
2170 vec![
2171 peer(0, "dc1", "rA", 10, true, true),
2172 peer(1, "dc1", "rB", 20, false, true),
2173 peer(2, "dc2", "rA", 30, false, false),
2174 ],
2175 );
2176 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2177 let plan = ClusterDispatcher::new(p).plan(&req, b"k");
2178 match plan {
2179 DispatchPlan::Replicas { targets: rs, .. } => {
2180 assert_eq!(rs.len(), 2);
2181 for r in rs {
2182 assert_eq!(r.dc, "dc1");
2183 }
2184 }
2185 _ => panic!("expected replicas"),
2186 }
2187 }
2188
2189 #[test]
2190 fn dc_each_safe_quorum_fans_out_per_dc() {
2191 let p = pool(
2192 ConsistencyLevel::DcEachSafeQuorum,
2193 ConsistencyLevel::DcEachSafeQuorum,
2194 vec![
2195 peer(0, "dc1", "rA", 10, true, true),
2196 peer(1, "dc2", "rA", 20, false, false),
2197 ],
2198 );
2199 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2200 let plan = ClusterDispatcher::new(p).plan(&req, b"k");
2201 match plan {
2202 DispatchPlan::Replicas { targets: rs, .. } => {
2203 assert_eq!(rs.len(), 2);
2204 let dcs: Vec<&str> = rs.iter().map(|r| r.dc.as_str()).collect();
2205 assert!(dcs.contains(&"dc1"));
2206 assert!(dcs.contains(&"dc2"));
2207 }
2208 _ => panic!("expected replicas"),
2209 }
2210 }
2211
2212 #[test]
2213 fn no_routable_peers_returns_no_targets() {
2214 let mut p0 = peer(0, "dc1", "rA", 10, true, true);
2215 p0.set_state(PeerState::Down, 0);
2216 let p = pool(
2217 ConsistencyLevel::DcQuorum,
2218 ConsistencyLevel::DcQuorum,
2219 vec![p0],
2220 );
2221 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2222 let plan = ClusterDispatcher::new(p).plan(&req, b"k");
2223 assert_eq!(plan, DispatchPlan::NoTargets);
2224 }
2225
2226 #[test]
2234 fn no_targets_error_response_carries_dynomite_wire_bytes() {
2235 let mut p0 = peer(0, "dc1", "rA", 10, true, true);
2236 p0.set_state(PeerState::Down, 0);
2237 let p = pool(
2238 ConsistencyLevel::DcQuorum,
2239 ConsistencyLevel::DcQuorum,
2240 vec![p0],
2241 );
2242 let disp = ClusterDispatcher::new(p);
2243 let mut req = Msg::new(1, MsgType::ReqRedisGet, true);
2244 req.push_key(crate::msg::keypos::KeyPos::without_tag(b"k".to_vec()));
2245 let (tx, _rx) = mpsc::channel(1);
2246 let outcome = disp.dispatch(req, tx);
2247 match outcome {
2248 DispatchOutcome::Error(rsp) => {
2249 assert_eq!(rsp.ty(), MsgType::RspRedisError);
2250 assert!(rsp.flags().is_error);
2251 let bytes: Vec<u8> = rsp
2252 .mbufs()
2253 .iter()
2254 .flat_map(|b| b.readable().to_vec())
2255 .collect();
2256 assert!(
2257 !bytes.is_empty(),
2258 "NoTargets must produce on-wire bytes, not a 0-byte hang"
2259 );
2260 assert!(bytes.starts_with(b"-Dynomite: "));
2261 assert!(bytes.ends_with(b"\r\n"));
2262 assert_eq!(rsp.mlen() as usize, bytes.len());
2263 }
2264 other => panic!("expected DispatchOutcome::Error, got {other:?}"),
2265 }
2266 }
2267
2268 #[test]
2272 fn no_targets_error_response_memcache_wire_bytes() {
2273 let mut cfg = cfg(ConsistencyLevel::DcQuorum, ConsistencyLevel::DcQuorum);
2279 cfg.data_store = DataStore::Memcache;
2280 let mut p0 = peer(0, "dc1", "rA", 10, true, true);
2281 p0.set_state(PeerState::Down, 0);
2282 let pool_arc = ServerPool::new(cfg, vec![p0]);
2283 pool_arc.preselect_remote_racks();
2284 let disp = ClusterDispatcher::new(Arc::new(pool_arc));
2285 let mut req = Msg::new(1, MsgType::ReqMcGet, true);
2286 req.push_key(crate::msg::keypos::KeyPos::without_tag(b"k".to_vec()));
2287 let (tx, _rx) = mpsc::channel(1);
2288 let outcome = disp.dispatch(req, tx);
2289 match outcome {
2290 DispatchOutcome::Error(rsp) => {
2291 assert_eq!(rsp.ty(), MsgType::RspMcServerError);
2292 let bytes: Vec<u8> = rsp
2293 .mbufs()
2294 .iter()
2295 .flat_map(|b| b.readable().to_vec())
2296 .collect();
2297 assert!(
2298 !bytes.is_empty(),
2299 "NoTargets must produce on-wire bytes, not a 0-byte hang"
2300 );
2301 assert!(bytes.starts_with(b"SERVER_ERROR "));
2302 assert!(bytes.ends_with(b"\r\n"));
2303 }
2304 other => panic!("expected DispatchOutcome::Error, got {other:?}"),
2305 }
2306 }
2307
2308 use crate::cluster::pool::{BucketType, PoolConfig};
2309
2310 fn pool_with_bucket_types(
2311 pool_read: ConsistencyLevel,
2312 pool_write: ConsistencyLevel,
2313 bucket_types: Vec<BucketType>,
2314 default_bucket_type: Option<&str>,
2315 peers: Vec<Peer>,
2316 ) -> Arc<ServerPool> {
2317 let cfg = PoolConfig {
2318 read_consistency: pool_read,
2319 write_consistency: pool_write,
2320 dc: "dc1".into(),
2321 rack: "rA".into(),
2322 bucket_types,
2323 default_bucket_type: default_bucket_type.map(str::to_string),
2324 ..PoolConfig::default()
2325 };
2326 let pool = ServerPool::new(cfg, peers);
2327 pool.preselect_remote_racks();
2328 Arc::new(pool)
2329 }
2330
2331 fn three_local_peers() -> Vec<Peer> {
2332 vec![
2333 peer(0, "dc1", "rA", 10, true, true),
2334 peer(1, "dc1", "rB", 20, false, true),
2335 peer(2, "dc1", "rC", 30, false, true),
2336 ]
2337 }
2338
2339 #[test]
2340 fn bucket_type_overrides_pool_consistency() {
2341 let bts = vec![BucketType {
2343 name: "hot".into(),
2344 read_consistency: ConsistencyLevel::DcQuorum,
2345 write_consistency: ConsistencyLevel::DcQuorum,
2346 n_val: 0,
2347 }];
2348 let p = pool_with_bucket_types(
2349 ConsistencyLevel::DcOne,
2350 ConsistencyLevel::DcOne,
2351 bts,
2352 None,
2353 three_local_peers(),
2354 );
2355 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2356 let plan = ClusterDispatcher::new(p).plan(&req, b"hot/key1");
2357 match plan {
2358 DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2359 other => panic!("expected DC_QUORUM fan-out, got {other:?}"),
2360 }
2361 }
2362
2363 #[test]
2364 fn slashless_key_falls_back_to_pool_default() {
2365 let bts = vec![BucketType {
2366 name: "hot".into(),
2367 read_consistency: ConsistencyLevel::DcQuorum,
2368 write_consistency: ConsistencyLevel::DcQuorum,
2369 n_val: 0,
2370 }];
2371 let p = pool_with_bucket_types(
2372 ConsistencyLevel::DcOne,
2373 ConsistencyLevel::DcOne,
2374 bts,
2375 None,
2376 three_local_peers(),
2377 );
2378 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2379 let plan = ClusterDispatcher::new(p).plan(&req, b"plain-key");
2380 assert!(matches!(plan, DispatchPlan::LocalDatastore));
2383 }
2384
2385 #[test]
2386 fn unknown_bucket_uses_default_bucket_type_when_set() {
2387 let bts = vec![BucketType {
2388 name: "safe".into(),
2389 read_consistency: ConsistencyLevel::DcQuorum,
2390 write_consistency: ConsistencyLevel::DcQuorum,
2391 n_val: 0,
2392 }];
2393 let p = pool_with_bucket_types(
2394 ConsistencyLevel::DcOne,
2395 ConsistencyLevel::DcOne,
2396 bts,
2397 Some("safe"),
2398 three_local_peers(),
2399 );
2400 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2401 let plan = ClusterDispatcher::new(p.clone()).plan(&req, b"plain-key");
2404 match plan {
2405 DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2406 other => panic!("expected DC_QUORUM via default bucket, got {other:?}"),
2407 }
2408 let plan = ClusterDispatcher::new(p).plan(&req, b"unknown-bucket/key");
2411 match plan {
2412 DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2413 other => panic!("expected DC_QUORUM via default bucket, got {other:?}"),
2414 }
2415 }
2416
2417 #[test]
2418 fn unknown_bucket_with_no_default_uses_pool_default() {
2419 let bts = vec![BucketType {
2420 name: "safe".into(),
2421 read_consistency: ConsistencyLevel::DcQuorum,
2422 write_consistency: ConsistencyLevel::DcQuorum,
2423 n_val: 0,
2424 }];
2425 let p = pool_with_bucket_types(
2426 ConsistencyLevel::DcOne,
2427 ConsistencyLevel::DcOne,
2428 bts,
2429 None,
2430 three_local_peers(),
2431 );
2432 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2433 let plan = ClusterDispatcher::new(p).plan(&req, b"unknown-bucket/key");
2434 assert!(matches!(plan, DispatchPlan::LocalDatastore));
2435 }
2436
2437 #[test]
2438 fn n_val_one_caps_replicas_to_first_target() {
2439 let bts = vec![BucketType {
2440 name: "thin".into(),
2441 read_consistency: ConsistencyLevel::DcQuorum,
2442 write_consistency: ConsistencyLevel::DcQuorum,
2443 n_val: 1,
2444 }];
2445 let p = pool_with_bucket_types(
2446 ConsistencyLevel::DcOne,
2447 ConsistencyLevel::DcOne,
2448 bts,
2449 None,
2450 three_local_peers(),
2451 );
2452 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2453 let plan = ClusterDispatcher::new(p).plan(&req, b"thin/key");
2454 match plan {
2455 DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 1),
2456 other => panic!("expected single-target plan, got {other:?}"),
2457 }
2458 }
2459
2460 #[test]
2461 fn n_val_two_caps_replicas_to_first_two_targets() {
2462 let bts = vec![BucketType {
2463 name: "medium".into(),
2464 read_consistency: ConsistencyLevel::DcQuorum,
2465 write_consistency: ConsistencyLevel::DcQuorum,
2466 n_val: 2,
2467 }];
2468 let p = pool_with_bucket_types(
2469 ConsistencyLevel::DcOne,
2470 ConsistencyLevel::DcOne,
2471 bts,
2472 None,
2473 three_local_peers(),
2474 );
2475 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2476 let plan = ClusterDispatcher::new(p).plan(&req, b"medium/key");
2477 match plan {
2478 DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 2),
2479 other => panic!("expected two-target plan, got {other:?}"),
2480 }
2481 }
2482
2483 #[test]
2484 fn n_val_zero_does_not_cap() {
2485 let bts = vec![BucketType {
2486 name: "any".into(),
2487 read_consistency: ConsistencyLevel::DcQuorum,
2488 write_consistency: ConsistencyLevel::DcQuorum,
2489 n_val: 0,
2490 }];
2491 let p = pool_with_bucket_types(
2492 ConsistencyLevel::DcOne,
2493 ConsistencyLevel::DcOne,
2494 bts,
2495 None,
2496 three_local_peers(),
2497 );
2498 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2499 let plan = ClusterDispatcher::new(p).plan(&req, b"any/key");
2500 match plan {
2501 DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2502 other => panic!("expected uncapped plan, got {other:?}"),
2503 }
2504 }
2505
2506 #[test]
2507 fn n_val_larger_than_replicas_is_a_no_op() {
2508 let bts = vec![BucketType {
2509 name: "big".into(),
2510 read_consistency: ConsistencyLevel::DcQuorum,
2511 write_consistency: ConsistencyLevel::DcQuorum,
2512 n_val: 7,
2513 }];
2514 let p = pool_with_bucket_types(
2515 ConsistencyLevel::DcOne,
2516 ConsistencyLevel::DcOne,
2517 bts,
2518 None,
2519 three_local_peers(),
2520 );
2521 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2522 let plan = ClusterDispatcher::new(p).plan(&req, b"big/key");
2523 match plan {
2524 DispatchPlan::Replicas { targets: rs, .. } => assert_eq!(rs.len(), 3),
2525 other => panic!("expected uncapped plan, got {other:?}"),
2526 }
2527 }
2528
2529 #[test]
2533 fn no_targets_records_failure_metric() {
2534 let mut p0 = peer(0, "dc1", "rA", 10, true, true);
2535 p0.set_state(PeerState::Down, 0);
2536 let p = pool(
2537 ConsistencyLevel::DcQuorum,
2538 ConsistencyLevel::DcQuorum,
2539 vec![p0],
2540 );
2541 let metrics = Arc::new(crate::stats::FailureMetrics::new());
2542 let disp = ClusterDispatcher::new(p).with_failure_metrics(metrics.clone());
2543 let req = Msg::new(1, MsgType::ReqRedisGet, true);
2544 assert_eq!(disp.plan(&req, b"k"), DispatchPlan::NoTargets);
2545 let snap = metrics.snapshot();
2546 assert_eq!(snap.no_targets.len(), 1);
2547 let entry = &snap.no_targets[0];
2548 assert_eq!(entry.dc, "dc1");
2549 assert_eq!(entry.rack, "rA");
2550 assert_eq!(entry.consistency, ConsistencyLevel::DcQuorum);
2551 assert_eq!(entry.count, 1);
2552 }
2553
2554 #[tokio::test]
2560 async fn closed_backend_channel_records_closed_metric() {
2561 let p = pool(
2562 ConsistencyLevel::DcOne,
2563 ConsistencyLevel::DcOne,
2564 vec![peer(0, "dc1", "rA", 10, true, true)],
2565 );
2566 let (tx, rx) = mpsc::channel::<crate::net::server::OutboundRequest>(4);
2567 drop(rx);
2568 let metrics = Arc::new(crate::stats::FailureMetrics::new());
2569 let disp = ClusterDispatcher::new(p)
2570 .with_backend(tx)
2571 .with_failure_metrics(metrics.clone());
2572 let mut req = Msg::new(1, MsgType::ReqRedisGet, true);
2573 let pool_buf = crate::io::mbuf::MbufPool::default();
2577 let mut buf = pool_buf.get();
2578 buf.copy_from_slice(b"PING\r\n");
2579 req.mbufs_mut().push_back(buf);
2580 let (resp_tx, _resp_rx) = mpsc::channel(1);
2581 let outcome = disp.dispatch(req, resp_tx);
2582 assert!(matches!(outcome, DispatchOutcome::Error(_)));
2583 let snap = metrics.snapshot();
2584 assert_eq!(snap.backend_send_closed, 1);
2585 assert_eq!(snap.backend_send_full, 0);
2586 }
2587
2588 #[test]
2593 fn two_peer_pool_with_one_down_records_per_key_no_targets() {
2594 let cfg = crate::cluster::PoolConfig {
2595 dc: "dc1".into(),
2596 rack: "rA".into(),
2597 read_consistency: ConsistencyLevel::DcQuorum,
2598 write_consistency: ConsistencyLevel::DcQuorum,
2599 ..crate::cluster::PoolConfig::default()
2600 };
2601 let p0 = peer(0, "dc1", "rA", 2_147_483_648, true, true);
2609 let mut p1 = peer(1, "dc1", "rA", 0, false, true);
2610 p1.set_state(PeerState::Down, 0);
2611 let pool_arc = ServerPool::new(cfg, vec![p0, p1]);
2612 pool_arc.preselect_remote_racks();
2613 let metrics = Arc::new(crate::stats::FailureMetrics::new());
2614 let disp = ClusterDispatcher::new(Arc::new(pool_arc)).with_failure_metrics(metrics.clone());
2615 let mut planned_no_targets = 0u64;
2616 let mut planned_routable = 0u64;
2617 for i in 0..100u32 {
2618 let key = format!("k{i:03}");
2619 let req = Msg::new(u64::from(i), MsgType::ReqRedisGet, true);
2620 match disp.plan(&req, key.as_bytes()) {
2621 DispatchPlan::NoTargets => planned_no_targets += 1,
2622 DispatchPlan::Replicas { .. } | DispatchPlan::LocalDatastore => {
2623 planned_routable += 1;
2624 }
2625 DispatchPlan::Drop => panic!("unexpected Drop in plan"),
2626 }
2627 }
2628 assert!(planned_no_targets > 0, "expected some NoTargets dispatches");
2629 assert!(planned_routable > 0, "expected some routable dispatches");
2630 let snap = metrics.snapshot();
2631 let counter_total: u64 = snap.no_targets.iter().map(|e| e.count).sum();
2632 assert_eq!(
2633 counter_total, planned_no_targets,
2634 "dispatch_no_targets_total must match observed NoTargets count",
2635 );
2636 assert_eq!(snap.backend_send_full, 0);
2639 assert_eq!(snap.backend_send_closed, 0);
2640 assert!(snap.peer_send_full.is_empty());
2641 assert!(snap.peer_send_closed.is_empty());
2642 }
2643}