use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use crate::mirror::{ReplicationSlot, Role};
pub const DEFAULT_PROBE_INTERVAL: Duration = Duration::from_secs(5);
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
pub const DEFAULT_PROMOTE_AFTER: u32 = 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StandbyWatch {
pub primary: SocketAddr,
pub probe_interval: Duration,
pub connect_timeout: Duration,
pub promote_after: u32,
}
impl StandbyWatch {
#[must_use]
pub fn new(primary: SocketAddr) -> Self {
StandbyWatch {
primary,
probe_interval: DEFAULT_PROBE_INTERVAL,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
promote_after: DEFAULT_PROMOTE_AFTER,
}
}
#[must_use]
pub fn earliest_takeover(&self) -> Duration {
self.probe_interval
.saturating_mul(self.promote_after)
.saturating_add(self.connect_timeout)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Probe {
Reachable,
Unreachable,
Wedged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Wait,
Promote,
}
#[derive(Debug, Clone, Copy)]
pub struct Tally {
consecutive_failures: u32,
promote_after: u32,
promoted: bool,
}
impl Tally {
#[must_use]
pub fn new(promote_after: u32) -> Self {
Tally {
consecutive_failures: 0,
promote_after: promote_after.max(1),
promoted: false,
}
}
pub fn observe(&mut self, probe: Probe) -> Verdict {
if self.promoted {
return Verdict::Promote;
}
match probe {
Probe::Reachable => {
self.consecutive_failures = 0;
Verdict::Wait
}
Probe::Unreachable | Probe::Wedged => {
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
if self.consecutive_failures >= self.promote_after {
self.promoted = true;
Verdict::Promote
} else {
Verdict::Wait
}
}
}
}
#[must_use]
pub fn consecutive_failures(&self) -> u32 {
self.consecutive_failures
}
}
const H2_HELLO: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n\x00\x00\x00\x04\x00\x00\x00\x00\x00";
pub async fn probe(addr: SocketAddr, timeout: Duration) -> Probe {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let started = tokio::time::Instant::now();
let mut stream = match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr)).await {
Ok(Ok(stream)) => stream,
Ok(Err(_)) | Err(_) => return Probe::Unreachable,
};
let left = timeout.saturating_sub(started.elapsed());
let answered = tokio::time::timeout(left, async {
stream.write_all(H2_HELLO).await?;
let mut first = [0u8; 1];
stream.read(&mut first).await
})
.await;
match answered {
Ok(Ok(_)) => Probe::Reachable,
Ok(Err(_)) => Probe::Unreachable,
Err(_) => Probe::Wedged,
}
}
pub async fn watch(slot: Arc<ReplicationSlot>, watch: StandbyWatch) {
let mut tally = Tally::new(watch.promote_after);
let mut ticker = tokio::time::interval(watch.probe_interval);
loop {
ticker.tick().await;
match slot.config().map(|config| Tick::for_role(config.role)) {
Ok(Tick::Probe) => {}
Ok(Tick::StopPrimary) => {
tracing::info!(
target: "gunnar::replication",
primary = %watch.primary,
"standby watch stopping: this box is a primary now"
);
return;
}
Ok(Tick::StopBrowse) => {
tracing::info!(
target: "gunnar::replication",
primary = %watch.primary,
"standby watch stopping: this box is a browse replica and never promotes \
itself; `gunnar control replication standby` first if a takeover is wanted"
);
return;
}
Err(err) => {
tracing::warn!(
target: "gunnar::replication",
error = %format!("{err:#}"),
"standby watch could not read the replication configuration; not counting this tick"
);
continue;
}
}
let seen = probe(watch.primary, watch.connect_timeout).await;
match tally.observe(seen) {
Verdict::Wait => {
if seen != Probe::Reachable {
tracing::warn!(
target: "gunnar::replication",
primary = %watch.primary,
consecutive_failures = tally.consecutive_failures(),
promote_after = watch.promote_after,
"{}",
if seen == Probe::Wedged { "primary wedged: it accepts connections and answers nothing" } else { "primary unreachable" }
);
}
}
Verdict::Promote => {
promote(&slot, &watch);
return;
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tick {
Probe,
StopPrimary,
StopBrowse,
}
impl Tick {
pub fn for_role(role: Role) -> Tick {
match role {
Role::Standby => Tick::Probe,
Role::Primary => Tick::StopPrimary,
Role::Browse => Tick::StopBrowse,
}
}
}
fn promote(slot: &ReplicationSlot, watch: &StandbyWatch) {
let mut config = match slot.config() {
Ok(c) => c,
Err(err) => {
tracing::error!(
target: "gunnar::replication",
error = %format!("{err:#}"),
"standby watch decided to take over and could not read the configuration to do it \
— this box is still read-only; run `gunnar control replication promote`"
);
return;
}
};
if Tick::for_role(config.role) != Tick::Probe {
tracing::warn!(
target: "gunnar::replication",
role = ?config.role,
"standby watch reached the takeover with a role that must not be promoted; not \
flipping"
);
return;
}
config.role = Role::Primary;
match slot.apply(config) {
Ok(_) => tracing::warn!(
target: "gunnar::replication",
primary = %watch.primary,
after_failures = watch.promote_after,
"TOOK OVER: the primary was unreachable for {} consecutive probes; this box is now \
the primary and accepts client writes. The front still has to be repointed at it",
watch.promote_after
),
Err(err) => tracing::error!(
target: "gunnar::replication",
error = %format!("{err:#}"),
"standby watch decided to take over and the promotion FAILED — this box is still \
read-only; run `gunnar control replication promote`"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_run_promotes_at_the_threshold_and_not_one_probe_earlier() {
let mut tally = Tally::new(3);
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Wait);
assert_eq!(tally.consecutive_failures(), 1);
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Wait, "two is not three");
assert_eq!(tally.consecutive_failures(), 2);
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Promote);
}
#[test]
fn a_reachable_probe_resets_the_run_so_a_flapping_link_never_adds_up() {
let mut tally = Tally::new(3);
for _ in 0..3 {
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Wait);
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Wait);
assert_eq!(
tally.consecutive_failures(),
2,
"the run must be two here, not a growing total"
);
assert_eq!(tally.observe(Probe::Reachable), Verdict::Wait);
assert_eq!(tally.consecutive_failures(), 0, "one success clears the run");
}
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Wait);
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Wait);
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Promote);
}
#[test]
fn a_promotion_is_not_taken_back_by_the_primary_coming_back() {
let mut tally = Tally::new(1);
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Promote);
assert_eq!(tally.observe(Probe::Reachable), Verdict::Promote);
assert_eq!(tally.observe(Probe::Reachable), Verdict::Promote);
}
#[test]
fn only_a_standby_is_probed_and_a_browse_replica_stops_the_watch_by_name() {
assert_eq!(Tick::for_role(Role::Standby), Tick::Probe);
assert_eq!(Tick::for_role(Role::Primary), Tick::StopPrimary);
assert_eq!(Tick::for_role(Role::Browse), Tick::StopBrowse);
}
#[test]
fn a_threshold_of_zero_still_needs_one_failed_probe() {
let mut tally = Tally::new(0);
assert_eq!(tally.observe(Probe::Reachable), Verdict::Wait, "no evidence, no takeover");
assert_eq!(tally.observe(Probe::Unreachable), Verdict::Promote);
}
#[test]
fn the_reported_takeover_time_is_the_three_knobs_and_nothing_else() {
let w = StandbyWatch {
primary: "127.0.0.1:9418".parse().expect("addr"),
probe_interval: Duration::from_millis(5_000),
connect_timeout: Duration::from_millis(2_000),
promote_after: 6,
};
assert_eq!(w.earliest_takeover(), Duration::from_millis(32_000));
let bare = StandbyWatch::new("127.0.0.1:9418".parse().expect("addr"));
assert_eq!(bare.probe_interval, DEFAULT_PROBE_INTERVAL);
assert_eq!(bare.promote_after, DEFAULT_PROMOTE_AFTER);
assert_eq!(bare.connect_timeout, DEFAULT_CONNECT_TIMEOUT);
assert!(
bare.connect_timeout < bare.probe_interval,
"a connect that outlives the interval lets probes overlap"
);
}
#[tokio::test]
async fn a_listening_port_reads_reachable_and_a_closed_one_does_not() {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(crate::stores::ServerStores::open(dir.path()).expect("stores"));
let plane: Arc<dyn gunnar_grpc::ControlPlane> = Arc::new(crate::CoreControlPlane::new(stores));
let ctx = gunnar_grpc::ServiceContext::new(plane, Arc::new(gunnar_grpc::EventBus::new(4)), gunnar_grpc::ServerConfig::default().insecure_allow_all_for_local_development());
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind a probe target");
let addr = listener.local_addr().expect("local addr");
let served = tokio::spawn(gunnar_grpc::router(ctx, || 0).serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)));
assert_eq!(
probe(addr, Duration::from_secs(2)).await,
Probe::Reachable,
"a serving control plane must read as reachable, or the detector can only ever promote"
);
served.abort();
let closed = SocketAddr::from(([127, 0, 0, 1], 1));
assert_eq!(
probe(closed, Duration::from_secs(2)).await,
Probe::Unreachable,
"a port nothing is listening on must read as unreachable, or the detector can \
never promote"
);
}
#[tokio::test]
async fn a_listener_that_accepts_and_never_answers_is_wedged_not_alive() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let held = tokio::spawn(async move {
let mut open = Vec::new();
while let Ok((s, _)) = listener.accept().await {
open.push(s);
}
});
let seen = probe(addr, Duration::from_millis(600)).await;
assert_eq!(seen, Probe::Wedged, "a port that accepts and never answers must not read as a live primary");
held.abort();
}
}