Drop local caches and reload settings when the coordinator comes back #176

Merged
jcoffey-dev merged 1 commits from fix/caches-on-reconnect into main 2026-10-08 22:31:17 +00:00
3 changed files with 124 additions and 6 deletions

No files matched your search

+55 -5
View File
@@ -17,6 +17,7 @@ use std::{
use crate::Coordinator;
use async_nats::Client;
use registry::schema::structs::NatsCoordinator;
use tokio::sync::watch;
use trc::ClusterEvent;
pub mod pubsub;
@@ -24,6 +25,7 @@ pub mod pubsub;
#[derive(Debug)]
pub struct NatsPubSub {
client: Client,
reporter: Arc<Reporter>,
}
impl NatsPubSub {
@@ -60,7 +62,7 @@ impl NatsPubSub {
// starts while NATS is down still joins the cluster once NATS is
// back, instead of running without a coordinator until restarted;
// and report the connection going and coming back
let reporter = Arc::new(Reporter::default());
let reporter = Arc::new(Reporter::new());
opts = opts.retry_on_initial_connect().event_callback({
let reporter = reporter.clone();
move |event| {
@@ -74,11 +76,18 @@ impl NatsPubSub {
.await
.map(|client| {
reporter.watch_first_connection(client.clone(), connection_timeout);
Coordinator::Nats(Arc::new(NatsPubSub { client }))
Coordinator::Nats(Arc::new(NatsPubSub { client, reporter }))
})
.map_err(|err| format!("Failed to connect to Nats: {}", err))
}
/// inbuxa: counts the client's reconnections. Core NATS delivers at
/// most once, so whatever the cluster published while this node was
/// away is gone; a watcher drops what those events would have changed.
pub fn reconnects(&self) -> watch::Receiver<u64> {
self.reporter.reconnects.subscribe()
}
/// inbuxa: whether the client is connected to a NATS server right now.
pub fn is_connected(&self) -> bool {
matches!(
@@ -89,21 +98,40 @@ impl NatsPubSub {
}
/// inbuxa: reports the client's connection events as the server's own.
#[derive(Default)]
#[derive(Debug)]
struct Reporter {
connected_once: AtomicBool,
// A failed attempt raises an error each time the client retries, every
// few seconds while NATS is down: report the first after each change
error_reported: AtomicBool,
/// How many times the connection came back after being lost.
reconnects: watch::Sender<u64>,
}
impl Reporter {
fn new() -> Self {
Self {
connected_once: AtomicBool::new(false),
error_reported: AtomicBool::new(false),
reconnects: watch::Sender::new(0),
}
}
fn report(&self, event: async_nats::Event) {
match event {
async_nats::Event::Connected => {
self.connected_once.store(true, Ordering::Relaxed);
let reconnected = self.connected_once.swap(true, Ordering::Relaxed);
self.error_reported.store(false, Ordering::Relaxed);
trc::event!(Cluster(ClusterEvent::CoordinatorConnected), Type = "nats");
if reconnected {
self.reconnects.send_modify(|count| *count += 1);
trc::event!(
Cluster(ClusterEvent::CoordinatorConnected),
Type = "nats",
Details = "Reconnected",
);
} else {
trc::event!(Cluster(ClusterEvent::CoordinatorConnected), Type = "nats");
}
}
async_nats::Event::Disconnected => {
self.error_reported.store(false, Ordering::Relaxed);
@@ -169,3 +197,25 @@ impl Reporter {
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_connection_that_comes_back_counts() {
let reporter = Reporter::new();
let watcher = reporter.reconnects.subscribe();
reporter.report(async_nats::Event::Connected);
assert_eq!(
*watcher.borrow(),
0,
"the first connection is not a reconnect"
);
reporter.report(async_nats::Event::Disconnected);
reporter.report(async_nats::Event::Connected);
assert_eq!(*watcher.borrow(), 1);
reporter.report(async_nats::Event::Connected);
assert_eq!(*watcher.borrow(), 2);
}
}
+10
View File
@@ -56,6 +56,16 @@ impl Coordinator {
_ => None,
}
}
/// inbuxa: a watch on the number of reconnections, for backends that
/// report them (NATS); `None` for the others.
pub fn reconnects(&self) -> Option<tokio::sync::watch::Receiver<u64>> {
match self {
#[cfg(feature = "nats")]
Coordinator::Nats(store) => Some(store.reconnects()),
_ => None,
}
}
}
impl PubSubStream {
+59 -1
View File
@@ -11,7 +11,7 @@ use common::{
BuildServer, Inner,
ipc::{BroadcastEvent, PushEvent, PushNotification, QueueEvent, RegistryChange},
};
use registry::types::EnumImpl;
use registry::{schema::prelude::ObjectType, types::EnumImpl};
use std::{sync::Arc, time::Duration};
use tokio::sync::watch;
use trc::{ClusterEvent, ServerEvent};
@@ -27,6 +27,9 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
tokio::spawn(async move {
let mut retry_count: u32 = 0;
// inbuxa: whether events may have been published while this node
// had no subscription
let mut lost_events = false;
trc::event!(Cluster(ClusterEvent::SubscriberStart));
@@ -43,6 +46,9 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
let mut stream = match coordinator.subscribe(BROADCAST_TOPIC).await {
Ok(stream) => {
retry_count = 0;
if std::mem::take(&mut lost_events) {
recover_missed_events(&inner, "Subscribed again").await;
}
stream
}
Err(err) => {
@@ -69,8 +75,28 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
}
};
// inbuxa: the client subscribes again by itself after a
// reconnection, but core NATS delivers at most once, so what the
// cluster published meanwhile never arrives
let mut reconnects = coordinator.reconnects();
if let Some(reconnects) = &mut reconnects {
reconnects.mark_unchanged();
}
loop {
tokio::select! {
changed = async {
match &mut reconnects {
Some(reconnects) => reconnects.changed().await.is_ok(),
None => std::future::pending().await,
}
} => {
if changed {
recover_missed_events(&inner, "Reconnected to the coordinator").await;
} else {
reconnects = None;
}
},
message = stream.next() => {
match message {
Some(message) => {
@@ -234,6 +260,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
trc::event!(
Cluster(ClusterEvent::SubscriberDisconnected),
);
lost_events = true;
break;
}
}
@@ -249,6 +276,37 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
});
}
/// inbuxa: after a gap in which cluster events may have been missed, drops
/// every local cache that only those events keep fresh and rebuilds the
/// settings, certificates and blocked addresses from the registry, as the
/// missed events would have. The message caches check the change log on
/// each use and need nothing.
async fn recover_missed_events(inner: &Arc<Inner>, why: &str) {
let server = inner.build_server();
server.invalidate_all_local_caches();
for object in [
ObjectType::ClusterRole,
ObjectType::Certificate,
ObjectType::BlockedIp,
] {
match Box::pin(server.reload_registry(RegistryChange::Reload(object))).await {
Ok(result) => result.log(),
Err(err) => {
trc::error!(
err.details("Failed to reload settings after the coordinator came back")
.caused_by(trc::location!())
);
}
}
}
trc::event!(
Cluster(ClusterEvent::CoordinatorConnected),
Details = format!(
"{why}: events may have been missed; local caches dropped and settings reloaded"
),
);
}
/// Delay before the next subscribe attempt: 1 s, 2 s, 4 s ... capped at 64 s.
fn subscribe_retry_delay(retry_count: u32) -> Duration {
Duration::from_secs(1u64 << retry_count.min(6))