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::HashMap;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::{Arc, Mutex, OnceLock};
18use std::time::Duration;
19
20use async_trait::async_trait;
21use camel_api::CamelError;
22
23/// Erased staged-set marker with an owned-downcast accessor.
24///
25/// `Box<dyn TemplateReloadStaged>` has no inherent `downcast`; `into_any`
26/// returns `Box<dyn Any>`, which does. This is the standard object-downcast
27/// idiom: a concrete staged type `T` impls `into_any` by returning `self`
28/// (`Box<T>` coerces to `Box<dyn Any>` when `T: 'static`). Consumers then call
29/// `staged.into_any().downcast::<T>()`.
30pub trait TemplateReloadStaged: Send {
31    /// Convert this boxed trait object into a `Box<dyn Any>` for downcasting.
32    fn into_any(self: Box<Self>) -> Box<dyn Any>;
33}
34
35/// A built staged set paired with the generation read at build time.
36type StagedBuild = (Box<dyn TemplateReloadStaged>, u64);
37
38/// One reload target per registered template producer endpoint.
39///
40/// There is NO single-producer `reload()` on this trait — the only reload path
41/// is [`TemplateReloadRegistry::reload_route`]. `commit` is infallible (`()`)
42/// because it is only ever reached after `reload_route` has validated every
43/// staged generation, so all-or-nothing holds structurally.
44#[async_trait]
45pub trait TemplateReloadTarget: Send + Sync {
46    /// Route id this target serves.
47    fn route_id(&self) -> &str;
48    /// Per-target deadline for a reload. `reload_route` uses the TIGHTEST
49    /// (minimum) across all targets for the route, so registration order does
50    /// not define the route deadline.
51    fn reload_timeout(&self) -> Duration;
52    /// Current committed generation (bumped on each successful commit).
53    fn current_generation(&self) -> u64;
54    /// Build a staged set against the current sources. Returns the staged set
55    /// and the generation read at build time. May fail (invalid source); the
56    /// prior set is retained on failure.
57    async fn build(&self) -> Result<(Box<dyn TemplateReloadStaged>, u64), CamelError>;
58    /// Commit a previously-built staged set (infallible). Only called by
59    /// `reload_route` after validation, so no recheck is needed here.
60    fn commit(&self, staged: Box<dyn TemplateReloadStaged>);
61}
62
63/// A registered target plus its unique id.
64struct RegisteredTarget {
65    id: u64,
66    target: Arc<dyn TemplateReloadTarget>,
67}
68
69/// Monotonic id source for registrations (unique per process).
70static NEXT_ID: AtomicU64 = AtomicU64::new(1);
71
72fn next_id() -> u64 {
73    NEXT_ID.fetch_add(1, Ordering::Relaxed)
74}
75
76/// Process-global registry of template reload targets.
77pub struct TemplateReloadRegistry {
78    handlers: Mutex<Vec<RegisteredTarget>>,
79    route_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
80}
81
82impl Default for TemplateReloadRegistry {
83    fn default() -> Self {
84        Self {
85            handlers: Mutex::new(Vec::new()),
86            route_locks: Mutex::new(HashMap::new()),
87        }
88    }
89}
90
91impl TemplateReloadRegistry {
92    /// Process-global singleton (mirror of `TlsReloadRegistry::global`).
93    pub fn global() -> &'static TemplateReloadRegistry {
94        static INSTANCE: OnceLock<TemplateReloadRegistry> = OnceLock::new();
95        INSTANCE.get_or_init(TemplateReloadRegistry::default)
96    }
97
98    /// Register a target. The returned guard unregisters on drop (RAII).
99    ///
100    /// Only callable on the [`global`](Self::global) singleton: the guard
101    /// retains a `&'static` reference so it can evict its entry on drop from
102    /// any context.
103    pub fn register(&'static self, target: Arc<dyn TemplateReloadTarget>) -> RegistrationGuard {
104        let id = next_id();
105        {
106            let mut guard = self
107                .handlers
108                .lock()
109                .expect("TemplateReloadRegistry handlers lock poisoned"); // allow-unwrap
110            guard.push(RegisteredTarget { id, target });
111        }
112        RegistrationGuard { id, registry: self }
113    }
114
115    /// All targets registered for `route_id`, in registration order.
116    /// `pub` so integration tests in `camel-template` can assert registration.
117    pub fn find_all(&self, route_id: &str) -> Vec<Arc<dyn TemplateReloadTarget>> {
118        let guard = self
119            .handlers
120            .lock()
121            .expect("TemplateReloadRegistry handlers lock poisoned"); // allow-unwrap
122        guard
123            .iter()
124            .filter(|t| t.target.route_id() == route_id)
125            .map(|t| Arc::clone(&t.target))
126            .collect()
127    }
128
129    /// Remove the registration with this id (called by [`RegistrationGuard`]'s
130    /// Drop). Removes by `id`, NOT route_id — a stopped-generation guard cannot
131    /// evict a restarted-generation registration.
132    fn remove(&self, id: u64) {
133        let mut guard = self
134            .handlers
135            .lock()
136            .expect("TemplateReloadRegistry handlers lock poisoned"); // allow-unwrap
137        guard.retain(|t| t.id != id);
138    }
139
140    /// Get-or-insert the per-route async lock. The std guard is dropped before
141    /// this returns, so the returned `Arc<tokio::sync::Mutex<_>>` is the only
142    /// thing held across `.await` (no std lock held across await).
143    fn route_lock(&self, route_id: &str) -> Arc<tokio::sync::Mutex<()>> {
144        let mut guard = self
145            .route_locks
146            .lock()
147            .expect("TemplateReloadRegistry route_locks lock poisoned"); // allow-unwrap
148        guard
149            .entry(route_id.to_string())
150            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
151            .clone()
152    }
153
154    /// Reload ALL targets for `route_id` — all-or-nothing.
155    ///
156    /// Acquires the per-route `tokio::sync::Mutex` (serializing concurrent
157    /// `reload_route` for the SAME route; different routes run in parallel).
158    /// Since there is no other reload path, no concurrent generation bump is
159    /// possible. Phases, in strict order:
160    ///
161    /// 1. **Build** — run every target's `build` concurrently; if ANY returns
162    ///    `Err`, abort (nothing committed).
163    /// 2. **Validate** — every staged `read_gen` must equal the target's
164    ///    `current_generation`; any mismatch aborts (nothing committed).
165    /// 3. **Commit** — only reached if all builds and validations succeeded;
166    ///    `commit` is infallible, so atomicity is structural.
167    ///
168    /// The whole sequence is bounded by the tightest target deadline; a
169    /// timeout returns `Err` and the dropped build futures never commit.
170    pub async fn reload_route(&self, route_id: &str) -> Result<(), CamelError> {
171        // Serialize concurrent reload_route for the SAME route. Different routes
172        // get distinct locks and run in parallel. Holding this tokio::sync::Mutex
173        // across the build/commit awaits is intentional and safe.
174        let route_lock = self.route_lock(route_id);
175        let _route_guard = route_lock.lock().await;
176
177        let targets = self.find_all(route_id);
178        if targets.is_empty() {
179            return Err(CamelError::Config(format!(
180                "no template target for route '{route_id}'"
181            )));
182        }
183
184        // TIGHTEST deadline wins — registration order must not set the deadline.
185        let timeout = targets
186            .iter()
187            .map(|t| t.reload_timeout())
188            .min()
189            .unwrap_or(Duration::from_millis(5000));
190
191        tokio::time::timeout(timeout, async {
192            // Build phase: run every build concurrently. join_all preserves
193            // input order, so targets[i] aligns with staged[i].
194            let built = futures::future::join_all(targets.iter().map(|t| t.build())).await;
195            // ANY Err → abort; nothing committed (all-or-nothing).
196            let staged: Vec<StagedBuild> = built.into_iter().collect::<Result<_, _>>()?;
197
198            // Validate phase (structural stale-guard). Under the per-route
199            // mutex with no other reload path this never fires in practice —
200            // it is the guarantee that a delayed stale build cannot swap.
201            for (target, (_set, read_gen)) in targets.iter().zip(&staged) {
202                if *read_gen != target.current_generation() {
203                    return Err(CamelError::TemplateReload("stale generation".to_string()));
204                }
205            }
206
207            // Commit phase (infallible). Only reached if every build and every
208            // validation succeeded.
209            for (target, (set, _)) in targets.into_iter().zip(staged) {
210                target.commit(set);
211            }
212            Ok(())
213        })
214        .await
215        .map_err(|_| CamelError::TemplateReload("reload timeout".to_string()))?
216    }
217}
218
219/// RAII guard returned by [`TemplateReloadRegistry::register`]. Removes the
220/// target on drop by its unique `id` (NOT route_id) — so a stopped-generation
221/// guard cannot evict a restarted-generation registration.
222pub struct RegistrationGuard {
223    id: u64,
224    registry: &'static TemplateReloadRegistry,
225}
226
227impl Drop for RegistrationGuard {
228    fn drop(&mut self) {
229        self.registry.remove(self.id);
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::sync::Mutex as StdMutex;
237    use std::sync::atomic::{AtomicUsize, Ordering};
238
239    /// Concrete staged type carrying the generation read at build time.
240    struct FakeStaged {
241        read_generation: u64,
242    }
243    impl TemplateReloadStaged for FakeStaged {
244        // `Box<FakeStaged>` coerces to `Box<dyn Any>` (FakeStaged: 'static).
245        fn into_any(self: Box<Self>) -> Box<dyn Any> {
246            self
247        }
248    }
249
250    /// What a fake's `build()` should do.
251    #[derive(Clone)]
252    enum BuildMode {
253        /// Succeed, returning the current generation.
254        Ok,
255        /// Fail immediately.
256        Err,
257        /// Sleep before succeeding (for the timeout test).
258        Sleep(Duration),
259        /// Read G, then bump current to G+1 and return read_gen=G (stale).
260        Stale,
261    }
262
263    /// Shared, observable state for a fake target.
264    #[derive(Default)]
265    struct FakeState {
266        generation: AtomicU64,
267        commit_calls: AtomicUsize,
268        build_calls: AtomicUsize,
269    }
270
271    struct FakeTarget {
272        route: String,
273        timeout: Duration,
274        state: Arc<FakeState>,
275        mode: StdMutex<BuildMode>,
276        /// Optional event recorder for the serialize test.
277        events: Option<Arc<StdMutex<Vec<&'static str>>>>,
278    }
279
280    impl FakeTarget {
281        fn new(route: &str) -> Arc<Self> {
282            Arc::new(Self {
283                route: route.to_string(),
284                timeout: Duration::from_secs(5),
285                state: Arc::new(FakeState::default()),
286                mode: StdMutex::new(BuildMode::Ok),
287                events: None,
288            })
289        }
290
291        fn set_mode(&self, mode: BuildMode) {
292            *self.mode.lock().unwrap() = mode;
293        }
294
295        /// Coerce to the erased trait-object Arc that `register` expects.
296        fn as_dyn(self: &Arc<Self>) -> Arc<dyn TemplateReloadTarget> {
297            // Bind to a concrete-typed local so CoerceUnsized fires at the
298            // return site (inference would otherwise pin `Arc::clone` to the
299            // trait object and fail).
300            let concrete: Arc<Self> = Arc::clone(self);
301            concrete
302        }
303    }
304
305    #[async_trait]
306    impl TemplateReloadTarget for FakeTarget {
307        fn route_id(&self) -> &str {
308            &self.route
309        }
310        fn reload_timeout(&self) -> Duration {
311            self.timeout
312        }
313        fn current_generation(&self) -> u64 {
314            self.state.generation.load(Ordering::SeqCst)
315        }
316        async fn build(&self) -> Result<(Box<dyn TemplateReloadStaged>, u64), CamelError> {
317            self.state.build_calls.fetch_add(1, Ordering::SeqCst);
318            if let Some(ev) = &self.events {
319                ev.lock().unwrap().push("start");
320            }
321            let mode = self.mode.lock().unwrap().clone();
322            match mode {
323                BuildMode::Err => {
324                    if let Some(ev) = &self.events {
325                        ev.lock().unwrap().push("end");
326                    }
327                    return Err(CamelError::TemplateReload("fake build failed".to_string()));
328                }
329                BuildMode::Sleep(d) => {
330                    tokio::time::sleep(d).await;
331                }
332                BuildMode::Ok | BuildMode::Stale => {
333                    // yield to encourage interleaving when not serialized.
334                    tokio::task::yield_now().await;
335                }
336            }
337            let read_gen = match mode {
338                // Read G, then bump current to G+1 BEFORE returning, so the
339                // validate phase sees read_gen(G) != current(G+1).
340                BuildMode::Stale => self.state.generation.fetch_add(1, Ordering::SeqCst),
341                _ => self.state.generation.load(Ordering::SeqCst),
342            };
343            if let Some(ev) = &self.events {
344                ev.lock().unwrap().push("end");
345            }
346            Ok((
347                Box::new(FakeStaged {
348                    read_generation: read_gen,
349                }),
350                read_gen,
351            ))
352        }
353        fn commit(&self, staged: Box<dyn TemplateReloadStaged>) {
354            // Exercise the downcast idiom end-to-end.
355            let concrete = staged.into_any().downcast::<FakeStaged>().unwrap();
356            assert_eq!(
357                concrete.read_generation,
358                self.state.generation.load(Ordering::SeqCst)
359            );
360            self.state.commit_calls.fetch_add(1, Ordering::SeqCst);
361            self.state.generation.fetch_add(1, Ordering::SeqCst);
362        }
363    }
364
365    #[test]
366    fn registry_register_find_all_remove() {
367        let reg = TemplateReloadRegistry::global();
368        let route = "test-register-find-all-remove";
369        let target = FakeTarget::new(route);
370        let _guard = reg.register(target.as_dyn());
371        assert_eq!(reg.find_all(route).len(), 1);
372        drop(_guard);
373        assert_eq!(reg.find_all(route).len(), 0);
374    }
375
376    #[tokio::test]
377    async fn reload_route_all_or_nothing() {
378        let reg = TemplateReloadRegistry::global();
379        let route = "test-all-or-nothing";
380        let ok = FakeTarget::new(route);
381        let err = FakeTarget::new(route);
382        err.set_mode(BuildMode::Err);
383        let g1 = reg.register(ok.as_dyn());
384        let g2 = reg.register(err.as_dyn());
385
386        let res = reg.reload_route(route).await;
387        assert!(res.is_err(), "expected reload to fail");
388        assert_eq!(
389            ok.state.commit_calls.load(Ordering::SeqCst),
390            0,
391            "OK target must NOT be committed"
392        );
393        assert_eq!(
394            err.state.commit_calls.load(Ordering::SeqCst),
395            0,
396            "Err target must NOT be committed"
397        );
398        assert_eq!(
399            ok.state.generation.load(Ordering::SeqCst),
400            0,
401            "prior generation retained"
402        );
403        drop(g1);
404        drop(g2);
405    }
406
407    #[tokio::test]
408    async fn reload_route_commits_all_on_success() {
409        let reg = TemplateReloadRegistry::global();
410        let route = "test-commits-all-on-success";
411        let a = FakeTarget::new(route);
412        let b = FakeTarget::new(route);
413        let ga = reg.register(a.as_dyn());
414        let gb = reg.register(b.as_dyn());
415
416        let res = reg.reload_route(route).await;
417        assert!(res.is_ok(), "expected reload to succeed: {:?}", res);
418        assert_eq!(a.state.commit_calls.load(Ordering::SeqCst), 1);
419        assert_eq!(b.state.commit_calls.load(Ordering::SeqCst), 1);
420        assert_eq!(a.state.generation.load(Ordering::SeqCst), 1);
421        assert_eq!(b.state.generation.load(Ordering::SeqCst), 1);
422        drop(ga);
423        drop(gb);
424    }
425
426    #[tokio::test]
427    async fn reload_route_timeout_no_commit() {
428        let reg = TemplateReloadRegistry::global();
429        let route = "test-timeout-no-commit";
430        // Tighten the deadline (40ms) and make build overrun it (2s sleep).
431        let slow = Arc::new(FakeTarget {
432            route: route.to_string(),
433            timeout: Duration::from_millis(40),
434            state: Arc::new(FakeState::default()),
435            mode: StdMutex::new(BuildMode::Sleep(Duration::from_millis(2_000))),
436            events: None,
437        });
438        let g = reg.register(slow.as_dyn());
439
440        let res = reg.reload_route(route).await;
441        assert!(
442            matches!(res, Err(CamelError::TemplateReload(_))),
443            "expected TemplateReload timeout error, got {res:?}"
444        );
445        assert_eq!(
446            slow.state.commit_calls.load(Ordering::SeqCst),
447            0,
448            "commit must never be called on timeout"
449        );
450        drop(g);
451    }
452
453    #[tokio::test]
454    async fn reload_route_rejects_stale_no_commit() {
455        let reg = TemplateReloadRegistry::global();
456        let route = "test-rejects-stale-no-commit";
457        let target = FakeTarget::new(route);
458        target.set_mode(BuildMode::Stale);
459        let g = reg.register(target.as_dyn());
460
461        let res = reg.reload_route(route).await;
462        assert!(
463            matches!(res, Err(CamelError::TemplateReload(_))),
464            "expected TemplateReload stale error, got {res:?}"
465        );
466        assert_eq!(
467            target.state.commit_calls.load(Ordering::SeqCst),
468            0,
469            "commit must never be called on stale rejection"
470        );
471        drop(g);
472    }
473
474    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
475    async fn reload_route_serializes_concurrent() {
476        let reg = TemplateReloadRegistry::global();
477        let route = "test-serializes-concurrent";
478        let events: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
479        let target = Arc::new(FakeTarget {
480            route: route.to_string(),
481            timeout: Duration::from_secs(5),
482            state: Arc::new(FakeState::default()),
483            mode: StdMutex::new(BuildMode::Ok),
484            events: Some(Arc::clone(&events)),
485        });
486        let g = reg.register(target.as_dyn());
487
488        // Spawn two concurrent reload_route for the SAME route.
489        let h1 = tokio::spawn(async move { reg.reload_route(route).await });
490        let h2 = tokio::spawn(async move { reg.reload_route(route).await });
491        let (r1, r2) = tokio::join!(h1, h2);
492        r1.unwrap().unwrap();
493        r2.unwrap().unwrap();
494
495        // If serialized by the per-route mutex, build calls never interleave:
496        // the sequence must be start,end,start,end (NOT start,start,end,end).
497        let evs = events.lock().unwrap().clone();
498        assert_eq!(
499            evs,
500            vec!["start", "end", "start", "end"],
501            "per-route mutex must serialize concurrent reload_route"
502        );
503        assert_eq!(target.state.commit_calls.load(Ordering::SeqCst), 2);
504        drop(g);
505    }
506}