Skip to main content

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    fn store(
225        name: &str,
226        shard_count: usize,
227        owned: &[usize],
228    ) -> Result<HaematiteStore, StoreError> {
229        let store = HaematiteStore::create_with_shard_count(unique_dir(name), shard_count)?;
230        store.set_owned_shards(owned.iter().copied());
231        Ok(store)
232    }
233
234    /// Locally-owned shards resolve Local; the own-all scope resolves every shard
235    /// Local.
236    #[test]
237    fn owned_shards_resolve_local() -> TestResult {
238        let store = std::sync::Arc::new(store("local", 4, &[0, 1])?);
239        let directory = StaticShardDirectory::new(store, Vec::new(), None);
240        assert_eq!(directory.owner_of(0), OwnerView::Local);
241        assert_eq!(directory.owner_of(1), OwnerView::Local);
242        Ok(())
243    }
244
245    /// A shard declared by a peer with no live link resolves Unknown (route
246    /// locally; the fence + supervisor converge), NOT a forwardable Remote.
247    #[test]
248    fn down_peer_shard_resolves_unknown() -> TestResult {
249        let store = std::sync::Arc::new(store("downpeer", 4, &[0])?);
250        let directory = StaticShardDirectory::new(
251            store,
252            vec![DirectoryPeer {
253                name: "peer-1".to_owned(),
254                owned_shards: vec![2, 3],
255                grpc_addr: Some(
256                    "127.0.0.1:6001"
257                        .parse()
258                        .map_err(|error| StoreError::Backend(format!("bad addr: {error}")))?,
259                ),
260            }],
261            None,
262        );
263        // A single-node test store has no live distribution link, so
264        // peer_connected is always false → the peer is believed-down.
265        assert_eq!(directory.owner_of(2), OwnerView::Unknown);
266        Ok(())
267    }
268
269    /// A shard no peer declares resolves Unknown.
270    #[test]
271    fn undeclared_shard_resolves_unknown() -> TestResult {
272        let store = std::sync::Arc::new(store("undeclared", 4, &[0])?);
273        let directory = StaticShardDirectory::new(
274            store,
275            vec![DirectoryPeer {
276                name: "peer-1".to_owned(),
277                owned_shards: vec![1],
278                grpc_addr: None,
279            }],
280            None,
281        );
282        assert_eq!(directory.owner_of(3), OwnerView::Unknown);
283        Ok(())
284    }
285}