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}