1use std::any::Any;
15use std::collections::BTreeMap;
16use std::collections::BTreeSet;
17use std::collections::HashMap;
18use std::path::PathBuf;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::sync::{Arc, Mutex, OnceLock};
21use std::time::Duration;
22
23use async_trait::async_trait;
24use camel_api::CamelError;
25
26pub trait TemplateReloadStaged: Send {
34 fn into_any(self: Box<Self>) -> Box<dyn Any>;
36}
37
38type StagedBuild = (Box<dyn TemplateReloadStaged>, u64);
40
41#[async_trait]
48pub trait TemplateReloadTarget: Send + Sync {
49 fn route_id(&self) -> &str;
51 fn reload_timeout(&self) -> Duration;
55 fn current_generation(&self) -> u64;
57 fn watch_sources(&self) -> Vec<PathBuf>;
65 async fn build(&self) -> Result<(Box<dyn TemplateReloadStaged>, u64), CamelError>;
69 fn commit(&self, staged: Box<dyn TemplateReloadStaged>);
72}
73
74struct RegisteredTarget {
76 id: u64,
77 target: Arc<dyn TemplateReloadTarget>,
78}
79
80static NEXT_ID: AtomicU64 = AtomicU64::new(1);
82
83fn next_id() -> u64 {
84 NEXT_ID.fetch_add(1, Ordering::Relaxed)
85}
86
87pub struct TemplateReloadRegistry {
89 handlers: Mutex<Vec<RegisteredTarget>>,
90 route_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
91}
92
93impl Default for TemplateReloadRegistry {
94 fn default() -> Self {
95 Self {
96 handlers: Mutex::new(Vec::new()),
97 route_locks: Mutex::new(HashMap::new()),
98 }
99 }
100}
101
102impl TemplateReloadRegistry {
103 pub fn global() -> &'static TemplateReloadRegistry {
105 static INSTANCE: OnceLock<TemplateReloadRegistry> = OnceLock::new();
106 INSTANCE.get_or_init(TemplateReloadRegistry::default)
107 }
108
109 pub fn register(&'static self, target: Arc<dyn TemplateReloadTarget>) -> RegistrationGuard {
115 let id = next_id();
116 {
117 let mut guard = self
118 .handlers
119 .lock()
120 .expect("TemplateReloadRegistry handlers lock poisoned"); guard.push(RegisteredTarget { id, target });
122 }
123 RegistrationGuard { id, registry: self }
124 }
125
126 pub fn find_all(&self, route_id: &str) -> Vec<Arc<dyn TemplateReloadTarget>> {
129 let guard = self
130 .handlers
131 .lock()
132 .expect("TemplateReloadRegistry handlers lock poisoned"); guard
134 .iter()
135 .filter(|t| t.target.route_id() == route_id)
136 .map(|t| Arc::clone(&t.target))
137 .collect()
138 }
139
140 pub fn route_watch_sources(&self) -> Vec<(String, Vec<PathBuf>)> {
146 let guard = self
147 .handlers
148 .lock()
149 .expect("TemplateReloadRegistry handlers lock poisoned"); let mut by_route: BTreeMap<String, BTreeSet<PathBuf>> = BTreeMap::new();
151 for registered in guard.iter() {
152 by_route
153 .entry(registered.target.route_id().to_string())
154 .or_default()
155 .extend(registered.target.watch_sources());
156 }
157 by_route
158 .into_iter()
159 .map(|(route, sources)| (route, sources.into_iter().collect()))
160 .collect()
161 }
162
163 fn remove(&self, id: u64) {
167 let mut guard = self
168 .handlers
169 .lock()
170 .expect("TemplateReloadRegistry handlers lock poisoned"); guard.retain(|t| t.id != id);
172 }
173
174 fn route_lock(&self, route_id: &str) -> Arc<tokio::sync::Mutex<()>> {
178 let mut guard = self
179 .route_locks
180 .lock()
181 .expect("TemplateReloadRegistry route_locks lock poisoned"); guard
183 .entry(route_id.to_string())
184 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
185 .clone()
186 }
187
188 pub async fn reload_route(&self, route_id: &str) -> Result<(), CamelError> {
205 let route_lock = self.route_lock(route_id);
209 let _route_guard = route_lock.lock().await;
210
211 let targets = self.find_all(route_id);
212 if targets.is_empty() {
213 return Err(CamelError::Config(format!(
214 "no template target for route '{route_id}'"
215 )));
216 }
217
218 let timeout = targets
220 .iter()
221 .map(|t| t.reload_timeout())
222 .min()
223 .unwrap_or(Duration::from_millis(5000));
224
225 tokio::time::timeout(timeout, async {
226 let built = futures::future::join_all(targets.iter().map(|t| t.build())).await;
229 let staged: Vec<StagedBuild> = built.into_iter().collect::<Result<_, _>>()?;
231
232 for (target, (_set, read_gen)) in targets.iter().zip(&staged) {
236 if *read_gen != target.current_generation() {
237 return Err(CamelError::TemplateReload("stale generation".to_string()));
238 }
239 }
240
241 for (target, (set, _)) in targets.into_iter().zip(staged) {
244 target.commit(set);
245 }
246 Ok(())
247 })
248 .await
249 .map_err(|_| CamelError::TemplateReload("reload timeout".to_string()))?
250 }
251}
252
253pub struct RegistrationGuard {
257 id: u64,
258 registry: &'static TemplateReloadRegistry,
259}
260
261impl Drop for RegistrationGuard {
262 fn drop(&mut self) {
263 self.registry.remove(self.id);
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use std::sync::Mutex as StdMutex;
271 use std::sync::atomic::{AtomicUsize, Ordering};
272
273 struct FakeStaged {
275 read_generation: u64,
276 }
277 impl TemplateReloadStaged for FakeStaged {
278 fn into_any(self: Box<Self>) -> Box<dyn Any> {
280 self
281 }
282 }
283
284 #[derive(Clone)]
286 enum BuildMode {
287 Ok,
289 Err,
291 Sleep(Duration),
293 Stale,
295 }
296
297 #[derive(Default)]
299 struct FakeState {
300 generation: AtomicU64,
301 commit_calls: AtomicUsize,
302 build_calls: AtomicUsize,
303 }
304
305 struct FakeTarget {
306 route: String,
307 timeout: Duration,
308 state: Arc<FakeState>,
309 mode: StdMutex<BuildMode>,
310 events: Option<Arc<StdMutex<Vec<&'static str>>>>,
312 sources: Vec<PathBuf>,
314 }
315
316 impl FakeTarget {
317 fn new(route: &str) -> Arc<Self> {
318 Self::new_with_sources(route, Vec::new())
319 }
320
321 fn new_with_sources(route: &str, sources: Vec<PathBuf>) -> Arc<Self> {
322 Arc::new(Self {
323 route: route.to_string(),
324 timeout: Duration::from_secs(5),
325 state: Arc::new(FakeState::default()),
326 mode: StdMutex::new(BuildMode::Ok),
327 events: None,
328 sources,
329 })
330 }
331
332 fn set_mode(&self, mode: BuildMode) {
333 *self.mode.lock().unwrap() = mode;
334 }
335
336 fn as_dyn(self: &Arc<Self>) -> Arc<dyn TemplateReloadTarget> {
338 let concrete: Arc<Self> = Arc::clone(self);
342 concrete
343 }
344 }
345
346 #[async_trait]
347 impl TemplateReloadTarget for FakeTarget {
348 fn route_id(&self) -> &str {
349 &self.route
350 }
351 fn reload_timeout(&self) -> Duration {
352 self.timeout
353 }
354 fn current_generation(&self) -> u64 {
355 self.state.generation.load(Ordering::SeqCst)
356 }
357 async fn build(&self) -> Result<(Box<dyn TemplateReloadStaged>, u64), CamelError> {
358 self.state.build_calls.fetch_add(1, Ordering::SeqCst);
359 if let Some(ev) = &self.events {
360 ev.lock().unwrap().push("start");
361 }
362 let mode = self.mode.lock().unwrap().clone();
363 match mode {
364 BuildMode::Err => {
365 if let Some(ev) = &self.events {
366 ev.lock().unwrap().push("end");
367 }
368 return Err(CamelError::TemplateReload("fake build failed".to_string()));
369 }
370 BuildMode::Sleep(d) => {
371 tokio::time::sleep(d).await;
372 }
373 BuildMode::Ok | BuildMode::Stale => {
374 tokio::task::yield_now().await;
376 }
377 }
378 let read_gen = match mode {
379 BuildMode::Stale => self.state.generation.fetch_add(1, Ordering::SeqCst),
382 _ => self.state.generation.load(Ordering::SeqCst),
383 };
384 if let Some(ev) = &self.events {
385 ev.lock().unwrap().push("end");
386 }
387 Ok((
388 Box::new(FakeStaged {
389 read_generation: read_gen,
390 }),
391 read_gen,
392 ))
393 }
394 fn commit(&self, staged: Box<dyn TemplateReloadStaged>) {
395 let concrete = staged.into_any().downcast::<FakeStaged>().unwrap();
397 assert_eq!(
398 concrete.read_generation,
399 self.state.generation.load(Ordering::SeqCst)
400 );
401 self.state.commit_calls.fetch_add(1, Ordering::SeqCst);
402 self.state.generation.fetch_add(1, Ordering::SeqCst);
403 }
404 fn watch_sources(&self) -> Vec<PathBuf> {
405 self.sources.clone()
406 }
407 }
408
409 #[test]
410 fn registry_register_find_all_remove() {
411 let reg = TemplateReloadRegistry::global();
412 let route = "test-register-find-all-remove";
413 let target = FakeTarget::new(route);
414 let _guard = reg.register(target.as_dyn());
415 assert_eq!(reg.find_all(route).len(), 1);
416 drop(_guard);
417 assert_eq!(reg.find_all(route).len(), 0);
418 }
419
420 #[tokio::test]
421 async fn reload_route_all_or_nothing() {
422 let reg = TemplateReloadRegistry::global();
423 let route = "test-all-or-nothing";
424 let ok = FakeTarget::new(route);
425 let err = FakeTarget::new(route);
426 err.set_mode(BuildMode::Err);
427 let g1 = reg.register(ok.as_dyn());
428 let g2 = reg.register(err.as_dyn());
429
430 let res = reg.reload_route(route).await;
431 assert!(res.is_err(), "expected reload to fail");
432 assert_eq!(
433 ok.state.commit_calls.load(Ordering::SeqCst),
434 0,
435 "OK target must NOT be committed"
436 );
437 assert_eq!(
438 err.state.commit_calls.load(Ordering::SeqCst),
439 0,
440 "Err target must NOT be committed"
441 );
442 assert_eq!(
443 ok.state.generation.load(Ordering::SeqCst),
444 0,
445 "prior generation retained"
446 );
447 drop(g1);
448 drop(g2);
449 }
450
451 #[tokio::test]
452 async fn reload_route_commits_all_on_success() {
453 let reg = TemplateReloadRegistry::global();
454 let route = "test-commits-all-on-success";
455 let a = FakeTarget::new(route);
456 let b = FakeTarget::new(route);
457 let ga = reg.register(a.as_dyn());
458 let gb = reg.register(b.as_dyn());
459
460 let res = reg.reload_route(route).await;
461 assert!(res.is_ok(), "expected reload to succeed: {:?}", res);
462 assert_eq!(a.state.commit_calls.load(Ordering::SeqCst), 1);
463 assert_eq!(b.state.commit_calls.load(Ordering::SeqCst), 1);
464 assert_eq!(a.state.generation.load(Ordering::SeqCst), 1);
465 assert_eq!(b.state.generation.load(Ordering::SeqCst), 1);
466 drop(ga);
467 drop(gb);
468 }
469
470 #[tokio::test]
471 async fn reload_route_timeout_no_commit() {
472 let reg = TemplateReloadRegistry::global();
473 let route = "test-timeout-no-commit";
474 let slow = Arc::new(FakeTarget {
476 route: route.to_string(),
477 timeout: Duration::from_millis(40),
478 state: Arc::new(FakeState::default()),
479 mode: StdMutex::new(BuildMode::Sleep(Duration::from_millis(2_000))),
480 events: None,
481 sources: Vec::new(),
482 });
483 let g = reg.register(slow.as_dyn());
484
485 let res = reg.reload_route(route).await;
486 assert!(
487 matches!(res, Err(CamelError::TemplateReload(_))),
488 "expected TemplateReload timeout error, got {res:?}"
489 );
490 assert_eq!(
491 slow.state.commit_calls.load(Ordering::SeqCst),
492 0,
493 "commit must never be called on timeout"
494 );
495 drop(g);
496 }
497
498 #[tokio::test]
499 async fn reload_route_rejects_stale_no_commit() {
500 let reg = TemplateReloadRegistry::global();
501 let route = "test-rejects-stale-no-commit";
502 let target = FakeTarget::new(route);
503 target.set_mode(BuildMode::Stale);
504 let g = reg.register(target.as_dyn());
505
506 let res = reg.reload_route(route).await;
507 assert!(
508 matches!(res, Err(CamelError::TemplateReload(_))),
509 "expected TemplateReload stale error, got {res:?}"
510 );
511 assert_eq!(
512 target.state.commit_calls.load(Ordering::SeqCst),
513 0,
514 "commit must never be called on stale rejection"
515 );
516 drop(g);
517 }
518
519 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
520 async fn reload_route_serializes_concurrent() {
521 let reg = TemplateReloadRegistry::global();
522 let route = "test-serializes-concurrent";
523 let events: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
524 let target = Arc::new(FakeTarget {
525 route: route.to_string(),
526 timeout: Duration::from_secs(5),
527 state: Arc::new(FakeState::default()),
528 mode: StdMutex::new(BuildMode::Ok),
529 events: Some(Arc::clone(&events)),
530 sources: Vec::new(),
531 });
532 let g = reg.register(target.as_dyn());
533
534 let h1 = tokio::spawn(async move { reg.reload_route(route).await });
536 let h2 = tokio::spawn(async move { reg.reload_route(route).await });
537 let (r1, r2) = tokio::join!(h1, h2);
538 r1.unwrap().unwrap();
539 r2.unwrap().unwrap();
540
541 let evs = events.lock().unwrap().clone();
544 assert_eq!(
545 evs,
546 vec!["start", "end", "start", "end"],
547 "per-route mutex must serialize concurrent reload_route"
548 );
549 assert_eq!(target.state.commit_calls.load(Ordering::SeqCst), 2);
550 drop(g);
551 }
552
553 fn fresh_registry() -> &'static TemplateReloadRegistry {
556 Box::leak(Box::new(TemplateReloadRegistry::default()))
557 }
558
559 #[test]
560 fn route_watch_sources_merges_and_dedups_per_route() {
561 let reg = fresh_registry();
562 let r1a = FakeTarget::new_with_sources(
563 "r1",
564 vec![PathBuf::from("/tpl/b.jinja"), PathBuf::from("/tpl/a.jinja")],
565 );
566 let r1b = FakeTarget::new_with_sources(
567 "r1",
568 vec![PathBuf::from("/tpl/b.jinja"), PathBuf::from("/tpl/c.jinja")],
569 );
570 let r2 = FakeTarget::new_with_sources("r2", vec![PathBuf::from("/tpl/x.jinja")]);
571 let g1 = reg.register(r1a.as_dyn());
572 let g2 = reg.register(r1b.as_dyn());
573 let g3 = reg.register(r2.as_dyn());
574
575 let sources = reg.route_watch_sources();
576 assert_eq!(sources.len(), 2, "one entry per route id: {sources:?}");
577 assert_eq!(sources[0].0, "r1");
578 assert_eq!(
579 sources[0].1,
580 vec![
581 PathBuf::from("/tpl/a.jinja"),
582 PathBuf::from("/tpl/b.jinja"),
583 PathBuf::from("/tpl/c.jinja"),
584 ],
585 "r1 sources merged, deduplicated and sorted"
586 );
587 assert_eq!(sources[1].0, "r2");
588 assert_eq!(sources[1].1, vec![PathBuf::from("/tpl/x.jinja")]);
589
590 drop(g1);
591 drop(g2);
592 drop(g3);
593 }
594
595 #[test]
596 fn route_watch_sources_empty_when_no_targets() {
597 let reg = TemplateReloadRegistry::default();
598 assert!(reg.route_watch_sources().is_empty());
599 }
600
601 #[test]
602 fn route_watch_sources_excludes_unregistered() {
603 let reg = fresh_registry();
604 let target = FakeTarget::new_with_sources("gone", vec![PathBuf::from("/tpl/a.jinja")]);
605 let guard = reg.register(target.as_dyn());
606 assert_eq!(reg.route_watch_sources().len(), 1);
607
608 drop(guard);
609 assert!(
610 reg.route_watch_sources().is_empty(),
611 "dropped registration must not be enumerated"
612 );
613 }
614}