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}