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}