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;