reddb_server/runtime/impl_replication_commit.rs
1use super::*;
2
3impl RedDBRuntime {
4 /// PLAN.md Phase 11.4 — owned snapshot of every registered
5 /// replica's state on this primary. Returns empty vec on
6 /// non-primary instances or when no replicas are registered yet.
7 pub fn primary_replica_snapshots(&self) -> Vec<crate::replication::primary::ReplicaState> {
8 self.inner
9 .db
10 .replication
11 .as_ref()
12 .map(|repl| repl.replica_snapshots())
13 .unwrap_or_default()
14 }
15
16 /// Issue #839 — the primary's current logical-WAL head LSN, used as
17 /// the reference point for per-replica lag. `0` on non-primary
18 /// instances or before the logical spool has any records.
19 pub fn primary_logical_head_lsn(&self) -> u64 {
20 self.inner
21 .db
22 .replication
23 .as_ref()
24 .map(|repl| repl.current_logical_lsn())
25 .unwrap_or(0)
26 }
27
28 /// Issue #839 — count of pulls that forced a full re-bootstrap since
29 /// process start. The primary operator alert signal; always `0` on a
30 /// non-primary instance.
31 pub fn replication_full_resync_count(&self) -> u64 {
32 self.inner
33 .db
34 .replication
35 .as_ref()
36 .map(|repl| repl.full_resync_count())
37 .unwrap_or(0)
38 }
39
40 /// Issue #839 — count of pulls served as a partial (incremental)
41 /// resync since process start. Always `0` on a non-primary instance.
42 pub fn replication_partial_resync_count(&self) -> u64 {
43 self.inner
44 .db
45 .replication
46 .as_ref()
47 .map(|repl| repl.partial_resync_count())
48 .unwrap_or(0)
49 }
50
51 /// Issue #1243 (PRD #1237 Phase B) — count of primary↔replica
52 /// reconnects observed by this node's replica loop since process
53 /// start. A reconnect is a link that was healthy, dropped (the pull
54 /// loop fell back to `connecting`), and recovered. The initial connect
55 /// is **not** counted. Always `0` on a node whose replica loop never
56 /// ran (e.g. a standalone or primary instance). In-memory only: resets
57 /// to `0` on process restart, like the resync counters above.
58 pub fn replication_reconnects_count(&self) -> u64 {
59 self.inner.replica_link_metrics.reconnects_total()
60 }
61
62 /// Issue #1243 — this node's persisted replica identity, read-only.
63 /// Unlike [`node_id`](Self::node_id) / `resolve_replica_id` this never
64 /// generates or persists a new id; it returns the empty string when one
65 /// has not been assigned yet. Used as the bounded `replica_id`
66 /// dimension on `reddb_replication_reconnects_total`.
67 pub fn replication_replica_id(&self) -> String {
68 self.config_string("red.replication.replica_id", "")
69 }
70
71 /// Issue #1243 — drive the reconnect tracker from a replica health
72 /// state string. Production code reaches this through the replica
73 /// loop's health-persist chokepoint; exposed on the runtime so an
74 /// integration test can drive a deterministic link drop/restore
75 /// without standing up a full gRPC link on a memory-constrained host.
76 pub fn observe_replica_link_state(&self, state: &str) {
77 self.inner.replica_link_metrics.observe_state(state);
78 }
79
80 pub fn enforce_primary_replica_retention_limits(
81 &self,
82 ) -> Vec<(String, reddb_file::ReplicationSlotInvalidationCause)> {
83 self.inner
84 .db
85 .replication
86 .as_ref()
87 .map(|repl| repl.enforce_retention_limits(crate::utils::now_unix_millis() as u128))
88 .unwrap_or_default()
89 }
90
91 /// Issue #839 — this node's stable identity, surfaced as the leader
92 /// identity in `/replication/status` when the node is the primary.
93 /// Reuses the same persisted id a replica advertises to the primary,
94 /// so a cluster has one stable name per node regardless of role.
95 pub fn node_id(&self) -> String {
96 self.resolve_replica_id()
97 }
98
99 /// Issue #826 — re-evaluate write-admission flow control from the
100 /// live primary replica registry and return the resulting throttle
101 /// state. Computes the max lag across in-quorum replicas (async
102 /// read-replicas excluded) against the primary's current LSN and
103 /// engages/releases the `WriteGate` throttle accordingly.
104 ///
105 /// No-op (returns `false`) on non-primary instances or when flow
106 /// control is disabled (soft target `0`). Cheap enough to call on
107 /// the replica-ack path and from `/metrics` scrapes so the throttle
108 /// tracks lag without a dedicated background loop.
109 pub fn refresh_replication_flow_control(&self) -> bool {
110 let flow = self.inner.write_gate.flow_control();
111 if !flow.is_enabled() {
112 return false;
113 }
114 let Some(repl) = self.inner.db.replication.as_ref() else {
115 return false;
116 };
117 let primary_lsn = repl.current_logical_lsn();
118 let replicas = repl.replica_snapshots();
119 flow.observe(&replicas, primary_lsn)
120 }
121
122 /// PLAN.md Phase 11.4 — active commit policy. Reads
123 /// `RED_PRIMARY_COMMIT_POLICY` once at runtime construction;
124 /// future env reloads will need a reload endpoint. Default is
125 /// `Local` — current behavior, no replica blocking.
126 pub fn commit_policy(&self) -> crate::replication::CommitPolicy {
127 crate::replication::CommitPolicy::from_env()
128 }
129
130 /// Issue #1001 — resolve the *effective* commit policy for one collection by
131 /// combining the cluster default ([`commit_policy`](Self::commit_policy)),
132 /// the collection's declared override, the collection data model, and the
133 /// deployment's HA intent (`RED_CLUSTER_HA_INTENT`).
134 ///
135 /// Both write admission and failover eligibility call this so they read the
136 /// same decision: a durable model (transactional/queue/audit/config/vault)
137 /// may not silently use local-only acknowledgement under declared HA intent
138 /// — that returns [`CommitPolicyViolation`] and the caller must fail closed.
139 /// Explicitly ephemeral/cache-like collections may opt into local commit
140 /// with the documented failover-eligibility data-loss window.
141 pub fn resolve_commit_policy(
142 &self,
143 model: crate::cluster::CollectionDataModel,
144 collection_override: Option<crate::replication::CommitPolicy>,
145 ) -> Result<crate::cluster::CommitPolicyResolution, crate::cluster::CommitPolicyViolation> {
146 crate::cluster::resolve_commit_policy(
147 self.commit_policy(),
148 collection_override,
149 model,
150 crate::cluster::HaIntent::from_env(),
151 )
152 }
153
154 pub fn primary_replica_durability(&self) -> reddb_file::ReplicationDurability {
155 Self::primary_replica_durability_for_policy(self.commit_policy())
156 }
157
158 pub(crate) fn primary_replica_durability_for_policy(
159 policy: crate::replication::CommitPolicy,
160 ) -> reddb_file::ReplicationDurability {
161 match policy {
162 crate::replication::CommitPolicy::AckN(n) if n > 0 => {
163 reddb_file::ReplicationDurability::RemoteFlush {
164 quorum: u16::try_from(n).unwrap_or(u16::MAX),
165 }
166 }
167 _ => reddb_file::ReplicationDurability::Async,
168 }
169 }
170
171 /// PLAN.md Phase 11.5 — accessor for replica-side apply error
172 /// counters (gap / divergence / apply / decode / apply_miss). Returned
173 /// snapshot is consistent across the counters; the labels match
174 /// `reddb_replica_apply_errors_total{kind}`. Issue #814 adds the
175 /// `apply_miss` kind for deletes against a missing target.
176 pub fn replica_apply_error_counts(
177 &self,
178 ) -> [(crate::replication::logical::ApplyErrorKind, u64); 6] {
179 self.inner.replica_apply_metrics.snapshot()
180 }
181
182 /// Issue #1242 — accessor for WAL apply throughput counters.
183 /// Returns `(bytes_applied_total, records_applied_total)` as a
184 /// monotonic snapshot since process start. Used to export
185 /// `reddb_replication_apply_bytes_total` and
186 /// `reddb_replication_apply_records_total` via `/metrics`.
187 pub fn replica_apply_throughput_counts(&self) -> (u64, u64) {
188 self.inner.replica_apply_metrics.snapshot_throughput()
189 }
190
191 /// PLAN.md Phase 11.4 — observability snapshot of every
192 /// replica's durable LSN as known to the commit waiter. Empty
193 /// vec on non-primary instances or when no replica has acked.
194 pub fn commit_waiter_snapshot(&self) -> Vec<(String, u64)> {
195 self.inner
196 .db
197 .replication
198 .as_ref()
199 .map(|repl| repl.commit_waiter.snapshot())
200 .unwrap_or_default()
201 }
202
203 /// PLAN.md Phase 11.4 — `(reached, timed_out, not_required, last_micros)`
204 /// counters for /metrics. Always-zero on non-primary instances.
205 pub fn commit_waiter_metrics_snapshot(&self) -> (u64, u64, u64, u64) {
206 self.inner
207 .db
208 .replication
209 .as_ref()
210 .map(|repl| repl.commit_waiter.metrics_snapshot())
211 .unwrap_or((0, 0, 0, 0))
212 }
213
214 /// Named commit watermark: highest LSN durable on the active
215 /// synchronous commit quorum. Returns 0 when the active policy does
216 /// not require replica durability.
217 pub fn commit_watermark(&self) -> u64 {
218 match self.primary_replica_durability() {
219 reddb_file::ReplicationDurability::RemoteWrite { quorum }
220 | reddb_file::ReplicationDurability::RemoteFlush { quorum }
221 | reddb_file::ReplicationDurability::RemoteApply { quorum }
222 if quorum > 0 =>
223 {
224 self.inner
225 .db
226 .replication
227 .as_ref()
228 .map(|repl| repl.commit_waiter.commit_watermark(u32::from(quorum)))
229 .unwrap_or(0)
230 }
231 _ if matches!(
232 self.commit_policy(),
233 crate::replication::CommitPolicy::Quorum
234 ) =>
235 {
236 self.inner
237 .db
238 .quorum
239 .as_ref()
240 .map(|q| q.commit_watermark())
241 .unwrap_or(0)
242 }
243 _ => 0,
244 }
245 }
246
247 /// PLAN.md Phase 11.4 — block until at least `count` replicas
248 /// have durably applied through `target_lsn`, or `timeout`
249 /// elapses. Returns the `AwaitOutcome` so the caller can decide
250 /// whether to surface a timeout error to the client or continue
251 /// (the policy mapping lives in the commit dispatcher).
252 ///
253 /// Used by the `ack_n` commit policy once the operator flips
254 /// `RED_PRIMARY_COMMIT_POLICY` away from `local`.
255 pub fn await_replica_acks(
256 &self,
257 target_lsn: u64,
258 count: u32,
259 timeout: std::time::Duration,
260 ) -> crate::replication::AwaitOutcome {
261 match &self.inner.db.replication {
262 Some(repl) => repl.commit_waiter.await_acks(target_lsn, count, timeout),
263 None => {
264 // No replication configured: policy must be `Local`.
265 // Treat as immediate `NotRequired` so callers don't
266 // block on a degenerate setup.
267 crate::replication::AwaitOutcome::NotRequired
268 }
269 }
270 }
271
272 /// PLAN.md Phase 11.4 — enforce the configured commit policy
273 /// against `post_lsn` (the LSN of the just-completed write).
274 /// Returns `Ok(AwaitOutcome)` on every successful enforcement
275 /// (including `Reached` and `TimedOut` when fail-on-timeout is
276 /// off). Returns `Err(ReadOnly)` only when a synchronous policy
277 /// misses its threshold and `RED_COMMIT_FAIL_ON_TIMEOUT=true` is
278 /// set.
279 ///
280 /// The HTTP / gRPC / wire surfaces map the error to 504 / wire
281 /// backoff. Default behaviour (env unset) logs warn and returns
282 /// success — matches PLAN.md "default v1 stays local" semantics
283 /// while still letting the operator opt into hard-blocking.
284 pub fn enforce_commit_policy(
285 &self,
286 post_lsn: u64,
287 ) -> RedDBResult<crate::replication::AwaitOutcome> {
288 let policy = self.commit_policy();
289 if matches!(policy, crate::replication::CommitPolicy::Quorum) {
290 return match self.inner.db.wait_for_replication_quorum(post_lsn) {
291 Ok(()) => Ok(crate::replication::AwaitOutcome::Reached(0)),
292 Err(err) => {
293 tracing::warn!(
294 target: "reddb::commit",
295 post_lsn,
296 error = %err,
297 "quorum: timed out waiting for commit watermark"
298 );
299 let fail = std::env::var("RED_COMMIT_FAIL_ON_TIMEOUT")
300 .ok()
301 .map(|v| {
302 let t = v.trim();
303 t.eq_ignore_ascii_case("true")
304 || t == "1"
305 || t.eq_ignore_ascii_case("yes")
306 })
307 .unwrap_or(false);
308 if fail {
309 return Err(RedDBError::ReadOnly(format!(
310 "commit policy timed out at lsn {post_lsn}: {err} (RED_COMMIT_FAIL_ON_TIMEOUT=true)"
311 )));
312 }
313 Ok(crate::replication::AwaitOutcome::TimedOut {
314 observed: 0,
315 required: 1,
316 })
317 }
318 };
319 }
320
321 let durability = Self::primary_replica_durability_for_policy(policy);
322 let n = match durability {
323 reddb_file::ReplicationDurability::RemoteWrite { quorum }
324 | reddb_file::ReplicationDurability::RemoteFlush { quorum }
325 | reddb_file::ReplicationDurability::RemoteApply { quorum }
326 if quorum > 0 =>
327 {
328 u32::from(quorum)
329 }
330 _ => return Ok(crate::replication::AwaitOutcome::NotRequired),
331 };
332 let timeout_ms = std::env::var("RED_REPLICATION_ACK_TIMEOUT_MS")
333 .ok()
334 .and_then(|v| v.parse::<u64>().ok())
335 .unwrap_or(5_000);
336 let outcome =
337 self.await_replica_acks(post_lsn, n, std::time::Duration::from_millis(timeout_ms));
338 {
339 use crate::runtime::control_events::{EventKind, Outcome, Sensitivity};
340 let (event_outcome, fields) = match &outcome {
341 crate::replication::AwaitOutcome::Reached(count) => (
342 Outcome::Allowed,
343 vec![
344 (
345 "post_lsn".to_string(),
346 Sensitivity::raw(post_lsn.to_string()),
347 ),
348 ("required".to_string(), Sensitivity::raw(n.to_string())),
349 ("observed".to_string(), Sensitivity::raw(count.to_string())),
350 (
351 "timeout_ms".to_string(),
352 Sensitivity::raw(timeout_ms.to_string()),
353 ),
354 ],
355 ),
356 crate::replication::AwaitOutcome::TimedOut { observed, required } => (
357 Outcome::Error,
358 vec![
359 (
360 "post_lsn".to_string(),
361 Sensitivity::raw(post_lsn.to_string()),
362 ),
363 (
364 "required".to_string(),
365 Sensitivity::raw(required.to_string()),
366 ),
367 (
368 "observed".to_string(),
369 Sensitivity::raw(observed.to_string()),
370 ),
371 (
372 "timeout_ms".to_string(),
373 Sensitivity::raw(timeout_ms.to_string()),
374 ),
375 ],
376 ),
377 crate::replication::AwaitOutcome::NotRequired => (Outcome::Allowed, Vec::new()),
378 };
379 if !fields.is_empty() {
380 self.emit_control_event(
381 EventKind::ReplicationSafety,
382 event_outcome,
383 "replication_commit_policy",
384 Some(format!("replication:lsn:{post_lsn}")),
385 None,
386 fields,
387 )?;
388 }
389 }
390 if let crate::replication::AwaitOutcome::TimedOut { observed, required } = &outcome {
391 tracing::warn!(
392 target: "reddb::commit",
393 post_lsn,
394 observed = *observed,
395 required = *required,
396 timeout_ms,
397 "ack_n: timed out waiting for replicas"
398 );
399 let fail = std::env::var("RED_COMMIT_FAIL_ON_TIMEOUT")
400 .ok()
401 .map(|v| {
402 let t = v.trim();
403 t.eq_ignore_ascii_case("true") || t == "1" || t.eq_ignore_ascii_case("yes")
404 })
405 .unwrap_or(false);
406 if fail {
407 return Err(RedDBError::ReadOnly(format!(
408 "commit policy timed out at lsn {post_lsn}: observed={observed} required={required} (RED_COMMIT_FAIL_ON_TIMEOUT=true)"
409 )));
410 }
411 }
412 Ok(outcome)
413 }
414}