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::{ContentHash, ManifestDigest, ManifestVersion, Package};
7
8use crate::error::EngineError;
9
10/// Outcome of one package load, computed inside the catalog mutation lock.
11///
12/// `freshly_loaded` distinguishes a real registration from an idempotent
13/// re-load of a resident hash; `route_changed` reports whether the call
14/// re-pointed routing (false means the hash was already route-active and the
15/// load was a full no-op). Both flags are race-free truth captured under the
16/// same lock that committed the mutation, never a list-before/list-after read.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct LoadOutcome {
19    /// The loaded (or already-resident) workflow record.
20    pub record: LoadedWorkflow,
21    /// True when this call registered the version; false on idempotent re-load.
22    pub freshly_loaded: bool,
23    /// True when this call re-pointed the type's route at the version.
24    pub route_changed: bool,
25}
26
27/// Workflow package entrypoint registered in the embedded runtime.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct LoadedWorkflow {
30    workflow_type: String,
31    deployed_entry_module: String,
32    entry_function: String,
33    version: ContentHash,
34    declared_timeout: Option<Duration>,
35}
36
37impl LoadedWorkflow {
38    /// Assembles a loaded-workflow record from already-validated parts.
39    ///
40    /// `declared_timeout` is the entry's explicitly authored workflow timeout,
41    /// or `None` when the package's content-hash identity does not commit to one
42    /// (a legacy or defaulted manifest). It is the sole input the start path
43    /// consults to decide whether to arm a deadline, so a non-declared entry can
44    /// never arm.
45    pub(crate) const fn from_parts(
46        workflow_type: String,
47        deployed_entry_module: String,
48        entry_function: String,
49        version: ContentHash,
50        declared_timeout: Option<Duration>,
51    ) -> Self {
52        Self {
53            workflow_type,
54            deployed_entry_module,
55            entry_function,
56            version,
57            declared_timeout,
58        }
59    }
60
61    /// Logical workflow type from the package manifest entry module.
62    #[must_use]
63    pub fn workflow_type(&self) -> &str {
64        &self.workflow_type
65    }
66
67    /// Namespaced module name to spawn for this package version.
68    #[must_use]
69    pub fn deployed_entry_module(&self) -> &str {
70        &self.deployed_entry_module
71    }
72
73    /// Exported function to spawn for this package version.
74    #[must_use]
75    pub fn entry_function(&self) -> &str {
76        &self.entry_function
77    }
78
79    /// Content-hash version identifying this package.
80    #[must_use]
81    pub fn version(&self) -> &ContentHash {
82        &self.version
83    }
84
85    /// The entry's explicitly authored workflow timeout, or `None`.
86    ///
87    /// `Some` only when the package identity commits to a declared timeout; the
88    /// start path arms a deadline exactly when this is `Some`, so a legacy or
89    /// defaulted manifest — which resolves to `None` here — arms nothing.
90    #[must_use]
91    pub fn declared_timeout(&self) -> Option<Duration> {
92        self.declared_timeout
93    }
94}
95
96/// One workflow entry staged from a package manifest.
97pub(crate) struct StagedWorkflow {
98    pub(crate) workflow_type: String,
99    pub(crate) deployed_entry_module: String,
100    pub(crate) entry_function: String,
101    pub(crate) declared_timeout: Option<Duration>,
102}
103
104/// One package validated and decomposed into deployable module units.
105pub(crate) struct StagedLoad<'a> {
106    pub(crate) workflows: Vec<StagedWorkflow>,
107    pub(crate) manifest_version: ManifestVersion,
108    pub(crate) manifest_digest: ManifestDigest,
109    pub(crate) version: ContentHash,
110    pub(crate) modules: Vec<StagedModule<'a>>,
111}
112
113impl<'a> StagedLoad<'a> {
114    pub(crate) fn new(package: &'a Package) -> Result<Self, EngineError> {
115        let manifest = package.manifest();
116        let version = package.content_hash().clone();
117        // Declaredness is a tamper-evident, authenticated PER-ENTRY property of
118        // the content-hash identity: the timeout-bearing identity binds every
119        // entry's timeout, so `declared_entry_timeout` returns an entry's authored
120        // value only when the identity commits to it. A legacy or defaulted
121        // manifest — or one whose additional entries were not bound — reads as
122        // wholly undeclared, so each entry's timeout is held non-arming (`None`)
123        // regardless of what value its `timeout` field happens to carry.
124        let mut seen = HashSet::new();
125        let mut workflows = Vec::with_capacity(1 + manifest.additional_workflows.len());
126        let entries = std::iter::once((
127            manifest.entry_module.as_str(),
128            manifest.entry_module.as_str(),
129            manifest.entry_function.as_str(),
130            manifest.timeout,
131        ))
132        .chain(manifest.additional_workflows.iter().map(|entry| {
133            (
134                entry.workflow_type.as_str(),
135                entry.entry_module.as_str(),
136                entry.entry_function.as_str(),
137                entry.timeout,
138            )
139        }));
140        for (workflow_type, entry_module, entry_function, entry_timeout) in entries {
141            if !seen.insert(workflow_type) {
142                return Err(load_error(format!(
143                    "package declares workflow type `{workflow_type}` more than once"
144                )));
145            }
146            if package.beams().get(entry_module).is_none() {
147                return Err(load_error(format!(
148                    "manifest entry module `{entry_module}` for workflow `{workflow_type}` is absent from package beams"
149                )));
150            }
151            workflows.push(StagedWorkflow {
152                workflow_type: workflow_type.to_owned(),
153                deployed_entry_module: aion_package::deployed_name(entry_module, &version),
154                entry_function: entry_function.to_owned(),
155                declared_timeout: package.declared_entry_timeout(entry_timeout),
156            });
157        }
158        let modules = package
159            .deployed_modules()
160            .into_iter()
161            .map(|(deployed_name, bytes)| StagedModule {
162                deployed_name,
163                bytes,
164            })
165            .collect();
166
167        Ok(Self {
168            workflows,
169            manifest_version: manifest.version.clone(),
170            manifest_digest: manifest.canonical_digest()?,
171            version,
172            modules,
173        })
174    }
175
176    /// Loaded-workflow records this package commits atomically.
177    pub(crate) fn records(&self) -> Vec<LoadedWorkflow> {
178        self.workflows
179            .iter()
180            .map(|entry| {
181                LoadedWorkflow::from_parts(
182                    entry.workflow_type.clone(),
183                    entry.deployed_entry_module.clone(),
184                    entry.entry_function.clone(),
185                    self.version.clone(),
186                    entry.declared_timeout,
187                )
188            })
189            .collect()
190    }
191}
192
193/// One deployable module of a staged package.
194pub(crate) struct StagedModule<'a> {
195    pub(crate) deployed_name: String,
196    pub(crate) bytes: &'a [u8],
197}
198
199pub(crate) fn load_error(reason: String) -> EngineError {
200    EngineError::Load { reason }
201}
202
203/// Best-effort rollback of modules registered before a failed load step.
204///
205/// Returns a human-readable suffix describing rollback failures, empty when
206/// every registration was unwound cleanly.
207pub(crate) fn rollback_registered<R>(rollback: &mut R, registered_now: &[String]) -> String
208where
209    R: FnMut(&str) -> Result<(), EngineError>,
210{
211    let mut errors = Vec::new();
212    for deployed_name in registered_now.iter().rev() {
213        if let Err(error) = rollback(deployed_name) {
214            errors.push(format!("{deployed_name}: {error}"));
215        }
216    }
217
218    if errors.is_empty() {
219        String::new()
220    } else {
221        format!("; rollback failed for {}", errors.join(", "))
222    }
223}