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