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    /// A successful load is persisted to the store (archive bytes plus,
31    /// atomically, the route pointer) so the deploy survives a restart:
32    /// startup reloads every persisted package before recovery resolves any
33    /// run's recorded pinned version. Idempotent re-loads re-persist —
34    /// re-deploying is a routing intent and the durable pointer must mirror
35    /// it.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`EngineError::ShuttingDown`] once shutdown begins,
40    /// [`EngineError::Load`] for archive, collision, registration, or
41    /// entry-verification failures, and [`EngineError::ManifestMismatch`]
42    /// when an idempotent re-load presents the resident content hash with a
43    /// different manifest. On those failures the catalog is untouched:
44    /// routing, loaded versions, and in-flight dispatches are unaffected.
45    /// Returns [`EngineError::Store`] when the load committed but
46    /// persistence failed — the version is live in THIS process yet would
47    /// not survive a restart, so the deploy must not be reported applied;
48    /// re-sending the same archive is idempotent and retries persistence.
49    pub async fn load_package(
50        &self,
51        source: impl Into<WorkflowPackageSource>,
52    ) -> Result<LoadOutcome, EngineError> {
53        // A load is new-work admission, not a wind-down operation: refuse
54        // after shutdown begins so modules never register into a dying VM.
55        let operation = self.shutdown_gate.begin_start()?;
56        let result = async {
57            // Catalog commit and persistence are one deploy mutation: an
58            // interleaved route/unload/deploy between them could persist
59            // state the catalog no longer holds.
60            let _deploy = self.deploy_mutations.lock().await;
61            let package = package_from_source(source.into())?;
62            let outcome = self
63                .workflow_catalog()
64                .load_package(self.runtime(), &package)
65                .await?;
66            crate::loader::persistence::persist_deployed_package(self.store().as_ref(), &package)
67                .await?;
68            Ok(outcome)
69        }
70        .await;
71        drop(operation);
72        result
73    }
74
75    /// Lists every loaded workflow version with its routing flag, sorted by
76    /// `(workflow_type, loaded_at)`.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`EngineError::CatalogPoisoned`] when the catalog lock is poisoned.
81    pub fn list_workflow_versions(&self) -> Result<Vec<WorkflowVersionInfo>, EngineError> {
82        self.workflow_catalog().versions()
83    }
84
85    /// Re-points routing for `workflow_type` at an already-loaded version
86    /// (rollback / roll-forward). Atomic and idempotent.
87    ///
88    /// The pointer is persisted so the re-point survives a restart; startup
89    /// restores persisted pointers after reloading persisted packages.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`EngineError::ShuttingDown`] once shutdown begins,
94    /// [`EngineError::UnknownVersion`] naming the loaded set when
95    /// `(type, version)` is not loaded — routing to a never-loaded hash is
96    /// impossible — and [`EngineError::Store`] when the in-memory re-point
97    /// committed but the durable pointer could not be written (retrying the
98    /// same re-point is idempotent and re-attempts persistence).
99    pub async fn route_workflow_version(
100        &self,
101        workflow_type: &str,
102        version: &ContentHash,
103    ) -> Result<(), EngineError> {
104        let operation = self.shutdown_gate.begin_operation()?;
105        let result = async {
106            // One deploy mutation: the catalog re-point and the durable
107            // pointer write must not interleave with another deploy
108            // mutation's persistence.
109            let _deploy = self.deploy_mutations.lock().await;
110            self.workflow_catalog()
111                .route_version(workflow_type, version)
112                .await?;
113            self.store()
114                .put_package_route(workflow_type, &version.to_string())
115                .await?;
116            Ok(())
117        }
118        .await;
119        drop(operation);
120        result
121    }
122
123    /// Unloads a workflow version after verifying nothing pins it (D2).
124    ///
125    /// Refusal conditions, each typed and naming what pins the version:
126    /// route-inactive is required (the route-active version of a type can
127    /// never be unloaded), no in-flight start may pin it, no live registry
128    /// handle may run on it, and no recoverable instance in the store —
129    /// including a recorded-but-never-started child — may be pinned to it.
130    ///
131    /// The engine owns the mechanism; the embedding platform owns *when* to
132    /// unload. There is no automatic garbage collection.
133    ///
134    /// Unload deletes the persisted deploy artifact too (a no-op for
135    /// versions loaded from operator files, which were never persisted), so
136    /// an unloaded version does not resurrect at the next restart.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`EngineError::ShuttingDown`] once shutdown begins,
141    /// [`EngineError::UnknownVersion`] when `(type, version)` is not loaded,
142    /// [`EngineError::RouteActive`] when the version is route-active,
143    /// [`EngineError::VersionPinned`] naming the concrete pin holder (with
144    /// the catalog restored untouched), [`EngineError::Store`] when the
145    /// persisted artifact could not be deleted (the catalog is restored and
146    /// the unload did not happen), and [`EngineError::Runtime`] when module
147    /// unregistration fails after the catalog commit.
148    pub async fn unload_workflow_version(
149        &self,
150        workflow_type: &str,
151        version: &ContentHash,
152    ) -> Result<(), EngineError> {
153        let operation = self.shutdown_gate.begin_operation()?;
154        let result = self
155            .unload_workflow_version_inner(workflow_type, version)
156            .await;
157        drop(operation);
158        result
159    }
160
161    async fn unload_workflow_version_inner(
162        &self,
163        workflow_type: &str,
164        version: &ContentHash,
165    ) -> Result<(), EngineError> {
166        let catalog = self.workflow_catalog();
167        // Deploy lock first (the engine-wide ordering: deploy_mutations,
168        // then the catalog mutation lock), so the persisted-artifact delete
169        // below cannot interleave with a concurrent re-deploy's persistence.
170        let _deploy = self.deploy_mutations.lock().await;
171        let _mutation = catalog.begin_mutation().await;
172        // Swap the version out FIRST: from this instant no new resolution can
173        // produce it, so the checks below cannot be invalidated by a racing
174        // start (a start that already resolved holds a pin and is detected).
175        let removed = catalog.swap_out_version(workflow_type, version)?;
176        if let Err(error) = self
177            .verify_unload_unpinned(catalog, workflow_type, version)
178            .await
179        {
180            catalog.restore_version(removed)?;
181            return Err(error);
182        }
183        // Delete the persisted artifact BEFORE unregistering modules: if the
184        // delete fails the unload is rolled back wholesale, never leaving a
185        // version that is gone from this process yet resurrects at the next
186        // restart. Idempotent for never-persisted (operator-file) versions.
187        if let Err(error) = self
188            .store()
189            .delete_package(workflow_type, &version.to_string())
190            .await
191        {
192            catalog.restore_version(removed)?;
193            return Err(error.into());
194        }
195        self.unregister_unloaded_modules(workflow_type, version, &removed)
196    }
197
198    /// Verifies no in-flight start, resident handle, or recoverable instance
199    /// pins `(type, version)`.
200    async fn verify_unload_unpinned(
201        &self,
202        catalog: &WorkflowCatalog,
203        workflow_type: &str,
204        version: &ContentHash,
205    ) -> Result<(), EngineError> {
206        if catalog.has_pinned_starts(workflow_type, version)? {
207            return Err(EngineError::VersionPinned {
208                workflow_type: workflow_type.to_owned(),
209                version: version.clone(),
210                pinned_by: PinHolder::InFlightStart,
211            });
212        }
213
214        for handle in self.registry().list()? {
215            if handle.workflow_type() == workflow_type
216                && handle.loaded_version() == version
217                && !handle.cached_status().is_terminal()
218            {
219                return Err(EngineError::VersionPinned {
220                    workflow_type: workflow_type.to_owned(),
221                    version: version.clone(),
222                    pinned_by: PinHolder::LiveRun {
223                        workflow_id: handle.workflow_id().clone(),
224                        run_id: handle.run_id().clone(),
225                    },
226                });
227            }
228        }
229
230        let recorded = crate::loader::package_version_of(version);
231        let store = self.store();
232        for workflow_id in store.list_active().await? {
233            let history = store.read_history(&workflow_id).await?;
234            let current_run_pin = history.iter().rev().find_map(|event| match event {
235                Event::WorkflowStarted {
236                    workflow_type: started_type,
237                    package_version,
238                    ..
239                } => Some(started_type == workflow_type && package_version == &recorded),
240                _ => None,
241            });
242            if current_run_pin == Some(true) {
243                return Err(EngineError::VersionPinned {
244                    workflow_type: workflow_type.to_owned(),
245                    version: version.clone(),
246                    pinned_by: PinHolder::RecoverableRun {
247                        workflow_id: workflow_id.clone(),
248                    },
249                });
250            }
251            if let Some(child_workflow_id) = self
252                .recorded_unstarted_child_pin(&history, workflow_type, &recorded)
253                .await?
254            {
255                return Err(EngineError::VersionPinned {
256                    workflow_type: workflow_type.to_owned(),
257                    version: version.clone(),
258                    pinned_by: PinHolder::RecordedChild {
259                        child_workflow_id,
260                        recorded_by: workflow_id.clone(),
261                    },
262                });
263            }
264        }
265        Ok(())
266    }
267
268    /// A recorded-but-never-started child pinned to the target version, if
269    /// any: its `ChildWorkflowStarted` carries the version and its own
270    /// history is still empty, so the crash-repair sweep would have to start
271    /// it on exactly this version.
272    async fn recorded_unstarted_child_pin(
273        &self,
274        parent_history: &[Event],
275        workflow_type: &str,
276        recorded: &aion_core::PackageVersion,
277    ) -> Result<Option<aion_core::WorkflowId>, EngineError> {
278        let store = self.store();
279        for event in parent_history {
280            let Event::ChildWorkflowStarted {
281                child_workflow_id,
282                workflow_type: child_type,
283                package_version,
284                ..
285            } = event
286            else {
287                continue;
288            };
289            if child_type != workflow_type || package_version != recorded {
290                continue;
291            }
292            if store.read_history(child_workflow_id).await?.is_empty() {
293                return Ok(Some(child_workflow_id.clone()));
294            }
295        }
296        Ok(None)
297    }
298
299    /// Unregisters the removed version's modules from the runtime, skipping
300    /// host NIF modules that were never BEAM-registered.
301    fn unregister_unloaded_modules(
302        &self,
303        workflow_type: &str,
304        version: &ContentHash,
305        removed: &crate::loader::catalog::RemovedVersion,
306    ) -> Result<(), EngineError> {
307        let nif_modules = self.runtime().registered_nif_modules();
308        let mut failures = Vec::new();
309        for deployed_name in removed.module_names() {
310            let original = deployed_name.split('$').next().unwrap_or(deployed_name);
311            if nif_modules.iter().any(|name| name == original) {
312                continue;
313            }
314            if let Err(error) = self.runtime().unregister_module(deployed_name) {
315                failures.push(format!("{deployed_name}: {error}"));
316            }
317        }
318        if failures.is_empty() {
319            Ok(())
320        } else {
321            // The catalog commit stands: the version is unloaded and its
322            // names are unreachable (content-hash unique), but the runtime
323            // retains orphaned module entries.
324            Err(EngineError::Runtime {
325                reason: format!(
326                    "workflow `{workflow_type}` version `{version}` was removed from the catalog but module unregistration failed for {}",
327                    failures.join(", ")
328                ),
329            })
330        }
331    }
332}