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 if let Some((&oldest_stamp, _)) = self.order.first_key_value() {
206 if let Some(evicted) = self.order.remove(&oldest_stamp) {
207 self.entries.remove(&evicted);
208 }
209 }
210 }
211 self.entries
212 .insert(workflow_id.clone(), StampedVerdict { stamp, verdict });
213 self.order.insert(stamp, workflow_id);
214 }
215
216 fn refresh_type(&mut self, workflow_id: &WorkflowId, workflow_type: Arc<str>) {
217 if let Some(entry) = self.entries.get_mut(workflow_id) {
218 entry.verdict.workflow_type = Some(workflow_type);
219 }
220 }
221
222 #[cfg(test)]
223 fn len(&self) -> usize {
224 debug_assert_eq!(self.entries.len(), self.order.len());
225 self.entries.len()
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use std::num::NonZeroUsize;
232
233 use aion_core::{Event, EventEnvelope, Payload, WorkflowId};
234 use async_trait::async_trait;
235
236 use super::{GateVerdict, NamespaceEventGate};
237 use crate::config::NamespaceMode;
238 use crate::error::ServerError;
239 use crate::namespace::{
240 NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces, WorkflowAttribution,
241 WorkflowNamespaceSource,
242 };
243
244 fn capacity(value: usize) -> Result<NonZeroUsize, Box<dyn std::error::Error>> {
245 NonZeroUsize::new(value).ok_or_else(|| "capacity must be non-zero".into())
246 }
247
248 fn event(seq: u64, workflow_id: &WorkflowId) -> Result<Event, aion_core::PayloadError> {
249 Ok(Event::SignalReceived {
250 envelope: EventEnvelope {
251 seq,
252 recorded_at: chrono::Utc::now(),
253 workflow_id: workflow_id.clone(),
254 },
255 name: "ship".to_owned(),
256 payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
257 })
258 }
259
260 fn started(
261 seq: u64,
262 workflow_id: &WorkflowId,
263 workflow_type: &str,
264 ) -> Result<Event, aion_core::PayloadError> {
265 Ok(Event::WorkflowStarted {
266 envelope: EventEnvelope {
267 seq,
268 recorded_at: chrono::Utc::now(),
269 workflow_id: workflow_id.clone(),
270 },
271 workflow_type: workflow_type.to_owned(),
272 input: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
273 run_id: aion_core::RunId::new(uuid::Uuid::from_u128(u128::from(seq))),
274 parent_run_id: None,
275 parent_workflow_id: None,
276 package_version: aion_core::PackageVersion::new("a".repeat(64)),
277 })
278 }
279
280 fn resolver(ownership: StaticWorkflowNamespaces) -> NamespaceResolver {
281 NamespaceResolver::authorization_only(
282 NamespaceMode::SharedEngine,
283 ownership,
284 StaticScheduleNamespaces::default(),
285 )
286 }
287
288 fn permitted(verdict: &GateVerdict) -> bool {
289 matches!(verdict, GateVerdict::Permitted { .. })
290 }
291
292 #[tokio::test]
293 async fn gate_permits_own_namespace_and_filters_foreign_and_unknown()
294 -> Result<(), Box<dyn std::error::Error>> {
295 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
296 let foreign = WorkflowId::new(uuid::Uuid::from_u128(2));
297 let unknown = WorkflowId::new(uuid::Uuid::from_u128(3));
298 let ownership = StaticWorkflowNamespaces::default();
299 ownership.record(own.clone(), "tenant-a")?;
300 ownership.record(foreign.clone(), "tenant-b")?;
301 let mut gate =
302 NamespaceEventGate::new(resolver(ownership), "tenant-a".to_owned(), capacity(8)?);
303
304 assert!(permitted(&gate.admit(&event(1, &own)?).await?));
305 assert_eq!(
306 gate.admit(&event(1, &foreign)?).await?,
307 GateVerdict::Filtered
308 );
309 assert_eq!(
310 gate.admit(&event(1, &unknown)?).await?,
311 GateVerdict::Filtered
312 );
313 Ok(())
314 }
315
316 #[tokio::test]
317 async fn admit_carries_the_recorded_workflow_type() -> Result<(), Box<dyn std::error::Error>> {
318 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
319 let ownership = StaticWorkflowNamespaces::default();
320 ownership.record_with_type(own.clone(), "tenant-a", "checkout")?;
321 let mut gate =
322 NamespaceEventGate::new(resolver(ownership), "tenant-a".to_owned(), capacity(8)?);
323
324 let verdict = gate.admit(&event(5, &own)?).await?;
327 let GateVerdict::Permitted { workflow_type } = verdict else {
328 return Err("owned workflow must be permitted".into());
329 };
330 assert_eq!(workflow_type.as_deref(), Some("checkout"));
331 Ok(())
332 }
333
334 #[tokio::test]
335 async fn workflow_started_refreshes_the_cached_type_inline()
336 -> Result<(), Box<dyn std::error::Error>> {
337 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
338 let ownership = StaticWorkflowNamespaces::default();
339 ownership.record_with_type(own.clone(), "tenant-a", "checkout")?;
340 let mut gate =
341 NamespaceEventGate::new(resolver(ownership), "tenant-a".to_owned(), capacity(8)?);
342
343 let first = gate.admit(&event(1, &own)?).await?;
344 let GateVerdict::Permitted { workflow_type } = first else {
345 return Err("owned workflow must be permitted".into());
346 };
347 assert_eq!(workflow_type.as_deref(), Some("checkout"));
348
349 let started_verdict = gate.admit(&started(2, &own, "checkout-v2")?).await?;
352 let GateVerdict::Permitted { workflow_type } = started_verdict else {
353 return Err("owned workflow must be permitted".into());
354 };
355 assert_eq!(workflow_type.as_deref(), Some("checkout-v2"));
356
357 let after = gate.admit(&event(3, &own)?).await?;
358 let GateVerdict::Permitted { workflow_type } = after else {
359 return Err("owned workflow must be permitted".into());
360 };
361 assert_eq!(workflow_type.as_deref(), Some("checkout-v2"));
362 Ok(())
363 }
364
365 struct CountingOwnership {
368 inner: StaticWorkflowNamespaces,
369 reads: std::sync::Arc<std::sync::atomic::AtomicUsize>,
370 fail_after: usize,
371 }
372
373 #[async_trait]
374 impl WorkflowNamespaceSource for CountingOwnership {
375 async fn workflow_attribution(
376 &self,
377 workflow_id: &WorkflowId,
378 ) -> Result<Option<WorkflowAttribution>, ServerError> {
379 let reads = self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
380 if reads >= self.fail_after {
381 return Err(ServerError::Config {
382 message: "ownership source unavailable".to_owned(),
383 });
384 }
385 self.inner.workflow_attribution(workflow_id).await
386 }
387 }
388
389 fn counting_resolver(
390 inner: StaticWorkflowNamespaces,
391 fail_after: usize,
392 ) -> (
393 NamespaceResolver,
394 std::sync::Arc<std::sync::atomic::AtomicUsize>,
395 ) {
396 let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
397 let counting = CountingOwnership {
398 inner,
399 reads: std::sync::Arc::clone(&reads),
400 fail_after,
401 };
402 (
403 NamespaceResolver::authorization_only(
404 NamespaceMode::SharedEngine,
405 counting,
406 StaticScheduleNamespaces::default(),
407 ),
408 reads,
409 )
410 }
411
412 #[tokio::test]
413 async fn verdicts_are_cached_per_workflow() -> Result<(), Box<dyn std::error::Error>> {
414 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
415 let ownership = StaticWorkflowNamespaces::default();
416 ownership.record(own.clone(), "tenant-a")?;
417 let (resolver, _reads) = counting_resolver(ownership, 1);
418 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(8)?);
419
420 assert!(permitted(&gate.admit(&event(1, &own)?).await?));
422 assert!(permitted(&gate.admit(&event(2, &own)?).await?));
423 Ok(())
424 }
425
426 #[tokio::test]
432 async fn verdict_cache_is_bounded_and_eviction_rereads_consistently()
433 -> Result<(), Box<dyn std::error::Error>> {
434 let first = WorkflowId::new(uuid::Uuid::from_u128(1));
435 let second = WorkflowId::new(uuid::Uuid::from_u128(2));
436 let third = WorkflowId::new(uuid::Uuid::from_u128(3));
437 let ownership = StaticWorkflowNamespaces::default();
438 ownership.record(first.clone(), "tenant-a")?;
439 ownership.record(second.clone(), "tenant-b")?;
440 ownership.record(third.clone(), "tenant-a")?;
441 let (resolver, reads) = counting_resolver(ownership, usize::MAX);
442 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(2)?);
443
444 assert!(permitted(&gate.admit(&event(1, &first)?).await?));
445 assert_eq!(
446 gate.admit(&event(1, &second)?).await?,
447 GateVerdict::Filtered
448 );
449 assert!(permitted(&gate.admit(&event(1, &third)?).await?));
451 assert_eq!(gate.verdicts.len(), 2, "cache must never exceed its bound");
452 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3);
453
454 assert!(permitted(&gate.admit(&event(2, &first)?).await?));
457 assert_eq!(gate.verdicts.len(), 2, "cache must never exceed its bound");
458 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 4);
459 Ok(())
460 }
461
462 #[tokio::test]
465 async fn lru_eviction_respects_recency() -> Result<(), Box<dyn std::error::Error>> {
466 let first = WorkflowId::new(uuid::Uuid::from_u128(1));
467 let second = WorkflowId::new(uuid::Uuid::from_u128(2));
468 let third = WorkflowId::new(uuid::Uuid::from_u128(3));
469 let ownership = StaticWorkflowNamespaces::default();
470 ownership.record(first.clone(), "tenant-a")?;
471 ownership.record(second.clone(), "tenant-a")?;
472 ownership.record(third.clone(), "tenant-a")?;
473 let (resolver, reads) = counting_resolver(ownership, usize::MAX);
474 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(2)?);
475
476 assert!(permitted(&gate.admit(&event(1, &first)?).await?));
477 assert!(permitted(&gate.admit(&event(1, &second)?).await?));
478 assert!(permitted(&gate.admit(&event(2, &first)?).await?));
480 assert!(permitted(&gate.admit(&event(1, &third)?).await?));
481 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3);
482
483 assert!(permitted(&gate.admit(&event(3, &first)?).await?));
485 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 3);
486 assert!(permitted(&gate.admit(&event(2, &second)?).await?));
488 assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 4);
489 Ok(())
490 }
491
492 #[tokio::test]
493 async fn pre_seeded_target_never_consults_the_ownership_source()
494 -> Result<(), Box<dyn std::error::Error>> {
495 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
496 let (resolver, _reads) = counting_resolver(StaticWorkflowNamespaces::default(), 0);
497 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(8)?);
498 gate.allow(own.clone());
499
500 assert!(permitted(&gate.admit(&event(1, &own)?).await?));
501 Ok(())
502 }
503
504 #[tokio::test]
505 async fn ownership_read_failure_propagates_instead_of_guessing()
506 -> Result<(), Box<dyn std::error::Error>> {
507 let own = WorkflowId::new(uuid::Uuid::from_u128(1));
508 let (resolver, _reads) = counting_resolver(StaticWorkflowNamespaces::default(), 0);
509 let mut gate = NamespaceEventGate::new(resolver, "tenant-a".to_owned(), capacity(8)?);
510
511 let error = gate.admit(&event(1, &own)?).await.err();
512 assert!(matches!(error, Some(ServerError::Config { .. })));
513 Ok(())
514 }
515}