Skip to main content

camel_component_api/
template_reload.rs

1//! Process-global registry of template reload targets + the erased reload
2//! target contract.
3//!
4//! This is the dependency-inversion seam (mirror of
5//! [`crate::tls_source::TlsReloadRegistry`]): it lives in `camel-component-api`
6//! so that both `camel-core` (the RuntimeBus) and `camel-template` (the
7//! ReloadHandler impl) can see it, WITHOUT `camel-core` depending on
8//! `camel-template`.
9//!
10//! The ONLY reload path is [`TemplateReloadRegistry::reload_route`], which is
11//! all-or-nothing: it builds every target for a route, validates every staged
12//! generation, then commits — guaranteeing atomicity structurally.
13
14use 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
26/// Erased staged-set marker with an owned-downcast accessor.
27///
28/// `Box<dyn TemplateReloadStaged>` has no inherent `downcast`; `into_any`
29/// returns `Box<dyn Any>`, which does. This is the standard object-downcast
30/// idiom: a concrete staged type `T` impls `into_any` by returning `self`
31/// (`Box<T>` coerces to `Box<dyn Any>` when `T: 'static`). Consumers then call
32/// `staged.into_any().downcast::<T>()`.
33pub trait TemplateReloadStaged: Send {
34    /// Convert this boxed trait object into a `Box<dyn Any>` for downcasting.
35    fn into_any(self: Box<Self>) -> Box<dyn Any>;
36}
37
38/// A built staged set paired with the generation read at build time.
39type StagedBuild = (Box<dyn TemplateReloadStaged>, u64);
40
41/// One reload target per registered template producer endpoint.
42///
43/// There is NO single-producer `reload()` on this trait — the only reload path
44/// is [`TemplateReloadRegistry::reload_route`]. `commit` is infallible (`()`)
45/// because it is only ever reached after `reload_route` has validated every
46/// staged generation, so all-or-nothing holds structurally.
47#[async_trait]
48pub trait TemplateReloadTarget: Send + Sync {
49    /// Route id this target serves.
50    fn route_id(&self) -> &str;
51    /// Per-target deadline for a reload. `reload_route` uses the TIGHTEST
52    /// (minimum) across all targets for the route, so registration order does
53    /// not define the route deadline.
54    fn reload_timeout(&self) -> Duration;
55    /// Current committed generation (bumped on each successful commit).
56    fn current_generation(&self) -> u64;
57    /// Unique absolute FILE paths whose change should reload this target.
58    ///
59    /// Reports the last COMMITTED dependency closure only: a failed build or a
60    /// staged-but-uncommitted set never contributes. Every entry is a file —
61    /// never a directory (a confinement root is not reported) — and entries
62    /// are deduplicated. Callers that merge targets sharing a route apply
63    /// [`TemplateReloadRegistry::route_watch_sources`].
64    fn watch_sources(&self) -> Vec<PathBuf>;
65    /// Build a staged set against the current sources. Returns the staged set
66    /// and the generation read at build time. May fail (invalid source); the
67    /// prior set is retained on failure.
68    async fn build(&self) -> Result<(Box<dyn TemplateReloadStaged>, u64), CamelError>;
69    /// Commit a previously-built staged set (infallible). Only called by
70    /// `reload_route` after validation, so no recheck is needed here.
71    fn commit(&self, staged: Box<dyn TemplateReloadStaged>);
72}
73
74/// A registered target plus its unique id.
75struct RegisteredTarget {
76    id: u64,
77    target: Arc<dyn TemplateReloadTarget>,
78}
79
80/// Monotonic id source for registrations (unique per process).
81static NEXT_ID: AtomicU64 = AtomicU64::new(1);
82
83fn next_id() -> u64 {
84    NEXT_ID.fetch_add(1, Ordering::Relaxed)
85}
86
87/// Process-global registry of template reload targets.
88pub 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    /// Process-global singleton (mirror of `TlsReloadRegistry::global`).
104    pub fn global() -> &'static TemplateReloadRegistry {
105        static INSTANCE: OnceLock<TemplateReloadRegistry> = OnceLock::new();
106        INSTANCE.get_or_init(TemplateReloadRegistry::default)
107    }
108
109    /// Register a target. The returned guard unregisters on drop (RAII).
110    ///
111    /// Only callable on the [`global`](Self::global) singleton: the guard
112    /// retains a `&'static` reference so it can evict its entry on drop from
113    /// any context.
114    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"); // allow-unwrap
121            guard.push(RegisteredTarget { id, target });
122        }
123        RegistrationGuard { id, registry: self }
124    }
125
126    /// All targets registered for `route_id`, in registration order.
127    /// `pub` so integration tests in `camel-template` can assert registration.
128    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"); // allow-unwrap
133        guard
134            .iter()
135            .filter(|t| t.target.route_id() == route_id)
136            .map(|t| Arc::clone(&t.target))
137            .collect()
138    }
139
140    /// Enumerate every registered target's watch sources, grouped per route.
141    ///
142    /// One entry per route id, sorted by route id. Multiple targets on the
143    /// same route are merged: paths are deduplicated and sorted. Empty when
144    /// no targets are registered.
145    pub fn route_watch_sources(&self) -> Vec<(String, Vec<PathBuf>)> {
146        let guard = self
147            .handlers
148            .lock()
149            .expect("TemplateReloadRegistry handlers lock poisoned"); // allow-unwrap
150        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    /// Remove the registration with this id (called by [`RegistrationGuard`]'s
164    /// Drop). Removes by `id`, NOT route_id — a stopped-generation guard cannot
165    /// evict a restarted-generation registration.
166    fn remove(&self, id: u64) {
167        let mut guard = self
168            .handlers
169            .lock()
170            .expect("TemplateReloadRegistry handlers lock poisoned"); // allow-unwrap
171        guard.retain(|t| t.id != id);
172    }
173
174    /// Get-or-insert the per-route async lock. The std guard is dropped before
175    /// this returns, so the returned `Arc<tokio::sync::Mutex<_>>` is the only
176    /// thing held across `.await` (no std lock held across await).
177    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"); // allow-unwrap
182        guard
183            .entry(route_id.to_string())
184            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
185            .clone()
186    }
187
188    /// Reload ALL targets for `route_id` — all-or-nothing.
189    ///
190    /// Acquires the per-route `tokio::sync::Mutex` (serializing concurrent
191    /// `reload_route` for the SAME route; different routes run in parallel).
192    /// Since there is no other reload path, no concurrent generation bump is
193    /// possible. Phases, in strict order:
194    ///
195    /// 1. **Build** — run every target's `build` concurrently; if ANY returns
196    ///    `Err`, abort (nothing committed).
197    /// 2. **Validate** — every staged `read_gen` must equal the target's
198    ///    `current_generation`; any mismatch aborts (nothing committed).
199    /// 3. **Commit** — only reached if all builds and validations succeeded;
200    ///    `commit` is infallible, so atomicity is structural.
201    ///
202    /// The whole sequence is bounded by the tightest target deadline; a
203    /// timeout returns `Err` and the dropped build futures never commit.
204    pub async fn reload_route(&self, route_id: &str) -> Result<(), CamelError> {
205        // Serialize concurrent reload_route for the SAME route. Different routes
206        // get distinct locks and run in parallel. Holding this tokio::sync::Mutex
207        // across the build/commit awaits is intentional and safe.
208        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        // TIGHTEST deadline wins — registration order must not set the deadline.
219        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            // Build phase: run every build concurrently. join_all preserves
227            // input order, so targets[i] aligns with staged[i].
228            let built = futures::future::join_all(targets.iter().map(|t| t.build())).await;
229            // ANY Err → abort; nothing committed (all-or-nothing).
230            let staged: Vec<StagedBuild> = built.into_iter().collect::<Result<_, _>>()?;
231
232            // Validate phase (structural stale-guard). Under the per-route
233            // mutex with no other reload path this never fires in practice —
234            // it is the guarantee that a delayed stale build cannot swap.
235            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            // Commit phase (infallible). Only reached if every build and every
242            // validation succeeded.
243            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
253/// RAII guard returned by [`TemplateReloadRegistry::register`]. Removes the
254/// target on drop by its unique `id` (NOT route_id) — so a stopped-generation
255/// guard cannot evict a restarted-generation registration.
256pub 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    /// Concrete staged type carrying the generation read at build time.
274    struct FakeStaged {
275        read_generation: u64,
276    }
277    impl TemplateReloadStaged for FakeStaged {
278        // `Box<FakeStaged>` coerces to `Box<dyn Any>` (FakeStaged: 'static).
279        fn into_any(self: Box<Self>) -> Box<dyn Any> {
280            self
281        }
282    }
283
284    /// What a fake's `build()` should do.
285    #[derive(Clone)]
286    enum BuildMode {
287        /// Succeed, returning the current generation.
288        Ok,
289        /// Fail immediately.
290        Err,
291        /// Sleep before succeeding (for the timeout test).
292        Sleep(Duration),
293        /// Read G, then bump current to G+1 and return read_gen=G (stale).
294        Stale,
295    }
296
297    /// Shared, observable state for a fake target.
298    #[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        /// Optional event recorder for the serialize test.
311        events: Option<Arc<StdMutex<Vec<&'static str>>>>,
312        /// Watch sources reported by this fake.
313        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        /// Coerce to the erased trait-object Arc that `register` expects.
337        fn as_dyn(self: &Arc<Self>) -> Arc<dyn TemplateReloadTarget> {
338            // Bind to a concrete-typed local so CoerceUnsized fires at the
339            // return site (inference would otherwise pin `Arc::clone` to the
340            // trait object and fail).
341            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                    // yield to encourage interleaving when not serialized.
375                    tokio::task::yield_now().await;
376                }
377            }
378            let read_gen = match mode {
379                // Read G, then bump current to G+1 BEFORE returning, so the
380                // validate phase sees read_gen(G) != current(G+1).
381                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            // Exercise the downcast idiom end-to-end.
396            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        // Tighten the deadline (40ms) and make build overrun it (2s sleep).
475        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        // Spawn two concurrent reload_route for the SAME route.
535        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        // If serialized by the per-route mutex, build calls never interleave:
542        // the sequence must be start,end,start,end (NOT start,start,end,end).
543        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    /// A fresh, leaked registry so tests do not observe global registrations
554    /// from other tests running in parallel (`register` needs `&'static self`).
555    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}