1use std::collections::{BTreeMap, HashMap};
11use std::num::NonZeroUsize;
12use std::sync::Arc;
13
14use aion_core::{Event, WorkflowId};
15
16use crate::error::ServerError;
17use crate::namespace::NamespaceResolver;
18
19#[derive(Clone, Debug, Eq, PartialEq)]
21pub enum GateVerdict {
22 Permitted {
25 workflow_type: Option<Arc<str>>,
27 },
28 Filtered,
32}
33
34pub struct NamespaceEventGate {
53 resolver: NamespaceResolver,
54 namespace: String,
55 verdicts: VerdictCache,
56}
57
58impl NamespaceEventGate {
59 #[must_use]
61 pub fn new(
62 resolver: NamespaceResolver,
63 namespace: String,
64 verdict_capacity: NonZeroUsize,
65 ) -> Self {
66 Self {
67 resolver,
68 namespace,
69 verdicts: VerdictCache::new(verdict_capacity),
70 }
71 }
72
73 pub fn allow(&mut self, workflow_id: WorkflowId) {
79 self.verdicts.insert(
80 workflow_id,
81 CachedVerdict {
82 permitted: true,
83 workflow_type: None,
84 },
85 );
86 }
87
88 pub async fn admit(&mut self, event: &Event) -> Result<GateVerdict, ServerError> {
108 let workflow_id = event.workflow_id();
109 let cached = if let Some(verdict) = self.verdicts.get(workflow_id) {
110 verdict
111 } else {
112 let verdict = match self
116 .resolver
117 .workflow_attribution(&self.namespace, workflow_id)
118 .await?
119 {
120 Some(attribution) => CachedVerdict {
121 permitted: true,
122 workflow_type: attribution.workflow_type.map(Arc::from),
123 },
124 None => CachedVerdict {
125 permitted: false,
126 workflow_type: None,
127 },
128 };
129 self.verdicts.insert(workflow_id.clone(), verdict.clone());
130 verdict
131 };
132 if !cached.permitted {
133 return Ok(GateVerdict::Filtered);
134 }
135 let workflow_type = if let Event::WorkflowStarted { workflow_type, .. } = event {
136 let inline: Arc<str> = Arc::from(workflow_type.as_str());
137 self.verdicts.refresh_type(workflow_id, Arc::clone(&inline));
138 Some(inline)
139 } else {
140 cached.workflow_type
141 };
142 Ok(GateVerdict::Permitted { workflow_type })
143 }
144}
145
146#[derive(Clone, Debug)]
148struct CachedVerdict {
149 permitted: bool,
150 workflow_type: Option<Arc<str>>,
151}
152
153struct VerdictCache {
160 capacity: NonZeroUsize,
161 entries: HashMap<WorkflowId, StampedVerdict>,
162 order: BTreeMap<u64, WorkflowId>,
163 clock: u64,
164}
165
166struct StampedVerdict {
167 stamp: u64,
168 verdict: CachedVerdict,
169}
170
171impl VerdictCache {
172 fn new(capacity: NonZeroUsize) -> Self {
173 Self {
174 capacity,
175 entries: HashMap::new(),
176 order: BTreeMap::new(),
177 clock: 0,
178 }
179 }
180
181 fn next_stamp(&mut self) -> u64 {
182 self.clock += 1;
183 self.clock
184 }
185
186 fn get(&mut self, workflow_id: &WorkflowId) -> Option<CachedVerdict> {
187 let stamp = self.next_stamp();
188 let entry = self.entries.get_mut(workflow_id)?;
189 self.order.remove(&entry.stamp);
190 entry.stamp = stamp;
191 self.order.insert(stamp, workflow_id.clone());
192 Some(entry.verdict.clone())
193 }
194
195 fn insert(&mut self, workflow_id: WorkflowId, verdict: CachedVerdict) {
196 let stamp = self.next_stamp();
197 if let Some(existing) = self.entries.get_mut(&workflow_id) {
198 self.order.remove(&existing.stamp);
199 existing.stamp = stamp;
200 existing.verdict = verdict;
201 self.order.insert(stamp, workflow_id);
202 return;
203 }
204 if self.entries.len() >= self.capacity.get()
205 && let Some((&oldest_stamp, _)) = self.order.first_key_value()
206 && let Some(evicted) = self.order.remove(&oldest_stamp)
207 {
208 self.entries.remove(&evicted);
209 }
210 self.entries
211 .insert(workflow_id.clone(), StampedVerdict { stamp, verdict });
212 self.order.insert(stamp, workflow_id);
213 }
214
215 fn refresh_type(&mut self, workflow_id: &WorkflowId, workflow_type: Arc<str>) {
216 if let Some(entry) = self.entries.get_mut(workflow_id) {
217 entry.verdict.workflow_type = Some(workflow_type);
218 }
219 }
220
221 #[cfg(test)]
222 fn len(&self) -> usize {
223 debug_assert_eq!(self.entries.len(), self.order.len());
224 self.entries.len()
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use std::num::NonZeroUsize;
231
232 use aion_core::{Event, EventEnvelope, Payload, WorkflowId};
233 use async_trait::async_trait;
234
235 use super::{GateVerdict, NamespaceEventGate};
236 use crate::config::NamespaceMode;
237 use crate::error::ServerError;
238 use crate::namespace::{
239 NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces, WorkflowAttribution,
240 WorkflowNamespaceSource,
241 };
242
243 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
244 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
245 }
246
247 fn event(seq: u64, workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
248 Ok(Event::SignalReceived {
249 envelope: EventEnvelope {
250 seq,
251 recorded_at: chrono::Utc::now(),
252 workflow_id: workflow_id.clone(),
253 },
254 name: "ship".to_owned(),
255 payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
256 })
257 }
258
259 fn started(
260 seq: u64,
261 workflow_id: &WorkflowId,
262 workflow_type: &str,
263 ) -> Result<Event, aion_core::PayloadError> {
264 Ok(Event::WorkflowStarted {
265 envelope: EventEnvelope {
266 seq,
267 recorded_at: chrono::Utc::now(),
268 workflow_id: workflow_id.clone(),
269 },
270 workflow_type: workflow_type.to_owned(),
271 input: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
272 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(u128::from(seq))),
273 parent_run_id: None,
274 parent_workflow_id: None,
275 package_version: aion_core::PackageVersion::new("a".repeat(64)),
276 })
277 }
278
279 fn resolver(ownership: StaticWorkflowNamespaces) -> NamespaceResolver {
280 NamespaceResolver::authorization_only(
281 NamespaceMode::SharedEngine,
282 ownership,
283 StaticScheduleNamespaces::default(),
284 )
285 }
286
287 fn permitted(verdict: &GateVerdict) -> bool {
288 matches!(verdict, GateVerdict::Permitted { .. })
289 }
290
291 #[tokio::test]
292 async fn gate_permits_own_namespace_and_filters_foreign_and_unknown()
293 -> Result<(), Box<dyn std::error::Error>> {
294 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
295 let foreign = WorkflowId::new(uuid::Uuid::from_u128(2));
296 let unknown = WorkflowId::new(uuid::Uuid::from_u128(3));
297 let ownership = StaticWorkflowNamespaces::default();
298 ownership.record(own.clone(), "tenant-a")?;
299 ownership.record(foreign.clone(), "tenant-b")?;
300 let mut gate =
301 NamespaceEventGate::new(resolver(ownership), "tenant-a".to_owned(), capacity(8)?);
302
303 assert!(permitted(&gate.admit(&event(1, &own)?).await?));
304 assert_eq!(
305 gate.admit(&event(1, &foreign)?).await?,
306 GateVerdict::Filtered
307 );
308 assert_eq!(
309 gate.admit(&event(1, &unknown)?).await?,
310 GateVerdict::Filtered
311 );
312 Ok(())
313 }
314
315 #[tokio::test]
316 async fn admit_carries_the_recorded_workflow_type() -> Result<(), Box<dyn std::error::Error>> {
317 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
318 let ownership = StaticWorkflowNamespaces::default();
319 ownership.record_with_type(own.clone(), "tenant-a", "checkout")?;
320 let mut gate =
321 NamespaceEventGate::new(resolver(ownership), "tenant-a".to_owned(), capacity(8)?);
322
323 let verdict = gate.admit(&event(5, &own)?).await?;
326 let GateVerdict::Permitted { workflow_type } = verdict else {
327 return Err("owned workflow must be permitted".into());
328 };
329 assert_eq!(workflow_type.as_deref(), Some("checkout"));
330 Ok(())
331 }
332
333 #[tokio::test]
334 async fn workflow_started_refreshes_the_cached_type_inline()
335 -> Result<(), Box<dyn std::error::Error>> {
336 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
337 let ownership = StaticWorkflowNamespaces::default();
338 ownership.record_with_type(own.clone(), "tenant-a", "checkout")?;
339 let mut gate =
340 NamespaceEventGate::new(resolver(ownership), "tenant-a".to_owned(), capacity(8)?);
341
342 let first = gate.admit(&event(1, &own)?).await?;
343 let GateVerdict::Permitted { workflow_type } = first else {
344 return Err("owned workflow must be permitted".into());
345 };
346 assert_eq!(workflow_type.as_deref(), Some("checkout"));
347
348 let started_verdict = gate.admit(&started(2, &own, "checkout-v2")?).await?;
351 let GateVerdict::Permitted { workflow_type } = started_verdict else {
352 return Err("owned workflow must be permitted".into());
353 };
354 assert_eq!(workflow_type.as_deref(), Some("checkout-v2"));
355
356 let after = gate.admit(&event(3, &own)?).await?;
357 let GateVerdict::Permitted { workflow_type } = after else {
358 return Err("owned workflow must be permitted".into());
359 };
360 assert_eq!(workflow_type.as_deref(), Some("checkout-v2"));
361 Ok(())
362 }
363
364 struct CountingOwnership {
367 inner: StaticWorkflowNamespaces,
368 reads: std::sync::Arc<std::sync::atomic::AtomicUsize>,
369 fail_after: usize,
370 }
371
372 #[async_trait]
373 impl WorkflowNamespaceSource for CountingOwnership {
374 async fn workflow_attribution(
375 &self,
376 workflow_id: &WorkflowId,
377 ) -> Result<Option<WorkflowAttribution>, ServerError> {
378 let reads = self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
379 if reads >= self.fail_after {
380 return Err(ServerError::Config {
381 message: "ownership source unavailable".to_owned(),
382 });
383 }
384 self.inner.workflow_attribution(workflow_id).await
385 }
386 }
387
388 fn counting_resolver(
389 inner: StaticWorkflowNamespaces,
390 fail_after: usize,
391 ) -> (
392 NamespaceResolver,
393 std::sync::Arc<std::sync::atomic::AtomicUsize>,
394 ) {
395 let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
396 let counting = CountingOwnership {
397 inner,
398 reads: std::sync::Arc::clone(&reads),
399 fail_after,
400 };
401 (
402 NamespaceResolver::authorization_only(
403 NamespaceMode::SharedEngine,
404 counting,
405 StaticScheduleNamespaces::default(),
406 ),
407 reads,
408 )
409 }
410
411 #[tokio::test]
412 async fn verdicts_are_cached_per_workflow() -> Result<(), Box<dyn std::error::Error>> {
413 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
414 let ownership = StaticWorkflowNamespaces::default();
415 ownership.record(own.clone(), "tenant-a")?;
416 let (resolver, _reads) = counting_resolver(ownership, 1);
417 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(8)?);
418
419 assert!(permitted(&gate.admit(&event(1, &own)?).await?));
421 assert!(permitted(&gate.admit(&event(2, &own)?).await?));
422 Ok(())
423 }
424
425 #[tokio::test]
431 async fn verdict_cache_is_bounded_and_eviction_rereads_consistently()
432 -> Result<(), Box<dyn std::error::Error>> {
433 let first = WorkflowId::new(uuid::Uuid::from_u128(1));
434 let second = WorkflowId::new(uuid::Uuid::from_u128(2));
435 let third = WorkflowId::new(uuid::Uuid::from_u128(3));
436 let ownership = StaticWorkflowNamespaces::default();
437 ownership.record(first.clone(), "tenant-a")?;
438 ownership.record(second.clone(), "tenant-b")?;
439 ownership.record(third.clone(), "tenant-a")?;
440 let (resolver, reads) = counting_resolver(ownership, usize::MAX);
441 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(2)?);
442
443 assert!(permitted(&gate.admit(&event(1, &first)?).await?));
444 assert_eq!(
445 gate.admit(&event(1, &second)?).await?,
446 GateVerdict::Filtered
447 );
448 assert!(permitted(&gate.admit(&event(1, &third)?).await?));
450 assert_eq!(gate.verdicts.len(), 2, "cache must never exceed its bound");
451 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3);
452
453 assert!(permitted(&gate.admit(&event(2, &first)?).await?));
456 assert_eq!(gate.verdicts.len(), 2, "cache must never exceed its bound");
457 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 4);
458 Ok(())
459 }
460
461 #[tokio::test]
464 async fn lru_eviction_respects_recency() -> Result<(), Box<dyn std::error::Error>> {
465 let first = WorkflowId::new(uuid::Uuid::from_u128(1));
466 let second = WorkflowId::new(uuid::Uuid::from_u128(2));
467 let third = WorkflowId::new(uuid::Uuid::from_u128(3));
468 let ownership = StaticWorkflowNamespaces::default();
469 ownership.record(first.clone(), "tenant-a")?;
470 ownership.record(second.clone(), "tenant-a")?;
471 ownership.record(third.clone(), "tenant-a")?;
472 let (resolver, reads) = counting_resolver(ownership, usize::MAX);
473 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(2)?);
474
475 assert!(permitted(&gate.admit(&event(1, &first)?).await?));
476 assert!(permitted(&gate.admit(&event(1, &second)?).await?));
477 assert!(permitted(&gate.admit(&event(2, &first)?).await?));
479 assert!(permitted(&gate.admit(&event(1, &third)?).await?));
480 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3);
481
482 assert!(permitted(&gate.admit(&event(3, &first)?).await?));
484 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3);
485 assert!(permitted(&gate.admit(&event(2, &second)?).await?));
487 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 4);
488 Ok(())
489 }
490
491 #[tokio::test]
492 async fn pre_seeded_target_never_consults_the_ownership_source()
493 -> Result<(), Box<dyn std::error::Error>> {
494 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
495 let (resolver, _reads) = counting_resolver(StaticWorkflowNamespaces::default(), 0);
496 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(8)?);
497 gate.allow(own.clone());
498
499 assert!(permitted(&gate.admit(&event(1, &own)?).await?));
500 Ok(())
501 }
502
503 #[tokio::test]
504 async fn ownership_read_failure_propagates_instead_of_guessing()
505 -> Result<(), Box<dyn std::error::Error>> {
506 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
507 let (resolver, _reads) = counting_resolver(StaticWorkflowNamespaces::default(), 0);
508 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(8)?);
509
510 let error = gate.admit(&event(1, &own)?).await.err();
511 assert!(matches!(error, Some(ServerError::Config { .. })));
512 Ok(())
513 }
514}