use std::collections::BTreeMap;
use nasaga_backend::SagaInstanceStore;
use nasaga_core::{
DefinitionVersion, ResolutionMode, SagaId, TenantId, WorkflowDefinition, WorkflowName,
};
#[derive(Debug, Clone)]
struct DefinitionEntry {
definition: WorkflowDefinition,
accepts_new: bool,
}
#[derive(Debug, Clone, Default)]
pub struct DefinitionRegistry {
definitions: BTreeMap<(String, u32), DefinitionEntry>,
tenant_definitions: BTreeMap<(String, String, u32), DefinitionEntry>,
}
impl DefinitionRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.definitions.is_empty() && self.tenant_definitions.is_empty()
}
pub fn len(&self) -> usize {
self.definitions.len() + self.tenant_definitions.len()
}
pub fn definitions(&self) -> impl Iterator<Item = &WorkflowDefinition> {
self.definitions
.values()
.chain(self.tenant_definitions.values())
.map(|entry| &entry.definition)
}
pub fn definitions_with_tenants(
&self,
) -> impl Iterator<Item = (Option<&str>, &WorkflowDefinition)> {
self.definitions
.values()
.map(|entry| (None, &entry.definition))
.chain(
self.tenant_definitions
.iter()
.map(|((tenant, _, _), entry)| (Some(tenant.as_str()), &entry.definition)),
)
}
pub fn register(&mut self, definition: WorkflowDefinition) -> anyhow::Result<()> {
validate_runtime_resolution_modes(&definition)?;
let key = (
definition.name().as_str().to_string(),
definition.version().get(),
);
if let Some(existing) = self.definitions.get(&key) {
if existing.definition.digest() != definition.digest() {
anyhow::bail!(
"definition `{}` v{} already registered with a different digest",
key.0,
key.1
);
}
return Ok(());
}
self.definitions.insert(
key,
DefinitionEntry {
definition,
accepts_new: true,
},
);
Ok(())
}
pub fn register_for_tenant(
&mut self,
tenant: TenantId,
definition: WorkflowDefinition,
accepts_new: bool,
) -> anyhow::Result<()> {
validate_runtime_resolution_modes(&definition)?;
let key = (
tenant.as_str().to_owned(),
definition.name().as_str().to_owned(),
definition.version().get(),
);
if let Some(existing) = self.tenant_definitions.get(&key) {
if existing.definition.digest() != definition.digest()
|| existing.accepts_new != accepts_new
{
anyhow::bail!(
"tenant definition `{}` v{} already registered with different content or lifecycle",
key.1,
key.2
);
}
return Ok(());
}
self.tenant_definitions.insert(
key,
DefinitionEntry {
definition,
accepts_new,
},
);
Ok(())
}
pub fn get(
&self,
workflow: &WorkflowName,
version: DefinitionVersion,
) -> Option<&WorkflowDefinition> {
self.definitions
.get(&(workflow.as_str().to_string(), version.get()))
.map(|entry| &entry.definition)
}
pub fn get_for_tenant(
&self,
tenant: &TenantId,
workflow: &WorkflowName,
version: DefinitionVersion,
) -> Option<&WorkflowDefinition> {
self.tenant_definitions
.get(&(
tenant.as_str().to_owned(),
workflow.as_str().to_owned(),
version.get(),
))
.map(|entry| &entry.definition)
.or_else(|| self.get(workflow, version))
}
pub fn get_for_start(
&self,
tenant: &TenantId,
workflow: &WorkflowName,
version: DefinitionVersion,
) -> Option<&WorkflowDefinition> {
if let Some(entry) = self.tenant_definitions.get(&(
tenant.as_str().to_owned(),
workflow.as_str().to_owned(),
version.get(),
)) {
return entry.accepts_new.then_some(&entry.definition);
}
self.definitions
.get(&(workflow.as_str().to_owned(), version.get()))
.filter(|entry| entry.accepts_new)
.map(|entry| &entry.definition)
}
pub async fn verify_non_terminal<S>(&self, store: &S, scan_limit: u32) -> anyhow::Result<()>
where
S: SagaInstanceStore,
{
let mut missing = 0usize;
let mut drifted = 0usize;
let mut cursor: Option<SagaId> = None;
loop {
let instances = store
.list_non_terminal_after(cursor.as_ref(), scan_limit)
.await?;
if instances.is_empty() {
break;
}
for instance in &instances {
match self.get_for_tenant(
&instance.tenant,
&instance.workflow,
instance.definition_version,
) {
None => missing += 1,
Some(definition) if definition.digest() != instance.definition_digest => {
drifted += 1
}
Some(_) => {}
}
}
cursor = instances.last().map(|instance| instance.saga_id.clone());
if instances.len() < scan_limit as usize {
break;
}
}
if missing > 0 || drifted > 0 {
anyhow::bail!(
"definition precheck failed: {missing} non-terminal instance(s) reference \
unregistered definitions, {drifted} reference drifted digests; refusing Ready"
);
}
Ok(())
}
}
fn validate_runtime_resolution_modes(definition: &WorkflowDefinition) -> anyhow::Result<()> {
if let Some(step) = definition.steps().iter().find(|step| {
step.resolution().allow_unknown() && step.resolution().mode() != Some(ResolutionMode::Poll)
}) {
anyhow::bail!(
"definition `{}` v{} step `{}` uses an unsupported Saga runtime resolution mode; only poll is hosted",
definition.name().as_str(),
definition.version().get(),
step.name().as_str()
);
}
Ok(())
}