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 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}