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