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}