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::{DeclaredQueues, DeployedWorkerContract, 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 // Read the superseded set AFTER routing settles, so the version
87 // this call just routed is never in it.
88 let mut outcome = outcome;
89 outcome.superseded_versions =
90 catalog.superseded_versions(outcome.record.workflow_type())?;
91 Ok(outcome)
92 }
93 .await;
94 drop(operation);
95 result
96 }
97
98 /// Lists every loaded workflow version with its routing flag, sorted by
99 /// `(workflow_type, loaded_at)`.
100 ///
101 /// # Errors
102 ///
103 /// Returns [`EngineError::CatalogPoisoned`] when the catalog lock is poisoned.
104 pub fn list_workflow_versions(&self) -> Result<Vec<WorkflowVersionInfo>, EngineError> {
105 self.workflow_catalog().versions()
106 }
107
108 /// Returns every retained `.v4` package contract declaring `task_queue`.
109 ///
110 /// This is the RAW retained set, which under content-hash namespacing holds
111 /// every coexisting version — including ones nothing can reach any more.
112 /// Worker admission must not be decided from it directly; use
113 /// [`Engine::worker_contracts_for_admission`], which splits it into the
114 /// reachable versions that bind a connection and the unreachable ones that
115 /// bind nobody. Demanding the raw set of one connection is what made a
116 /// queue with a single stale version permanently unservable.
117 ///
118 /// # Errors
119 ///
120 /// Returns [`EngineError::CatalogPoisoned`] when the catalog lock is poisoned.
121 pub fn worker_contracts_for_queue(
122 &self,
123 task_queue: &str,
124 ) -> Result<Vec<DeployedWorkerContract>, EngineError> {
125 self.workflow_catalog()
126 .worker_contracts_for_queue(task_queue)
127 }
128
129 /// Returns one read of the task queues the retained contracts declare.
130 ///
131 /// The server's queue-service classifier (R1) reads this to tell a
132 /// structurally undeclared queue apart from a declared but unserved one.
133 /// The answer reports whether it covered every retained entry: a queue
134 /// missing from a read that could not decode some entry is unknowable, not
135 /// undeclared. An empty set likewise means this catalog declares no queues
136 /// at all and therefore cannot contradict any dispatch — see
137 /// [`WorkflowCatalog::declared_task_queues`](crate::loader::WorkflowCatalog::declared_task_queues).
138 ///
139 /// # Errors
140 ///
141 /// Returns [`EngineError::CatalogPoisoned`] when the catalog lock is poisoned.
142 pub fn declared_task_queues(&self) -> Result<DeclaredQueues, EngineError> {
143 self.workflow_catalog().declared_task_queues()
144 }
145
146 /// Re-points routing for `workflow_type` at an already-loaded version
147 /// (rollback / roll-forward). Atomic and idempotent.
148 ///
149 /// The pointer is persisted so the re-point survives a restart; startup
150 /// restores persisted pointers after reloading persisted packages.
151 ///
152 /// # Errors
153 ///
154 /// Returns [`EngineError::ShuttingDown`] once shutdown begins,
155 /// [`EngineError::UnknownVersion`] naming the loaded set when
156 /// `(type, version)` is not loaded — routing to a never-loaded hash is
157 /// impossible — and [`EngineError::Store`] when the durable pointer could
158 /// not be written. The in-memory route is published only after that write.
159 pub async fn route_workflow_version(
160 &self,
161 workflow_type: &str,
162 version: &ContentHash,
163 ) -> Result<(), EngineError> {
164 let operation = self.shutdown_gate.begin_operation()?;
165 let result = async {
166 // One deploy mutation: the catalog re-point and the durable
167 // pointer write must not interleave with another deploy
168 // mutation's persistence.
169 let _deploy = self.deploy_mutations.lock().await;
170 let catalog = self.workflow_catalog();
171 if catalog.get(workflow_type, version)?.is_none() {
172 let loaded_versions = catalog
173 .versions()?
174 .into_iter()
175 .filter(|entry| entry.workflow_type == workflow_type)
176 .map(|entry| entry.content_hash.to_string())
177 .collect::<Vec<_>>();
178 let loaded = if loaded_versions.is_empty() {
179 "none".to_owned()
180 } else {
181 loaded_versions.join(", ")
182 };
183 return Err(EngineError::UnknownVersion {
184 workflow_type: workflow_type.to_owned(),
185 version: version.clone(),
186 loaded,
187 });
188 }
189 self.store()
190 .put_package_route(workflow_type, &version.to_string())
191 .await?;
192 catalog.route_version(workflow_type, version).await?;
193 Ok(())
194 }
195 .await;
196 drop(operation);
197 result
198 }
199
200 /// Unloads a workflow version after verifying nothing pins it (D2).
201 ///
202 /// Refusal conditions, each typed and naming what pins the version:
203 /// route-inactive is required (the route-active version of a type can
204 /// never be unloaded), no in-flight start may pin it, no live registry
205 /// handle may run on it, and no recoverable instance in the store —
206 /// running, durably paused, or a recorded-but-never-started child — may be
207 /// pinned to it.
208 ///
209 /// The engine owns the mechanism; the embedding platform owns *when* to
210 /// unload. There is no automatic garbage collection.
211 ///
212 /// Unload deletes the persisted deploy artifact too (a no-op for
213 /// versions loaded from operator files, which were never persisted), so
214 /// an unloaded version does not resurrect at the next restart.
215 ///
216 /// # Errors
217 ///
218 /// Returns [`EngineError::ShuttingDown`] once shutdown begins,
219 /// [`EngineError::UnknownVersion`] when `(type, version)` is not loaded,
220 /// [`EngineError::RouteActive`] when the version is route-active,
221 /// [`EngineError::VersionPinned`] naming the concrete pin holder (with
222 /// the catalog restored untouched), [`EngineError::Store`] when the
223 /// persisted artifact could not be deleted (the catalog is restored and
224 /// the unload did not happen), and [`EngineError::Runtime`] when module
225 /// unregistration fails after the catalog commit.
226 pub async fn unload_workflow_version(
227 &self,
228 workflow_type: &str,
229 version: &ContentHash,
230 ) -> Result<(), EngineError> {
231 let operation = self.shutdown_gate.begin_operation()?;
232 let result = self
233 .unload_workflow_version_inner(workflow_type, version)
234 .await;
235 drop(operation);
236 result
237 }
238
239 async fn unload_workflow_version_inner(
240 &self,
241 workflow_type: &str,
242 version: &ContentHash,
243 ) -> Result<(), EngineError> {
244 let catalog = self.workflow_catalog();
245 // Deploy lock first (the engine-wide ordering: deploy_mutations,
246 // then the catalog mutation lock), so the persisted-artifact delete
247 // below cannot interleave with a concurrent re-deploy's persistence.
248 let _deploy = self.deploy_mutations.lock().await;
249 let _mutation = catalog.begin_mutation().await;
250 // Swap the version out FIRST: from this instant no new resolution can
251 // produce it, so the checks below cannot be invalidated by a racing
252 // start (a start that already resolved holds a pin and is detected).
253 let removed = catalog.swap_out_package(workflow_type, version)?;
254 let member_types = removed
255 .workflow_types()
256 .map(str::to_owned)
257 .collect::<Vec<_>>();
258 if let Err(error) = self
259 .verify_unload_unpinned(catalog, &member_types, version)
260 .await
261 {
262 catalog.restore_package(removed)?;
263 return Err(error);
264 }
265 // Delete the persisted artifact BEFORE unregistering modules: if the
266 // delete fails the unload is rolled back wholesale, never leaving a
267 // version that is gone from this process yet resurrects at the next
268 // restart. Idempotent for never-persisted (operator-file) versions.
269 if let Err(error) = self
270 .store()
271 .delete_package(removed.primary_workflow_type(), &version.to_string())
272 .await
273 {
274 catalog.restore_package(removed)?;
275 return Err(error.into());
276 }
277 self.unregister_unloaded_modules(workflow_type, version, &removed)
278 }
279
280 /// Verifies no member type in an archive group is pinned to `version`.
281 async fn verify_unload_unpinned(
282 &self,
283 catalog: &WorkflowCatalog,
284 workflow_types: &[String],
285 version: &ContentHash,
286 ) -> Result<(), EngineError> {
287 for workflow_type in workflow_types {
288 self.verify_unload_member_unpinned(catalog, workflow_type, version)
289 .await?;
290 }
291 Ok(())
292 }
293
294 async fn verify_unload_member_unpinned(
295 &self,
296 catalog: &WorkflowCatalog,
297 workflow_type: &str,
298 version: &ContentHash,
299 ) -> Result<(), EngineError> {
300 if catalog.has_pinned_starts(workflow_type, version)? {
301 return Err(EngineError::VersionPinned {
302 workflow_type: workflow_type.to_owned(),
303 version: version.clone(),
304 pinned_by: PinHolder::InFlightStart,
305 });
306 }
307
308 for handle in self.registry().list()? {
309 if handle.workflow_type() == workflow_type
310 && handle.loaded_version() == version
311 && !handle.cached_status().is_terminal()
312 {
313 return Err(EngineError::VersionPinned {
314 workflow_type: workflow_type.to_owned(),
315 version: version.clone(),
316 pinned_by: PinHolder::LiveRun {
317 workflow_id: handle.workflow_id().clone(),
318 run_id: handle.run_id().clone(),
319 },
320 });
321 }
322 }
323
324 let recorded = crate::loader::package_version_of(version);
325 let store = self.store();
326 // `list_active` projects `Running` ONLY — a durably-`Paused` run is
327 // non-terminal, is not respawned by startup recovery (so it holds no
328 // registry handle either), and an operator `resume` puts it straight
329 // back on this exact version. Scanning the active set alone therefore
330 // let an unload delete the module a paused run would resume into,
331 // stranding it permanently. Both projections are scanned; a workflow in
332 // both sets is simply checked twice.
333 let mut recoverable = store.list_active().await?;
334 recoverable.extend(store.list_paused().await?);
335 for workflow_id in recoverable {
336 let history = store.read_history(&workflow_id).await?;
337 let current_run_pin = history.iter().rev().find_map(|event| match event {
338 Event::WorkflowStarted {
339 workflow_type: started_type,
340 package_version,
341 ..
342 } => Some(started_type == workflow_type && package_version == &recorded),
343 _ => None,
344 });
345 if current_run_pin == Some(true) {
346 return Err(EngineError::VersionPinned {
347 workflow_type: workflow_type.to_owned(),
348 version: version.clone(),
349 pinned_by: PinHolder::RecoverableRun {
350 workflow_id: workflow_id.clone(),
351 },
352 });
353 }
354 if let Some(child_workflow_id) = self
355 .recorded_unstarted_child_pin(&history, workflow_type, &recorded)
356 .await?
357 {
358 return Err(EngineError::VersionPinned {
359 workflow_type: workflow_type.to_owned(),
360 version: version.clone(),
361 pinned_by: PinHolder::RecordedChild {
362 child_workflow_id,
363 recorded_by: workflow_id.clone(),
364 },
365 });
366 }
367 }
368 Ok(())
369 }
370
371 /// A recorded-but-never-started child pinned to the target version, if
372 /// any: its `ChildWorkflowStarted` carries the version and its own
373 /// history is still empty, so the crash-repair sweep would have to start
374 /// it on exactly this version.
375 async fn recorded_unstarted_child_pin(
376 &self,
377 parent_history: &[Event],
378 workflow_type: &str,
379 recorded: &aion_core::PackageVersion,
380 ) -> Result<Option<aion_core::WorkflowId>, EngineError> {
381 let store = self.store();
382 for event in parent_history {
383 let Event::ChildWorkflowStarted {
384 child_workflow_id,
385 workflow_type: child_type,
386 package_version,
387 ..
388 } = event
389 else {
390 continue;
391 };
392 if child_type != workflow_type || package_version != recorded {
393 continue;
394 }
395 if store.read_history(child_workflow_id).await?.is_empty() {
396 return Ok(Some(child_workflow_id.clone()));
397 }
398 }
399 Ok(None)
400 }
401
402 /// Unregisters the removed version's modules from the runtime, skipping
403 /// host NIF modules that were never BEAM-registered.
404 fn unregister_unloaded_modules(
405 &self,
406 workflow_type: &str,
407 version: &ContentHash,
408 removed: &crate::loader::catalog::RemovedPackage,
409 ) -> Result<(), EngineError> {
410 let nif_modules = self.runtime().registered_nif_modules();
411 let mut failures = Vec::new();
412 for deployed_name in removed.module_names() {
413 let original = deployed_name.split('$').next().unwrap_or(deployed_name);
414 if nif_modules.iter().any(|name| name == original) {
415 continue;
416 }
417 if let Err(error) = self.runtime().unregister_module(deployed_name) {
418 failures.push(format!("{deployed_name}: {error}"));
419 }
420 }
421 if failures.is_empty() {
422 Ok(())
423 } else {
424 // The catalog commit stands: the version is unloaded and its
425 // names are unreachable (content-hash unique), but the runtime
426 // retains orphaned module entries.
427 Err(EngineError::Runtime {
428 reason: format!(
429 "workflow `{workflow_type}` version `{version}` was removed from the catalog but module unregistration failed for {}",
430 failures.join(", ")
431 ),
432 })
433 }
434 }
435}