Skip to main content

aion_package/codegen/awl_worker/
plan.rs

1//! The CONNECTION PLAN: what a worker process owes a queue, per node.
2//!
3//! The server routes an activity by (namespace × `task_queue` × node) and by
4//! nothing else — never by activity type. Two consequences decide the whole
5//! shape of a generated worker, and getting either wrong costs a first flight:
6//!
7//! * A process serving actions pinned to several nodes must open ONE
8//!   CONNECTION PER NODE. Two connections on one node would let the server
9//!   land an activity on the connection that holds no handler for it.
10//! * Every connection owes the actions a dispatch could REACH it with, which
11//!   is exactly [`aion_package::compatibility`]'s admission rule: an action
12//!   pinned to a node reaches only that node's connection, while an UNPINNED
13//!   action reaches every worker in the pool and is therefore owed by all of
14//!   them.
15//!
16//! An action carrying a declarative `body` is executed by the SERVER itself,
17//! so no worker serves it and none may advertise it. The filter here is the
18//! same [`ActionContract::worker_owed`] the admission gate applies before
19//! deciding what a connection owes; a generator that filtered differently
20//! would emit a worker the gate refuses (advertising too much) or one that
21//! parks a dispatch forever (advertising too little).
22
23use crate::contract::{ActionContract, WorkerContract};
24
25use super::error::AwlScaffoldError;
26
27/// One connection the generated worker opens: the node it registers on and
28/// every action it serves there, in the document's declaration order.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Connection {
31    /// The node this connection advertises at registration, or `None` when
32    /// the queue pins no action to any node — a connection carrying no
33    /// locality is reachable by unpinned dispatches alone, which is then the
34    /// whole of the queue.
35    pub node: Option<String>,
36    /// The actions served on this connection, in declaration order.
37    pub actions: Vec<String>,
38}
39
40/// The whole plan for one queue: its connections and the servable actions
41/// behind them.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ConnectionPlan {
44    /// The queue (the document's `worker` block name) every connection joins.
45    pub task_queue: String,
46    /// One entry per node, in first-declaration order.
47    pub connections: Vec<Connection>,
48    /// Every action needing an out-of-band worker, in declaration order —
49    /// the union of the connections' action lists, and exactly the set of
50    /// handler stubs the author owes.
51    pub servable: Vec<ServableAction>,
52}
53
54/// One action the generated worker must serve: its name, its node pin, and
55/// the wire schemas the document declares for it.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct ServableAction {
58    /// Activity name, which is also the handler function's name.
59    pub name: String,
60    /// The node the document pins the action to, `None` when unpinned.
61    pub node: Option<String>,
62    /// The declared input schema, verbatim from the compiled contract.
63    pub input_schema: serde_json::Value,
64    /// The declared output schema, verbatim from the compiled contract.
65    pub output_schema: serde_json::Value,
66}
67
68/// Derives the connection plan for one compiled worker contract.
69///
70/// # Errors
71///
72/// Returns [`AwlScaffoldError::NoServableAction`] when every declared action
73/// carries a body (the server runs them all, so there is no worker to write),
74/// or [`AwlScaffoldError::ActionNameNotAnIdentifier`] /
75/// [`AwlScaffoldError::ActionNameUnnameable`] when an action name cannot name
76/// a Rust handler function.
77pub fn plan(contract: &WorkerContract) -> Result<ConnectionPlan, AwlScaffoldError> {
78    let servable = servable_actions(contract)?;
79    let plan = ConnectionPlan {
80        task_queue: contract.task_queue.clone(),
81        connections: connections(&servable),
82        servable,
83    };
84    Ok(plan)
85}
86
87/// The actions a worker must serve: every declared action WITHOUT a body, in
88/// declaration order, each validated as nameable by a Rust function.
89fn servable_actions(contract: &WorkerContract) -> Result<Vec<ServableAction>, AwlScaffoldError> {
90    let servable = contract
91        .actions
92        .iter()
93        .filter(|action| action.worker_owed())
94        .map(|action| servable_action(&contract.task_queue, action))
95        .collect::<Result<Vec<_>, _>>()?;
96    if servable.is_empty() {
97        return Err(AwlScaffoldError::NoServableAction {
98            task_queue: contract.task_queue.clone(),
99        });
100    }
101    Ok(servable)
102}
103
104/// Validates one bodyless action and takes its declared surface.
105fn servable_action(
106    task_queue: &str,
107    action: &ActionContract,
108) -> Result<ServableAction, AwlScaffoldError> {
109    validate_action_name(task_queue, &action.name)?;
110    Ok(ServableAction {
111        name: action.name.clone(),
112        node: action.node.clone(),
113        input_schema: action.input_schema.clone(),
114        output_schema: action.output_schema.clone(),
115    })
116}
117
118/// Refuses an action name that cannot name a Rust handler function.
119///
120/// AWL action names are `snake_case` identifiers, so this only ever fires on a
121/// contract built by some other route — but the generated crate must COMPILE,
122/// and a name that cannot be a function name would emit a crate that does not.
123/// `self`, `crate`, and `super` are singled out because they are the three
124/// keywords with no raw-identifier form.
125fn validate_action_name(task_queue: &str, name: &str) -> Result<(), AwlScaffoldError> {
126    if matches!(name, "self" | "crate" | "super" | "Self") {
127        return Err(AwlScaffoldError::ActionNameUnnameable {
128            task_queue: task_queue.to_owned(),
129            action: name.to_owned(),
130        });
131    }
132    let mut characters = name.chars();
133    let starts = characters
134        .next()
135        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_');
136    let continues = characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_');
137    if starts && continues {
138        return Ok(());
139    }
140    Err(AwlScaffoldError::ActionNameNotAnIdentifier {
141        task_queue: task_queue.to_owned(),
142        action: name.to_owned(),
143    })
144}
145
146/// Folds the servable actions into one connection per NODE, in first-
147/// declaration order, each carrying the actions a dispatch can reach it with.
148///
149/// A queue pinning nothing yields ONE connection carrying no locality: it is
150/// reachable by every unpinned dispatch, which is then the whole queue. A
151/// queue with pins yields no such extra connection — every node connection
152/// already carries the unpinned actions, so a node-less one would add a
153/// registration nothing needs.
154fn connections(servable: &[ServableAction]) -> Vec<Connection> {
155    let mut nodes: Vec<&str> = Vec::new();
156    for action in servable {
157        if let Some(node) = action.node.as_deref()
158            && !nodes.contains(&node)
159        {
160            nodes.push(node);
161        }
162    }
163    if nodes.is_empty() {
164        return vec![Connection {
165            node: None,
166            actions: servable.iter().map(|action| action.name.clone()).collect(),
167        }];
168    }
169    nodes
170        .into_iter()
171        .map(|node| Connection {
172            node: Some(node.to_owned()),
173            actions: servable
174                .iter()
175                .filter(|action| dispatch_can_reach(action.node.as_deref(), Some(node)))
176                .map(|action| action.name.clone())
177                .collect(),
178        })
179        .collect()
180}
181
182/// Whether a dispatch for an action pinned to `action_node` can REACH a
183/// connection advertising `worker_node`.
184///
185/// This mirrors the admission gate's own rule
186/// ([`crate::compatibility`]'s `dispatch_can_reach`) deliberately: the set of
187/// actions a connection is GENERATED to serve must be exactly the set the
188/// server DEMANDS of it. Serving fewer is a refused dial
189/// (`WORKER_CONTRACT_MISMATCH`); serving more is a handler no dispatch can
190/// ever reach.
191fn dispatch_can_reach(action_node: Option<&str>, worker_node: Option<&str>) -> bool {
192    match action_node {
193        None => true,
194        Some(pin) => worker_node == Some(pin),
195    }
196}