aster_forge_config/notification/
supervisor.rs

1use async_trait::async_trait;
2use std::future::Future;
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5use tokio::sync::mpsc;
6use tokio_util::sync::CancellationToken;
7
8use crate::{ConfigCoreError, Result};
9
10use super::message::{
11    ConfigChangeEvent, ConfigReloadDecision, ConfigReloadMessage, ConfigReloadWorkerConfig,
12    handle_config_reload_notification,
13};
14use super::notifier::{ConfigChangeNotifier, ConfigNotification};
15
16/// Observability event emitted after handling a config reload notification.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ConfigReloadObservation {
19    /// Source label suitable for low-cardinality metrics.
20    pub source: &'static str,
21    /// Handling decision.
22    pub decision: ConfigReloadDecision,
23    /// Whether the handling path succeeded.
24    pub status: &'static str,
25    /// Number of changed keys advertised by the reload hint.
26    pub changed_keys: u64,
27    /// Time spent handling this notification.
28    pub duration_seconds: f64,
29}
30
31impl ConfigReloadObservation {
32    fn new(
33        source: &'static str,
34        decision: ConfigReloadDecision,
35        status: &'static str,
36        changed_keys: usize,
37        duration_seconds: f64,
38    ) -> Self {
39        Self {
40            source,
41            decision,
42            status,
43            changed_keys: u64::try_from(changed_keys).unwrap_or(u64::MAX),
44            duration_seconds,
45        }
46    }
47}
48
49/// Connection lifecycle state for a config-sync subscription.
50pub type ConfigSyncConnectionState = aster_forge_events::EventConnectionState;
51
52/// Low-cardinality observation emitted for config-sync connection transitions.
53#[derive(Debug, Clone, PartialEq)]
54pub struct ConfigSyncConnectionObservation {
55    /// Connection lifecycle state.
56    pub state: ConfigSyncConnectionState,
57    /// One-based reconnect attempt number, or zero outside reconnect attempts.
58    pub reconnect_attempt: u32,
59    /// Planned backoff for a reconnect attempt, or zero for other states.
60    pub backoff_seconds: f64,
61}
62
63impl ConfigSyncConnectionObservation {
64    fn new(state: ConfigSyncConnectionState, reconnect_attempt: u32, backoff: Duration) -> Self {
65        Self {
66            state,
67            reconnect_attempt,
68            backoff_seconds: backoff.as_secs_f64(),
69        }
70    }
71}
72
73/// Receives config reload observability events.
74pub trait ConfigReloadObserver: Send + Sync {
75    /// Records one reload observation.
76    fn observe_config_reload(&self, observation: ConfigReloadObservation);
77}
78
79impl<F> ConfigReloadObserver for F
80where
81    F: Fn(ConfigReloadObservation) + Send + Sync,
82{
83    fn observe_config_reload(&self, observation: ConfigReloadObservation) {
84        self(observation);
85    }
86}
87
88/// Receives config-sync connection lifecycle observations.
89pub trait ConfigSyncConnectionObserver: Send + Sync {
90    /// Records one connection transition.
91    fn observe_config_sync_connection(&self, observation: ConfigSyncConnectionObservation);
92}
93
94impl<F> ConfigSyncConnectionObserver for F
95where
96    F: Fn(ConfigSyncConnectionObservation) + Send + Sync,
97{
98    fn observe_config_sync_connection(&self, observation: ConfigSyncConnectionObservation) {
99        self(observation);
100    }
101}
102
103pub(super) type ConfigReloadReconnectPolicy = aster_forge_events::EventReconnectPolicy;
104
105fn default_config_reload_reconnect_policy() -> ConfigReloadReconnectPolicy {
106    ConfigReloadReconnectPolicy {
107        initial_delay: Duration::from_millis(250),
108        max_delay: Duration::from_secs(30),
109        stable_reset_after: Duration::from_secs(30),
110        jitter_min_percent: 50,
111        jitter_max_percent: 100,
112    }
113}
114
115/// Runs a reload subscription loop until `shutdown` is cancelled.
116///
117/// The loop never carries configuration values over pub/sub. A matching
118/// notification only tells this process to reload from its authoritative store.
119/// Reload errors are logged and the loop keeps listening, because one failed DB
120/// read should not permanently break cross-process synchronization.
121///
122/// The loop is supervised like [`run_config_reload_supervisor`] with a no-op
123/// reconcile: subscription failures, stream endings, and broadcast lag trigger
124/// a bounded reconnect instead of exiting.
125///
126/// # Errors
127///
128/// Returns [`ConfigError`] when publishing, subscription, reconciliation, or reload handling fails.
129pub async fn run_config_reload_worker<N, F, Fut>(
130    notifier: Arc<N>,
131    config: ConfigReloadWorkerConfig,
132    shutdown: CancellationToken,
133    reload: F,
134) -> Result<()>
135where
136    N: ConfigChangeNotifier + ?Sized,
137    F: FnMut(ConfigReloadMessage) -> Fut,
138    Fut: Future<Output = Result<()>>,
139{
140    run_config_reload_worker_with_observer(
141        notifier,
142        config,
143        shutdown,
144        reload,
145        None::<&dyn ConfigReloadObserver>,
146    )
147    .await
148}
149
150/// Runs a reload subscription loop and reports low-cardinality observations.
151///
152/// # Errors
153///
154/// Returns [`ConfigError`] when publishing, subscription, reconciliation, or reload handling fails.
155pub async fn run_config_reload_worker_with_observer<N, F, Fut>(
156    notifier: Arc<N>,
157    config: ConfigReloadWorkerConfig,
158    shutdown: CancellationToken,
159    mut reload: F,
160    observer: Option<&dyn ConfigReloadObserver>,
161) -> Result<()>
162where
163    N: ConfigChangeNotifier + ?Sized,
164    F: FnMut(ConfigReloadMessage) -> Fut,
165    Fut: Future<Output = Result<()>>,
166{
167    let mut reconcile = || async { Ok(()) };
168    run_config_reload_supervisor_inner(
169        notifier,
170        config,
171        default_config_reload_reconnect_policy(),
172        shutdown,
173        &mut reconcile,
174        &mut reload,
175        observer,
176        None,
177    )
178    .await
179}
180
181/// Runs a reconnecting reload subscription with authoritative reconciliation.
182///
183/// `reconcile` runs after every successful subscription, including the initial
184/// connection. This closes the race between the product's startup snapshot load
185/// and the moment pub/sub begins receiving notifications. After a disconnect it
186/// also repairs any changes missed while the transient transport was unavailable.
187///
188/// A disconnect is any of: subscribe failure, transport stream error or ending,
189/// and local broadcast lag (the receiver fell behind and events were dropped).
190/// Each one is observed, waited out with bounded exponential backoff (250 ms
191/// initial, 30 s cap, jittered; the failure counter resets after a subscription
192/// stays stable for 30 s), then followed by a fresh subscription and reconcile.
193/// The loop only returns when `shutdown` is cancelled.
194///
195/// # Errors
196///
197/// Returns [`ConfigError`] when publishing, subscription, reconciliation, or reload handling fails.
198pub async fn run_config_reload_supervisor<N, R, RFut, F, Fut>(
199    notifier: Arc<N>,
200    config: ConfigReloadWorkerConfig,
201    shutdown: CancellationToken,
202    reconcile: R,
203    reload: F,
204) -> Result<()>
205where
206    N: ConfigChangeNotifier + ?Sized,
207    R: FnMut() -> RFut,
208    RFut: Future<Output = Result<()>>,
209    F: FnMut(ConfigReloadMessage) -> Fut,
210    Fut: Future<Output = Result<()>>,
211{
212    run_config_reload_supervisor_with_observers(
213        notifier, config, shutdown, reconcile, reload, None, None,
214    )
215    .await
216}
217
218/// Runs a reconnecting reload subscription and reports reload and connection observations.
219///
220/// # Errors
221///
222/// Returns [`ConfigError`] when publishing, subscription, reconciliation, or reload handling fails.
223pub async fn run_config_reload_supervisor_with_observers<N, R, RFut, F, Fut>(
224    notifier: Arc<N>,
225    config: ConfigReloadWorkerConfig,
226    shutdown: CancellationToken,
227    mut reconcile: R,
228    mut reload: F,
229    reload_observer: Option<&dyn ConfigReloadObserver>,
230    connection_observer: Option<&dyn ConfigSyncConnectionObserver>,
231) -> Result<()>
232where
233    N: ConfigChangeNotifier + ?Sized,
234    R: FnMut() -> RFut,
235    RFut: Future<Output = Result<()>>,
236    F: FnMut(ConfigReloadMessage) -> Fut,
237    Fut: Future<Output = Result<()>>,
238{
239    run_config_reload_supervisor_inner(
240        notifier,
241        config,
242        default_config_reload_reconnect_policy(),
243        shutdown,
244        &mut reconcile,
245        &mut reload,
246        reload_observer,
247        connection_observer,
248    )
249    .await
250}
251
252#[expect(
253    clippy::too_many_arguments,
254    reason = "The supervisor accepts explicit notifier, retry, reload, cancellation, and observability dependencies."
255)]
256pub(super) async fn run_config_reload_supervisor_inner<N, R, RFut, F, Fut>(
257    notifier: Arc<N>,
258    config: ConfigReloadWorkerConfig,
259    reconnect_policy: ConfigReloadReconnectPolicy,
260    shutdown: CancellationToken,
261    reconcile: &mut R,
262    reload: &mut F,
263    reload_observer: Option<&dyn ConfigReloadObserver>,
264    connection_observer: Option<&dyn ConfigSyncConnectionObserver>,
265) -> Result<()>
266where
267    N: ConfigChangeNotifier + ?Sized,
268    R: FnMut() -> RFut,
269    RFut: Future<Output = Result<()>>,
270    F: FnMut(ConfigReloadMessage) -> Fut,
271    Fut: Future<Output = Result<()>>,
272{
273    let source = Arc::new(ConfigNotifierSubscriptionSource { notifier });
274    let (updates_tx, mut updates_rx) = mpsc::channel(1);
275    let supervisor = aster_forge_events::supervise_event_subscription(
276        source,
277        reconnect_policy,
278        shutdown.clone(),
279        updates_tx,
280    );
281    tokio::pin!(supervisor);
282
283    loop {
284        let update = tokio::select! {
285            () = shutdown.cancelled() => return Ok(()),
286            () = &mut supervisor => return Ok(()),
287            update = updates_rx.recv() => update,
288        };
289        match update {
290            Some(aster_forge_events::EventSubscriptionUpdate::Connection(observation)) => {
291                observe_config_sync_connection(
292                    connection_observer,
293                    observation.state,
294                    observation.reconnect_attempt,
295                    observation.backoff,
296                );
297                match observation.state {
298                    ConfigSyncConnectionState::Connected | ConfigSyncConnectionState::Recovered => {
299                        if observation.state == ConfigSyncConnectionState::Recovered {
300                            tracing::info!(
301                                reconnect_attempt = observation.reconnect_attempt,
302                                "config reload subscription recovered"
303                            );
304                        }
305                        if let Err(error) = reconcile().await {
306                            tracing::warn!(
307                                error = %error,
308                                "failed to reconcile runtime config after subscription connected"
309                            );
310                        } else {
311                            tracing::debug!(
312                                "runtime config reconciled after subscription connected"
313                            );
314                        }
315                    }
316                    ConfigSyncConnectionState::Disconnected => {
317                        tracing::warn!(
318                            reconnect_attempt = observation.reconnect_attempt,
319                            "config reload subscription disconnected"
320                        );
321                    }
322                    ConfigSyncConnectionState::Reconnecting => {
323                        tracing::warn!(
324                            reconnect_attempt = observation.reconnect_attempt,
325                            backoff_ms = duration_millis_u64(observation.backoff),
326                            "waiting before config reload subscription reconnect"
327                        );
328                    }
329                }
330            }
331            Some(aster_forge_events::EventSubscriptionUpdate::Item(ConfigChangeEvent::Reload(
332                message,
333            ))) => {
334                process_config_reload_message(&config, message, reload, reload_observer).await;
335            }
336            None => return Ok(()),
337        }
338    }
339}
340
341async fn process_config_reload_message<F, Fut>(
342    config: &ConfigReloadWorkerConfig,
343    message: ConfigReloadMessage,
344    reload: &mut F,
345    observer: Option<&dyn ConfigReloadObserver>,
346) where
347    F: FnMut(ConfigReloadMessage) -> Fut,
348    Fut: Future<Output = Result<()>>,
349{
350    let changed_keys = message.keys.len();
351    let started = Instant::now();
352    match handle_config_reload_notification(config, message, reload).await {
353        Ok(ConfigReloadDecision::Reloaded) => {
354            observe_config_reload(
355                observer,
356                ConfigReloadDecision::Reloaded,
357                "ok",
358                changed_keys,
359                started,
360            );
361            tracing::debug!("runtime config reloaded after remote notification");
362        }
363        Ok(
364            decision @ (ConfigReloadDecision::IgnoredNamespace
365            | ConfigReloadDecision::IgnoredOrigin),
366        ) => {
367            observe_config_reload(observer, decision, "ok", changed_keys, started);
368        }
369        Err(error) => {
370            observe_config_reload(
371                observer,
372                ConfigReloadDecision::Reloaded,
373                "error",
374                changed_keys,
375                started,
376            );
377            tracing::warn!(
378                error = %error,
379                "failed to reload runtime config after remote notification"
380            );
381        }
382    }
383}
384
385#[cfg(test)]
386pub(super) fn config_reload_reconnect_delay(
387    policy: ConfigReloadReconnectPolicy,
388    reconnect_attempt: u32,
389) -> Duration {
390    policy.reconnect_delay(reconnect_attempt)
391}
392
393pub(super) fn duration_millis_u64(duration: Duration) -> u64 {
394    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
395}
396
397fn observe_config_sync_connection(
398    observer: Option<&dyn ConfigSyncConnectionObserver>,
399    state: ConfigSyncConnectionState,
400    reconnect_attempt: u32,
401    backoff: Duration,
402) {
403    if let Some(observer) = observer {
404        observer.observe_config_sync_connection(ConfigSyncConnectionObservation::new(
405            state,
406            reconnect_attempt,
407            backoff,
408        ));
409    }
410}
411
412fn observe_config_reload(
413    observer: Option<&dyn ConfigReloadObserver>,
414    decision: ConfigReloadDecision,
415    status: &'static str,
416    changed_keys: usize,
417    started: Instant,
418) {
419    if let Some(observer) = observer {
420        observer.observe_config_reload(ConfigReloadObservation::new(
421            "pubsub",
422            decision,
423            status,
424            changed_keys,
425            started.elapsed().as_secs_f64(),
426        ));
427    }
428}
429struct ConfigNotifierSubscriptionSource<N: ?Sized> {
430    notifier: Arc<N>,
431}
432
433#[async_trait]
434impl<N> aster_forge_events::EventSubscriptionSource for ConfigNotifierSubscriptionSource<N>
435where
436    N: ConfigChangeNotifier + ?Sized,
437{
438    type Item = ConfigChangeEvent;
439    type Subscription = ConfigNotification;
440    type Error = ConfigCoreError;
441
442    async fn subscribe(&self) -> Result<Self::Subscription> {
443        self.notifier.subscribe().await
444    }
445
446    async fn receive(&self, subscription: &mut Self::Subscription) -> Result<Self::Item> {
447        subscription.recv().await
448    }
449}