Skip to main content

aion_server/namespace/
schedule_source.rs

1//! Durable schedule→namespace ownership sources.
2//!
3//! Schedule ownership is a projection of the schedule coordinator's durable
4//! event history: the server force-stamps the authorized namespace into the
5//! schedule config before the engine records `ScheduleCreated`, and ownership
6//! is folded back out of the first `ScheduleCreated` event recorded for a
7//! schedule id. Deriving from creation rather than the latest config makes
8//! ownership immutable by construction — no update-path bug can ever migrate a
9//! schedule between tenants. A `ScheduleCreated` whose config carries no
10//! namespace attribute resolves to no owner and is therefore invisible through
11//! every namespaced server API.
12
13use std::collections::HashMap;
14use std::sync::{Arc, RwLock};
15
16use aion::Engine;
17use aion_core::{Event, ScheduleId, SearchAttributeValue};
18use async_trait::async_trait;
19
20use crate::error::ServerError;
21
22use super::resolver::NAMESPACE_ATTRIBUTE;
23
24/// Durable source of schedule→namespace ownership facts.
25///
26/// The production implementation projects ownership from the schedule
27/// coordinator's recorded event history; tests substitute a static fixture to
28/// prove adapter-boundary denials without an engine.
29#[async_trait]
30pub trait ScheduleNamespaceSource: Send + Sync {
31    /// Returns the namespace recorded at schedule creation, or [`None`] when
32    /// the schedule is unknown or its creation config recorded no namespace
33    /// attribute.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`ServerError`] when the underlying ownership data cannot be read.
38    async fn schedule_namespace(
39        &self,
40        schedule_id: &ScheduleId,
41    ) -> Result<Option<String>, ServerError>;
42}
43
44/// Production ownership source: folds the `aion.namespace` search attribute
45/// out of the first `ScheduleCreated` event in the schedule coordinator's
46/// durable history.
47///
48/// `ScheduleDeleted` deliberately does not erase ownership: a foreign probe of
49/// a deleted schedule must still see the guard's anti-existence-leak `NotFound`,
50/// while the owner's probe falls through to the engine's `ScheduleNotFound`.
51pub(crate) struct HistoryScheduleNamespaceSource {
52    engine: Arc<Engine>,
53}
54
55impl HistoryScheduleNamespaceSource {
56    /// Build a source over the engine whose coordinator history records schedules.
57    pub(crate) const fn new(engine: Arc<Engine>) -> Self {
58        Self { engine }
59    }
60}
61
62#[async_trait]
63impl ScheduleNamespaceSource for HistoryScheduleNamespaceSource {
64    async fn schedule_namespace(
65        &self,
66        schedule_id: &ScheduleId,
67    ) -> Result<Option<String>, ServerError> {
68        // Read-amplification note: all schedule events share one coordinator
69        // history, so this scan is O(global schedule history) per verification
70        // rather than O(one schedule's events). Correct but a candidate for a
71        // per-schedule visibility index once coordinator compaction lands.
72        let history = self
73            .engine
74            .store()
75            .read_history(self.engine.schedule_coordinator_workflow_id())
76            .await
77            .map_err(ServerError::from)?;
78        for event in &history {
79            if let Event::ScheduleCreated {
80                schedule_id: created_id,
81                config,
82                ..
83            } = event
84                && created_id == schedule_id
85            {
86                // First ScheduleCreated wins: ownership is creation-pinned.
87                return match config.search_attributes.get(NAMESPACE_ATTRIBUTE) {
88                    Some(SearchAttributeValue::String(namespace)) => Ok(Some(namespace.clone())),
89                    Some(other) => Err(ServerError::Config {
90                        message: format!(
91                            "schedule {schedule_id} recorded a non-string {NAMESPACE_ATTRIBUTE} search attribute: {other:?}"
92                        ),
93                    }),
94                    None => Ok(None),
95                };
96            }
97        }
98        Ok(None)
99    }
100}
101
102/// Static schedule→namespace fixture for adapter-boundary tests and alternate
103/// wiring that must authorize without an engine handle.
104#[derive(Clone, Default)]
105pub struct StaticScheduleNamespaces {
106    inner: Arc<RwLock<HashMap<ScheduleId, String>>>,
107}
108
109impl StaticScheduleNamespaces {
110    /// Record that a schedule is owned by a namespace.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`ServerError::LockPoisoned`] if the fixture lock was poisoned.
115    pub fn record(&self, schedule_id: ScheduleId, namespace: &str) -> Result<(), ServerError> {
116        let mut ownership = self
117            .inner
118            .write()
119            .map_err(|_| ServerError::lock_poisoned("namespace schedule ownership"))?;
120        ownership.insert(schedule_id, namespace.to_owned());
121        Ok(())
122    }
123}
124
125#[async_trait]
126impl ScheduleNamespaceSource for StaticScheduleNamespaces {
127    async fn schedule_namespace(
128        &self,
129        schedule_id: &ScheduleId,
130    ) -> Result<Option<String>, ServerError> {
131        let ownership = self
132            .inner
133            .read()
134            .map_err(|_| ServerError::lock_poisoned("namespace schedule ownership"))?;
135        Ok(ownership.get(schedule_id).cloned())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use std::collections::HashMap;
142    use std::sync::Arc;
143    use std::time::Duration;
144
145    use aion::{Engine, EngineBuilder};
146    use aion_core::{
147        CatchUpPolicy, Event, EventEnvelope, OverlapPolicy, Payload, ScheduleConfig, ScheduleId,
148        SearchAttributeValue, TriggerSpec,
149    };
150    use aion_store::{EventStore, InMemoryStore, WriteToken, visibility::VisibilityStore};
151    use chrono::Utc;
152    use serde_json::json;
153
154    use super::{
155        HistoryScheduleNamespaceSource, NAMESPACE_ATTRIBUTE, ScheduleNamespaceSource,
156        StaticScheduleNamespaces,
157    };
158    use crate::error::ServerError;
159    use crate::test_support::EngineUnderTest;
160
161    struct Fixture {
162        /// Held in the guard, so the fixture's engine is stopped when the
163        /// fixture is dropped rather than leaking its scheduler threads.
164        engine: EngineUnderTest,
165        store: Arc<dyn EventStore>,
166    }
167
168    async fn fixture() -> Result<Fixture, aion::EngineError> {
169        let backing = Arc::new(InMemoryStore::default());
170        let store: Arc<dyn EventStore> = backing.clone();
171        let visibility_store: Arc<dyn VisibilityStore> = backing;
172        let engine = Arc::new(
173            EngineBuilder::new()
174                .stop_drain_timeout(std::time::Duration::from_secs(5))
175                .store_arc(Arc::clone(&store))
176                .visibility_store_arc(visibility_store)
177                .scheduler_threads(1)
178                .build()
179                .await?,
180        );
181        Ok(Fixture {
182            engine: EngineUnderTest::new(engine),
183            store,
184        })
185    }
186
187    fn schedule_config(
188        attributes: HashMap<String, SearchAttributeValue>,
189    ) -> Result<ScheduleConfig, aion_core::PayloadError> {
190        Ok(ScheduleConfig {
191            trigger: TriggerSpec::Interval {
192                period: Duration::from_secs(60),
193            },
194            overlap_policy: OverlapPolicy::Skip,
195            catch_up_policy: CatchUpPolicy::Skip,
196            workflow_type: "fixture".to_owned(),
197            input: Payload::from_json(&json!({ "fixture": true }))?,
198            search_attributes: attributes,
199        })
200    }
201
202    fn namespace_attributes(namespace: &str) -> HashMap<String, SearchAttributeValue> {
203        HashMap::from([(
204            NAMESPACE_ATTRIBUTE.to_owned(),
205            SearchAttributeValue::String(namespace.to_owned()),
206        )])
207    }
208
209    fn created_event(
210        engine: &Engine,
211        seq: u64,
212        schedule_id: &ScheduleId,
213        config: ScheduleConfig,
214    ) -> Event {
215        Event::ScheduleCreated {
216            envelope: EventEnvelope {
217                seq,
218                recorded_at: Utc::now(),
219                workflow_id: engine.schedule_coordinator_workflow_id().clone(),
220            },
221            schedule_id: schedule_id.clone(),
222            config,
223        }
224    }
225
226    /// Current coordinator history head: the engine builder seeds the
227    /// coordinator workflow with its start event, so direct test appends must
228    /// continue from the recorded head rather than zero.
229    async fn coordinator_head(fixture: &Fixture) -> Result<u64, Box<dyn std::error::Error>> {
230        let history = fixture
231            .store
232            .read_history(fixture.engine.schedule_coordinator_workflow_id())
233            .await?;
234        Ok(u64::try_from(history.len())?)
235    }
236
237    async fn append_coordinator_events(
238        fixture: &Fixture,
239        events: &[Event],
240        expected_head: u64,
241    ) -> Result<(), Box<dyn std::error::Error>> {
242        fixture
243            .store
244            .append(
245                WriteToken::recorder(),
246                fixture.engine.schedule_coordinator_workflow_id(),
247                events,
248                expected_head,
249            )
250            .await?;
251        Ok(())
252    }
253
254    #[tokio::test]
255    async fn history_source_reads_namespace_from_schedule_created()
256    -> Result<(), Box<dyn std::error::Error>> {
257        let fixture = fixture().await?;
258        let head = coordinator_head(&fixture).await?;
259        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(1));
260        let event = created_event(
261            &fixture.engine,
262            head + 1,
263            &schedule_id,
264            schedule_config(namespace_attributes("tenant-a"))?,
265        );
266        append_coordinator_events(&fixture, std::slice::from_ref(&event), head).await?;
267        let source = HistoryScheduleNamespaceSource::new(fixture.engine.handle());
268
269        assert_eq!(
270            source.schedule_namespace(&schedule_id).await?,
271            Some(String::from("tenant-a"))
272        );
273        Ok(())
274    }
275
276    #[tokio::test]
277    async fn history_source_returns_none_for_unknown_and_unstamped_schedules()
278    -> Result<(), Box<dyn std::error::Error>> {
279        let fixture = fixture().await?;
280        let head = coordinator_head(&fixture).await?;
281        let unstamped = ScheduleId::new(uuid::Uuid::from_u128(2));
282        let unknown = ScheduleId::new(uuid::Uuid::from_u128(3));
283        let event = created_event(
284            &fixture.engine,
285            head + 1,
286            &unstamped,
287            schedule_config(HashMap::new())?,
288        );
289        append_coordinator_events(&fixture, std::slice::from_ref(&event), head).await?;
290        let source = HistoryScheduleNamespaceSource::new(fixture.engine.handle());
291
292        assert_eq!(source.schedule_namespace(&unstamped).await?, None);
293        assert_eq!(source.schedule_namespace(&unknown).await?, None);
294        Ok(())
295    }
296
297    #[tokio::test]
298    async fn history_source_rejects_non_string_namespace_attribute()
299    -> Result<(), Box<dyn std::error::Error>> {
300        let fixture = fixture().await?;
301        let head = coordinator_head(&fixture).await?;
302        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(4));
303        let attributes =
304            HashMap::from([(NAMESPACE_ATTRIBUTE.to_owned(), SearchAttributeValue::Int(7))]);
305        let event = created_event(
306            &fixture.engine,
307            head + 1,
308            &schedule_id,
309            schedule_config(attributes)?,
310        );
311        append_coordinator_events(&fixture, std::slice::from_ref(&event), head).await?;
312        let source = HistoryScheduleNamespaceSource::new(fixture.engine.handle());
313
314        let error = source.schedule_namespace(&schedule_id).await;
315
316        assert!(matches!(error, Err(ServerError::Config { .. })));
317        Ok(())
318    }
319
320    #[tokio::test]
321    async fn ownership_is_pinned_to_creation_not_latest_update()
322    -> Result<(), Box<dyn std::error::Error>> {
323        let fixture = fixture().await?;
324        let head = coordinator_head(&fixture).await?;
325        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(5));
326        let created = created_event(
327            &fixture.engine,
328            head + 1,
329            &schedule_id,
330            schedule_config(namespace_attributes("tenant-a"))?,
331        );
332        let updated = Event::ScheduleUpdated {
333            envelope: EventEnvelope {
334                seq: head + 2,
335                recorded_at: Utc::now(),
336                workflow_id: fixture.engine.schedule_coordinator_workflow_id().clone(),
337            },
338            schedule_id: schedule_id.clone(),
339            config: schedule_config(namespace_attributes("tenant-b"))?,
340        };
341        append_coordinator_events(&fixture, &[created, updated], head).await?;
342        let source = HistoryScheduleNamespaceSource::new(fixture.engine.handle());
343
344        assert_eq!(
345            source.schedule_namespace(&schedule_id).await?,
346            Some(String::from("tenant-a"))
347        );
348        Ok(())
349    }
350
351    #[tokio::test]
352    async fn duplicate_creations_pin_the_first_recorded_owner()
353    -> Result<(), Box<dyn std::error::Error>> {
354        // Unreachable through public APIs (schedule ids are server-generated
355        // v4 UUIDs), but the fold's lowest-sequence-wins rule is load-bearing
356        // for ownership immutability, so pin it explicitly: a second
357        // ScheduleCreated for the same id must never migrate the owner.
358        let fixture = fixture().await?;
359        let head = coordinator_head(&fixture).await?;
360        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(8));
361        let first = created_event(
362            &fixture.engine,
363            head + 1,
364            &schedule_id,
365            schedule_config(namespace_attributes("tenant-a"))?,
366        );
367        let second = created_event(
368            &fixture.engine,
369            head + 2,
370            &schedule_id,
371            schedule_config(namespace_attributes("tenant-b"))?,
372        );
373        append_coordinator_events(&fixture, &[first, second], head).await?;
374        let source = HistoryScheduleNamespaceSource::new(fixture.engine.handle());
375
376        assert_eq!(
377            source.schedule_namespace(&schedule_id).await?,
378            Some(String::from("tenant-a"))
379        );
380        Ok(())
381    }
382
383    #[tokio::test]
384    async fn deleted_schedules_keep_their_recorded_owner() -> Result<(), Box<dyn std::error::Error>>
385    {
386        let fixture = fixture().await?;
387        let head = coordinator_head(&fixture).await?;
388        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(6));
389        let created = created_event(
390            &fixture.engine,
391            head + 1,
392            &schedule_id,
393            schedule_config(namespace_attributes("tenant-a"))?,
394        );
395        let deleted = Event::ScheduleDeleted {
396            envelope: EventEnvelope {
397                seq: head + 2,
398                recorded_at: Utc::now(),
399                workflow_id: fixture.engine.schedule_coordinator_workflow_id().clone(),
400            },
401            schedule_id: schedule_id.clone(),
402        };
403        append_coordinator_events(&fixture, &[created, deleted], head).await?;
404        let source = HistoryScheduleNamespaceSource::new(fixture.engine.handle());
405
406        assert_eq!(
407            source.schedule_namespace(&schedule_id).await?,
408            Some(String::from("tenant-a"))
409        );
410        Ok(())
411    }
412
413    #[tokio::test]
414    async fn static_source_reports_recorded_namespace() -> Result<(), Box<dyn std::error::Error>> {
415        let ownership = StaticScheduleNamespaces::default();
416        let schedule_id = ScheduleId::new(uuid::Uuid::from_u128(7));
417        ownership.record(schedule_id.clone(), "tenant-a")?;
418
419        assert_eq!(
420            ownership.schedule_namespace(&schedule_id).await?,
421            Some(String::from("tenant-a"))
422        );
423        Ok(())
424    }
425}