nasaga_runtime_core/
registry.rs1use std::collections::BTreeMap;
8
9use nasaga_backend::SagaInstanceStore;
10use nasaga_core::{
11 DefinitionVersion, ResolutionMode, SagaId, TenantId, WorkflowDefinition, WorkflowName,
12};
13
14#[derive(Debug, Clone)]
16struct DefinitionEntry {
17 definition: WorkflowDefinition,
18 accepts_new: bool,
19}
20
21#[derive(Debug, Clone, Default)]
26pub struct DefinitionRegistry {
27 definitions: BTreeMap<(String, u32), DefinitionEntry>,
28 tenant_definitions: BTreeMap<(String, String, u32), DefinitionEntry>,
29}
30
31impl DefinitionRegistry {
32 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn is_empty(&self) -> bool {
47 self.definitions.is_empty() && self.tenant_definitions.is_empty()
48 }
49
50 pub fn len(&self) -> usize {
56 self.definitions.len() + self.tenant_definitions.len()
57 }
58
59 pub fn definitions(&self) -> impl Iterator<Item = &WorkflowDefinition> {
65 self.definitions
66 .values()
67 .chain(self.tenant_definitions.values())
68 .map(|entry| &entry.definition)
69 }
70
71 pub fn definitions_with_tenants(
77 &self,
78 ) -> impl Iterator<Item = (Option<&str>, &WorkflowDefinition)> {
79 self.definitions
80 .values()
81 .map(|entry| (None, &entry.definition))
82 .chain(
83 self.tenant_definitions
84 .iter()
85 .map(|((tenant, _, _), entry)| (Some(tenant.as_str()), &entry.definition)),
86 )
87 }
88
89 pub fn register(&mut self, definition: WorkflowDefinition) -> anyhow::Result<()> {
97 validate_runtime_resolution_modes(&definition)?;
98 let key = (
99 definition.name().as_str().to_string(),
100 definition.version().get(),
101 );
102 if let Some(existing) = self.definitions.get(&key) {
103 if existing.definition.digest() != definition.digest() {
106 anyhow::bail!(
107 "definition `{}` v{} already registered with a different digest",
108 key.0,
109 key.1
110 );
111 }
112 return Ok(());
113 }
114 self.definitions.insert(
115 key,
116 DefinitionEntry {
117 definition,
118 accepts_new: true,
119 },
120 );
121 Ok(())
122 }
123
124 pub fn register_for_tenant(
133 &mut self,
134 tenant: TenantId,
135 definition: WorkflowDefinition,
136 accepts_new: bool,
137 ) -> anyhow::Result<()> {
138 validate_runtime_resolution_modes(&definition)?;
139 let key = (
140 tenant.as_str().to_owned(),
141 definition.name().as_str().to_owned(),
142 definition.version().get(),
143 );
144 if let Some(existing) = self.tenant_definitions.get(&key) {
145 if existing.definition.digest() != definition.digest()
146 || existing.accepts_new != accepts_new
147 {
148 anyhow::bail!(
149 "tenant definition `{}` v{} already registered with different content or lifecycle",
150 key.1,
151 key.2
152 );
153 }
154 return Ok(());
155 }
156 self.tenant_definitions.insert(
157 key,
158 DefinitionEntry {
159 definition,
160 accepts_new,
161 },
162 );
163 Ok(())
164 }
165
166 pub fn get(
174 &self,
175 workflow: &WorkflowName,
176 version: DefinitionVersion,
177 ) -> Option<&WorkflowDefinition> {
178 self.definitions
179 .get(&(workflow.as_str().to_string(), version.get()))
180 .map(|entry| &entry.definition)
181 }
182
183 pub fn get_for_tenant(
192 &self,
193 tenant: &TenantId,
194 workflow: &WorkflowName,
195 version: DefinitionVersion,
196 ) -> Option<&WorkflowDefinition> {
197 self.tenant_definitions
198 .get(&(
199 tenant.as_str().to_owned(),
200 workflow.as_str().to_owned(),
201 version.get(),
202 ))
203 .map(|entry| &entry.definition)
204 .or_else(|| self.get(workflow, version))
205 }
206
207 pub fn get_for_start(
216 &self,
217 tenant: &TenantId,
218 workflow: &WorkflowName,
219 version: DefinitionVersion,
220 ) -> Option<&WorkflowDefinition> {
221 if let Some(entry) = self.tenant_definitions.get(&(
222 tenant.as_str().to_owned(),
223 workflow.as_str().to_owned(),
224 version.get(),
225 )) {
226 return entry.accepts_new.then_some(&entry.definition);
227 }
228 self.definitions
229 .get(&(workflow.as_str().to_owned(), version.get()))
230 .filter(|entry| entry.accepts_new)
231 .map(|entry| &entry.definition)
232 }
233
234 pub async fn verify_non_terminal<S>(&self, store: &S, scan_limit: u32) -> anyhow::Result<()>
242 where
243 S: SagaInstanceStore,
244 {
245 let mut missing = 0usize;
246 let mut drifted = 0usize;
247 let mut cursor: Option<SagaId> = None;
248 loop {
249 let instances = store
250 .list_non_terminal_after(cursor.as_ref(), scan_limit)
251 .await?;
252 if instances.is_empty() {
253 break;
254 }
255 for instance in &instances {
256 match self.get_for_tenant(
257 &instance.tenant,
258 &instance.workflow,
259 instance.definition_version,
260 ) {
261 None => missing += 1,
262 Some(definition) if definition.digest() != instance.definition_digest => {
263 drifted += 1
264 }
265 Some(_) => {}
266 }
267 }
268 cursor = instances.last().map(|instance| instance.saga_id.clone());
271 if instances.len() < scan_limit as usize {
272 break;
273 }
274 }
275 if missing > 0 || drifted > 0 {
276 anyhow::bail!(
277 "definition precheck failed: {missing} non-terminal instance(s) reference \
278 unregistered definitions, {drifted} reference drifted digests; refusing Ready"
279 );
280 }
281 Ok(())
282 }
283}
284
285fn validate_runtime_resolution_modes(definition: &WorkflowDefinition) -> anyhow::Result<()> {
296 if let Some(step) = definition.steps().iter().find(|step| {
297 step.resolution().allow_unknown() && step.resolution().mode() != Some(ResolutionMode::Poll)
298 }) {
299 anyhow::bail!(
300 "definition `{}` v{} step `{}` uses an unsupported Saga runtime resolution mode; only poll is hosted",
301 definition.name().as_str(),
302 definition.version().get(),
303 step.name().as_str()
304 );
305 }
306 Ok(())
307}