Skip to main content

aion/engine/
reload.rs

1//! `Engine` runtime package-load seam: live load, routing, listing, unload.
2//!
3//! Decision record (#62, adopted 2026-06-12): D1 always-latest-at-record-time
4//! with durable pinning, D2 manual unload with engine-enforced safety checks,
5//! D3 embedded-only API with serde-ready types (the server endpoint is a
6//! follow-up brief), D4 required `package_version` on start events.
7
8use aion_core::Event;
9use aion_package::ContentHash;
10
11use crate::error::PinHolder;
12use crate::loader::{LoadOutcome, WorkflowVersionInfo};
13use crate::{EngineError, WorkflowCatalog};
14
15use super::api::Engine;
16use super::builder::{WorkflowPackageSource, package_from_source};
17
18impl Engine {
19    /// Loads a validated package into the running engine and atomically
20    /// routes its workflow type's new dispatches to it.
21    ///
22    /// Every start that resolved before the route flip completes on the
23    /// version it resolved (loads never unregister anything); every start
24    /// after this call returns resolves the new version. Re-loading an
25    /// already-loaded hash is idempotent (nothing registers,
26    /// `freshly_loaded = false`) but still re-points the route at it —
27    /// re-deploying a previously rolled-back version must take effect
28    /// (`route_changed` reports whether it did).
29    ///
30    /// Verified modules and catalog entries are staged first, then the archive
31    /// and route are persisted, and only then are in-memory routes published.
32    /// Therefore no start can record a hash whose archive is not durable.
33    /// startup reloads every persisted package before recovery resolves any
34    /// run's recorded pinned version. Idempotent re-loads re-persist —
35    /// re-deploying is a routing intent and the durable pointer must mirror
36    /// it.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`EngineError::ShuttingDown`] once shutdown begins,
41    /// [`EngineError::Load`] for archive, collision, registration, or
42    /// entry-verification failures, and [`EngineError::ManifestMismatch`]
43    /// when an idempotent re-load presents the resident content hash with a
44    /// different manifest. On those failures live routing is untouched:
45    /// routing, loaded versions, and in-flight dispatches are unaffected.
46    /// Returns [`EngineError::Store`] when persistence fails; newly staged
47    /// modules and entries are rolled back before the error is returned.
48    pub async fn load_package(
49        &self,
50        source: impl Into<WorkflowPackageSource>,
51    ) -> Result<LoadOutcome, EngineError> {
52        // A load is new-work admission, not a wind-down operation: refuse
53        // after shutdown begins so modules never register into a dying VM.
54        let operation = self.shutdown_gate.begin_start()?;
55        let result = async {
56            // Catalog commit and persistence are one deploy mutation: an
57            // interleaved route/unload/deploy between them could persist
58            // state the catalog no longer holds.
59            let _deploy = self.deploy_mutations.lock().await;
60            let package = package_from_source(source.into())?;
61            let catalog = self.workflow_catalog();
62            let outcome = catalog.stage_package(self.runtime(), &package).await?;
63            let version = package.content_hash().clone();
64            if let Err(error) = crate::loader::persistence::persist_deployed_package(
65                self.store().as_ref(),
66                &package,
67            )
68            .await
69            {
70                if outcome.freshly_loaded {
71                    let mutation_guard = catalog.begin_mutation().await;
72                    let removed = catalog
73                        .swap_out_package(package.manifest().entry_module.as_str(), &version)?;
74                    self.unregister_unloaded_modules(
75                        package.manifest().entry_module.as_str(),
76                        &version,
77                        &removed,
78                    )?;
79                    drop(mutation_guard);
80                }
81                return Err(error);
82            }
83            catalog
84                .publish_package_routes(package.manifest().entry_module.as_str(), &version)
85                .await?;
86            Ok(outcome)
87        }
88        .await;
89        drop(operation);
90        result
91    }
92
93    /// Lists every loaded workflow version with its routing flag, sorted by
94    /// `(workflow_type, loaded_at)`.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`EngineError::CatalogPoisoned`] when the catalog lock is poisoned.
99    pub fn list_workflow_versions(&self) -> Result<Vec<WorkflowVersionInfo>, EngineError> {
100        self.workflow_catalog().versions()
101    }
102
103    /// Re-points routing for `workflow_type` at an already-loaded version
104    /// (rollback / roll-forward). Atomic and idempotent.
105    ///
106    /// The pointer is persisted so the re-point survives a restart; startup
107    /// restores persisted pointers after reloading persisted packages.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`EngineError::ShuttingDown`] once shutdown begins,
112    /// [`EngineError::UnknownVersion`] naming the loaded set when
113    /// `(type, version)` is not loaded — routing to a never-loaded hash is
114    /// impossible — and [`EngineError::Store`] when the durable pointer could
115    /// not be written. The in-memory route is published only after that write.
116    pub async fn route_workflow_version(
117        &self,
118        workflow_type: &str,
119        version: &ContentHash,
120    ) -> Result<(), EngineError> {
121        let operation = self.shutdown_gate.begin_operation()?;
122        let result = async {
123            // One deploy mutation: the catalog re-point and the durable
124            // pointer write must not interleave with another deploy
125            // mutation's persistence.
126            let _deploy = self.deploy_mutations.lock().await;
127            let catalog = self.workflow_catalog();
128            if catalog.get(workflow_type, version)?.is_none() {
129                let loaded_versions = catalog
130                    .versions()?
131                    .into_iter()
132                    .filter(|entry| entry.workflow_type == workflow_type)
133                    .map(|entry| entry.content_hash.to_string())
134                    .collect::<Vec<_>>();
135                let loaded = if loaded_versions.is_empty() {
136                    "none".to_owned()
137                } else {
138                    loaded_versions.join(", ")
139                };
140                return Err(EngineError::UnknownVersion {
141                    workflow_type: workflow_type.to_owned(),
142                    version: version.clone(),
143                    loaded,
144                });
145            }
146            self.store()
147                .put_package_route(workflow_type, &version.to_string())
148                .await?;
149            catalog.route_version(workflow_type, version).await?;
150            Ok(())
151        }
152        .await;
153        drop(operation);
154        result
155    }
156
157    /// Unloads a workflow version after verifying nothing pins it (D2).
158    ///
159    /// Refusal conditions, each typed and naming what pins the version:
160    /// route-inactive is required (the route-active version of a type can
161    /// never be unloaded), no in-flight start may pin it, no live registry
162    /// handle may run on it, and no recoverable instance in the store —
163    /// including a recorded-but-never-started child — may be pinned to it.
164    ///
165    /// The engine owns the mechanism; the embedding platform owns *when* to
166    /// unload. There is no automatic garbage collection.
167    ///
168    /// Unload deletes the persisted deploy artifact too (a no-op for
169    /// versions loaded from operator files, which were never persisted), so
170    /// an unloaded version does not resurrect at the next restart.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`EngineError::ShuttingDown`] once shutdown begins,
175    /// [`EngineError::UnknownVersion`] when `(type, version)` is not loaded,
176    /// [`EngineError::RouteActive`] when the version is route-active,
177    /// [`EngineError::VersionPinned`] naming the concrete pin holder (with
178    /// the catalog restored untouched), [`EngineError::Store`] when the
179    /// persisted artifact could not be deleted (the catalog is restored and
180    /// the unload did not happen), and [`EngineError::Runtime`] when module
181    /// unregistration fails after the catalog commit.
182    pub async fn unload_workflow_version(
183        &self,
184        workflow_type: &str,
185        version: &ContentHash,
186    ) -> Result<(), EngineError> {
187        let operation = self.shutdown_gate.begin_operation()?;
188        let result = self
189            .unload_workflow_version_inner(workflow_type, version)
190            .await;
191        drop(operation);
192        result
193    }
194
195    async fn unload_workflow_version_inner(
196        &self,
197        workflow_type: &str,
198        version: &ContentHash,
199    ) -> Result<(), EngineError> {
200        let catalog = self.workflow_catalog();
201        // Deploy lock first (the engine-wide ordering: deploy_mutations,
202        // then the catalog mutation lock), so the persisted-artifact delete
203        // below cannot interleave with a concurrent re-deploy's persistence.
204        let _deploy = self.deploy_mutations.lock().await;
205        let _mutation = catalog.begin_mutation().await;
206        // Swap the version out FIRST: from this instant no new resolution can
207        // produce it, so the checks below cannot be invalidated by a racing
208        // start (a start that already resolved holds a pin and is detected).
209        let removed = catalog.swap_out_package(workflow_type, version)?;
210        let member_types = removed
211            .workflow_types()
212            .map(str::to_owned)
213            .collect::<Vec<_>>();
214        if let Err(error) = self
215            .verify_unload_unpinned(catalog, &member_types, version)
216            .await
217        {
218            catalog.restore_package(removed)?;
219            return Err(error);
220        }
221        // Delete the persisted artifact BEFORE unregistering modules: if the
222        // delete fails the unload is rolled back wholesale, never leaving a
223        // version that is gone from this process yet resurrects at the next
224        // restart. Idempotent for never-persisted (operator-file) versions.
225        if let Err(error) = self
226            .store()
227            .delete_package(removed.primary_workflow_type(), &version.to_string())
228            .await
229        {
230            catalog.restore_package(removed)?;
231            return Err(error.into());
232        }
233        self.unregister_unloaded_modules(workflow_type, version, &removed)
234    }
235
236    /// Verifies no member type in an archive group is pinned to `version`.
237    async fn verify_unload_unpinned(
238        &self,
239        catalog: &WorkflowCatalog,
240        workflow_types: &[String],
241        version: &ContentHash,
242    ) -> Result<(), EngineError> {
243        for workflow_type in workflow_types {
244            self.verify_unload_member_unpinned(catalog, workflow_type, version)
245                .await?;
246        }
247        Ok(())
248    }
249
250    async fn verify_unload_member_unpinned(
251        &self,
252        catalog: &WorkflowCatalog,
253        workflow_type: &str,
254        version: &ContentHash,
255    ) -> Result<(), EngineError> {
256        if catalog.has_pinned_starts(workflow_type, version)? {
257            return Err(EngineError::VersionPinned {
258                workflow_type: workflow_type.to_owned(),
259                version: version.clone(),
260                pinned_by: PinHolder::InFlightStart,
261            });
262        }
263
264        for handle in self.registry().list()? {
265            if handle.workflow_type() == workflow_type
266                && handle.loaded_version() == version
267                && !handle.cached_status().is_terminal()
268            {
269                return Err(EngineError::VersionPinned {
270                    workflow_type: workflow_type.to_owned(),
271                    version: version.clone(),
272                    pinned_by: PinHolder::LiveRun {
273                        workflow_id: handle.workflow_id().clone(),
274                        run_id: handle.run_id().clone(),
275                    },
276                });
277            }
278        }
279
280        let recorded = crate::loader::package_version_of(version);
281        let store = self.store();
282        for workflow_id in store.list_active().await? {
283            let history = store.read_history(&workflow_id).await?;
284            let current_run_pin = history.iter().rev().find_map(|event| match event {
285                Event::WorkflowStarted {
286                    workflow_type: started_type,
287                    package_version,
288                    ..
289                } => Some(started_type == workflow_type && package_version == &recorded),
290                _ => None,
291            });
292            if current_run_pin == Some(true) {
293                return Err(EngineError::VersionPinned {
294                    workflow_type: workflow_type.to_owned(),
295                    version: version.clone(),
296                    pinned_by: PinHolder::RecoverableRun {
297                        workflow_id: workflow_id.clone(),
298                    },
299                });
300            }
301            if let Some(child_workflow_id) = self
302                .recorded_unstarted_child_pin(&history, workflow_type, &recorded)
303                .await?
304            {
305                return Err(EngineError::VersionPinned {
306                    workflow_type: workflow_type.to_owned(),
307                    version: version.clone(),
308                    pinned_by: PinHolder::RecordedChild {
309                        child_workflow_id,
310                        recorded_by: workflow_id.clone(),
311                    },
312                });
313            }
314        }
315        Ok(())
316    }
317
318    /// A recorded-but-never-started child pinned to the target version, if
319    /// any: its `ChildWorkflowStarted` carries the version and its own
320    /// history is still empty, so the crash-repair sweep would have to start
321    /// it on exactly this version.
322    async fn recorded_unstarted_child_pin(
323        &self,
324        parent_history: &[Event],
325        workflow_type: &str,
326        recorded: &aion_core::PackageVersion,
327    ) -> Result<Option<aion_core::WorkflowId>, EngineError> {
328        let store = self.store();
329        for event in parent_history {
330            let Event::ChildWorkflowStarted {
331                child_workflow_id,
332                workflow_type: child_type,
333                package_version,
334                ..
335            } = event
336            else {
337                continue;
338            };
339            if child_type != workflow_type || package_version != recorded {
340                continue;
341            }
342            if store.read_history(child_workflow_id).await?.is_empty() {
343                return Ok(Some(child_workflow_id.clone()));
344            }
345        }
346        Ok(None)
347    }
348
349    /// Unregisters the removed version's modules from the runtime, skipping
350    /// host NIF modules that were never BEAM-registered.
351    fn unregister_unloaded_modules(
352        &self,
353        workflow_type: &str,
354        version: &ContentHash,
355        removed: &crate::loader::catalog::RemovedPackage,
356    ) -> Result<(), EngineError> {
357        let nif_modules = self.runtime().registered_nif_modules();
358        let mut failures = Vec::new();
359        for deployed_name in removed.module_names() {
360            let original = deployed_name.split('$').next().unwrap_or(deployed_name);
361            if nif_modules.iter().any(|name| name == original) {
362                continue;
363            }
364            if let Err(error) = self.runtime().unregister_module(deployed_name) {
365                failures.push(format!("{deployed_name}: {error}"));
366            }
367        }
368        if failures.is_empty() {
369            Ok(())
370        } else {
371            // The catalog commit stands: the version is unloaded and its
372            // names are unreachable (content-hash unique), but the runtime
373            // retains orphaned module entries.
374            Err(EngineError::Runtime {
375                reason: format!(
376                    "workflow `{workflow_type}` version `{version}` was removed from the catalog but module unregistration failed for {}",
377                    failures.join(", ")
378                ),
379            })
380        }
381    }
382}