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::time::Instant as RetryInstant;
7use tokio_util::sync::CancellationToken;
8
9use crate::{ConfigCoreError, Result};
10
11use super::message::{
12    ConfigChangeEvent, ConfigReloadDecision, ConfigReloadMessage, ConfigReloadWorkerConfig,
13    handle_config_reload_notification,
14};
15use super::notifier::{ConfigChangeNotifier, ConfigNotification};
16
17/// Observability event emitted after handling a config reload notification.
18#[derive(Debug, Clone, PartialEq)]
19pub struct ConfigReloadObservation {
20    /// Source label suitable for low-cardinality metrics.
21    pub source: &'static str,
22    /// Handling decision.
23    pub decision: ConfigReloadDecision,
24    /// Whether the handling path succeeded.
25    pub status: &'static str,
26    /// Number of changed keys advertised by the reload hint.
27    pub changed_keys: u64,
28    /// Time spent handling this notification.
29    pub duration_seconds: f64,
30}
31
32impl ConfigReloadObservation {
33    fn new(
34        source: &'static str,
35        decision: ConfigReloadDecision,
36        status: &'static str,
37        changed_keys: usize,
38        duration_seconds: f64,
39    ) -> Self {
40        Self {
41            source,
42            decision,
43            status,
44            changed_keys: u64::try_from(changed_keys).unwrap_or(u64::MAX),
45            duration_seconds,
46        }
47    }
48}
49
50/// Connection lifecycle state for a config-sync subscription.
51pub type ConfigSyncConnectionState = aster_forge_events::EventConnectionState;
52
53/// Low-cardinality observation emitted for config-sync connection transitions.
54#[derive(Debug, Clone, PartialEq)]
55pub struct ConfigSyncConnectionObservation {
56    /// Connection lifecycle state.
57    pub state: ConfigSyncConnectionState,
58    /// One-based reconnect attempt number, or zero outside reconnect attempts.
59    pub reconnect_attempt: u32,
60    /// Planned backoff for a reconnect attempt, or zero for other states.
61    pub backoff_seconds: f64,
62}
63
64impl ConfigSyncConnectionObservation {
65    fn new(state: ConfigSyncConnectionState, reconnect_attempt: u32, backoff: Duration) -> Self {
66        Self {
67            state,
68            reconnect_attempt,
69            backoff_seconds: backoff.as_secs_f64(),
70        }
71    }
72}
73
74/// Receives config reload observability events.
75pub trait ConfigReloadObserver: Send + Sync {
76    /// Records one reload observation.
77    fn observe_config_reload(&self, observation: ConfigReloadObservation);
78}
79
80impl<F> ConfigReloadObserver for F
81where
82    F: Fn(ConfigReloadObservation) + Send + Sync,
83{
84    fn observe_config_reload(&self, observation: ConfigReloadObservation) {
85        self(observation);
86    }
87}
88
89/// Receives config-sync connection lifecycle observations.
90pub trait ConfigSyncConnectionObserver: Send + Sync {
91    /// Records one connection transition.
92    fn observe_config_sync_connection(&self, observation: ConfigSyncConnectionObservation);
93}
94
95impl<F> ConfigSyncConnectionObserver for F
96where
97    F: Fn(ConfigSyncConnectionObservation) + Send + Sync,
98{
99    fn observe_config_sync_connection(&self, observation: ConfigSyncConnectionObservation) {
100        self(observation);
101    }
102}
103
104pub(super) type ConfigReloadReconnectPolicy = aster_forge_events::EventReconnectPolicy;
105
106fn default_config_reload_reconnect_policy() -> ConfigReloadReconnectPolicy {
107    ConfigReloadReconnectPolicy {
108        initial_delay: Duration::from_millis(250),
109        max_delay: Duration::from_secs(30),
110        stable_reset_after: Duration::from_secs(30),
111        jitter_min_percent: 50,
112        jitter_max_percent: 100,
113    }
114}
115
116/// Runs a reload subscription loop until `shutdown` is cancelled.
117///
118/// The loop never carries configuration values over pub/sub. A matching
119/// notification only tells this process to reload from its authoritative store.
120/// Failed reload hints are coalesced and retried with bounded exponential
121/// backoff, even if no further notification arrives. Callbacks must support
122/// repeated authoritative reads; an empty key list means reload all config.
123/// Shutdown drops in-flight callbacks, which must be safe to cancel and repeat.
124///
125/// The loop uses the transport supervision of [`run_config_reload_supervisor`],
126/// but has no authoritative reconcile callback. It retries received hints;
127/// repairing notifications lost during disconnection requires the supervisor
128/// API with a full reconcile callback.
129///
130/// # Errors
131///
132/// Returns [`ConfigError`] when publishing, subscription, reconciliation, or reload handling fails.
133pub async fn run_config_reload_worker<N, F, Fut>(
134    notifier: Arc<N>,
135    config: ConfigReloadWorkerConfig,
136    shutdown: CancellationToken,
137    reload: F,
138) -> Result<()>
139where
140    N: ConfigChangeNotifier + ?Sized,
141    F: FnMut(ConfigReloadMessage) -> Fut,
142    Fut: Future<Output = Result<()>>,
143{
144    run_config_reload_worker_with_observer(
145        notifier,
146        config,
147        shutdown,
148        reload,
149        None::<&dyn ConfigReloadObserver>,
150    )
151    .await
152}
153
154/// Runs a reload subscription loop and reports low-cardinality observations.
155///
156/// # Errors
157///
158/// Returns [`ConfigError`] when publishing, subscription, reconciliation, or reload handling fails.
159pub async fn run_config_reload_worker_with_observer<N, F, Fut>(
160    notifier: Arc<N>,
161    config: ConfigReloadWorkerConfig,
162    shutdown: CancellationToken,
163    mut reload: F,
164    observer: Option<&dyn ConfigReloadObserver>,
165) -> Result<()>
166where
167    N: ConfigChangeNotifier + ?Sized,
168    F: FnMut(ConfigReloadMessage) -> Fut,
169    Fut: Future<Output = Result<()>>,
170{
171    run_config_reload_supervisor_inner(
172        notifier,
173        config,
174        default_config_reload_reconnect_policy(),
175        shutdown,
176        None::<&mut fn() -> std::future::Ready<Result<()>>>,
177        &mut reload,
178        observer,
179        None,
180    )
181    .await
182}
183
184/// Runs a reconnecting reload subscription with authoritative reconciliation.
185///
186/// `reconcile` runs after every successful subscription, including the initial
187/// connection. This closes the race between the product's startup snapshot load
188/// and the moment pub/sub begins receiving notifications. After a disconnect it
189/// also repairs any changes missed while the transient transport was unavailable.
190///
191/// A disconnect is any of: subscribe failure, transport stream error or ending,
192/// and local broadcast lag (the receiver fell behind and events were dropped).
193/// Each one is observed, waited out with bounded exponential backoff (250 ms
194/// initial, 30 s cap, jittered; the failure counter resets after a subscription
195/// stays stable for 30 s), then followed by a fresh subscription and reconcile.
196/// Failed reloads or reconciliations schedule one authoritative reconcile retry
197/// with the same delay bounds, independent of transport health. Only successful
198/// reconciliation clears this pending work; a later key-specific reload cannot
199/// prove that all previously missed changes and derived caches are current.
200/// Shutdown cancels both retry waits and in-flight callbacks. Callbacks must be
201/// safe to drop and repeat after partially completed work.
202/// The loop only returns when `shutdown` is cancelled.
203///
204/// # Errors
205///
206/// Returns [`ConfigError`] when publishing, subscription, reconciliation, or reload handling fails.
207pub async fn run_config_reload_supervisor<N, R, RFut, F, Fut>(
208    notifier: Arc<N>,
209    config: ConfigReloadWorkerConfig,
210    shutdown: CancellationToken,
211    reconcile: R,
212    reload: F,
213) -> Result<()>
214where
215    N: ConfigChangeNotifier + ?Sized,
216    R: FnMut() -> RFut,
217    RFut: Future<Output = Result<()>>,
218    F: FnMut(ConfigReloadMessage) -> Fut,
219    Fut: Future<Output = Result<()>>,
220{
221    run_config_reload_supervisor_with_observers(
222        notifier, config, shutdown, reconcile, reload, None, None,
223    )
224    .await
225}
226
227/// Runs a reconnecting reload subscription and reports reload and connection observations.
228///
229/// # Errors
230///
231/// Returns [`ConfigError`] when publishing, subscription, reconciliation, or reload handling fails.
232pub async fn run_config_reload_supervisor_with_observers<N, R, RFut, F, Fut>(
233    notifier: Arc<N>,
234    config: ConfigReloadWorkerConfig,
235    shutdown: CancellationToken,
236    mut reconcile: R,
237    mut reload: F,
238    reload_observer: Option<&dyn ConfigReloadObserver>,
239    connection_observer: Option<&dyn ConfigSyncConnectionObserver>,
240) -> Result<()>
241where
242    N: ConfigChangeNotifier + ?Sized,
243    R: FnMut() -> RFut,
244    RFut: Future<Output = Result<()>>,
245    F: FnMut(ConfigReloadMessage) -> Fut,
246    Fut: Future<Output = Result<()>>,
247{
248    run_config_reload_supervisor_inner(
249        notifier,
250        config,
251        default_config_reload_reconnect_policy(),
252        shutdown,
253        Some(&mut reconcile),
254        &mut reload,
255        reload_observer,
256        connection_observer,
257    )
258    .await
259}
260
261#[expect(
262    clippy::too_many_arguments,
263    reason = "The supervisor accepts explicit notifier, retry, reload, cancellation, and observability dependencies."
264)]
265pub(super) async fn run_config_reload_supervisor_inner<N, R, RFut, F, Fut>(
266    notifier: Arc<N>,
267    config: ConfigReloadWorkerConfig,
268    reconnect_policy: ConfigReloadReconnectPolicy,
269    shutdown: CancellationToken,
270    mut reconcile: Option<&mut R>,
271    reload: &mut F,
272    reload_observer: Option<&dyn ConfigReloadObserver>,
273    connection_observer: Option<&dyn ConfigSyncConnectionObserver>,
274) -> Result<()>
275where
276    N: ConfigChangeNotifier + ?Sized,
277    R: FnMut() -> RFut,
278    RFut: Future<Output = Result<()>>,
279    F: FnMut(ConfigReloadMessage) -> Fut,
280    Fut: Future<Output = Result<()>>,
281{
282    let source = Arc::new(ConfigNotifierSubscriptionSource { notifier });
283    let (updates_tx, mut updates_rx) = mpsc::channel(1);
284    let supervisor = aster_forge_events::supervise_event_subscription(
285        source,
286        reconnect_policy,
287        shutdown.clone(),
288        updates_tx,
289    );
290    tokio::pin!(supervisor);
291    let mut retry = ConfigReloadRetry::default();
292
293    loop {
294        let update = tokio::select! {
295            biased;
296            () = shutdown.cancelled() => return Ok(()),
297            () = retry.wait() => {
298                retry.deadline = None;
299                let Some(result) = await_config_callback(&shutdown, async {
300                        if let Some(reconcile) = reconcile.as_mut() {
301                            reconcile().await
302                        } else if let Some(message) = retry.message.clone() {
303                            process_config_reload_message(&config, message, reload, reload_observer)
304                                .await.map(|_| ())
305                        } else {
306                            Ok(())
307                        }
308                    }).await else { return Ok(()); };
309                if let Err(error) = result {
310                    tracing::warn!(%error, "failed to retry runtime config reload");
311                    retry.schedule(reconnect_policy);
312                } else {
313                    retry = ConfigReloadRetry::default();
314                }
315                continue;
316            }
317            () = &mut supervisor => return Ok(()),
318            update = updates_rx.recv() => update,
319        };
320        match update {
321            Some(aster_forge_events::EventSubscriptionUpdate::Connection(observation)) => {
322                observe_config_sync_connection(
323                    connection_observer,
324                    observation.state,
325                    observation.reconnect_attempt,
326                    observation.backoff,
327                );
328                if matches!(
329                    observation.state,
330                    ConfigSyncConnectionState::Connected | ConfigSyncConnectionState::Recovered
331                ) && let Some(reconcile) = reconcile.as_mut()
332                {
333                    let Some(result) = await_config_callback(&shutdown, reconcile()).await else {
334                        return Ok(());
335                    };
336                    if let Err(error) = result {
337                        tracing::warn!(
338                            error = %error,
339                            "failed to reconcile runtime config after subscription connected"
340                        );
341                        retry.schedule(reconnect_policy);
342                    } else {
343                        retry = ConfigReloadRetry::default();
344                        tracing::debug!("runtime config reconciled after subscription connected");
345                    }
346                }
347            }
348            Some(aster_forge_events::EventSubscriptionUpdate::Item(ConfigChangeEvent::Reload(
349                message,
350            ))) => {
351                let Some(result) = await_config_callback(
352                    &shutdown,
353                    process_config_reload_message(
354                        &config,
355                        message.clone(),
356                        reload,
357                        reload_observer,
358                    ),
359                )
360                .await
361                else {
362                    return Ok(());
363                };
364                if result.is_err() {
365                    if reconcile.is_none() {
366                        retry.merge_message(message);
367                    }
368                    retry.schedule(reconnect_policy);
369                }
370            }
371            None => return Ok(()),
372        }
373    }
374}
375
376async fn await_config_callback<T>(
377    shutdown: &CancellationToken,
378    callback: impl Future<Output = T>,
379) -> Option<T> {
380    tokio::select! {
381        biased;
382        () = shutdown.cancelled() => None,
383        result = callback => Some(result),
384    }
385}
386
387#[derive(Default)]
388struct ConfigReloadRetry {
389    deadline: Option<RetryInstant>,
390    attempt: u32,
391    message: Option<ConfigReloadMessage>,
392}
393
394impl ConfigReloadRetry {
395    fn schedule(&mut self, policy: ConfigReloadReconnectPolicy) {
396        // Further notifications must not postpone recovery or reset its backoff.
397        if self.deadline.is_none() {
398            self.attempt = self.attempt.saturating_add(1);
399            let delay = policy
400                .reconnect_delay(self.attempt)
401                .max(Duration::from_millis(1));
402            self.deadline = Some(RetryInstant::now() + delay);
403        }
404    }
405
406    async fn wait(&self) {
407        if let Some(deadline) = self.deadline {
408            tokio::time::sleep_until(deadline).await;
409        } else {
410            std::future::pending::<()>().await;
411        }
412    }
413
414    fn merge_message(&mut self, mut message: ConfigReloadMessage) {
415        const MAX_PENDING_KEYS: usize = 1024;
416        if let Some(pending) = &self.message {
417            if pending.keys.is_empty() || message.keys.is_empty() {
418                message.keys.clear();
419            } else {
420                message.keys.extend(pending.keys.iter().cloned());
421                message.keys.sort();
422                message.keys.dedup();
423            }
424        }
425        // A long outage must not accumulate an unbounded list of failed hints.
426        // Empty keys already mean an authoritative full reload.
427        if message.keys.len() > MAX_PENDING_KEYS {
428            message.keys.clear();
429        }
430        self.message = Some(message);
431    }
432}
433
434async fn process_config_reload_message<F, Fut>(
435    config: &ConfigReloadWorkerConfig,
436    message: ConfigReloadMessage,
437    reload: &mut F,
438    observer: Option<&dyn ConfigReloadObserver>,
439) -> Result<ConfigReloadDecision>
440where
441    F: FnMut(ConfigReloadMessage) -> Fut,
442    Fut: Future<Output = Result<()>>,
443{
444    let changed_keys = message.keys.len();
445    let started = Instant::now();
446    let result = handle_config_reload_notification(config, message, reload).await;
447    match &result {
448        Ok(ConfigReloadDecision::Reloaded) => {
449            observe_config_reload(
450                observer,
451                ConfigReloadDecision::Reloaded,
452                "ok",
453                changed_keys,
454                started,
455            );
456            tracing::debug!("runtime config reloaded after remote notification");
457        }
458        Ok(
459            decision @ (ConfigReloadDecision::IgnoredNamespace
460            | ConfigReloadDecision::IgnoredOrigin),
461        ) => {
462            observe_config_reload(observer, *decision, "ok", changed_keys, started);
463        }
464        Err(error) => {
465            observe_config_reload(
466                observer,
467                ConfigReloadDecision::Reloaded,
468                "error",
469                changed_keys,
470                started,
471            );
472            tracing::warn!(
473                error = %error,
474                "failed to reload runtime config after remote notification"
475            );
476        }
477    }
478    result
479}
480
481#[cfg(test)]
482pub(super) fn config_reload_reconnect_delay(
483    policy: ConfigReloadReconnectPolicy,
484    reconnect_attempt: u32,
485) -> Duration {
486    policy.reconnect_delay(reconnect_attempt)
487}
488
489pub(super) fn duration_millis_u64(duration: Duration) -> u64 {
490    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
491}
492
493fn observe_config_sync_connection(
494    observer: Option<&dyn ConfigSyncConnectionObserver>,
495    state: ConfigSyncConnectionState,
496    reconnect_attempt: u32,
497    backoff: Duration,
498) {
499    if let Some(observer) = observer {
500        observer.observe_config_sync_connection(ConfigSyncConnectionObservation::new(
501            state,
502            reconnect_attempt,
503            backoff,
504        ));
505    }
506    match state {
507        ConfigSyncConnectionState::Connected => {}
508        ConfigSyncConnectionState::Recovered => {
509            tracing::info!(reconnect_attempt, "config reload subscription recovered");
510        }
511        ConfigSyncConnectionState::Disconnected => {
512            tracing::warn!(reconnect_attempt, "config reload subscription disconnected");
513        }
514        ConfigSyncConnectionState::Reconnecting => {
515            tracing::warn!(
516                reconnect_attempt,
517                backoff_ms = duration_millis_u64(backoff),
518                "waiting before config reload subscription reconnect"
519            );
520        }
521    }
522}
523
524fn observe_config_reload(
525    observer: Option<&dyn ConfigReloadObserver>,
526    decision: ConfigReloadDecision,
527    status: &'static str,
528    changed_keys: usize,
529    started: Instant,
530) {
531    if let Some(observer) = observer {
532        observer.observe_config_reload(ConfigReloadObservation::new(
533            "pubsub",
534            decision,
535            status,
536            changed_keys,
537            started.elapsed().as_secs_f64(),
538        ));
539    }
540}
541struct ConfigNotifierSubscriptionSource<N: ?Sized> {
542    notifier: Arc<N>,
543}
544
545#[async_trait]
546impl<N> aster_forge_events::EventSubscriptionSource for ConfigNotifierSubscriptionSource<N>
547where
548    N: ConfigChangeNotifier + ?Sized,
549{
550    type Item = ConfigChangeEvent;
551    type Subscription = ConfigNotification;
552    type Error = ConfigCoreError;
553
554    async fn subscribe(&self) -> Result<Self::Subscription> {
555        self.notifier.subscribe().await
556    }
557
558    async fn receive(&self, subscription: &mut Self::Subscription) -> Result<Self::Item> {
559        subscription.recv().await
560    }
561}