Skip to main content

nodedb_crdt/
pre_validate.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Pre-validation: fast-reject before Raft round-trip.
4//!
5//! The full validation path is: agent delta → Raft proposal → leader validates
6//! → commit or reject. This round-trip is expensive (network + consensus).
7//!
8//! Pre-validation offers an optional fast-reject: the Control Plane checks
9//! the delta against the leader's current state BEFORE submitting to Raft.
10//! If the delta would obviously fail (e.g., UNIQUE violation on a value that
11//! already exists), it's rejected immediately — saving the Raft round-trip.
12//!
13//! Pre-validation is best-effort: it may have a stale view of the leader state,
14//! so deltas that pass pre-validation might still fail at commit time. But
15//! deltas that fail pre-validation would definitely fail at commit time.
16
17use crate::state::CrdtState;
18use crate::validator::{ProposedChange, ValidationOutcome, Validator};
19
20/// Result of pre-validation.
21#[derive(Debug)]
22pub enum PreValidationResult {
23    /// The change looks valid against current state — proceed to Raft.
24    Proceed,
25    /// The change would definitely fail — reject immediately.
26    FastReject { constraint: String, reason: String },
27}
28
29/// Pre-validate a proposed change against the leader's current state.
30///
31/// This is called on the Control Plane before submitting to Raft.
32/// It uses the same validator but doesn't touch the DLQ — that's only
33/// for actual commit-time rejections.
34pub fn pre_validate(
35    validator: &Validator,
36    state: &CrdtState,
37    change: &ProposedChange,
38) -> PreValidationResult {
39    match validator.validate(state, change) {
40        ValidationOutcome::Accepted => PreValidationResult::Proceed,
41        ValidationOutcome::Rejected(violations) => {
42            let v = &violations[0];
43            PreValidationResult::FastReject {
44                constraint: v.constraint_name.clone(),
45                reason: v.reason.clone(),
46            }
47        }
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54    use crate::constraint::ConstraintSet;
55    use loro::LoroValue;
56
57    #[test]
58    fn pre_validate_fast_rejects_not_null() {
59        let state = CrdtState::new(1).unwrap();
60        let mut cs = ConstraintSet::new();
61        cs.add_not_null("name_nn", "users", "name");
62        let validator = Validator::new(cs, 10);
63
64        let change = ProposedChange {
65            collection: "users".into(),
66            row_id: "u1".into(),
67            surrogate: nodedb_types::Surrogate::ZERO,
68            fields: vec![("email".into(), LoroValue::String("a@b.com".into()))],
69        };
70
71        match pre_validate(&validator, &state, &change) {
72            PreValidationResult::FastReject { constraint, .. } => {
73                assert_eq!(constraint, "name_nn");
74            }
75            _ => panic!("expected fast reject"),
76        }
77    }
78
79    #[test]
80    fn pre_validate_proceeds_when_valid() {
81        let state = CrdtState::new(1).unwrap();
82        let mut cs = ConstraintSet::new();
83        cs.add_not_null("name_nn", "users", "name");
84        let validator = Validator::new(cs, 10);
85
86        let change = ProposedChange {
87            collection: "users".into(),
88            row_id: "u1".into(),
89            surrogate: nodedb_types::Surrogate::ZERO,
90            fields: vec![("name".into(), LoroValue::String("Alice".into()))],
91        };
92
93        assert!(matches!(
94            pre_validate(&validator, &state, &change),
95            PreValidationResult::Proceed
96        ));
97    }
98}