Skip to main content

aion/loader/
catalog.rs

1//! Shared, atomically-swappable workflow package catalog.
2//!
3//! The catalog is the single routing authority for loaded workflow packages.
4//! Readers resolve against an immutable snapshot behind one `Arc` clone, so a
5//! dispatch sees the catalog entirely-before or entirely-after any mutation —
6//! never torn state. Writers serialize on a mutation lock, build a fresh
7//! snapshot, and commit it with a single pointer swap: that swap *is* the
8//! atomic route flip for new starts, while in-flight runs keep the version
9//! they already resolved (loads never unregister anything).
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex, RwLock};
13
14#[path = "catalog_load.rs"]
15mod catalog_load;
16#[path = "catalog_snapshot.rs"]
17mod catalog_snapshot;
18
19use aion_core::PackageVersion;
20use aion_package::{ContentHash, ManifestDigest, ManifestVersion};
21use chrono::{DateTime, Utc};
22
23use super::load::{LoadedWorkflow, load_error};
24use super::version_info::WorkflowVersionInfo;
25use crate::error::EngineError;
26
27/// In-flight start pins keyed by `(workflow type, version)`.
28type StartPins = Arc<Mutex<HashMap<(String, ContentHash), usize>>>;
29
30/// Shared, atomically-swappable workflow package catalog.
31pub struct WorkflowCatalog {
32    /// Immutable snapshot; readers clone the `Arc` under a short read lock
33    /// and resolve against a consistent view.
34    snapshot: RwLock<Arc<CatalogSnapshot>>,
35    /// Serializes load / route / unload. Mutation paths are async (unload
36    /// verification scans the store), so this is a tokio mutex; dispatch is
37    /// never blocked by it — readers only touch `snapshot`.
38    mutations: tokio::sync::Mutex<()>,
39    /// Starts that resolved a version but have not yet registered a handle
40    /// (the registration birth window). Unload refuses while any pin for the
41    /// target version is held.
42    pinned_starts: StartPins,
43}
44
45/// One immutable catalog view.
46#[derive(Clone, Default)]
47struct CatalogSnapshot {
48    by_version: HashMap<(String, ContentHash), CatalogEntry>,
49    /// Explicit route pointer per workflow type — replaces the old
50    /// insertion-order "latest" reading.
51    routed: HashMap<String, ContentHash>,
52    /// Deployed-module collision index over every loaded version.
53    registered_modules: HashMap<String, ContentHash>,
54    /// First-class archive membership, keyed independently from shared beams.
55    package_groups: HashMap<(String, ContentHash), PackageGroup>,
56}
57
58#[derive(Clone, Debug)]
59struct PackageGroup {
60    primary_workflow_type: String,
61    workflow_types: Vec<String>,
62}
63
64/// One loaded package version retained by the catalog.
65#[derive(Clone, Debug)]
66struct CatalogEntry {
67    workflow: LoadedWorkflow,
68    manifest_version: ManifestVersion,
69    /// Canonical digest of the manifest this version was loaded with. The
70    /// content hash covers beams only, so this digest is what detects a
71    /// same-hash-different-manifest re-load (the silent-wrong-deploy tripwire).
72    manifest_digest: ManifestDigest,
73    loaded_at: DateTime<Utc>,
74}
75
76/// A resolved workflow holding its in-flight start pin.
77///
78/// The pin keeps the resolved version visible to unload verification until
79/// the start path has inserted the registry handle (or failed); dropping the
80/// value releases it.
81pub struct PinnedWorkflow {
82    workflow: LoadedWorkflow,
83    _pin: StartPin,
84}
85
86impl PinnedWorkflow {
87    /// The resolved workflow record.
88    #[must_use]
89    pub fn workflow(&self) -> &LoadedWorkflow {
90        &self.workflow
91    }
92}
93
94/// RAII start pin: registered on resolve, released on drop.
95struct StartPin {
96    pins: StartPins,
97    key: (String, ContentHash),
98}
99
100/// A version swapped out of the snapshot during unload verification.
101///
102/// Restoring it is the same single-pointer commit as removing it was.
103#[derive(Debug)]
104pub(crate) struct RemovedPackage {
105    primary_workflow_type: String,
106    version: ContentHash,
107    entries: Vec<(String, CatalogEntry)>,
108    modules: Vec<(String, ContentHash)>,
109}
110
111impl RemovedPackage {
112    /// Deployed module names registered for the removed version.
113    pub(crate) fn module_names(&self) -> impl Iterator<Item = &str> {
114        self.modules.iter().map(|(name, _)| name.as_str())
115    }
116
117    /// Every workflow type implemented by the archive group.
118    pub(crate) fn workflow_types(&self) -> impl Iterator<Item = &str> {
119        self.entries
120            .iter()
121            .map(|(workflow_type, _)| workflow_type.as_str())
122    }
123
124    /// Primary type used as the durable package-record key.
125    pub(crate) fn primary_workflow_type(&self) -> &str {
126        &self.primary_workflow_type
127    }
128}
129
130impl Default for WorkflowCatalog {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl WorkflowCatalog {
137    /// Creates an empty catalog.
138    #[must_use]
139    pub fn new() -> Self {
140        Self {
141            snapshot: RwLock::new(Arc::new(CatalogSnapshot::default())),
142            mutations: tokio::sync::Mutex::new(()),
143            pinned_starts: Arc::new(Mutex::new(HashMap::new())),
144        }
145    }
146
147    fn current(&self) -> Result<Arc<CatalogSnapshot>, EngineError> {
148        let guard = self
149            .snapshot
150            .read()
151            .map_err(|_| EngineError::CatalogPoisoned)?;
152        Ok(Arc::clone(&guard))
153    }
154
155    fn install(&self, snapshot: CatalogSnapshot) -> Result<(), EngineError> {
156        *self
157            .snapshot
158            .write()
159            .map_err(|_| EngineError::CatalogPoisoned)? = Arc::new(snapshot);
160        Ok(())
161    }
162
163    /// Workflow currently routed for `workflow_type`, without a start pin.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`EngineError::CatalogPoisoned`] when the snapshot lock is poisoned.
168    pub fn routed(&self, workflow_type: &str) -> Result<Option<LoadedWorkflow>, EngineError> {
169        let snapshot = self.current()?;
170        Ok(snapshot
171            .routed_entry(workflow_type)
172            .map(|entry| entry.workflow.clone()))
173    }
174
175    /// Durable textual version currently routed for `workflow_type`.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`EngineError::CatalogPoisoned`] when the snapshot lock is poisoned.
180    pub fn routed_version(
181        &self,
182        workflow_type: &str,
183    ) -> Result<Option<PackageVersion>, EngineError> {
184        Ok(self
185            .routed(workflow_type)?
186            .map(|workflow| super::package_version_of(workflow.version())))
187    }
188
189    /// Exact `(type, version)` lookup, without a start pin.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`EngineError::CatalogPoisoned`] when the snapshot lock is poisoned.
194    pub fn get(
195        &self,
196        workflow_type: &str,
197        version: &ContentHash,
198    ) -> Result<Option<LoadedWorkflow>, EngineError> {
199        let snapshot = self.current()?;
200        Ok(snapshot
201            .by_version
202            .get(&(workflow_type.to_owned(), version.clone()))
203            .map(|entry| entry.workflow.clone()))
204    }
205
206    /// Every retained loaded workflow record.
207    ///
208    /// # Errors
209    ///
210    /// Returns [`EngineError::CatalogPoisoned`] when the snapshot lock is poisoned.
211    pub fn workflows(&self) -> Result<Vec<LoadedWorkflow>, EngineError> {
212        let snapshot = self.current()?;
213        Ok(snapshot
214            .by_version
215            .values()
216            .map(|entry| entry.workflow.clone())
217            .collect())
218    }
219
220    /// Every loaded version with its route flag, sorted by `(type, loaded_at)`.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`EngineError::CatalogPoisoned`] when the snapshot lock is poisoned.
225    pub fn versions(&self) -> Result<Vec<WorkflowVersionInfo>, EngineError> {
226        let snapshot = self.current()?;
227        let mut versions: Vec<WorkflowVersionInfo> = snapshot
228            .by_version
229            .values()
230            .map(|entry| WorkflowVersionInfo {
231                workflow_type: entry.workflow.workflow_type().to_owned(),
232                content_hash: entry.workflow.version().clone(),
233                deployed_entry_module: entry.workflow.deployed_entry_module().to_owned(),
234                entry_function: entry.workflow.entry_function().to_owned(),
235                manifest_version: entry.manifest_version.clone(),
236                loaded_at: entry.loaded_at,
237                route_active: snapshot.routed.get(entry.workflow.workflow_type())
238                    == Some(entry.workflow.version()),
239            })
240            .collect();
241        versions.sort_by(|left, right| {
242            left.workflow_type
243                .cmp(&right.workflow_type)
244                .then(left.loaded_at.cmp(&right.loaded_at))
245                .then_with(|| {
246                    left.content_hash
247                        .to_string()
248                        .cmp(&right.content_hash.to_string())
249                })
250        });
251        Ok(versions)
252    }
253
254    /// Resolves the routed version of `workflow_type`, holding a start pin.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`EngineError::CatalogPoisoned`] when a catalog lock is poisoned.
259    pub(crate) fn resolve_routed(
260        &self,
261        workflow_type: &str,
262    ) -> Result<Option<PinnedWorkflow>, EngineError> {
263        let snapshot = self.current()?;
264        let Some(entry) = snapshot.routed_entry(workflow_type) else {
265            return Ok(None);
266        };
267        self.pin_validated(entry.workflow.clone())
268    }
269
270    /// Resolves an exact `(type, version)`, holding a start pin.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`EngineError::CatalogPoisoned`] when a catalog lock is poisoned.
275    pub(crate) fn resolve_exact(
276        &self,
277        workflow_type: &str,
278        version: &ContentHash,
279    ) -> Result<Option<PinnedWorkflow>, EngineError> {
280        let snapshot = self.current()?;
281        let Some(entry) = snapshot
282            .by_version
283            .get(&(workflow_type.to_owned(), version.clone()))
284        else {
285            return Ok(None);
286        };
287        self.pin_validated(entry.workflow.clone())
288    }
289
290    /// Pins the resolution, then re-validates it against the CURRENT
291    /// snapshot. An unload that swapped the version out between this
292    /// reader's snapshot clone and its pin insert would not have seen the
293    /// pin; re-checking after the insert closes that window — either the
294    /// unload sees the pin and refuses, or this resolution observes the
295    /// removal and reports the version as not loaded. Never both, never
296    /// neither, never a dispatch into a deleted module.
297    fn pin_validated(
298        &self,
299        workflow: LoadedWorkflow,
300    ) -> Result<Option<PinnedWorkflow>, EngineError> {
301        let pinned = self.pin(workflow)?;
302        let key = (
303            pinned.workflow.workflow_type().to_owned(),
304            pinned.workflow.version().clone(),
305        );
306        if self.current()?.by_version.contains_key(&key) {
307            Ok(Some(pinned))
308        } else {
309            drop(pinned);
310            Ok(None)
311        }
312    }
313
314    fn pin(&self, workflow: LoadedWorkflow) -> Result<PinnedWorkflow, EngineError> {
315        let key = (
316            workflow.workflow_type().to_owned(),
317            workflow.version().clone(),
318        );
319        {
320            let mut pins = self
321                .pinned_starts
322                .lock()
323                .map_err(|_| EngineError::CatalogPoisoned)?;
324            *pins.entry(key.clone()).or_insert(0) += 1;
325        }
326        Ok(PinnedWorkflow {
327            workflow,
328            _pin: StartPin {
329                pins: Arc::clone(&self.pinned_starts),
330                key,
331            },
332        })
333    }
334
335    /// Whether any in-flight start currently pins `(type, version)`.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`EngineError::CatalogPoisoned`] when the pin lock is poisoned.
340    pub(crate) fn has_pinned_starts(
341        &self,
342        workflow_type: &str,
343        version: &ContentHash,
344    ) -> Result<bool, EngineError> {
345        let pins = self
346            .pinned_starts
347            .lock()
348            .map_err(|_| EngineError::CatalogPoisoned)?;
349        Ok(pins
350            .get(&(workflow_type.to_owned(), version.clone()))
351            .is_some_and(|count| *count > 0))
352    }
353
354    /// Re-points the route for `workflow_type` at an already-loaded version.
355    ///
356    /// # Errors
357    ///
358    /// Returns [`EngineError::UnknownVersion`] naming the loaded set when the
359    /// version is not loaded, and [`EngineError::CatalogPoisoned`] on lock
360    /// poison.
361    pub(crate) async fn route_version(
362        &self,
363        workflow_type: &str,
364        version: &ContentHash,
365    ) -> Result<(), EngineError> {
366        let _mutation = self.mutations.lock().await;
367        let snapshot = self.current()?;
368        let key = (workflow_type.to_owned(), version.clone());
369        if !snapshot.by_version.contains_key(&key) {
370            return Err(EngineError::UnknownVersion {
371                workflow_type: workflow_type.to_owned(),
372                version: version.clone(),
373                loaded: snapshot.loaded_versions_of(workflow_type),
374            });
375        }
376        if snapshot.routed.get(workflow_type) == Some(version) {
377            return Ok(());
378        }
379        let mut next = (*snapshot).clone();
380        next.routed
381            .insert(workflow_type.to_owned(), version.clone());
382        self.install(next)
383    }
384
385    /// Acquires the catalog mutation lock for a multi-step protocol (unload).
386    pub(crate) async fn begin_mutation(&self) -> tokio::sync::MutexGuard<'_, ()> {
387        self.mutations.lock().await
388    }
389
390    /// Swaps an entire non-routed archive group out so no sibling entry remains
391    /// reachable without the group's shared modules. Caller holds the mutation lock.
392    ///
393    /// # Errors
394    ///
395    /// Returns [`EngineError::UnknownVersion`] when the version is not loaded
396    /// and [`EngineError::RouteActive`] when it is the route-active version of
397    /// its type.
398    pub(crate) fn swap_out_package(
399        &self,
400        workflow_type: &str,
401        version: &ContentHash,
402    ) -> Result<RemovedPackage, EngineError> {
403        let snapshot = self.current()?;
404        let key = (workflow_type.to_owned(), version.clone());
405        if !snapshot.by_version.contains_key(&key) {
406            return Err(EngineError::UnknownVersion {
407                workflow_type: workflow_type.to_owned(),
408                version: version.clone(),
409                loaded: snapshot.loaded_versions_of(workflow_type),
410            });
411        }
412        let group = snapshot
413            .package_groups
414            .iter()
415            .find(|((_, hash), group)| {
416                hash == version
417                    && group
418                        .workflow_types
419                        .iter()
420                        .any(|member| member == workflow_type)
421            })
422            .map(|(_, group)| group)
423            .ok_or_else(|| {
424                load_error(format!(
425                    "package group for workflow `{workflow_type}` version `{version}` is missing"
426                ))
427            })?;
428        for member in &group.workflow_types {
429            if snapshot.routed.get(member) == Some(version) {
430                return Err(EngineError::RouteActive {
431                    workflow_type: member.clone(),
432                    version: version.clone(),
433                });
434            }
435        }
436        let mut next = (*snapshot).clone();
437        let mut entries = Vec::with_capacity(group.workflow_types.len());
438        for member in &group.workflow_types {
439            let member_key = (member.clone(), version.clone());
440            let Some(member_entry) = next.by_version.remove(&member_key) else {
441                return Err(load_error(format!(
442                    "package group `{version}` is partially registered: missing `{member}`"
443                )));
444            };
445            entries.push((member.clone(), member_entry));
446        }
447        next.package_groups
448            .remove(&(group.primary_workflow_type.clone(), version.clone()));
449        let hash_still_referenced = next.package_groups.keys().any(|(_, hash)| hash == version);
450        let modules: Vec<(String, ContentHash)> = if hash_still_referenced {
451            Vec::new()
452        } else {
453            next.registered_modules
454                .iter()
455                .filter(|(_, hash)| *hash == version)
456                .map(|(name, hash)| (name.clone(), hash.clone()))
457                .collect()
458        };
459        for (name, _) in &modules {
460            next.registered_modules.remove(name);
461        }
462        self.install(next)?;
463        Ok(RemovedPackage {
464            primary_workflow_type: group.primary_workflow_type.clone(),
465            version: version.clone(),
466            entries,
467            modules,
468        })
469    }
470
471    /// Restores a group swapped out by [`Self::swap_out_package`] after a
472    /// failed unload check. Caller must hold the mutation lock.
473    ///
474    /// # Errors
475    ///
476    /// Returns [`EngineError::CatalogPoisoned`] on lock poison.
477    pub(crate) fn restore_package(&self, removed: RemovedPackage) -> Result<(), EngineError> {
478        let snapshot = self.current()?;
479        let mut next = (*snapshot).clone();
480        let workflow_types = removed
481            .entries
482            .iter()
483            .map(|(workflow_type, _)| workflow_type.clone())
484            .collect();
485        for (workflow_type, entry) in removed.entries {
486            next.by_version
487                .insert((workflow_type, removed.version.clone()), entry);
488        }
489        for (name, hash) in removed.modules {
490            next.registered_modules.insert(name, hash);
491        }
492        next.package_groups.insert(
493            (removed.primary_workflow_type.clone(), removed.version),
494            PackageGroup {
495                primary_workflow_type: removed.primary_workflow_type,
496                workflow_types,
497            },
498        );
499        self.install(next)
500    }
501}
502
503#[cfg(test)]
504#[path = "catalog_test_support.rs"]
505mod test_support;
506
507#[cfg(test)]
508#[path = "catalog_multi_entry_tests.rs"]
509mod catalog_multi_entry_tests;
510#[cfg(test)]
511#[path = "catalog_tests.rs"]
512mod catalog_tests;
513#[cfg(test)]
514#[path = "catalog_timeout_tests.rs"]
515mod catalog_timeout_tests;