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::{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
311                && handle.loaded_version() == version
312                && !handle.cached_status().is_terminal()
313            {
314                return Err(EngineError::VersionPinned {
315                    workflow_type: workflow_type.to_owned(),
316                    version: version.clone(),
317                    pinned_by: PinHolder::LiveRun {
318                        workflow_id: handle.workflow_id().clone(),
319                        run_id: handle.run_id().clone(),
320                    },
321                });
322            }
323        }
324
325        let recorded = crate::loader::package_version_of(version);
326        let store = self.store();
327        // `list_active` projects `Running` ONLY — a durably-`Paused` run is
328        // non-terminal, is not respawned by startup recovery (so it holds no
329        // registry handle either), and an operator `resume` puts it straight
330        // back on this exact version. Scanning the active set alone therefore
331        // let an unload delete the module a paused run would resume into,
332        // stranding it permanently. Both projections are scanned; a workflow in
333        // both sets is simply checked twice.
334        let mut recoverable = store.list_active().await?;
335        recoverable.extend(store.list_paused().await?);
336        for workflow_id in recoverable {
337            let history = store.read_history(&workflow_id).await?;
338            let current_run_pin = history.iter().rev().find_map(|event| match event {
339                Event::WorkflowStarted {
340                    workflow_type: started_type,
341                    package_version,
342                    ..
343                } => Some(started_type == workflow_type && package_version == &recorded),
344                _ => None,
345            });
346            if current_run_pin == Some(true) {
347                return Err(EngineError::VersionPinned {
348                    workflow_type: workflow_type.to_owned(),
349                    version: version.clone(),
350                    pinned_by: PinHolder::RecoverableRun {
351                        workflow_id: workflow_id.clone(),
352                    },
353                });
354            }
355            if let Some(child_workflow_id) = self
356                .recorded_unstarted_child_pin(&history, workflow_type, &recorded)
357                .await?
358            {
359                return Err(EngineError::VersionPinned {
360                    workflow_type: workflow_type.to_owned(),
361                    version: version.clone(),
362                    pinned_by: PinHolder::RecordedChild {
363                        child_workflow_id,
364                        recorded_by: workflow_id.clone(),
365                    },
366                });
367            }
368        }
369        Ok(())
370    }
371
372    /// A recorded-but-never-started child pinned to the target version, if
373    /// any: its `ChildWorkflowStarted` carries the version and its own
374    /// history is still empty, so the crash-repair sweep would have to start
375    /// it on exactly this version.
376    async fn recorded_unstarted_child_pin(
377        &self,
378        parent_history: &[Event],
379        workflow_type: &str,
380        recorded: &aion_core::PackageVersion,
381    ) -> Result<Option<aion_core::WorkflowId>, EngineError> {
382        let store = self.store();
383        for event in parent_history {
384            let Event::ChildWorkflowStarted {
385                child_workflow_id,
386                workflow_type: child_type,
387                package_version,
388                ..
389            } = event
390            else {
391                continue;
392            };
393            if child_type != workflow_type || package_version != recorded {
394                continue;
395            }
396            if store.read_history(child_workflow_id).await?.is_empty() {
397                return Ok(Some(child_workflow_id.clone()));
398            }
399        }
400        Ok(None)
401    }
402
403    /// Unregisters the removed version's modules from the runtime, skipping
404    /// host NIF modules that were never BEAM-registered.
405    fn unregister_unloaded_modules(
406        &self,
407        workflow_type: &str,
408        version: &ContentHash,
409        removed: &crate::loader::catalog::RemovedPackage,
410    ) -> Result<(), EngineError> {
411        let nif_modules = self.runtime().registered_nif_modules();
412        let mut failures = Vec::new();
413        for deployed_name in removed.module_names() {
414            let original = deployed_name.split('$').next().unwrap_or(deployed_name);
415            if nif_modules.iter().any(|name| name == original) {
416                continue;
417            }
418            if let Err(error) = self.runtime().unregister_module(deployed_name) {
419                failures.push(format!("{deployed_name}: {error}"));
420            }
421        }
422        if failures.is_empty() {
423            Ok(())
424        } else {
425            // The catalog commit stands: the version is unloaded and its
426            // names are unreachable (content-hash unique), but the runtime
427            // retains orphaned module entries.
428            Err(EngineError::Runtime {
429                reason: format!(
430                    "workflow `{workflow_type}` version `{version}` was removed from the catalog but module unregistration failed for {}",
431                    failures.join(", ")
432                ),
433            })
434        }
435    }
436}