1use 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
27type StartPins = Arc<Mutex<HashMap<(String, ContentHash), usize>>>;
29
30pub struct WorkflowCatalog {
32 snapshot: RwLock<Arc<CatalogSnapshot>>,
35 mutations: tokio::sync::Mutex<()>,
39 pinned_starts: StartPins,
43}
44
45#[derive(Clone, Default)]
47struct CatalogSnapshot {
48 by_version: HashMap<(String, ContentHash), CatalogEntry>,
49 routed: HashMap<String, ContentHash>,
52 registered_modules: HashMap<String, ContentHash>,
54 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#[derive(Clone, Debug)]
66struct CatalogEntry {
67 workflow: LoadedWorkflow,
68 manifest_version: ManifestVersion,
69 manifest_digest: ManifestDigest,
73 loaded_at: DateTime<Utc>,
74}
75
76pub struct PinnedWorkflow {
82 workflow: LoadedWorkflow,
83 _pin: StartPin,
84}
85
86impl PinnedWorkflow {
87 #[must_use]
89 pub fn workflow(&self) -> &LoadedWorkflow {
90 &self.workflow
91 }
92}
93
94struct StartPin {
96 pins: StartPins,
97 key: (String, ContentHash),
98}
99
100#[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 pub(crate) fn module_names(&self) -> impl Iterator<Item = &str> {
114 self.modules.iter().map(|(name, _)| name.as_str())
115 }
116
117 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 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 #[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 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 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 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 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 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 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 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 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 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 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 pub(crate) async fn begin_mutation(&self) -> tokio::sync::MutexGuard<'_, ()> {
387 self.mutations.lock().await
388 }
389
390 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 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;