Skip to main content

reddb_server/replication/
reconnect.rs

1//! Primary↔replica reconnect telemetry (issue #1243, PRD #1237 Phase B).
2//!
3//! A replica drives a long-lived gRPC pull loop against its primary
4//! (`run_replica_loop`). When that link drops — a failed `pull_wal_records`
5//! or the initial `connect` retry — the loop persists the `connecting`
6//! health state and keeps retrying. When a pull succeeds again the loop
7//! persists `healthy`. A **reconnect** is exactly that transition: a link
8//! that was up, went down, and came back up.
9//!
10//! This module records that signal as a monotonic counter so red-ui can
11//! explain instability ("this replica has reconnected 14 times in the last
12//! hour") instead of showing only a last-error snapshot. It is the
13//! reconnect-counter producer/consumer slice of the operational telemetry
14//! substrate (ADR 0060): measurement here, export through `/metrics`
15//! (`reddb_replication_reconnects_total`) and the red-ui status read model.
16//!
17//! # What is *not* counted
18//!
19//! - The **initial** connect on startup. A replica coming up for the first
20//!   time is not "reconnecting"; the counter only moves once the link has
21//!   been healthy at least once and then recovers from a drop.
22//! - Apply-side failures (`apply_error`, `divergence`, `relay_error`,
23//!   `ack_error`, …). Those are not link drops — the gRPC stream is still
24//!   up — and have their own counters
25//!   (`reddb_replica_apply_errors_total`). Treating them as drops would
26//!   inflate the reconnect count, so only the `connecting` state marks the
27//!   link down.
28//!
29//! # Privacy (ADR 0060 §5)
30//!
31//! Reconnect telemetry stores **no** endpoint, URL, credential, or
32//! authorization material — only a monotonic count and the bounded
33//! transition state. The exported series carries the node's own stable
34//! `replica_id` as its single dimension, never the primary's address.
35//!
36//! # Reset / restart behavior
37//!
38//! The counter is an in-memory `AtomicU64` scoped to the process lifetime.
39//! It starts at `0` on every boot and is **not** persisted across restarts
40//! — a process restart resets it to `0`, exactly like the sibling
41//! `reddb_replication_full_resync_total` / `_partial_resync_total` counters.
42//! Prometheus treats a counter that drops to `0` as a reset (via the
43//! process-start timestamp), so `rate()`/`increase()` stay correct across a
44//! restart. red-ui reads the live value; it does not assume monotonicity
45//! across a process boundary.
46
47use std::sync::atomic::{AtomicU64, Ordering};
48use std::sync::Mutex;
49
50/// The replica's view of the link to its primary, as projected from the
51/// health states the pull loop persists.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum LinkState {
54    /// No health state observed yet (pre-connect).
55    Unknown,
56    /// The link has been healthy at least once and is currently up.
57    Up,
58    /// The link was up before and is currently down (retrying connect).
59    Down,
60}
61
62#[derive(Debug)]
63struct LinkTracker {
64    state: LinkState,
65}
66
67/// Process-lifetime reconnect telemetry for the local replica's link to its
68/// primary. Lives on the runtime beside [`super::logical::ReplicaApplyMetrics`]
69/// and is driven from the single health-persist chokepoint in the replica
70/// loop, so every link-state transition is observed exactly once.
71#[derive(Debug)]
72pub struct ReplicaLinkMetrics {
73    reconnects_total: AtomicU64,
74    tracker: Mutex<LinkTracker>,
75}
76
77impl Default for ReplicaLinkMetrics {
78    fn default() -> Self {
79        Self {
80            reconnects_total: AtomicU64::new(0),
81            tracker: Mutex::new(LinkTracker {
82                state: LinkState::Unknown,
83            }),
84        }
85    }
86}
87
88impl ReplicaLinkMetrics {
89    /// Observe a persisted replica health `state` string and advance the
90    /// reconnect counter when the link transitions from down back to up.
91    ///
92    /// Only `"healthy"` counts as the link being up and only `"connecting"`
93    /// counts as the link being down; every other state (apply errors,
94    /// rebootstrap phases, rejoining, …) is neither a clean up nor a link
95    /// drop and leaves the tracked state unchanged, so it can neither arm
96    /// nor fire a reconnect.
97    pub fn observe_state(&self, state: &str) {
98        let mut tracker = match self.tracker.lock() {
99            Ok(guard) => guard,
100            // A poisoned lock means a prior observer panicked mid-update.
101            // Reconnect telemetry must never take down the loop that feeds
102            // it, so recover the guard and carry on; the worst case is a
103            // single mis-attributed transition.
104            Err(poisoned) => poisoned.into_inner(),
105        };
106        match state {
107            "healthy" => {
108                if tracker.state == LinkState::Down {
109                    // down -> up after having been up before: a reconnect.
110                    self.reconnects_total.fetch_add(1, Ordering::Relaxed);
111                }
112                tracker.state = LinkState::Up;
113            }
114            // Only arm a reconnect once the link has been up at least once;
115            // the initial connect (state still `Unknown`) must not be counted.
116            "connecting" if tracker.state == LinkState::Up => {
117                tracker.state = LinkState::Down;
118            }
119            _ => {}
120        }
121    }
122
123    /// Total reconnects observed since process start. Monotonic within a
124    /// process; resets to `0` on restart (see module docs).
125    pub fn reconnects_total(&self) -> u64 {
126        self.reconnects_total.load(Ordering::Relaxed)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn initial_connect_is_not_a_reconnect() {
136        let metrics = ReplicaLinkMetrics::default();
137        // Startup: the loop persists `connecting` while it waits for the
138        // primary, then `healthy` once the first pull lands.
139        metrics.observe_state("connecting");
140        metrics.observe_state("healthy");
141        assert_eq!(metrics.reconnects_total(), 0);
142    }
143
144    #[test]
145    fn drop_and_restore_counts_one_reconnect() {
146        let metrics = ReplicaLinkMetrics::default();
147        metrics.observe_state("connecting");
148        metrics.observe_state("healthy");
149        // Link drops (a failed pull) then restores.
150        metrics.observe_state("connecting");
151        metrics.observe_state("healthy");
152        assert_eq!(metrics.reconnects_total(), 1);
153    }
154
155    #[test]
156    fn repeated_connecting_during_one_outage_counts_once() {
157        let metrics = ReplicaLinkMetrics::default();
158        metrics.observe_state("healthy");
159        // A multi-poll outage persists `connecting` several times.
160        metrics.observe_state("connecting");
161        metrics.observe_state("connecting");
162        metrics.observe_state("connecting");
163        metrics.observe_state("healthy");
164        assert_eq!(metrics.reconnects_total(), 1);
165    }
166
167    #[test]
168    fn multiple_outages_accumulate() {
169        let metrics = ReplicaLinkMetrics::default();
170        metrics.observe_state("healthy");
171        for _ in 0..5 {
172            metrics.observe_state("connecting");
173            metrics.observe_state("healthy");
174        }
175        assert_eq!(metrics.reconnects_total(), 5);
176    }
177
178    #[test]
179    fn apply_errors_are_not_reconnects() {
180        let metrics = ReplicaLinkMetrics::default();
181        metrics.observe_state("healthy");
182        // The stream is still up; these are apply-side, not link drops.
183        metrics.observe_state("apply_error");
184        metrics.observe_state("divergence");
185        metrics.observe_state("relay_error");
186        metrics.observe_state("ack_error");
187        metrics.observe_state("healthy");
188        assert_eq!(metrics.reconnects_total(), 0);
189    }
190
191    #[test]
192    fn apply_error_during_outage_does_not_disarm_reconnect() {
193        let metrics = ReplicaLinkMetrics::default();
194        metrics.observe_state("healthy");
195        metrics.observe_state("connecting");
196        // An interleaved non-link state must not clear the armed drop.
197        metrics.observe_state("apply_error");
198        metrics.observe_state("healthy");
199        assert_eq!(metrics.reconnects_total(), 1);
200    }
201}