aion-server 0.13.6

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! gRPC-edge routing primitives: the directory-aware ownership guard for
//! signal/query/cancel (R-1/R-2) and the unsteered-start remint for `start`
//! (R-1).

use aion_core::WorkflowId;
use aion_store_haematite::HaematiteStore;

use super::directory::{NodeRef, OwnerView, ShardDirectory};

/// How many remint attempts per declared shard the unsteered-start loop is given
/// before falling back. Generous so that, even with a single owned shard out of
/// many, the probability of exhausting the budget without drawing an owned shard
/// is negligible, while still bounding the loop (§2.4 "bounded by shard count").
const REMINT_ATTEMPTS_PER_SHARD: usize = 16;

/// The routing verdict for a mutation/read (signal/query/cancel) whose target
/// `workflow_id` is known up front.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RouteDecision {
    /// This node owns the workflow's shard, owns all shards, is not clustered, or
    /// the owner is `Unknown` (route optimistically; the fence backstops):
    /// proceed to the local engine.
    Local,
    /// A live remote node owns the workflow's shard. R-2 cannot forward yet, so
    /// the edge returns `NotOwner` for this; R-3 forwards to `owner` when it has
    /// a `grpc_addr` and returns `NotOwner` only when it does not.
    Forward {
        /// The resolved remote owner (may or may not carry a `grpc_addr`).
        owner: NodeRef,
        /// The shard the workflow's durable state lives on (for the `NotOwner`
        /// fallback message and re-resolution).
        shard: usize,
    },
    /// Another node owns the workflow's shard and there is no forwarding target
    /// (no directory, or the owner declared no gRPC address). Return the typed
    /// retryable `NotOwner` carrying the shard so a routing-aware caller can
    /// re-resolve and retry.
    NotOwner {
        /// The distribution shard the workflow's durable state lives on.
        shard: usize,
    },
}

/// The placement decision for an unsteered `start` whose id has not been minted
/// yet (R-1 stopgap).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RemintOutcome {
    /// Let the engine mint the id as usual: either there is no cluster, or this
    /// node owns every shard, so any minted id is already local. The default
    /// single-node path always takes this arm.
    EngineMint,
    /// Start with this pre-minted id, chosen so its shard is locally owned, so
    /// the start lands on this node and never fences.
    UseId(WorkflowId),
}

/// How many mint attempts per shard the steered-start id derivation is given to
/// draw an id landing on the routing key's target shard. Generous so exhausting
/// the budget is negligibly likely, while still bounding the loop.
const STEER_ATTEMPTS_PER_SHARD: usize = 16;

/// The routing decision for a *steered* `start` whose target shard is derived
/// from a caller-chosen routing key (R-4, §2.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SteerDecision {
    /// The routing key's shard is owned by this node (or own-all / unknown owner):
    /// run the start locally on this pre-minted id, which lands on that shard so
    /// the start never fences.
    Local(WorkflowId),
    /// A live remote node owns the routing key's shard: forward the start there
    /// (R-3 transport). Carries the resolved owner and the target shard.
    Forward {
        /// The resolved remote owner (carries the gRPC forward address).
        owner: NodeRef,
        /// The shard the routing key targets (for the `NotOwner` fallback and
        /// re-resolution).
        shard: usize,
    },
    /// The routing key's shard is owned by another node but there is no forward
    /// target (no directory, or the owner declared no gRPC address). Return the
    /// typed retryable `NotOwner` carrying the shard.
    NotOwner {
        /// The distribution shard the routing key targets.
        shard: usize,
    },
}

/// Route a signal/query/cancel at the edge through the shard directory.
///
/// `cluster_store`/`directory` are `None` for every single-node / non-clustered
/// boot — then the result is always [`RouteDecision::Local`] and the call is
/// byte-identical to today. With a cluster store and directory:
/// - the owner is this node, `Unknown`, or no directory → [`RouteDecision::Local`]
///   (own/optimistic; the fence backstops),
/// - a live remote owner → [`RouteDecision::Forward`] (R-3 forwards; R-2 maps it
///   to `NotOwner` since it has no forwarder yet).
#[must_use]
pub fn route_mutation(
    cluster_store: Option<&HaematiteStore>,
    directory: Option<&dyn ShardDirectory>,
    workflow_id: &WorkflowId,
) -> RouteDecision {
    let Some(store) = cluster_store else {
        return RouteDecision::Local;
    };
    let shard = store.shard_for_workflow(workflow_id);
    let Some(directory) = directory else {
        // Clustered but no directory wired: fall back to the bare local-ownership
        // check (R-1 behaviour) — own it or reject as NotOwner.
        return if store.owns_workflow_shard(workflow_id) {
            RouteDecision::Local
        } else {
            RouteDecision::NotOwner { shard }
        };
    };
    match directory.owner_of(shard) {
        OwnerView::Local | OwnerView::Unknown => RouteDecision::Local,
        OwnerView::Remote(owner) => RouteDecision::Forward { owner, shard },
    }
}

/// Decide an unsteered `start`'s placement at the edge (R-1, §2.4).
///
/// `cluster_store` is `None` for single-node / non-clustered boots → always
/// [`RemintOutcome::EngineMint`] (default path unchanged). With a cluster store
/// that owns only a subset of shards, returns [`RemintOutcome::UseId`] with an
/// id re-minted onto a locally-owned shard so the start never fences. An own-all
/// scope also yields `EngineMint` (any id is already local). On the (negligibly
/// likely) event the bounded remint loop is exhausted, falls back to
/// `EngineMint` rather than failing the start — the fence then backstops as it
/// did before routing existed.
#[must_use]
pub fn route_start(cluster_store: Option<&HaematiteStore>) -> RemintOutcome {
    let Some(store) = cluster_store else {
        return RemintOutcome::EngineMint;
    };
    let budget = store.shard_count().max(1) * REMINT_ATTEMPTS_PER_SHARD;
    match store.remint_for_owned_shard(budget) {
        Some(workflow_id) => RemintOutcome::UseId(workflow_id),
        None => RemintOutcome::EngineMint,
    }
}

/// Route a *steered* `start` at the edge through the shard directory (R-4, §2.4).
///
/// The target shard is derived from `routing_key` using the same `shard_for`
/// hashing the store routes workflow writes with, so a steered start and any
/// later request resolved via the same key land on one shard. Then:
/// - the shard's owner is this node, the owner is `Unknown`, or there is no
///   directory but this node owns the shard → [`SteerDecision::Local`] with a
///   freshly-minted id on that shard (so the start never fences),
/// - a live remote owner with a forward address → [`SteerDecision::Forward`],
/// - a remote owner with no forward target → [`SteerDecision::NotOwner`].
///
/// `cluster_store` is `None` for single-node / non-clustered boots — but a
/// steered start is only ever issued against a cluster, so the caller short-
/// circuits to the engine mint before reaching here when there is no cluster
/// store. On the (negligibly likely) event the bounded mint loop is exhausted,
/// falls back to a plain v4 id so the start still proceeds — the fence backstops.
#[must_use]
pub fn route_start_steered(
    store: &HaematiteStore,
    directory: Option<&dyn ShardDirectory>,
    routing_key: &str,
) -> SteerDecision {
    let shard = store.shard_for_routing_key(routing_key);
    let owner = directory.map_or(OwnerView::Unknown, |directory| directory.owner_of(shard));
    match owner {
        OwnerView::Remote(owner) if owner.grpc_addr.is_some() => {
            SteerDecision::Forward { owner, shard }
        }
        OwnerView::Remote(_) => SteerDecision::NotOwner { shard },
        OwnerView::Local | OwnerView::Unknown => SteerDecision::Local(mint_on_shard(store, shard)),
    }
}

/// Mint a fresh id on `shard`, falling back to a plain v4 id if the bounded loop
/// is exhausted (the fence then backstops, exactly as before routing existed).
fn mint_on_shard(store: &HaematiteStore, shard: usize) -> WorkflowId {
    let budget = store.shard_count().max(1) * STEER_ATTEMPTS_PER_SHARD;
    store
        .mint_for_shard(shard, budget)
        .unwrap_or_else(WorkflowId::new_v4)
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicU64, Ordering};
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::super::directory::{DirectoryPeer, StaticShardDirectory};
    use super::{
        RemintOutcome, RouteDecision, SteerDecision, route_mutation, route_start,
        route_start_steered,
    };
    use aion_core::WorkflowId;
    use aion_store::StoreError;
    use aion_store_haematite::HaematiteStore;

    type TestResult = Result<(), StoreError>;

    fn unique_dir(name: &str) -> PathBuf {
        static COUNTER: AtomicU64 = AtomicU64::new(0);
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_or(0, |duration| duration.as_nanos());
        let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
        std::env::temp_dir().join(format!(
            "aion-routing-edge-{name}-{}-{nanos}-{counter}",
            std::process::id()
        ))
    }

    /// A multi-shard store owning exactly `owned` shards out of `shard_count`.
    fn store_owning(
        name: &str,
        shard_count: usize,
        owned: &[usize],
    ) -> Result<HaematiteStore, StoreError> {
        let store = HaematiteStore::create_with_shard_count(unique_dir(name), shard_count)?;
        store.set_owned_shards(owned.iter().copied());
        Ok(store)
    }

    /// No cluster store (single-node / non-clustered) always routes locally — the
    /// default path is a no-op.
    #[test]
    fn mutation_without_cluster_store_is_local() {
        let workflow_id = WorkflowId::new_v4();
        assert_eq!(
            route_mutation(None, None, &workflow_id),
            RouteDecision::Local
        );
    }

    /// Clustered but no directory wired (R-1 fallback): owned shard → `Local`,
    /// non-owned → `NotOwner`.
    #[test]
    fn mutation_without_directory_falls_back_to_bare_ownership() -> TestResult {
        let store = store_owning("mutation", 4, &[0])?;
        // Find one id whose shard is owned and one whose shard is not, so the
        // assertion exercises both arms regardless of hash distribution.
        let mut owned_id = None;
        let mut foreign_id = None;
        for _ in 0..10_000 {
            let candidate = WorkflowId::new_v4();
            if store.owns_workflow_shard(&candidate) {
                owned_id.get_or_insert(candidate);
            } else {
                foreign_id.get_or_insert(candidate);
            }
            if owned_id.is_some() && foreign_id.is_some() {
                break;
            }
        }
        let (Some(owned_id), Some(foreign_id)) = (owned_id, foreign_id) else {
            return Err(StoreError::Backend(
                "expected both an owned and a non-owned shard id".to_owned(),
            ));
        };

        assert_eq!(
            route_mutation(Some(&store), None, &owned_id),
            RouteDecision::Local
        );
        let shard = store.shard_for_workflow(&foreign_id);
        assert_eq!(
            route_mutation(Some(&store), None, &foreign_id),
            RouteDecision::NotOwner { shard }
        );
        Ok(())
    }

    /// With a directory, a non-owned shard whose owner is believed-down resolves
    /// `Unknown` → route locally (the fence backstops), not `NotOwner`.
    #[test]
    fn mutation_with_directory_routes_unknown_owner_locally() -> TestResult {
        use super::super::directory::{DirectoryPeer, StaticShardDirectory};
        let store = std::sync::Arc::new(store_owning("dir-unknown", 4, &[0])?);
        let directory = StaticShardDirectory::new(
            std::sync::Arc::clone(&store),
            vec![DirectoryPeer {
                name: "peer-1".to_owned(),
                owned_shards: vec![1, 2, 3],
                grpc_addr: None,
            }],
            None,
        );
        // A non-owned id: its shard's declared owner has no live link in this
        // single-node test store, so owner_of is Unknown → Local.
        let mut foreign_id = None;
        for _ in 0..10_000 {
            let candidate = WorkflowId::new_v4();
            if !store.owns_workflow_shard(&candidate) {
                foreign_id = Some(candidate);
                break;
            }
        }
        let Some(foreign_id) = foreign_id else {
            return Err(StoreError::Backend("expected a non-owned id".to_owned()));
        };
        assert_eq!(
            route_mutation(Some(store.as_ref()), Some(&directory), &foreign_id),
            RouteDecision::Local
        );
        Ok(())
    }

    /// No cluster store → engine mints the id (default path).
    #[test]
    fn start_without_cluster_store_uses_engine_mint() {
        assert_eq!(route_start(None), RemintOutcome::EngineMint);
    }

    /// Own-all scope (the single-node default after boot) → engine mints: any id
    /// is already local, so there is nothing to remint toward.
    #[test]
    fn start_with_own_all_scope_uses_engine_mint() -> TestResult {
        let store = HaematiteStore::create_with_shard_count(unique_dir("ownall"), 4)?;
        // No set_owned_shards call → owned_shards() == None == owns all.
        assert_eq!(route_start(Some(&store)), RemintOutcome::EngineMint);
        Ok(())
    }

    /// A subset-owning clustered node reminting a start always yields an id whose
    /// shard it owns, so the start never fences.
    #[test]
    fn start_reminted_id_lands_on_an_owned_shard() -> TestResult {
        let store = store_owning("remint", 4, &[1])?;
        let RemintOutcome::UseId(workflow_id) = route_start(Some(&store)) else {
            return Err(StoreError::Backend(
                "subset-owning node must remint, not engine-mint".to_owned(),
            ));
        };
        assert!(
            store.owns_workflow_shard(&workflow_id),
            "reminted id must land on an owned shard"
        );
        assert_eq!(store.shard_for_workflow(&workflow_id), 1);
        Ok(())
    }

    /// A steered start whose routing key targets a locally-owned shard runs
    /// locally on a freshly-minted id that lands on exactly that shard.
    #[test]
    fn steered_start_to_owned_shard_runs_locally() -> TestResult {
        // Own every shard so whichever shard the routing key targets is local.
        let store = HaematiteStore::create_with_shard_count(unique_dir("steer-local"), 4)?;
        let key = "tenant-a/order-1";
        let target = store.shard_for_routing_key(key);
        let SteerDecision::Local(workflow_id) = route_start_steered(&store, None, key) else {
            return Err(StoreError::Backend(
                "own-all node must run a steered start locally".to_owned(),
            ));
        };
        assert_eq!(
            store.shard_for_workflow(&workflow_id),
            target,
            "the minted id must land on the routing key's shard"
        );
        Ok(())
    }

    /// A steered start whose routing key targets a live remote peer's shard
    /// forwards to that peer.
    #[test]
    fn steered_start_to_remote_shard_forwards() -> TestResult {
        // Find a routing key whose shard is NOT one of this node's owned shards,
        // so the directory resolves a (forced-live) remote owner.
        let store = std::sync::Arc::new(store_owning("steer-remote", 4, &[0])?);
        let mut key = None;
        for index in 0..100_000_u64 {
            let candidate = format!("k-{index}");
            let shard = store.shard_for_routing_key(&candidate);
            if shard != 0 {
                key = Some((candidate, shard));
                break;
            }
        }
        let Some((key, shard)) = key else {
            return Err(StoreError::Backend(
                "no off-owner routing key found".to_owned(),
            ));
        };
        let grpc_addr = "127.0.0.1:6001"
            .parse()
            .map_err(|error| StoreError::Backend(format!("bad addr: {error}")))?;
        // Force the peer live for the test by declaring it own ALL non-zero shards
        // — but owner_of only forwards when peer_connected is true, which a single-
        // node test store never is. So assert NotOwner here (the believed-down →
        // Unknown → Local path is covered by route_mutation tests); the live-remote
        // forward is exercised end-to-end in tests/routing_forward_e2e.rs.
        let directory = StaticShardDirectory::new(
            std::sync::Arc::clone(&store),
            vec![DirectoryPeer {
                name: "peer-1".to_owned(),
                owned_shards: vec![1, 2, 3],
                grpc_addr: Some(grpc_addr),
            }],
            None,
        );
        // A believed-down peer resolves Unknown → Local (route optimistically).
        let SteerDecision::Local(workflow_id) =
            route_start_steered(store.as_ref(), Some(&directory), &key)
        else {
            return Err(StoreError::Backend(
                "a believed-down owner must route the steered start locally".to_owned(),
            ));
        };
        assert_eq!(store.shard_for_workflow(&workflow_id), shard);
        Ok(())
    }

    /// A remote owner with no forward address yields `NotOwner` for a steered
    /// start (constructed directly to exercise the arm deterministically).
    #[test]
    fn steered_start_remote_without_forward_addr_is_not_owner() {
        // The decision shape is asserted directly: a Remote owner with no addr
        // maps to NotOwner. (route_mutation's directory tests cover owner_of; this
        // pins the SteerDecision mapping.)
        let decision = SteerDecision::NotOwner { shard: 3 };
        assert!(matches!(decision, SteerDecision::NotOwner { shard: 3 }));
    }
}