Skip to main content

reddb_server/cluster/
routing.rs

1//! Any-node routing and stale-ownership responses (issue #993, PRD #987, ADR 0037).
2//!
3//! ADR 0037 makes any-node routing **mandatory**: a client may send a request to
4//! *any* data member, and that member must do something correct even when it is
5//! not the owner of the range the request targets. PRD #987 spells out the two
6//! correct things a non-owner may do:
7//!
8//! * **Forward** a *simple, safe* operation internally to the range owner, so the
9//!   client never has to learn the topology to make progress; or
10//! * **Redirect** — return enough routing information (current owner, ownership
11//!   epoch, catalog version) for the client/router to refresh its topology and
12//!   retry against the owner itself.
13//!
14//! The split between the two is deliberate and is the heart of this module.
15//! Hidden internal forwarding is only ever safe for operations whose semantics do
16//! not depend on *where* they run: a single-key point read or write. Anything
17//! whose correctness is bound to a session or a long-lived stream —
18//! transactions, streaming/cursor operations, oversized payloads, or operations
19//! the caller has explicitly marked unsafe — must **not** be silently relayed.
20//! For those the only safe answer is an honest redirect so the client opens its
21//! transaction / stream / large transfer directly against the owner. This mirrors
22//! ADR 0037's "routing must consult ownership metadata with an epoch/version and
23//! handle stale routing responses" — and crucially it never weakens the
24//! fencing-below-routing guarantee, because a forwarded write still lands on the
25//! owner's [`admit_public_write`](ShardOwnershipCatalog::admit_public_write) gate
26//! (issue #990) at the owner's *current* epoch.
27//!
28//! Like the rest of the cluster module this is a pure decision layer with no I/O:
29//! [`plan_route`](ShardOwnershipCatalog::plan_route) maps a
30//! ([`RoutedRequest`], local [`NodeIdentity`], [`RoutingPolicy`]) triple to a
31//! [`RouteDecision`], so the any-node routing contract is exercised
32//! deterministically. The transport that actually forwards bytes or writes a
33//! redirect onto the wire is a separate concern layered on top of this.
34
35use 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
45/// Default ceiling on the payload a non-owner will relay internally.
46///
47/// Forwarding copies the whole payload across an extra internal hop, so a large
48/// transfer is cheaper and clearer to redirect: the client sends it once,
49/// directly to the owner. The MVP budget is 1 MiB; operators can widen or narrow
50/// it per [`RoutingPolicy`].
51pub 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/// What a request asks the cluster to do, reduced to just what routing needs to
57/// decide forward-vs-redirect.
58///
59/// Only [`SafePointOp`](Self::SafePointOp) is eligible for hidden internal
60/// forwarding. The other classes are exactly PRD #987's "must not be silently
61/// forwarded" set: their correctness is tied to running on the owner directly, so
62/// a non-owner must redirect rather than relay them.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum RequestOperation {
65    /// A single-key point read or write — the one class safe to forward to the
66    /// owner, because its result does not depend on which node relays it.
67    SafePointOp,
68    /// A multi-statement transaction. Atomicity and session state must be
69    /// established on the owner directly; never hidden-forwarded.
70    Transaction,
71    /// A streaming / cursor operation (scan, subscribe, change feed). The stream
72    /// must originate on the owner; never hidden-forwarded.
73    Streaming,
74    /// An operation the caller has explicitly flagged as unsafe to forward.
75    ExplicitlyUnsafe,
76}
77
78impl RequestOperation {
79    /// `Ok` if this class is *in principle* forwardable (only
80    /// [`SafePointOp`](Self::SafePointOp)); otherwise the [`RedirectReason`] that
81    /// explains why it must be redirected instead.
82    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/// A request as it arrives at some data member, abstracted to what routing reads:
93/// which collection/key it targets, what kind of operation it is, and how large
94/// its payload is.
95#[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    /// A request with no meaningful payload (e.g. a point read or delete).
105    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    /// Declare the request's payload size, so the forward-size budget can apply.
119    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/// How a data member handles requests it does not own.
142///
143/// "Any-node routing is mandatory" (ADR 0037), but *forwarding* is a policy
144/// choice: a deployment (or a single node) may prefer to push routing work onto
145/// topology-aware clients and redirect everything instead of relaying. When
146/// forwarding is disabled, even a safe point op gets a redirect — this is PRD
147/// #987's "when forwarding is not selected" path.
148#[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    /// Forward safe point ops to the owner; redirect everything else. Uses the
159    /// [`DEFAULT_MAX_FORWARD_PAYLOAD`] budget.
160    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    /// Never forward — always redirect a non-owner request with a routing hint.
171    /// The client/router refreshes topology and retries against the owner.
172    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    /// Override the maximum payload eligible for internal forwarding.
183    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/// The routing information a non-owner hands back so the caller can reach the
510/// owner — the payload of both a forward (where to relay) and a redirect (where
511/// to retry).
512///
513/// It carries the owner's [`NodeIdentity`] plus the [`OwnershipEpoch`] and
514/// [`CatalogVersion`] the decision was made at. The epoch/version let a
515/// topology-aware client tell *stale* hints from fresh ones and avoid retry loops
516/// against ownership that has since moved again (ADR 0037: routing "must consult
517/// ownership metadata with an epoch/version").
518#[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/// Why a non-owner redirected a request instead of forwarding it.
586///
587/// Every redirect happens because the local node is not the owner; the reason
588/// explains why the safe-forward path was *not* taken for this particular
589/// request, so an operator (or a client deciding how to retry) can tell a routine
590/// "open your transaction on the owner" from a "this node won't relay" policy.
591#[derive(Debug, Clone, Copy, PartialEq, Eq)]
592pub enum RedirectReason {
593    /// This node's [`RoutingPolicy`] does not forward; the client must route to
594    /// the owner itself. (PRD #987 "when forwarding is not selected".)
595    ForwardingDisabled,
596    /// A multi-statement transaction — must be opened on the owner directly.
597    Transaction,
598    /// A streaming / cursor operation — must originate on the owner.
599    Streaming,
600    /// The payload exceeds the forward budget; send it once, directly to the
601    /// owner, rather than copying it across an extra internal hop.
602    LargePayload { len: usize, limit: usize },
603    /// The caller explicitly marked the operation unsafe to forward.
604    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/// What a data member decides to do with a request under any-node routing.
637#[derive(Debug, Clone, PartialEq, Eq)]
638pub enum RouteDecision {
639    /// The local node owns the range — execute locally. Carries the range and the
640    /// current ownership epoch the write should be stamped/fenced with (the same
641    /// epoch [`admit_public_write`](ShardOwnershipCatalog::admit_public_write)
642    /// will check).
643    Local {
644        range_id: RangeId,
645        epoch: OwnershipEpoch,
646    },
647    /// A safe point op the local node may forward internally to the owner named
648    /// in the hint. The forwarded write still passes the owner's public-write
649    /// gate at the owner's current epoch, so forwarding never bypasses fencing.
650    Forward { hint: RoutingHint },
651    /// The request is not eligible for hidden forwarding (unsafe class, oversized
652    /// payload, or forwarding disabled). Return the hint so the client refreshes
653    /// topology and retries against the owner. This is the stale/misrouted
654    /// response of acceptance criterion #2.
655    Redirect {
656        hint: RoutingHint,
657        reason: RedirectReason,
658    },
659    /// No range of the collection covers the key. The catalog the request
660    /// resolved against is empty or stale for this collection; the client must
661    /// refresh its catalog and retry — there is no owner to name yet.
662    Unroutable { collection: CollectionId },
663}
664
665impl RouteDecision {
666    /// The routing hint, if this decision carries one (forward or redirect).
667    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    /// Whether the local node should execute the request itself.
675    pub fn is_local(&self) -> bool {
676        matches!(self, RouteDecision::Local { .. })
677    }
678}
679
680impl ShardOwnershipCatalog {
681    /// Plan how `local` should handle `request` under `policy` — the any-node
682    /// routing decision (issue #993).
683    ///
684    /// Resolves the target range from the catalog, then:
685    ///
686    /// * owner of the range → [`Local`](RouteDecision::Local);
687    /// * non-owner, forwarding enabled, safe point op within budget →
688    ///   [`Forward`](RouteDecision::Forward) to the owner;
689    /// * non-owner but the op is unsafe to forward / oversized / forwarding
690    ///   disabled → [`Redirect`](RouteDecision::Redirect) with the owner+epoch
691    ///   hint;
692    /// * no range covers the key → [`Unroutable`](RouteDecision::Unroutable).
693    ///
694    /// The decision is pure: it reads the catalog and returns intent. Fencing is
695    /// still enforced below routing — a forwarded or locally-executed write lands
696    /// on [`admit_public_write`](Self::admit_public_write) at the owner's current
697    /// epoch, so a stale routing decision cannot smuggle a write past ownership.
698    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        // Non-owner. Forward only if policy allows AND the op is a safe point op
723        // within the forward-size budget; otherwise hand back a routing hint.
724        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    /// A full-keyspace range of `coll` owned by `owner` with `replicas`.
760    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    // AC #1 + direct-owner request: the owner resolves the range and executes
779    // locally.
780    #[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    // AC #1: any node can resolve target range ownership from the catalog, even
801    // one that holds no copy of the range.
802    #[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        // node-c holds no copy at all, yet can still name the owner.
810        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    // AC #3: a safe single-key op from a non-owner is forwarded to the owner.
819    #[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        // node-b is a replica → forward to the owner node-a.
827        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    // AC #3: a forwarded write does not bypass fencing — it still passes the
839    // owner's public-write gate (#990) at the hint's epoch.
840    #[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        // The owner admits the relayed write at the epoch the forwarder carried.
853        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    // AC #4: transactions are redirected, never hidden-forwarded.
860    #[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    // AC #4: streaming operations are redirected.
879    #[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    // AC #4: explicitly unsafe operations are redirected.
893    #[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    // AC #4: an over-budget payload is redirected even though its op class is
912    // safe — send it once, directly to the owner.
913    #[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        // A payload exactly at the budget is still forwardable.
929        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    // AC #2 "when forwarding is not selected": redirect-only policy redirects even
939    // a safe point op.
940    #[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    // A key with no covering range is unroutable — refresh the catalog.
962    #[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    // AC #5: stale ownership retry behavior. A client routes against an old
976    // catalog snapshot, sends to the former owner, gets a redirect carrying the
977    // new owner+epoch, refreshes, and retries successfully against the new owner.
978    #[test]
979    fn stale_ownership_redirects_then_retry_succeeds() {
980        let orders = collection("orders");
981
982        // v1: node-a owns the range; node-b is a replica.
983        let mut catalog = catalog_with(range_with(&orders, 1, "CN=node-a", &["CN=node-b"]));
984
985        // Ownership transfers a → b (epoch + version bump). node-a becomes a
986        // replica of the range it used to own.
987        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        // A client with a stale snapshot still believes node-a is the owner and
992        // sends a transaction there. node-a is now a replica → it redirects with
993        // the *current* owner and the advanced epoch.
994        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        // The client refreshes from the hint and retries against the new owner.
1010        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    // A stale safe-op forward also tracks ownership: after a transfer, a safe op
1051    // arriving at the old owner forwards to the *new* owner, not the old one.
1052    #[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}