1use super::identity::NodeIdentity;
36use super::ownership::{
37 CatalogVersion, CollectionId, OwnershipEpoch, RangeId, RangeOwnership, RangeRole,
38 ShardOwnershipCatalog,
39};
40use std::sync::{
41 atomic::{AtomicUsize, Ordering},
42 Arc,
43};
44
45pub const DEFAULT_MAX_FORWARD_PAYLOAD: usize = 1024 * 1024;
52pub const DEFAULT_MAX_FORWARD_HOPS: u8 = 1;
53pub const DEFAULT_FORWARD_IN_FLIGHT_LIMIT: usize = 64;
54pub const DEFAULT_FORWARD_PER_HOP_BUDGET_MS: u64 = 50;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum RequestOperation {
65 SafePointOp,
68 Transaction,
71 Streaming,
74 ExplicitlyUnsafe,
76}
77
78impl RequestOperation {
79 fn forwardable(self) -> Result<(), RedirectReason> {
83 match self {
84 RequestOperation::SafePointOp => Ok(()),
85 RequestOperation::Transaction => Err(RedirectReason::Transaction),
86 RequestOperation::Streaming => Err(RedirectReason::Streaming),
87 RequestOperation::ExplicitlyUnsafe => Err(RedirectReason::ExplicitlyUnsafe),
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct RoutedRequest {
97 collection: CollectionId,
98 key: Vec<u8>,
99 operation: RequestOperation,
100 payload_len: usize,
101}
102
103impl RoutedRequest {
104 pub fn new(
106 collection: CollectionId,
107 key: impl Into<Vec<u8>>,
108 operation: RequestOperation,
109 ) -> Self {
110 Self {
111 collection,
112 key: key.into(),
113 operation,
114 payload_len: 0,
115 }
116 }
117
118 pub fn with_payload_len(mut self, payload_len: usize) -> Self {
120 self.payload_len = payload_len;
121 self
122 }
123
124 pub fn collection(&self) -> &CollectionId {
125 &self.collection
126 }
127
128 pub fn key(&self) -> &[u8] {
129 &self.key
130 }
131
132 pub fn operation(&self) -> RequestOperation {
133 self.operation
134 }
135
136 pub fn payload_len(&self) -> usize {
137 self.payload_len
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct RoutingPolicy {
150 forwarding_enabled: bool,
151 max_forward_payload: usize,
152 max_forward_hops: u8,
153 forward_in_flight_limit: usize,
154 forward_per_hop_budget_ms: u64,
155}
156
157impl RoutingPolicy {
158 pub fn forwarding() -> Self {
161 Self {
162 forwarding_enabled: true,
163 max_forward_payload: DEFAULT_MAX_FORWARD_PAYLOAD,
164 max_forward_hops: DEFAULT_MAX_FORWARD_HOPS,
165 forward_in_flight_limit: DEFAULT_FORWARD_IN_FLIGHT_LIMIT,
166 forward_per_hop_budget_ms: DEFAULT_FORWARD_PER_HOP_BUDGET_MS,
167 }
168 }
169
170 pub fn redirect_only() -> Self {
173 Self {
174 forwarding_enabled: false,
175 max_forward_payload: 0,
176 max_forward_hops: DEFAULT_MAX_FORWARD_HOPS,
177 forward_in_flight_limit: DEFAULT_FORWARD_IN_FLIGHT_LIMIT,
178 forward_per_hop_budget_ms: DEFAULT_FORWARD_PER_HOP_BUDGET_MS,
179 }
180 }
181
182 pub fn with_max_forward_payload(mut self, max_forward_payload: usize) -> Self {
184 self.max_forward_payload = max_forward_payload;
185 self
186 }
187
188 pub fn with_max_forward_hops(mut self, max_forward_hops: u8) -> Self {
189 self.max_forward_hops = max_forward_hops;
190 self
191 }
192
193 pub fn with_forward_in_flight_limit(mut self, forward_in_flight_limit: usize) -> Self {
194 self.forward_in_flight_limit = forward_in_flight_limit;
195 self
196 }
197
198 pub fn with_forward_per_hop_budget_ms(mut self, forward_per_hop_budget_ms: u64) -> Self {
199 self.forward_per_hop_budget_ms = forward_per_hop_budget_ms;
200 self
201 }
202
203 pub fn forwarding_enabled(&self) -> bool {
204 self.forwarding_enabled
205 }
206
207 pub fn max_forward_payload(&self) -> usize {
208 self.max_forward_payload
209 }
210
211 pub fn max_forward_hops(&self) -> u8 {
212 self.max_forward_hops
213 }
214
215 pub fn forward_in_flight_limit(&self) -> usize {
216 self.forward_in_flight_limit
217 }
218
219 pub fn forward_per_hop_budget_ms(&self) -> u64 {
220 self.forward_per_hop_budget_ms
221 }
222}
223
224impl Default for RoutingPolicy {
225 fn default() -> Self {
226 Self::forwarding()
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub enum ForwardError {
232 NotForwarded,
233 HopLimitExceeded { attempted_hop: u8, max_hops: u8 },
234 BudgetExhausted { required_ms: u64, remaining_ms: u64 },
235 Overloaded { in_flight: usize, limit: usize },
236}
237
238impl std::fmt::Display for ForwardError {
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 match self {
241 Self::NotForwarded => write!(f, "request was not selected for forwarding"),
242 Self::HopLimitExceeded {
243 attempted_hop,
244 max_hops,
245 } => write!(
246 f,
247 "forward hop {attempted_hop} exceeds the {max_hops}-hop guard"
248 ),
249 Self::BudgetExhausted {
250 required_ms,
251 remaining_ms,
252 } => write!(
253 f,
254 "forward hop needs {required_ms}ms but only {remaining_ms}ms remains"
255 ),
256 Self::Overloaded { in_flight, limit } => write!(
257 f,
258 "forward path overloaded: {in_flight} in-flight requests at limit {limit}"
259 ),
260 }
261 }
262}
263
264impl std::error::Error for ForwardError {}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ForwardContext {
268 hop: u8,
269 target_owner: NodeIdentity,
270 range_id: RangeId,
271 budget_ms: u64,
272}
273
274impl ForwardContext {
275 pub fn hop(&self) -> u8 {
276 self.hop
277 }
278
279 pub fn target_owner(&self) -> &NodeIdentity {
280 &self.target_owner
281 }
282
283 pub fn range_id(&self) -> RangeId {
284 self.range_id
285 }
286
287 pub fn budget_ms(&self) -> u64 {
288 self.budget_ms
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct ForwardMetadata {
294 hop: u8,
295 target_owner: NodeIdentity,
296 range_id: RangeId,
297 budget_ms: u64,
298 payload_len: usize,
299}
300
301impl ForwardMetadata {
302 fn from_context(context: &ForwardContext, payload_len: usize) -> Self {
303 Self {
304 hop: context.hop,
305 target_owner: context.target_owner.clone(),
306 range_id: context.range_id,
307 budget_ms: context.budget_ms,
308 payload_len,
309 }
310 }
311
312 pub fn hop(&self) -> u8 {
313 self.hop
314 }
315
316 pub fn target_owner(&self) -> &NodeIdentity {
317 &self.target_owner
318 }
319
320 pub fn range_id(&self) -> RangeId {
321 self.range_id
322 }
323
324 pub fn budget_ms(&self) -> u64 {
325 self.budget_ms
326 }
327
328 pub fn payload_len(&self) -> usize {
329 self.payload_len
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct ForwardMetricsSnapshot {
335 attempted: u64,
336 succeeded: u64,
337 shed_overloaded: u64,
338 shed_hop_limit: u64,
339 shed_budget: u64,
340}
341
342impl ForwardMetricsSnapshot {
343 pub fn attempted(&self) -> u64 {
344 self.attempted
345 }
346
347 pub fn succeeded(&self) -> u64 {
348 self.succeeded
349 }
350
351 pub fn shed_overloaded(&self) -> u64 {
352 self.shed_overloaded
353 }
354
355 pub fn shed_hop_limit(&self) -> u64 {
356 self.shed_hop_limit
357 }
358
359 pub fn shed_budget(&self) -> u64 {
360 self.shed_budget
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct ForwardOutcome<T> {
366 response: T,
367 metadata: ForwardMetadata,
368 metrics: ForwardMetricsSnapshot,
369}
370
371impl<T> ForwardOutcome<T> {
372 pub fn response(&self) -> &T {
373 &self.response
374 }
375
376 pub fn into_response(self) -> T {
377 self.response
378 }
379
380 pub fn metadata(&self) -> &ForwardMetadata {
381 &self.metadata
382 }
383
384 pub fn metrics(&self) -> &ForwardMetricsSnapshot {
385 &self.metrics
386 }
387}
388
389#[derive(Debug, Default)]
390struct ForwardMetrics {
391 attempted: AtomicUsize,
392 succeeded: AtomicUsize,
393 shed_overloaded: AtomicUsize,
394 shed_hop_limit: AtomicUsize,
395 shed_budget: AtomicUsize,
396}
397
398impl ForwardMetrics {
399 fn snapshot(&self) -> ForwardMetricsSnapshot {
400 ForwardMetricsSnapshot {
401 attempted: self.attempted.load(Ordering::Relaxed) as u64,
402 succeeded: self.succeeded.load(Ordering::Relaxed) as u64,
403 shed_overloaded: self.shed_overloaded.load(Ordering::Relaxed) as u64,
404 shed_hop_limit: self.shed_hop_limit.load(Ordering::Relaxed) as u64,
405 shed_budget: self.shed_budget.load(Ordering::Relaxed) as u64,
406 }
407 }
408}
409
410#[derive(Debug, Clone)]
411pub struct ForwardCoordinator {
412 policy: RoutingPolicy,
413 in_flight: Arc<AtomicUsize>,
414 metrics: Arc<ForwardMetrics>,
415}
416
417impl ForwardCoordinator {
418 pub fn new(policy: RoutingPolicy) -> Self {
419 Self {
420 policy,
421 in_flight: Arc::new(AtomicUsize::new(0)),
422 metrics: Arc::new(ForwardMetrics::default()),
423 }
424 }
425
426 pub fn execute<T>(
427 &self,
428 decision: &RouteDecision,
429 request: &RoutedRequest,
430 incoming_hops: u8,
431 remaining_budget_ms: u64,
432 owner_call: impl FnOnce(&ForwardContext) -> T,
433 ) -> Result<ForwardOutcome<T>, ForwardError> {
434 let RouteDecision::Forward { hint } = decision else {
435 return Err(ForwardError::NotForwarded);
436 };
437
438 self.metrics.attempted.fetch_add(1, Ordering::Relaxed);
439 let hop = incoming_hops.saturating_add(1);
440 if hop > self.policy.max_forward_hops() {
441 self.metrics.shed_hop_limit.fetch_add(1, Ordering::Relaxed);
442 return Err(ForwardError::HopLimitExceeded {
443 attempted_hop: hop,
444 max_hops: self.policy.max_forward_hops(),
445 });
446 }
447 if remaining_budget_ms < self.policy.forward_per_hop_budget_ms() {
448 self.metrics.shed_budget.fetch_add(1, Ordering::Relaxed);
449 return Err(ForwardError::BudgetExhausted {
450 required_ms: self.policy.forward_per_hop_budget_ms(),
451 remaining_ms: remaining_budget_ms,
452 });
453 }
454
455 let _permit = self.acquire_permit()?;
456 let context = ForwardContext {
457 hop,
458 target_owner: hint.owner().clone(),
459 range_id: hint.range_id(),
460 budget_ms: self.policy.forward_per_hop_budget_ms(),
461 };
462 let response = owner_call(&context);
463 self.metrics.succeeded.fetch_add(1, Ordering::Relaxed);
464 Ok(ForwardOutcome {
465 response,
466 metadata: ForwardMetadata::from_context(&context, request.payload_len()),
467 metrics: self.metrics.snapshot(),
468 })
469 }
470
471 pub fn metrics(&self) -> ForwardMetricsSnapshot {
472 self.metrics.snapshot()
473 }
474
475 fn acquire_permit(&self) -> Result<ForwardPermit<'_>, ForwardError> {
476 let limit = self.policy.forward_in_flight_limit();
477 loop {
478 let current = self.in_flight.load(Ordering::Acquire);
479 if current >= limit {
480 self.metrics.shed_overloaded.fetch_add(1, Ordering::Relaxed);
481 return Err(ForwardError::Overloaded {
482 in_flight: current,
483 limit,
484 });
485 }
486 if self
487 .in_flight
488 .compare_exchange(current, current + 1, Ordering::AcqRel, Ordering::Acquire)
489 .is_ok()
490 {
491 return Ok(ForwardPermit {
492 in_flight: &self.in_flight,
493 });
494 }
495 }
496 }
497}
498
499struct ForwardPermit<'a> {
500 in_flight: &'a AtomicUsize,
501}
502
503impl Drop for ForwardPermit<'_> {
504 fn drop(&mut self) {
505 self.in_flight.fetch_sub(1, Ordering::AcqRel);
506 }
507}
508
509#[derive(Debug, Clone, PartialEq, Eq)]
519pub struct RoutingHint {
520 collection: CollectionId,
521 range_id: RangeId,
522 owner: NodeIdentity,
523 epoch: OwnershipEpoch,
524 version: CatalogVersion,
525}
526
527impl RoutingHint {
528 fn from_range(collection: &CollectionId, range: &RangeOwnership) -> Self {
529 Self {
530 collection: collection.clone(),
531 range_id: range.range_id(),
532 owner: range.owner().clone(),
533 epoch: range.epoch(),
534 version: range.version(),
535 }
536 }
537
538 pub fn collection(&self) -> &CollectionId {
539 &self.collection
540 }
541
542 pub fn range_id(&self) -> RangeId {
543 self.range_id
544 }
545
546 pub fn owner(&self) -> &NodeIdentity {
547 &self.owner
548 }
549
550 pub fn epoch(&self) -> OwnershipEpoch {
551 self.epoch
552 }
553
554 pub fn version(&self) -> CatalogVersion {
555 self.version
556 }
557
558 pub fn to_moved_redirect(
559 &self,
560 slot: Option<u64>,
561 reason: RedirectReason,
562 ) -> reddb_wire::MovedRedirect {
563 reddb_wire::MovedRedirect {
564 slot,
565 collection: self.collection.as_str().to_string(),
566 range_id: self.range_id.value(),
567 owner_addr: self.owner.as_str().to_string(),
568 ownership_epoch: self.epoch.value(),
569 catalog_version: self.version.value(),
570 reason: reason.code().to_string(),
571 }
572 }
573}
574
575impl std::fmt::Display for RoutingHint {
576 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577 write!(
578 f,
579 "{}/{} owned by {} at epoch {} (catalog version {})",
580 self.collection, self.range_id, self.owner, self.epoch, self.version
581 )
582 }
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
592pub enum RedirectReason {
593 ForwardingDisabled,
596 Transaction,
598 Streaming,
600 LargePayload { len: usize, limit: usize },
603 ExplicitlyUnsafe,
605}
606
607impl std::fmt::Display for RedirectReason {
608 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609 match self {
610 Self::ForwardingDisabled => write!(f, "forwarding not selected on this node"),
611 Self::Transaction => write!(f, "transactions must be opened on the owner"),
612 Self::Streaming => write!(f, "streaming operations must originate on the owner"),
613 Self::LargePayload { len, limit } => write!(
614 f,
615 "payload {len} bytes exceeds the {limit}-byte forward budget; send directly to the owner"
616 ),
617 Self::ExplicitlyUnsafe => {
618 write!(f, "operation explicitly marked unsafe to forward")
619 }
620 }
621 }
622}
623
624impl RedirectReason {
625 pub fn code(self) -> &'static str {
626 match self {
627 Self::ForwardingDisabled => "forwarding_disabled",
628 Self::Transaction => "transaction",
629 Self::Streaming => "streaming",
630 Self::LargePayload { .. } => "large_payload",
631 Self::ExplicitlyUnsafe => "explicitly_unsafe",
632 }
633 }
634}
635
636#[derive(Debug, Clone, PartialEq, Eq)]
638pub enum RouteDecision {
639 Local {
644 range_id: RangeId,
645 epoch: OwnershipEpoch,
646 },
647 Forward { hint: RoutingHint },
651 Redirect {
656 hint: RoutingHint,
657 reason: RedirectReason,
658 },
659 Unroutable { collection: CollectionId },
663}
664
665impl RouteDecision {
666 pub fn hint(&self) -> Option<&RoutingHint> {
668 match self {
669 RouteDecision::Forward { hint } | RouteDecision::Redirect { hint, .. } => Some(hint),
670 RouteDecision::Local { .. } | RouteDecision::Unroutable { .. } => None,
671 }
672 }
673
674 pub fn is_local(&self) -> bool {
676 matches!(self, RouteDecision::Local { .. })
677 }
678}
679
680impl ShardOwnershipCatalog {
681 pub fn plan_route(
699 &self,
700 local: &NodeIdentity,
701 request: &RoutedRequest,
702 policy: &RoutingPolicy,
703 ) -> RouteDecision {
704 let range = match self.route_shard_key(request.collection(), request.key()) {
705 Some(range) => range,
706 None => {
707 return RouteDecision::Unroutable {
708 collection: request.collection().clone(),
709 }
710 }
711 };
712
713 if range.role_of(local) == RangeRole::Owner {
714 return RouteDecision::Local {
715 range_id: range.range_id(),
716 epoch: range.epoch(),
717 };
718 }
719
720 let hint = RoutingHint::from_range(request.collection(), range);
721
722 if !policy.forwarding_enabled() {
725 return RouteDecision::Redirect {
726 hint,
727 reason: RedirectReason::ForwardingDisabled,
728 };
729 }
730 if let Err(reason) = request.operation().forwardable() {
731 return RouteDecision::Redirect { hint, reason };
732 }
733 if request.payload_len() > policy.max_forward_payload() {
734 return RouteDecision::Redirect {
735 hint,
736 reason: RedirectReason::LargePayload {
737 len: request.payload_len(),
738 limit: policy.max_forward_payload(),
739 },
740 };
741 }
742 RouteDecision::Forward { hint }
743 }
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749 use crate::cluster::ownership::{PlacementMetadata, RangeBound, RangeBounds, ShardKeyMode};
750
751 fn collection(name: &str) -> CollectionId {
752 CollectionId::new(name).unwrap()
753 }
754
755 fn ident(cn: &str) -> NodeIdentity {
756 NodeIdentity::from_certificate_subject(cn).unwrap()
757 }
758
759 fn range_with(coll: &CollectionId, id: u64, owner: &str, replicas: &[&str]) -> RangeOwnership {
761 RangeOwnership::establish(
762 coll.clone(),
763 RangeId::new(id),
764 ShardKeyMode::Hash,
765 RangeBounds::full(),
766 ident(owner),
767 replicas.iter().map(|r| ident(r)).collect::<Vec<_>>(),
768 PlacementMetadata::with_replication_factor(3),
769 )
770 }
771
772 fn catalog_with(range: RangeOwnership) -> ShardOwnershipCatalog {
773 let mut catalog = ShardOwnershipCatalog::new();
774 catalog.apply_update(range).unwrap();
775 catalog
776 }
777
778 #[test]
781 fn owner_executes_locally() {
782 let orders = collection("orders");
783 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
784 let request =
785 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp);
786
787 let decision =
788 catalog.plan_route(&ident("CN=node-a"), &request, &RoutingPolicy::forwarding());
789 assert_eq!(
790 decision,
791 RouteDecision::Local {
792 range_id: RangeId::new(1),
793 epoch: OwnershipEpoch::initial(),
794 }
795 );
796 assert!(decision.is_local());
797 assert!(decision.hint().is_none());
798 }
799
800 #[test]
803 fn any_node_resolves_owner_from_catalog() {
804 let orders = collection("orders");
805 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
806 let request =
807 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp);
808
809 let decision =
811 catalog.plan_route(&ident("CN=node-c"), &request, &RoutingPolicy::forwarding());
812 let hint = decision.hint().expect("non-owner carries a hint");
813 assert_eq!(hint.owner(), &ident("CN=node-a"));
814 assert_eq!(hint.range_id(), RangeId::new(1));
815 assert_eq!(hint.epoch(), OwnershipEpoch::initial());
816 }
817
818 #[test]
820 fn safe_point_op_is_forwarded_from_non_owner() {
821 let orders = collection("orders");
822 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
823 let request =
824 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp);
825
826 let decision =
828 catalog.plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding());
829 match decision {
830 RouteDecision::Forward { hint } => {
831 assert_eq!(hint.owner(), &ident("CN=node-a"));
832 assert_eq!(hint.epoch(), OwnershipEpoch::initial());
833 }
834 other => panic!("expected Forward, got {other:?}"),
835 }
836 }
837
838 #[test]
841 fn forwarded_write_still_passes_owner_public_gate() {
842 let orders = collection("orders");
843 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
844 let request =
845 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp);
846
847 let hint =
848 match catalog.plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding()) {
849 RouteDecision::Forward { hint } => hint,
850 other => panic!("expected Forward, got {other:?}"),
851 };
852 let admitted = catalog
854 .admit_public_write(&ident("CN=node-a"), &orders, b"k", hint.epoch())
855 .expect("owner admits the forwarded write at the current epoch");
856 assert_eq!(admitted.owner(), &ident("CN=node-a"));
857 }
858
859 #[test]
861 fn transaction_from_non_owner_is_redirected() {
862 let orders = collection("orders");
863 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
864 let request =
865 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
866
867 let decision =
868 catalog.plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding());
869 match decision {
870 RouteDecision::Redirect { hint, reason } => {
871 assert_eq!(reason, RedirectReason::Transaction);
872 assert_eq!(hint.owner(), &ident("CN=node-a"));
873 }
874 other => panic!("expected Redirect(Transaction), got {other:?}"),
875 }
876 }
877
878 #[test]
880 fn streaming_from_non_owner_is_redirected() {
881 let orders = collection("orders");
882 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
883 let request =
884 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Streaming);
885
886 match catalog.plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding()) {
887 RouteDecision::Redirect { reason, .. } => assert_eq!(reason, RedirectReason::Streaming),
888 other => panic!("expected Redirect(Streaming), got {other:?}"),
889 }
890 }
891
892 #[test]
894 fn explicitly_unsafe_op_is_redirected() {
895 let orders = collection("orders");
896 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
897 let request = RoutedRequest::new(
898 orders.clone(),
899 b"k".to_vec(),
900 RequestOperation::ExplicitlyUnsafe,
901 );
902
903 match catalog.plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding()) {
904 RouteDecision::Redirect { reason, .. } => {
905 assert_eq!(reason, RedirectReason::ExplicitlyUnsafe)
906 }
907 other => panic!("expected Redirect(ExplicitlyUnsafe), got {other:?}"),
908 }
909 }
910
911 #[test]
914 fn large_payload_is_redirected_not_forwarded() {
915 let orders = collection("orders");
916 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
917 let policy = RoutingPolicy::forwarding().with_max_forward_payload(64);
918 let request =
919 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp)
920 .with_payload_len(65);
921
922 match catalog.plan_route(&ident("CN=node-b"), &request, &policy) {
923 RouteDecision::Redirect { reason, .. } => {
924 assert_eq!(reason, RedirectReason::LargePayload { len: 65, limit: 64 })
925 }
926 other => panic!("expected Redirect(LargePayload), got {other:?}"),
927 }
928 let at_budget =
930 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp)
931 .with_payload_len(64);
932 assert!(matches!(
933 catalog.plan_route(&ident("CN=node-b"), &at_budget, &policy),
934 RouteDecision::Forward { .. }
935 ));
936 }
937
938 #[test]
941 fn redirect_only_policy_redirects_safe_op() {
942 let orders = collection("orders");
943 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
944 let request =
945 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp);
946
947 match catalog.plan_route(
948 &ident("CN=node-b"),
949 &request,
950 &RoutingPolicy::redirect_only(),
951 ) {
952 RouteDecision::Redirect { hint, reason } => {
953 assert_eq!(reason, RedirectReason::ForwardingDisabled);
954 assert_eq!(hint.owner(), &ident("CN=node-a"));
955 assert_eq!(hint.epoch(), OwnershipEpoch::initial());
956 }
957 other => panic!("expected Redirect(ForwardingDisabled), got {other:?}"),
958 }
959 }
960
961 #[test]
963 fn key_with_no_range_is_unroutable() {
964 let catalog = ShardOwnershipCatalog::new();
965 let orders = collection("orders");
966 let request =
967 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp);
968
969 let decision =
970 catalog.plan_route(&ident("CN=node-a"), &request, &RoutingPolicy::forwarding());
971 assert_eq!(decision, RouteDecision::Unroutable { collection: orders });
972 assert!(decision.hint().is_none());
973 }
974
975 #[test]
979 fn stale_ownership_redirects_then_retry_succeeds() {
980 let orders = collection("orders");
981
982 let mut catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
984
985 let v1 = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
988 let v2 = v1.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]);
989 catalog.apply_update(v2).unwrap();
990
991 let request =
995 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
996 let redirect =
997 catalog.plan_route(&ident("CN=node-a"), &request, &RoutingPolicy::forwarding());
998 let hint = match redirect {
999 RouteDecision::Redirect { hint, reason } => {
1000 assert_eq!(reason, RedirectReason::Transaction);
1001 hint
1002 }
1003 other => panic!("expected Redirect, got {other:?}"),
1004 };
1005 assert_eq!(hint.owner(), &ident("CN=node-b"));
1006 assert_eq!(hint.epoch().value(), 2);
1007 assert!(hint.epoch() > OwnershipEpoch::initial());
1008
1009 let retry = catalog.plan_route(hint.owner(), &request, &RoutingPolicy::forwarding());
1011 assert_eq!(
1012 retry,
1013 RouteDecision::Local {
1014 range_id: RangeId::new(1),
1015 epoch: hint.epoch(),
1016 }
1017 );
1018 }
1019
1020 #[test]
1021 fn redirect_builds_moved_payload_with_owner_epoch_and_catalog_version() {
1022 let orders = collection("orders");
1023 let mut catalog = catalog_with(range_with(&orders, 1, "node-a:5050", &["node-b:5050"]));
1024 let v1 = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1025 catalog
1026 .apply_update(v1.transfer_to(ident("node-b:5050"), [ident("node-a:5050")]))
1027 .unwrap();
1028
1029 let request =
1030 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
1031 let (hint, reason) = match catalog.plan_route(
1032 &ident("node-a:5050"),
1033 &request,
1034 &RoutingPolicy::forwarding(),
1035 ) {
1036 RouteDecision::Redirect { hint, reason } => (hint, reason),
1037 other => panic!("expected Redirect, got {other:?}"),
1038 };
1039 let payload = hint.to_moved_redirect(Some(9), reason);
1040
1041 assert_eq!(payload.slot, Some(9));
1042 assert_eq!(payload.collection, "orders");
1043 assert_eq!(payload.range_id, 1);
1044 assert_eq!(payload.owner_addr, "node-b:5050");
1045 assert_eq!(payload.ownership_epoch, 2);
1046 assert_eq!(payload.catalog_version, 2);
1047 assert_eq!(payload.reason, "transaction");
1048 }
1049
1050 #[test]
1053 fn safe_op_forward_targets_current_owner_after_transfer() {
1054 let orders = collection("orders");
1055 let mut catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
1056 let v1 = catalog.range(&orders, RangeId::new(1)).unwrap().clone();
1057 catalog
1058 .apply_update(v1.transfer_to(ident("CN=node-b"), [ident("CN=node-a")]))
1059 .unwrap();
1060
1061 let request =
1062 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp);
1063 match catalog.plan_route(&ident("CN=node-a"), &request, &RoutingPolicy::forwarding()) {
1064 RouteDecision::Forward { hint } => {
1065 assert_eq!(hint.owner(), &ident("CN=node-b"));
1066 assert_eq!(hint.epoch().value(), 2);
1067 }
1068 other => panic!("expected Forward to new owner, got {other:?}"),
1069 }
1070 }
1071
1072 #[test]
1073 fn coordinator_forwards_safe_op_with_metadata_and_metrics() {
1074 let orders = collection("orders");
1075 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
1076 let request =
1077 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp)
1078 .with_payload_len(32);
1079 let decision =
1080 catalog.plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding());
1081 let coordinator =
1082 ForwardCoordinator::new(RoutingPolicy::forwarding().with_forward_per_hop_budget_ms(25));
1083
1084 let outcome = coordinator
1085 .execute(&decision, &request, 0, 50, |ctx| {
1086 assert_eq!(ctx.hop(), 1);
1087 assert_eq!(ctx.target_owner(), &ident("CN=node-a"));
1088 "owner response"
1089 })
1090 .expect("safe op forwards");
1091
1092 assert_eq!(outcome.response(), &"owner response");
1093 assert_eq!(outcome.metadata().hop(), 1);
1094 assert_eq!(outcome.metadata().target_owner(), &ident("CN=node-a"));
1095 assert_eq!(outcome.metadata().range_id(), RangeId::new(1));
1096 assert_eq!(outcome.metadata().budget_ms(), 25);
1097 assert_eq!(outcome.metadata().payload_len(), 32);
1098 assert_eq!(outcome.metrics().attempted(), 1);
1099 assert_eq!(outcome.metrics().succeeded(), 1);
1100 assert_eq!(outcome.metrics().shed_overloaded(), 0);
1101 }
1102
1103 #[test]
1104 fn coordinator_refuses_redirect_decisions_without_calling_owner() {
1105 let orders = collection("orders");
1106 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
1107 let request =
1108 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
1109 let decision =
1110 catalog.plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding());
1111 assert!(matches!(decision, RouteDecision::Redirect { .. }));
1112
1113 let err = ForwardCoordinator::new(RoutingPolicy::forwarding())
1114 .execute(&decision, &request, 0, 50, |_| {
1115 panic!("owner must not be called")
1116 })
1117 .expect_err("redirects are not forwarded");
1118 assert_eq!(err, ForwardError::NotForwarded);
1119 }
1120
1121 #[test]
1122 fn coordinator_sheds_when_hop_guard_or_budget_is_exhausted() {
1123 let orders = collection("orders");
1124 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
1125 let request =
1126 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp);
1127 let decision =
1128 catalog.plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding());
1129 let coordinator = ForwardCoordinator::new(
1130 RoutingPolicy::forwarding()
1131 .with_max_forward_hops(1)
1132 .with_forward_per_hop_budget_ms(20),
1133 );
1134
1135 let hop_err = coordinator
1136 .execute(&decision, &request, 1, 50, |_| {
1137 panic!("owner must not be called")
1138 })
1139 .expect_err("second hop is rejected");
1140 assert_eq!(
1141 hop_err,
1142 ForwardError::HopLimitExceeded {
1143 attempted_hop: 2,
1144 max_hops: 1,
1145 }
1146 );
1147
1148 let budget_err = coordinator
1149 .execute(&decision, &request, 0, 19, |_| {
1150 panic!("owner must not be called")
1151 })
1152 .expect_err("under-budget hop is rejected");
1153 assert_eq!(
1154 budget_err,
1155 ForwardError::BudgetExhausted {
1156 required_ms: 20,
1157 remaining_ms: 19,
1158 }
1159 );
1160 let metrics = coordinator.metrics();
1161 assert_eq!(metrics.attempted(), 2);
1162 assert_eq!(metrics.shed_hop_limit(), 1);
1163 assert_eq!(metrics.shed_budget(), 1);
1164 assert_eq!(metrics.succeeded(), 0);
1165 }
1166
1167 #[test]
1168 fn coordinator_sheds_overload_instead_of_cascading() {
1169 let orders = collection("orders");
1170 let catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
1171 let request =
1172 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::SafePointOp);
1173 let decision =
1174 catalog.plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding());
1175 let coordinator =
1176 ForwardCoordinator::new(RoutingPolicy::forwarding().with_forward_in_flight_limit(1));
1177 let nested = coordinator.clone();
1178
1179 let outcome = coordinator
1180 .execute(&decision, &request, 0, 50, |_| {
1181 nested.execute(&decision, &request, 0, 50, |_| "unexpected")
1182 })
1183 .expect("outer request holds the only forward slot");
1184 let inner = outcome.response();
1185 assert_eq!(
1186 inner.as_ref().expect_err("inner request sheds"),
1187 &ForwardError::Overloaded {
1188 in_flight: 1,
1189 limit: 1,
1190 }
1191 );
1192
1193 let metrics = coordinator.metrics();
1194 assert_eq!(metrics.attempted(), 2);
1195 assert_eq!(metrics.succeeded(), 1);
1196 assert_eq!(metrics.shed_overloaded(), 1);
1197 }
1198
1199 #[test]
1200 fn routing_hint_display_names_owner_and_epoch() {
1201 let orders = collection("orders");
1202 let catalog = catalog_with(range_with(&orders, 4, "CN=node-a", &["CN=node-b"]));
1203 let request =
1204 RoutedRequest::new(orders.clone(), b"k".to_vec(), RequestOperation::Transaction);
1205 let hint = catalog
1206 .plan_route(&ident("CN=node-b"), &request, &RoutingPolicy::forwarding())
1207 .hint()
1208 .cloned()
1209 .expect("redirect carries a hint");
1210 let rendered = hint.to_string();
1211 assert!(rendered.contains("CN=node-a"));
1212 assert!(rendered.contains("epoch 1"));
1213 }
1214}