Skip to main content

aion_server/namespace/
route.rs

1//! Namespace-mint routing: send the MINT to the namespace registry shard's
2//! owner, never the start.
3//!
4//! A namespace's registry record is keyed `n: || name`, so it hashes to a shard
5//! of its own — unrelated to the shard any workflow started in that namespace
6//! lands on. Two keys, two routings. The workflow half was already routed (the
7//! R-1 unsteered remint places the execution on a locally-owned shard); the
8//! namespace half was not, so on a multi-shard cluster every unsteered `start`
9//! on a node that did not happen to own `shard_for_namespace(name)` was fenced
10//! by the quorum CAS, forever, with a constant `NotOwner { shard }`
11//! (`docs/evidence/failover-start-path-investigation.md`).
12//!
13//! Routing the whole START instead was considered and rejected: the caller's
14//! workflow-shard steering and the namespace shard hash DIFFERENT keys and
15//! routinely disagree, so start-forwarding re-couples two independent placement
16//! decisions and can bounce off the forward hop cap.
17//!
18//! ## The seam
19//!
20//! [`NamespaceRouting`] is the one optional dependency
21//! [`NamespaceMinter`](super::NamespaceMinter) gains. It is `None` on every
22//! single-node / non-clustered boot, where the minter behaves byte-for-byte as
23//! it did before this module existed. It holds three handles:
24//!
25//! - a [`NamespaceShardResolver`] — which registry shard a name lives on;
26//! - a [`MintShardOwners`] directory — who owns that shard right now;
27//! - a [`MintForwarder`] — how to ship an already-authorized mint there.
28//!
29//! Each is a narrow trait rather than a concrete type, so this module compiles
30//! and is testable without the distributed backend; the production
31//! implementations (haematite's store, the R-2 `StaticShardDirectory`, the R-3
32//! gRPC forwarder) are adapted at the bottom of this file behind
33//! `feature = "haematite-backend"`.
34//!
35//! ## What the route preserves
36//!
37//! The forward moves ONLY which process initiates the write. The ENTIRE
38//! read-modify-write — the `database.get`, the `Hash::of` expected value, and
39//! the `replicate_write` proposal — executes on the node that runs it. Reading
40//! locally and proposing remotely would introduce a cross-node TOCTOU and is
41//! deliberately not what this does. The epoch fence stays receiver-side and
42//! unchanged; forwarding to the owner simply supplies the stamp receivers
43//! accept, which is the whole fix.
44//!
45//! The directory is a liveness-gated HINT, never the authority: an owner that
46//! cannot be resolved with confidence resolves [`MintOwner::Unknown`] and the
47//! mint is attempted locally so the receiver fence — the real enforcement —
48//! answers. The directory is never trusted over the fence.
49
50use std::net::SocketAddr;
51use std::sync::Arc;
52
53use async_trait::async_trait;
54
55use aion_proto::WireError;
56use aion_proto::generated;
57use aion_store::NamespaceOrigin;
58
59use crate::error::ServerError;
60
61/// Which distribution shard a namespace's registry record lives on.
62///
63/// Implemented by the distributed store; a narrow trait so the routing decision
64/// is unit-testable without standing up a cluster.
65pub trait NamespaceShardResolver: Send + Sync {
66    /// The registry shard `name`'s record hashes to.
67    fn shard_for_namespace(&self, name: &str) -> usize;
68}
69
70/// The mint's view of who owns a shard right now.
71///
72/// Deliberately coarser than the routing edge's `OwnerView`: a mint can only do
73/// two things with the answer — forward to a dialable address, or attempt the
74/// write locally and let the fence rule. An owner that is known but carries no
75/// forward address is therefore [`Self::Unknown`] here: there is nothing to dial,
76/// so the truthful outcome is the local attempt's fence refusal.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub enum MintOwner {
79    /// This node owns the shard: take the ordinary local quorum write.
80    Local,
81    /// Another node owns it and is reachable for forwarding at this address.
82    Remote(SocketAddr),
83    /// Ownership is not known with confidence (no record, owner believed down,
84    /// or no forward address): attempt locally and let the fence be the
85    /// authority.
86    Unknown,
87}
88
89/// Resolves the current owner of a distribution shard for the mint.
90pub trait MintShardOwners: Send + Sync {
91    /// Who owns `shard`, as far as the mint can act on it.
92    fn owner_of(&self, shard: usize) -> MintOwner;
93}
94
95/// The decision for one namespace's mint, carrying the shard so a refusal can
96/// name it.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum MintRoute {
99    /// Mint here — this node owns the registry shard.
100    Local {
101        /// The namespace's registry shard.
102        shard: usize,
103    },
104    /// Forward the mint to the owner at `target`.
105    Remote {
106        /// The namespace's registry shard.
107        shard: usize,
108        /// The owner's gRPC address.
109        target: SocketAddr,
110    },
111    /// Attempt here and let the receiver fence answer.
112    Unknown {
113        /// The namespace's registry shard.
114        shard: usize,
115    },
116}
117
118/// The already-authorized caller's inbound request credentials, carried verbatim
119/// onto a forwarded mint so the OWNING node authorizes the caller exactly as
120/// this node did — the same discipline the R-3 request forwarder applies to a
121/// forwarded signal/query/cancel.
122///
123/// Empty for mint seams that have no inbound request metadata to copy (the HTTP
124/// start path and the worker-registration seam). A forwarded mint carrying no
125/// credentials authorizes on the owner exactly as an unauthenticated request
126/// would: the operator identity when the deployment runs with auth off, and a
127/// refusal when it does not. That is a truthful refusal, never a silent bypass.
128#[derive(Clone, Default)]
129pub struct MintCredentials {
130    metadata: tonic::metadata::MetadataMap,
131}
132
133impl MintCredentials {
134    /// Copy an inbound gRPC request's caller metadata.
135    #[must_use]
136    pub fn from_grpc_metadata(metadata: &tonic::metadata::MetadataMap) -> Self {
137        Self {
138            metadata: metadata.clone(),
139        }
140    }
141
142    /// The metadata to stamp onto an outbound forward.
143    #[must_use]
144    pub fn to_grpc_metadata(&self) -> tonic::metadata::MetadataMap {
145        self.metadata.clone()
146    }
147}
148
149/// Prints how many credential entries are carried, never their values: a bearer
150/// token must not reach a log through a `Debug` derive.
151impl std::fmt::Debug for MintCredentials {
152    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        formatter
154            .debug_struct("MintCredentials")
155            .field("entries", &self.metadata.len())
156            .finish()
157    }
158}
159
160/// Why a forwarded mint did not succeed.
161///
162/// The two arms have opposite meanings and opposite surfacings, so they are
163/// distinguished structurally (does the reply carry the server's typed wire
164/// error?) and never by matching on message text.
165#[derive(Debug)]
166pub enum ForwardMintError {
167    /// The owner ANSWERED and refused. Its typed refusal is relayed to the
168    /// caller verbatim — an `auto_create = closed` denial forwarded back is a
169    /// namespace denial here too.
170    Refused(WireError),
171    /// The forward never reached a decision: the dial failed, the transport
172    /// broke, or the reply did not match the request. The caller is told the
173    /// truth (the namespace's shard is owned elsewhere and was not reachable)
174    /// and owns the retry policy; nothing retries internally.
175    Unreachable(String),
176}
177
178/// Ships an already-authorized mint to the namespace shard's owning node.
179#[async_trait]
180pub trait MintForwarder: Send + Sync {
181    /// Forward the mint of `namespaces` (with `origin`) to the owner at
182    /// `target`, copying `credentials` so the owner authorizes identically.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`ForwardMintError::Refused`] with the owner's typed refusal, or
187    /// [`ForwardMintError::Unreachable`] when no decision was reached.
188    async fn forward_mint(
189        &self,
190        target: SocketAddr,
191        credentials: &MintCredentials,
192        namespaces: &[String],
193        origin: NamespaceOrigin,
194    ) -> Result<(), ForwardMintError>;
195}
196
197/// The routing context a clustered boot threads into every
198/// [`NamespaceMinter`](super::NamespaceMinter).
199#[derive(Clone)]
200pub struct NamespaceRouting {
201    shards: Arc<dyn NamespaceShardResolver>,
202    owners: Arc<dyn MintShardOwners>,
203    forwarder: Arc<dyn MintForwarder>,
204    credentials: MintCredentials,
205}
206
207impl std::fmt::Debug for NamespaceRouting {
208    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        formatter
210            .debug_struct("NamespaceRouting")
211            .field("credentials", &self.credentials)
212            .finish_non_exhaustive()
213    }
214}
215
216impl NamespaceRouting {
217    /// Build the routing context over its three handles, with no caller
218    /// credentials yet.
219    #[must_use]
220    pub fn new(
221        shards: Arc<dyn NamespaceShardResolver>,
222        owners: Arc<dyn MintShardOwners>,
223        forwarder: Arc<dyn MintForwarder>,
224    ) -> Self {
225        Self {
226            shards,
227            owners,
228            forwarder,
229            credentials: MintCredentials::default(),
230        }
231    }
232
233    /// Attach the inbound request's caller credentials for this request's mints.
234    #[must_use]
235    pub fn with_credentials(mut self, credentials: MintCredentials) -> Self {
236        self.credentials = credentials;
237        self
238    }
239
240    /// Where `name`'s mint must execute.
241    ///
242    /// Decision order: resolve the name's registry shard, then ask the directory
243    /// who owns it. `Local` and `Unknown` both execute here — the second
244    /// deliberately, so the receiver fence (the enforcement) answers rather than
245    /// the directory (a hint).
246    #[must_use]
247    pub fn route_for(&self, name: &str) -> MintRoute {
248        let shard = self.shards.shard_for_namespace(name);
249        match self.owners.owner_of(shard) {
250            MintOwner::Local => MintRoute::Local { shard },
251            MintOwner::Remote(target) => MintRoute::Remote { shard, target },
252            MintOwner::Unknown => MintRoute::Unknown { shard },
253        }
254    }
255
256    /// Forward `name`'s mint to `target`, surfacing exactly one truthful typed
257    /// refusal on failure.
258    ///
259    /// # Errors
260    ///
261    /// Returns the owner's refusal verbatim when it answered, or a typed
262    /// `not_owner` naming the namespace and its shard when the forward reached
263    /// no decision. Never retries internally — the client owns retry policy.
264    pub async fn forward(
265        &self,
266        name: &str,
267        target: SocketAddr,
268        shard: usize,
269        origin: NamespaceOrigin,
270    ) -> Result<(), ServerError> {
271        let namespaces = [name.to_owned()];
272        match self
273            .forwarder
274            .forward_mint(target, &self.credentials, &namespaces, origin)
275            .await
276        {
277            Ok(()) => Ok(()),
278            Err(ForwardMintError::Refused(wire)) => Err(ServerError::Wire { wire }),
279            Err(ForwardMintError::Unreachable(detail)) => Err(ServerError::Wire {
280                wire: WireError::not_owner(format!(
281                    "namespace `{name}` is registered on shard {shard}, owned by \
282                         cluster node {target}, which did not answer the mint: {detail}"
283                ))
284                .with_error_type("NotOwner"),
285            }),
286        }
287    }
288}
289
290/// The production namespace-shard resolver: the distributed haematite store
291/// hashes the registry key itself.
292#[cfg(feature = "haematite-backend")]
293impl NamespaceShardResolver for aion_store_haematite::HaematiteStore {
294    fn shard_for_namespace(&self, name: &str) -> usize {
295        Self::shard_for_namespace(self, name)
296    }
297}
298
299/// The production owner directory: the R-2 static directory with its SS-3
300/// quorum-replicated shard-owner overlay and peer-liveness gate — the SAME
301/// resolver the request-routing edge consults, so the mint and a signal for the
302/// same shard can never disagree about who owns it.
303#[cfg(feature = "haematite-backend")]
304impl MintShardOwners for crate::routing::StaticShardDirectory {
305    fn owner_of(&self, shard: usize) -> MintOwner {
306        use crate::routing::{OwnerView, ShardDirectory};
307        match ShardDirectory::owner_of(self, shard) {
308            OwnerView::Local => MintOwner::Local,
309            // A known owner with no declared gRPC address has nothing to dial:
310            // the truthful outcome is the local attempt's fence, not a forward.
311            OwnerView::Remote(node) => node.grpc_addr.map_or(MintOwner::Unknown, MintOwner::Remote),
312            OwnerView::Unknown => MintOwner::Unknown,
313        }
314    }
315}
316
317/// The wire code for a mint origin, so the owner records the same provenance the
318/// initiating node would have recorded.
319#[must_use]
320pub const fn encode_mint_origin(origin: NamespaceOrigin) -> i32 {
321    let code = match origin {
322        NamespaceOrigin::WorkerMint => generated::NamespaceMintOrigin::Worker,
323        NamespaceOrigin::StartMint => generated::NamespaceMintOrigin::Start,
324        NamespaceOrigin::Explicit => generated::NamespaceMintOrigin::Explicit,
325        NamespaceOrigin::InferredFromState => generated::NamespaceMintOrigin::InferredFromState,
326    };
327    code as i32
328}
329
330/// Decode a wire mint origin. `None` for the unspecified/unknown code, which the
331/// owner-side handler refuses as invalid input rather than guessing a
332/// provenance.
333#[must_use]
334pub fn decode_mint_origin(code: i32) -> Option<NamespaceOrigin> {
335    match generated::NamespaceMintOrigin::try_from(code).ok()? {
336        generated::NamespaceMintOrigin::Unspecified => None,
337        generated::NamespaceMintOrigin::Worker => Some(NamespaceOrigin::WorkerMint),
338        generated::NamespaceMintOrigin::Start => Some(NamespaceOrigin::StartMint),
339        generated::NamespaceMintOrigin::Explicit => Some(NamespaceOrigin::Explicit),
340        generated::NamespaceMintOrigin::InferredFromState => {
341            Some(NamespaceOrigin::InferredFromState)
342        }
343    }
344}
345
346#[cfg(test)]
347#[path = "route_tests.rs"]
348mod tests;