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 resolve_request_commit_policy(
155 &self,
156 request_override: Option<crate::replication::CommitPolicy>,
157 ) -> Result<crate::cluster::CommitPolicyResolution, crate::cluster::CommitPolicyViolation> {
158 let floor = crate::cluster::CommitPolicyResolution {
159 effective: self.commit_policy(),
160 source: crate::cluster::ResolutionSource::ClusterDefault,
161 guardrail: crate::cluster::GuardrailDisposition::NotApplicable,
162 };
163 crate::cluster::resolve_request_commit_policy(floor, request_override)
164 }
165
166 pub fn primary_replica_durability(&self) -> reddb_file::ReplicationDurability {
167 Self::primary_replica_durability_for_policy(self.commit_policy())
168 }
169
170 pub(crate) fn primary_replica_durability_for_policy(
171 policy: crate::replication::CommitPolicy,
172 ) -> reddb_file::ReplicationDurability {
173 match policy {
174 crate::replication::CommitPolicy::AckN(n) if n > 0 => {
175 reddb_file::ReplicationDurability::RemoteFlush {
176 quorum: u16::try_from(n).unwrap_or(u16::MAX),
177 }
178 }
179 _ => reddb_file::ReplicationDurability::Async,
180 }
181 }
182
183 /// PLAN.md Phase 11.5 — accessor for replica-side apply error
184 /// counters (gap / divergence / apply / decode / apply_miss). Returned
185 /// snapshot is consistent across the counters; the labels match
186 /// `reddb_replica_apply_errors_total{kind}`. Issue #814 adds the
187 /// `apply_miss` kind for deletes against a missing target.
188 pub fn replica_apply_error_counts(
189 &self,
190 ) -> [(crate::replication::logical::ApplyErrorKind, u64); 6] {
191 self.inner.replica_apply_metrics.snapshot()
192 }
193
194 /// Issue #1242 — accessor for WAL apply throughput counters.
195 /// Returns `(bytes_applied_total, records_applied_total)` as a
196 /// monotonic snapshot since process start. Used to export
197 /// `reddb_replication_apply_bytes_total` and
198 /// `reddb_replication_apply_records_total` via `/metrics`.
199 pub fn replica_apply_throughput_counts(&self) -> (u64, u64) {
200 self.inner.replica_apply_metrics.snapshot_throughput()
201 }
202
203 /// PLAN.md Phase 11.4 — observability snapshot of every
204 /// replica's durable LSN as known to the commit waiter. Empty
205 /// vec on non-primary instances or when no replica has acked.
206 pub fn commit_waiter_snapshot(&self) -> Vec<(String, u64)> {
207 self.inner
208 .db
209 .replication
210 .as_ref()
211 .map(|repl| repl.commit_waiter.snapshot())
212 .unwrap_or_default()
213 }
214
215 /// PLAN.md Phase 11.4 — `(reached, timed_out, not_required, last_micros)`
216 /// counters for /metrics. Always-zero on non-primary instances.
217 pub fn commit_waiter_metrics_snapshot(&self) -> (u64, u64, u64, u64) {
218 self.inner
219 .db
220 .replication
221 .as_ref()
222 .map(|repl| repl.commit_waiter.metrics_snapshot())
223 .unwrap_or((0, 0, 0, 0))
224 }
225
226 /// Named commit watermark: highest LSN durable on the active
227 /// synchronous commit quorum. Returns 0 when the active policy does
228 /// not require replica durability.
229 pub fn commit_watermark(&self) -> u64 {
230 match self.primary_replica_durability() {
231 reddb_file::ReplicationDurability::RemoteWrite { quorum }
232 | reddb_file::ReplicationDurability::RemoteFlush { quorum }
233 | reddb_file::ReplicationDurability::RemoteApply { quorum }
234 if quorum > 0 =>
235 {
236 self.inner
237 .db
238 .replication
239 .as_ref()
240 .map(|repl| repl.commit_waiter.commit_watermark(u32::from(quorum)))
241 .unwrap_or(0)
242 }
243 _ if matches!(
244 self.commit_policy(),
245 crate::replication::CommitPolicy::Quorum
246 ) =>
247 {
248 self.inner
249 .db
250 .quorum
251 .as_ref()
252 .map(|q| q.commit_watermark())
253 .unwrap_or(0)
254 }
255 _ => 0,
256 }
257 }
258
259 /// PLAN.md Phase 11.4 — block until at least `count` replicas
260 /// have durably applied through `target_lsn`, or `timeout`
261 /// elapses. Returns the `AwaitOutcome` so the caller can decide
262 /// whether to surface a timeout error to the client or continue
263 /// (the policy mapping lives in the commit dispatcher).
264 ///
265 /// Used by the `ack_n` commit policy once the operator flips
266 /// `RED_PRIMARY_COMMIT_POLICY` away from `local`.
267 pub fn await_replica_acks(
268 &self,
269 target_lsn: u64,
270 count: u32,
271 timeout: std::time::Duration,
272 ) -> crate::replication::AwaitOutcome {
273 match &self.inner.db.replication {
274 Some(repl) => repl.commit_waiter.await_acks(target_lsn, count, timeout),
275 None => {
276 // No replication configured: policy must be `Local`.
277 // Treat as immediate `NotRequired` so callers don't
278 // block on a degenerate setup.
279 crate::replication::AwaitOutcome::NotRequired
280 }
281 }
282 }
283
284 /// PLAN.md Phase 11.4 — enforce the configured commit policy
285 /// against `post_lsn` (the LSN of the just-completed write).
286 /// Returns `Ok(AwaitOutcome)` on every successful enforcement
287 /// (including `Reached` and `TimedOut` when fail-on-timeout is
288 /// off). Returns `Err(ReadOnly)` only when a synchronous policy
289 /// misses its threshold and `RED_COMMIT_FAIL_ON_TIMEOUT=true` is
290 /// set.
291 ///
292 /// The HTTP / gRPC / wire surfaces map the error to 504 / wire
293 /// backoff. Default behaviour (env unset) logs warn and returns
294 /// success — matches PLAN.md "default v1 stays local" semantics
295 /// while still letting the operator opt into hard-blocking.
296 pub fn enforce_commit_policy(
297 &self,
298 post_lsn: u64,
299 ) -> RedDBResult<crate::replication::AwaitOutcome> {
300 self.enforce_commit_policy_for_policy(post_lsn, self.commit_policy())
301 }
302
303 pub fn enforce_commit_policy_for_request(
304 &self,
305 post_lsn: u64,
306 request_override: Option<crate::replication::CommitPolicy>,
307 ) -> RedDBResult<crate::replication::AwaitOutcome> {
308 let resolution = self
309 .resolve_request_commit_policy(request_override)
310 .map_err(commit_policy_violation_error)?;
311 self.enforce_commit_policy_for_policy(post_lsn, resolution.effective)
312 }
313
314 fn enforce_commit_policy_for_policy(
315 &self,
316 post_lsn: u64,
317 policy: crate::replication::CommitPolicy,
318 ) -> RedDBResult<crate::replication::AwaitOutcome> {
319 if matches!(policy, crate::replication::CommitPolicy::Quorum) {
320 return match self.inner.db.wait_for_replication_quorum(post_lsn) {
321 Ok(()) => Ok(crate::replication::AwaitOutcome::Reached(0)),
322 Err(err) => {
323 tracing::warn!(
324 target: "reddb::commit",
325 post_lsn,
326 error = %err,
327 "quorum: timed out waiting for commit watermark"
328 );
329 let fail = std::env::var("RED_COMMIT_FAIL_ON_TIMEOUT")
330 .ok()
331 .map(|v| {
332 let t = v.trim();
333 t.eq_ignore_ascii_case("true")
334 || t == "1"
335 || t.eq_ignore_ascii_case("yes")
336 })
337 .unwrap_or(false);
338 if fail {
339 return Err(RedDBError::ReadOnly(format!(
340 "commit policy timed out at lsn {post_lsn}: {err} (RED_COMMIT_FAIL_ON_TIMEOUT=true)"
341 )));
342 }
343 Ok(crate::replication::AwaitOutcome::TimedOut {
344 observed: 0,
345 required: 1,
346 })
347 }
348 };
349 }
350
351 let durability = Self::primary_replica_durability_for_policy(policy);
352 let n = match durability {
353 reddb_file::ReplicationDurability::RemoteWrite { quorum }
354 | reddb_file::ReplicationDurability::RemoteFlush { quorum }
355 | reddb_file::ReplicationDurability::RemoteApply { quorum }
356 if quorum > 0 =>
357 {
358 u32::from(quorum)
359 }
360 _ => return Ok(crate::replication::AwaitOutcome::NotRequired),
361 };
362 let timeout_ms = std::env::var("RED_REPLICATION_ACK_TIMEOUT_MS")
363 .ok()
364 .and_then(|v| v.parse::<u64>().ok())
365 .unwrap_or(5_000);
366 let outcome =
367 self.await_replica_acks(post_lsn, n, std::time::Duration::from_millis(timeout_ms));
368 {
369 use crate::runtime::control_events::{EventKind, Outcome, Sensitivity};
370 let (event_outcome, fields) = match &outcome {
371 crate::replication::AwaitOutcome::Reached(count) => (
372 Outcome::Allowed,
373 vec![
374 (
375 "post_lsn".to_string(),
376 Sensitivity::raw(post_lsn.to_string()),
377 ),
378 ("required".to_string(), Sensitivity::raw(n.to_string())),
379 ("observed".to_string(), Sensitivity::raw(count.to_string())),
380 (
381 "timeout_ms".to_string(),
382 Sensitivity::raw(timeout_ms.to_string()),
383 ),
384 ],
385 ),
386 crate::replication::AwaitOutcome::TimedOut { observed, required } => (
387 Outcome::Error,
388 vec![
389 (
390 "post_lsn".to_string(),
391 Sensitivity::raw(post_lsn.to_string()),
392 ),
393 (
394 "required".to_string(),
395 Sensitivity::raw(required.to_string()),
396 ),
397 (
398 "observed".to_string(),
399 Sensitivity::raw(observed.to_string()),
400 ),
401 (
402 "timeout_ms".to_string(),
403 Sensitivity::raw(timeout_ms.to_string()),
404 ),
405 ],
406 ),
407 crate::replication::AwaitOutcome::NotRequired => (Outcome::Allowed, Vec::new()),
408 };
409 if !fields.is_empty() {
410 self.emit_control_event(
411 EventKind::ReplicationSafety,
412 event_outcome,
413 "replication_commit_policy",
414 Some(format!("replication:lsn:{post_lsn}")),
415 None,
416 fields,
417 )?;
418 }
419 }
420 if let crate::replication::AwaitOutcome::TimedOut { observed, required } = &outcome {
421 tracing::warn!(
422 target: "reddb::commit",
423 post_lsn,
424 observed = *observed,
425 required = *required,
426 timeout_ms,
427 "ack_n: timed out waiting for replicas"
428 );
429 let fail = std::env::var("RED_COMMIT_FAIL_ON_TIMEOUT")
430 .ok()
431 .map(|v| {
432 let t = v.trim();
433 t.eq_ignore_ascii_case("true") || t == "1" || t.eq_ignore_ascii_case("yes")
434 })
435 .unwrap_or(false);
436 if fail {
437 return Err(RedDBError::ReadOnly(format!(
438 "commit policy timed out at lsn {post_lsn}: observed={observed} required={required} (RED_COMMIT_FAIL_ON_TIMEOUT=true)"
439 )));
440 }
441 }
442 Ok(outcome)
443 }
444}
445
446fn commit_policy_violation_error(violation: crate::cluster::CommitPolicyViolation) -> RedDBError {
447 let code = violation.code();
448 let detail = violation.message();
449 let mut validation = crate::json::Map::new();
450 validation.insert(
451 "code".to_string(),
452 crate::json::Value::String(code.to_string()),
453 );
454 validation.insert(
455 "message".to_string(),
456 crate::json::Value::String(detail.clone()),
457 );
458 RedDBError::Validation {
459 message: format!("{code}: {detail}"),
460 validation: crate::json::Value::Object(validation),
461 }
462}