aion/loader/load.rs
1//! Package staging: validated load units shared by the workflow catalog.
2
3use std::collections::HashSet;
4use std::time::Duration;
5
6use aion_package::{
7 ContentHash, ContractIdentityError, ManifestDigest, ManifestVersion, Package, PackageContract,
8 WorkerContract,
9};
10
11use crate::error::EngineError;
12
13/// Outcome of one package load, computed inside the catalog mutation lock.
14///
15/// `freshly_loaded` distinguishes a real registration from an idempotent
16/// re-load of a resident hash; `route_changed` reports whether the call
17/// re-pointed routing (false means the hash was already route-active and the
18/// load was a full no-op). Both flags are race-free truth captured under the
19/// same lock that committed the mutation, never a list-before/list-after read.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct LoadOutcome {
22 /// The loaded (or already-resident) workflow record.
23 pub record: LoadedWorkflow,
24 /// True when this call registered the version; false on idempotent re-load.
25 pub freshly_loaded: bool,
26 /// True when this call re-pointed the type's route at the version.
27 pub route_changed: bool,
28 /// Every OTHER version of this workflow type still resident after the
29 /// load, sorted, with the newly routed one excluded.
30 ///
31 /// Deploy has never removed a superseded version and is not going to
32 /// start: a version may still be carrying live runs, and the decision to
33 /// unload one is the platform's, not the engine's (decision record #62).
34 /// What deploy CAN do is stop the accumulation being invisible. A stale
35 /// retained version is still a reachable contract — one of them made a
36 /// whole task queue unservable — so the operator is handed the exact
37 /// hashes `unload_workflow_version` will accept.
38 pub superseded_versions: Vec<ContentHash>,
39}
40
41/// One queue contract retained under an exact `.v4` package identity.
42///
43/// Content-hash namespacing means several versions of the same logical package
44/// are retained side by side, so a queue's retained contracts are a SET, not a
45/// single current shape. Whether any given one still binds a registering worker
46/// depends on reachability, which is why the record carries the routing and
47/// membership facts the admission gate decides on rather than the contract
48/// alone.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct DeployedWorkerContract {
51 /// Exact package identity requiring the queue surface.
52 pub package_version: ContentHash,
53 /// Queue-scoped action declarations from the durable contract record.
54 pub contract: WorkerContract,
55 /// Every workflow type this exact package version implements, sorted. A
56 /// package archive can carry several entry modules, and each is a distinct
57 /// catalog entry under the SAME content hash.
58 pub workflow_types: Vec<String>,
59 /// Whether any of those workflow types currently routes new starts at this
60 /// version. A route-active version can be started at any moment, so it
61 /// always binds a registering worker.
62 pub route_active: bool,
63}
64
65/// Workflow package entrypoint registered in the embedded runtime.
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct LoadedWorkflow {
68 workflow_type: String,
69 deployed_entry_module: String,
70 entry_function: String,
71 version: ContentHash,
72 declared_timeout: Option<Duration>,
73 contract: Option<PackageContract>,
74}
75
76impl LoadedWorkflow {
77 /// Assembles a loaded-workflow record from already-validated parts.
78 ///
79 /// `declared_timeout` is the entry's explicitly authored workflow timeout,
80 /// or `None` when the package's content-hash identity does not commit to one
81 /// (a legacy or defaulted manifest). It is the sole input the start path
82 /// consults to decide whether to arm a deadline, so a non-declared entry can
83 /// never arm.
84 pub(crate) const fn from_parts(
85 workflow_type: String,
86 deployed_entry_module: String,
87 entry_function: String,
88 version: ContentHash,
89 declared_timeout: Option<Duration>,
90 contract: Option<PackageContract>,
91 ) -> Self {
92 Self {
93 workflow_type,
94 deployed_entry_module,
95 entry_function,
96 version,
97 declared_timeout,
98 contract,
99 }
100 }
101
102 /// Logical workflow type from the package manifest entry module.
103 #[must_use]
104 pub fn workflow_type(&self) -> &str {
105 &self.workflow_type
106 }
107
108 /// Namespaced module name to spawn for this package version.
109 #[must_use]
110 pub fn deployed_entry_module(&self) -> &str {
111 &self.deployed_entry_module
112 }
113
114 /// Exported function to spawn for this package version.
115 #[must_use]
116 pub fn entry_function(&self) -> &str {
117 &self.entry_function
118 }
119
120 /// Content-hash version identifying this package.
121 #[must_use]
122 pub fn version(&self) -> &ContentHash {
123 &self.version
124 }
125
126 /// The entry's explicitly authored workflow timeout, or `None`.
127 ///
128 /// `Some` only when the package identity commits to a declared timeout; the
129 /// start path arms a deadline exactly when this is `Some`, so a legacy or
130 /// defaulted manifest — which resolves to `None` here — arms nothing.
131 #[must_use]
132 pub fn declared_timeout(&self) -> Option<Duration> {
133 self.declared_timeout
134 }
135
136 /// Returns the durable contract only when this exact identity is `.v4`.
137 ///
138 /// # Errors
139 ///
140 /// Returns [`ContractIdentityError::RedeployRequired`] for a pre-`.v4`
141 /// package, naming the exact stored identity that must be re-deployed.
142 pub fn contract(&self) -> Result<&PackageContract, ContractIdentityError> {
143 self.contract
144 .as_ref()
145 .ok_or_else(|| ContractIdentityError::RedeployRequired {
146 stored_version: self.version.to_string(),
147 })
148 }
149}
150
151/// One workflow entry staged from a package manifest.
152pub(crate) struct StagedWorkflow {
153 pub(crate) workflow_type: String,
154 pub(crate) deployed_entry_module: String,
155 pub(crate) entry_function: String,
156 pub(crate) declared_timeout: Option<Duration>,
157}
158
159/// One package validated and decomposed into deployable module units.
160pub(crate) struct StagedLoad<'a> {
161 pub(crate) workflows: Vec<StagedWorkflow>,
162 pub(crate) manifest_version: ManifestVersion,
163 pub(crate) manifest_digest: ManifestDigest,
164 pub(crate) version: ContentHash,
165 pub(crate) modules: Vec<StagedModule<'a>>,
166 pub(crate) contract: Option<PackageContract>,
167}
168
169impl<'a> StagedLoad<'a> {
170 pub(crate) fn new(package: &'a Package) -> Result<Self, EngineError> {
171 let manifest = package.manifest();
172 let version = package.content_hash().clone();
173 let contract = package.contract().ok().cloned();
174 // Declaredness is a tamper-evident, authenticated PER-ENTRY property of
175 // the content-hash identity: the timeout-bearing identity binds every
176 // entry's timeout, so `declared_entry_timeout` returns an entry's authored
177 // value only when the identity commits to it. A legacy or defaulted
178 // manifest — or one whose additional entries were not bound — reads as
179 // wholly undeclared, so each entry's timeout is held non-arming (`None`)
180 // regardless of what value its `timeout` field happens to carry.
181 let mut seen = HashSet::new();
182 let mut workflows = Vec::with_capacity(1 + manifest.additional_workflows.len());
183 let entries = std::iter::once((
184 manifest.entry_module.as_str(),
185 manifest.entry_module.as_str(),
186 manifest.entry_function.as_str(),
187 manifest.timeout,
188 ))
189 .chain(manifest.additional_workflows.iter().map(|entry| {
190 (
191 entry.workflow_type.as_str(),
192 entry.entry_module.as_str(),
193 entry.entry_function.as_str(),
194 entry.timeout,
195 )
196 }));
197 for (workflow_type, entry_module, entry_function, entry_timeout) in entries {
198 if !seen.insert(workflow_type) {
199 return Err(load_error(format!(
200 "package declares workflow type `{workflow_type}` more than once"
201 )));
202 }
203 if package.beams().get(entry_module).is_none() {
204 return Err(load_error(format!(
205 "manifest entry module `{entry_module}` for workflow `{workflow_type}` is absent from package beams"
206 )));
207 }
208 workflows.push(StagedWorkflow {
209 workflow_type: workflow_type.to_owned(),
210 deployed_entry_module: aion_package::deployed_name(entry_module, &version),
211 entry_function: entry_function.to_owned(),
212 declared_timeout: package.declared_entry_timeout(entry_timeout),
213 });
214 }
215 let modules = package
216 .deployed_modules()
217 .into_iter()
218 .map(|(deployed_name, bytes)| StagedModule {
219 deployed_name,
220 bytes,
221 })
222 .collect();
223
224 Ok(Self {
225 workflows,
226 manifest_version: manifest.version.clone(),
227 manifest_digest: manifest.canonical_digest()?,
228 version,
229 modules,
230 contract,
231 })
232 }
233
234 /// Loaded-workflow records this package commits atomically.
235 pub(crate) fn records(&self) -> Vec<LoadedWorkflow> {
236 self.workflows
237 .iter()
238 .map(|entry| {
239 LoadedWorkflow::from_parts(
240 entry.workflow_type.clone(),
241 entry.deployed_entry_module.clone(),
242 entry.entry_function.clone(),
243 self.version.clone(),
244 entry.declared_timeout,
245 self.contract.clone(),
246 )
247 })
248 .collect()
249 }
250}
251
252/// What the catalog does with a package whose declared contract it cannot
253/// enforce.
254///
255/// The distinction is between a package being OFFERED and a package being
256/// RECOVERED, and it is the whole reason this is a parameter rather than a
257/// constant: an operator handing over an archive can fix it and must be told
258/// now, while a run that has been parked for months cannot fix anything and
259/// must not be stranded for a defect in a declaration it never reads.
260#[derive(Clone, Copy, Debug, PartialEq, Eq)]
261pub(crate) enum ContractEnforcement {
262 /// Refuse the load. Every door an operator offers a package through:
263 /// the deploy seam, and the startup sources a builder is handed.
264 Refuse,
265 /// Load it, and say so at `error` level. The recovery path only.
266 ReportOnly,
267}
268
269/// Refuses (or reports) a staged package that declares schemas no validator
270/// can compile.
271///
272/// An uncompilable declared schema does not fail loudly at the boundary it
273/// guards: `admit_value` can only answer `UnusableSchema`, and every admission
274/// site's answer to that is to let the value through unchecked. So a package
275/// carrying one runs with that part of its declared contract switched off, and
276/// the only trace is a log line at a moment nobody is watching. The door is
277/// the last place it can still be a diagnostic.
278///
279/// A pre-`.v4` identity commits to no contract at all, so there is nothing to
280/// enforce and nothing to report.
281pub(crate) fn enforce_contract(
282 staged: &StagedLoad<'_>,
283 workflow_type: &str,
284 enforcement: ContractEnforcement,
285) -> Result<(), EngineError> {
286 let Some(contract) = staged.contract.as_ref() else {
287 return Ok(());
288 };
289 let unenforceable = contract.unenforceable_schemas();
290 if unenforceable.is_empty() {
291 return Ok(());
292 }
293 let detail = unenforceable
294 .iter()
295 .map(ToString::to_string)
296 .collect::<Vec<_>>()
297 .join("; ");
298 match enforcement {
299 ContractEnforcement::Refuse => Err(EngineError::UnenforceableContract {
300 workflow_type: workflow_type.to_owned(),
301 count: unenforceable.len(),
302 detail,
303 }),
304 ContractEnforcement::ReportOnly => {
305 tracing::error!(
306 workflow_type,
307 version = %staged.version,
308 count = unenforceable.len(),
309 %detail,
310 "recovering a persisted package whose declared contract cannot be enforced: these declarations compile into no validator, so every value they cover is admitted unchecked until the package is re-deployed"
311 );
312 Ok(())
313 }
314 }
315}
316
317/// One deployable module of a staged package.
318pub(crate) struct StagedModule<'a> {
319 pub(crate) deployed_name: String,
320 pub(crate) bytes: &'a [u8],
321}
322
323pub(crate) fn load_error(reason: String) -> EngineError {
324 EngineError::Load { reason }
325}
326
327/// Best-effort rollback of modules registered before a failed load step.
328///
329/// Returns a human-readable suffix describing rollback failures, empty when
330/// every registration was unwound cleanly.
331pub(crate) fn rollback_registered<R>(rollback: &mut R, registered_now: &[String]) -> String
332where
333 R: FnMut(&str) -> Result<(), EngineError>,
334{
335 let mut errors = Vec::new();
336 for deployed_name in registered_now.iter().rev() {
337 if let Err(error) = rollback(deployed_name) {
338 errors.push(format!("{deployed_name}: {error}"));
339 }
340 }
341
342 if errors.is_empty() {
343 String::new()
344 } else {
345 format!("; rollback failed for {}", errors.join(", "))
346 }
347}