Skip to main content

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;