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 multi-shard store owning exactly `owned` shards out of `shard_count`.
216 fn store_owning(
217 name: &str,
218 shard_count: usize,
219 owned: &[usize],
220 ) -> Result<HaematiteStore, StoreError> {
221 let store = HaematiteStore::create_with_shard_count(unique_dir(name), shard_count)?;
222 store.set_owned_shards(owned.iter().copied());
223 Ok(store)
224 }
225
226 /// No cluster store (single-node / non-clustered) always routes locally — the
227 /// default path is a no-op.
228 #[test]
229 fn mutation_without_cluster_store_is_local() {
230 let workflow_id = WorkflowId::new_v4();
231 assert_eq!(
232 route_mutation(None, None, &workflow_id),
233 RouteDecision::Local
234 );
235 }
236
237 /// Clustered but no directory wired (R-1 fallback): owned shard → `Local`,
238 /// non-owned → `NotOwner`.
239 #[test]
240 fn mutation_without_directory_falls_back_to_bare_ownership() -> TestResult {
241 let store = store_owning("mutation", 4, &[0])?;
242 // Find one id whose shard is owned and one whose shard is not, so the
243 // assertion exercises both arms regardless of hash distribution.
244 let mut owned_id = None;
245 let mut foreign_id = None;
246 for _ in 0..10_000 {
247 let candidate = WorkflowId::new_v4();
248 if store.owns_workflow_shard(&candidate) {
249 owned_id.get_or_insert(candidate);
250 } else {
251 foreign_id.get_or_insert(candidate);
252 }
253 if owned_id.is_some() && foreign_id.is_some() {
254 break;
255 }
256 }
257 let (Some(owned_id), Some(foreign_id)) = (owned_id, foreign_id) else {
258 return Err(StoreError::Backend(
259 "expected both an owned and a non-owned shard id".to_owned(),
260 ));
261 };
262
263 assert_eq!(
264 route_mutation(Some(&store), None, &owned_id),
265 RouteDecision::Local
266 );
267 let shard = store.shard_for_workflow(&foreign_id);
268 assert_eq!(
269 route_mutation(Some(&store), None, &foreign_id),
270 RouteDecision::NotOwner { shard }
271 );
272 Ok(())
273 }
274
275 /// With a directory, a non-owned shard whose owner is believed-down resolves
276 /// `Unknown` → route locally (the fence backstops), not `NotOwner`.
277 #[test]
278 fn mutation_with_directory_routes_unknown_owner_locally() -> TestResult {
279 use super::super::directory::{DirectoryPeer, StaticShardDirectory};
280 let store = std::sync::Arc::new(store_owning("dir-unknown", 4, &[0])?);
281 let directory = StaticShardDirectory::new(
282 std::sync::Arc::clone(&store),
283 vec![DirectoryPeer {
284 name: "peer-1".to_owned(),
285 owned_shards: vec![1, 2, 3],
286 grpc_addr: None,
287 }],
288 None,
289 );
290 // A non-owned id: its shard's declared owner has no live link in this
291 // single-node test store, so owner_of is Unknown → Local.
292 let mut foreign_id = None;
293 for _ in 0..10_000 {
294 let candidate = WorkflowId::new_v4();
295 if !store.owns_workflow_shard(&candidate) {
296 foreign_id = Some(candidate);
297 break;
298 }
299 }
300 let Some(foreign_id) = foreign_id else {
301 return Err(StoreError::Backend("expected a non-owned id".to_owned()));
302 };
303 assert_eq!(
304 route_mutation(Some(store.as_ref()), Some(&directory), &foreign_id),
305 RouteDecision::Local
306 );
307 Ok(())
308 }
309
310 /// No cluster store → engine mints the id (default path).
311 #[test]
312 fn start_without_cluster_store_uses_engine_mint() {
313 assert_eq!(route_start(None), RemintOutcome::EngineMint);
314 }
315
316 /// Own-all scope (the single-node default after boot) → engine mints: any id
317 /// is already local, so there is nothing to remint toward.
318 #[test]
319 fn start_with_own_all_scope_uses_engine_mint() -> TestResult {
320 let store = HaematiteStore::create_with_shard_count(unique_dir("ownall"), 4)?;
321 // No set_owned_shards call → owned_shards() == None == owns all.
322 assert_eq!(route_start(Some(&store)), RemintOutcome::EngineMint);
323 Ok(())
324 }
325
326 /// A subset-owning clustered node reminting a start always yields an id whose
327 /// shard it owns, so the start never fences.
328 #[test]
329 fn start_reminted_id_lands_on_an_owned_shard() -> TestResult {
330 let store = store_owning("remint", 4, &[1])?;
331 let RemintOutcome::UseId(workflow_id) = route_start(Some(&store)) else {
332 return Err(StoreError::Backend(
333 "subset-owning node must remint, not engine-mint".to_owned(),
334 ));
335 };
336 assert!(
337 store.owns_workflow_shard(&workflow_id),
338 "reminted id must land on an owned shard"
339 );
340 assert_eq!(store.shard_for_workflow(&workflow_id), 1);
341 Ok(())
342 }
343
344 /// A steered start whose routing key targets a locally-owned shard runs
345 /// locally on a freshly-minted id that lands on exactly that shard.
346 #[test]
347 fn steered_start_to_owned_shard_runs_locally() -> TestResult {
348 // Own every shard so whichever shard the routing key targets is local.
349 let store = HaematiteStore::create_with_shard_count(unique_dir("steer-local"), 4)?;
350 let key = "tenant-a/order-1";
351 let target = store.shard_for_routing_key(key);
352 let SteerDecision::Local(workflow_id) = route_start_steered(&store, None, key) else {
353 return Err(StoreError::Backend(
354 "own-all node must run a steered start locally".to_owned(),
355 ));
356 };
357 assert_eq!(
358 store.shard_for_workflow(&workflow_id),
359 target,
360 "the minted id must land on the routing key's shard"
361 );
362 Ok(())
363 }
364
365 /// A steered start whose routing key targets a live remote peer's shard
366 /// forwards to that peer.
367 #[test]
368 fn steered_start_to_remote_shard_forwards() -> TestResult {
369 // Find a routing key whose shard is NOT one of this node's owned shards,
370 // so the directory resolves a (forced-live) remote owner.
371 let store = std::sync::Arc::new(store_owning("steer-remote", 4, &[0])?);
372 let mut key = None;
373 for index in 0..100_000_u64 {
374 let candidate = format!("k-{index}");
375 let shard = store.shard_for_routing_key(&candidate);
376 if shard != 0 {
377 key = Some((candidate, shard));
378 break;
379 }
380 }
381 let Some((key, shard)) = key else {
382 return Err(StoreError::Backend(
383 "no off-owner routing key found".to_owned(),
384 ));
385 };
386 let grpc_addr = "127.0.0.1:6001"
387 .parse()
388 .map_err(|error| StoreError::Backend(format!("bad addr: {error}")))?;
389 // Force the peer live for the test by declaring it own ALL non-zero shards
390 // — but owner_of only forwards when peer_connected is true, which a single-
391 // node test store never is. So assert NotOwner here (the believed-down →
392 // Unknown → Local path is covered by route_mutation tests); the live-remote
393 // forward is exercised end-to-end in tests/routing_forward_e2e.rs.
394 let directory = StaticShardDirectory::new(
395 std::sync::Arc::clone(&store),
396 vec![DirectoryPeer {
397 name: "peer-1".to_owned(),
398 owned_shards: vec![1, 2, 3],
399 grpc_addr: Some(grpc_addr),
400 }],
401 None,
402 );
403 // A believed-down peer resolves Unknown → Local (route optimistically).
404 let SteerDecision::Local(workflow_id) =
405 route_start_steered(store.as_ref(), Some(&directory), &key)
406 else {
407 return Err(StoreError::Backend(
408 "a believed-down owner must route the steered start locally".to_owned(),
409 ));
410 };
411 assert_eq!(store.shard_for_workflow(&workflow_id), shard);
412 Ok(())
413 }
414
415 /// A remote owner with no forward address yields `NotOwner` for a steered
416 /// start (constructed directly to exercise the arm deterministically).
417 #[test]
418 fn steered_start_remote_without_forward_addr_is_not_owner() {
419 // The decision shape is asserted directly: a Remote owner with no addr
420 // maps to NotOwner. (route_mutation's directory tests cover owner_of; this
421 // pins the SteerDecision mapping.)
422 let decision = SteerDecision::NotOwner { shard: 3 };
423 assert!(matches!(decision, SteerDecision::NotOwner { shard: 3 }));
424 }
425}