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}