1use 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#[async_trait]
30pub trait ScheduleNamespaceSource: Send + Sync {
31 async fn schedule_namespace(
39 &self,
40 schedule_id: &ScheduleId,
41 ) -> Result<Option<String>, ServerError>;
42}
43
44pub(crate) struct HistoryScheduleNamespaceSource {
52 engine: Arc<Engine>,
53}
54
55impl HistoryScheduleNamespaceSource {
56 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 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 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#[derive(Clone, Default)]
105pub struct StaticScheduleNamespaces {
106 inner: Arc<RwLock<HashMap<ScheduleId, String>>>,
107}
108
109impl StaticScheduleNamespaces {
110 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 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 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 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}