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#[derive(Debug, Clone, PartialEq)]
19pub struct ConfigReloadObservation {
20 pub source: &'static str,
22 pub decision: ConfigReloadDecision,
24 pub status: &'static str,
26 pub changed_keys: u64,
28 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
50pub type ConfigSyncConnectionState = aster_forge_events::EventConnectionState;
52
53#[derive(Debug, Clone, PartialEq)]
55pub struct ConfigSyncConnectionObservation {
56 pub state: ConfigSyncConnectionState,
58 pub reconnect_attempt: u32,
60 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
74pub trait ConfigReloadObserver: Send + Sync {
76 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
89pub trait ConfigSyncConnectionObserver: Send + Sync {
91 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
116pub 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
154pub 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
184pub 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
227pub 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 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 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}