Skip to main content

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}