Skip to main content

reddb_server/cluster/
commit_resolution.rs

1//! Commit policy resolution for multi-writer clusters (issue #1001, PRD #987).
2//!
3//! A cluster has one global default [`CommitPolicy`], and a collection may
4//! declare a stricter or looser override when its model semantics justify it
5//! (see the [clustering glossary](../../../.red/context/clustering.md) entries
6//! *Commit policy* and *Ephemeral-local commit*). This module is the single
7//! deterministic place that combines those two inputs into the **effective**
8//! policy a write actually commits under, and enforces the one safety rule that
9//! the raw [`CommitPolicy`] type cannot express on its own:
10//!
11//! > Durable transactional, queue, audit, config, and vault collections must not
12//! > *silently* use local-only acknowledgement once HA intent is declared.
13//! > Only collections explicitly declared ephemeral/cache-like may opt into
14//! > `local` commit, and they do so with documented failover semantics.
15//!
16//! ## Why a resolver rather than a field on the collection
17//!
18//! The effective policy is a function of three independent inputs — the cluster
19//! default, the per-collection override, and whether the deployment has declared
20//! HA intent — and the guardrail couples all three. Resolving them ad hoc at each
21//! call site (write admission *and* failover eligibility both need the answer)
22//! would let the two paths drift, so a misconfigured durable collection could be
23//! admitted with `local` on the write path while failover still believed it was
24//! quorum-durable. A single pure resolver keeps both paths reading the same
25//! decision and makes the guardrail testable in isolation.
26//!
27//! ## Resolution
28//!
29//! 1. The effective policy is the collection override if present, otherwise the
30//!    cluster default ([`ResolutionSource`] records which won).
31//! 2. If the effective policy is local-only acknowledgement (`Local`, or the
32//!    degenerate `AckN(0)` which [the policy docs](super::super::replication::commit_policy)
33//!    define as equivalent to `Local`) **and** HA intent is declared:
34//!    - a **durable** model ([`CollectionDataModel::is_durable`]) is rejected with
35//!      [`CommitPolicyViolation::DurableLocalUnderHa`] — fail closed, the caller
36//!      must not admit writes under a silently-degraded policy.
37//!    - an **ephemeral/cache-like** model is allowed, tagged
38//!      [`GuardrailDisposition::EphemeralLocalAllowed`] so the decision is
39//!      explicit in the audit trail.
40//! 3. Otherwise the resolution succeeds; the guardrail is
41//!    [`GuardrailDisposition::Satisfied`] for a durable model under declared HA
42//!    intent (the effective policy is genuinely durable), or
43//!    [`GuardrailDisposition::NotApplicable`] when HA intent is not declared.
44//!
45//! The resolved policy also reports its **failover eligibility**
46//! ([`CommitPolicyResolution::failover_eligibility`]): a durable policy means a
47//! candidate may be promoted only if its log covers the range commit watermark,
48//! while a local-ack policy carries an explicit data-loss window — the documented
49//! failover semantics ephemeral/cache collections accept in exchange for `local`.
50
51use crate::replication::CommitPolicy;
52
53/// The durability model a collection declares for itself. The first five are
54/// **durable** models whose data must survive a single-node loss; the last two
55/// are explicitly **local-eligible** — losing their most recent unreplicated
56/// writes on failover is an accepted trade for lower write latency.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CollectionDataModel {
59    /// Durable transactional records — the default model for user data.
60    Transactional,
61    /// Durable work-queue collection (at-least-once delivery semantics).
62    Queue,
63    /// Append-only audit log.
64    Audit,
65    /// Cluster/application configuration.
66    Config,
67    /// Secret/credential material.
68    Vault,
69    /// Explicitly ephemeral data with no durability expectation.
70    Ephemeral,
71    /// Cache-like data that can be rebuilt from a source of truth.
72    Cache,
73}
74
75impl CollectionDataModel {
76    /// `true` for models whose data must survive a single-node loss and so may
77    /// never silently acknowledge a write locally under declared HA intent.
78    pub fn is_durable(self) -> bool {
79        match self {
80            Self::Transactional | Self::Queue | Self::Audit | Self::Config | Self::Vault => true,
81            Self::Ephemeral | Self::Cache => false,
82        }
83    }
84
85    /// `true` for the explicitly local-eligible models (`Ephemeral`, `Cache`)
86    /// that may opt into local commit even under declared HA intent.
87    pub fn allows_ephemeral_local(self) -> bool {
88        !self.is_durable()
89    }
90
91    pub fn label(self) -> &'static str {
92        match self {
93            Self::Transactional => "transactional",
94            Self::Queue => "queue",
95            Self::Audit => "audit",
96            Self::Config => "config",
97            Self::Vault => "vault",
98            Self::Ephemeral => "ephemeral",
99            Self::Cache => "cache",
100        }
101    }
102}
103
104/// Whether the deployment has declared HA intent. The guardrail only restricts
105/// local-only acknowledgement once intent is [`Declared`](Self::Declared); a
106/// single-writer / non-HA deployment resolves policies without restriction.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
108pub enum HaIntent {
109    /// Multi-writer HA mode: durable models may not silently use `local`.
110    Declared,
111    /// No HA intent declared — the guardrail does not apply.
112    #[default]
113    None,
114}
115
116impl HaIntent {
117    pub fn is_declared(self) -> bool {
118        matches!(self, Self::Declared)
119    }
120
121    /// Parse from `RED_CLUSTER_HA_INTENT`. Truthy (`true`/`1`/`yes`/`declared`)
122    /// means [`Declared`](Self::Declared); anything else (including unset) means
123    /// [`None`](Self::None) so the guardrail stays off unless opted into.
124    pub fn from_env() -> Self {
125        match std::env::var("RED_CLUSTER_HA_INTENT") {
126            Ok(raw) => Self::parse(raw.trim()),
127            Err(_) => Self::None,
128        }
129    }
130
131    pub fn parse(raw: &str) -> Self {
132        let t = raw.trim();
133        if t.eq_ignore_ascii_case("true")
134            || t == "1"
135            || t.eq_ignore_ascii_case("yes")
136            || t.eq_ignore_ascii_case("declared")
137        {
138            Self::Declared
139        } else {
140            Self::None
141        }
142    }
143}
144
145/// Which input supplied the effective policy.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ResolutionSource {
148    /// No collection override; the cluster global default applied.
149    ClusterDefault,
150    /// The collection's own override applied.
151    CollectionOverride,
152    /// A per-request override strengthened the already-resolved floor.
153    RequestOverride,
154}
155
156impl ResolutionSource {
157    pub fn label(self) -> &'static str {
158        match self {
159            Self::ClusterDefault => "cluster_default",
160            Self::CollectionOverride => "collection_override",
161            Self::RequestOverride => "request_override",
162        }
163    }
164}
165
166/// How the ephemeral-local guardrail dispositioned a successful resolution.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum GuardrailDisposition {
169    /// HA intent not declared — the guardrail did not run.
170    NotApplicable,
171    /// Durable model under declared HA intent with a genuinely durable effective
172    /// policy: the guardrail ran and was satisfied.
173    Satisfied,
174    /// Ephemeral/cache-like model explicitly permitted to use local commit under
175    /// declared HA intent (documented failover semantics apply).
176    EphemeralLocalAllowed,
177}
178
179/// Failover implication of a resolved commit policy. Consumed by failover
180/// eligibility: a durable policy gates promotion on watermark coverage, while a
181/// local-ack policy admits an explicit data-loss window on the promoted node.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum FailoverEligibility {
184    /// The effective policy is durable: a candidate may be promoted only if its
185    /// applied log covers the range commit watermark.
186    RequiresWatermarkCoverage,
187    /// The effective policy is local-only: a promoted candidate may not have the
188    /// failed owner's most recent local-only writes — an accepted, documented
189    /// loss window for ephemeral/cache-like data.
190    LocalAckDataLossWindow,
191}
192
193/// The deterministic outcome of resolving a cluster default + collection
194/// override + HA intent against a collection's data model.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct CommitPolicyResolution {
197    /// The policy the write actually commits under.
198    pub effective: CommitPolicy,
199    /// Which input supplied [`effective`](Self::effective).
200    pub source: ResolutionSource,
201    /// How the guardrail dispositioned this resolution.
202    pub guardrail: GuardrailDisposition,
203}
204
205impl CommitPolicyResolution {
206    /// `true` when the effective policy requires durability beyond the local WAL,
207    /// i.e. failover must gate promotion on range-commit-watermark coverage.
208    pub fn requires_durable_watermark(&self) -> bool {
209        !is_local_ack(self.effective)
210    }
211
212    /// Failover implication of the resolved policy. See [`FailoverEligibility`].
213    pub fn failover_eligibility(&self) -> FailoverEligibility {
214        if self.requires_durable_watermark() {
215            FailoverEligibility::RequiresWatermarkCoverage
216        } else {
217            FailoverEligibility::LocalAckDataLossWindow
218        }
219    }
220}
221
222/// Rejection raised when resolution would silently degrade a durable model to
223/// local-only acknowledgement under declared HA intent. The caller must fail
224/// closed rather than admit writes under the degraded policy.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum CommitPolicyViolation {
227    /// A durable model resolved to local-only acknowledgement under declared HA
228    /// intent. `source` records whether the offending policy came from the
229    /// cluster default or the collection's own override.
230    DurableLocalUnderHa {
231        model: CollectionDataModel,
232        source: ResolutionSource,
233    },
234    /// A per-request override attempted to weaken the already-resolved floor.
235    RequestBelowResolvedFloor {
236        floor: CommitPolicy,
237        requested: CommitPolicy,
238    },
239}
240
241impl CommitPolicyViolation {
242    pub fn message(&self) -> String {
243        match self {
244            Self::DurableLocalUnderHa { model, source } => format!(
245                "durable collection model '{}' may not use local-only commit acknowledgement \
246                 under declared HA intent (policy source: {})",
247                model.label(),
248                source.label()
249            ),
250            Self::RequestBelowResolvedFloor { floor, requested } => format!(
251                "per-request commit policy '{}' is weaker than resolved floor '{}'",
252                requested.detail_label(),
253                floor.detail_label()
254            ),
255        }
256    }
257
258    pub fn code(&self) -> &'static str {
259        match self {
260            Self::DurableLocalUnderHa { .. } => "DURABLE_LOCAL_UNDER_HA",
261            Self::RequestBelowResolvedFloor { .. } => "COMMIT_POLICY_BELOW_FLOOR",
262        }
263    }
264}
265
266impl std::fmt::Display for CommitPolicyViolation {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        f.write_str(&self.message())
269    }
270}
271
272impl std::error::Error for CommitPolicyViolation {}
273
274/// `true` when `policy` acknowledges a commit on local WAL durability alone:
275/// `Local`, or the degenerate `AckN(0)` the policy docs define as equivalent.
276pub fn is_local_ack(policy: CommitPolicy) -> bool {
277    matches!(policy, CommitPolicy::Local | CommitPolicy::AckN(0))
278}
279
280fn durability_rank(policy: CommitPolicy) -> u64 {
281    match policy {
282        CommitPolicy::Local | CommitPolicy::AckN(0) => 0,
283        CommitPolicy::RemoteWal => 10,
284        CommitPolicy::AckN(n) => 100 + u64::from(n),
285        CommitPolicy::Quorum => 10_000,
286    }
287}
288
289/// Apply an optional per-request override to an already-resolved floor.
290///
291/// The request may strengthen durability for one write, but it may not weaken
292/// the floor chosen by collection/HA resolution. Weakening is rejected rather
293/// than clamped so callers can surface a typed client error.
294pub fn resolve_request_commit_policy(
295    floor: CommitPolicyResolution,
296    request_override: Option<CommitPolicy>,
297) -> Result<CommitPolicyResolution, CommitPolicyViolation> {
298    let Some(requested) = request_override else {
299        return Ok(floor);
300    };
301
302    if durability_rank(requested) < durability_rank(floor.effective) {
303        return Err(CommitPolicyViolation::RequestBelowResolvedFloor {
304            floor: floor.effective,
305            requested,
306        });
307    }
308
309    Ok(CommitPolicyResolution {
310        effective: requested,
311        source: ResolutionSource::RequestOverride,
312        guardrail: floor.guardrail,
313    })
314}
315
316/// Deterministically resolve the effective commit policy for one collection.
317///
318/// `cluster_default` is the global default; `collection_override` is the
319/// collection's declared override (if any); `model` is the collection's
320/// durability model; `ha_intent` is whether the deployment declared HA intent.
321///
322/// Returns the resolved policy, or [`CommitPolicyViolation`] when the guardrail
323/// rejects a durable model degraded to local-only acknowledgement under HA
324/// intent. The function is pure and side-effect free.
325pub fn resolve_commit_policy(
326    cluster_default: CommitPolicy,
327    collection_override: Option<CommitPolicy>,
328    model: CollectionDataModel,
329    ha_intent: HaIntent,
330) -> Result<CommitPolicyResolution, CommitPolicyViolation> {
331    let (effective, source) = match collection_override {
332        Some(p) => (p, ResolutionSource::CollectionOverride),
333        None => (cluster_default, ResolutionSource::ClusterDefault),
334    };
335
336    let guardrail = if !ha_intent.is_declared() {
337        // No HA intent: the guardrail does not constrain the resolution.
338        GuardrailDisposition::NotApplicable
339    } else if is_local_ack(effective) {
340        if model.is_durable() {
341            return Err(CommitPolicyViolation::DurableLocalUnderHa { model, source });
342        }
343        // Ephemeral/cache-like: explicitly permitted to opt into local commit.
344        GuardrailDisposition::EphemeralLocalAllowed
345    } else {
346        // Durable model under declared HA intent with a genuinely durable policy.
347        GuardrailDisposition::Satisfied
348    };
349
350    Ok(CommitPolicyResolution {
351        effective,
352        source,
353        guardrail,
354    })
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    const DURABLE: [CollectionDataModel; 5] = [
362        CollectionDataModel::Transactional,
363        CollectionDataModel::Queue,
364        CollectionDataModel::Audit,
365        CollectionDataModel::Config,
366        CollectionDataModel::Vault,
367    ];
368    const LOCAL_ELIGIBLE: [CollectionDataModel; 2] =
369        [CollectionDataModel::Ephemeral, CollectionDataModel::Cache];
370
371    #[test]
372    fn data_model_durability_partition() {
373        for m in DURABLE {
374            assert!(m.is_durable(), "{} should be durable", m.label());
375            assert!(!m.allows_ephemeral_local());
376        }
377        for m in LOCAL_ELIGIBLE {
378            assert!(!m.is_durable(), "{} should not be durable", m.label());
379            assert!(m.allows_ephemeral_local());
380        }
381    }
382
383    #[test]
384    fn is_local_ack_treats_ack0_as_local() {
385        assert!(is_local_ack(CommitPolicy::Local));
386        assert!(is_local_ack(CommitPolicy::AckN(0)));
387        assert!(!is_local_ack(CommitPolicy::AckN(1)));
388        assert!(!is_local_ack(CommitPolicy::Quorum));
389        assert!(!is_local_ack(CommitPolicy::RemoteWal));
390    }
391
392    // AC: default quorum behavior — cluster default applies with no override.
393    #[test]
394    fn cluster_default_quorum_applies_without_override() {
395        let r = resolve_commit_policy(
396            CommitPolicy::Quorum,
397            None,
398            CollectionDataModel::Transactional,
399            HaIntent::Declared,
400        )
401        .expect("quorum default is durable under HA");
402        assert_eq!(r.effective, CommitPolicy::Quorum);
403        assert_eq!(r.source, ResolutionSource::ClusterDefault);
404        assert_eq!(r.guardrail, GuardrailDisposition::Satisfied);
405        assert_eq!(
406            r.failover_eligibility(),
407            FailoverEligibility::RequiresWatermarkCoverage
408        );
409    }
410
411    // AC: collection override — a stricter/looser override beats the default.
412    #[test]
413    fn collection_override_beats_cluster_default() {
414        let r = resolve_commit_policy(
415            CommitPolicy::AckN(1),
416            Some(CommitPolicy::Quorum),
417            CollectionDataModel::Audit,
418            HaIntent::Declared,
419        )
420        .expect("override quorum is durable");
421        assert_eq!(r.effective, CommitPolicy::Quorum);
422        assert_eq!(r.source, ResolutionSource::CollectionOverride);
423        assert_eq!(r.guardrail, GuardrailDisposition::Satisfied);
424    }
425
426    #[test]
427    fn request_override_can_strengthen_above_resolved_floor() {
428        let floor = resolve_commit_policy(
429            CommitPolicy::Local,
430            None,
431            CollectionDataModel::Transactional,
432            HaIntent::None,
433        )
434        .expect("non-HA local floor resolves");
435
436        let r = resolve_request_commit_policy(floor, Some(CommitPolicy::Quorum))
437            .expect("request may strengthen local floor to quorum");
438        assert_eq!(r.effective, CommitPolicy::Quorum);
439        assert_eq!(r.source, ResolutionSource::RequestOverride);
440    }
441
442    #[test]
443    fn request_override_rejects_weaker_than_resolved_floor() {
444        let floor = resolve_commit_policy(
445            CommitPolicy::Quorum,
446            None,
447            CollectionDataModel::Transactional,
448            HaIntent::Declared,
449        )
450        .expect("quorum floor resolves");
451
452        let err = resolve_request_commit_policy(floor, Some(CommitPolicy::AckN(1)))
453            .expect_err("request may not weaken quorum floor");
454        assert_eq!(
455            err,
456            CommitPolicyViolation::RequestBelowResolvedFloor {
457                floor: CommitPolicy::Quorum,
458                requested: CommitPolicy::AckN(1),
459            }
460        );
461        assert_eq!(err.code(), "COMMIT_POLICY_BELOW_FLOOR");
462    }
463
464    // AC: local commit allowed for ephemeral/cache-like data under HA intent.
465    #[test]
466    fn local_commit_allowed_for_ephemeral_cache_under_ha() {
467        for m in LOCAL_ELIGIBLE {
468            // via cluster default
469            let r = resolve_commit_policy(CommitPolicy::Local, None, m, HaIntent::Declared)
470                .unwrap_or_else(|e| panic!("{} local should be allowed: {e}", m.label()));
471            assert_eq!(r.effective, CommitPolicy::Local);
472            assert_eq!(r.guardrail, GuardrailDisposition::EphemeralLocalAllowed);
473            assert_eq!(
474                r.failover_eligibility(),
475                FailoverEligibility::LocalAckDataLossWindow
476            );
477            assert!(!r.requires_durable_watermark());
478
479            // via explicit override, and the AckN(0) degenerate form
480            let r = resolve_commit_policy(
481                CommitPolicy::Quorum,
482                Some(CommitPolicy::AckN(0)),
483                m,
484                HaIntent::Declared,
485            )
486            .expect("ack_n=0 is local-eligible for ephemeral/cache");
487            assert_eq!(r.guardrail, GuardrailDisposition::EphemeralLocalAllowed);
488        }
489    }
490
491    // AC: local commit rejected for durable models under HA intent.
492    #[test]
493    fn local_commit_rejected_for_durable_models_under_ha() {
494        for m in DURABLE {
495            // via cluster default
496            let err = resolve_commit_policy(CommitPolicy::Local, None, m, HaIntent::Declared)
497                .expect_err("durable local must be rejected under HA");
498            assert_eq!(
499                err,
500                CommitPolicyViolation::DurableLocalUnderHa {
501                    model: m,
502                    source: ResolutionSource::ClusterDefault,
503                }
504            );
505            assert!(err.message().contains(m.label()));
506
507            // via override, including the AckN(0) degenerate form
508            let err = resolve_commit_policy(
509                CommitPolicy::Quorum,
510                Some(CommitPolicy::AckN(0)),
511                m,
512                HaIntent::Declared,
513            )
514            .expect_err("durable ack_n=0 override must be rejected under HA");
515            assert_eq!(
516                err,
517                CommitPolicyViolation::DurableLocalUnderHa {
518                    model: m,
519                    source: ResolutionSource::CollectionOverride,
520                }
521            );
522        }
523    }
524
525    // Guardrail only bites under declared HA intent: a non-HA deployment may use
526    // local commit for any model.
527    #[test]
528    fn local_commit_allowed_for_durable_when_ha_not_declared() {
529        for m in DURABLE {
530            let r = resolve_commit_policy(CommitPolicy::Local, None, m, HaIntent::None)
531                .expect("guardrail off without HA intent");
532            assert_eq!(r.effective, CommitPolicy::Local);
533            assert_eq!(r.guardrail, GuardrailDisposition::NotApplicable);
534        }
535    }
536
537    // AC: failover watermark implications follow the resolved policy.
538    #[test]
539    fn failover_watermark_implications_track_resolved_policy() {
540        // Durable resolved policy → promotion gated on watermark coverage.
541        let durable = resolve_commit_policy(
542            CommitPolicy::AckN(2),
543            None,
544            CollectionDataModel::Queue,
545            HaIntent::Declared,
546        )
547        .unwrap();
548        assert!(durable.requires_durable_watermark());
549        assert_eq!(
550            durable.failover_eligibility(),
551            FailoverEligibility::RequiresWatermarkCoverage
552        );
553
554        // Local resolved policy (ephemeral) → explicit data-loss window.
555        let local = resolve_commit_policy(
556            CommitPolicy::Local,
557            None,
558            CollectionDataModel::Cache,
559            HaIntent::Declared,
560        )
561        .unwrap();
562        assert!(!local.requires_durable_watermark());
563        assert_eq!(
564            local.failover_eligibility(),
565            FailoverEligibility::LocalAckDataLossWindow
566        );
567    }
568
569    #[test]
570    fn resolution_is_deterministic() {
571        let inputs = (
572            CommitPolicy::AckN(1),
573            Some(CommitPolicy::Quorum),
574            CollectionDataModel::Vault,
575            HaIntent::Declared,
576        );
577        let a = resolve_commit_policy(inputs.0, inputs.1, inputs.2, inputs.3);
578        let b = resolve_commit_policy(inputs.0, inputs.1, inputs.2, inputs.3);
579        assert_eq!(a, b);
580    }
581
582    #[test]
583    fn ha_intent_parse() {
584        assert_eq!(HaIntent::parse("true"), HaIntent::Declared);
585        assert_eq!(HaIntent::parse("1"), HaIntent::Declared);
586        assert_eq!(HaIntent::parse("YES"), HaIntent::Declared);
587        assert_eq!(HaIntent::parse("declared"), HaIntent::Declared);
588        assert_eq!(HaIntent::parse("false"), HaIntent::None);
589        assert_eq!(HaIntent::parse(""), HaIntent::None);
590        assert_eq!(HaIntent::parse("nonsense"), HaIntent::None);
591        assert_eq!(HaIntent::default(), HaIntent::None);
592    }
593
594    #[test]
595    fn source_and_disposition_labels() {
596        assert_eq!(ResolutionSource::ClusterDefault.label(), "cluster_default");
597        assert_eq!(
598            ResolutionSource::CollectionOverride.label(),
599            "collection_override"
600        );
601    }
602}