Skip to main content

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.
125pub async fn run_config_reload_worker<N, F, Fut>(
126    notifier: Arc<N>,
127    config: ConfigReloadWorkerConfig,
128    shutdown: CancellationToken,
129    reload: F,
130) -> Result<()>
131where
132    N: ConfigChangeNotifier + ?Sized,
133    F: FnMut(ConfigReloadMessage) -> Fut,
134    Fut: Future<Output = Result<()>>,
135{
136    run_config_reload_worker_with_observer(
137        notifier,
138        config,
139        shutdown,
140        reload,
141        None::<&dyn ConfigReloadObserver>,
142    )
143    .await
144}
145
146/// Runs a reload subscription loop and reports low-cardinality observations.
147pub async fn run_config_reload_worker_with_observer<N, F, Fut>(
148    notifier: Arc<N>,
149    config: ConfigReloadWorkerConfig,
150    shutdown: CancellationToken,
151    mut reload: F,
152    observer: Option<&dyn ConfigReloadObserver>,
153) -> Result<()>
154where
155    N: ConfigChangeNotifier + ?Sized,
156    F: FnMut(ConfigReloadMessage) -> Fut,
157    Fut: Future<Output = Result<()>>,
158{
159    let mut reconcile = || async { Ok(()) };
160    run_config_reload_supervisor_inner(
161        notifier,
162        config,
163        default_config_reload_reconnect_policy(),
164        shutdown,
165        &mut reconcile,
166        &mut reload,
167        observer,
168        None,
169    )
170    .await
171}
172
173/// Runs a reconnecting reload subscription with authoritative reconciliation.
174///
175/// `reconcile` runs after every successful subscription, including the initial
176/// connection. This closes the race between the product's startup snapshot load
177/// and the moment pub/sub begins receiving notifications. After a disconnect it
178/// also repairs any changes missed while the transient transport was unavailable.
179///
180/// A disconnect is any of: subscribe failure, transport stream error or ending,
181/// and local broadcast lag (the receiver fell behind and events were dropped).
182/// Each one is observed, waited out with bounded exponential backoff (250 ms
183/// initial, 30 s cap, jittered; the failure counter resets after a subscription
184/// stays stable for 30 s), then followed by a fresh subscription and reconcile.
185/// The loop only returns when `shutdown` is cancelled.
186pub async fn run_config_reload_supervisor<N, R, RFut, F, Fut>(
187    notifier: Arc<N>,
188    config: ConfigReloadWorkerConfig,
189    shutdown: CancellationToken,
190    reconcile: R,
191    reload: F,
192) -> Result<()>
193where
194    N: ConfigChangeNotifier + ?Sized,
195    R: FnMut() -> RFut,
196    RFut: Future<Output = Result<()>>,
197    F: FnMut(ConfigReloadMessage) -> Fut,
198    Fut: Future<Output = Result<()>>,
199{
200    run_config_reload_supervisor_with_observers(
201        notifier, config, shutdown, reconcile, reload, None, None,
202    )
203    .await
204}
205
206/// Runs a reconnecting reload subscription and reports reload and connection observations.
207pub async fn run_config_reload_supervisor_with_observers<N, R, RFut, F, Fut>(
208    notifier: Arc<N>,
209    config: ConfigReloadWorkerConfig,
210    shutdown: CancellationToken,
211    mut reconcile: R,
212    mut reload: F,
213    reload_observer: Option<&dyn ConfigReloadObserver>,
214    connection_observer: Option<&dyn ConfigSyncConnectionObserver>,
215) -> Result<()>
216where
217    N: ConfigChangeNotifier + ?Sized,
218    R: FnMut() -> RFut,
219    RFut: Future<Output = Result<()>>,
220    F: FnMut(ConfigReloadMessage) -> Fut,
221    Fut: Future<Output = Result<()>>,
222{
223    run_config_reload_supervisor_inner(
224        notifier,
225        config,
226        default_config_reload_reconnect_policy(),
227        shutdown,
228        &mut reconcile,
229        &mut reload,
230        reload_observer,
231        connection_observer,
232    )
233    .await
234}
235
236#[allow(clippy::too_many_arguments)]
237pub(super) async fn run_config_reload_supervisor_inner<N, R, RFut, F, Fut>(
238    notifier: Arc<N>,
239    config: ConfigReloadWorkerConfig,
240    reconnect_policy: ConfigReloadReconnectPolicy,
241    shutdown: CancellationToken,
242    reconcile: &mut R,
243    reload: &mut F,
244    reload_observer: Option<&dyn ConfigReloadObserver>,
245    connection_observer: Option<&dyn ConfigSyncConnectionObserver>,
246) -> Result<()>
247where
248    N: ConfigChangeNotifier + ?Sized,
249    R: FnMut() -> RFut,
250    RFut: Future<Output = Result<()>>,
251    F: FnMut(ConfigReloadMessage) -> Fut,
252    Fut: Future<Output = Result<()>>,
253{
254    let source = Arc::new(ConfigNotifierSubscriptionSource { notifier });
255    let (updates_tx, mut updates_rx) = mpsc::channel(1);
256    let supervisor = aster_forge_events::supervise_event_subscription(
257        source,
258        reconnect_policy,
259        shutdown.clone(),
260        updates_tx,
261    );
262    tokio::pin!(supervisor);
263
264    loop {
265        let update = tokio::select! {
266            () = shutdown.cancelled() => return Ok(()),
267            () = &mut supervisor => return Ok(()),
268            update = updates_rx.recv() => update,
269        };
270        match update {
271            Some(aster_forge_events::EventSubscriptionUpdate::Connection(observation)) => {
272                observe_config_sync_connection(
273                    connection_observer,
274                    observation.state,
275                    observation.reconnect_attempt,
276                    observation.backoff,
277                );
278                match observation.state {
279                    ConfigSyncConnectionState::Connected | ConfigSyncConnectionState::Recovered => {
280                        if observation.state == ConfigSyncConnectionState::Recovered {
281                            tracing::info!(
282                                reconnect_attempt = observation.reconnect_attempt,
283                                "config reload subscription recovered"
284                            );
285                        }
286                        if let Err(error) = reconcile().await {
287                            tracing::warn!(
288                                error = %error,
289                                "failed to reconcile runtime config after subscription connected"
290                            );
291                        } else {
292                            tracing::debug!(
293                                "runtime config reconciled after subscription connected"
294                            );
295                        }
296                    }
297                    ConfigSyncConnectionState::Disconnected => {
298                        tracing::warn!(
299                            reconnect_attempt = observation.reconnect_attempt,
300                            "config reload subscription disconnected"
301                        );
302                    }
303                    ConfigSyncConnectionState::Reconnecting => {
304                        tracing::warn!(
305                            reconnect_attempt = observation.reconnect_attempt,
306                            backoff_ms = duration_millis_u64(observation.backoff),
307                            "waiting before config reload subscription reconnect"
308                        );
309                    }
310                }
311            }
312            Some(aster_forge_events::EventSubscriptionUpdate::Item(ConfigChangeEvent::Reload(
313                message,
314            ))) => {
315                process_config_reload_message(&config, message, reload, reload_observer).await;
316            }
317            None => return Ok(()),
318        }
319    }
320}
321
322async fn process_config_reload_message<F, Fut>(
323    config: &ConfigReloadWorkerConfig,
324    message: ConfigReloadMessage,
325    reload: &mut F,
326    observer: Option<&dyn ConfigReloadObserver>,
327) where
328    F: FnMut(ConfigReloadMessage) -> Fut,
329    Fut: Future<Output = Result<()>>,
330{
331    let changed_keys = message.keys.len();
332    let started = Instant::now();
333    match handle_config_reload_notification(config, message, reload).await {
334        Ok(ConfigReloadDecision::Reloaded) => {
335            observe_config_reload(
336                observer,
337                ConfigReloadDecision::Reloaded,
338                "ok",
339                changed_keys,
340                started,
341            );
342            tracing::debug!("runtime config reloaded after remote notification");
343        }
344        Ok(
345            decision @ (ConfigReloadDecision::IgnoredNamespace
346            | ConfigReloadDecision::IgnoredOrigin),
347        ) => {
348            observe_config_reload(observer, decision, "ok", changed_keys, started);
349        }
350        Err(error) => {
351            observe_config_reload(
352                observer,
353                ConfigReloadDecision::Reloaded,
354                "error",
355                changed_keys,
356                started,
357            );
358            tracing::warn!(
359                error = %error,
360                "failed to reload runtime config after remote notification"
361            );
362        }
363    }
364}
365
366#[cfg(test)]
367pub(super) fn config_reload_reconnect_delay(
368    policy: ConfigReloadReconnectPolicy,
369    reconnect_attempt: u32,
370) -> Duration {
371    policy.reconnect_delay(reconnect_attempt)
372}
373
374pub(super) fn duration_millis_u64(duration: Duration) -> u64 {
375    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
376}
377
378fn observe_config_sync_connection(
379    observer: Option<&dyn ConfigSyncConnectionObserver>,
380    state: ConfigSyncConnectionState,
381    reconnect_attempt: u32,
382    backoff: Duration,
383) {
384    if let Some(observer) = observer {
385        observer.observe_config_sync_connection(ConfigSyncConnectionObservation::new(
386            state,
387            reconnect_attempt,
388            backoff,
389        ));
390    }
391}
392
393fn observe_config_reload(
394    observer: Option<&dyn ConfigReloadObserver>,
395    decision: ConfigReloadDecision,
396    status: &'static str,
397    changed_keys: usize,
398    started: Instant,
399) {
400    if let Some(observer) = observer {
401        observer.observe_config_reload(ConfigReloadObservation::new(
402            "pubsub",
403            decision,
404            status,
405            changed_keys,
406            started.elapsed().as_secs_f64(),
407        ));
408    }
409}
410struct ConfigNotifierSubscriptionSource<N: ?Sized> {
411    notifier: Arc<N>,
412}
413
414#[async_trait]
415impl<N> aster_forge_events::EventSubscriptionSource for ConfigNotifierSubscriptionSource<N>
416where
417    N: ConfigChangeNotifier + ?Sized,
418{
419    type Item = ConfigChangeEvent;
420    type Subscription = ConfigNotification;
421    type Error = ConfigCoreError;
422
423    async fn subscribe(&self) -> Result<Self::Subscription> {
424        self.notifier.subscribe().await
425    }
426
427    async fn receive(&self, subscription: &mut Self::Subscription) -> Result<Self::Item> {
428        subscription.recv().await
429    }
430}