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;