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, 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}