Skip to main content

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}