1use crate::replication::CommitPolicy;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CollectionDataModel {
59 Transactional,
61 Queue,
63 Audit,
65 Config,
67 Vault,
69 Ephemeral,
71 Cache,
73}
74
75impl CollectionDataModel {
76 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
108pub enum HaIntent {
109 Declared,
111 #[default]
113 None,
114}
115
116impl HaIntent {
117 pub fn is_declared(self) -> bool {
118 matches!(self, Self::Declared)
119 }
120
121 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ResolutionSource {
148 ClusterDefault,
150 CollectionOverride,
152 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum GuardrailDisposition {
169 NotApplicable,
171 Satisfied,
174 EphemeralLocalAllowed,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum FailoverEligibility {
184 RequiresWatermarkCoverage,
187 LocalAckDataLossWindow,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct CommitPolicyResolution {
197 pub effective: CommitPolicy,
199 pub source: ResolutionSource,
201 pub guardrail: GuardrailDisposition,
203}
204
205impl CommitPolicyResolution {
206 pub fn requires_durable_watermark(&self) -> bool {
209 !is_local_ack(self.effective)
210 }
211
212 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum CommitPolicyViolation {
227 DurableLocalUnderHa {
231 model: CollectionDataModel,
232 source: ResolutionSource,
233 },
234 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
274pub 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
289pub 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
316pub 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 GuardrailDisposition::NotApplicable
339 } else if is_local_ack(effective) {
340 if model.is_durable() {
341 return Err(CommitPolicyViolation::DurableLocalUnderHa { model, source });
342 }
343 GuardrailDisposition::EphemeralLocalAllowed
345 } else {
346 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 #[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 #[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 #[test]
466 fn local_commit_allowed_for_ephemeral_cache_under_ha() {
467 for m in LOCAL_ELIGIBLE {
468 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 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 #[test]
493 fn local_commit_rejected_for_durable_models_under_ha() {
494 for m in DURABLE {
495 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 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 #[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 #[test]
539 fn failover_watermark_implications_track_resolved_policy() {
540 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 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}