Skip to main content

aion_server/routing/
edge.rs

1//! gRPC-edge routing primitives: the directory-aware ownership guard for
2//! signal/query/cancel (R-1/R-2) and the unsteered-start remint for `start`
3//! (R-1).
4
5use aion_core::WorkflowId;
6use aion_store_haematite::HaematiteStore;
7
8use super::directory::{NodeRef, OwnerView, ShardDirectory};
9
10/// How many remint attempts per declared shard the unsteered-start loop is given
11/// before falling back. Generous so that, even with a single owned shard out of
12/// many, the probability of exhausting the budget without drawing an owned shard
13/// is negligible, while still bounding the loop (§2.4 "bounded by shard count").
14const REMINT_ATTEMPTS_PER_SHARD: usize = 16;
15
16/// The routing verdict for a mutation/read (signal/query/cancel) whose target
17/// `workflow_id` is known up front.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum RouteDecision {
20    /// This node owns the workflow's shard, owns all shards, is not clustered, or
21    /// the owner is `Unknown` (route optimistically; the fence backstops):
22    /// proceed to the local engine.
23    Local,
24    /// A live remote node owns the workflow's shard. R-2 cannot forward yet, so
25    /// the edge returns `NotOwner` for this; R-3 forwards to `owner` when it has
26    /// a `grpc_addr` and returns `NotOwner` only when it does not.
27    Forward {
28        /// The resolved remote owner (may or may not carry a `grpc_addr`).
29        owner: NodeRef,
30        /// The shard the workflow's durable state lives on (for the `NotOwner`
31        /// fallback message and re-resolution).
32        shard: usize,
33    },
34    /// Another node owns the workflow's shard and there is no forwarding target
35    /// (no directory, or the owner declared no gRPC address). Return the typed
36    /// retryable `NotOwner` carrying the shard so a routing-aware caller can
37    /// re-resolve and retry.
38    NotOwner {
39        /// The distribution shard the workflow's durable state lives on.
40        shard: usize,
41    },
42}
43
44/// The placement decision for an unsteered `start` whose id has not been minted
45/// yet (R-1 stopgap).
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub enum RemintOutcome {
48    /// Let the engine mint the id as usual: either there is no cluster, or this
49    /// node owns every shard, so any minted id is already local. The default
50    /// single-node path always takes this arm.
51    EngineMint,
52    /// Start with this pre-minted id, chosen so its shard is locally owned, so
53    /// the start lands on this node and never fences.
54    UseId(WorkflowId),
55}
56
57/// How many mint attempts per shard the steered-start id derivation is given to
58/// draw an id landing on the routing key's target shard. Generous so exhausting
59/// the budget is negligibly likely, while still bounding the loop.
60const STEER_ATTEMPTS_PER_SHARD: usize = 16;
61
62/// The routing decision for a *steered* `start` whose target shard is derived
63/// from a caller-chosen routing key (R-4, §2.4).
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum SteerDecision {
66    /// The routing key's shard is owned by this node (or own-all / unknown owner):
67    /// run the start locally on this pre-minted id, which lands on that shard so
68    /// the start never fences.
69    Local(WorkflowId),
70    /// A live remote node owns the routing key's shard: forward the start there
71    /// (R-3 transport). Carries the resolved owner and the target shard.
72    Forward {
73        /// The resolved remote owner (carries the gRPC forward address).
74        owner: NodeRef,
75        /// The shard the routing key targets (for the `NotOwner` fallback and
76        /// re-resolution).
77        shard: usize,
78    },
79    /// The routing key's shard is owned by another node but there is no forward
80    /// target (no directory, or the owner declared no gRPC address). Return the
81    /// typed retryable `NotOwner` carrying the shard.
82    NotOwner {
83        /// The distribution shard the routing key targets.
84        shard: usize,
85    },
86}
87
88/// Route a signal/query/cancel at the edge through the shard directory.
89///
90/// `cluster_store`/`directory` are `None` for every single-node / non-clustered
91/// boot — then the result is always [`RouteDecision::Local`] and the call is
92/// byte-identical to today. With a cluster store and directory:
93/// - the owner is this node, `Unknown`, or no directory → [`RouteDecision::Local`]
94///   (own/optimistic; the fence backstops),
95/// - a live remote owner → [`RouteDecision::Forward`] (R-3 forwards; R-2 maps it
96///   to `NotOwner` since it has no forwarder yet).
97#[must_use]
98pub fn route_mutation(
99    cluster_store: Option<&HaematiteStore>,
100    directory: Option<&dyn ShardDirectory>,
101    workflow_id: &WorkflowId,
102) -> RouteDecision {
103    let Some(store) = cluster_store else {
104        return RouteDecision::Local;
105    };
106    let shard = store.shard_for_workflow(workflow_id);
107    let Some(directory) = directory else {
108        // Clustered but no directory wired: fall back to the bare local-ownership
109        // check (R-1 behaviour) — own it or reject as NotOwner.
110        return if store.owns_workflow_shard(workflow_id) {
111            RouteDecision::Local
112        } else {
113            RouteDecision::NotOwner { shard }
114        };
115    };
116    match directory.owner_of(shard) {
117        OwnerView::Local | OwnerView::Unknown => RouteDecision::Local,
118        OwnerView::Remote(owner) => RouteDecision::Forward { owner, shard },
119    }
120}
121
122/// Decide an unsteered `start`'s placement at the edge (R-1, §2.4).
123///
124/// `cluster_store` is `None` for single-node / non-clustered boots → always
125/// [`RemintOutcome::EngineMint`] (default path unchanged). With a cluster store
126/// that owns only a subset of shards, returns [`RemintOutcome::UseId`] with an
127/// id re-minted onto a locally-owned shard so the start never fences. An own-all
128/// scope also yields `EngineMint` (any id is already local). On the (negligibly
129/// likely) event the bounded remint loop is exhausted, falls back to
130/// `EngineMint` rather than failing the start — the fence then backstops as it
131/// did before routing existed.
132#[must_use]
133pub fn route_start(cluster_store: Option<&HaematiteStore>) -> RemintOutcome {
134    let Some(store) = cluster_store else {
135        return RemintOutcome::EngineMint;
136    };
137    let budget = store.shard_count().max(1) * REMINT_ATTEMPTS_PER_SHARD;
138    match store.remint_for_owned_shard(budget) {
139        Some(workflow_id) => RemintOutcome::UseId(workflow_id),
140        None => RemintOutcome::EngineMint,
141    }
142}
143
144/// Route a *steered* `start` at the edge through the shard directory (R-4, §2.4).
145///
146/// The target shard is derived from `routing_key` using the same `shard_for`
147/// hashing the store routes workflow writes with, so a steered start and any
148/// later request resolved via the same key land on one shard. Then:
149/// - the shard's owner is this node, the owner is `Unknown`, or there is no
150///   directory but this node owns the shard → [`SteerDecision::Local`] with a
151///   freshly-minted id on that shard (so the start never fences),
152/// - a live remote owner with a forward address → [`SteerDecision::Forward`],
153/// - a remote owner with no forward target → [`SteerDecision::NotOwner`].
154///
155/// `cluster_store` is `None` for single-node / non-clustered boots — but a
156/// steered start is only ever issued against a cluster, so the caller short-
157/// circuits to the engine mint before reaching here when there is no cluster
158/// store. On the (negligibly likely) event the bounded mint loop is exhausted,
159/// falls back to a plain v4 id so the start still proceeds — the fence backstops.
160#[must_use]
161pub fn route_start_steered(
162    store: &HaematiteStore,
163    directory: Option<&dyn ShardDirectory>,
164    routing_key: &str,
165) -> SteerDecision {
166    let shard = store.shard_for_routing_key(routing_key);
167    let owner = directory.map_or(OwnerView::Unknown, |directory| directory.owner_of(shard));
168    match owner {
169        OwnerView::Remote(owner) if owner.grpc_addr.is_some() => {
170            SteerDecision::Forward { owner, shard }
171        }
172        OwnerView::Remote(_) => SteerDecision::NotOwner { shard },
173        OwnerView::Local | OwnerView::Unknown => SteerDecision::Local(mint_on_shard(store, shard)),
174    }
175}
176
177/// Mint a fresh id on `shard`, falling back to a plain v4 id if the bounded loop
178/// is exhausted (the fence then backstops, exactly as before routing existed).
179fn mint_on_shard(store: &HaematiteStore, shard: usize) -> WorkflowId {
180    let budget = store.shard_count().max(1) * STEER_ATTEMPTS_PER_SHARD;
181    store
182        .mint_for_shard(shard, budget)
183        .unwrap_or_else(WorkflowId::new_v4)
184}
185
186#[cfg(test)]
187mod tests {
188    use std::path::PathBuf;
189    use std::sync::atomic::{AtomicU64, Ordering};
190    use std::time::{SystemTime, UNIX_EPOCH};
191
192    use super::super::directory::{DirectoryPeer, StaticShardDirectory};
193    use super::{
194        RemintOutcome, RouteDecision, SteerDecision, route_mutation, route_start,
195        route_start_steered,
196    };
197    use aion_core::WorkflowId;
198    use aion_store::StoreError;
199    use aion_store_haematite::HaematiteStore;
200
201    type TestResult = Result<(), StoreError>;
202
203    fn unique_dir(name: &str) -> PathBuf {
204        static COUNTER: AtomicU64 = AtomicU64::new(0);
205        let nanos = SystemTime::now()
206            .duration_since(UNIX_EPOCH)
207            .map_or(0, |duration| duration.as_nanos());
208        let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
209        std::env::temp_dir().join(format!(
210            "aion-routing-edge-{name}-{}-{nanos}-{counter}",
211            std::process::id()
212        ))
213    }
214
215    /// A generous node-cache byte budget (1 GiB) for these routing fixtures:
216    /// roomy enough that no test here can reach it, so these stay shard-routing
217    /// tests. A TEST value, never a default — production states its own.
218    fn test_node_cache_budget() -> Result<haematite::NodeCacheBudget, StoreError> {
219        haematite::NodeCacheBudget::bytes(1 << 30)
220            .map_err(|error| StoreError::Backend(error.to_string()))
221    }
222
223    /// A multi-shard store owning exactly `owned` shards out of `shard_count`.
224    fn store_owning(
225        name: &str,
226        shard_count: usize,
227        owned: &[usize],
228    ) -> Result<HaematiteStore, StoreError> {
229        let store = HaematiteStore::create_with_shard_count(
230            unique_dir(name),
231            shard_count,
232            test_node_cache_budget()?,
233        )?;
234        store.set_owned_shards(owned.iter().copied());
235        Ok(store)
236    }
237
238    /// No cluster store (single-node / non-clustered) always routes locally — the
239    /// default path is a no-op.
240    #[test]
241    fn mutation_without_cluster_store_is_local() {
242        let workflow_id = WorkflowId::new_v4();
243        assert_eq!(
244            route_mutation(None, None, &workflow_id),
245            RouteDecision::Local
246        );
247    }
248
249    /// Clustered but no directory wired (R-1 fallback): owned shard → `Local`,
250    /// non-owned → `NotOwner`.
251    #[test]
252    fn mutation_without_directory_falls_back_to_bare_ownership() -> TestResult {
253        let store = store_owning("mutation", 4, &[0])?;
254        // Find one id whose shard is owned and one whose shard is not, so the
255        // assertion exercises both arms regardless of hash distribution.
256        let mut owned_id = None;
257        let mut foreign_id = None;
258        for _ in 0..10_000 {
259            let candidate = WorkflowId::new_v4();
260            if store.owns_workflow_shard(&candidate) {
261                owned_id.get_or_insert(candidate);
262            } else {
263                foreign_id.get_or_insert(candidate);
264            }
265            if owned_id.is_some() && foreign_id.is_some() {
266                break;
267            }
268        }
269        let (Some(owned_id), Some(foreign_id)) = (owned_id, foreign_id) else {
270            return Err(StoreError::Backend(
271                "expected both an owned and a non-owned shard id".to_owned(),
272            ));
273        };
274
275        assert_eq!(
276            route_mutation(Some(&store), None, &owned_id),
277            RouteDecision::Local
278        );
279        let shard = store.shard_for_workflow(&foreign_id);
280        assert_eq!(
281            route_mutation(Some(&store), None, &foreign_id),
282            RouteDecision::NotOwner { shard }
283        );
284        Ok(())
285    }
286
287    /// With a directory, a non-owned shard whose owner is believed-down resolves
288    /// `Unknown` → route locally (the fence backstops), not `NotOwner`.
289    #[test]
290    fn mutation_with_directory_routes_unknown_owner_locally() -> TestResult {
291        use super::super::directory::{DirectoryPeer, StaticShardDirectory};
292        let store = std::sync::Arc::new(store_owning("dir-unknown", 4, &[0])?);
293        let directory = StaticShardDirectory::new(
294            std::sync::Arc::clone(&store),
295            vec![DirectoryPeer {
296                name: "peer-1".to_owned(),
297                owned_shards: vec![1, 2, 3],
298                grpc_addr: None,
299            }],
300            None,
301        );
302        // A non-owned id: its shard's declared owner has no live link in this
303        // single-node test store, so owner_of is Unknown → Local.
304        let mut foreign_id = None;
305        for _ in 0..10_000 {
306            let candidate = WorkflowId::new_v4();
307            if !store.owns_workflow_shard(&candidate) {
308                foreign_id = Some(candidate);
309                break;
310            }
311        }
312        let Some(foreign_id) = foreign_id else {
313            return Err(StoreError::Backend("expected a non-owned id".to_owned()));
314        };
315        assert_eq!(
316            route_mutation(Some(store.as_ref()), Some(&directory), &foreign_id),
317            RouteDecision::Local
318        );
319        Ok(())
320    }
321
322    /// No cluster store → engine mints the id (default path).
323    #[test]
324    fn start_without_cluster_store_uses_engine_mint() {
325        assert_eq!(route_start(None), RemintOutcome::EngineMint);
326    }
327
328    /// Own-all scope (the single-node default after boot) → engine mints: any id
329    /// is already local, so there is nothing to remint toward.
330    #[test]
331    fn start_with_own_all_scope_uses_engine_mint() -> TestResult {
332        let store = HaematiteStore::create_with_shard_count(
333            unique_dir("ownall"),
334            4,
335            test_node_cache_budget()?,
336        )?;
337        // No set_owned_shards call → owned_shards() == None == owns all.
338        assert_eq!(route_start(Some(&store)), RemintOutcome::EngineMint);
339        Ok(())
340    }
341
342    /// A subset-owning clustered node reminting a start always yields an id whose
343    /// shard it owns, so the start never fences.
344    #[test]
345    fn start_reminted_id_lands_on_an_owned_shard() -> TestResult {
346        let store = store_owning("remint", 4, &[1])?;
347        let RemintOutcome::UseId(workflow_id) = route_start(Some(&store)) else {
348            return Err(StoreError::Backend(
349                "subset-owning node must remint, not engine-mint".to_owned(),
350            ));
351        };
352        assert!(
353            store.owns_workflow_shard(&workflow_id),
354            "reminted id must land on an owned shard"
355        );
356        assert_eq!(store.shard_for_workflow(&workflow_id), 1);
357        Ok(())
358    }
359
360    /// A steered start whose routing key targets a locally-owned shard runs
361    /// locally on a freshly-minted id that lands on exactly that shard.
362    #[test]
363    fn steered_start_to_owned_shard_runs_locally() -> TestResult {
364        // Own every shard so whichever shard the routing key targets is local.
365        let store = HaematiteStore::create_with_shard_count(
366            unique_dir("steer-local"),
367            4,
368            test_node_cache_budget()?,
369        )?;
370        let key = "tenant-a/order-1";
371        let target = store.shard_for_routing_key(key);
372        let SteerDecision::Local(workflow_id) = route_start_steered(&store, None, key) else {
373            return Err(StoreError::Backend(
374                "own-all node must run a steered start locally".to_owned(),
375            ));
376        };
377        assert_eq!(
378            store.shard_for_workflow(&workflow_id),
379            target,
380            "the minted id must land on the routing key's shard"
381        );
382        Ok(())
383    }
384
385    /// A steered start whose routing key targets a live remote peer's shard
386    /// forwards to that peer.
387    #[test]
388    fn steered_start_to_remote_shard_forwards() -> TestResult {
389        // Find a routing key whose shard is NOT one of this node's owned shards,
390        // so the directory resolves a (forced-live) remote owner.
391        let store = std::sync::Arc::new(store_owning("steer-remote", 4, &[0])?);
392        let mut key = None;
393        for index in 0..100_000_u64 {
394            let candidate = format!("k-{index}");
395            let shard = store.shard_for_routing_key(&candidate);
396            if shard != 0 {
397                key = Some((candidate, shard));
398                break;
399            }
400        }
401        let Some((key, shard)) = key else {
402            return Err(StoreError::Backend(
403                "no off-owner routing key found".to_owned(),
404            ));
405        };
406        let grpc_addr = "127.0.0.1:6001"
407            .parse()
408            .map_err(|error| StoreError::Backend(format!("bad addr: {error}")))?;
409        // Force the peer live for the test by declaring it own ALL non-zero shards
410        // — but owner_of only forwards when peer_connected is true, which a single-
411        // node test store never is. So assert NotOwner here (the believed-down →
412        // Unknown → Local path is covered by route_mutation tests); the live-remote
413        // forward is exercised end-to-end in tests/routing_forward_e2e.rs.
414        let directory = StaticShardDirectory::new(
415            std::sync::Arc::clone(&store),
416            vec![DirectoryPeer {
417                name: "peer-1".to_owned(),
418                owned_shards: vec![1, 2, 3],
419                grpc_addr: Some(grpc_addr),
420            }],
421            None,
422        );
423        // A believed-down peer resolves Unknown → Local (route optimistically).
424        let SteerDecision::Local(workflow_id) =
425            route_start_steered(store.as_ref(), Some(&directory), &key)
426        else {
427            return Err(StoreError::Backend(
428                "a believed-down owner must route the steered start locally".to_owned(),
429            ));
430        };
431        assert_eq!(store.shard_for_workflow(&workflow_id), shard);
432        Ok(())
433    }
434
435    /// A remote owner with no forward address yields `NotOwner` for a steered
436    /// start (constructed directly to exercise the arm deterministically).
437    #[test]
438    fn steered_start_remote_without_forward_addr_is_not_owner() {
439        // The decision shape is asserted directly: a Remote owner with no addr
440        // maps to NotOwner. (route_mutation's directory tests cover owner_of; this
441        // pins the SteerDecision mapping.)
442        let decision = SteerDecision::NotOwner { shard: 3 };
443        assert!(matches!(decision, SteerDecision::NotOwner { shard: 3 }));
444    }
445}