aion/engine/admission.rs
1//! Which deployed package versions a registering worker must satisfy.
2//!
3//! # The contradiction this resolves
4//!
5//! Content-hash module namespacing (load-bearing invariant 5) exists so that
6//! long-lived workflows can coexist with new deploys: every `.aion` package
7//! version is a distinct immutable module, and the catalog retains them side by
8//! side. Worker admission used to take that retained SET and demand that ONE
9//! connection be contract-compatible with ALL of it simultaneously. But a
10//! worker advertises exactly one shape per action, so the moment any historical
11//! version on the queue declared a different shape for an action, no worker
12//! could ever satisfy the whole set and the queue was permanently unservable.
13//! Coexistence by design became mutual exclusion at the worker boundary.
14//!
15//! It is not hypothetical: on 2026-07-30 a stale, non-routed package whose
16//! `verify_integration` action lacked a `base_branch` field made the
17//! `staged_rounds` queue unservable. Every worker connection was refused with
18//! `WORKER_CONTRACT_MISMATCH` on every dial, and the only way out was manually
19//! unloading the stale version.
20//!
21//! # The rule
22//!
23//! A retained version binds a registering worker when a dispatch could actually
24//! be produced under it — that is, when it is REACHABLE:
25//!
26//! - it is route-active, so a caller can start a workflow on it at any moment
27//! ([`AdmissionReason::RouteActive`]);
28//! - a non-terminal run is registered on it ([`AdmissionReason::LiveWorkflow`]);
29//! - an in-flight start has resolved it and not yet registered its run
30//! ([`AdmissionReason::StartInFlight`]).
31//!
32//! A version that is none of these cannot be entered by anything: new starts
33//! resolve the routed version, and no existing run is pinned to it. Demanding a
34//! worker satisfy it protects nothing and costs the whole queue.
35//!
36//! # What this read is and is not
37//!
38//! It is a synchronous, in-memory read. That is a hard requirement, not a
39//! convenience: admission runs on the liminal connection process's frame-apply
40//! callback (a beamr scheduler thread), where a durable scan would block the
41//! scheduler that other connections' dispatches and completions are applied on.
42//!
43//! It is therefore a PRE-FLIGHT check over what could dispatch right now, never
44//! a standing guarantee — and it never was one. A package deployed AFTER a
45//! worker registered is never checked against that already-registered worker at
46//! all, so "every registered worker satisfies every retained contract" has
47//! never held. The gate's job is to catch an operator's mismatch at the moment
48//! of connection, with a message precise enough to fix it.
49//!
50//! The one live run this read cannot see is a durably-`Paused` run that this
51//! process has not resurrected — startup recovery deliberately does not respawn
52//! a paused run, so it holds no registry handle until an operator resumes it.
53//! Such a run's version is not demanded of a worker registering before the
54//! resume. Closing that would require the engine to carry the paused runs'
55//! pinned versions in memory across the `list_paused` rebuild.
56
57use std::collections::HashSet;
58
59use aion_package::ContentHash;
60
61use crate::EngineError;
62use crate::loader::DeployedWorkerContract;
63
64use super::api::Engine;
65
66/// Why one retained contract binds a registering worker.
67///
68/// The three variants are exactly the three conditions on which
69/// [`Engine::unload_workflow_version`] refuses a version, so a version admission
70/// demands is always a version unload will not remove.
71#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
72pub enum AdmissionReason {
73 /// The version routes new starts, so a workflow can begin on it at any
74 /// moment — including between this check and the worker's first dispatch.
75 RouteActive,
76 /// At least one registered run with a non-terminal projected status is
77 /// pinned to the version.
78 LiveWorkflow,
79 /// A start has resolved the version and has not yet registered its run.
80 StartInFlight,
81}
82
83impl AdmissionReason {
84 /// Operator-facing clause naming why the version was demanded.
85 #[must_use]
86 pub const fn explanation(self) -> &'static str {
87 match self {
88 Self::RouteActive => "it currently routes new starts",
89 Self::LiveWorkflow => "a live workflow run is pinned to it",
90 Self::StartInFlight => "a workflow start is in flight on it",
91 }
92 }
93}
94
95/// One retained contract a registering worker must satisfy, with the reason.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct RequiredContract {
98 /// The retained queue contract under its exact package identity.
99 pub contract: DeployedWorkerContract,
100 /// Why this version is still reachable.
101 pub reason: AdmissionReason,
102}
103
104/// One retained contract that binds nobody because nothing can reach it.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct UnreachableContract {
107 /// Exact package identity that was skipped.
108 pub package_version: ContentHash,
109 /// Workflow types this version implements, sorted.
110 pub workflow_types: Vec<String>,
111}
112
113/// The admission decision inputs for one task queue.
114#[derive(Clone, Debug, Default, PartialEq, Eq)]
115pub struct QueueAdmission {
116 /// Contracts a registering worker must satisfy, in stable identity order.
117 pub required: Vec<RequiredContract>,
118 /// Retained contracts nothing can reach, in stable identity order. Reported
119 /// rather than dropped silently: an operator reading a refusal needs to
120 /// know which retained versions were NOT held against the worker.
121 pub unreachable: Vec<UnreachableContract>,
122}
123
124impl QueueAdmission {
125 /// How many required contracts carry each reason, for refusal summaries.
126 #[must_use]
127 pub fn reason_census(&self) -> ReasonCensus {
128 let mut census = ReasonCensus::default();
129 for required in &self.required {
130 match required.reason {
131 AdmissionReason::RouteActive => census.route_active += 1,
132 AdmissionReason::LiveWorkflow => census.live_workflow += 1,
133 AdmissionReason::StartInFlight => census.start_in_flight += 1,
134 }
135 }
136 census
137 }
138}
139
140/// Counts of required contracts by reason.
141#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
142pub struct ReasonCensus {
143 /// Versions demanded because they route new starts.
144 pub route_active: usize,
145 /// Versions demanded because a live run is pinned to them.
146 pub live_workflow: usize,
147 /// Versions demanded because a start is in flight on them.
148 pub start_in_flight: usize,
149}
150
151impl Engine {
152 /// Splits `task_queue`'s retained contracts into the set a registering
153 /// worker must satisfy and the set nothing can reach.
154 ///
155 /// See the [module documentation](self) for the rule and its limits.
156 ///
157 /// # Errors
158 ///
159 /// Returns [`EngineError::CatalogPoisoned`] when the catalog snapshot or
160 /// start-pin lock is poisoned, and [`EngineError::RegistryPoisoned`] when
161 /// the active-execution registry lock is poisoned. Admission never guesses
162 /// on a poisoned lock: an unreadable liveness answer refuses the worker
163 /// rather than admitting one whose obligations are unknown.
164 pub fn worker_contracts_for_admission(
165 &self,
166 task_queue: &str,
167 ) -> Result<QueueAdmission, EngineError> {
168 let catalog = self.workflow_catalog();
169 let contracts = catalog.worker_contracts_for_queue(task_queue)?;
170 if contracts.is_empty() {
171 return Ok(QueueAdmission::default());
172 }
173
174 // Versions carrying a non-terminal registered run. A content hash is
175 // matched on its own rather than paired with a workflow type: a hash
176 // shared by an archive group's members is the SAME immutable package,
177 // and matching wider can only ever demand more of a worker.
178 let mut live = HashSet::new();
179 for handle in self.registry().list()? {
180 if !handle.cached_status().is_terminal() {
181 live.insert(handle.loaded_version().clone());
182 }
183 }
184 let starting = catalog
185 .pinned_start_versions()?
186 .into_iter()
187 .map(|(_, version)| version)
188 .collect::<HashSet<_>>();
189
190 let mut admission = QueueAdmission::default();
191 for contract in contracts {
192 // Route-active is checked first: it is the only reason that holds
193 // for a version with no run at all, and it is the reason an
194 // operator can act on directly.
195 let reason = if contract.route_active {
196 Some(AdmissionReason::RouteActive)
197 } else if live.contains(&contract.package_version) {
198 Some(AdmissionReason::LiveWorkflow)
199 } else if starting.contains(&contract.package_version) {
200 Some(AdmissionReason::StartInFlight)
201 } else {
202 None
203 };
204 match reason {
205 Some(reason) => admission
206 .required
207 .push(RequiredContract { contract, reason }),
208 None => admission.unreachable.push(UnreachableContract {
209 package_version: contract.package_version,
210 workflow_types: contract.workflow_types,
211 }),
212 }
213 }
214 Ok(admission)
215 }
216}
217
218#[cfg(test)]
219#[path = "admission_tests.rs"]
220mod admission_tests;