aion_server/routing/directory.rs
1//! R-2 / SS-3 shard → owner directory (DISTRIBUTED-ROUTING-DESIGN §2.3).
2//!
3//! The [`ShardDirectory`] trait is the stable seam routing consumes; the full
4//! SS-3 liminal-coordinator CAS directory later swaps the implementation without
5//! touching the edge. This file ships [`StaticShardDirectory`], which layers
6//! three sources, most-authoritative first:
7//!
8//! 1. **Live local ownership** (`store.owned_shards()`, which `adopt_shards`
9//! widens on failover) → [`OwnerView::Local`].
10//! 2. **The SS-3 quorum-replicated shard-owner directory record**
11//! (`store.read_shard_owner()`): when a survivor adopts a dead owner's shard
12//! it PUBLISHES itself as the new owner (fenced, quorum-replicated), so every
13//! other survivor reads the *adopter* off its own replica and forwards there.
14//! This is the increment that closes gap #2 — without it a survivor that did
15//! not adopt the shard resolves the dead declared owner to `Unknown`, routes
16//! locally, and fails `WorkflowNotFound`.
17//! 3. **Static peer config + live peer liveness** (`store.peer_connected()`) as
18//! the steady-state pre-adoption fallback.
19//!
20//! ## SS-3 status — minimal correct increment (not the full CAS directory)
21//!
22//! The published record IS quorum-backed and fenced (via haematite
23//! `replicate_write` co-located on the adopted shard), which is the load-bearing
24//! property: only the true election winner can publish, and the record is
25//! linearizable per shard under the same epoch fence as the data. What the FULL
26//! SS-3 directory (DISTRIBUTED-ROUTING-DESIGN §2.3 v2 / STORAGE-SWAP §3(e)) adds
27//! on top: a single liminal global-name *coordinator* that owns the authoritative
28//! `{shard, owner, epoch}` assignment for the WHOLE map (not just post-adoption
29//! deltas), epoch-stamped cache invalidation in `NodeRef::epoch`, and assignment
30//! at cluster formation / rebalance rather than only on failover. This increment
31//! deliberately scopes to the failover-adoption delta — the one gap the kill-9
32//! demo surfaced — behind the unchanged [`ShardDirectory`] trait so the full
33//! coordinator is a later impl swap.
34
35use std::net::SocketAddr;
36use std::sync::Arc;
37
38use aion_store_haematite::HaematiteStore;
39
40/// A resolved remote shard owner.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct NodeRef {
43 /// The owner's distribution node id (its `ClusterPeer.name`).
44 pub node_id: String,
45 /// The owner's gRPC client-API address for forwarding (R-3). `None` when the
46 /// peer declared no `grpc_address` — then it is a known-but-not-forwardable
47 /// owner and routing falls back to `NotOwner`.
48 pub grpc_addr: Option<SocketAddr>,
49 /// The ownership epoch the resolver believes is current. The static resolver
50 /// has no epoch source, so it reports `0`; SS-3's CAS directory fills this in.
51 pub epoch: u64,
52}
53
54/// The directory's view of a shard's current owner.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum OwnerView {
57 /// This node owns the shard: proceed to the local engine.
58 Local,
59 /// Another node owns the shard. Carries the owner reference (which may or may
60 /// not be forwardable, per `NodeRef::grpc_addr`).
61 Remote(NodeRef),
62 /// The owner is not known with confidence: either no peer declares the shard,
63 /// or the peer that does is believed-down (its liveness link dropped). Route
64 /// locally/optimistically — the epoch fence backstops correctness and the
65 /// local supervisor's pending adoption converges ownership (§2.5).
66 Unknown,
67}
68
69/// Resolves the current owner of a distribution shard.
70///
71/// Routing consumes this; SS-3 produces the authoritative implementation later.
72pub trait ShardDirectory: Send + Sync {
73 /// The current owner of `shard`, with the epoch the resolver believes is
74 /// current.
75 fn owner_of(&self, shard: usize) -> OwnerView;
76}
77
78/// One peer entry in the static directory: its name, the shards it statically
79/// declares ownership of, and its (optional) gRPC forward address.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct DirectoryPeer {
82 /// The peer's distribution node id.
83 pub name: String,
84 /// The shards this peer statically declares it owns.
85 pub owned_shards: Vec<usize>,
86 /// The peer's gRPC client-API address, if declared (R-3 forward target).
87 pub grpc_addr: Option<SocketAddr>,
88}
89
90/// The static directory: static peer config + live local-ownership, the SS-3
91/// quorum-replicated shard-owner overlay, and a peer-liveness overlay, all read
92/// live from the cluster store.
93pub struct StaticShardDirectory {
94 /// The cluster store, read live for this node's owned shards, the SS-3
95 /// shard-owner directory record, and peer liveness. Holding the `Arc` keeps
96 /// the directory cheap to clone-by-Arc and always current without a rebuild
97 /// on failover.
98 store: Arc<HaematiteStore>,
99 /// Peers and the shards they statically declare, with forward addresses.
100 peers: Vec<DirectoryPeer>,
101 /// This node's own distribution name, so a shard-owner record naming THIS
102 /// node resolves `Local` (defensive — local ownership normally already
103 /// reflects an adoption) and a record naming a peer can be matched to that
104 /// peer's forward address. `None` leaves the SS-3 overlay's self-match
105 /// disabled (the local-ownership check still covers self-adoption).
106 self_node_id: Option<String>,
107}
108
109impl StaticShardDirectory {
110 /// Build a directory over `store`, the configured `peers`, and this node's
111 /// own distribution name `self_node_id` (used to resolve an SS-3 shard-owner
112 /// record that names this node).
113 #[must_use]
114 pub fn new(
115 store: Arc<HaematiteStore>,
116 peers: Vec<DirectoryPeer>,
117 self_node_id: Option<String>,
118 ) -> Self {
119 Self {
120 store,
121 peers,
122 self_node_id,
123 }
124 }
125
126 /// Whether this node currently owns `shard` (live, `adopt_shards`-aware).
127 /// `owned_shards() == None` means own-all (single owner / pre-failover boot).
128 fn owns_locally(&self, shard: usize) -> bool {
129 self.store
130 .owned_shards()
131 .is_none_or(|owned| owned.contains(&shard))
132 }
133
134 /// Resolve `shard` from the SS-3 shard-owner directory record, if one names a
135 /// CURRENT owner. Returns `None` when no record exists (steady state), the
136 /// record names a peer that is not configured/forwardable, or the read fails
137 /// (treated as "no overlay opinion" — the static fallback then applies). A
138 /// record naming this node resolves `Local`; a record naming a live,
139 /// forwardable peer resolves `Remote`.
140 fn resolve_from_record(&self, shard: usize) -> Option<OwnerView> {
141 // A failed read must not break routing: fall through to the static map.
142 let owner = self.store.read_shard_owner(shard).ok().flatten()?;
143 // The record names THIS node (it adopted the shard): serve locally.
144 if self.self_node_id.as_deref() == Some(owner.as_str()) {
145 return Some(OwnerView::Local);
146 }
147 // The record names a configured peer: forward there if it is forwardable
148 // and currently live (a record can outlive its writer; liveness still
149 // gates the forward target, §2.5).
150 let peer = self.peers.iter().find(|peer| peer.name == owner)?;
151 if self.store.peer_connected(&peer.name) {
152 Some(OwnerView::Remote(NodeRef {
153 node_id: peer.name.clone(),
154 grpc_addr: peer.grpc_addr,
155 epoch: 0,
156 }))
157 } else {
158 // The recorded owner is itself now down: no opinion — let the static
159 // map / a later adoption + re-publish converge.
160 None
161 }
162 }
163}
164
165impl ShardDirectory for StaticShardDirectory {
166 fn owner_of(&self, shard: usize) -> OwnerView {
167 if self.owns_locally(shard) {
168 return OwnerView::Local;
169 }
170 // SS-3: the quorum-replicated shard-owner record is the authoritative
171 // post-adoption signal. Consulted BEFORE the static map so a survivor
172 // that adopted this shard is resolved as the current owner even though
173 // the static config still names the (dead) declared owner — gap #2.
174 if let Some(view) = self.resolve_from_record(shard) {
175 return view;
176 }
177 // Steady-state fallback: the peer that statically declares this shard.
178 let Some(peer) = self
179 .peers
180 .iter()
181 .find(|peer| peer.owned_shards.contains(&shard))
182 else {
183 // No declared owner: route optimistically; the fence backstops.
184 return OwnerView::Unknown;
185 };
186 // A peer believed-down resolves Unknown so the edge routes locally while
187 // the supervisor adopts; a live peer is the forward target (§2.5).
188 if self.store.peer_connected(&peer.name) {
189 OwnerView::Remote(NodeRef {
190 node_id: peer.name.clone(),
191 grpc_addr: peer.grpc_addr,
192 epoch: 0,
193 })
194 } else {
195 OwnerView::Unknown
196 }
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use std::path::PathBuf;
203 use std::sync::atomic::{AtomicU64, Ordering};
204 use std::time::{SystemTime, UNIX_EPOCH};
205
206 use super::{DirectoryPeer, OwnerView, ShardDirectory, StaticShardDirectory};
207 use aion_store::StoreError;
208 use aion_store_haematite::HaematiteStore;
209
210 type TestResult = Result<(), StoreError>;
211
212 fn unique_dir(name: &str) -> PathBuf {
213 static COUNTER: AtomicU64 = AtomicU64::new(0);
214 let nanos = SystemTime::now()
215 .duration_since(UNIX_EPOCH)
216 .map_or(0, |duration| duration.as_nanos());
217 let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
218 std::env::temp_dir().join(format!(
219 "aion-routing-dir-{name}-{}-{nanos}-{counter}",
220 std::process::id()
221 ))
222 }
223
224 /// A generous node-cache byte budget (1 GiB) for these routing fixtures:
225 /// roomy enough that no test here can reach it, so these stay shard-routing
226 /// tests. A TEST value, never a default — production states its own.
227 fn test_node_cache_budget() -> Result<haematite::NodeCacheBudget, StoreError> {
228 haematite::NodeCacheBudget::bytes(1 << 30)
229 .map_err(|error| StoreError::Backend(error.to_string()))
230 }
231
232 fn store(
233 name: &str,
234 shard_count: usize,
235 owned: &[usize],
236 ) -> Result<HaematiteStore, StoreError> {
237 let store = HaematiteStore::create_with_shard_count(
238 unique_dir(name),
239 shard_count,
240 test_node_cache_budget()?,
241 )?;
242 store.set_owned_shards(owned.iter().copied());
243 Ok(store)
244 }
245
246 /// Locally-owned shards resolve Local; the own-all scope resolves every shard
247 /// Local.
248 #[test]
249 fn owned_shards_resolve_local() -> TestResult {
250 let store = std::sync::Arc::new(store("local", 4, &[0, 1])?);
251 let directory = StaticShardDirectory::new(store, Vec::new(), None);
252 assert_eq!(directory.owner_of(0), OwnerView::Local);
253 assert_eq!(directory.owner_of(1), OwnerView::Local);
254 Ok(())
255 }
256
257 /// A shard declared by a peer with no live link resolves Unknown (route
258 /// locally; the fence + supervisor converge), NOT a forwardable Remote.
259 #[test]
260 fn down_peer_shard_resolves_unknown() -> TestResult {
261 let store = std::sync::Arc::new(store("downpeer", 4, &[0])?);
262 let directory = StaticShardDirectory::new(
263 store,
264 vec![DirectoryPeer {
265 name: "peer-1".to_owned(),
266 owned_shards: vec![2, 3],
267 grpc_addr: Some(
268 "127.0.0.1:6001"
269 .parse()
270 .map_err(|error| StoreError::Backend(format!("bad addr: {error}")))?,
271 ),
272 }],
273 None,
274 );
275 // A single-node test store has no live distribution link, so
276 // peer_connected is always false → the peer is believed-down.
277 assert_eq!(directory.owner_of(2), OwnerView::Unknown);
278 Ok(())
279 }
280
281 /// A shard no peer declares resolves Unknown.
282 #[test]
283 fn undeclared_shard_resolves_unknown() -> TestResult {
284 let store = std::sync::Arc::new(store("undeclared", 4, &[0])?);
285 let directory = StaticShardDirectory::new(
286 store,
287 vec![DirectoryPeer {
288 name: "peer-1".to_owned(),
289 owned_shards: vec![1],
290 grpc_addr: None,
291 }],
292 None,
293 );
294 assert_eq!(directory.owner_of(3), OwnerView::Unknown);
295 Ok(())
296 }
297}