Skip to main content

ai_memory/federation/
quorum.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Quorum finalisation and error payload serialisation.
5
6use std::time::Instant;
7
8use serde::{Deserialize, Serialize};
9
10use crate::replication::{AckTracker, QuorumError, QuorumFailureReason};
11
12/// Classify an `AckTracker` into either a committed quorum (`Ok(n)`) or
13/// an error with a reason suitable for the `/503 quorum_not_met`
14/// payload. Consumes the tracker — call after the broadcast loop.
15///
16/// # Errors
17///
18/// Returns `QuorumError::QuorumNotMet` if the tracker did not meet
19/// its W threshold by the `now` tick.
20pub fn finalise_quorum(tracker: &AckTracker) -> Result<usize, QuorumError> {
21    tracker.finalise(Instant::now())
22}
23
24/// #1558 batch 5 wave 3 — `error` slug on the serialised 503 payload
25/// for failed quorum writes. File-local: only the three
26/// [`QuorumNotMetPayload::from_err`] arms emit it.
27const QUORUM_NOT_MET: &str = "quorum_not_met";
28
29/// Serialised 503 payload for failed quorum writes.
30#[derive(Debug, Serialize, Deserialize)]
31pub struct QuorumNotMetPayload {
32    pub error: &'static str,
33    pub got: usize,
34    pub needed: usize,
35    pub reason: String,
36}
37
38impl QuorumNotMetPayload {
39    #[must_use]
40    pub fn from_err(err: &QuorumError) -> Self {
41        match err {
42            QuorumError::QuorumNotMet {
43                got,
44                needed,
45                reason,
46            } => Self {
47                error: QUORUM_NOT_MET,
48                got: *got,
49                needed: *needed,
50                // InFlight shouldn't surface in the HTTP payload — the
51                // broadcast loop waits until the deadline before
52                // calling finalise(). If a caller somehow gets it here,
53                // we map to "timeout" for the operator-facing 503 so
54                // we don't leak a transient internal state as a fourth
55                // public string.
56                reason: match reason {
57                    QuorumFailureReason::Unreachable => "unreachable".to_string(),
58                    QuorumFailureReason::Timeout | QuorumFailureReason::InFlight => {
59                        "timeout".to_string()
60                    }
61                    QuorumFailureReason::IdDrift => "id_drift".to_string(),
62                },
63            },
64            QuorumError::InvalidPolicy { detail } => Self {
65                error: QUORUM_NOT_MET,
66                got: 0,
67                needed: 0,
68                reason: format!("invalid_policy:{detail}"),
69            },
70            QuorumError::LocalWriteFailed { detail } => Self {
71                error: QUORUM_NOT_MET,
72                got: 0,
73                needed: 0,
74                reason: format!("local_write_failed:{detail}"),
75            },
76        }
77    }
78}