Skip to main content

rhiza_quepaxa/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{
4    cmp::Ordering as CmpOrdering,
5    collections::{hash_map, BTreeMap, BTreeSet, HashMap},
6    fmt, fs,
7    io::{self, Write},
8    path::{Path, PathBuf},
9    sync::{
10        atomic::{AtomicU64, AtomicUsize, Ordering},
11        Arc, Mutex,
12    },
13    thread,
14    time::{Duration, Instant},
15};
16
17use rhiza_core::canonical_membership_digest;
18
19pub use rhiza_core::{
20    ClusterId, Command, CommandKind, ConfigChange, ConfigId, EntryType, Epoch, LogEntry, LogHash,
21    LogIndex, NodeId, StoredCommand,
22};
23
24pub type Result<T> = std::result::Result<T, Error>;
25pub type Slot = u64;
26pub type Round = u64;
27pub type Phase = u8;
28pub type Step = u64;
29pub type Priority = u128;
30
31const RECORDER_STATE_VERSION: u16 = 4;
32const CONFIGURATION_STATE_VERSION: u16 = 3;
33const RECORD_WORKER_QUEUE_CAPACITY: usize = 1;
34const CONTROL_WORKER_QUEUE_CAPACITY: usize = 1;
35
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub enum Error {
38    ChainConflict {
39        slot: Slot,
40        expected_prev_hash: LogHash,
41        actual_prev_hash: LogHash,
42    },
43    CommandHashMismatch,
44    CommandUnavailable,
45    Cancelled,
46    ConflictingCertificates,
47    Decode(String),
48    DuplicateRecorderIdentity,
49    EmptyRecorderIdentity,
50    EmptyFixedMembership,
51    InvalidFixedMembershipSize,
52    InvalidRecoveredTip,
53    Io(String),
54    MigrationRequired {
55        format: &'static str,
56        version: u16,
57    },
58    NoQuorum,
59    ProposeFailed,
60    RandomnessUnavailable(String),
61    RecorderRootLocked(PathBuf),
62    Rejected(RejectReason),
63    ReadFenceUnsupported,
64    TypedProofInstallRequired,
65    TypedRecordRequired,
66}
67
68impl fmt::Display for Error {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Self::ChainConflict { slot, .. } => {
72                write!(f, "QuePaxa predecessor conflicts at slot {slot}")
73            }
74            Self::CommandHashMismatch => write!(f, "QuePaxa command hash mismatch"),
75            Self::CommandUnavailable => write!(f, "QuePaxa command bytes unavailable"),
76            Self::Cancelled => write!(f, "QuePaxa proposal cancelled"),
77            Self::ConflictingCertificates => {
78                write!(f, "QuePaxa recovered conflicting decision certificates")
79            }
80            Self::Decode(message) => write!(f, "QuePaxa decode failed: {message}"),
81            Self::DuplicateRecorderIdentity => write!(f, "recorder identities must be unique"),
82            Self::EmptyRecorderIdentity => write!(f, "recorder identity must not be empty"),
83            Self::EmptyFixedMembership => {
84                write!(f, "fixed membership must include at least one node")
85            }
86            Self::InvalidFixedMembershipSize => {
87                write!(f, "membership requires between three and seven recorders")
88            }
89            Self::InvalidRecoveredTip => write!(f, "recovered qlog next index must be positive"),
90            Self::Io(message) => write!(f, "QuePaxa io failed: {message}"),
91            Self::MigrationRequired { format, version } => {
92                write!(f, "QuePaxa {format} version {version} requires migration")
93            }
94            Self::NoQuorum => write!(f, "QuePaxa quorum was not reached"),
95            Self::ProposeFailed => write!(f, "QuePaxa propose failed"),
96            Self::RandomnessUnavailable(message) => {
97                write!(f, "QuePaxa OS randomness unavailable: {message}")
98            }
99            Self::RecorderRootLocked(root) => {
100                write!(f, "recorder root is already owned: {}", root.display())
101            }
102            Self::Rejected(reason) => write!(f, "QuePaxa recorder rejected request: {reason:?}"),
103            Self::ReadFenceUnsupported => {
104                write!(f, "recorder does not implement context-bound read fences")
105            }
106            Self::TypedProofInstallRequired => {
107                write!(
108                    f,
109                    "recorder does not implement typed decision-proof installation"
110                )
111            }
112            Self::TypedRecordRequired => {
113                write!(f, "recorder does not implement the typed Record operation")
114            }
115        }
116    }
117}
118
119impl std::error::Error for Error {}
120
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct Membership {
123    members: Vec<NodeId>,
124    digest: LogHash,
125}
126
127impl Membership {
128    pub fn new<const N: usize>(members: [&str; N]) -> Result<Self> {
129        Self::from_voters(members.into_iter().map(String::from))
130    }
131
132    pub fn from_voters(voters: impl IntoIterator<Item = NodeId>) -> Result<Self> {
133        Self::from_members(voters.into_iter().collect())
134    }
135
136    pub fn members(&self) -> &[NodeId] {
137        &self.members
138    }
139
140    pub fn contains(&self, recorder_id: &str) -> bool {
141        self.members
142            .binary_search_by(|member| member.as_str().cmp(recorder_id))
143            .is_ok()
144    }
145
146    pub const fn digest(&self) -> LogHash {
147        self.digest
148    }
149
150    pub fn quorum_size(&self) -> usize {
151        quorum_size(self.members.len())
152    }
153
154    fn from_members(members: Vec<NodeId>) -> Result<Self> {
155        if members.is_empty() {
156            return Err(Error::EmptyFixedMembership);
157        }
158        if !(3..=7).contains(&members.len()) {
159            return Err(Error::InvalidFixedMembershipSize);
160        }
161        if members.iter().any(String::is_empty) {
162            return Err(Error::EmptyRecorderIdentity);
163        }
164        let member_count = members.len();
165        let members: Vec<_> = members
166            .into_iter()
167            .collect::<BTreeSet<_>>()
168            .into_iter()
169            .collect();
170        if members.len() != member_count {
171            return Err(Error::DuplicateRecorderIdentity);
172        }
173        Ok(Self {
174            digest: canonical_membership_digest(&members)
175                .map_err(|_| Error::InvalidFixedMembershipSize)?,
176            members,
177        })
178    }
179}
180
181pub type FixedMembership = Membership;
182
183pub trait Consensus {
184    fn propose(&self, command: Command) -> Result<LogEntry>;
185}
186
187pub fn quorum_size(n: usize) -> usize {
188    n / 2 + 1
189}
190
191#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)]
192pub struct Ballot {
193    pub round: Round,
194    pub priority: Priority,
195    pub proposer_id: NodeId,
196}
197
198impl Ballot {
199    pub fn new(round: Round, priority: Priority, proposer_id: impl Into<NodeId>) -> Self {
200        Self {
201            round,
202            priority,
203            proposer_id: proposer_id.into(),
204        }
205    }
206}
207
208#[derive(
209    Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
210)]
211pub struct LogicalStep {
212    pub round: Round,
213    pub phase: Phase,
214}
215
216impl LogicalStep {
217    pub const fn as_u64(&self) -> Step {
218        self.round * 4 + self.phase as u64
219    }
220}
221
222#[derive(
223    Clone,
224    Copy,
225    Debug,
226    Default,
227    Eq,
228    Ord,
229    PartialEq,
230    PartialOrd,
231    serde::Deserialize,
232    serde::Serialize,
233)]
234pub struct ProposalPriority(pub [u8; 32]);
235
236impl ProposalPriority {
237    pub const ZERO: Self = Self([0; 32]);
238    pub const MAX: Self = Self([u8::MAX; 32]);
239
240    pub const fn from_u64(value: u64) -> Self {
241        let mut bytes = [0; 32];
242        let encoded = value.to_be_bytes();
243        let mut index = 0;
244        while index < encoded.len() {
245            bytes[24 + index] = encoded[index];
246            index += 1;
247        }
248        Self(bytes)
249    }
250
251    fn legacy_u128(self) -> u128 {
252        u128::from_be_bytes(self.0[16..].try_into().expect("fixed priority suffix"))
253    }
254}
255
256#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
257pub struct Proposal {
258    pub priority: ProposalPriority,
259    pub proposer_id: NodeId,
260    pub proposal_id: u64,
261    pub value: Option<AcceptedValue>,
262}
263
264impl Proposal {
265    pub fn new(
266        priority: ProposalPriority,
267        proposer_id: impl Into<NodeId>,
268        proposal_id: u64,
269        value: AcceptedValue,
270    ) -> Self {
271        Self {
272            priority,
273            proposer_id: proposer_id.into(),
274            proposal_id,
275            value: Some(value),
276        }
277    }
278
279    pub fn nil() -> Self {
280        Self {
281            priority: ProposalPriority::ZERO,
282            proposer_id: String::new(),
283            proposal_id: 0,
284            value: None,
285        }
286    }
287
288    fn identity(&self) -> (ProposalPriority, &str, u64, Option<&AcceptedValue>) {
289        (
290            self.priority,
291            &self.proposer_id,
292            self.proposal_id,
293            self.value.as_ref(),
294        )
295    }
296
297    fn is_nil(&self) -> bool {
298        self.value.is_none()
299    }
300}
301
302impl PartialEq for Proposal {
303    fn eq(&self, other: &Self) -> bool {
304        self.identity() == other.identity()
305    }
306}
307
308impl Eq for Proposal {}
309
310impl PartialOrd for Proposal {
311    fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
312        Some(self.cmp(other))
313    }
314}
315
316impl Ord for Proposal {
317    fn cmp(&self, other: &Self) -> CmpOrdering {
318        match (self.is_nil(), other.is_nil()) {
319            (true, true) => CmpOrdering::Equal,
320            (true, false) => CmpOrdering::Less,
321            (false, true) => CmpOrdering::Greater,
322            (false, false) => self.identity().cmp(&other.identity()),
323        }
324    }
325}
326
327#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
328pub struct IsrState {
329    step: Step,
330    first_current: Option<Proposal>,
331    aggregate_current: Option<Proposal>,
332    aggregate_prior: Option<Proposal>,
333}
334
335#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
336pub struct IsrReply {
337    pub step: Step,
338    pub first_current: Option<Proposal>,
339    pub aggregate_prior: Option<Proposal>,
340}
341
342impl IsrState {
343    pub const fn step(&self) -> Step {
344        self.step
345    }
346
347    pub const fn first_current(&self) -> Option<&Proposal> {
348        self.first_current.as_ref()
349    }
350
351    pub const fn aggregate_current(&self) -> Option<&Proposal> {
352        self.aggregate_current.as_ref()
353    }
354
355    pub const fn aggregate_prior(&self) -> Option<&Proposal> {
356        self.aggregate_prior.as_ref()
357    }
358
359    /// Pure Algorithm 3 transition. Stale inputs return an unchanged state.
360    pub fn record(&self, step: Step, proposal: Proposal) -> (Self, IsrReply) {
361        let mut next = self.clone();
362        if step == next.step {
363            if next.first_current.is_none() {
364                next.first_current = Some(proposal.clone());
365            }
366            if next.aggregate_current.as_ref() < Some(&proposal) {
367                next.aggregate_current = Some(proposal);
368            }
369        } else if step > next.step {
370            next.aggregate_prior = if step == next.step.saturating_add(1) {
371                next.aggregate_current.take()
372            } else {
373                None
374            };
375            next.step = step;
376            next.first_current = Some(proposal.clone());
377            next.aggregate_current = Some(proposal);
378        }
379        let reply = IsrReply {
380            step: next.step,
381            first_current: next.first_current.clone(),
382            aggregate_prior: next.aggregate_prior.clone(),
383        };
384        (next, reply)
385    }
386}
387
388#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
389pub struct AcceptedValue {
390    pub command_hash: LogHash,
391    pub prev_hash: LogHash,
392    pub entry_hash: LogHash,
393}
394
395impl PartialOrd for AcceptedValue {
396    fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
397        Some(self.cmp(other))
398    }
399}
400
401impl Ord for AcceptedValue {
402    fn cmp(&self, other: &Self) -> CmpOrdering {
403        (
404            self.entry_hash.as_bytes(),
405            self.command_hash.as_bytes(),
406            self.prev_hash.as_bytes(),
407        )
408            .cmp(&(
409                other.entry_hash.as_bytes(),
410                other.command_hash.as_bytes(),
411                other.prev_hash.as_bytes(),
412            ))
413    }
414}
415
416impl AcceptedValue {
417    pub fn from_command(
418        cluster_id: &str,
419        slot: Slot,
420        epoch: Epoch,
421        config_id: ConfigId,
422        prev_hash: LogHash,
423        command: &StoredCommand,
424    ) -> Self {
425        Self {
426            command_hash: command.hash(),
427            prev_hash,
428            entry_hash: LogEntry::calculate_hash(
429                cluster_id,
430                slot,
431                epoch,
432                config_id,
433                command.entry_type,
434                prev_hash,
435                &command.payload,
436            ),
437        }
438    }
439}
440
441#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
442pub struct AcceptedSummary {
443    pub ballot: Ballot,
444    pub value: AcceptedValue,
445}
446
447pub type ProposalSummary = AcceptedSummary;
448
449#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
450pub struct DecisionCertificate {
451    pub slot: Slot,
452    pub epoch: Epoch,
453    pub config_id: ConfigId,
454    pub config_digest: LogHash,
455    pub ballot: Ballot,
456    pub value: AcceptedValue,
457    pub recorder_ids: Vec<NodeId>,
458}
459
460impl DecisionCertificate {
461    pub fn cluster_id(&self) -> Option<&str> {
462        decode_certificate_proposer(&self.ballot.proposer_id).map(|(cluster_id, _)| cluster_id)
463    }
464
465    pub fn validate_for_cluster(
466        &self,
467        cluster_id: &str,
468        config_id: ConfigId,
469        membership: &Membership,
470    ) -> std::result::Result<(), RejectReason> {
471        if self.cluster_id() != Some(cluster_id) {
472            return Err(RejectReason::WrongCluster);
473        }
474        self.validate_for(config_id, membership)
475    }
476
477    pub fn validate(&self, membership: &FixedMembership) -> std::result::Result<(), RejectReason> {
478        if self.config_digest != membership.digest() {
479            return Err(RejectReason::WrongConfig);
480        }
481        if self.recorder_ids.len() != membership.quorum_size()
482            || !self.recorder_ids.windows(2).all(|pair| pair[0] < pair[1])
483            || self
484                .recorder_ids
485                .iter()
486                .any(|recorder_id| !membership.contains(recorder_id))
487        {
488            return Err(RejectReason::InvalidCertificate);
489        }
490        Ok(())
491    }
492
493    pub fn validate_for(
494        &self,
495        config_id: ConfigId,
496        membership: &Membership,
497    ) -> std::result::Result<(), RejectReason> {
498        if self.config_id != config_id {
499            return Err(RejectReason::WrongConfig);
500        }
501        self.validate(membership)
502    }
503
504    fn validate_context(
505        &self,
506        slot: Slot,
507        epoch: Epoch,
508        config_id: ConfigId,
509        config_digest: LogHash,
510    ) -> std::result::Result<(), RejectReason> {
511        if self.slot != slot || self.epoch != epoch {
512            return Err(RejectReason::MalformedDecision);
513        }
514        if self.config_id != config_id || self.config_digest != config_digest {
515            return Err(RejectReason::WrongConfig);
516        }
517        Ok(())
518    }
519}
520
521pub type DecisionRecord = DecisionCertificate;
522
523#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
524pub struct RecorderSummary {
525    pub recorder_id: NodeId,
526    pub slot: Slot,
527    pub step: Step,
528    pub first_current: Option<Proposal>,
529    pub aggregate_prior: Option<Proposal>,
530}
531
532#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
533pub enum DecisionProof {
534    FastPath {
535        cluster_id: ClusterId,
536        slot: Slot,
537        epoch: Epoch,
538        config_id: ConfigId,
539        config_digest: LogHash,
540        proposal: Proposal,
541        summaries: Vec<RecorderSummary>,
542    },
543    Phase2 {
544        cluster_id: ClusterId,
545        slot: Slot,
546        epoch: Epoch,
547        config_id: ConfigId,
548        config_digest: LogHash,
549        step: Step,
550        proposal: Proposal,
551        summaries: Vec<RecorderSummary>,
552    },
553}
554
555impl DecisionProof {
556    pub fn proposal(&self) -> &Proposal {
557        match self {
558            Self::FastPath { proposal, .. } | Self::Phase2 { proposal, .. } => proposal,
559        }
560    }
561
562    pub fn validate_for(
563        &self,
564        slot: Slot,
565        epoch: Epoch,
566        config_id: ConfigId,
567        membership: &Membership,
568    ) -> std::result::Result<(), RejectReason> {
569        let (proof_slot, proof_epoch, proof_config, digest, step, proposal, summaries, fast) =
570            match self {
571                Self::FastPath {
572                    slot,
573                    epoch,
574                    config_id,
575                    config_digest,
576                    proposal,
577                    summaries,
578                    ..
579                } => (
580                    *slot,
581                    *epoch,
582                    *config_id,
583                    *config_digest,
584                    4,
585                    proposal,
586                    summaries,
587                    true,
588                ),
589                Self::Phase2 {
590                    slot,
591                    epoch,
592                    config_id,
593                    config_digest,
594                    step,
595                    proposal,
596                    summaries,
597                    ..
598                } => (
599                    *slot,
600                    *epoch,
601                    *config_id,
602                    *config_digest,
603                    *step,
604                    proposal,
605                    summaries,
606                    false,
607                ),
608            };
609        if proof_slot != slot || proof_epoch != epoch {
610            return Err(RejectReason::MalformedDecision);
611        }
612        if proof_config != config_id || digest != membership.digest() {
613            return Err(RejectReason::WrongConfig);
614        }
615        if proposal.is_nil() || proposal.value.is_none() {
616            return Err(RejectReason::InvalidCertificate);
617        }
618        if summaries.len() != membership.quorum_size()
619            || !summaries
620                .windows(2)
621                .all(|pair| pair[0].recorder_id < pair[1].recorder_id)
622            || summaries.iter().any(|summary| {
623                !membership.contains(&summary.recorder_id)
624                    || summary.slot != slot
625                    || summary.step != step
626            })
627        {
628            return Err(RejectReason::InvalidCertificate);
629        }
630        if fast {
631            if step != 4
632                || proposal.priority != ProposalPriority::MAX
633                || summaries.iter().any(|summary| {
634                    !summary
635                        .first_current
636                        .as_ref()
637                        .is_some_and(|candidate| proposal_exact(candidate, proposal))
638                })
639            {
640                return Err(RejectReason::InvalidCertificate);
641            }
642        } else {
643            if step % 4 != 2 {
644                return Err(RejectReason::InvalidCertificate);
645            }
646            let maximum = summaries
647                .iter()
648                .filter_map(|summary| summary.aggregate_prior.as_ref())
649                .max();
650            if maximum != Some(proposal)
651                || !maximum.is_some_and(|candidate| proposal_exact(candidate, proposal))
652            {
653                return Err(RejectReason::InvalidCertificate);
654            }
655        }
656        Ok(())
657    }
658
659    pub fn validate_for_cluster(
660        &self,
661        cluster_id: &str,
662        slot: Slot,
663        epoch: Epoch,
664        config_id: ConfigId,
665        membership: &Membership,
666    ) -> std::result::Result<(), RejectReason> {
667        if proof_cluster_id(self) != cluster_id {
668            return Err(RejectReason::WrongCluster);
669        }
670        self.validate_for(slot, epoch, config_id, membership)
671    }
672}
673
674fn proposal_exact(left: &Proposal, right: &Proposal) -> bool {
675    left == right && left.value == right.value
676}
677
678#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
679pub struct RecordRequest {
680    pub cluster_id: ClusterId,
681    pub epoch: Epoch,
682    pub config_id: ConfigId,
683    pub config_digest: LogHash,
684    pub slot: Slot,
685    pub step: Step,
686    pub proposal: Proposal,
687    #[serde(default)]
688    pub command: Option<StoredCommand>,
689}
690
691#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
692pub struct RecordSummary {
693    pub recorder_id: NodeId,
694    pub slot: Slot,
695    pub config_id: ConfigId,
696    pub config_digest: LogHash,
697    pub step: Step,
698    pub first_current: Option<Proposal>,
699    pub aggregate_prior: Option<Proposal>,
700    pub decided: Option<DecisionProof>,
701}
702
703#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
704pub struct ReadFenceRequest {
705    pub cluster_id: ClusterId,
706    pub epoch: Epoch,
707    pub config_id: ConfigId,
708    pub config_digest: LogHash,
709    pub slot: Slot,
710}
711
712#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
713pub enum ReadFenceSlotState {
714    Empty,
715    /// The exact slot is present, or a durable later slot proves that this
716    /// recorder has already crossed the requested position. A crossed gap has
717    /// no exact summary and must therefore fail closed as pending.
718    Occupied {
719        summary: Option<Box<RecordSummary>>,
720    },
721}
722
723#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
724pub struct ReadFenceObservation {
725    pub recorder_id: NodeId,
726    pub cluster_id: ClusterId,
727    pub epoch: Epoch,
728    pub config_id: ConfigId,
729    pub config_digest: LogHash,
730    pub slot: Slot,
731    pub max_head: Option<Slot>,
732    pub slot_state: ReadFenceSlotState,
733}
734
735fn valid_read_fence_observation(
736    observation: &ReadFenceObservation,
737    expected_recorder_id: &str,
738    request: &ReadFenceRequest,
739) -> bool {
740    if observation.recorder_id != expected_recorder_id
741        || observation.cluster_id != request.cluster_id
742        || observation.epoch != request.epoch
743        || observation.config_id != request.config_id
744        || observation.config_digest != request.config_digest
745        || observation.slot != request.slot
746    {
747        return false;
748    }
749    match &observation.slot_state {
750        ReadFenceSlotState::Empty => observation
751            .max_head
752            .is_none_or(|max_head| max_head < request.slot),
753        ReadFenceSlotState::Occupied { summary } => {
754            observation
755                .max_head
756                .is_some_and(|max_head| max_head >= request.slot)
757                && summary.as_ref().is_none_or(|summary| {
758                    summary.recorder_id == observation.recorder_id
759                        && summary.slot == request.slot
760                        && summary.config_id == request.config_id
761                        && summary.config_digest == request.config_digest
762                })
763        }
764    }
765}
766
767#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
768pub struct RecordResponse {
769    pub from: NodeId,
770    pub slot: Slot,
771    pub step: Step,
772    pub highest_promised: Option<Ballot>,
773    pub accepted: Option<AcceptedSummary>,
774    pub recorder_epoch: Epoch,
775    pub config_id: ConfigId,
776    pub config_digest: LogHash,
777}
778
779#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
780pub struct RecorderReply {
781    pub recorder_id: NodeId,
782    pub slot: Slot,
783    pub config_id: ConfigId,
784    pub config_digest: LogHash,
785    pub step: Step,
786    pub highest_promised: Option<Ballot>,
787    pub accepted: Option<AcceptedSummary>,
788    pub decided: Option<DecisionCertificate>,
789    pub command: Option<StoredCommand>,
790}
791
792#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
793pub enum RecorderRequest {
794    Identity,
795    StoreCommand {
796        cluster_id: ClusterId,
797        epoch: Epoch,
798        config_id: ConfigId,
799        config_digest: LogHash,
800        command_hash: LogHash,
801        command: StoredCommand,
802    },
803    FetchCommand {
804        cluster_id: ClusterId,
805        epoch: Epoch,
806        config_id: ConfigId,
807        config_digest: LogHash,
808        command_hash: LogHash,
809    },
810    Inspect {
811        cluster_id: ClusterId,
812        epoch: Epoch,
813        config_id: ConfigId,
814        config_digest: LogHash,
815        slot: Slot,
816    },
817    /// Legacy transport envelope; active protocol code uses `RecorderRpc::record`.
818    Observe {
819        cluster_id: ClusterId,
820        epoch: Epoch,
821        config_id: ConfigId,
822        config_digest: LogHash,
823        slot: Slot,
824        ballot: Ballot,
825    },
826    /// Legacy transport envelope; active protocol code uses `RecorderRpc::record`.
827    Converge {
828        cluster_id: ClusterId,
829        epoch: Epoch,
830        config_id: ConfigId,
831        config_digest: LogHash,
832        slot: Slot,
833        ballot: Ballot,
834        value: AcceptedValue,
835    },
836    Decide {
837        cluster_id: ClusterId,
838        epoch: Epoch,
839        config_id: ConfigId,
840        config_digest: LogHash,
841        slot: Slot,
842        decision: DecisionCertificate,
843    },
844}
845
846#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
847pub enum RejectReason {
848    StaleEpoch,
849    FutureEpoch,
850    WrongCluster,
851    WrongConfig,
852    WrongSlot,
853    AlreadyDecided,
854    MalformedDecision,
855    BallotPromised { promised: Ballot },
856    ConflictingValue,
857    InvalidValue,
858    InvalidCertificate,
859    ConfigurationSealed { stop_slot: Slot },
860    ConfigurationNotInstalled,
861    ActivationRequired,
862    TransitionInProgress,
863    InvalidTransition,
864    LocalVoterRequired,
865    StepRegression,
866    InvalidRequest,
867}
868
869#[derive(Clone, Debug, Eq, PartialEq)]
870pub struct ConfigurationSeal {
871    pub stop_slot: Slot,
872    pub command_hash: LogHash,
873    pub prefix_hash: LogHash,
874}
875
876#[derive(Clone, Debug, Eq, PartialEq)]
877pub struct ConfigurationState {
878    config_id: ConfigId,
879    config_digest: LogHash,
880    membership: Option<Membership>,
881    predecessor: Option<ConfigurationSeal>,
882    seal: Option<ConfigurationSeal>,
883    max_accepted_or_decided_slot: Option<Slot>,
884    activated: bool,
885}
886
887impl ConfigurationState {
888    pub const fn config_id(&self) -> ConfigId {
889        self.config_id
890    }
891
892    pub const fn config_digest(&self) -> LogHash {
893        self.config_digest
894    }
895
896    pub const fn membership(&self) -> Option<&Membership> {
897        self.membership.as_ref()
898    }
899
900    pub const fn predecessor(&self) -> Option<&ConfigurationSeal> {
901        self.predecessor.as_ref()
902    }
903
904    pub const fn seal(&self) -> Option<&ConfigurationSeal> {
905        self.seal.as_ref()
906    }
907
908    pub const fn is_activated(&self) -> bool {
909        self.activated
910    }
911
912    pub const fn max_accepted_or_decided_slot(&self) -> Option<Slot> {
913        self.max_accepted_or_decided_slot
914    }
915
916    fn initial(
917        config_id: ConfigId,
918        config_digest: LogHash,
919        membership: Option<Membership>,
920    ) -> Self {
921        Self {
922            config_id,
923            config_digest,
924            membership,
925            predecessor: None,
926            seal: None,
927            max_accepted_or_decided_slot: None,
928            activated: true,
929        }
930    }
931}
932
933#[derive(Clone, Copy, Debug, Eq, PartialEq)]
934pub enum SealFaultPoint {
935    AfterIntent,
936    AfterSlot,
937    AfterConfiguration,
938    BeforeRecordManifest,
939    AfterRecordManifest,
940    AfterRecordCache,
941    AfterHeadIntent,
942    AfterHeadConfiguration,
943    AfterHead,
944    AfterWalWrite,
945    AfterWalSync,
946}
947
948#[derive(Clone, Debug, Eq, PartialEq)]
949pub struct RecorderSlotState {
950    slot: Slot,
951    cluster_id: ClusterId,
952    epoch: Epoch,
953    config_id: ConfigId,
954    config_digest: LogHash,
955    highest_promised: Option<Ballot>,
956    accepted: Option<AcceptedSummary>,
957    decided: Option<DecisionCertificate>,
958    isr: IsrState,
959    decided_proof: Option<DecisionProof>,
960}
961
962impl RecorderSlotState {
963    pub fn new(
964        slot: Slot,
965        cluster_id: impl Into<ClusterId>,
966        epoch: Epoch,
967        config_id: ConfigId,
968    ) -> Self {
969        Self::new_with_digest(slot, cluster_id, epoch, config_id, LogHash::ZERO)
970    }
971
972    pub fn new_with_digest(
973        slot: Slot,
974        cluster_id: impl Into<ClusterId>,
975        epoch: Epoch,
976        config_id: ConfigId,
977        config_digest: LogHash,
978    ) -> Self {
979        Self {
980            slot,
981            cluster_id: cluster_id.into(),
982            epoch,
983            config_id,
984            config_digest,
985            highest_promised: None,
986            accepted: None,
987            decided: None,
988            isr: IsrState::default(),
989            decided_proof: None,
990        }
991    }
992
993    pub fn apply(
994        &mut self,
995        request: RecorderRequest,
996    ) -> std::result::Result<RecorderReply, RejectReason> {
997        match request {
998            RecorderRequest::Inspect {
999                cluster_id,
1000                epoch,
1001                config_id,
1002                config_digest,
1003                slot,
1004            } => self.inspect(cluster_id, epoch, config_id, config_digest, slot),
1005            RecorderRequest::Observe { .. }
1006            | RecorderRequest::Converge { .. }
1007            | RecorderRequest::Decide { .. } => Err(RejectReason::InvalidRequest),
1008            RecorderRequest::Identity
1009            | RecorderRequest::StoreCommand { .. }
1010            | RecorderRequest::FetchCommand { .. } => Err(RejectReason::InvalidRequest),
1011        }
1012    }
1013
1014    pub fn decided(&self) -> Option<&DecisionCertificate> {
1015        self.decided.as_ref()
1016    }
1017
1018    pub fn decision_proof(&self) -> Option<&DecisionProof> {
1019        self.decided_proof.as_ref()
1020    }
1021
1022    pub fn isr(&self) -> &IsrState {
1023        &self.isr
1024    }
1025
1026    pub fn record(
1027        &self,
1028        request: &RecordRequest,
1029    ) -> std::result::Result<(Self, IsrReply), RejectReason> {
1030        self.validate(
1031            request.cluster_id.clone(),
1032            request.epoch,
1033            request.config_id,
1034            request.config_digest,
1035            request.slot,
1036        )?;
1037        let mut next = self.clone();
1038        let (isr, reply) = self.isr.record(request.step, request.proposal.clone());
1039        next.isr = isr;
1040        Ok((next, reply))
1041    }
1042
1043    fn install_proof(&mut self, proof: DecisionProof) -> std::result::Result<(), RejectReason> {
1044        if let Some(existing) = &self.decided_proof {
1045            if existing.proposal().value != proof.proposal().value {
1046                return Err(RejectReason::AlreadyDecided);
1047            }
1048            return Ok(());
1049        }
1050        self.decided_proof = Some(proof);
1051        Ok(())
1052    }
1053
1054    pub const fn slot(&self) -> Slot {
1055        self.slot
1056    }
1057
1058    pub fn cluster_id(&self) -> &str {
1059        &self.cluster_id
1060    }
1061
1062    pub const fn epoch(&self) -> Epoch {
1063        self.epoch
1064    }
1065
1066    pub const fn config_id(&self) -> ConfigId {
1067        self.config_id
1068    }
1069
1070    pub const fn config_digest(&self) -> LogHash {
1071        self.config_digest
1072    }
1073
1074    pub fn highest_promised(&self) -> Option<&Ballot> {
1075        self.highest_promised.as_ref()
1076    }
1077
1078    pub fn accepted(&self) -> Option<&AcceptedSummary> {
1079        self.accepted.as_ref()
1080    }
1081
1082    pub fn max_step_seen(&self) -> Step {
1083        self.highest_promised
1084            .as_ref()
1085            .map_or(0, |ballot| ballot.round)
1086    }
1087
1088    fn inspect(
1089        &self,
1090        cluster_id: ClusterId,
1091        epoch: Epoch,
1092        config_id: ConfigId,
1093        config_digest: LogHash,
1094        slot: Slot,
1095    ) -> std::result::Result<RecorderReply, RejectReason> {
1096        self.validate(cluster_id, epoch, config_id, config_digest, slot)?;
1097        Ok(self.reply())
1098    }
1099
1100    fn validate(
1101        &self,
1102        cluster_id: ClusterId,
1103        epoch: Epoch,
1104        config_id: ConfigId,
1105        config_digest: LogHash,
1106        slot: Slot,
1107    ) -> std::result::Result<(), RejectReason> {
1108        if cluster_id != self.cluster_id {
1109            return Err(RejectReason::WrongCluster);
1110        }
1111        if slot != self.slot {
1112            return Err(RejectReason::WrongSlot);
1113        }
1114        if epoch < self.epoch {
1115            return Err(RejectReason::StaleEpoch);
1116        }
1117        if epoch > self.epoch {
1118            return Err(RejectReason::FutureEpoch);
1119        }
1120        if config_id != self.config_id {
1121            return Err(RejectReason::WrongConfig);
1122        }
1123        if config_digest != self.config_digest {
1124            return Err(RejectReason::WrongConfig);
1125        }
1126        Ok(())
1127    }
1128
1129    fn reply(&self) -> RecorderReply {
1130        RecorderReply {
1131            recorder_id: String::new(),
1132            slot: self.slot,
1133            config_id: self.config_id,
1134            config_digest: self.config_digest,
1135            step: self.max_step_seen(),
1136            highest_promised: self.highest_promised.clone(),
1137            accepted: self.accepted.clone(),
1138            decided: self.decided.clone(),
1139            command: None,
1140        }
1141    }
1142}
1143
1144#[derive(Clone, Debug)]
1145pub struct RecorderFileStore {
1146    root: PathBuf,
1147    recorder_id: NodeId,
1148    cluster_id: ClusterId,
1149    epoch: Epoch,
1150    config_id: ConfigId,
1151    config_digest: LogHash,
1152    configuration: Arc<Mutex<ConfigurationState>>,
1153    recorded_head: Arc<Mutex<RecordedHeadProvenance>>,
1154    recent_slots: Arc<Mutex<Vec<DurableSlotSnapshot>>>,
1155    wal: Arc<Mutex<RecorderWal>>,
1156    seal_fault: Arc<Mutex<Option<SealFaultPoint>>>,
1157    _root_lock: Arc<fs::File>,
1158    sync: Arc<Mutex<()>>,
1159}
1160
1161const RECORDED_HEAD_MAGIC: &[u8; 4] = b"QRHD";
1162const RECORDED_HEAD_VERSION: u16 = 3;
1163const RECORDER_WAL_MAGIC: &[u8; 4] = b"QWAL";
1164const RECORDER_WAL_VERSION: u16 = 1;
1165// Keep rotation bounded while allowing 512 KiB replicated commands to amortize
1166// quorum fsyncs without forcing a synchronous checkpoint every few dozen slots.
1167const RECORDER_WAL_SOFT_BYTE_LIMIT: u64 = 64 * 1024 * 1024;
1168#[cfg(not(test))]
1169const RECORDER_WAL_HARD_FRAME_LIMIT: u64 = 1_024;
1170#[cfg(test)]
1171const RECORDER_WAL_HARD_FRAME_LIMIT: u64 = 32;
1172
1173#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1174struct WalCheckpoint {
1175    generation: u64,
1176    through_sequence: u64,
1177}
1178
1179impl Default for WalCheckpoint {
1180    fn default() -> Self {
1181        Self {
1182            generation: 1,
1183            through_sequence: 0,
1184        }
1185    }
1186}
1187
1188#[derive(Debug)]
1189struct RecorderWal {
1190    checkpoint: WalCheckpoint,
1191    next_sequence: u64,
1192    last_digest: LogHash,
1193    frame_count: u64,
1194    byte_count: u64,
1195    slots: BTreeMap<Slot, Vec<u8>>,
1196    commands: HashMap<LogHash, StoredCommand>,
1197    file: Option<fs::File>,
1198    failed: bool,
1199}
1200
1201impl Default for RecorderWal {
1202    fn default() -> Self {
1203        Self {
1204            checkpoint: WalCheckpoint::default(),
1205            next_sequence: 1,
1206            last_digest: LogHash::ZERO,
1207            frame_count: 0,
1208            byte_count: 0,
1209            slots: BTreeMap::new(),
1210            commands: HashMap::new(),
1211            file: None,
1212            failed: false,
1213        }
1214    }
1215}
1216
1217#[derive(Debug)]
1218struct WalFrame {
1219    generation: u64,
1220    sequence: u64,
1221    prev_digest: LogHash,
1222    digest: LogHash,
1223    slot: Slot,
1224    slot_bytes: Vec<u8>,
1225    configuration_bytes: Vec<u8>,
1226    head: RecordedHeadProvenance,
1227    command: Option<(LogHash, StoredCommand)>,
1228}
1229
1230#[derive(Clone, Debug, Eq, PartialEq)]
1231struct DurableSlotSnapshot {
1232    slot: Slot,
1233    bytes: Vec<u8>,
1234}
1235
1236#[derive(Clone, Debug, Eq, PartialEq)]
1237enum RecordedHeadProvenance {
1238    Empty,
1239    SlotBacked {
1240        slot: Slot,
1241    },
1242    CheckpointBacked {
1243        stop_slot: Slot,
1244        prefix_hash: LogHash,
1245        recovered_tip: Slot,
1246        recovered_hash: LogHash,
1247    },
1248}
1249
1250pub trait RecorderRpc: Send + Sync {
1251    /// Performs one genuine QuePaxa Record operation.
1252    ///
1253    /// Network implementations must enforce a finite deadline and return an
1254    /// error when it expires. The same bounded-call requirement applies to all
1255    /// process-bound methods on this trait.
1256    fn record(&self, _request: RecordRequest) -> Result<RecordSummary> {
1257        Err(Error::TypedRecordRequired)
1258    }
1259
1260    fn install_decision_proof(
1261        &self,
1262        _proof: DecisionProof,
1263        _membership: &Membership,
1264    ) -> Result<()> {
1265        Err(Error::TypedProofInstallRequired)
1266    }
1267
1268    fn inspect_decision_proof(&self, _slot: Slot) -> Result<Option<DecisionProof>> {
1269        Ok(None)
1270    }
1271
1272    fn inspect_record_summary(&self, _slot: Slot) -> Result<Option<RecordSummary>> {
1273        Err(Error::TypedRecordRequired)
1274    }
1275
1276    /// Whether this recorder can atomically bind an exact slot observation to
1277    /// the durable maximum accepted-or-decided head and the requested config.
1278    fn supports_context_read_fence(&self) -> bool {
1279        false
1280    }
1281
1282    fn observe_read_fence(&self, _request: ReadFenceRequest) -> Result<ReadFenceObservation> {
1283        Err(Error::ReadFenceUnsupported)
1284    }
1285
1286    fn recorder_id(&self) -> Result<NodeId> {
1287        Err(Error::TypedRecordRequired)
1288    }
1289
1290    fn store_command(&self, command_hash: LogHash, command: StoredCommand) -> Result<()> {
1291        self.store_command_for(String::new(), 0, 0, LogHash::ZERO, command_hash, command)
1292    }
1293
1294    fn store_command_for(
1295        &self,
1296        cluster_id: ClusterId,
1297        epoch: Epoch,
1298        config_id: ConfigId,
1299        config_digest: LogHash,
1300        command_hash: LogHash,
1301        command: StoredCommand,
1302    ) -> Result<()> {
1303        let _ = (
1304            cluster_id,
1305            epoch,
1306            config_id,
1307            config_digest,
1308            command_hash,
1309            command,
1310        );
1311        Err(Error::TypedRecordRequired)
1312    }
1313
1314    fn fetch_command(&self, command_hash: LogHash) -> Result<Option<StoredCommand>> {
1315        self.fetch_command_for(String::new(), 0, 0, LogHash::ZERO, command_hash)
1316    }
1317
1318    fn fetch_command_for(
1319        &self,
1320        cluster_id: ClusterId,
1321        epoch: Epoch,
1322        config_id: ConfigId,
1323        config_digest: LogHash,
1324        command_hash: LogHash,
1325    ) -> Result<Option<StoredCommand>> {
1326        let _ = (cluster_id, epoch, config_id, config_digest, command_hash);
1327        Err(Error::TypedRecordRequired)
1328    }
1329}
1330
1331impl RecorderFileStore {
1332    pub fn new(
1333        root: impl Into<PathBuf>,
1334        cluster_id: impl Into<ClusterId>,
1335        epoch: Epoch,
1336        config_id: ConfigId,
1337    ) -> Result<Self> {
1338        let root = root.into();
1339        let recorder_id = root
1340            .file_name()
1341            .and_then(|name| name.to_str())
1342            .filter(|name| !name.is_empty())
1343            .unwrap_or("recorder")
1344            .to_string();
1345        Self::new_with_id(root, recorder_id, cluster_id, epoch, config_id)
1346    }
1347
1348    pub fn new_with_id(
1349        root: impl Into<PathBuf>,
1350        recorder_id: impl Into<NodeId>,
1351        cluster_id: impl Into<ClusterId>,
1352        epoch: Epoch,
1353        config_id: ConfigId,
1354    ) -> Result<Self> {
1355        let (store, existing_format) =
1356            Self::open_root(root, recorder_id, cluster_id, epoch, config_id)?;
1357        store.open_or_initialize_recorded_head(existing_format)?;
1358        store.open_or_replay_wal()?;
1359        Ok(store)
1360    }
1361
1362    fn open_root(
1363        root: impl Into<PathBuf>,
1364        recorder_id: impl Into<NodeId>,
1365        cluster_id: impl Into<ClusterId>,
1366        epoch: Epoch,
1367        config_id: ConfigId,
1368    ) -> Result<(Self, bool)> {
1369        let root = root.into();
1370        let head_exists = root.join("recorded-head.rec").exists();
1371        let legacy_files_exist = if root.exists() && !head_exists {
1372            fs::read_dir(&root)
1373                .map_err(|err| Error::Io(err.to_string()))?
1374                .filter_map(std::result::Result::ok)
1375                .any(|entry| {
1376                    let name = entry.file_name();
1377                    let name = name.to_string_lossy();
1378                    name == "configuration.rec"
1379                        || name.starts_with("slot-")
1380                        || name.starts_with("command-")
1381                })
1382        } else {
1383            false
1384        };
1385        let existing_format = head_exists || legacy_files_exist;
1386        let recorder_id = recorder_id.into();
1387        if recorder_id.is_empty() {
1388            return Err(Error::EmptyRecorderIdentity);
1389        }
1390        fs::create_dir_all(&root).map_err(|err| Error::Io(err.to_string()))?;
1391        let root_lock = fs::OpenOptions::new()
1392            .read(true)
1393            .write(true)
1394            .create(true)
1395            .truncate(false)
1396            .open(root.join(".recorder.lock"))
1397            .map_err(|err| Error::Io(err.to_string()))?;
1398        match root_lock.try_lock() {
1399            Ok(()) => {}
1400            Err(fs::TryLockError::WouldBlock) => {
1401                return Err(Error::RecorderRootLocked(root));
1402            }
1403            Err(fs::TryLockError::Error(err)) => return Err(Error::Io(err.to_string())),
1404        }
1405        Ok((
1406            Self {
1407                root,
1408                recorder_id,
1409                cluster_id: cluster_id.into(),
1410                epoch,
1411                config_id,
1412                config_digest: LogHash::ZERO,
1413                configuration: Arc::new(Mutex::new(ConfigurationState::initial(
1414                    config_id,
1415                    LogHash::ZERO,
1416                    None,
1417                ))),
1418                recorded_head: Arc::new(Mutex::new(RecordedHeadProvenance::Empty)),
1419                recent_slots: Arc::new(Mutex::new(Vec::new())),
1420                wal: Arc::new(Mutex::new(RecorderWal::default())),
1421                seal_fault: Arc::new(Mutex::new(None)),
1422                _root_lock: Arc::new(root_lock),
1423                sync: Arc::new(Mutex::new(())),
1424            },
1425            existing_format,
1426        ))
1427    }
1428
1429    pub fn new_with_membership(
1430        root: impl Into<PathBuf>,
1431        recorder_id: impl Into<NodeId>,
1432        cluster_id: impl Into<ClusterId>,
1433        epoch: Epoch,
1434        config_id: ConfigId,
1435        membership: Membership,
1436    ) -> Result<Self> {
1437        let recorder_id = recorder_id.into();
1438        let (mut store, existing_format) =
1439            Self::open_root(root, recorder_id, cluster_id, epoch, config_id)?;
1440        store.recover_configuration_head_intent()?;
1441        let configured = if store.configuration_path().exists() {
1442            decode_configuration_state(
1443                &fs::read(store.configuration_path()).map_err(|err| Error::Io(err.to_string()))?,
1444            )?
1445        } else {
1446            if existing_format {
1447                return Err(Error::MigrationRequired {
1448                    format: "recorder durable head",
1449                    version: 2,
1450                });
1451            }
1452            let configured =
1453                ConfigurationState::initial(config_id, membership.digest(), Some(membership));
1454            store
1455                .commit_configuration_head_unlocked(&configured, &RecordedHeadProvenance::Empty)?;
1456            configured
1457        };
1458        if configured
1459            .membership
1460            .as_ref()
1461            .is_some_and(|current| current.digest() != configured.config_digest)
1462        {
1463            return Err(Error::Decode("installed membership digest mismatch".into()));
1464        }
1465        store.config_id = configured.config_id;
1466        store.config_digest = configured.config_digest;
1467        store.configuration = Arc::new(Mutex::new(configured));
1468        store.recover_intent()?;
1469        store.open_or_initialize_recorded_head(existing_format)?;
1470        store.open_or_replay_wal()?;
1471        Ok(store)
1472    }
1473
1474    pub fn configuration_state(&self) -> Result<ConfigurationState> {
1475        self.configuration
1476            .lock()
1477            .map(|state| state.clone())
1478            .map_err(|_| Error::Io("configuration lock poisoned".into()))
1479    }
1480
1481    #[doc(hidden)]
1482    pub fn set_seal_fault(&self, fault: Option<SealFaultPoint>) -> Result<()> {
1483        *self
1484            .seal_fault
1485            .lock()
1486            .map_err(|_| Error::Io("seal fault lock poisoned".into()))? = fault;
1487        Ok(())
1488    }
1489
1490    pub fn install_successor(
1491        &self,
1492        _next_config_id: ConfigId,
1493        _membership: Membership,
1494        _stop_certificate: &DecisionCertificate,
1495        _stop_slot: Slot,
1496        _prefix_hash: LogHash,
1497    ) -> Result<ConfigurationState> {
1498        Err(Error::Rejected(RejectReason::InvalidTransition))
1499    }
1500
1501    pub fn install_successor_from_proof(
1502        &self,
1503        membership: Membership,
1504        stop_proof: &DecisionProof,
1505    ) -> Result<ConfigurationState> {
1506        let _guard = self
1507            .sync
1508            .lock()
1509            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1510        self.recover_intent()?;
1511        let current = self.configuration_state()?;
1512        let Some(old_membership) = current.membership.as_ref() else {
1513            return Err(Error::Rejected(RejectReason::ConfigurationNotInstalled));
1514        };
1515        let next_config_id = current
1516            .config_id
1517            .checked_add(1)
1518            .ok_or(Error::Rejected(RejectReason::InvalidTransition))?;
1519        if current.predecessor.is_some() && !current.activated {
1520            return Err(Error::Rejected(RejectReason::TransitionInProgress));
1521        }
1522        if !membership.contains(&self.recorder_id) {
1523            return Err(Error::Rejected(RejectReason::LocalVoterRequired));
1524        }
1525        if proof_cluster_id(stop_proof) != self.cluster_id {
1526            return Err(Error::Rejected(RejectReason::WrongCluster));
1527        }
1528        let (stop_slot, epoch, config_id, config_digest) = proof_context(stop_proof);
1529        if epoch != self.epoch
1530            || config_id != current.config_id
1531            || config_digest != current.config_digest
1532        {
1533            return Err(Error::Rejected(RejectReason::InvalidTransition));
1534        }
1535        stop_proof
1536            .validate_for_cluster(
1537                &self.cluster_id,
1538                stop_slot,
1539                self.epoch,
1540                current.config_id,
1541                old_membership,
1542            )
1543            .map_err(Error::Rejected)?;
1544        let stop_command = ConfigChange::bound_stop(
1545            self.cluster_id.clone(),
1546            current.config_id,
1547            current.config_digest,
1548            next_config_id,
1549            membership.members().to_vec(),
1550        )
1551        .map_err(|_| Error::Rejected(RejectReason::InvalidTransition))?
1552        .to_stored_command();
1553        let stop_value = stop_proof
1554            .proposal()
1555            .value
1556            .as_ref()
1557            .ok_or(Error::Rejected(RejectReason::InvalidCertificate))?;
1558        let expected_stop = AcceptedValue::from_command(
1559            &self.cluster_id,
1560            stop_slot,
1561            self.epoch,
1562            current.config_id,
1563            stop_value.prev_hash,
1564            &stop_command,
1565        );
1566        if *stop_value != expected_stop {
1567            return Err(Error::Rejected(RejectReason::InvalidTransition));
1568        }
1569        let prefix_hash = expected_stop.entry_hash;
1570        let expected_seal = ConfigurationSeal {
1571            stop_slot,
1572            command_hash: expected_stop.command_hash,
1573            prefix_hash,
1574        };
1575        if current
1576            .seal
1577            .as_ref()
1578            .is_some_and(|seal| seal != &expected_seal)
1579        {
1580            return Err(Error::Rejected(RejectReason::InvalidTransition));
1581        }
1582        self.checkpoint_wal_unlocked()?;
1583        self.store_command_unlocked(expected_stop.command_hash, &stop_command)?;
1584        let installed = ConfigurationState {
1585            config_id: next_config_id,
1586            config_digest: membership.digest(),
1587            membership: Some(membership),
1588            predecessor: Some(expected_seal),
1589            seal: None,
1590            max_accepted_or_decided_slot: None,
1591            activated: false,
1592        };
1593        let head = RecordedHeadProvenance::Empty;
1594        self.commit_configuration_head_unlocked(&installed, &head)?;
1595        *self
1596            .configuration
1597            .lock()
1598            .map_err(|_| Error::Io("configuration lock poisoned".into()))? = installed.clone();
1599        *self
1600            .recorded_head
1601            .lock()
1602            .map_err(|_| Error::Io("recorder head lock poisoned".into()))? = head;
1603        self.recent_slots
1604            .lock()
1605            .map_err(|_| Error::Io("recorder recent-slot lock poisoned".into()))?
1606            .clear();
1607        Ok(installed)
1608    }
1609
1610    pub fn recover_successor_activation_from_checkpoint(
1611        &self,
1612        stop_slot: Slot,
1613        prefix_hash: LogHash,
1614        recovered_tip: Slot,
1615        recovered_hash: LogHash,
1616    ) -> Result<ConfigurationState> {
1617        let _guard = self
1618            .sync
1619            .lock()
1620            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1621        self.recover_intent()?;
1622        let current = self.configuration_state()?;
1623        let predecessor = current
1624            .predecessor
1625            .as_ref()
1626            .ok_or(Error::Rejected(RejectReason::InvalidTransition))?;
1627        if predecessor.stop_slot != stop_slot
1628            || predecessor.prefix_hash != prefix_hash
1629            || recovered_tip <= stop_slot
1630        {
1631            return Err(Error::Rejected(RejectReason::InvalidTransition));
1632        }
1633        if current.activated {
1634            if current
1635                .max_accepted_or_decided_slot
1636                .is_some_and(|slot| slot > recovered_tip)
1637            {
1638                return Err(Error::Rejected(RejectReason::InvalidTransition));
1639            }
1640            return Ok(current);
1641        }
1642        let mut recovered = current;
1643        recovered.activated = true;
1644        recovered.max_accepted_or_decided_slot = Some(recovered_tip);
1645        let head = RecordedHeadProvenance::CheckpointBacked {
1646            stop_slot,
1647            prefix_hash,
1648            recovered_tip,
1649            recovered_hash,
1650        };
1651        self.checkpoint_wal_unlocked()?;
1652        self.commit_configuration_head_unlocked(&recovered, &head)?;
1653        *self
1654            .configuration
1655            .lock()
1656            .map_err(|_| Error::Io("configuration lock poisoned".into()))? = recovered.clone();
1657        *self
1658            .recorded_head
1659            .lock()
1660            .map_err(|_| Error::Io("recorder head lock poisoned".into()))? = head;
1661        self.recent_slots
1662            .lock()
1663            .map_err(|_| Error::Io("recorder recent-slot lock poisoned".into()))?
1664            .clear();
1665        Ok(recovered)
1666    }
1667
1668    pub fn apply(&self, request: RecorderRequest) -> Result<RecorderReply> {
1669        if matches!(
1670            request,
1671            RecorderRequest::Observe { .. }
1672                | RecorderRequest::Converge { .. }
1673                | RecorderRequest::Decide { .. }
1674        ) {
1675            return Err(Error::Rejected(RejectReason::InvalidRequest));
1676        }
1677        if !matches!(request, RecorderRequest::Identity) {
1678            self.validate_request_context(&request)?;
1679        }
1680        match request {
1681            RecorderRequest::Identity => Ok(self.reply(0, None)),
1682            RecorderRequest::StoreCommand {
1683                config_id,
1684                config_digest,
1685                command_hash,
1686                command,
1687                ..
1688            } => {
1689                let _guard = self
1690                    .sync
1691                    .lock()
1692                    .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1693                self.recover_intent()?;
1694                let context = RecorderRequest::StoreCommand {
1695                    cluster_id: self.cluster_id.clone(),
1696                    epoch: self.epoch,
1697                    config_id,
1698                    config_digest,
1699                    command_hash,
1700                    command: command.clone(),
1701                };
1702                self.validate_request_context(&context)?;
1703                self.store_command_unlocked(command_hash, &command)?;
1704                let mut reply = self.reply(0, None);
1705                reply.config_id = config_id;
1706                reply.config_digest = config_digest;
1707                Ok(reply)
1708            }
1709            RecorderRequest::FetchCommand {
1710                config_id,
1711                config_digest,
1712                command_hash,
1713                ..
1714            } => {
1715                let _guard = self
1716                    .sync
1717                    .lock()
1718                    .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1719                self.recover_intent()?;
1720                let command = self.fetch_command_unlocked(command_hash)?;
1721                let mut reply = self.reply(0, command);
1722                reply.config_id = config_id;
1723                reply.config_digest = config_digest;
1724                Ok(reply)
1725            }
1726            request => {
1727                let slot =
1728                    request_slot(&request).ok_or(Error::Rejected(RejectReason::InvalidRequest))?;
1729                let request_digest = request_context(&request)
1730                    .ok_or(Error::Rejected(RejectReason::InvalidRequest))?
1731                    .3;
1732                let should_save = !matches!(request, RecorderRequest::Inspect { .. });
1733                let _guard = self
1734                    .sync
1735                    .lock()
1736                    .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1737                self.recover_intent()?;
1738                self.validate_request_context(&request)?;
1739                let configuration = self.configuration_state()?;
1740                let change = match &request {
1741                    RecorderRequest::Converge { value, .. } => {
1742                        self.change_for_value_unlocked(value)?
1743                    }
1744                    RecorderRequest::Decide { decision, .. } => {
1745                        if let Some(membership) = configuration.membership.as_ref() {
1746                            decision
1747                                .validate_for(configuration.config_id, membership)
1748                                .map_err(Error::Rejected)?;
1749                        }
1750                        self.change_for_value_unlocked(&decision.value)?
1751                    }
1752                    _ => None,
1753                };
1754                if !configuration.activated
1755                    && change.is_none()
1756                    && matches!(
1757                        &request,
1758                        RecorderRequest::Converge { .. } | RecorderRequest::Decide { .. }
1759                    )
1760                {
1761                    return Err(Error::Rejected(RejectReason::ActivationRequired));
1762                }
1763                self.validate_slot_gate(&configuration, slot, change.as_ref())?;
1764                match &request {
1765                    RecorderRequest::Converge { value, .. } => {
1766                        self.validate_value_unlocked(slot, value)?;
1767                    }
1768                    RecorderRequest::Decide { decision, .. } => {
1769                        self.validate_value_unlocked(slot, &decision.value)?;
1770                    }
1771                    _ => {}
1772                }
1773                let mut state = self.load_unlocked(slot, request_digest)?;
1774                let mut reply = state.apply(request).map_err(Error::Rejected)?;
1775                let next_configuration =
1776                    self.transition_after_apply(&configuration, &state, change.as_ref(), None)?;
1777                if should_save || next_configuration != configuration {
1778                    self.persist_state_transition_unlocked(
1779                        &state,
1780                        &configuration,
1781                        &next_configuration,
1782                    )?;
1783                }
1784                reply.recorder_id = self.recorder_id.clone();
1785                Ok(reply)
1786            }
1787        }
1788    }
1789
1790    pub fn record_proposal(&self, request: RecordRequest) -> Result<RecordSummary> {
1791        self.validate_record_context(&request)?;
1792        let value = request
1793            .proposal
1794            .value
1795            .as_ref()
1796            .ok_or(Error::Rejected(RejectReason::InvalidRequest))?;
1797        let _guard = self
1798            .sync
1799            .lock()
1800            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1801        self.recover_intent()?;
1802        let configuration = self.configuration_state()?;
1803        let command = if let Some(command) = request.command.as_ref() {
1804            self.validate_resolved_command_for_value(
1805                request.slot,
1806                configuration.config_id,
1807                value,
1808                command,
1809            )?;
1810            std::borrow::Cow::Borrowed(command)
1811        } else {
1812            std::borrow::Cow::Owned(self.command_for_value_unlocked(value)?)
1813        };
1814        let change = Self::change_for_command(&command)?;
1815        if !configuration.activated && change.is_none() {
1816            return Err(Error::Rejected(RejectReason::ActivationRequired));
1817        }
1818        self.validate_slot_gate(&configuration, request.slot, change.as_ref())?;
1819        if request.command.is_none() {
1820            self.validate_resolved_command_for_value(
1821                request.slot,
1822                configuration.config_id,
1823                value,
1824                &command,
1825            )?;
1826        }
1827        let state = self.load_unlocked(request.slot, request.config_digest)?;
1828        if let Some(proof) = state.decision_proof() {
1829            return Ok(record_summary(
1830                &self.recorder_id,
1831                &state,
1832                Some(proof.clone()),
1833            ));
1834        }
1835        let (mut next, _) = state.record(&request).map_err(Error::Rejected)?;
1836
1837        // Legacy fields are intentionally not synthesized from ISR state.
1838        next.highest_promised = next.isr.first_current().and_then(proposal_ballot);
1839        next.accepted = None;
1840        let next_configuration =
1841            self.transition_after_apply(&configuration, &next, change.as_ref(), Some(value))?;
1842        self.persist_state_transition_with_command_unlocked(
1843            &next,
1844            &configuration,
1845            &next_configuration,
1846            request
1847                .command
1848                .as_ref()
1849                .map(|command| (value.command_hash, command)),
1850        )?;
1851        Ok(record_summary(&self.recorder_id, &next, None))
1852    }
1853
1854    pub fn install_decision_proof_record(
1855        &self,
1856        proof: DecisionProof,
1857        membership: &Membership,
1858    ) -> Result<()> {
1859        let (slot, epoch, config_id, digest) = proof_context(&proof);
1860        if proof_cluster_id(&proof) != self.cluster_id {
1861            return Err(Error::Rejected(RejectReason::WrongCluster));
1862        }
1863        let _guard = self
1864            .sync
1865            .lock()
1866            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1867        self.recover_intent()?;
1868        let configuration = self.configuration_state()?;
1869        if epoch != self.epoch
1870            || config_id != configuration.config_id
1871            || digest != configuration.config_digest
1872            || configuration.membership.as_ref() != Some(membership)
1873        {
1874            return Err(Error::Rejected(RejectReason::WrongConfig));
1875        }
1876        proof
1877            .validate_for_cluster(&self.cluster_id, slot, epoch, config_id, membership)
1878            .map_err(Error::Rejected)?;
1879        let value = proof
1880            .proposal()
1881            .value
1882            .as_ref()
1883            .ok_or(Error::Rejected(RejectReason::InvalidCertificate))?;
1884        self.validate_value_unlocked(slot, value)?;
1885        let mut state = self.load_unlocked(slot, digest)?;
1886        if state.decision_proof().is_some() {
1887            state.install_proof(proof).map_err(Error::Rejected)?;
1888            return Ok(());
1889        }
1890        let change = self.change_for_value_unlocked(value)?;
1891        self.validate_slot_gate(&configuration, slot, change.as_ref())?;
1892        state
1893            .install_proof(proof.clone())
1894            .map_err(Error::Rejected)?;
1895        let certificate = certificate_from_proof(&proof)?;
1896        if let Some(existing) = &state.decided {
1897            if existing.value != certificate.value {
1898                return Err(Error::Rejected(RejectReason::AlreadyDecided));
1899            }
1900        } else {
1901            state.decided = Some(certificate);
1902        }
1903        let next =
1904            self.transition_after_apply(&configuration, &state, change.as_ref(), Some(value))?;
1905        self.persist_state_transition_unlocked(&state, &configuration, &next)
1906    }
1907
1908    fn validate_record_context(&self, request: &RecordRequest) -> Result<()> {
1909        if request.cluster_id != self.cluster_id {
1910            return Err(Error::Rejected(RejectReason::WrongCluster));
1911        }
1912        if request.epoch != self.epoch {
1913            return Err(Error::Rejected(if request.epoch < self.epoch {
1914                RejectReason::StaleEpoch
1915            } else {
1916                RejectReason::FutureEpoch
1917            }));
1918        }
1919        let configuration = self.configuration_state()?;
1920        if request.config_id != configuration.config_id
1921            || (configuration.config_digest != LogHash::ZERO
1922                && request.config_digest != configuration.config_digest)
1923        {
1924            return Err(Error::Rejected(RejectReason::WrongConfig));
1925        }
1926        Ok(())
1927    }
1928
1929    pub fn load(&self, slot: Slot) -> Result<RecorderSlotState> {
1930        let _guard = self
1931            .sync
1932            .lock()
1933            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1934        self.load_unlocked(slot, self.config_digest())
1935    }
1936
1937    pub fn save(&self, state: &RecorderSlotState) -> Result<()> {
1938        let _guard = self
1939            .sync
1940            .lock()
1941            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1942        self.recover_intent()?;
1943        let configuration = self.configuration_state()?;
1944        if state.cluster_id != self.cluster_id
1945            || state.epoch != self.epoch
1946            || state.config_id != configuration.config_id
1947            || (configuration.config_digest != LogHash::ZERO
1948                && state.config_digest != configuration.config_digest)
1949        {
1950            return Err(Error::Rejected(RejectReason::WrongConfig));
1951        }
1952        let change = state
1953            .decided()
1954            .map(|decision| &decision.value)
1955            .or_else(|| state.accepted().map(|accepted| &accepted.value))
1956            .map(|value| self.change_for_value_unlocked(value))
1957            .transpose()?
1958            .flatten();
1959        self.validate_slot_gate(&configuration, state.slot(), change.as_ref())?;
1960        let applied_value = state
1961            .decided()
1962            .map(|decision| &decision.value)
1963            .or_else(|| state.accepted().map(|accepted| &accepted.value));
1964        let next =
1965            self.transition_after_apply(&configuration, state, change.as_ref(), applied_value)?;
1966        self.persist_state_transition_unlocked(state, &configuration, &next)
1967    }
1968
1969    pub fn store_command(&self, command_hash: LogHash, command: StoredCommand) -> Result<()> {
1970        let _guard = self
1971            .sync
1972            .lock()
1973            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
1974        self.store_command_unlocked(command_hash, &command)
1975    }
1976
1977    fn store_command_unlocked(&self, command_hash: LogHash, command: &StoredCommand) -> Result<()> {
1978        if command.hash() != command_hash {
1979            return Err(Error::CommandHashMismatch);
1980        }
1981        {
1982            let wal = self
1983                .wal
1984                .lock()
1985                .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?;
1986            match wal.commands.get(&command_hash) {
1987                Some(existing) if existing == command => return Ok(()),
1988                Some(_) => return Err(Error::CommandHashMismatch),
1989                None => {}
1990            }
1991        }
1992        self.stage_command_unlocked(command_hash, command)?;
1993        self.sync_root()
1994    }
1995
1996    fn stage_command_unlocked(&self, command_hash: LogHash, command: &StoredCommand) -> Result<()> {
1997        let path = self.command_path(command_hash);
1998        if path.exists() {
1999            return match self.fetch_command_cache_unlocked(command_hash)? {
2000                Some(existing) if existing == *command => Ok(()),
2001                _ => Err(Error::CommandHashMismatch),
2002            };
2003        }
2004        atomic_replace(&path, &encode_stored_command(command))?;
2005        Ok(())
2006    }
2007
2008    pub fn fetch_command(&self, command_hash: LogHash) -> Result<Option<StoredCommand>> {
2009        let _guard = self
2010            .sync
2011            .lock()
2012            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
2013        self.fetch_command_unlocked(command_hash)
2014    }
2015
2016    fn load_unlocked(&self, slot: Slot, config_digest: LogHash) -> Result<RecorderSlotState> {
2017        let wal = self
2018            .wal
2019            .lock()
2020            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?;
2021        if let Some(bytes) = wal.slots.get(&slot) {
2022            let state = decode_recorder_state(bytes)?;
2023            drop(wal);
2024            if state.cluster_id != self.cluster_id
2025                || state.epoch != self.epoch
2026                || state.config_id != self.current_config_id()
2027                || (config_digest != LogHash::ZERO && state.config_digest != config_digest)
2028            {
2029                return Err(Error::Decode("recorder WAL state identity mismatch".into()));
2030            }
2031            return Ok(state);
2032        }
2033        drop(wal);
2034        let path = self.path(slot);
2035        if !path.exists() {
2036            return Ok(RecorderSlotState::new_with_digest(
2037                slot,
2038                self.cluster_id.clone(),
2039                self.epoch,
2040                self.current_config_id(),
2041                config_digest,
2042            ));
2043        }
2044        let state =
2045            decode_recorder_state(&fs::read(path).map_err(|err| Error::Io(err.to_string()))?)?;
2046        if state.slot != slot
2047            || state.cluster_id != self.cluster_id
2048            || state.epoch != self.epoch
2049            || state.config_id != self.current_config_id()
2050            || (config_digest != LogHash::ZERO && state.config_digest != config_digest)
2051        {
2052            return Err(Error::Decode("recorder state identity mismatch".into()));
2053        }
2054        Ok(state)
2055    }
2056
2057    fn open_or_initialize_recorded_head(&self, existing_format: bool) -> Result<()> {
2058        let configuration = self.configuration_state()?;
2059        let (head, recent_slots, wal_checkpoint) = if self.recorded_head_path().exists() {
2060            decode_recorded_head(
2061                &fs::read(self.recorded_head_path()).map_err(|err| Error::Io(err.to_string()))?,
2062                &self.cluster_id,
2063                self.epoch,
2064                &configuration,
2065            )?
2066        } else {
2067            if existing_format {
2068                return Err(Error::MigrationRequired {
2069                    format: "recorder durable head",
2070                    version: 2,
2071                });
2072            }
2073            let head = RecordedHeadProvenance::Empty;
2074            let wal_checkpoint = WalCheckpoint::default();
2075            atomic_write(
2076                &self.recorded_head_path(),
2077                &encode_recorded_head(
2078                    &self.cluster_id,
2079                    self.epoch,
2080                    &configuration,
2081                    &head,
2082                    &[],
2083                    wal_checkpoint,
2084                )?,
2085            )?;
2086            (head, Vec::new(), wal_checkpoint)
2087        };
2088        self.install_recorded_head(&configuration, head, recent_slots, wal_checkpoint)
2089    }
2090
2091    fn open_or_replay_wal(&self) -> Result<()> {
2092        let path = self.wal_path();
2093        let created = match fs::OpenOptions::new()
2094            .write(true)
2095            .create_new(true)
2096            .open(&path)
2097        {
2098            Ok(_) => true,
2099            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false,
2100            Err(error) => return Err(Error::Io(error.to_string())),
2101        };
2102        if created {
2103            self.sync_root()?;
2104        }
2105        let bytes = fs::read(&path).map_err(|error| Error::Io(error.to_string()))?;
2106        let checkpoint = self.wal_checkpoint()?;
2107        let mut replayed = RecorderWal {
2108            checkpoint,
2109            next_sequence: checkpoint
2110                .through_sequence
2111                .checked_add(1)
2112                .ok_or_else(|| Error::Decode("recorder WAL sequence exhausted".into()))?,
2113            ..RecorderWal::default()
2114        };
2115        let mut configuration = self.configuration_state()?;
2116        let mut head = self
2117            .recorded_head
2118            .lock()
2119            .map_err(|_| Error::Io("recorder head lock poisoned".into()))?
2120            .clone();
2121        let mut offset = 0usize;
2122        while offset < bytes.len() {
2123            let Some((frame, end)) = decode_wal_frame(&bytes, offset)? else {
2124                break;
2125            };
2126            if frame.generation < checkpoint.generation {
2127                offset = end;
2128                continue;
2129            }
2130            if frame.generation != checkpoint.generation
2131                || frame.sequence != replayed.next_sequence
2132                || frame.prev_digest != replayed.last_digest
2133            {
2134                return Err(Error::Decode(
2135                    "recorder WAL sequence or digest chain mismatch".into(),
2136                ));
2137            }
2138            let state = decode_recorder_state(&frame.slot_bytes)?;
2139            let next_configuration = decode_configuration_state(&frame.configuration_bytes)?;
2140            if state.slot() != frame.slot
2141                || state.cluster_id != self.cluster_id
2142                || state.epoch != self.epoch
2143                || state.config_id != next_configuration.config_id
2144                || state.config_digest != next_configuration.config_digest
2145                || configuration_structure_changed(&configuration, &next_configuration)
2146            {
2147                return Err(Error::Decode("recorder WAL state identity mismatch".into()));
2148            }
2149            if let Some((hash, command)) = &frame.command {
2150                if command.hash() != *hash {
2151                    return Err(Error::Decode(
2152                        "recorder WAL inline command hash mismatch".into(),
2153                    ));
2154                }
2155                upsert_wal_command(&mut replayed.commands, *hash, command)?;
2156            }
2157            for value in recorder_state_values(&state) {
2158                let cached_command;
2159                let command = match replayed.commands.get(&value.command_hash) {
2160                    Some(command) => command,
2161                    None => {
2162                        cached_command = self
2163                            .fetch_command_cache_unlocked(value.command_hash)
2164                            .ok()
2165                            .flatten()
2166                            .ok_or(Error::CommandUnavailable)?;
2167                        &cached_command
2168                    }
2169                };
2170                if AcceptedValue::from_command(
2171                    &self.cluster_id,
2172                    frame.slot,
2173                    self.epoch,
2174                    next_configuration.config_id,
2175                    value.prev_hash,
2176                    command,
2177                ) != *value
2178                {
2179                    return Err(Error::Decode("recorder WAL value mismatch".into()));
2180                }
2181            }
2182            let expected_head = if next_configuration.max_accepted_or_decided_slot
2183                == Some(frame.slot)
2184                && recorder_state_values(&state).next().is_some()
2185            {
2186                RecordedHeadProvenance::SlotBacked { slot: frame.slot }
2187            } else {
2188                head.clone()
2189            };
2190            if frame.head != expected_head {
2191                return Err(Error::Decode("recorder WAL head mismatch".into()));
2192            }
2193            replayed.slots.insert(frame.slot, frame.slot_bytes);
2194            replayed.next_sequence = replayed
2195                .next_sequence
2196                .checked_add(1)
2197                .ok_or_else(|| Error::Decode("recorder WAL sequence exhausted".into()))?;
2198            replayed.last_digest = frame.digest;
2199            replayed.frame_count += 1;
2200            configuration = next_configuration;
2201            head = frame.head;
2202            offset = end;
2203        }
2204        if offset != bytes.len() {
2205            let file = fs::OpenOptions::new()
2206                .write(true)
2207                .open(&path)
2208                .map_err(|error| Error::Io(error.to_string()))?;
2209            file.set_len(offset as u64)
2210                .and_then(|_| sync_wal_metadata(&file))
2211                .map_err(|error| Error::Io(error.to_string()))?;
2212        }
2213        replayed.file = Some(
2214            fs::OpenOptions::new()
2215                .append(true)
2216                .open(&path)
2217                .map_err(|error| Error::Io(error.to_string()))?,
2218        );
2219        replayed.byte_count = offset as u64;
2220        *self
2221            .wal
2222            .lock()
2223            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))? = replayed;
2224        *self
2225            .configuration
2226            .lock()
2227            .map_err(|_| Error::Io("configuration lock poisoned".into()))? = configuration;
2228        *self
2229            .recorded_head
2230            .lock()
2231            .map_err(|_| Error::Io("recorder head lock poisoned".into()))? = head;
2232        Ok(())
2233    }
2234
2235    fn install_recorded_head(
2236        &self,
2237        configuration: &ConfigurationState,
2238        head: RecordedHeadProvenance,
2239        recent_slots: Vec<DurableSlotSnapshot>,
2240        wal_checkpoint: WalCheckpoint,
2241    ) -> Result<()> {
2242        let mut recovered_cache = false;
2243        for snapshot in &recent_slots {
2244            let state = decode_recorder_state(&snapshot.bytes)?;
2245            if state.slot() != snapshot.slot
2246                || state.cluster_id != self.cluster_id
2247                || state.epoch != self.epoch
2248                || state.config_id != configuration.config_id
2249                || state.config_digest != configuration.config_digest
2250            {
2251                return Err(Error::Decode(
2252                    "durable recorder snapshot identity mismatch".into(),
2253                ));
2254            }
2255            for value in recorder_state_values(&state) {
2256                self.validate_value_unlocked(snapshot.slot, value)?;
2257            }
2258            let path = self.path(snapshot.slot);
2259            if fs::read(&path).ok().as_deref() != Some(snapshot.bytes.as_slice()) {
2260                atomic_replace(&path, &snapshot.bytes)?;
2261                recovered_cache = true;
2262            }
2263        }
2264        if recovered_cache {
2265            self.sync_root()?;
2266        }
2267        let recovered_max = match &head {
2268            RecordedHeadProvenance::Empty => None,
2269            RecordedHeadProvenance::SlotBacked { slot } => {
2270                let state = self.load_unlocked(*slot, configuration.config_digest)?;
2271                let mut values = recorder_state_values(&state).peekable();
2272                if values.peek().is_none() {
2273                    return Err(Error::Decode(
2274                        "slot-backed recorder head references a state without a value".into(),
2275                    ));
2276                }
2277                for value in values {
2278                    self.validate_value_unlocked(*slot, value)?;
2279                }
2280                Some(*slot)
2281            }
2282            RecordedHeadProvenance::CheckpointBacked {
2283                stop_slot,
2284                prefix_hash,
2285                recovered_tip,
2286                recovered_hash,
2287            } => {
2288                let predecessor = configuration.predecessor.as_ref().ok_or_else(|| {
2289                    Error::Decode("checkpoint-backed head has no predecessor binding".into())
2290                })?;
2291                if !configuration.activated
2292                    || predecessor.stop_slot != *stop_slot
2293                    || predecessor.prefix_hash != *prefix_hash
2294                    || recovered_tip <= stop_slot
2295                    || *recovered_hash == LogHash::ZERO
2296                    || configuration.max_accepted_or_decided_slot != Some(*recovered_tip)
2297                {
2298                    return Err(Error::Decode(
2299                        "checkpoint-backed recorder head evidence is invalid".into(),
2300                    ));
2301                }
2302                Some(*recovered_tip)
2303            }
2304        };
2305        self.configuration
2306            .lock()
2307            .map_err(|_| Error::Io("configuration lock poisoned".into()))?
2308            .max_accepted_or_decided_slot = recovered_max;
2309        *self
2310            .recorded_head
2311            .lock()
2312            .map_err(|_| Error::Io("recorder head lock poisoned".into()))? = head;
2313        *self
2314            .recent_slots
2315            .lock()
2316            .map_err(|_| Error::Io("recorder recent-slot lock poisoned".into()))? = recent_slots;
2317        let mut wal = self
2318            .wal
2319            .lock()
2320            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?;
2321        wal.checkpoint = wal_checkpoint;
2322        wal.next_sequence = wal_checkpoint
2323            .through_sequence
2324            .checked_add(1)
2325            .ok_or_else(|| Error::Decode("recorder WAL sequence exhausted".into()))?;
2326        Ok(())
2327    }
2328
2329    fn fetch_command_unlocked(&self, command_hash: LogHash) -> Result<Option<StoredCommand>> {
2330        let wal = self
2331            .wal
2332            .lock()
2333            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?;
2334        if let Some(command) = wal.commands.get(&command_hash).cloned() {
2335            return Ok(Some(command));
2336        }
2337        drop(wal);
2338        self.fetch_command_cache_unlocked(command_hash)
2339    }
2340
2341    fn fetch_command_cache_unlocked(&self, command_hash: LogHash) -> Result<Option<StoredCommand>> {
2342        let path = self.command_path(command_hash);
2343        if !path.exists() {
2344            return Ok(None);
2345        }
2346        #[cfg(test)]
2347        COMMAND_FILE_READS.with(|reads| reads.set(reads.get() + 1));
2348        let command =
2349            decode_stored_command(&fs::read(path).map_err(|err| Error::Io(err.to_string()))?)?;
2350        if command.hash() != command_hash {
2351            return Err(Error::CommandHashMismatch);
2352        }
2353        Ok(Some(command))
2354    }
2355
2356    fn validate_value_unlocked(&self, slot: Slot, value: &AcceptedValue) -> Result<()> {
2357        let config_id = self.current_config_id();
2358        let command = self.command_for_value_unlocked(value)?;
2359        self.validate_resolved_command_for_value(slot, config_id, value, &command)
2360    }
2361
2362    fn validate_resolved_command_for_value(
2363        &self,
2364        slot: Slot,
2365        config_id: ConfigId,
2366        value: &AcceptedValue,
2367        command: &StoredCommand,
2368    ) -> Result<()> {
2369        let expected = AcceptedValue::from_command(
2370            &self.cluster_id,
2371            slot,
2372            self.epoch,
2373            config_id,
2374            value.prev_hash,
2375            command,
2376        );
2377        if expected != *value {
2378            return Err(Error::Rejected(RejectReason::InvalidValue));
2379        }
2380        Ok(())
2381    }
2382
2383    fn change_for_value_unlocked(&self, value: &AcceptedValue) -> Result<Option<ConfigChange>> {
2384        let command = self.command_for_value_unlocked(value)?;
2385        Self::change_for_command(&command)
2386    }
2387
2388    fn change_for_command(command: &StoredCommand) -> Result<Option<ConfigChange>> {
2389        if command.entry_type != EntryType::ConfigChange {
2390            return Ok(None);
2391        }
2392        ConfigChange::recognize(command)
2393            .map_err(|_| Error::Rejected(RejectReason::InvalidRequest))
2394            .map(Some)
2395    }
2396
2397    fn command_for_value_unlocked(&self, value: &AcceptedValue) -> Result<StoredCommand> {
2398        self.fetch_command_unlocked(value.command_hash)?
2399            .ok_or(Error::CommandUnavailable)
2400    }
2401
2402    fn validate_slot_gate(
2403        &self,
2404        configuration: &ConfigurationState,
2405        slot: Slot,
2406        change: Option<&ConfigChange>,
2407    ) -> Result<()> {
2408        if let Some(predecessor) = &configuration.predecessor {
2409            if slot <= predecessor.stop_slot {
2410                return Err(Error::Rejected(RejectReason::ConfigurationNotInstalled));
2411            }
2412        }
2413        if let Some(seal) = &configuration.seal {
2414            if slot > seal.stop_slot {
2415                return Err(Error::Rejected(RejectReason::ConfigurationSealed {
2416                    stop_slot: seal.stop_slot,
2417                }));
2418            }
2419            if matches!(
2420                change,
2421                Some(ConfigChange::Stop { .. } | ConfigChange::BoundStop { .. })
2422            ) && (slot != seal.stop_slot || seal.command_hash == LogHash::ZERO)
2423            {
2424                return Err(Error::Rejected(RejectReason::TransitionInProgress));
2425            }
2426        }
2427        if matches!(
2428            change,
2429            Some(ConfigChange::Stop { .. } | ConfigChange::BoundStop { .. })
2430        ) && configuration
2431            .max_accepted_or_decided_slot
2432            .is_some_and(|accepted_slot| accepted_slot > slot)
2433        {
2434            return Err(Error::Rejected(RejectReason::InvalidTransition));
2435        }
2436        if !configuration.activated {
2437            let Some(predecessor) = &configuration.predecessor else {
2438                return Err(Error::Rejected(RejectReason::InvalidTransition));
2439            };
2440            match change {
2441                Some(ConfigChange::BoundActivationBarrier {
2442                    successor,
2443                    stop_slot,
2444                    prefix_hash,
2445                    stop_command_hash,
2446                }) if successor.cluster_id() == self.cluster_id
2447                    && successor.config_id() == configuration.config_id
2448                    && successor.digest() == configuration.config_digest
2449                    && successor.predecessor_config_id().checked_add(1)
2450                        == Some(configuration.config_id)
2451                    && *stop_slot == predecessor.stop_slot
2452                    && *prefix_hash == predecessor.prefix_hash
2453                    && *stop_command_hash == predecessor.command_hash
2454                    && slot == predecessor.stop_slot + 1 => {}
2455                None if slot == predecessor.stop_slot + 1 => {}
2456                _ => return Err(Error::Rejected(RejectReason::ActivationRequired)),
2457            }
2458        } else if matches!(
2459            change,
2460            Some(
2461                ConfigChange::ActivationBarrier { .. }
2462                    | ConfigChange::BoundActivationBarrier { .. }
2463            )
2464        ) {
2465            return Err(Error::Rejected(RejectReason::InvalidTransition));
2466        }
2467        if let Some(change) = change {
2468            let (config_id, config_digest) = change.binding();
2469            if config_id != configuration.config_id || config_digest != configuration.config_digest
2470            {
2471                return Err(Error::Rejected(RejectReason::WrongConfig));
2472            }
2473        }
2474        Ok(())
2475    }
2476
2477    fn transition_after_apply(
2478        &self,
2479        configuration: &ConfigurationState,
2480        state: &RecorderSlotState,
2481        change: Option<&ConfigChange>,
2482        applied_value: Option<&AcceptedValue>,
2483    ) -> Result<ConfigurationState> {
2484        let mut next = configuration.clone();
2485        if applied_value.is_some() {
2486            next.max_accepted_or_decided_slot = Some(
2487                next.max_accepted_or_decided_slot
2488                    .map_or(state.slot(), |current| current.max(state.slot())),
2489            );
2490        }
2491        if state.decision_proof().is_some()
2492            && next.seal.as_ref().is_some_and(|seal| {
2493                seal.stop_slot == state.slot()
2494                    && applied_value.is_some_and(|value| value.command_hash != seal.command_hash)
2495            })
2496        {
2497            next.seal = None;
2498        }
2499        match change {
2500            Some(ConfigChange::Stop { .. } | ConfigChange::BoundStop { .. })
2501                if applied_value.is_some() =>
2502            {
2503                let value = applied_value.expect("checked applied value");
2504                let proposed = ConfigurationSeal {
2505                    stop_slot: state.slot(),
2506                    command_hash: value.command_hash,
2507                    prefix_hash: value.entry_hash,
2508                };
2509                if let Some(existing) = &next.seal {
2510                    if existing != &proposed {
2511                        return Err(Error::Rejected(RejectReason::TransitionInProgress));
2512                    }
2513                } else {
2514                    next.seal = Some(proposed);
2515                }
2516            }
2517            Some(
2518                ConfigChange::ActivationBarrier { .. }
2519                | ConfigChange::BoundActivationBarrier { .. },
2520            ) if state.decision_proof().is_some() => {
2521                next.activated = true;
2522            }
2523            _ => {}
2524        }
2525        Ok(next)
2526    }
2527
2528    fn validate_request_context(&self, request: &RecorderRequest) -> Result<()> {
2529        let (cluster_id, epoch, config_id, config_digest) =
2530            request_context(request).ok_or(Error::Rejected(RejectReason::InvalidRequest))?;
2531        if cluster_id != &self.cluster_id {
2532            return Err(Error::Rejected(RejectReason::WrongCluster));
2533        }
2534        if epoch < self.epoch {
2535            return Err(Error::Rejected(RejectReason::StaleEpoch));
2536        }
2537        if epoch > self.epoch {
2538            return Err(Error::Rejected(RejectReason::FutureEpoch));
2539        }
2540        let configuration = self.configuration_state()?;
2541        if config_id != configuration.config_id {
2542            return Err(Error::Rejected(RejectReason::WrongConfig));
2543        }
2544        if configuration.config_digest != LogHash::ZERO
2545            && config_digest != configuration.config_digest
2546        {
2547            return Err(Error::Rejected(RejectReason::WrongConfig));
2548        }
2549        Ok(())
2550    }
2551
2552    fn reply(&self, slot: Slot, command: Option<StoredCommand>) -> RecorderReply {
2553        RecorderReply {
2554            recorder_id: self.recorder_id.clone(),
2555            slot,
2556            config_id: self.current_config_id(),
2557            config_digest: self.config_digest(),
2558            step: 0,
2559            highest_promised: None,
2560            accepted: None,
2561            decided: None,
2562            command,
2563        }
2564    }
2565
2566    fn path(&self, slot: Slot) -> PathBuf {
2567        self.root.join(format!("slot-{slot:020}.rec"))
2568    }
2569
2570    fn command_path(&self, command_hash: LogHash) -> PathBuf {
2571        self.root
2572            .join(format!("command-{}.cmd", command_hash.to_hex()))
2573    }
2574
2575    fn configuration_path(&self) -> PathBuf {
2576        self.root.join("configuration.rec")
2577    }
2578
2579    fn intent_path(&self) -> PathBuf {
2580        self.root.join("configuration.intent")
2581    }
2582
2583    fn configuration_head_intent_path(&self) -> PathBuf {
2584        self.root.join("configuration-head.intent")
2585    }
2586
2587    fn recorded_head_path(&self) -> PathBuf {
2588        self.root.join("recorded-head.rec")
2589    }
2590
2591    fn wal_path(&self) -> PathBuf {
2592        self.root.join("recorder.wal")
2593    }
2594
2595    fn head_after_slot_state(
2596        &self,
2597        configuration: &ConfigurationState,
2598        slot_state: &RecorderSlotState,
2599    ) -> Result<RecordedHeadProvenance> {
2600        let current = self
2601            .recorded_head
2602            .lock()
2603            .map_err(|_| Error::Io("recorder head lock poisoned".into()))?
2604            .clone();
2605        if configuration.max_accepted_or_decided_slot == Some(slot_state.slot())
2606            && recorder_state_values(slot_state).next().is_some()
2607        {
2608            Ok(RecordedHeadProvenance::SlotBacked {
2609                slot: slot_state.slot(),
2610            })
2611        } else {
2612            Ok(current)
2613        }
2614    }
2615
2616    fn recover_intent(&self) -> Result<()> {
2617        self.recover_configuration_head_intent()?;
2618        let path = self.intent_path();
2619        if !path.exists() {
2620            return Ok(());
2621        }
2622        let (slot, slot_bytes, configuration_bytes) =
2623            decode_transition_intent(&fs::read(&path).map_err(|err| Error::Io(err.to_string()))?)?;
2624        let configuration = decode_configuration_state(&configuration_bytes)?;
2625        let slot_state = decode_recorder_state(&slot_bytes)?;
2626        let head = self.head_after_slot_state(&configuration, &slot_state)?;
2627        atomic_write(&self.path(slot), &slot_bytes)?;
2628        atomic_write(&self.configuration_path(), &configuration_bytes)?;
2629        atomic_write(
2630            &self.recorded_head_path(),
2631            &encode_recorded_head(
2632                &self.cluster_id,
2633                self.epoch,
2634                &configuration,
2635                &head,
2636                &[],
2637                self.wal_checkpoint()?,
2638            )?,
2639        )?;
2640        fs::remove_file(path).map_err(|err| Error::Io(err.to_string()))?;
2641        fs::File::open(&self.root)
2642            .and_then(|directory| directory.sync_all())
2643            .map_err(|err| Error::Io(err.to_string()))?;
2644        *self
2645            .configuration
2646            .lock()
2647            .map_err(|_| Error::Io("configuration lock poisoned".into()))? = configuration;
2648        *self
2649            .recorded_head
2650            .lock()
2651            .map_err(|_| Error::Io("recorder head lock poisoned".into()))? = head;
2652        self.recent_slots
2653            .lock()
2654            .map_err(|_| Error::Io("recorder recent-slot lock poisoned".into()))?
2655            .clear();
2656        Ok(())
2657    }
2658
2659    fn recover_configuration_head_intent(&self) -> Result<()> {
2660        let path = self.configuration_head_intent_path();
2661        if !path.exists() {
2662            return Ok(());
2663        }
2664        let intent_bytes = fs::read(&path).map_err(|err| Error::Io(err.to_string()))?;
2665        let (configuration_bytes, head_bytes) = decode_configuration_head_intent(&intent_bytes)?;
2666        atomic_write(&self.configuration_path(), configuration_bytes)?;
2667        atomic_write(&self.recorded_head_path(), head_bytes)?;
2668        fs::remove_file(path).map_err(|err| Error::Io(err.to_string()))?;
2669        self.sync_root()
2670    }
2671
2672    fn commit_configuration_head_unlocked(
2673        &self,
2674        configuration: &ConfigurationState,
2675        head: &RecordedHeadProvenance,
2676    ) -> Result<()> {
2677        let configuration_bytes = encode_configuration_state(configuration)?;
2678        let head_bytes = encode_recorded_head(
2679            &self.cluster_id,
2680            self.epoch,
2681            configuration,
2682            head,
2683            &[],
2684            self.wal_checkpoint()?,
2685        )?;
2686        atomic_write(
2687            &self.configuration_head_intent_path(),
2688            &encode_configuration_head_intent(&configuration_bytes, &head_bytes),
2689        )?;
2690        self.fail_seal_at(SealFaultPoint::AfterHeadIntent)?;
2691        atomic_write(&self.configuration_path(), &configuration_bytes)?;
2692        self.fail_seal_at(SealFaultPoint::AfterHeadConfiguration)?;
2693        atomic_write(&self.recorded_head_path(), &head_bytes)?;
2694        self.fail_seal_at(SealFaultPoint::AfterHead)?;
2695        fs::remove_file(self.configuration_head_intent_path())
2696            .map_err(|err| Error::Io(err.to_string()))?;
2697        self.sync_root()
2698    }
2699
2700    fn persist_state_transition_unlocked(
2701        &self,
2702        slot_state: &RecorderSlotState,
2703        previous: &ConfigurationState,
2704        next: &ConfigurationState,
2705    ) -> Result<()> {
2706        self.persist_state_transition_with_command_unlocked(slot_state, previous, next, None)
2707    }
2708
2709    fn persist_state_transition_with_command_unlocked(
2710        &self,
2711        slot_state: &RecorderSlotState,
2712        previous: &ConfigurationState,
2713        next: &ConfigurationState,
2714        command: Option<(LogHash, &StoredCommand)>,
2715    ) -> Result<()> {
2716        if configuration_structure_changed(previous, next) {
2717            self.checkpoint_wal_unlocked()?;
2718            if let Some((hash, command)) = command {
2719                self.store_command_unlocked(hash, command)?;
2720            }
2721            return self.commit_transition_unlocked(slot_state, next);
2722        }
2723        let head = self.head_after_slot_state(next, slot_state)?;
2724        self.append_wal_unlocked(slot_state, next, &head, command)?;
2725        *self
2726            .configuration
2727            .lock()
2728            .map_err(|_| Error::Io("configuration lock poisoned".into()))? = next.clone();
2729        *self
2730            .recorded_head
2731            .lock()
2732            .map_err(|_| Error::Io("recorder head lock poisoned".into()))? = head;
2733        self.recent_slots
2734            .lock()
2735            .map_err(|_| Error::Io("recorder recent-slot lock poisoned".into()))?
2736            .clear();
2737        Ok(())
2738    }
2739
2740    fn append_wal_unlocked(
2741        &self,
2742        slot_state: &RecorderSlotState,
2743        configuration: &ConfigurationState,
2744        head: &RecordedHeadProvenance,
2745        command: Option<(LogHash, &StoredCommand)>,
2746    ) -> Result<()> {
2747        let should_checkpoint = {
2748            let wal = self
2749                .wal
2750                .lock()
2751                .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?;
2752            if wal.failed {
2753                return Err(Error::Io(
2754                    "recorder WAL is unavailable after an I/O failure".into(),
2755                ));
2756            }
2757            wal.frame_count >= RECORDER_WAL_HARD_FRAME_LIMIT
2758                || wal.byte_count >= RECORDER_WAL_SOFT_BYTE_LIMIT
2759        };
2760        if should_checkpoint {
2761            self.checkpoint_wal_unlocked()?;
2762        }
2763        let (generation, sequence, prev_digest) = {
2764            let wal = self
2765                .wal
2766                .lock()
2767                .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?;
2768            (
2769                wal.checkpoint.generation,
2770                wal.next_sequence,
2771                wal.last_digest,
2772            )
2773        };
2774        let (frame, digest, slot_bytes) = encode_wal_frame(
2775            generation,
2776            sequence,
2777            prev_digest,
2778            slot_state,
2779            configuration,
2780            head,
2781            command,
2782        )?;
2783        let mut wal = self
2784            .wal
2785            .lock()
2786            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?;
2787        let append_result = (|| {
2788            let file = wal
2789                .file
2790                .as_mut()
2791                .ok_or_else(|| Error::Io("recorder WAL file is not open".into()))?;
2792            file.write_all(&frame)
2793                .map_err(|error| Error::Io(error.to_string()))?;
2794            self.fail_seal_at(SealFaultPoint::AfterWalWrite)?;
2795            sync_wal_append(file).map_err(|error| Error::Io(error.to_string()))?;
2796            self.fail_seal_at(SealFaultPoint::AfterWalSync)
2797        })();
2798        if let Err(error) = append_result {
2799            wal.failed = true;
2800            return Err(error);
2801        }
2802        wal.slots.insert(slot_state.slot(), slot_bytes);
2803        if let Some((hash, command)) = command {
2804            wal.commands.entry(hash).or_insert_with(|| command.clone());
2805        }
2806        wal.next_sequence = sequence
2807            .checked_add(1)
2808            .ok_or_else(|| Error::Io("recorder WAL sequence exhausted".into()))?;
2809        wal.last_digest = digest;
2810        wal.frame_count += 1;
2811        wal.byte_count = wal
2812            .byte_count
2813            .checked_add(frame.len() as u64)
2814            .ok_or_else(|| Error::Io("recorder WAL byte count overflow".into()))?;
2815        Ok(())
2816    }
2817
2818    fn checkpoint_wal_unlocked(&self) -> Result<()> {
2819        let (checkpoint, next_sequence, slots, commands) = {
2820            let wal = self
2821                .wal
2822                .lock()
2823                .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?;
2824            if wal.failed {
2825                return Err(Error::Io(
2826                    "recorder WAL is unavailable after an I/O failure".into(),
2827                ));
2828            }
2829            if wal.frame_count == 0 {
2830                return Ok(());
2831            }
2832            (
2833                wal.checkpoint,
2834                wal.next_sequence,
2835                wal.slots.clone(),
2836                wal.commands.clone(),
2837            )
2838        };
2839        let next_checkpoint = WalCheckpoint {
2840            generation: checkpoint
2841                .generation
2842                .checked_add(1)
2843                .ok_or_else(|| Error::Io("recorder WAL generation exhausted".into()))?,
2844            through_sequence: next_sequence - 1,
2845        };
2846        for (hash, command) in &commands {
2847            atomic_replace(&self.command_path(*hash), &encode_stored_command(command))?;
2848        }
2849        for (slot, bytes) in &slots {
2850            atomic_replace(&self.path(*slot), bytes)?;
2851        }
2852        let configuration = self.configuration_state()?;
2853        let head = self
2854            .recorded_head
2855            .lock()
2856            .map_err(|_| Error::Io("recorder head lock poisoned".into()))?
2857            .clone();
2858        atomic_replace(
2859            &self.configuration_path(),
2860            &encode_configuration_state(&configuration)?,
2861        )?;
2862        atomic_write(
2863            &self.recorded_head_path(),
2864            &encode_recorded_head(
2865                &self.cluster_id,
2866                self.epoch,
2867                &configuration,
2868                &head,
2869                &[],
2870                next_checkpoint,
2871            )?,
2872        )?;
2873        let truncate_result = fs::OpenOptions::new()
2874            .write(true)
2875            .open(self.wal_path())
2876            .and_then(|file| file.set_len(0).and_then(|_| sync_wal_metadata(&file)));
2877        if let Err(error) = truncate_result {
2878            if let Ok(mut wal) = self.wal.lock() {
2879                wal.failed = true;
2880            }
2881            return Err(Error::Io(error.to_string()));
2882        }
2883        let mut wal = self
2884            .wal
2885            .lock()
2886            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?;
2887        wal.checkpoint = next_checkpoint;
2888        wal.last_digest = LogHash::ZERO;
2889        wal.frame_count = 0;
2890        wal.byte_count = 0;
2891        wal.slots.clear();
2892        wal.commands.clear();
2893        self.recent_slots
2894            .lock()
2895            .map_err(|_| Error::Io("recorder recent-slot lock poisoned".into()))?
2896            .clear();
2897        Ok(())
2898    }
2899
2900    fn sync_root(&self) -> Result<()> {
2901        fs::File::open(&self.root)
2902            .and_then(|directory| directory.sync_all())
2903            .map_err(|err| Error::Io(err.to_string()))?;
2904        #[cfg(test)]
2905        record_directory_sync();
2906        Ok(())
2907    }
2908
2909    fn commit_transition_unlocked(
2910        &self,
2911        slot_state: &RecorderSlotState,
2912        configuration: &ConfigurationState,
2913    ) -> Result<()> {
2914        let slot_bytes = encode_recorder_state(slot_state)?;
2915        let configuration_bytes = encode_configuration_state(configuration)?;
2916        let head = self.head_after_slot_state(configuration, slot_state)?;
2917        let head_bytes = encode_recorded_head(
2918            &self.cluster_id,
2919            self.epoch,
2920            configuration,
2921            &head,
2922            &[],
2923            self.wal_checkpoint()?,
2924        )?;
2925        atomic_write(
2926            &self.intent_path(),
2927            &encode_transition_intent(slot_state.slot(), &slot_bytes, &configuration_bytes)?,
2928        )?;
2929        self.fail_seal_at(SealFaultPoint::AfterIntent)?;
2930        atomic_write(&self.path(slot_state.slot()), &slot_bytes)?;
2931        self.fail_seal_at(SealFaultPoint::AfterSlot)?;
2932        atomic_write(&self.configuration_path(), &configuration_bytes)?;
2933        self.fail_seal_at(SealFaultPoint::AfterConfiguration)?;
2934        atomic_write(&self.recorded_head_path(), &head_bytes)?;
2935        fs::remove_file(self.intent_path()).map_err(|err| Error::Io(err.to_string()))?;
2936        fs::File::open(&self.root)
2937            .and_then(|directory| directory.sync_all())
2938            .map_err(|err| Error::Io(err.to_string()))?;
2939        *self
2940            .configuration
2941            .lock()
2942            .map_err(|_| Error::Io("configuration lock poisoned".into()))? = configuration.clone();
2943        *self
2944            .recorded_head
2945            .lock()
2946            .map_err(|_| Error::Io("recorder head lock poisoned".into()))? = head;
2947        self.recent_slots
2948            .lock()
2949            .map_err(|_| Error::Io("recorder recent-slot lock poisoned".into()))?
2950            .clear();
2951        Ok(())
2952    }
2953
2954    fn fail_seal_at(&self, point: SealFaultPoint) -> Result<()> {
2955        let mut fault = self
2956            .seal_fault
2957            .lock()
2958            .map_err(|_| Error::Io("seal fault lock poisoned".into()))?;
2959        if *fault == Some(point) {
2960            *fault = None;
2961            return Err(Error::Io(format!("injected seal fault at {point:?}")));
2962        }
2963        Ok(())
2964    }
2965
2966    fn config_digest(&self) -> LogHash {
2967        self.configuration
2968            .lock()
2969            .map(|state| state.config_digest)
2970            .unwrap_or(self.config_digest)
2971    }
2972
2973    fn current_config_id(&self) -> ConfigId {
2974        self.configuration
2975            .lock()
2976            .map(|state| state.config_id)
2977            .unwrap_or(self.config_id)
2978    }
2979
2980    fn wal_checkpoint(&self) -> Result<WalCheckpoint> {
2981        self.wal
2982            .lock()
2983            .map(|wal| wal.checkpoint)
2984            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))
2985    }
2986
2987    #[cfg(test)]
2988    fn wal_stats(&self) -> Result<(u64, u64, u64)> {
2989        self.wal
2990            .lock()
2991            .map(|wal| {
2992                (
2993                    wal.checkpoint.generation,
2994                    wal.checkpoint.through_sequence,
2995                    wal.frame_count,
2996                )
2997            })
2998            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))
2999    }
3000}
3001
3002fn configuration_structure_changed(
3003    previous: &ConfigurationState,
3004    next: &ConfigurationState,
3005) -> bool {
3006    previous.config_id != next.config_id
3007        || previous.config_digest != next.config_digest
3008        || previous.membership != next.membership
3009        || previous.predecessor != next.predecessor
3010        || previous.seal != next.seal
3011        || previous.activated != next.activated
3012}
3013
3014fn recorder_state_values(state: &RecorderSlotState) -> impl Iterator<Item = &AcceptedValue> {
3015    [
3016        state.accepted.as_ref().map(|accepted| &accepted.value),
3017        state.decided.as_ref().map(|decided| &decided.value),
3018        state
3019            .isr
3020            .first_current
3021            .as_ref()
3022            .and_then(|proposal| proposal.value.as_ref()),
3023        state
3024            .isr
3025            .aggregate_current
3026            .as_ref()
3027            .and_then(|proposal| proposal.value.as_ref()),
3028        state
3029            .isr
3030            .aggregate_prior
3031            .as_ref()
3032            .and_then(|proposal| proposal.value.as_ref()),
3033        state
3034            .decided_proof
3035            .as_ref()
3036            .and_then(|proof| proof.proposal().value.as_ref()),
3037    ]
3038    .into_iter()
3039    .flatten()
3040}
3041
3042pub struct ThreeNodeConsensus {
3043    cluster_id: ClusterId,
3044    proposer_id: NodeId,
3045    epoch: Epoch,
3046    config_id: ConfigId,
3047    config_digest: LogHash,
3048    membership: FixedMembership,
3049    recorders: Vec<Arc<dyn RecorderRpc>>,
3050    record_workers: Vec<RecordWorker>,
3051    control_workers: Vec<ControlWorker>,
3052    // Read fences must not queue behind recovery/control RPCs whose network
3053    // deadline is intentionally longer. A lost majority can otherwise occupy
3054    // two control workers and turn a read-only quorum check into the caller's
3055    // HTTP timeout instead of a prompt Unavailable result.
3056    read_fence_workers: Vec<ControlWorker>,
3057    priority_source: Arc<dyn PrioritySource>,
3058    proposal_sequence: AtomicU64,
3059    legacy_tip: Mutex<SingleNodeState>,
3060}
3061
3062struct RecordJob {
3063    index: usize,
3064    request: RecordRequest,
3065    result: std::sync::mpsc::SyncSender<(usize, Result<RecordSummary>)>,
3066}
3067
3068struct RecordWorker {
3069    sender: Option<std::sync::mpsc::SyncSender<RecordJob>>,
3070    handle: Option<thread::JoinHandle<()>>,
3071    pending: Arc<AtomicUsize>,
3072}
3073
3074impl RecordWorker {
3075    fn spawn(
3076        recorder_id: NodeId,
3077        recorder: Arc<dyn RecorderRpc>,
3078        config_id: ConfigId,
3079        config_digest: LogHash,
3080    ) -> Result<Self> {
3081        let expected_id = recorder_id;
3082        let (sender, receiver) =
3083            std::sync::mpsc::sync_channel::<RecordJob>(RECORD_WORKER_QUEUE_CAPACITY);
3084        let pending = Arc::new(AtomicUsize::new(0));
3085        let worker_pending = Arc::clone(&pending);
3086        let handle = thread::Builder::new()
3087            .spawn(move || {
3088                while let Ok(job) = receiver.recv() {
3089                    let expected_slot = job.request.slot;
3090                    let reply = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3091                        recorder.record(job.request)
3092                    }))
3093                    .unwrap_or(Err(Error::ProposeFailed))
3094                    .and_then(|reply| {
3095                        if reply.recorder_id == expected_id
3096                            && reply.slot == expected_slot
3097                            && reply.config_id == config_id
3098                            && reply.config_digest == config_digest
3099                        {
3100                            Ok(reply)
3101                        } else {
3102                            Err(Error::Rejected(RejectReason::InvalidRequest))
3103                        }
3104                    });
3105                    let _ = job.result.send((job.index, reply));
3106                    worker_pending.fetch_sub(1, Ordering::Release);
3107                }
3108            })
3109            .map_err(|error| Error::Io(error.to_string()))?;
3110        Ok(Self {
3111            sender: Some(sender),
3112            handle: Some(handle),
3113            pending,
3114        })
3115    }
3116
3117    fn dispatch(&self, job: RecordJob) -> bool {
3118        self.pending.fetch_add(1, Ordering::Relaxed);
3119        let (failed, saturated) = match &self.sender {
3120            Some(sender) => match sender.try_send(job) {
3121                Ok(()) => (None, false),
3122                Err(std::sync::mpsc::TrySendError::Full(job)) => (
3123                    Some((
3124                        job,
3125                        Error::Io("recorder worker queue is temporarily full".into()),
3126                    )),
3127                    true,
3128                ),
3129                Err(std::sync::mpsc::TrySendError::Disconnected(job)) => {
3130                    (Some((job, Error::ProposeFailed)), false)
3131                }
3132            },
3133            None => (Some((job, Error::ProposeFailed)), false),
3134        };
3135        if let Some((job, error)) = failed {
3136            self.pending.fetch_sub(1, Ordering::Relaxed);
3137            let _ = job.result.send((job.index, Err(error)));
3138        }
3139        saturated
3140    }
3141
3142    fn is_idle(&self) -> bool {
3143        self.pending.load(Ordering::Acquire) == 0
3144    }
3145
3146    fn shutdown(&mut self) {
3147        self.sender.take();
3148        if let Some(handle) = self.handle.take() {
3149            // Recorder RPCs are deadline-bounded, but Drop must not wait for a
3150            // blocked minority. Idle workers are joined; an in-flight worker
3151            // observes the disconnected queue and exits after its RPC returns.
3152            if self.pending.load(Ordering::Acquire) == 0 || handle.is_finished() {
3153                let _ = handle.join();
3154            }
3155        }
3156    }
3157}
3158
3159impl Drop for RecordWorker {
3160    fn drop(&mut self) {
3161        self.shutdown();
3162    }
3163}
3164
3165enum ControlJob {
3166    InstallProof {
3167        index: usize,
3168        proof: DecisionProof,
3169        membership: Membership,
3170        result: std::sync::mpsc::SyncSender<(usize, Result<()>)>,
3171    },
3172    InspectProof {
3173        index: usize,
3174        slot: Slot,
3175        result: std::sync::mpsc::SyncSender<(usize, Result<Option<DecisionProof>>)>,
3176    },
3177    InspectSummary {
3178        index: usize,
3179        slot: Slot,
3180        result: std::sync::mpsc::SyncSender<(usize, Result<Option<RecordSummary>>)>,
3181    },
3182    ObserveReadFence {
3183        index: usize,
3184        request: ReadFenceRequest,
3185        result: std::sync::mpsc::SyncSender<(usize, Result<ReadFenceObservation>)>,
3186    },
3187    StoreCommand {
3188        index: usize,
3189        cluster_id: ClusterId,
3190        epoch: Epoch,
3191        config_id: ConfigId,
3192        config_digest: LogHash,
3193        command_hash: LogHash,
3194        command: StoredCommand,
3195        result: std::sync::mpsc::SyncSender<(usize, Result<()>)>,
3196    },
3197    FetchCommand {
3198        index: usize,
3199        cluster_id: ClusterId,
3200        epoch: Epoch,
3201        config_id: ConfigId,
3202        config_digest: LogHash,
3203        command_hash: LogHash,
3204        result: std::sync::mpsc::SyncSender<(usize, Result<Option<StoredCommand>>)>,
3205    },
3206}
3207
3208impl ControlJob {
3209    fn run(self, recorder: &dyn RecorderRpc) {
3210        match self {
3211            Self::InstallProof {
3212                index,
3213                proof,
3214                membership,
3215                result,
3216            } => {
3217                let reply = control_rpc(|| recorder.install_decision_proof(proof, &membership));
3218                let _ = result.send((index, reply));
3219            }
3220            Self::InspectProof {
3221                index,
3222                slot,
3223                result,
3224            } => {
3225                let reply = control_rpc(|| recorder.inspect_decision_proof(slot));
3226                let _ = result.send((index, reply));
3227            }
3228            Self::InspectSummary {
3229                index,
3230                slot,
3231                result,
3232            } => {
3233                let reply = control_rpc(|| recorder.inspect_record_summary(slot));
3234                let _ = result.send((index, reply));
3235            }
3236            Self::ObserveReadFence {
3237                index,
3238                request,
3239                result,
3240            } => {
3241                let reply = control_rpc(|| recorder.observe_read_fence(request));
3242                let _ = result.send((index, reply));
3243            }
3244            Self::StoreCommand {
3245                index,
3246                cluster_id,
3247                epoch,
3248                config_id,
3249                config_digest,
3250                command_hash,
3251                command,
3252                result,
3253            } => {
3254                let reply = control_rpc(|| {
3255                    recorder.store_command_for(
3256                        cluster_id,
3257                        epoch,
3258                        config_id,
3259                        config_digest,
3260                        command_hash,
3261                        command,
3262                    )
3263                });
3264                let _ = result.send((index, reply));
3265            }
3266            Self::FetchCommand {
3267                index,
3268                cluster_id,
3269                epoch,
3270                config_id,
3271                config_digest,
3272                command_hash,
3273                result,
3274            } => {
3275                let reply = control_rpc(|| {
3276                    recorder.fetch_command_for(
3277                        cluster_id,
3278                        epoch,
3279                        config_id,
3280                        config_digest,
3281                        command_hash,
3282                    )
3283                });
3284                let _ = result.send((index, reply));
3285            }
3286        }
3287    }
3288
3289    fn fail(self, error: Error) {
3290        match self {
3291            Self::InstallProof { index, result, .. } | Self::StoreCommand { index, result, .. } => {
3292                let _ = result.send((index, Err(error)));
3293            }
3294            Self::InspectProof { index, result, .. } => {
3295                let _ = result.send((index, Err(error)));
3296            }
3297            Self::InspectSummary { index, result, .. } => {
3298                let _ = result.send((index, Err(error)));
3299            }
3300            Self::ObserveReadFence { index, result, .. } => {
3301                let _ = result.send((index, Err(error)));
3302            }
3303            Self::FetchCommand { index, result, .. } => {
3304                let _ = result.send((index, Err(error)));
3305            }
3306        }
3307    }
3308}
3309
3310fn control_rpc<T>(call: impl FnOnce() -> Result<T>) -> Result<T> {
3311    std::panic::catch_unwind(std::panic::AssertUnwindSafe(call))
3312        .unwrap_or(Err(Error::ProposeFailed))
3313}
3314
3315struct ControlWorker {
3316    sender: Option<std::sync::mpsc::SyncSender<ControlJob>>,
3317    handle: Option<thread::JoinHandle<()>>,
3318    pending: Arc<AtomicUsize>,
3319}
3320
3321#[derive(Clone, Copy, Eq, PartialEq)]
3322enum ControlDispatch {
3323    Accepted,
3324    Saturated,
3325    Failed,
3326}
3327
3328impl ControlWorker {
3329    fn spawn(recorder: Arc<dyn RecorderRpc>) -> Result<Self> {
3330        let (sender, receiver) =
3331            std::sync::mpsc::sync_channel::<ControlJob>(CONTROL_WORKER_QUEUE_CAPACITY);
3332        let pending = Arc::new(AtomicUsize::new(0));
3333        let worker_pending = Arc::clone(&pending);
3334        let handle = thread::Builder::new()
3335            .spawn(move || {
3336                while let Ok(job) = receiver.recv() {
3337                    job.run(recorder.as_ref());
3338                    worker_pending.fetch_sub(1, Ordering::Release);
3339                }
3340            })
3341            .map_err(|error| Error::Io(error.to_string()))?;
3342        Ok(Self {
3343            sender: Some(sender),
3344            handle: Some(handle),
3345            pending,
3346        })
3347    }
3348
3349    fn dispatch(&self, job: ControlJob) -> ControlDispatch {
3350        self.pending.fetch_add(1, Ordering::Relaxed);
3351        let (failed, outcome) = match &self.sender {
3352            Some(sender) => match sender.try_send(job) {
3353                Ok(()) => (None, ControlDispatch::Accepted),
3354                Err(std::sync::mpsc::TrySendError::Full(job)) => (
3355                    Some((
3356                        job,
3357                        Error::Io("recorder control worker queue is temporarily full".into()),
3358                    )),
3359                    ControlDispatch::Saturated,
3360                ),
3361                Err(std::sync::mpsc::TrySendError::Disconnected(job)) => {
3362                    (Some((job, Error::ProposeFailed)), ControlDispatch::Failed)
3363                }
3364            },
3365            None => (Some((job, Error::ProposeFailed)), ControlDispatch::Failed),
3366        };
3367        if let Some((job, error)) = failed {
3368            self.pending.fetch_sub(1, Ordering::Relaxed);
3369            job.fail(error);
3370        }
3371        outcome
3372    }
3373
3374    fn is_idle(&self) -> bool {
3375        self.pending.load(Ordering::Acquire) == 0
3376    }
3377
3378    fn shutdown(&mut self) {
3379        self.sender.take();
3380        if let Some(handle) = self.handle.take() {
3381            if self.pending.load(Ordering::Acquire) == 0 || handle.is_finished() {
3382                let _ = handle.join();
3383            }
3384        }
3385    }
3386}
3387
3388fn control_quorum_reachable(successful: usize, saturated: usize, quorum: usize) -> bool {
3389    successful.saturating_add(saturated) >= quorum
3390}
3391
3392impl Drop for ControlWorker {
3393    fn drop(&mut self) {
3394        self.shutdown();
3395    }
3396}
3397
3398pub trait PrioritySource: Send + Sync {
3399    fn sample(
3400        &self,
3401        slot: Slot,
3402        round: Round,
3403        proposer_id: &str,
3404        recorder_id: &str,
3405    ) -> Result<ProposalPriority>;
3406}
3407
3408#[derive(Debug, Default)]
3409pub struct OsPrioritySource;
3410
3411impl PrioritySource for OsPrioritySource {
3412    fn sample(
3413        &self,
3414        slot: Slot,
3415        round: Round,
3416        proposer_id: &str,
3417        recorder_id: &str,
3418    ) -> Result<ProposalPriority> {
3419        let mut bytes = [0; 32];
3420        let _ = (slot, round, proposer_id, recorder_id);
3421        getrandom::fill(&mut bytes)
3422            .map_err(|error| Error::RandomnessUnavailable(error.to_string()))?;
3423        if bytes == ProposalPriority::ZERO.0 || bytes == ProposalPriority::MAX.0 {
3424            bytes[31] = 1;
3425        }
3426        Ok(ProposalPriority(bytes))
3427    }
3428}
3429
3430#[derive(Clone, Debug, Eq, PartialEq)]
3431pub struct ProposerProgress {
3432    pub slot: Slot,
3433    pub step: Step,
3434    pub proposal: Proposal,
3435    phase_zero_priorities: BTreeMap<(Round, NodeId), ProposalPriority>,
3436    command: Option<StoredCommand>,
3437    command_holders: BTreeSet<NodeId>,
3438    transition_involved: bool,
3439}
3440
3441impl ProposerProgress {
3442    pub fn new(slot: Slot, proposal: Proposal) -> Self {
3443        Self {
3444            slot,
3445            step: 4,
3446            proposal,
3447            phase_zero_priorities: BTreeMap::new(),
3448            command: None,
3449            command_holders: BTreeSet::new(),
3450            transition_involved: false,
3451        }
3452    }
3453
3454    fn with_command(mut self, command: StoredCommand) -> Self {
3455        self.transition_involved = command.entry_type == EntryType::ConfigChange;
3456        self.command = Some(command);
3457        self
3458    }
3459}
3460
3461#[derive(Clone, Debug, Eq, PartialEq)]
3462pub enum DriveOutcome {
3463    Progress(ProposerProgress),
3464    Pending(ProposerProgress),
3465    Decision(DecisionProof),
3466}
3467
3468impl fmt::Debug for ThreeNodeConsensus {
3469    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3470        f.debug_struct("ThreeNodeConsensus")
3471            .field("cluster_id", &self.cluster_id)
3472            .field("proposer_id", &self.proposer_id)
3473            .field("epoch", &self.epoch)
3474            .field("config_id", &self.config_id)
3475            .field("recorders", &self.membership.members())
3476            .finish_non_exhaustive()
3477    }
3478}
3479
3480impl Drop for ThreeNodeConsensus {
3481    fn drop(&mut self) {
3482        for worker in &mut self.record_workers {
3483            worker.shutdown();
3484        }
3485        for worker in &mut self.control_workers {
3486            worker.shutdown();
3487        }
3488        for worker in &mut self.read_fence_workers {
3489            worker.shutdown();
3490        }
3491    }
3492}
3493
3494impl ThreeNodeConsensus {
3495    /// Waits up to `timeout` for accepted recorder and control RPC jobs.
3496    /// Callers must first quiesce proposal admission and ensure no proposal
3497    /// can still dispatch jobs; this only drains already accepted jobs.
3498    pub fn finish_pending_rpcs(&self, timeout: Duration) -> bool {
3499        let started = Instant::now();
3500        loop {
3501            if self.record_workers.iter().all(RecordWorker::is_idle)
3502                && self.control_workers.iter().all(ControlWorker::is_idle)
3503                && self.read_fence_workers.iter().all(ControlWorker::is_idle)
3504            {
3505                return true;
3506            }
3507            if started.elapsed() >= timeout {
3508                return false;
3509            }
3510            thread::sleep(Duration::from_millis(1));
3511        }
3512    }
3513
3514    pub const fn config_id(&self) -> ConfigId {
3515        self.config_id
3516    }
3517
3518    pub const fn membership(&self) -> &FixedMembership {
3519        &self.membership
3520    }
3521
3522    pub fn new(
3523        cluster_id: impl Into<ClusterId>,
3524        proposer_id: impl Into<NodeId>,
3525        epoch: Epoch,
3526        config_id: ConfigId,
3527        recorder_roots: [PathBuf; 3],
3528    ) -> Result<Self> {
3529        Self::from_recovered_tip(
3530            cluster_id,
3531            proposer_id,
3532            epoch,
3533            config_id,
3534            recorder_roots,
3535            1,
3536            LogHash::ZERO,
3537        )
3538    }
3539
3540    pub fn from_recovered_tip(
3541        cluster_id: impl Into<ClusterId>,
3542        proposer_id: impl Into<NodeId>,
3543        epoch: Epoch,
3544        config_id: ConfigId,
3545        recorder_roots: [PathBuf; 3],
3546        next_index: LogIndex,
3547        last_hash: LogHash,
3548    ) -> Result<Self> {
3549        let cluster_id = cluster_id.into();
3550        let recorder_roots: Vec<_> = recorder_roots.into_iter().collect();
3551        let recorder_ids: Vec<_> = recorder_roots
3552            .iter()
3553            .map(|root| {
3554                root.file_name()
3555                    .and_then(|name| name.to_str())
3556                    .filter(|name| !name.is_empty())
3557                    .unwrap_or("recorder")
3558                    .to_owned()
3559            })
3560            .collect();
3561        let membership = Membership::from_voters(recorder_ids.iter().cloned())?;
3562        let recorders = recorder_roots
3563            .into_iter()
3564            .zip(recorder_ids)
3565            .map(|(root, recorder_id)| -> Result<Box<dyn RecorderRpc>> {
3566                Ok(Box::new(RecorderFileStore::new_with_membership(
3567                    root,
3568                    recorder_id,
3569                    cluster_id.clone(),
3570                    epoch,
3571                    config_id,
3572                    membership.clone(),
3573                )?) as Box<dyn RecorderRpc>)
3574            })
3575            .collect::<Result<Vec<_>>>()?;
3576        Self::from_recorders_with_recovered_tip(
3577            cluster_id,
3578            proposer_id,
3579            epoch,
3580            config_id,
3581            recorders,
3582            next_index,
3583            last_hash,
3584        )
3585    }
3586
3587    pub fn from_recorders(
3588        cluster_id: impl Into<ClusterId>,
3589        proposer_id: impl Into<NodeId>,
3590        epoch: Epoch,
3591        config_id: ConfigId,
3592        recorders: Vec<Box<dyn RecorderRpc>>,
3593    ) -> Result<Self> {
3594        Self::from_recorders_with_recovered_tip(
3595            cluster_id,
3596            proposer_id,
3597            epoch,
3598            config_id,
3599            recorders,
3600            1,
3601            LogHash::ZERO,
3602        )
3603    }
3604
3605    /// Constructs a proposer from expected recorder identities paired with RPC clients.
3606    ///
3607    /// This path does not issue `Identity` RPCs. Reply identities are still
3608    /// checked against the corresponding expected identity on every call.
3609    pub fn from_recorders_with_ids(
3610        cluster_id: impl Into<ClusterId>,
3611        proposer_id: impl Into<NodeId>,
3612        epoch: Epoch,
3613        config_id: ConfigId,
3614        recorders: Vec<(NodeId, Box<dyn RecorderRpc>)>,
3615    ) -> Result<Self> {
3616        Self::from_recorders_with_ids_and_recovered_tip(
3617            cluster_id,
3618            proposer_id,
3619            epoch,
3620            config_id,
3621            recorders,
3622            1,
3623            LogHash::ZERO,
3624        )
3625    }
3626
3627    pub fn from_recorders_with_recovered_tip(
3628        cluster_id: impl Into<ClusterId>,
3629        proposer_id: impl Into<NodeId>,
3630        epoch: Epoch,
3631        config_id: ConfigId,
3632        recorders: Vec<Box<dyn RecorderRpc>>,
3633        next_index: LogIndex,
3634        last_hash: LogHash,
3635    ) -> Result<Self> {
3636        let recorder_ids = recorders
3637            .iter()
3638            .map(|recorder| recorder.recorder_id())
3639            .collect::<Result<Vec<_>>>()?;
3640        Self::from_recorders_with_ids_and_recovered_tip(
3641            cluster_id,
3642            proposer_id,
3643            epoch,
3644            config_id,
3645            recorder_ids.into_iter().zip(recorders).collect(),
3646            next_index,
3647            last_hash,
3648        )
3649    }
3650
3651    /// Recovered-tip variant of [`Self::from_recorders_with_ids`].
3652    pub fn from_recorders_with_ids_and_recovered_tip(
3653        cluster_id: impl Into<ClusterId>,
3654        proposer_id: impl Into<NodeId>,
3655        epoch: Epoch,
3656        config_id: ConfigId,
3657        mut recorders: Vec<(NodeId, Box<dyn RecorderRpc>)>,
3658        next_index: LogIndex,
3659        last_hash: LogHash,
3660    ) -> Result<Self> {
3661        if next_index == 0 {
3662            return Err(Error::InvalidRecoveredTip);
3663        }
3664        recorders.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
3665        let (recorder_ids, recorders): (Vec<_>, Vec<_>) = recorders.into_iter().unzip();
3666        let recorders: Vec<Arc<dyn RecorderRpc>> = recorders.into_iter().map(Arc::from).collect();
3667        let membership = FixedMembership::from_members(recorder_ids)?;
3668        let config_digest = membership.digest();
3669        let record_workers = membership
3670            .members()
3671            .iter()
3672            .cloned()
3673            .zip(&recorders)
3674            .map(|(recorder_id, recorder)| {
3675                RecordWorker::spawn(recorder_id, Arc::clone(recorder), config_id, config_digest)
3676            })
3677            .collect::<Result<Vec<_>>>()?;
3678        let control_workers = recorders
3679            .iter()
3680            .cloned()
3681            .map(ControlWorker::spawn)
3682            .collect::<Result<Vec<_>>>()?;
3683        let read_fence_workers = recorders
3684            .iter()
3685            .cloned()
3686            .map(ControlWorker::spawn)
3687            .collect::<Result<Vec<_>>>()?;
3688        Ok(Self {
3689            cluster_id: cluster_id.into(),
3690            proposer_id: proposer_id.into(),
3691            epoch,
3692            config_id,
3693            config_digest,
3694            membership,
3695            recorders,
3696            record_workers,
3697            control_workers,
3698            read_fence_workers,
3699            priority_source: Arc::new(OsPrioritySource),
3700            proposal_sequence: AtomicU64::new(1),
3701            legacy_tip: Mutex::new(SingleNodeState {
3702                next_index,
3703                last_hash,
3704            }),
3705        })
3706    }
3707
3708    pub fn with_priority_source(mut self, source: Arc<dyn PrioritySource>) -> Self {
3709        self.priority_source = source;
3710        self
3711    }
3712
3713    /// Stores command bytes on a recorder quorum after verifying their hash.
3714    ///
3715    /// [`Error::NoQuorum`] is retryable, including when bounded control-worker
3716    /// queues are temporarily saturated.
3717    pub fn register_command(&self, command_hash: LogHash, command_bytes: Vec<u8>) -> Result<()> {
3718        let command = StoredCommand::new(EntryType::Command, command_bytes);
3719        if command.hash() != command_hash {
3720            return Err(Error::CommandHashMismatch);
3721        }
3722        self.store_command_on_quorum(command_hash, &command)
3723    }
3724
3725    fn propose_next(&self, command: Command) -> Result<LogEntry> {
3726        let mut tip = self.legacy_tip.lock().map_err(|_| Error::ProposeFailed)?;
3727        let entry = self.propose_at(tip.next_index, tip.last_hash, command)?;
3728        tip.next_index = entry.index + 1;
3729        tip.last_hash = entry.hash;
3730        Ok(entry)
3731    }
3732
3733    pub fn propose_at(&self, slot: Slot, prev_hash: LogHash, command: Command) -> Result<LogEntry> {
3734        self.propose_stored_at(slot, prev_hash, stored_command(command)?)
3735    }
3736
3737    pub fn propose_at_cancellable(
3738        &self,
3739        slot: Slot,
3740        prev_hash: LogHash,
3741        command: Command,
3742        cancelled: &std::sync::atomic::AtomicBool,
3743    ) -> Result<LogEntry> {
3744        self.propose_stored_at_until(slot, prev_hash, stored_command(command)?, || {
3745            cancelled.load(Ordering::Acquire)
3746        })
3747    }
3748
3749    pub fn propose_stop_at(&self, slot: Slot, prev_hash: LogHash) -> Result<LogEntry> {
3750        self.propose_stored_at(
3751            slot,
3752            prev_hash,
3753            ConfigChange::stop(self.config_id, self.config_digest).to_stored_command(),
3754        )
3755    }
3756
3757    pub fn propose_stop_for_successor_at(
3758        &self,
3759        slot: Slot,
3760        prev_hash: LogHash,
3761        successor: &Membership,
3762    ) -> Result<LogEntry> {
3763        let next_config_id = self
3764            .config_id
3765            .checked_add(1)
3766            .ok_or(Error::Rejected(RejectReason::InvalidTransition))?;
3767        let stop = ConfigChange::bound_stop(
3768            self.cluster_id.clone(),
3769            self.config_id,
3770            self.config_digest,
3771            next_config_id,
3772            successor.members().to_vec(),
3773        )
3774        .map_err(|_| Error::Rejected(RejectReason::InvalidTransition))?;
3775        self.propose_stored_at(slot, prev_hash, stop.to_stored_command())
3776    }
3777
3778    pub fn propose_activation_barrier_at(
3779        &self,
3780        stop_slot: Slot,
3781        prefix_hash: LogHash,
3782    ) -> Result<LogEntry> {
3783        self.propose_stored_at(
3784            stop_slot.checked_add(1).ok_or(Error::InvalidRecoveredTip)?,
3785            prefix_hash,
3786            ConfigChange::activation_barrier(
3787                self.config_id,
3788                self.config_digest,
3789                stop_slot,
3790                prefix_hash,
3791            )
3792            .to_stored_command(),
3793        )
3794    }
3795
3796    pub fn propose_activation_for_stop_entry(&self, stop: &LogEntry) -> Result<LogEntry> {
3797        let command = StoredCommand::new(stop.entry_type, stop.payload.clone());
3798        let change = ConfigChange::recognize(&command)
3799            .map_err(|_| Error::Rejected(RejectReason::InvalidTransition))?;
3800        let successor = change
3801            .successor()
3802            .filter(|successor| {
3803                successor.cluster_id() == self.cluster_id
3804                    && successor.config_id() == self.config_id
3805                    && successor.digest() == self.config_digest
3806                    && successor.members() == self.membership.members()
3807            })
3808            .ok_or(Error::Rejected(RejectReason::InvalidTransition))?
3809            .clone();
3810        self.propose_stored_at(
3811            stop.index
3812                .checked_add(1)
3813                .ok_or(Error::InvalidRecoveredTip)?,
3814            stop.hash,
3815            ConfigChange::bound_activation_barrier(
3816                successor,
3817                stop.index,
3818                stop.hash,
3819                command.hash(),
3820            )
3821            .to_stored_command(),
3822        )
3823    }
3824
3825    pub fn propose_activation_for_stop_at(&self, stop_proof: &DecisionProof) -> Result<LogEntry> {
3826        if proof_cluster_id(stop_proof) != self.cluster_id {
3827            return Err(Error::Rejected(RejectReason::WrongCluster));
3828        }
3829        let (stop_slot, epoch, predecessor_config_id, _) = proof_context(stop_proof);
3830        if epoch != self.epoch || predecessor_config_id.checked_add(1) != Some(self.config_id) {
3831            return Err(Error::Rejected(RejectReason::InvalidTransition));
3832        }
3833        let value = stop_proof
3834            .proposal()
3835            .value
3836            .as_ref()
3837            .ok_or(Error::Rejected(RejectReason::InvalidCertificate))?;
3838        let bound_stop = ConfigChange::bound_stop(
3839            self.cluster_id.clone(),
3840            predecessor_config_id,
3841            proof_context(stop_proof).3,
3842            self.config_id,
3843            self.membership.members().to_vec(),
3844        )
3845        .map_err(|_| Error::Rejected(RejectReason::InvalidTransition))?;
3846        let stop_command = bound_stop.to_stored_command();
3847        let expected = AcceptedValue::from_command(
3848            &self.cluster_id,
3849            stop_slot,
3850            self.epoch,
3851            predecessor_config_id,
3852            value.prev_hash,
3853            &stop_command,
3854        );
3855        if &expected != value {
3856            return Err(Error::Rejected(RejectReason::InvalidTransition));
3857        }
3858        let successor = bound_stop
3859            .successor()
3860            .expect("bound stop has successor")
3861            .clone();
3862        self.propose_stored_at(
3863            stop_slot.checked_add(1).ok_or(Error::InvalidRecoveredTip)?,
3864            value.entry_hash,
3865            ConfigChange::bound_activation_barrier(
3866                successor,
3867                stop_slot,
3868                value.entry_hash,
3869                value.command_hash,
3870            )
3871            .to_stored_command(),
3872        )
3873    }
3874
3875    pub fn propose_stored_at(
3876        &self,
3877        slot: Slot,
3878        prev_hash: LogHash,
3879        command: StoredCommand,
3880    ) -> Result<LogEntry> {
3881        self.propose_stored_at_until(slot, prev_hash, command, || false)
3882    }
3883
3884    fn propose_stored_at_until<F>(
3885        &self,
3886        slot: Slot,
3887        prev_hash: LogHash,
3888        offered_command: StoredCommand,
3889        cancelled: F,
3890    ) -> Result<LogEntry>
3891    where
3892        F: Fn() -> bool,
3893    {
3894        if cancelled() {
3895            return Err(Error::Cancelled);
3896        }
3897        let offered_value = AcceptedValue::from_command(
3898            &self.cluster_id,
3899            slot,
3900            self.epoch,
3901            self.config_id,
3902            prev_hash,
3903            &offered_command,
3904        );
3905        let proposal_id = self.proposal_sequence.fetch_add(1, Ordering::Relaxed);
3906        let mut progress = ProposerProgress::new(
3907            slot,
3908            Proposal::new(
3909                ProposalPriority::MAX,
3910                self.proposer_id.clone(),
3911                proposal_id,
3912                offered_value,
3913            ),
3914        )
3915        .with_command(offered_command.clone());
3916        loop {
3917            if cancelled() {
3918                return Err(Error::Cancelled);
3919            }
3920            match self.drive(progress)? {
3921                DriveOutcome::Progress(next) => progress = next,
3922                DriveOutcome::Pending(next) => {
3923                    progress = next;
3924                    thread::sleep(std::time::Duration::from_millis(10));
3925                }
3926                DriveOutcome::Decision(proof) => {
3927                    let value = proof
3928                        .proposal()
3929                        .value
3930                        .as_ref()
3931                        .ok_or(Error::Rejected(RejectReason::InvalidCertificate))?;
3932                    self.ensure_predecessor(slot, prev_hash, value.prev_hash)?;
3933                    let command = if self.command_matches_value(slot, value, &offered_command) {
3934                        offered_command.clone()
3935                    } else {
3936                        self.fetch_verified_value(slot, value)?
3937                            .ok_or(Error::CommandUnavailable)?
3938                    };
3939                    return self.log_entry_from_value(slot, command, value);
3940                }
3941            }
3942        }
3943    }
3944
3945    pub fn drive(&self, mut progress: ProposerProgress) -> Result<DriveOutcome> {
3946        self.ensure_progress_command(&mut progress)?;
3947        let round = progress.step / 4;
3948        let phase = progress.step % 4;
3949        if phase == 0 {
3950            progress
3951                .phase_zero_priorities
3952                .retain(|(cached_round, _), _| *cached_round == round);
3953        } else {
3954            progress.phase_zero_priorities.clear();
3955        }
3956        let command_targets: BTreeSet<_> = self
3957            .membership
3958            .members()
3959            .iter()
3960            .filter(|recorder_id| !progress.command_holders.contains(*recorder_id))
3961            .cloned()
3962            .collect();
3963        let requests: Vec<_> = self
3964            .membership
3965            .members()
3966            .iter()
3967            .map(|recorder_id| -> Result<RecordRequest> {
3968                let mut proposal = progress.proposal.clone();
3969                if phase == 0 {
3970                    proposal.priority =
3971                        if progress.step == 4 && self.proposer_id == self.membership.members()[0] {
3972                            ProposalPriority::MAX
3973                        } else {
3974                            match progress
3975                                .phase_zero_priorities
3976                                .entry((round, recorder_id.clone()))
3977                            {
3978                                std::collections::btree_map::Entry::Occupied(entry) => *entry.get(),
3979                                std::collections::btree_map::Entry::Vacant(entry) => {
3980                                    *entry.insert(self.priority_source.sample(
3981                                        progress.slot,
3982                                        round,
3983                                        &self.proposer_id,
3984                                        recorder_id,
3985                                    )?)
3986                                }
3987                            }
3988                        };
3989                }
3990                Ok(RecordRequest {
3991                    cluster_id: self.cluster_id.clone(),
3992                    epoch: self.epoch,
3993                    config_id: self.config_id,
3994                    config_digest: self.config_digest,
3995                    slot: progress.slot,
3996                    step: progress.step,
3997                    proposal,
3998                    command: command_targets
3999                        .contains(recorder_id)
4000                        .then(|| progress.command.clone())
4001                        .flatten(),
4002                })
4003            })
4004            .collect::<Result<_>>()?;
4005        let mut replies = self.record_broadcast(requests)?;
4006        progress.command_holders.extend(
4007            replies
4008                .iter()
4009                .filter(|reply| command_targets.contains(&reply.recorder_id))
4010                .map(|reply| reply.recorder_id.clone()),
4011        );
4012        for reply in &replies {
4013            if let Some(proof) = &reply.decided {
4014                if proof_cluster_id(proof) != self.cluster_id {
4015                    return Err(Error::Rejected(RejectReason::WrongCluster));
4016                }
4017                proof
4018                    .validate_for_cluster(
4019                        &self.cluster_id,
4020                        progress.slot,
4021                        self.epoch,
4022                        self.config_id,
4023                        &self.membership,
4024                    )
4025                    .map_err(Error::Rejected)?;
4026                return self.finish_decision(
4027                    proof.clone(),
4028                    progress.command.as_ref(),
4029                    progress.transition_involved,
4030                );
4031            }
4032        }
4033        if let Some(highest) = replies.iter().map(|reply| reply.step).max() {
4034            if highest > progress.step {
4035                let caught_up = replies
4036                    .iter()
4037                    .filter(|reply| reply.step == highest)
4038                    .min_by(|left, right| left.recorder_id.cmp(&right.recorder_id))
4039                    .expect("highest reply exists");
4040                progress.step = highest;
4041                if let Some(proposal) = &caught_up.first_current {
4042                    progress.proposal = proposal.clone();
4043                }
4044                self.ensure_progress_command(&mut progress)?;
4045                progress.phase_zero_priorities.clear();
4046                return Ok(DriveOutcome::Progress(progress));
4047            }
4048        }
4049        replies.retain(|reply| reply.step == progress.step);
4050        replies.sort_by(|left, right| left.recorder_id.cmp(&right.recorder_id));
4051        replies.dedup_by(|left, right| left.recorder_id == right.recorder_id);
4052        if replies.len() < self.membership.quorum_size() {
4053            return Ok(DriveOutcome::Pending(progress));
4054        }
4055        replies.truncate(self.membership.quorum_size());
4056        let summaries: Vec<_> = replies
4057            .iter()
4058            .map(|reply| RecorderSummary {
4059                recorder_id: reply.recorder_id.clone(),
4060                slot: reply.slot,
4061                step: reply.step,
4062                first_current: reply.first_current.clone(),
4063                aggregate_prior: reply.aggregate_prior.clone(),
4064            })
4065            .collect();
4066        match phase {
4067            0 => {
4068                let fast_proposal = summaries
4069                    .first()
4070                    .and_then(|summary| summary.first_current.as_ref())
4071                    .filter(|proposal| proposal.priority == ProposalPriority::MAX)
4072                    .filter(|proposal| {
4073                        progress.step == 4
4074                            && summaries.iter().all(|summary| {
4075                                summary
4076                                    .first_current
4077                                    .as_ref()
4078                                    .is_some_and(|candidate| proposal_exact(candidate, proposal))
4079                            })
4080                    })
4081                    .cloned();
4082                if let Some(proposal) = fast_proposal {
4083                    let proof = DecisionProof::FastPath {
4084                        cluster_id: self.cluster_id.clone(),
4085                        slot: progress.slot,
4086                        epoch: self.epoch,
4087                        config_id: self.config_id,
4088                        config_digest: self.config_digest,
4089                        proposal,
4090                        summaries,
4091                    };
4092                    return self.finish_decision(
4093                        proof,
4094                        progress.command.as_ref(),
4095                        progress.transition_involved,
4096                    );
4097                }
4098                progress.proposal = replies
4099                    .iter()
4100                    .filter_map(|reply| reply.first_current.clone())
4101                    .max()
4102                    .ok_or(Error::Rejected(RejectReason::InvalidRequest))?;
4103            }
4104            1 => {}
4105            2 => {
4106                let maximum = replies
4107                    .iter()
4108                    .filter_map(|reply| reply.aggregate_prior.clone())
4109                    .max();
4110                if maximum.as_ref() == Some(&progress.proposal) {
4111                    let proof = DecisionProof::Phase2 {
4112                        cluster_id: self.cluster_id.clone(),
4113                        slot: progress.slot,
4114                        epoch: self.epoch,
4115                        config_id: self.config_id,
4116                        config_digest: self.config_digest,
4117                        step: progress.step,
4118                        proposal: progress.proposal.clone(),
4119                        summaries,
4120                    };
4121                    return self.finish_decision(
4122                        proof,
4123                        progress.command.as_ref(),
4124                        progress.transition_involved,
4125                    );
4126                }
4127            }
4128            3 => {
4129                progress.proposal = replies
4130                    .iter()
4131                    .filter_map(|reply| reply.aggregate_prior.clone())
4132                    .max()
4133                    .ok_or(Error::Rejected(RejectReason::InvalidRequest))?;
4134            }
4135            _ => unreachable!("phase is step modulo four"),
4136        }
4137        self.ensure_progress_command(&mut progress)?;
4138        progress.step = progress.step.checked_add(1).ok_or(Error::ProposeFailed)?;
4139        progress.phase_zero_priorities.clear();
4140        Ok(DriveOutcome::Progress(progress))
4141    }
4142
4143    fn finish_decision(
4144        &self,
4145        proof: DecisionProof,
4146        known_command: Option<&StoredCommand>,
4147        transition_involved: bool,
4148    ) -> Result<DriveOutcome> {
4149        proof
4150            .validate_for_cluster(
4151                &self.cluster_id,
4152                proof_context(&proof).0,
4153                self.epoch,
4154                self.config_id,
4155                &self.membership,
4156            )
4157            .map_err(Error::Rejected)?;
4158        let value = proof
4159            .proposal()
4160            .value
4161            .as_ref()
4162            .ok_or(Error::Rejected(RejectReason::InvalidCertificate))?;
4163        let command = match known_command {
4164            Some(command)
4165                if self.command_matches_value(proof_context(&proof).0, value, command) =>
4166            {
4167                command.clone()
4168            }
4169            _ => self
4170                .fetch_verified_value(proof_context(&proof).0, value)?
4171                .ok_or(Error::CommandUnavailable)?,
4172        };
4173        if command.entry_type != EntryType::ConfigChange && !transition_involved {
4174            return Ok(DriveOutcome::Decision(proof));
4175        }
4176        self.install_decision_proof_quorum(proof.clone())?;
4177        Ok(DriveOutcome::Decision(proof))
4178    }
4179
4180    fn install_decision_proof_quorum(&self, proof: DecisionProof) -> Result<()> {
4181        let membership = self.membership.clone();
4182        let quorum = membership.quorum_size();
4183        let total = self.control_workers.len();
4184        let (sender, receiver) = std::sync::mpsc::sync_channel(total.max(1));
4185        let mut saturated = 0;
4186        for (index, worker) in self.control_workers.iter().enumerate() {
4187            if worker.dispatch(ControlJob::InstallProof {
4188                index,
4189                proof: proof.clone(),
4190                membership: membership.clone(),
4191                result: sender.clone(),
4192            }) == ControlDispatch::Saturated
4193            {
4194                saturated += 1;
4195            }
4196        }
4197        drop(sender);
4198        let mut installed = 0;
4199        let mut worker_failed = false;
4200        for (_, result) in receiver {
4201            match result {
4202                Ok(()) => installed += 1,
4203                Err(Error::ProposeFailed) => worker_failed = true,
4204                Err(_) => {}
4205            }
4206            if installed >= quorum {
4207                break;
4208            }
4209        }
4210        if installed < quorum {
4211            return Err(
4212                if worker_failed && !control_quorum_reachable(installed, saturated, quorum) {
4213                    Error::ProposeFailed
4214                } else {
4215                    Error::NoQuorum
4216                },
4217            );
4218        }
4219        Ok(())
4220    }
4221
4222    fn record_broadcast(&self, requests: Vec<RecordRequest>) -> Result<Vec<RecordSummary>> {
4223        let quorum = self.membership.quorum_size();
4224        let total = self.record_workers.len().min(requests.len());
4225        let (sender, receiver) = std::sync::mpsc::sync_channel(total.max(1));
4226        let mut saturated_workers = vec![false; total];
4227        for (index, (worker, request)) in self.record_workers.iter().zip(requests).enumerate() {
4228            saturated_workers[index] = worker.dispatch(RecordJob {
4229                index,
4230                request,
4231                result: sender.clone(),
4232            });
4233        }
4234        drop(sender);
4235        let saturated = saturated_workers
4236            .iter()
4237            .filter(|saturated| **saturated)
4238            .count();
4239        let accepted = total.saturating_sub(saturated);
4240        let mut accepted_completed = 0;
4241        let mut typed_errors = vec![None; total];
4242        let mut worker_failed = false;
4243        let mut replies = Vec::with_capacity(quorum);
4244        for (index, result) in receiver {
4245            if !saturated_workers[index] {
4246                accepted_completed += 1;
4247            }
4248            match result {
4249                Ok(reply) => {
4250                    if !replies
4251                        .iter()
4252                        .any(|seen: &RecordSummary| seen.recorder_id == reply.recorder_id)
4253                    {
4254                        replies.push(reply);
4255                    }
4256                    if replies.len() >= quorum {
4257                        return Ok(replies);
4258                    }
4259                }
4260                Err(error @ Error::Rejected(_)) | Err(error @ Error::TypedRecordRequired) => {
4261                    typed_errors[index] = Some(error);
4262                }
4263                Err(Error::ProposeFailed) => worker_failed = true,
4264                Err(_) => {}
4265            }
4266            let accepted_remaining = accepted.saturating_sub(accepted_completed);
4267            if replies.len() + saturated + accepted_remaining < quorum {
4268                return match typed_errors.into_iter().flatten().next() {
4269                    Some(error) => Err(error),
4270                    None if worker_failed => Err(Error::ProposeFailed),
4271                    None => Ok(replies),
4272                };
4273            }
4274        }
4275        if replies.len() + saturated >= quorum {
4276            return Ok(replies);
4277        }
4278        match typed_errors.into_iter().flatten().next() {
4279            Some(error) => Err(error),
4280            None if worker_failed => Err(Error::ProposeFailed),
4281            None => Ok(replies),
4282        }
4283    }
4284
4285    pub fn inspect_decision_at(
4286        &self,
4287        slot: Slot,
4288        prev_hash: LogHash,
4289    ) -> Result<DecisionInspection> {
4290        Ok(match self.inspect_certified_decision_at(slot, prev_hash)? {
4291            CertifiedDecisionInspection::Committed(certified) => {
4292                DecisionInspection::Committed(certified.entry)
4293            }
4294            CertifiedDecisionInspection::Empty => DecisionInspection::Empty,
4295            CertifiedDecisionInspection::Pending => DecisionInspection::Pending,
4296            CertifiedDecisionInspection::Unavailable => DecisionInspection::Unavailable,
4297        })
4298    }
4299
4300    pub fn inspect_decision_proof_at(&self, slot: Slot) -> Result<Option<DecisionProof>> {
4301        let quorum = self.membership.quorum_size();
4302        let total = self.control_workers.len();
4303        let (sender, receiver) = std::sync::mpsc::sync_channel(total.max(1));
4304        let mut saturated = 0;
4305        for (index, worker) in self.control_workers.iter().enumerate() {
4306            if worker.dispatch(ControlJob::InspectProof {
4307                index,
4308                slot,
4309                result: sender.clone(),
4310            }) == ControlDispatch::Saturated
4311            {
4312                saturated += 1;
4313            }
4314        }
4315        drop(sender);
4316        let mut successful = BTreeSet::new();
4317        let mut proofs = Vec::new();
4318        let mut worker_failed = false;
4319        for (index, result) in receiver {
4320            match result {
4321                Ok(proof) => {
4322                    successful.insert(self.membership.members()[index].clone());
4323                    proofs.extend(proof);
4324                }
4325                Err(Error::ProposeFailed) => worker_failed = true,
4326                Err(_) => {}
4327            }
4328            if successful.len() >= quorum {
4329                break;
4330            }
4331        }
4332        if successful.len() < quorum {
4333            return Err(
4334                if worker_failed && !control_quorum_reachable(successful.len(), saturated, quorum) {
4335                    Error::ProposeFailed
4336                } else {
4337                    Error::NoQuorum
4338                },
4339            );
4340        }
4341        self.select_decision_proof(slot, proofs)
4342    }
4343
4344    fn select_decision_proof(
4345        &self,
4346        slot: Slot,
4347        mut proofs: Vec<DecisionProof>,
4348    ) -> Result<Option<DecisionProof>> {
4349        for proof in &proofs {
4350            if proof_cluster_id(proof) != self.cluster_id {
4351                return Err(Error::Rejected(RejectReason::WrongCluster));
4352            }
4353            proof
4354                .validate_for_cluster(
4355                    &self.cluster_id,
4356                    slot,
4357                    self.epoch,
4358                    self.config_id,
4359                    &self.membership,
4360                )
4361                .map_err(Error::Rejected)?;
4362        }
4363        let Some(first) = proofs.first() else {
4364            return Ok(None);
4365        };
4366        if proofs
4367            .iter()
4368            .skip(1)
4369            .any(|proof| proof.proposal().value != first.proposal().value)
4370        {
4371            return Err(Error::ConflictingCertificates);
4372        }
4373        proofs.sort_by_key(|proof| match proof {
4374            DecisionProof::FastPath { .. } => 4,
4375            DecisionProof::Phase2 { step, .. } => *step,
4376        });
4377        Ok(proofs.pop())
4378    }
4379
4380    fn certified_inspection_from_proof(
4381        &self,
4382        slot: Slot,
4383        prev_hash: LogHash,
4384        proof: DecisionProof,
4385    ) -> Result<CertifiedDecisionInspection> {
4386        let decision = certificate_from_proof(&proof)?;
4387        self.ensure_predecessor(slot, prev_hash, decision.value.prev_hash)?;
4388        let Some(command) = self.fetch_verified_value(slot, &decision.value)? else {
4389            return Ok(CertifiedDecisionInspection::Unavailable);
4390        };
4391        if command.entry_type == EntryType::ConfigChange {
4392            self.install_decision_proof_quorum(proof.clone())?;
4393        }
4394        let entry = self.log_entry_from_value(slot, command, &decision.value)?;
4395        Ok(CertifiedDecisionInspection::Committed(Box::new(
4396            CertifiedDecision {
4397                entry,
4398                certificate: decision,
4399                proof,
4400            },
4401        )))
4402    }
4403
4404    pub fn inspect_certified_decision_at(
4405        &self,
4406        slot: Slot,
4407        prev_hash: LogHash,
4408    ) -> Result<CertifiedDecisionInspection> {
4409        self.inspect_typed_record_summaries(slot, prev_hash)
4410    }
4411
4412    pub fn supports_context_read_fence(&self) -> bool {
4413        self.recorders
4414            .iter()
4415            .all(|recorder| recorder.supports_context_read_fence())
4416    }
4417
4418    /// Observes whether `slot` is still empty at a quorum of recorders without
4419    /// mutating durable state. Any occupied or ambiguous quorum is delegated to
4420    /// the existing certified inspection path and can never become Empty.
4421    pub fn inspect_context_read_fence_at(
4422        &self,
4423        slot: Slot,
4424        prev_hash: LogHash,
4425    ) -> Result<CertifiedDecisionInspection> {
4426        if !self.supports_context_read_fence() {
4427            return Err(Error::ReadFenceUnsupported);
4428        }
4429        let quorum = self.membership.quorum_size();
4430        let total = self.read_fence_workers.len();
4431        let request = ReadFenceRequest {
4432            cluster_id: self.cluster_id.clone(),
4433            epoch: self.epoch,
4434            config_id: self.config_id,
4435            config_digest: self.config_digest,
4436            slot,
4437        };
4438        let (sender, receiver) = std::sync::mpsc::sync_channel(total.max(1));
4439        let mut saturated = 0;
4440        for (index, worker) in self.read_fence_workers.iter().enumerate() {
4441            if worker.dispatch(ControlJob::ObserveReadFence {
4442                index,
4443                request: request.clone(),
4444                result: sender.clone(),
4445            }) == ControlDispatch::Saturated
4446            {
4447                saturated += 1;
4448            }
4449        }
4450        drop(sender);
4451        let mut successful = 0_usize;
4452        let mut empty = 0_usize;
4453        let mut worker_failed = false;
4454        let mut received = 0_usize;
4455        for (index, result) in receiver {
4456            received += 1;
4457            match result {
4458                Ok(observation)
4459                    if valid_read_fence_observation(
4460                        &observation,
4461                        &self.membership.members()[index],
4462                        &request,
4463                    ) =>
4464                {
4465                    successful += 1;
4466                    if observation.slot_state == ReadFenceSlotState::Empty {
4467                        empty += 1;
4468                        if empty >= quorum {
4469                            return Ok(CertifiedDecisionInspection::Empty);
4470                        }
4471                    }
4472                }
4473                Err(Error::ProposeFailed) => worker_failed = true,
4474                Ok(_) | Err(_) => {}
4475            }
4476            let remaining = total.saturating_sub(received);
4477            if successful.saturating_add(remaining) < quorum {
4478                return Ok(CertifiedDecisionInspection::Unavailable);
4479            }
4480        }
4481        if successful < quorum {
4482            if worker_failed && !control_quorum_reachable(successful, saturated, quorum) {
4483                return Err(Error::ProposeFailed);
4484            }
4485            return Ok(CertifiedDecisionInspection::Unavailable);
4486        }
4487        Ok(match self.inspect_certified_decision_at(slot, prev_hash)? {
4488            // An occupied fence quorum cannot be weakened by the legacy typed
4489            // summary path's context-free absence classification.
4490            CertifiedDecisionInspection::Empty => CertifiedDecisionInspection::Pending,
4491            inspection => inspection,
4492        })
4493    }
4494
4495    fn inspect_typed_record_summaries(
4496        &self,
4497        slot: Slot,
4498        prev_hash: LogHash,
4499    ) -> Result<CertifiedDecisionInspection> {
4500        let quorum = self.membership.quorum_size();
4501        let config_id = self.config_id;
4502        let config_digest = self.config_digest;
4503        let total = self.control_workers.len();
4504        let (sender, receiver) = std::sync::mpsc::sync_channel(total.max(1));
4505        let mut saturated = 0;
4506        for (index, worker) in self.control_workers.iter().enumerate() {
4507            if worker.dispatch(ControlJob::InspectSummary {
4508                index,
4509                slot,
4510                result: sender.clone(),
4511            }) == ControlDispatch::Saturated
4512            {
4513                saturated += 1;
4514            }
4515        }
4516        drop(sender);
4517        let mut successful = 0;
4518        let mut summaries = Vec::new();
4519        let mut worker_failed = false;
4520        for (index, result) in receiver {
4521            match result {
4522                Ok(summary)
4523                    if summary.as_ref().is_none_or(|summary| {
4524                        summary.recorder_id == self.membership.members()[index]
4525                            && summary.slot == slot
4526                            && summary.config_id == config_id
4527                            && summary.config_digest == config_digest
4528                    }) =>
4529                {
4530                    successful += 1;
4531                    summaries.extend(summary);
4532                }
4533                Err(Error::ProposeFailed) => worker_failed = true,
4534                Ok(_) | Err(_) => {}
4535            }
4536            if successful >= quorum {
4537                if let Some(proof) = self.proof_from_record_summaries(slot, &summaries)? {
4538                    return self.certified_inspection_from_proof(slot, prev_hash, proof);
4539                }
4540            }
4541        }
4542        if successful < quorum {
4543            if worker_failed && !control_quorum_reachable(successful, saturated, quorum) {
4544                return Err(Error::ProposeFailed);
4545            }
4546            return Ok(CertifiedDecisionInspection::Unavailable);
4547        }
4548        if summaries.is_empty() {
4549            return Ok(CertifiedDecisionInspection::Empty);
4550        }
4551        if summaries.len() < quorum {
4552            return Ok(CertifiedDecisionInspection::Unavailable);
4553        }
4554        if let Some(proof) = self.proof_from_record_summaries(slot, &summaries)? {
4555            return self.certified_inspection_from_proof(slot, prev_hash, proof);
4556        }
4557        Ok(CertifiedDecisionInspection::Pending)
4558    }
4559
4560    fn proof_from_record_summaries(
4561        &self,
4562        slot: Slot,
4563        summaries: &[RecordSummary],
4564    ) -> Result<Option<DecisionProof>> {
4565        let quorum = self.membership.quorum_size();
4566        let mut summaries = summaries.to_vec();
4567        summaries.sort_by(|left, right| left.recorder_id.cmp(&right.recorder_id));
4568        summaries.dedup_by(|left, right| left.recorder_id == right.recorder_id);
4569        let installed_proofs = summaries
4570            .iter()
4571            .filter_map(|summary| summary.decided.clone())
4572            .collect();
4573        if let Some(proof) = self.select_decision_proof(slot, installed_proofs)? {
4574            return Ok(Some(proof));
4575        }
4576        for step in summaries
4577            .iter()
4578            .map(|summary| summary.step)
4579            .collect::<BTreeSet<_>>()
4580            .into_iter()
4581            .rev()
4582        {
4583            let mut step_summaries: Vec<_> = summaries
4584                .iter()
4585                .filter(|summary| summary.step == step)
4586                .cloned()
4587                .collect();
4588            if step_summaries.len() < quorum {
4589                continue;
4590            }
4591            step_summaries.truncate(quorum);
4592            let proof_summaries: Vec<_> = step_summaries
4593                .iter()
4594                .map(|summary| RecorderSummary {
4595                    recorder_id: summary.recorder_id.clone(),
4596                    slot: summary.slot,
4597                    step: summary.step,
4598                    first_current: summary.first_current.clone(),
4599                    aggregate_prior: summary.aggregate_prior.clone(),
4600                })
4601                .collect();
4602            let proof = if step == 4 {
4603                step_summaries
4604                    .first()
4605                    .and_then(|summary| summary.first_current.clone())
4606                    .filter(|proposal| proposal.priority == ProposalPriority::MAX)
4607                    .filter(|proposal| {
4608                        step_summaries.iter().all(|summary| {
4609                            summary
4610                                .first_current
4611                                .as_ref()
4612                                .is_some_and(|candidate| proposal_exact(candidate, proposal))
4613                        })
4614                    })
4615                    .map(|proposal| DecisionProof::FastPath {
4616                        cluster_id: self.cluster_id.clone(),
4617                        slot,
4618                        epoch: self.epoch,
4619                        config_id: self.config_id,
4620                        config_digest: self.config_digest,
4621                        proposal,
4622                        summaries: proof_summaries.clone(),
4623                    })
4624            } else if step % 4 == 2 {
4625                step_summaries
4626                    .iter()
4627                    .filter_map(|summary| summary.aggregate_prior.clone())
4628                    .max()
4629                    .map(|proposal| DecisionProof::Phase2 {
4630                        cluster_id: self.cluster_id.clone(),
4631                        slot,
4632                        epoch: self.epoch,
4633                        config_id: self.config_id,
4634                        config_digest: self.config_digest,
4635                        step,
4636                        proposal,
4637                        summaries: proof_summaries.clone(),
4638                    })
4639            } else {
4640                None
4641            };
4642            let Some(proof) = proof else {
4643                continue;
4644            };
4645            let Ok(Some(proof)) = self.select_decision_proof(slot, vec![proof]) else {
4646                continue;
4647            };
4648            return Ok(Some(proof));
4649        }
4650        Ok(None)
4651    }
4652
4653    pub fn recover_decision_at(
4654        &self,
4655        slot: Slot,
4656        prev_hash: LogHash,
4657    ) -> Result<DecisionInspection> {
4658        match self.inspect_decision_at(slot, prev_hash)? {
4659            DecisionInspection::Pending => self
4660                .propose_stored_at(
4661                    slot,
4662                    prev_hash,
4663                    StoredCommand::new(EntryType::Noop, Vec::new()),
4664                )
4665                .map(DecisionInspection::Committed),
4666            inspection => Ok(inspection),
4667        }
4668    }
4669
4670    pub fn recover_decided_at(&self, slot: Slot, prev_hash: LogHash) -> Result<Option<LogEntry>> {
4671        match self.inspect_decision_at(slot, prev_hash)? {
4672            DecisionInspection::Committed(entry) => Ok(Some(entry)),
4673            DecisionInspection::Empty | DecisionInspection::Pending => Ok(None),
4674            DecisionInspection::Unavailable => Err(Error::CommandUnavailable),
4675        }
4676    }
4677
4678    pub fn recover_decided_next(&self) -> Result<Option<LogEntry>> {
4679        let mut tip = self.legacy_tip.lock().map_err(|_| Error::ProposeFailed)?;
4680        let Some(entry) = self.recover_decided_at(tip.next_index, tip.last_hash)? else {
4681            return Ok(None);
4682        };
4683        tip.next_index = entry
4684            .index
4685            .checked_add(1)
4686            .ok_or(Error::InvalidRecoveredTip)?;
4687        tip.last_hash = entry.hash;
4688        Ok(Some(entry))
4689    }
4690
4691    fn ensure_predecessor(
4692        &self,
4693        slot: Slot,
4694        actual_prev_hash: LogHash,
4695        expected_prev_hash: LogHash,
4696    ) -> Result<()> {
4697        if actual_prev_hash != expected_prev_hash {
4698            return Err(Error::ChainConflict {
4699                slot,
4700                expected_prev_hash,
4701                actual_prev_hash,
4702            });
4703        }
4704        Ok(())
4705    }
4706
4707    fn store_command_on_quorum(
4708        &self,
4709        command_hash: LogHash,
4710        command: &StoredCommand,
4711    ) -> Result<()> {
4712        let quorum = quorum_size(self.control_workers.len());
4713        let total = self.control_workers.len();
4714        let (sender, receiver) = std::sync::mpsc::sync_channel(total.max(1));
4715        let mut saturated = 0;
4716        for (index, worker) in self.control_workers.iter().enumerate() {
4717            if worker.dispatch(ControlJob::StoreCommand {
4718                index,
4719                cluster_id: self.cluster_id.clone(),
4720                epoch: self.epoch,
4721                config_id: self.config_id,
4722                config_digest: self.config_digest,
4723                command_hash,
4724                command: command.clone(),
4725                result: sender.clone(),
4726            }) == ControlDispatch::Saturated
4727            {
4728                saturated += 1;
4729            }
4730        }
4731        drop(sender);
4732        let mut stored = 0;
4733        let mut worker_failed = false;
4734        for (_, result) in receiver {
4735            match result {
4736                Ok(()) => stored += 1,
4737                Err(Error::ProposeFailed) => worker_failed = true,
4738                Err(_) => {}
4739            }
4740            if stored >= quorum {
4741                break;
4742            }
4743        }
4744        if stored < quorum {
4745            return Err(
4746                if worker_failed && !control_quorum_reachable(stored, saturated, quorum) {
4747                    Error::ProposeFailed
4748                } else {
4749                    Error::NoQuorum
4750                },
4751            );
4752        }
4753        Ok(())
4754    }
4755
4756    fn fetch_verified_value(
4757        &self,
4758        slot: Slot,
4759        value: &AcceptedValue,
4760    ) -> Result<Option<StoredCommand>> {
4761        let quorum = quorum_size(self.control_workers.len());
4762        let total = self.control_workers.len();
4763        let (sender, receiver) = std::sync::mpsc::sync_channel(total.max(1));
4764        let mut saturated = 0;
4765        for (index, worker) in self.control_workers.iter().enumerate() {
4766            if worker.dispatch(ControlJob::FetchCommand {
4767                index,
4768                cluster_id: self.cluster_id.clone(),
4769                epoch: self.epoch,
4770                config_id: self.config_id,
4771                config_digest: self.config_digest,
4772                command_hash: value.command_hash,
4773                result: sender.clone(),
4774            }) == ControlDispatch::Saturated
4775            {
4776                saturated += 1;
4777            }
4778        }
4779        drop(sender);
4780        let mut mismatch = false;
4781        let mut successful = 0;
4782        let mut worker_failed = false;
4783        for (_, result) in receiver {
4784            match result {
4785                Ok(command) => {
4786                    successful += 1;
4787                    if let Some(command) = command {
4788                        if command.hash() != value.command_hash {
4789                            continue;
4790                        }
4791                        let expected = AcceptedValue::from_command(
4792                            &self.cluster_id,
4793                            slot,
4794                            self.epoch,
4795                            self.config_id,
4796                            value.prev_hash,
4797                            &command,
4798                        );
4799                        if expected == *value {
4800                            return Ok(Some(command));
4801                        }
4802                        mismatch = true;
4803                    }
4804                }
4805                Err(Error::ProposeFailed) => worker_failed = true,
4806                Err(_) => {}
4807            }
4808        }
4809        if mismatch {
4810            Err(Error::Rejected(RejectReason::InvalidValue))
4811        } else if successful < quorum && saturated > 0 {
4812            Err(Error::NoQuorum)
4813        } else if worker_failed && !control_quorum_reachable(successful, saturated, quorum) {
4814            Err(Error::ProposeFailed)
4815        } else {
4816            Ok(None)
4817        }
4818    }
4819
4820    fn ensure_progress_command(&self, progress: &mut ProposerProgress) -> Result<()> {
4821        let value = progress
4822            .proposal
4823            .value
4824            .as_ref()
4825            .ok_or(Error::Rejected(RejectReason::InvalidRequest))?;
4826        if progress
4827            .command
4828            .as_ref()
4829            .is_some_and(|command| self.command_matches_value(progress.slot, value, command))
4830        {
4831            return Ok(());
4832        }
4833        progress.command_holders.clear();
4834        progress.command = self.fetch_verified_value(progress.slot, value)?;
4835        if let Some(command) = &progress.command {
4836            progress.transition_involved |= command.entry_type == EntryType::ConfigChange;
4837            Ok(())
4838        } else {
4839            Err(Error::CommandUnavailable)
4840        }
4841    }
4842
4843    fn command_matches_value(
4844        &self,
4845        slot: Slot,
4846        value: &AcceptedValue,
4847        command: &StoredCommand,
4848    ) -> bool {
4849        AcceptedValue::from_command(
4850            &self.cluster_id,
4851            slot,
4852            self.epoch,
4853            self.config_id,
4854            value.prev_hash,
4855            command,
4856        ) == *value
4857    }
4858
4859    fn log_entry_from_value(
4860        &self,
4861        slot: Slot,
4862        command: StoredCommand,
4863        value: &AcceptedValue,
4864    ) -> Result<LogEntry> {
4865        let entry = LogEntry {
4866            cluster_id: self.cluster_id.clone(),
4867            epoch: self.epoch,
4868            config_id: self.config_id,
4869            index: slot,
4870            entry_type: command.entry_type,
4871            payload: command.payload,
4872            prev_hash: value.prev_hash,
4873            hash: value.entry_hash,
4874        };
4875        if entry.recompute_hash() != entry.hash {
4876            return Err(Error::Rejected(RejectReason::InvalidValue));
4877        }
4878        Ok(entry)
4879    }
4880}
4881
4882#[derive(Clone, Debug, Eq, PartialEq)]
4883pub enum DecisionInspection {
4884    Committed(LogEntry),
4885    Empty,
4886    Pending,
4887    Unavailable,
4888}
4889
4890#[derive(Clone, Debug, Eq, PartialEq)]
4891pub struct CertifiedDecision {
4892    pub entry: LogEntry,
4893    pub certificate: DecisionCertificate,
4894    pub proof: DecisionProof,
4895}
4896
4897#[derive(Clone, Debug, Eq, PartialEq)]
4898pub enum CertifiedDecisionInspection {
4899    Empty,
4900    Pending,
4901    Committed(Box<CertifiedDecision>),
4902    Unavailable,
4903}
4904
4905impl RecorderRpc for RecorderFileStore {
4906    fn recorder_id(&self) -> Result<NodeId> {
4907        Ok(self.recorder_id.clone())
4908    }
4909
4910    fn record(&self, request: RecordRequest) -> Result<RecordSummary> {
4911        self.record_proposal(request)
4912    }
4913
4914    fn install_decision_proof(&self, proof: DecisionProof, membership: &Membership) -> Result<()> {
4915        self.install_decision_proof_record(proof, membership)
4916    }
4917
4918    fn inspect_decision_proof(&self, slot: Slot) -> Result<Option<DecisionProof>> {
4919        Ok(self.load(slot)?.decision_proof().cloned())
4920    }
4921
4922    fn inspect_record_summary(&self, slot: Slot) -> Result<Option<RecordSummary>> {
4923        let _guard = self
4924            .sync
4925            .lock()
4926            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
4927        self.recover_intent()?;
4928        let configuration = self.configuration_state()?;
4929        let exists_in_wal = self
4930            .wal
4931            .lock()
4932            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?
4933            .slots
4934            .contains_key(&slot);
4935        if !exists_in_wal && !self.path(slot).exists() {
4936            return Ok(None);
4937        }
4938        let state = self.load_unlocked(slot, configuration.config_digest)?;
4939        Ok(Some(record_summary(
4940            &self.recorder_id,
4941            &state,
4942            state.decision_proof().cloned(),
4943        )))
4944    }
4945
4946    fn supports_context_read_fence(&self) -> bool {
4947        true
4948    }
4949
4950    fn observe_read_fence(&self, request: ReadFenceRequest) -> Result<ReadFenceObservation> {
4951        let _guard = self
4952            .sync
4953            .lock()
4954            .map_err(|_| Error::Io("recorder lock poisoned".into()))?;
4955        self.recover_intent()?;
4956        if request.cluster_id != self.cluster_id {
4957            return Err(Error::Rejected(RejectReason::WrongCluster));
4958        }
4959        if request.epoch != self.epoch {
4960            return Err(Error::Rejected(if request.epoch < self.epoch {
4961                RejectReason::StaleEpoch
4962            } else {
4963                RejectReason::FutureEpoch
4964            }));
4965        }
4966        let configuration = self.configuration_state()?;
4967        if request.config_id != configuration.config_id
4968            || request.config_digest != configuration.config_digest
4969        {
4970            return Err(Error::Rejected(RejectReason::WrongConfig));
4971        }
4972        let max_head = configuration.max_accepted_or_decided_slot;
4973        let exists_in_wal = self
4974            .wal
4975            .lock()
4976            .map_err(|_| Error::Io("recorder WAL lock poisoned".into()))?
4977            .slots
4978            .contains_key(&request.slot);
4979        let exact_exists = exists_in_wal || self.path(request.slot).exists();
4980        let summary = if exact_exists {
4981            let state = self.load_unlocked(request.slot, configuration.config_digest)?;
4982            Some(Box::new(record_summary(
4983                &self.recorder_id,
4984                &state,
4985                state.decision_proof().cloned(),
4986            )))
4987        } else {
4988            None
4989        };
4990        let slot_state =
4991            if summary.is_none() && max_head.is_none_or(|max_head| max_head < request.slot) {
4992                ReadFenceSlotState::Empty
4993            } else {
4994                ReadFenceSlotState::Occupied { summary }
4995            };
4996        Ok(ReadFenceObservation {
4997            recorder_id: self.recorder_id.clone(),
4998            cluster_id: request.cluster_id,
4999            epoch: request.epoch,
5000            config_id: request.config_id,
5001            config_digest: request.config_digest,
5002            slot: request.slot,
5003            max_head,
5004            slot_state,
5005        })
5006    }
5007
5008    fn store_command(&self, command_hash: LogHash, command: StoredCommand) -> Result<()> {
5009        RecorderFileStore::store_command(self, command_hash, command)
5010    }
5011
5012    fn store_command_for(
5013        &self,
5014        cluster_id: ClusterId,
5015        epoch: Epoch,
5016        config_id: ConfigId,
5017        config_digest: LogHash,
5018        command_hash: LogHash,
5019        command: StoredCommand,
5020    ) -> Result<()> {
5021        self.apply(RecorderRequest::StoreCommand {
5022            cluster_id,
5023            epoch,
5024            config_id,
5025            config_digest,
5026            command_hash,
5027            command,
5028        })?;
5029        Ok(())
5030    }
5031
5032    fn fetch_command(&self, command_hash: LogHash) -> Result<Option<StoredCommand>> {
5033        RecorderFileStore::fetch_command(self, command_hash)
5034    }
5035
5036    fn fetch_command_for(
5037        &self,
5038        cluster_id: ClusterId,
5039        epoch: Epoch,
5040        config_id: ConfigId,
5041        config_digest: LogHash,
5042        command_hash: LogHash,
5043    ) -> Result<Option<StoredCommand>> {
5044        Ok(self
5045            .apply(RecorderRequest::FetchCommand {
5046                cluster_id,
5047                epoch,
5048                config_id,
5049                config_digest,
5050                command_hash,
5051            })?
5052            .command)
5053    }
5054}
5055
5056fn proposal_ballot(proposal: &Proposal) -> Option<Ballot> {
5057    proposal.value.as_ref()?;
5058    Some(Ballot::new(
5059        proposal.proposal_id,
5060        proposal.priority.legacy_u128(),
5061        proposal.proposer_id.clone(),
5062    ))
5063}
5064
5065fn record_summary(
5066    recorder_id: &str,
5067    state: &RecorderSlotState,
5068    decided: Option<DecisionProof>,
5069) -> RecordSummary {
5070    RecordSummary {
5071        recorder_id: recorder_id.to_string(),
5072        slot: state.slot,
5073        config_id: state.config_id,
5074        config_digest: state.config_digest,
5075        step: state.isr.step(),
5076        first_current: state.isr.first_current().cloned(),
5077        aggregate_prior: state.isr.aggregate_prior().cloned(),
5078        decided,
5079    }
5080}
5081
5082fn proof_context(proof: &DecisionProof) -> (Slot, Epoch, ConfigId, LogHash) {
5083    match proof {
5084        DecisionProof::FastPath {
5085            slot,
5086            epoch,
5087            config_id,
5088            config_digest,
5089            ..
5090        }
5091        | DecisionProof::Phase2 {
5092            slot,
5093            epoch,
5094            config_id,
5095            config_digest,
5096            ..
5097        } => (*slot, *epoch, *config_id, *config_digest),
5098    }
5099}
5100
5101fn proof_cluster_id(proof: &DecisionProof) -> &str {
5102    match proof {
5103        DecisionProof::FastPath { cluster_id, .. } | DecisionProof::Phase2 { cluster_id, .. } => {
5104            cluster_id
5105        }
5106    }
5107}
5108
5109fn certificate_from_proof(proof: &DecisionProof) -> Result<DecisionCertificate> {
5110    let (slot, epoch, config_id, config_digest) = proof_context(proof);
5111    let proposal = proof.proposal();
5112    let value = proposal
5113        .value
5114        .clone()
5115        .ok_or(Error::Rejected(RejectReason::InvalidCertificate))?;
5116    let recorder_ids = match proof {
5117        DecisionProof::FastPath { summaries, .. } | DecisionProof::Phase2 { summaries, .. } => {
5118            summaries
5119                .iter()
5120                .map(|summary| summary.recorder_id.clone())
5121                .collect()
5122        }
5123    };
5124    Ok(DecisionCertificate {
5125        slot,
5126        epoch,
5127        config_id,
5128        config_digest,
5129        ballot: Ballot::new(
5130            proposal.proposal_id,
5131            proposal.priority.legacy_u128(),
5132            encode_certificate_proposer(proof_cluster_id(proof), &proposal.proposer_id),
5133        ),
5134        value,
5135        recorder_ids,
5136    })
5137}
5138
5139const CERTIFICATE_PROPOSER_PREFIX: &str = "QDC1:";
5140
5141fn encode_certificate_proposer(cluster_id: &str, proposer_id: &str) -> String {
5142    format!(
5143        "{CERTIFICATE_PROPOSER_PREFIX}{}:{cluster_id}{proposer_id}",
5144        cluster_id.len()
5145    )
5146}
5147
5148fn decode_certificate_proposer(encoded: &str) -> Option<(&str, &str)> {
5149    let rest = encoded.strip_prefix(CERTIFICATE_PROPOSER_PREFIX)?;
5150    let (length, joined) = rest.split_once(':')?;
5151    let length: usize = length.parse().ok()?;
5152    Some((joined.get(..length)?, joined.get(length..)?))
5153}
5154
5155impl Consensus for ThreeNodeConsensus {
5156    fn propose(&self, command: Command) -> Result<LogEntry> {
5157        self.propose_next(command)
5158    }
5159}
5160
5161fn request_slot(request: &RecorderRequest) -> Option<Slot> {
5162    match request {
5163        RecorderRequest::Inspect { slot, .. }
5164        | RecorderRequest::Observe { slot, .. }
5165        | RecorderRequest::Converge { slot, .. }
5166        | RecorderRequest::Decide { slot, .. } => Some(*slot),
5167        RecorderRequest::Identity
5168        | RecorderRequest::StoreCommand { .. }
5169        | RecorderRequest::FetchCommand { .. } => None,
5170    }
5171}
5172
5173fn request_context(request: &RecorderRequest) -> Option<(&ClusterId, Epoch, ConfigId, LogHash)> {
5174    match request {
5175        RecorderRequest::StoreCommand {
5176            cluster_id,
5177            epoch,
5178            config_id,
5179            config_digest,
5180            ..
5181        }
5182        | RecorderRequest::FetchCommand {
5183            cluster_id,
5184            epoch,
5185            config_id,
5186            config_digest,
5187            ..
5188        }
5189        | RecorderRequest::Inspect {
5190            cluster_id,
5191            epoch,
5192            config_id,
5193            config_digest,
5194            ..
5195        }
5196        | RecorderRequest::Observe {
5197            cluster_id,
5198            epoch,
5199            config_id,
5200            config_digest,
5201            ..
5202        }
5203        | RecorderRequest::Converge {
5204            cluster_id,
5205            epoch,
5206            config_id,
5207            config_digest,
5208            ..
5209        }
5210        | RecorderRequest::Decide {
5211            cluster_id,
5212            epoch,
5213            config_id,
5214            config_digest,
5215            ..
5216        } => Some((cluster_id, *epoch, *config_id, *config_digest)),
5217        _ => None,
5218    }
5219}
5220
5221fn stored_command(command: Command) -> Result<StoredCommand> {
5222    let entry_type = match command.kind() {
5223        CommandKind::Deterministic => EntryType::Command,
5224        CommandKind::ReadBarrier => EntryType::Noop,
5225    };
5226    Ok(StoredCommand::new(entry_type, command.payload().to_vec()))
5227}
5228
5229fn encode_configuration_state(state: &ConfigurationState) -> Result<Vec<u8>> {
5230    let mut out = Vec::new();
5231    out.extend_from_slice(b"QCON");
5232    put_u16(&mut out, CONFIGURATION_STATE_VERSION);
5233    put_u64(&mut out, state.config_id);
5234    out.extend_from_slice(state.config_digest.as_bytes());
5235    out.push(u8::from(state.activated));
5236    match state.max_accepted_or_decided_slot {
5237        Some(slot) => {
5238            out.push(1);
5239            put_u64(&mut out, slot);
5240        }
5241        None => out.push(0),
5242    }
5243    match &state.membership {
5244        Some(membership) => {
5245            out.push(membership.members().len() as u8);
5246            for member in membership.members() {
5247                put_bytes(&mut out, member.as_bytes())?;
5248            }
5249        }
5250        None => out.push(0),
5251    }
5252    encode_optional_seal(&mut out, state.predecessor.as_ref());
5253    encode_optional_seal(&mut out, state.seal.as_ref());
5254    let digest = LogHash::digest(&[&out]);
5255    out.extend_from_slice(digest.as_bytes());
5256    Ok(out)
5257}
5258
5259fn decode_configuration_state(bytes: &[u8]) -> Result<ConfigurationState> {
5260    if bytes.len() < 4 + 2 + 32 || bytes.get(..4) != Some(b"QCON") {
5261        return Err(Error::Decode("invalid configuration state".into()));
5262    }
5263    let (body, digest) = bytes.split_at(bytes.len() - 32);
5264    if LogHash::digest(&[body]).as_bytes() != digest {
5265        return Err(Error::Decode("configuration digest mismatch".into()));
5266    }
5267    let mut cursor = 4;
5268    let version = read_u16(body, &mut cursor)?;
5269    if version != CONFIGURATION_STATE_VERSION {
5270        return Err(Error::MigrationRequired {
5271            format: "QCON",
5272            version,
5273        });
5274    }
5275    let config_id = read_u64(body, &mut cursor)?;
5276    let config_digest = read_hash(body, &mut cursor)?;
5277    let activated = match read_u8(body, &mut cursor)? {
5278        0 => false,
5279        1 => true,
5280        _ => return Err(Error::Decode("invalid activation flag".into())),
5281    };
5282    let max_accepted_or_decided_slot = match read_u8(body, &mut cursor)? {
5283        0 => None,
5284        1 => Some(read_u64(body, &mut cursor)?),
5285        _ => return Err(Error::Decode("invalid accepted-slot flag".into())),
5286    };
5287    let member_count = read_u8(body, &mut cursor)? as usize;
5288    let membership = if member_count == 0 {
5289        None
5290    } else {
5291        let members = (0..member_count)
5292            .map(|_| {
5293                String::from_utf8(read_bytes(body, &mut cursor)?)
5294                    .map_err(|err| Error::Decode(err.to_string()))
5295            })
5296            .collect::<Result<Vec<_>>>()?;
5297        let membership = Membership::from_voters(members)?;
5298        if membership.digest() != config_digest {
5299            return Err(Error::Decode("membership digest mismatch".into()));
5300        }
5301        Some(membership)
5302    };
5303    let predecessor = decode_optional_seal(body, &mut cursor)?;
5304    let seal = decode_optional_seal(body, &mut cursor)?;
5305    if cursor != body.len() {
5306        return Err(Error::Decode("trailing configuration bytes".into()));
5307    }
5308    Ok(ConfigurationState {
5309        config_id,
5310        config_digest,
5311        membership,
5312        predecessor,
5313        seal,
5314        max_accepted_or_decided_slot,
5315        activated,
5316    })
5317}
5318
5319fn encode_optional_seal(out: &mut Vec<u8>, seal: Option<&ConfigurationSeal>) {
5320    match seal {
5321        Some(seal) => {
5322            out.push(1);
5323            put_u64(out, seal.stop_slot);
5324            out.extend_from_slice(seal.command_hash.as_bytes());
5325            out.extend_from_slice(seal.prefix_hash.as_bytes());
5326        }
5327        None => out.push(0),
5328    }
5329}
5330
5331fn decode_optional_seal(bytes: &[u8], cursor: &mut usize) -> Result<Option<ConfigurationSeal>> {
5332    match read_u8(bytes, cursor)? {
5333        0 => Ok(None),
5334        1 => Ok(Some(ConfigurationSeal {
5335            stop_slot: read_u64(bytes, cursor)?,
5336            command_hash: read_hash(bytes, cursor)?,
5337            prefix_hash: read_hash(bytes, cursor)?,
5338        })),
5339        _ => Err(Error::Decode("invalid configuration seal flag".into())),
5340    }
5341}
5342
5343fn encode_transition_intent(
5344    slot: Slot,
5345    slot_bytes: &[u8],
5346    configuration_bytes: &[u8],
5347) -> Result<Vec<u8>> {
5348    let mut out = Vec::new();
5349    out.extend_from_slice(b"QINT");
5350    put_u16(&mut out, 1);
5351    put_u64(&mut out, slot);
5352    put_bytes(&mut out, slot_bytes)?;
5353    put_bytes(&mut out, configuration_bytes)?;
5354    let digest = LogHash::digest(&[&out]);
5355    out.extend_from_slice(digest.as_bytes());
5356    Ok(out)
5357}
5358
5359fn decode_transition_intent(bytes: &[u8]) -> Result<(Slot, Vec<u8>, Vec<u8>)> {
5360    if bytes.len() < 4 + 2 + 32 || bytes.get(..4) != Some(b"QINT") {
5361        return Err(Error::Decode("invalid transition intent".into()));
5362    }
5363    let (body, digest) = bytes.split_at(bytes.len() - 32);
5364    if LogHash::digest(&[body]).as_bytes() != digest {
5365        return Err(Error::Decode("transition intent digest mismatch".into()));
5366    }
5367    let mut cursor = 4;
5368    if read_u16(body, &mut cursor)? != 1 {
5369        return Err(Error::Decode(
5370            "unsupported transition intent version".into(),
5371        ));
5372    }
5373    let slot = read_u64(body, &mut cursor)?;
5374    let slot_bytes = read_bytes(body, &mut cursor)?;
5375    let configuration_bytes = read_bytes(body, &mut cursor)?;
5376    if cursor != body.len() {
5377        return Err(Error::Decode("trailing transition intent bytes".into()));
5378    }
5379    Ok((slot, slot_bytes, configuration_bytes))
5380}
5381
5382fn encode_recorder_state(state: &RecorderSlotState) -> Result<Vec<u8>> {
5383    let mut out = Vec::new();
5384    out.extend_from_slice(b"QREC");
5385    put_u16(&mut out, RECORDER_STATE_VERSION);
5386    put_u64(&mut out, state.slot);
5387    put_u64(&mut out, state.epoch);
5388    put_u64(&mut out, state.config_id);
5389    out.extend_from_slice(state.config_digest.as_bytes());
5390    put_bytes(&mut out, state.cluster_id.as_bytes())?;
5391    encode_optional_ballot(&mut out, state.highest_promised.as_ref())?;
5392    match &state.accepted {
5393        Some(accepted) => {
5394            out.push(1);
5395            encode_ballot(&mut out, &accepted.ballot)?;
5396            encode_value(&mut out, &accepted.value);
5397        }
5398        None => out.push(0),
5399    }
5400    match &state.decided {
5401        Some(decided) => {
5402            out.push(1);
5403            encode_certificate(&mut out, decided)?;
5404        }
5405        None => out.push(0),
5406    }
5407    put_u64(&mut out, state.isr.step);
5408    encode_optional_proposal(&mut out, state.isr.first_current.as_ref())?;
5409    encode_optional_proposal(&mut out, state.isr.aggregate_current.as_ref())?;
5410    encode_optional_proposal(&mut out, state.isr.aggregate_prior.as_ref())?;
5411    encode_optional_proof(&mut out, state.decided_proof.as_ref())?;
5412    let digest = LogHash::digest(&[&out]);
5413    out.extend_from_slice(digest.as_bytes());
5414    Ok(out)
5415}
5416
5417fn decode_recorder_state(bytes: &[u8]) -> Result<RecorderSlotState> {
5418    if bytes.len() < 4 + 2 + 32 || &bytes[..4] != b"QREC" {
5419        return Err(Error::Decode("invalid recorder magic".into()));
5420    }
5421    let (body, digest) = bytes.split_at(bytes.len() - 32);
5422    if LogHash::digest(&[body]).as_bytes() != digest {
5423        return Err(Error::Decode("recorder digest mismatch".into()));
5424    }
5425    let mut cursor = 4;
5426    let version = read_u16(body, &mut cursor)?;
5427    if version != RECORDER_STATE_VERSION {
5428        return Err(Error::MigrationRequired {
5429            format: "QREC",
5430            version,
5431        });
5432    }
5433    let slot = read_u64(body, &mut cursor)?;
5434    let epoch = read_u64(body, &mut cursor)?;
5435    let config_id = read_u64(body, &mut cursor)?;
5436    let config_digest = read_hash(body, &mut cursor)?;
5437    let cluster_id = String::from_utf8(read_bytes(body, &mut cursor)?)
5438        .map_err(|err| Error::Decode(err.to_string()))?;
5439    let highest_promised = decode_optional_ballot(body, &mut cursor)?;
5440    let accepted = match read_u8(body, &mut cursor)? {
5441        0 => None,
5442        1 => Some(AcceptedSummary {
5443            ballot: decode_ballot(body, &mut cursor)?,
5444            value: decode_value(body, &mut cursor)?,
5445        }),
5446        _ => return Err(Error::Decode("invalid accepted flag".into())),
5447    };
5448    let decided = match read_u8(body, &mut cursor)? {
5449        0 => None,
5450        1 => Some(decode_certificate(body, &mut cursor)?),
5451        _ => return Err(Error::Decode("invalid decided flag".into())),
5452    };
5453    let isr = IsrState {
5454        step: read_u64(body, &mut cursor)?,
5455        first_current: decode_optional_proposal(body, &mut cursor)?,
5456        aggregate_current: decode_optional_proposal(body, &mut cursor)?,
5457        aggregate_prior: decode_optional_proposal(body, &mut cursor)?,
5458    };
5459    let decided_proof = decode_optional_proof(body, &mut cursor)?;
5460    if cursor != body.len() {
5461        return Err(Error::Decode("trailing recorder bytes".into()));
5462    }
5463    if let Some(accepted) = &accepted {
5464        if highest_promised.as_ref() < Some(&accepted.ballot) {
5465            return Err(Error::Decode("accepted ballot exceeds promise".into()));
5466        }
5467    }
5468    if let Some(decided) = &decided {
5469        decided
5470            .validate_context(slot, epoch, config_id, config_digest)
5471            .map_err(|_| Error::Decode("invalid decision certificate".into()))?;
5472    }
5473    if let Some(proof) = &decided_proof {
5474        let proof_value = proof.proposal().value.as_ref();
5475        if proof_context(proof) != (slot, epoch, config_id, config_digest)
5476            || proof_cluster_id(proof) != cluster_id
5477            || !matches!((decided.as_ref(), proof_value), (Some(certificate), Some(value)) if &certificate.value == value)
5478        {
5479            return Err(Error::Decode("invalid persisted decision proof".into()));
5480        }
5481    }
5482    Ok(RecorderSlotState {
5483        slot,
5484        cluster_id,
5485        epoch,
5486        config_id,
5487        config_digest,
5488        highest_promised,
5489        accepted,
5490        decided,
5491        isr,
5492        decided_proof,
5493    })
5494}
5495
5496fn encode_optional_proposal(out: &mut Vec<u8>, proposal: Option<&Proposal>) -> Result<()> {
5497    match proposal {
5498        None => out.push(0),
5499        Some(proposal) => {
5500            out.push(1);
5501            out.extend_from_slice(&proposal.priority.0);
5502            put_bytes(out, proposal.proposer_id.as_bytes())?;
5503            put_u64(out, proposal.proposal_id);
5504            match &proposal.value {
5505                None => out.push(0),
5506                Some(value) => {
5507                    out.push(1);
5508                    encode_value(out, value);
5509                }
5510            }
5511        }
5512    }
5513    Ok(())
5514}
5515
5516fn decode_optional_proposal(bytes: &[u8], cursor: &mut usize) -> Result<Option<Proposal>> {
5517    match read_u8(bytes, cursor)? {
5518        0 => Ok(None),
5519        1 => {
5520            let end = cursor
5521                .checked_add(32)
5522                .ok_or_else(|| Error::Decode("priority overflow".into()))?;
5523            let priority = ProposalPriority(
5524                bytes
5525                    .get(*cursor..end)
5526                    .ok_or_else(|| Error::Decode("truncated priority".into()))?
5527                    .try_into()
5528                    .expect("checked priority length"),
5529            );
5530            *cursor = end;
5531            let proposer_id = String::from_utf8(read_bytes(bytes, cursor)?)
5532                .map_err(|error| Error::Decode(error.to_string()))?;
5533            let proposal_id = read_u64(bytes, cursor)?;
5534            let value = match read_u8(bytes, cursor)? {
5535                0 => None,
5536                1 => Some(decode_value(bytes, cursor)?),
5537                _ => return Err(Error::Decode("invalid proposal value flag".into())),
5538            };
5539            Ok(Some(Proposal {
5540                priority,
5541                proposer_id,
5542                proposal_id,
5543                value,
5544            }))
5545        }
5546        _ => Err(Error::Decode("invalid proposal flag".into())),
5547    }
5548}
5549
5550fn encode_summary(out: &mut Vec<u8>, summary: &RecorderSummary) -> Result<()> {
5551    put_bytes(out, summary.recorder_id.as_bytes())?;
5552    put_u64(out, summary.slot);
5553    put_u64(out, summary.step);
5554    encode_optional_proposal(out, summary.first_current.as_ref())?;
5555    encode_optional_proposal(out, summary.aggregate_prior.as_ref())
5556}
5557
5558fn decode_summary(bytes: &[u8], cursor: &mut usize) -> Result<RecorderSummary> {
5559    Ok(RecorderSummary {
5560        recorder_id: String::from_utf8(read_bytes(bytes, cursor)?)
5561            .map_err(|error| Error::Decode(error.to_string()))?,
5562        slot: read_u64(bytes, cursor)?,
5563        step: read_u64(bytes, cursor)?,
5564        first_current: decode_optional_proposal(bytes, cursor)?,
5565        aggregate_prior: decode_optional_proposal(bytes, cursor)?,
5566    })
5567}
5568
5569fn encode_optional_proof(out: &mut Vec<u8>, proof: Option<&DecisionProof>) -> Result<()> {
5570    let Some(proof) = proof else {
5571        out.push(0);
5572        return Ok(());
5573    };
5574    let (tag, cluster_id, slot, epoch, config_id, digest, step, proposal, summaries) = match proof {
5575        DecisionProof::FastPath {
5576            cluster_id,
5577            slot,
5578            epoch,
5579            config_id,
5580            config_digest,
5581            proposal,
5582            summaries,
5583        } => (
5584            1,
5585            cluster_id,
5586            *slot,
5587            *epoch,
5588            *config_id,
5589            *config_digest,
5590            4,
5591            proposal,
5592            summaries,
5593        ),
5594        DecisionProof::Phase2 {
5595            cluster_id,
5596            slot,
5597            epoch,
5598            config_id,
5599            config_digest,
5600            step,
5601            proposal,
5602            summaries,
5603        } => (
5604            2,
5605            cluster_id,
5606            *slot,
5607            *epoch,
5608            *config_id,
5609            *config_digest,
5610            *step,
5611            proposal,
5612            summaries,
5613        ),
5614    };
5615    out.push(tag);
5616    put_bytes(out, cluster_id.as_bytes())?;
5617    put_u64(out, slot);
5618    put_u64(out, epoch);
5619    put_u64(out, config_id);
5620    out.extend_from_slice(digest.as_bytes());
5621    put_u64(out, step);
5622    encode_optional_proposal(out, Some(proposal))?;
5623    put_u16(
5624        out,
5625        u16::try_from(summaries.len())
5626            .map_err(|_| Error::Decode("too many proof summaries".into()))?,
5627    );
5628    for summary in summaries {
5629        encode_summary(out, summary)?;
5630    }
5631    Ok(())
5632}
5633
5634fn decode_optional_proof(bytes: &[u8], cursor: &mut usize) -> Result<Option<DecisionProof>> {
5635    let tag = read_u8(bytes, cursor)?;
5636    if tag == 0 {
5637        return Ok(None);
5638    }
5639    let cluster_id = String::from_utf8(read_bytes(bytes, cursor)?)
5640        .map_err(|error| Error::Decode(error.to_string()))?;
5641    let slot = read_u64(bytes, cursor)?;
5642    let epoch = read_u64(bytes, cursor)?;
5643    let config_id = read_u64(bytes, cursor)?;
5644    let config_digest = read_hash(bytes, cursor)?;
5645    let step = read_u64(bytes, cursor)?;
5646    let proposal = decode_optional_proposal(bytes, cursor)?
5647        .ok_or_else(|| Error::Decode("nil decision proposal".into()))?;
5648    let summaries = (0..read_u16(bytes, cursor)? as usize)
5649        .map(|_| decode_summary(bytes, cursor))
5650        .collect::<Result<Vec<_>>>()?;
5651    match tag {
5652        1 if step == 4 => Ok(Some(DecisionProof::FastPath {
5653            cluster_id,
5654            slot,
5655            epoch,
5656            config_id,
5657            config_digest,
5658            proposal,
5659            summaries,
5660        })),
5661        2 => Ok(Some(DecisionProof::Phase2 {
5662            cluster_id,
5663            slot,
5664            epoch,
5665            config_id,
5666            config_digest,
5667            step,
5668            proposal,
5669            summaries,
5670        })),
5671        _ => Err(Error::Decode("invalid decision proof tag".into())),
5672    }
5673}
5674
5675fn encode_optional_ballot(out: &mut Vec<u8>, ballot: Option<&Ballot>) -> Result<()> {
5676    match ballot {
5677        Some(ballot) => {
5678            out.push(1);
5679            encode_ballot(out, ballot)
5680        }
5681        None => {
5682            out.push(0);
5683            Ok(())
5684        }
5685    }
5686}
5687
5688fn decode_optional_ballot(bytes: &[u8], cursor: &mut usize) -> Result<Option<Ballot>> {
5689    match read_u8(bytes, cursor)? {
5690        0 => Ok(None),
5691        1 => Ok(Some(decode_ballot(bytes, cursor)?)),
5692        _ => Err(Error::Decode("invalid ballot flag".into())),
5693    }
5694}
5695
5696fn encode_ballot(out: &mut Vec<u8>, ballot: &Ballot) -> Result<()> {
5697    put_u64(out, ballot.round);
5698    put_u128(out, ballot.priority);
5699    put_bytes(out, ballot.proposer_id.as_bytes())
5700}
5701
5702fn decode_ballot(bytes: &[u8], cursor: &mut usize) -> Result<Ballot> {
5703    Ok(Ballot {
5704        round: read_u64(bytes, cursor)?,
5705        priority: read_u128(bytes, cursor)?,
5706        proposer_id: String::from_utf8(read_bytes(bytes, cursor)?)
5707            .map_err(|err| Error::Decode(err.to_string()))?,
5708    })
5709}
5710
5711fn encode_value(out: &mut Vec<u8>, value: &AcceptedValue) {
5712    out.extend_from_slice(value.command_hash.as_bytes());
5713    out.extend_from_slice(value.prev_hash.as_bytes());
5714    out.extend_from_slice(value.entry_hash.as_bytes());
5715}
5716
5717fn decode_value(bytes: &[u8], cursor: &mut usize) -> Result<AcceptedValue> {
5718    Ok(AcceptedValue {
5719        command_hash: read_hash(bytes, cursor)?,
5720        prev_hash: read_hash(bytes, cursor)?,
5721        entry_hash: read_hash(bytes, cursor)?,
5722    })
5723}
5724
5725fn encode_certificate(out: &mut Vec<u8>, decision: &DecisionCertificate) -> Result<()> {
5726    put_u64(out, decision.slot);
5727    put_u64(out, decision.epoch);
5728    put_u64(out, decision.config_id);
5729    out.extend_from_slice(decision.config_digest.as_bytes());
5730    encode_ballot(out, &decision.ballot)?;
5731    encode_value(out, &decision.value);
5732    put_u16(
5733        out,
5734        u16::try_from(decision.recorder_ids.len())
5735            .map_err(|_| Error::Decode("too many certificate recorders".into()))?,
5736    );
5737    for recorder_id in &decision.recorder_ids {
5738        put_bytes(out, recorder_id.as_bytes())?;
5739    }
5740    Ok(())
5741}
5742
5743fn decode_certificate(bytes: &[u8], cursor: &mut usize) -> Result<DecisionCertificate> {
5744    let slot = read_u64(bytes, cursor)?;
5745    let epoch = read_u64(bytes, cursor)?;
5746    let config_id = read_u64(bytes, cursor)?;
5747    let config_digest = read_hash(bytes, cursor)?;
5748    let ballot = decode_ballot(bytes, cursor)?;
5749    let value = decode_value(bytes, cursor)?;
5750    let recorder_count = read_u16(bytes, cursor)? as usize;
5751    let recorder_ids = (0..recorder_count)
5752        .map(|_| {
5753            String::from_utf8(read_bytes(bytes, cursor)?)
5754                .map_err(|err| Error::Decode(err.to_string()))
5755        })
5756        .collect::<Result<Vec<_>>>()?;
5757    Ok(DecisionCertificate {
5758        slot,
5759        epoch,
5760        config_id,
5761        config_digest,
5762        ballot,
5763        value,
5764        recorder_ids,
5765    })
5766}
5767
5768fn encode_stored_command(command: &StoredCommand) -> Vec<u8> {
5769    let mut out = Vec::new();
5770    out.extend_from_slice(b"QCMD");
5771    put_u16(&mut out, 1);
5772    out.push(command.entry_type.as_u8());
5773    put_u64(&mut out, command.payload.len() as u64);
5774    out.extend_from_slice(&command.payload);
5775    let digest = LogHash::digest(&[&out]);
5776    out.extend_from_slice(digest.as_bytes());
5777    out
5778}
5779
5780fn decode_stored_command(bytes: &[u8]) -> Result<StoredCommand> {
5781    if bytes.len() < 4 + 2 + 1 + 8 + 32 || &bytes[..4] != b"QCMD" {
5782        return Err(Error::Decode("invalid command magic".into()));
5783    }
5784    let (body, digest) = bytes.split_at(bytes.len() - 32);
5785    if LogHash::digest(&[body]).as_bytes() != digest {
5786        return Err(Error::Decode("command digest mismatch".into()));
5787    }
5788    let mut cursor = 4;
5789    if read_u16(body, &mut cursor)? != 1 {
5790        return Err(Error::Decode("unsupported command version".into()));
5791    }
5792    let entry_type = EntryType::from_u8(read_u8(body, &mut cursor)?)
5793        .ok_or_else(|| Error::Decode("invalid command entry type".into()))?;
5794    let payload_len = usize::try_from(read_u64(body, &mut cursor)?)
5795        .map_err(|_| Error::Decode("command payload too large".into()))?;
5796    let end = cursor
5797        .checked_add(payload_len)
5798        .ok_or_else(|| Error::Decode("command payload length overflow".into()))?;
5799    let payload = body
5800        .get(cursor..end)
5801        .ok_or_else(|| Error::Decode("short command payload".into()))?
5802        .to_vec();
5803    cursor = end;
5804    if cursor != body.len() {
5805        return Err(Error::Decode("trailing command bytes".into()));
5806    }
5807    Ok(StoredCommand::new(entry_type, payload))
5808}
5809
5810static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
5811
5812#[cfg(test)]
5813std::thread_local! {
5814    static SYNC_COUNTS: std::cell::Cell<(usize, usize)> = const {
5815        std::cell::Cell::new((0, 0))
5816    };
5817    static LAST_FILE_SYNC_KIND: std::cell::Cell<Option<FileSyncKind>> = const {
5818        std::cell::Cell::new(None)
5819    };
5820    static COMMAND_FILE_READS: std::cell::Cell<usize> = const {
5821        std::cell::Cell::new(0)
5822    };
5823}
5824
5825#[cfg(test)]
5826#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5827enum FileSyncKind {
5828    #[cfg(target_os = "linux")]
5829    Data,
5830    All,
5831}
5832
5833#[cfg(target_os = "linux")]
5834fn sync_wal_append(file: &fs::File) -> io::Result<()> {
5835    // Linux fdatasync (File::sync_data) also flushes metadata required for later data retrieval,
5836    // including the file size extended by this append, so a complete WAL frame remains replayable.
5837    file.sync_data()?;
5838    #[cfg(test)]
5839    record_file_sync_kind(FileSyncKind::Data);
5840    Ok(())
5841}
5842
5843#[cfg(not(target_os = "linux"))]
5844fn sync_wal_append(file: &fs::File) -> io::Result<()> {
5845    // Keep non-Linux durability conservative because sync_data semantics vary by platform.
5846    file.sync_all()?;
5847    #[cfg(test)]
5848    record_file_sync_kind(FileSyncKind::All);
5849    Ok(())
5850}
5851
5852fn sync_wal_metadata(file: &fs::File) -> io::Result<()> {
5853    file.sync_all()?;
5854    #[cfg(test)]
5855    record_file_sync_kind(FileSyncKind::All);
5856    Ok(())
5857}
5858
5859#[cfg(test)]
5860fn record_file_sync() {
5861    SYNC_COUNTS.with(|counts| {
5862        let (file, directory) = counts.get();
5863        counts.set((file + 1, directory));
5864    });
5865}
5866
5867#[cfg(test)]
5868fn record_file_sync_kind(kind: FileSyncKind) {
5869    record_file_sync();
5870    LAST_FILE_SYNC_KIND.with(|last| last.set(Some(kind)));
5871}
5872
5873#[cfg(test)]
5874fn record_directory_sync() {
5875    SYNC_COUNTS.with(|counts| {
5876        let (file, directory) = counts.get();
5877        counts.set((file, directory + 1));
5878    });
5879}
5880
5881#[cfg(test)]
5882fn reset_sync_counts() {
5883    SYNC_COUNTS.with(|counts| counts.set((0, 0)));
5884    LAST_FILE_SYNC_KIND.with(|last| last.set(None));
5885}
5886
5887#[cfg(test)]
5888fn sync_counts() -> (usize, usize) {
5889    SYNC_COUNTS.with(std::cell::Cell::get)
5890}
5891
5892#[cfg(test)]
5893fn last_file_sync_kind() -> Option<FileSyncKind> {
5894    LAST_FILE_SYNC_KIND.with(std::cell::Cell::get)
5895}
5896
5897#[cfg(test)]
5898fn reset_command_file_reads() {
5899    COMMAND_FILE_READS.with(|reads| reads.set(0));
5900}
5901
5902#[cfg(test)]
5903fn command_file_reads() -> usize {
5904    COMMAND_FILE_READS.with(std::cell::Cell::get)
5905}
5906
5907const CONFIGURATION_HEAD_INTENT_MAGIC: &[u8; 4] = b"QCHI";
5908
5909fn encode_configuration_head_intent(configuration: &[u8], head: &[u8]) -> Vec<u8> {
5910    let mut encoded = Vec::new();
5911    encoded.extend_from_slice(CONFIGURATION_HEAD_INTENT_MAGIC);
5912    put_u16(&mut encoded, 1);
5913    put_u64(&mut encoded, configuration.len() as u64);
5914    encoded.extend_from_slice(configuration);
5915    put_u64(&mut encoded, head.len() as u64);
5916    encoded.extend_from_slice(head);
5917    encoded
5918}
5919
5920fn decode_configuration_head_intent(bytes: &[u8]) -> Result<(&[u8], &[u8])> {
5921    let mut cursor = 0;
5922    if bytes.get(..CONFIGURATION_HEAD_INTENT_MAGIC.len()) != Some(CONFIGURATION_HEAD_INTENT_MAGIC) {
5923        return Err(Error::Decode(
5924            "invalid configuration-head intent magic".into(),
5925        ));
5926    }
5927    cursor += CONFIGURATION_HEAD_INTENT_MAGIC.len();
5928    if read_u16(bytes, &mut cursor)? != 1 {
5929        return Err(Error::Decode(
5930            "unsupported configuration-head intent version".into(),
5931        ));
5932    }
5933    let configuration_len = usize::try_from(read_u64(bytes, &mut cursor)?)
5934        .map_err(|_| Error::Decode("configuration-head intent length overflow".into()))?;
5935    let configuration_end = cursor
5936        .checked_add(configuration_len)
5937        .ok_or_else(|| Error::Decode("configuration-head intent length overflow".into()))?;
5938    let configuration = bytes
5939        .get(cursor..configuration_end)
5940        .ok_or_else(|| Error::Decode("truncated configuration-head intent".into()))?;
5941    cursor = configuration_end;
5942    let head_len = usize::try_from(read_u64(bytes, &mut cursor)?)
5943        .map_err(|_| Error::Decode("configuration-head intent length overflow".into()))?;
5944    let head_end = cursor
5945        .checked_add(head_len)
5946        .ok_or_else(|| Error::Decode("configuration-head intent length overflow".into()))?;
5947    let head = bytes
5948        .get(cursor..head_end)
5949        .ok_or_else(|| Error::Decode("truncated configuration-head intent".into()))?;
5950    if head_end != bytes.len() {
5951        return Err(Error::Decode(
5952            "trailing configuration-head intent bytes".into(),
5953        ));
5954    }
5955    Ok((configuration, head))
5956}
5957
5958fn encode_wal_frame(
5959    generation: u64,
5960    sequence: u64,
5961    prev_digest: LogHash,
5962    slot_state: &RecorderSlotState,
5963    configuration: &ConfigurationState,
5964    head: &RecordedHeadProvenance,
5965    command: Option<(LogHash, &StoredCommand)>,
5966) -> Result<(Vec<u8>, LogHash, Vec<u8>)> {
5967    let slot_bytes = encode_recorder_state(slot_state)?;
5968    let configuration_bytes = encode_configuration_state(configuration)?;
5969    let mut payload = Vec::new();
5970    put_u64(&mut payload, generation);
5971    put_u64(&mut payload, sequence);
5972    payload.extend_from_slice(prev_digest.as_bytes());
5973    put_u64(&mut payload, slot_state.slot());
5974    put_blob(&mut payload, &slot_bytes)?;
5975    put_blob(&mut payload, &configuration_bytes)?;
5976    encode_head_provenance(&mut payload, head);
5977    match command {
5978        Some((hash, command)) => {
5979            payload.push(1);
5980            payload.extend_from_slice(hash.as_bytes());
5981            put_blob(&mut payload, &encode_stored_command(command))?;
5982        }
5983        None => payload.push(0),
5984    }
5985    let total_len = 4usize
5986        .checked_add(2)
5987        .and_then(|len| len.checked_add(8))
5988        .and_then(|len| len.checked_add(payload.len()))
5989        .and_then(|len| len.checked_add(32))
5990        .ok_or_else(|| Error::Io("recorder WAL frame length overflow".into()))?;
5991    let mut frame = Vec::with_capacity(total_len);
5992    frame.extend_from_slice(RECORDER_WAL_MAGIC);
5993    put_u16(&mut frame, RECORDER_WAL_VERSION);
5994    put_u64(&mut frame, total_len as u64);
5995    frame.extend_from_slice(&payload);
5996    let digest = LogHash::digest(&[&frame]);
5997    frame.extend_from_slice(digest.as_bytes());
5998    Ok((frame, digest, slot_bytes))
5999}
6000
6001fn decode_wal_frame(bytes: &[u8], offset: usize) -> Result<Option<(WalFrame, usize)>> {
6002    const PREFIX_LEN: usize = 4 + 2 + 8;
6003    let remaining = bytes
6004        .get(offset..)
6005        .ok_or_else(|| Error::Decode("recorder WAL offset overflow".into()))?;
6006    if remaining.len() < PREFIX_LEN {
6007        return Ok(None);
6008    }
6009    if remaining.get(..4) != Some(RECORDER_WAL_MAGIC) {
6010        return Err(Error::Decode("recorder WAL frame magic mismatch".into()));
6011    }
6012    let mut cursor = offset + 4;
6013    if read_u16(bytes, &mut cursor)? != RECORDER_WAL_VERSION {
6014        return Err(Error::Decode("recorder WAL frame version mismatch".into()));
6015    }
6016    let frame_len = usize::try_from(read_u64(bytes, &mut cursor)?)
6017        .map_err(|_| Error::Decode("recorder WAL frame length overflow".into()))?;
6018    if frame_len < PREFIX_LEN + 32 {
6019        return Err(Error::Decode("recorder WAL frame length is invalid".into()));
6020    }
6021    let end = offset
6022        .checked_add(frame_len)
6023        .ok_or_else(|| Error::Decode("recorder WAL frame length overflow".into()))?;
6024    if end > bytes.len() {
6025        return Ok(None);
6026    }
6027    let digest_offset = end - 32;
6028    let digest = read_hash(bytes, &mut { digest_offset })?;
6029    let expected = LogHash::digest(&[&bytes[offset..digest_offset]]);
6030    if digest != expected {
6031        return Err(Error::Decode("recorder WAL frame checksum mismatch".into()));
6032    }
6033    let generation = read_u64(bytes, &mut cursor)?;
6034    let sequence = read_u64(bytes, &mut cursor)?;
6035    let prev_digest = read_hash(bytes, &mut cursor)?;
6036    let slot = read_u64(bytes, &mut cursor)?;
6037    let slot_bytes = read_blob(bytes, &mut cursor)?;
6038    let configuration_bytes = read_blob(bytes, &mut cursor)?;
6039    let head = decode_head_provenance(bytes, &mut cursor)?;
6040    let command = match read_u8(bytes, &mut cursor)? {
6041        0 => None,
6042        1 => {
6043            let hash = read_hash(bytes, &mut cursor)?;
6044            let command = decode_stored_command(&read_blob(bytes, &mut cursor)?)?;
6045            Some((hash, command))
6046        }
6047        _ => return Err(Error::Decode("recorder WAL command flag is invalid".into())),
6048    };
6049    if cursor != digest_offset {
6050        return Err(Error::Decode(
6051            "recorder WAL frame has trailing bytes".into(),
6052        ));
6053    }
6054    Ok(Some((
6055        WalFrame {
6056            generation,
6057            sequence,
6058            prev_digest,
6059            digest,
6060            slot,
6061            slot_bytes,
6062            configuration_bytes,
6063            head,
6064            command,
6065        },
6066        end,
6067    )))
6068}
6069
6070fn encode_head_provenance(out: &mut Vec<u8>, head: &RecordedHeadProvenance) {
6071    match head {
6072        RecordedHeadProvenance::Empty => out.push(0),
6073        RecordedHeadProvenance::SlotBacked { slot } => {
6074            out.push(1);
6075            put_u64(out, *slot);
6076        }
6077        RecordedHeadProvenance::CheckpointBacked {
6078            stop_slot,
6079            prefix_hash,
6080            recovered_tip,
6081            recovered_hash,
6082        } => {
6083            out.push(2);
6084            put_u64(out, *stop_slot);
6085            out.extend_from_slice(prefix_hash.as_bytes());
6086            put_u64(out, *recovered_tip);
6087            out.extend_from_slice(recovered_hash.as_bytes());
6088        }
6089    }
6090}
6091
6092fn decode_head_provenance(bytes: &[u8], cursor: &mut usize) -> Result<RecordedHeadProvenance> {
6093    match read_u8(bytes, cursor)? {
6094        0 => Ok(RecordedHeadProvenance::Empty),
6095        1 => Ok(RecordedHeadProvenance::SlotBacked {
6096            slot: read_u64(bytes, cursor)?,
6097        }),
6098        2 => Ok(RecordedHeadProvenance::CheckpointBacked {
6099            stop_slot: read_u64(bytes, cursor)?,
6100            prefix_hash: read_hash(bytes, cursor)?,
6101            recovered_tip: read_u64(bytes, cursor)?,
6102            recovered_hash: read_hash(bytes, cursor)?,
6103        }),
6104        _ => Err(Error::Decode(
6105            "recorder WAL head provenance is invalid".into(),
6106        )),
6107    }
6108}
6109
6110fn upsert_wal_command(
6111    commands: &mut HashMap<LogHash, StoredCommand>,
6112    hash: LogHash,
6113    command: &StoredCommand,
6114) -> Result<()> {
6115    match commands.entry(hash) {
6116        hash_map::Entry::Occupied(existing) if existing.get() != command => {
6117            Err(Error::CommandHashMismatch)
6118        }
6119        hash_map::Entry::Occupied(_) => Ok(()),
6120        hash_map::Entry::Vacant(vacant) => {
6121            vacant.insert(command.clone());
6122            Ok(())
6123        }
6124    }
6125}
6126
6127fn encode_recorded_head(
6128    cluster_id: &str,
6129    epoch: Epoch,
6130    configuration: &ConfigurationState,
6131    provenance: &RecordedHeadProvenance,
6132    recent_slots: &[DurableSlotSnapshot],
6133    wal_checkpoint: WalCheckpoint,
6134) -> Result<Vec<u8>> {
6135    if recent_slots.len() > 2 {
6136        return Err(Error::Io(
6137            "recorder manifest can retain at most two slot snapshots".into(),
6138        ));
6139    }
6140    let mut encoded = Vec::new();
6141    encoded.extend_from_slice(RECORDED_HEAD_MAGIC);
6142    put_u16(&mut encoded, RECORDED_HEAD_VERSION);
6143    put_bytes(&mut encoded, cluster_id.as_bytes())?;
6144    put_u64(&mut encoded, epoch);
6145    put_u64(&mut encoded, configuration.config_id);
6146    encoded.extend_from_slice(configuration.config_digest.as_bytes());
6147    match provenance {
6148        RecordedHeadProvenance::Empty => encoded.push(0),
6149        RecordedHeadProvenance::SlotBacked { slot } => {
6150            encoded.push(1);
6151            put_u64(&mut encoded, *slot);
6152        }
6153        RecordedHeadProvenance::CheckpointBacked {
6154            stop_slot,
6155            prefix_hash,
6156            recovered_tip,
6157            recovered_hash,
6158        } => {
6159            encoded.push(2);
6160            put_u64(&mut encoded, *stop_slot);
6161            encoded.extend_from_slice(prefix_hash.as_bytes());
6162            put_u64(&mut encoded, *recovered_tip);
6163            encoded.extend_from_slice(recovered_hash.as_bytes());
6164        }
6165    }
6166    put_u64(&mut encoded, wal_checkpoint.generation);
6167    put_u64(&mut encoded, wal_checkpoint.through_sequence);
6168    put_u16(&mut encoded, recent_slots.len() as u16);
6169    for snapshot in recent_slots {
6170        put_u64(&mut encoded, snapshot.slot);
6171        put_bytes(&mut encoded, &snapshot.bytes)?;
6172    }
6173    let digest = LogHash::digest(&[&encoded]);
6174    encoded.extend_from_slice(digest.as_bytes());
6175    Ok(encoded)
6176}
6177
6178fn decode_recorded_head(
6179    bytes: &[u8],
6180    expected_cluster_id: &str,
6181    expected_epoch: Epoch,
6182    configuration: &ConfigurationState,
6183) -> Result<(
6184    RecordedHeadProvenance,
6185    Vec<DurableSlotSnapshot>,
6186    WalCheckpoint,
6187)> {
6188    if bytes.get(..RECORDED_HEAD_MAGIC.len()) != Some(RECORDED_HEAD_MAGIC) {
6189        return Err(Error::Decode("invalid recorder durable head magic".into()));
6190    }
6191    let mut version_cursor = RECORDED_HEAD_MAGIC.len();
6192    if read_u16(bytes, &mut version_cursor)? != RECORDED_HEAD_VERSION {
6193        return Err(Error::MigrationRequired {
6194            format: "recorder durable head",
6195            version: RECORDED_HEAD_VERSION,
6196        });
6197    }
6198    if bytes.len() < 32 {
6199        return Err(Error::Decode("truncated recorder durable head".into()));
6200    }
6201    let (body, digest) = bytes.split_at(bytes.len() - 32);
6202    if LogHash::digest(&[body]).as_bytes() != digest {
6203        return Err(Error::Decode(
6204            "recorder durable head digest mismatch".into(),
6205        ));
6206    }
6207    let mut cursor = 0;
6208    cursor += RECORDED_HEAD_MAGIC.len();
6209    let _version = read_u16(body, &mut cursor)?;
6210    let cluster_id = String::from_utf8(read_bytes(body, &mut cursor)?)
6211        .map_err(|error| Error::Decode(error.to_string()))?;
6212    let epoch = read_u64(body, &mut cursor)?;
6213    let config_id = read_u64(body, &mut cursor)?;
6214    let config_digest = read_hash(body, &mut cursor)?;
6215    if cluster_id != expected_cluster_id
6216        || epoch != expected_epoch
6217        || config_id != configuration.config_id
6218        || config_digest != configuration.config_digest
6219    {
6220        return Err(Error::Decode(
6221            "recorder durable head identity mismatch".into(),
6222        ));
6223    }
6224    let provenance = match read_u8(body, &mut cursor)? {
6225        0 => RecordedHeadProvenance::Empty,
6226        1 => RecordedHeadProvenance::SlotBacked {
6227            slot: read_u64(body, &mut cursor)?,
6228        },
6229        2 => RecordedHeadProvenance::CheckpointBacked {
6230            stop_slot: read_u64(body, &mut cursor)?,
6231            prefix_hash: read_hash(body, &mut cursor)?,
6232            recovered_tip: read_u64(body, &mut cursor)?,
6233            recovered_hash: read_hash(body, &mut cursor)?,
6234        },
6235        value => {
6236            return Err(Error::Decode(format!(
6237                "invalid recorder durable head provenance {value}"
6238            )));
6239        }
6240    };
6241    let wal_checkpoint = WalCheckpoint {
6242        generation: read_u64(body, &mut cursor)?,
6243        through_sequence: read_u64(body, &mut cursor)?,
6244    };
6245    if wal_checkpoint.generation == 0 {
6246        return Err(Error::Decode(
6247            "recorder durable head has zero WAL generation".into(),
6248        ));
6249    }
6250    let recent_count = usize::from(read_u16(body, &mut cursor)?);
6251    if recent_count > 2 {
6252        return Err(Error::Decode(
6253            "recorder manifest contains too many slot snapshots".into(),
6254        ));
6255    }
6256    let mut recent_slots = Vec::with_capacity(recent_count);
6257    for _ in 0..recent_count {
6258        let slot = read_u64(body, &mut cursor)?;
6259        if recent_slots
6260            .iter()
6261            .any(|snapshot: &DurableSlotSnapshot| snapshot.slot == slot)
6262        {
6263            return Err(Error::Decode(
6264                "recorder manifest contains duplicate slot snapshots".into(),
6265            ));
6266        }
6267        recent_slots.push(DurableSlotSnapshot {
6268            slot,
6269            bytes: read_bytes(body, &mut cursor)?,
6270        });
6271    }
6272    if cursor != body.len() {
6273        return Err(Error::Decode("trailing recorder durable head bytes".into()));
6274    }
6275    Ok((provenance, recent_slots, wal_checkpoint))
6276}
6277
6278fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
6279    atomic_replace(path, bytes)?;
6280    let parent = path
6281        .parent()
6282        .ok_or_else(|| Error::Io("atomic write path has no parent".into()))?;
6283    fs::File::open(parent)
6284        .and_then(|directory| directory.sync_all())
6285        .map_err(|err| Error::Io(err.to_string()))?;
6286    #[cfg(test)]
6287    record_directory_sync();
6288    Ok(())
6289}
6290
6291fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<()> {
6292    let parent = path
6293        .parent()
6294        .ok_or_else(|| Error::Io("atomic write path has no parent".into()))?;
6295    fs::create_dir_all(parent).map_err(|err| Error::Io(err.to_string()))?;
6296    let file_name = path
6297        .file_name()
6298        .ok_or_else(|| Error::Io("atomic write path has no file name".into()))?
6299        .to_string_lossy();
6300    let (temp_path, mut file) = loop {
6301        let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
6302        let temp_path = parent.join(format!(".{file_name}.tmp-{}-{counter}", std::process::id()));
6303        match fs::OpenOptions::new()
6304            .write(true)
6305            .create_new(true)
6306            .open(&temp_path)
6307        {
6308            Ok(file) => break (temp_path, file),
6309            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
6310            Err(err) => return Err(Error::Io(err.to_string())),
6311        }
6312    };
6313    let result = (|| -> io::Result<()> {
6314        file.write_all(bytes)?;
6315        file.sync_all()?;
6316        #[cfg(test)]
6317        record_file_sync();
6318        drop(file);
6319        fs::rename(&temp_path, path)?;
6320        Ok(())
6321    })();
6322    if let Err(err) = result {
6323        let _ = fs::remove_file(&temp_path);
6324        return Err(Error::Io(err.to_string()));
6325    }
6326    Ok(())
6327}
6328
6329fn put_u16(out: &mut Vec<u8>, value: u16) {
6330    out.extend_from_slice(&value.to_be_bytes());
6331}
6332
6333fn put_u64(out: &mut Vec<u8>, value: u64) {
6334    out.extend_from_slice(&value.to_be_bytes());
6335}
6336
6337fn put_u128(out: &mut Vec<u8>, value: u128) {
6338    out.extend_from_slice(&value.to_be_bytes());
6339}
6340
6341fn put_bytes(out: &mut Vec<u8>, value: &[u8]) -> Result<()> {
6342    let len = u16::try_from(value.len())
6343        .map_err(|_| Error::Decode("recorder string is too long".into()))?;
6344    put_u16(out, len);
6345    out.extend_from_slice(value);
6346    Ok(())
6347}
6348
6349fn put_blob(out: &mut Vec<u8>, value: &[u8]) -> Result<()> {
6350    let len = u64::try_from(value.len())
6351        .map_err(|_| Error::Decode("recorder blob is too long".into()))?;
6352    put_u64(out, len);
6353    out.extend_from_slice(value);
6354    Ok(())
6355}
6356
6357fn read_u8(bytes: &[u8], cursor: &mut usize) -> Result<u8> {
6358    let value = *bytes
6359        .get(*cursor)
6360        .ok_or_else(|| Error::Decode("short recorder u8".into()))?;
6361    *cursor += 1;
6362    Ok(value)
6363}
6364
6365fn read_u16(bytes: &[u8], cursor: &mut usize) -> Result<u16> {
6366    let end = cursor
6367        .checked_add(2)
6368        .ok_or_else(|| Error::Decode("recorder cursor overflow".into()))?;
6369    let slice = bytes
6370        .get(*cursor..end)
6371        .ok_or_else(|| Error::Decode("short recorder u16".into()))?;
6372    *cursor = end;
6373    Ok(u16::from_be_bytes(slice.try_into().expect("u16 slice")))
6374}
6375
6376fn read_u64(bytes: &[u8], cursor: &mut usize) -> Result<u64> {
6377    let end = cursor
6378        .checked_add(8)
6379        .ok_or_else(|| Error::Decode("recorder cursor overflow".into()))?;
6380    let slice = bytes
6381        .get(*cursor..end)
6382        .ok_or_else(|| Error::Decode("short recorder u64".into()))?;
6383    *cursor = end;
6384    Ok(u64::from_be_bytes(slice.try_into().expect("u64 slice")))
6385}
6386
6387fn read_u128(bytes: &[u8], cursor: &mut usize) -> Result<u128> {
6388    let end = cursor
6389        .checked_add(16)
6390        .ok_or_else(|| Error::Decode("recorder cursor overflow".into()))?;
6391    let slice = bytes
6392        .get(*cursor..end)
6393        .ok_or_else(|| Error::Decode("short recorder u128".into()))?;
6394    *cursor = end;
6395    Ok(u128::from_be_bytes(slice.try_into().expect("u128 slice")))
6396}
6397
6398fn read_hash(bytes: &[u8], cursor: &mut usize) -> Result<LogHash> {
6399    let end = cursor
6400        .checked_add(32)
6401        .ok_or_else(|| Error::Decode("recorder cursor overflow".into()))?;
6402    let slice = bytes
6403        .get(*cursor..end)
6404        .ok_or_else(|| Error::Decode("short recorder hash".into()))?;
6405    *cursor = end;
6406    let mut out = [0; 32];
6407    out.copy_from_slice(slice);
6408    Ok(LogHash::from_bytes(out))
6409}
6410
6411fn read_bytes(bytes: &[u8], cursor: &mut usize) -> Result<Vec<u8>> {
6412    let len = read_u16(bytes, cursor)? as usize;
6413    let end = cursor
6414        .checked_add(len)
6415        .ok_or_else(|| Error::Decode("recorder cursor overflow".into()))?;
6416    let slice = bytes
6417        .get(*cursor..end)
6418        .ok_or_else(|| Error::Decode("short recorder bytes".into()))?;
6419    *cursor = end;
6420    Ok(slice.to_vec())
6421}
6422
6423fn read_blob(bytes: &[u8], cursor: &mut usize) -> Result<Vec<u8>> {
6424    let len = usize::try_from(read_u64(bytes, cursor)?)
6425        .map_err(|_| Error::Decode("recorder blob length overflow".into()))?;
6426    let end = cursor
6427        .checked_add(len)
6428        .ok_or_else(|| Error::Decode("recorder blob length overflow".into()))?;
6429    let value = bytes
6430        .get(*cursor..end)
6431        .ok_or_else(|| Error::Decode("short recorder blob".into()))?
6432        .to_vec();
6433    *cursor = end;
6434    Ok(value)
6435}
6436
6437#[derive(Debug)]
6438pub struct SingleNodeConsensus {
6439    cluster_id: String,
6440    epoch: Epoch,
6441    config_id: ConfigId,
6442    state: Mutex<SingleNodeState>,
6443}
6444
6445#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6446struct SingleNodeState {
6447    next_index: LogIndex,
6448    last_hash: LogHash,
6449}
6450
6451impl SingleNodeConsensus {
6452    pub fn new(cluster_id: impl Into<String>, epoch: Epoch, config_id: ConfigId) -> Self {
6453        Self {
6454            cluster_id: cluster_id.into(),
6455            epoch,
6456            config_id,
6457            state: Mutex::new(SingleNodeState {
6458                next_index: 1,
6459                last_hash: LogHash::ZERO,
6460            }),
6461        }
6462    }
6463}
6464
6465impl Consensus for SingleNodeConsensus {
6466    fn propose(&self, command: Command) -> Result<LogEntry> {
6467        let mut state = self.state.lock().map_err(|_| Error::ProposeFailed)?;
6468        let command = stored_command(command)?;
6469        let prev_hash = state.last_hash;
6470        let hash = LogEntry::calculate_hash(
6471            &self.cluster_id,
6472            state.next_index,
6473            self.epoch,
6474            self.config_id,
6475            command.entry_type,
6476            prev_hash,
6477            &command.payload,
6478        );
6479        let entry = LogEntry {
6480            cluster_id: self.cluster_id.clone(),
6481            epoch: self.epoch,
6482            config_id: self.config_id,
6483            index: state.next_index,
6484            entry_type: command.entry_type,
6485            payload: command.payload,
6486            prev_hash,
6487            hash,
6488        };
6489        state.next_index += 1;
6490        state.last_hash = hash;
6491        Ok(entry)
6492    }
6493}
6494
6495#[cfg(test)]
6496mod tests {
6497    use super::{
6498        command_file_reads, decode_wal_frame, encode_stored_command, encode_wal_frame,
6499        last_file_sync_kind, reset_command_file_reads, reset_sync_counts, sync_counts,
6500        sync_wal_append, sync_wal_metadata, upsert_wal_command, AcceptedValue,
6501        CertifiedDecisionInspection, ConfigChange, ConfigurationState, Consensus, ControlDispatch,
6502        ControlJob, DecisionInspection, DecisionProof, DriveOutcome, Error, FileSyncKind,
6503        Membership, PrioritySource, Proposal, ProposalPriority, ProposerProgress,
6504        ReadFenceObservation, ReadFenceRequest, ReadFenceSlotState, RecordRequest, RecordSummary,
6505        RecordedHeadProvenance, RecorderFileStore, RecorderRequest, RecorderRpc, RecorderSlotState,
6506        RecorderSummary, RejectReason, SealFaultPoint, SingleNodeConsensus, ThreeNodeConsensus,
6507    };
6508    use proptest::prelude::*;
6509    use rhiza_core::{Command, CommandKind, EntryType, LogHash, StoredCommand};
6510    use std::{
6511        collections::{BTreeSet, HashMap, HashSet},
6512        sync::{
6513            atomic::{AtomicUsize, Ordering},
6514            mpsc, Arc, Condvar, Mutex,
6515        },
6516        thread,
6517        time::{Duration, Instant},
6518    };
6519
6520    fn record_requests(consensus: &ThreeNodeConsensus, slot: u64) -> Vec<RecordRequest> {
6521        let proposal = Proposal::new(
6522            ProposalPriority::MAX,
6523            "n1",
6524            slot,
6525            AcceptedValue {
6526                command_hash: LogHash::ZERO,
6527                prev_hash: LogHash::ZERO,
6528                entry_hash: LogHash::ZERO,
6529            },
6530        );
6531        consensus
6532            .membership()
6533            .members()
6534            .iter()
6535            .map(|_| RecordRequest {
6536                cluster_id: "cluster".into(),
6537                epoch: 1,
6538                config_id: 1,
6539                config_digest: consensus.membership().digest(),
6540                slot,
6541                step: 4,
6542                proposal: proposal.clone(),
6543                command: None,
6544            })
6545            .collect()
6546    }
6547
6548    fn record_summary(recorder_id: &str, request: RecordRequest) -> RecordSummary {
6549        RecordSummary {
6550            recorder_id: recorder_id.into(),
6551            slot: request.slot,
6552            config_id: request.config_id,
6553            config_digest: request.config_digest,
6554            step: request.step,
6555            first_current: Some(request.proposal),
6556            aggregate_prior: None,
6557            decided: None,
6558        }
6559    }
6560
6561    struct ThreadRecordingRecorder {
6562        recorder_id: &'static str,
6563        threads: Arc<Mutex<HashSet<thread::ThreadId>>>,
6564    }
6565
6566    impl RecorderRpc for ThreadRecordingRecorder {
6567        fn record(&self, request: RecordRequest) -> super::Result<RecordSummary> {
6568            self.threads.lock().unwrap().insert(thread::current().id());
6569            Ok(record_summary(self.recorder_id, request))
6570        }
6571    }
6572
6573    struct ThreadRecordingControlRecorder {
6574        threads: Arc<Mutex<HashSet<thread::ThreadId>>>,
6575    }
6576
6577    impl RecorderRpc for ThreadRecordingControlRecorder {
6578        fn inspect_decision_proof(
6579            &self,
6580            _slot: u64,
6581        ) -> super::Result<Option<super::DecisionProof>> {
6582            self.threads.lock().unwrap().insert(thread::current().id());
6583            Ok(None)
6584        }
6585    }
6586
6587    struct BlockingControlRecorder {
6588        recorder_id: &'static str,
6589        started: mpsc::SyncSender<u64>,
6590        release_first: Mutex<mpsc::Receiver<()>>,
6591    }
6592
6593    struct BlockingInspectionReadFenceRecorder {
6594        recorder_id: &'static str,
6595        block_inspection: bool,
6596        started: mpsc::SyncSender<&'static str>,
6597        release: Arc<(Mutex<bool>, Condvar)>,
6598    }
6599
6600    struct BlockingCommandStoreRecorder {
6601        started: mpsc::SyncSender<()>,
6602        release: Arc<(Mutex<bool>, Condvar)>,
6603    }
6604
6605    struct SuccessfulCommandStoreRecorder;
6606
6607    impl RecorderRpc for SuccessfulCommandStoreRecorder {
6608        fn store_command_for(
6609            &self,
6610            _cluster_id: String,
6611            _epoch: u64,
6612            _config_id: u64,
6613            _config_digest: LogHash,
6614            _command_hash: LogHash,
6615            _command: StoredCommand,
6616        ) -> super::Result<()> {
6617            Ok(())
6618        }
6619    }
6620
6621    struct FailingCommandStoreRecorder;
6622
6623    impl RecorderRpc for FailingCommandStoreRecorder {
6624        fn store_command_for(
6625            &self,
6626            _cluster_id: String,
6627            _epoch: u64,
6628            _config_id: u64,
6629            _config_digest: LogHash,
6630            _command_hash: LogHash,
6631            _command: StoredCommand,
6632        ) -> super::Result<()> {
6633            Err(Error::ProposeFailed)
6634        }
6635    }
6636
6637    impl RecorderRpc for BlockingCommandStoreRecorder {
6638        fn store_command_for(
6639            &self,
6640            _cluster_id: String,
6641            _epoch: u64,
6642            _config_id: u64,
6643            _config_digest: LogHash,
6644            _command_hash: LogHash,
6645            _command: StoredCommand,
6646        ) -> super::Result<()> {
6647            self.started.send(()).unwrap();
6648            let (released, condition) = &*self.release;
6649            let mut released = released.lock().unwrap();
6650            while !*released {
6651                released = condition.wait(released).unwrap();
6652            }
6653            Ok(())
6654        }
6655    }
6656
6657    impl RecorderRpc for BlockingControlRecorder {
6658        fn record(&self, request: RecordRequest) -> super::Result<RecordSummary> {
6659            Ok(record_summary(self.recorder_id, request))
6660        }
6661
6662        fn inspect_decision_proof(&self, slot: u64) -> super::Result<Option<super::DecisionProof>> {
6663            self.started.send(slot).unwrap();
6664            if slot == 1 {
6665                self.release_first.lock().unwrap().recv().unwrap();
6666            }
6667            Ok(None)
6668        }
6669    }
6670
6671    impl RecorderRpc for BlockingInspectionReadFenceRecorder {
6672        fn inspect_record_summary(&self, _slot: u64) -> super::Result<Option<RecordSummary>> {
6673            if self.block_inspection {
6674                self.started.send(self.recorder_id).unwrap();
6675                let (released, condition) = &*self.release;
6676                let mut released = released.lock().unwrap();
6677                while !*released {
6678                    released = condition.wait(released).unwrap();
6679                }
6680            }
6681            Ok(None)
6682        }
6683
6684        fn supports_context_read_fence(&self) -> bool {
6685            true
6686        }
6687
6688        fn observe_read_fence(
6689            &self,
6690            request: ReadFenceRequest,
6691        ) -> super::Result<ReadFenceObservation> {
6692            Ok(ReadFenceObservation {
6693                recorder_id: self.recorder_id.into(),
6694                cluster_id: request.cluster_id,
6695                epoch: request.epoch,
6696                config_id: request.config_id,
6697                config_digest: request.config_digest,
6698                slot: request.slot,
6699                max_head: None,
6700                slot_state: ReadFenceSlotState::Empty,
6701            })
6702        }
6703    }
6704
6705    struct BlockingRecorder {
6706        recorder_id: &'static str,
6707        started: mpsc::SyncSender<u64>,
6708        release_first: Mutex<mpsc::Receiver<()>>,
6709    }
6710
6711    impl RecorderRpc for BlockingRecorder {
6712        fn record(&self, request: RecordRequest) -> super::Result<RecordSummary> {
6713            self.started.send(request.slot).unwrap();
6714            if request.slot == 1 {
6715                self.release_first.lock().unwrap().recv().unwrap();
6716            }
6717            Ok(record_summary(self.recorder_id, request))
6718        }
6719    }
6720
6721    struct SlotRecorder {
6722        recorder_id: &'static str,
6723        reject_slot: Option<u64>,
6724        observed: Option<mpsc::SyncSender<u64>>,
6725    }
6726
6727    impl RecorderRpc for SlotRecorder {
6728        fn record(&self, request: RecordRequest) -> super::Result<RecordSummary> {
6729            if let Some(observed) = &self.observed {
6730                observed.send(request.slot).unwrap();
6731            }
6732            if self.reject_slot == Some(request.slot) {
6733                Err(Error::Rejected(RejectReason::InvalidRequest))
6734            } else {
6735                Ok(record_summary(self.recorder_id, request))
6736            }
6737        }
6738    }
6739
6740    struct RejectFromSlotRecorder {
6741        recorder_id: &'static str,
6742        reject_from: u64,
6743    }
6744
6745    impl RecorderRpc for RejectFromSlotRecorder {
6746        fn record(&self, request: RecordRequest) -> super::Result<RecordSummary> {
6747            if request.slot >= self.reject_from {
6748                Err(Error::Rejected(RejectReason::InvalidRequest))
6749            } else {
6750                Ok(record_summary(self.recorder_id, request))
6751            }
6752        }
6753    }
6754
6755    struct PanickingRecorder;
6756
6757    impl RecorderRpc for PanickingRecorder {
6758        fn record(&self, _request: RecordRequest) -> super::Result<RecordSummary> {
6759            panic!("injected recorder panic")
6760        }
6761    }
6762
6763    struct FailingFromSlotRecorder {
6764        recorder_id: &'static str,
6765        fail_from: u64,
6766    }
6767
6768    impl RecorderRpc for FailingFromSlotRecorder {
6769        fn record(&self, request: RecordRequest) -> super::Result<RecordSummary> {
6770            if request.slot >= self.fail_from {
6771                Err(Error::ProposeFailed)
6772            } else {
6773                Ok(record_summary(self.recorder_id, request))
6774            }
6775        }
6776    }
6777
6778    struct AlwaysIoRecorder;
6779
6780    impl RecorderRpc for AlwaysIoRecorder {
6781        fn record(&self, _request: RecordRequest) -> super::Result<RecordSummary> {
6782            Err(Error::Io("injected recorder unavailable".into()))
6783        }
6784    }
6785
6786    struct MissingCommandRecorder {
6787        observed: mpsc::SyncSender<()>,
6788    }
6789
6790    impl RecorderRpc for MissingCommandRecorder {
6791        fn fetch_command_for(
6792            &self,
6793            _cluster_id: String,
6794            _epoch: u64,
6795            _config_id: u64,
6796            _config_digest: LogHash,
6797            _command_hash: LogHash,
6798        ) -> super::Result<Option<StoredCommand>> {
6799            self.observed.send(()).unwrap();
6800            Ok(None)
6801        }
6802    }
6803
6804    struct BlockingCommandRecorder {
6805        started: mpsc::SyncSender<()>,
6806        release: Mutex<mpsc::Receiver<()>>,
6807        command: StoredCommand,
6808    }
6809
6810    struct AvailableCommandRecorder {
6811        command: StoredCommand,
6812    }
6813
6814    impl RecorderRpc for AvailableCommandRecorder {
6815        fn fetch_command_for(
6816            &self,
6817            _cluster_id: String,
6818            _epoch: u64,
6819            _config_id: u64,
6820            _config_digest: LogHash,
6821            _command_hash: LogHash,
6822        ) -> super::Result<Option<StoredCommand>> {
6823            Ok(Some(self.command.clone()))
6824        }
6825    }
6826
6827    struct FailingCommandFetchRecorder;
6828
6829    impl RecorderRpc for FailingCommandFetchRecorder {
6830        fn fetch_command_for(
6831            &self,
6832            _cluster_id: String,
6833            _epoch: u64,
6834            _config_id: u64,
6835            _config_digest: LogHash,
6836            _command_hash: LogHash,
6837        ) -> super::Result<Option<StoredCommand>> {
6838            Err(Error::ProposeFailed)
6839        }
6840    }
6841
6842    impl RecorderRpc for BlockingCommandRecorder {
6843        fn fetch_command_for(
6844            &self,
6845            _cluster_id: String,
6846            _epoch: u64,
6847            _config_id: u64,
6848            _config_digest: LogHash,
6849            _command_hash: LogHash,
6850        ) -> super::Result<Option<StoredCommand>> {
6851            self.started.send(()).unwrap();
6852            let _ = self.release.lock().unwrap().recv();
6853            Ok(Some(self.command.clone()))
6854        }
6855    }
6856
6857    struct FailingPrioritySource;
6858
6859    impl PrioritySource for FailingPrioritySource {
6860        fn sample(
6861            &self,
6862            _slot: u64,
6863            _round: u64,
6864            _proposer_id: &str,
6865            _recorder_id: &str,
6866        ) -> super::Result<ProposalPriority> {
6867            Err(Error::RandomnessUnavailable("unexpected sample".into()))
6868        }
6869    }
6870
6871    #[derive(Default)]
6872    struct CountingPrioritySource {
6873        samples: AtomicUsize,
6874    }
6875
6876    impl PrioritySource for CountingPrioritySource {
6877        fn sample(
6878            &self,
6879            _slot: u64,
6880            _round: u64,
6881            _proposer_id: &str,
6882            _recorder_id: &str,
6883        ) -> super::Result<ProposalPriority> {
6884            let sample = self.samples.fetch_add(1, Ordering::Relaxed) + 1;
6885            Ok(ProposalPriority::from_u64(sample as u64))
6886        }
6887    }
6888
6889    struct CatchUpRecorder {
6890        recorder_id: &'static str,
6891        step: u64,
6892    }
6893
6894    impl RecorderRpc for CatchUpRecorder {
6895        fn record(&self, request: RecordRequest) -> super::Result<RecordSummary> {
6896            let mut summary = record_summary(self.recorder_id, request);
6897            summary.step = self.step;
6898            Ok(summary)
6899        }
6900    }
6901
6902    #[test]
6903    fn single_node_consensus_commits_contiguous_hash_chain() {
6904        let consensus = SingleNodeConsensus::new("cluster-a", 1, 1);
6905        let first = consensus
6906            .propose(Command::new(CommandKind::Deterministic, b"first".to_vec()))
6907            .unwrap();
6908        let second = consensus
6909            .propose(Command::new(CommandKind::Deterministic, b"second".to_vec()))
6910            .unwrap();
6911
6912        assert_eq!(first.index, 1);
6913        assert_eq!(first.prev_hash, LogHash::ZERO);
6914        assert_eq!(first.hash, first.recompute_hash());
6915        assert_eq!(second.index, 2);
6916        assert_eq!(second.prev_hash, first.hash);
6917        assert_eq!(second.hash, second.recompute_hash());
6918    }
6919
6920    #[test]
6921    fn cached_phase_zero_priorities_do_not_resample_randomness() {
6922        let consensus = ThreeNodeConsensus::from_recorders_with_ids(
6923            "cluster",
6924            "writer",
6925            1,
6926            1,
6927            ["n1", "n2", "n3"]
6928                .into_iter()
6929                .map(|recorder_id| {
6930                    (
6931                        recorder_id.into(),
6932                        Box::new(SlotRecorder {
6933                            recorder_id,
6934                            reject_slot: None,
6935                            observed: None,
6936                        }) as Box<dyn RecorderRpc>,
6937                    )
6938                })
6939                .collect(),
6940        )
6941        .unwrap()
6942        .with_priority_source(Arc::new(FailingPrioritySource));
6943        let command = StoredCommand::new(EntryType::Command, b"cached-priority".to_vec());
6944        let value = AcceptedValue::from_command("cluster", 1, 1, 1, LogHash::ZERO, &command);
6945        let mut progress =
6946            ProposerProgress::new(1, Proposal::new(ProposalPriority::MAX, "writer", 1, value))
6947                .with_command(command);
6948        progress.step = 0;
6949        for recorder_id in ["n1", "n2", "n3"] {
6950            progress
6951                .phase_zero_priorities
6952                .insert((0, recorder_id.into()), ProposalPriority::from_u64(1));
6953        }
6954
6955        let DriveOutcome::Progress(progress) = consensus.drive(progress).unwrap() else {
6956            panic!("phase zero quorum should advance progress");
6957        };
6958        assert!(progress.phase_zero_priorities.is_empty());
6959    }
6960
6961    #[test]
6962    fn phase_zero_priorities_are_stable_only_for_pending_retries_in_the_current_round() {
6963        let source = Arc::new(CountingPrioritySource::default());
6964        let consensus = ThreeNodeConsensus::from_recorders_with_ids(
6965            "cluster",
6966            "writer",
6967            1,
6968            1,
6969            vec![
6970                (
6971                    "n1".into(),
6972                    Box::new(SlotRecorder {
6973                        recorder_id: "n1",
6974                        reject_slot: None,
6975                        observed: None,
6976                    }) as Box<dyn RecorderRpc>,
6977                ),
6978                ("n2".into(), Box::new(AlwaysIoRecorder)),
6979                ("n3".into(), Box::new(AlwaysIoRecorder)),
6980            ],
6981        )
6982        .unwrap()
6983        .with_priority_source(source.clone());
6984        let command = StoredCommand::new(EntryType::Command, b"bounded-priorities".to_vec());
6985        let value = AcceptedValue::from_command("cluster", 1, 1, 1, LogHash::ZERO, &command);
6986        let mut progress =
6987            ProposerProgress::new(1, Proposal::new(ProposalPriority::MAX, "writer", 1, value))
6988                .with_command(command);
6989        progress.step = 0;
6990
6991        let DriveOutcome::Pending(mut progress) = consensus.drive(progress).unwrap() else {
6992            panic!("one recorder reply should leave progress pending");
6993        };
6994        assert_eq!(progress.phase_zero_priorities.len(), 3);
6995        assert_eq!(source.samples.load(Ordering::Relaxed), 3);
6996
6997        let DriveOutcome::Pending(retry) = consensus.drive(progress.clone()).unwrap() else {
6998            panic!("same-round retry should remain pending");
6999        };
7000        assert_eq!(retry.phase_zero_priorities, progress.phase_zero_priorities);
7001        assert_eq!(source.samples.load(Ordering::Relaxed), 3);
7002
7003        for round in 1..=64 {
7004            progress.step = round * 4;
7005            let DriveOutcome::Pending(next) = consensus.drive(progress).unwrap() else {
7006                panic!("one recorder reply should leave progress pending");
7007            };
7008            assert_eq!(next.phase_zero_priorities.len(), 3);
7009            assert!(next
7010                .phase_zero_priorities
7011                .keys()
7012                .all(|(cached_round, _)| *cached_round == round));
7013            progress = next;
7014        }
7015        assert_eq!(source.samples.load(Ordering::Relaxed), 3 * 65);
7016    }
7017
7018    #[test]
7019    fn phase_zero_priorities_are_cleared_when_progress_catches_up() {
7020        let consensus = ThreeNodeConsensus::from_recorders_with_ids(
7021            "cluster",
7022            "writer",
7023            1,
7024            1,
7025            ["n1", "n2", "n3"]
7026                .into_iter()
7027                .map(|recorder_id| {
7028                    (
7029                        recorder_id.into(),
7030                        Box::new(CatchUpRecorder {
7031                            recorder_id,
7032                            step: 8,
7033                        }) as Box<dyn RecorderRpc>,
7034                    )
7035                })
7036                .collect(),
7037        )
7038        .unwrap()
7039        .with_priority_source(Arc::new(FailingPrioritySource));
7040        let command = StoredCommand::new(EntryType::Command, b"catch-up".to_vec());
7041        let value = AcceptedValue::from_command("cluster", 1, 1, 1, LogHash::ZERO, &command);
7042        let mut progress =
7043            ProposerProgress::new(1, Proposal::new(ProposalPriority::MAX, "writer", 1, value))
7044                .with_command(command);
7045        progress.step = 0;
7046        for recorder_id in ["n1", "n2", "n3"] {
7047            progress
7048                .phase_zero_priorities
7049                .insert((0, recorder_id.into()), ProposalPriority::from_u64(1));
7050        }
7051
7052        let DriveOutcome::Progress(progress) = consensus.drive(progress).unwrap() else {
7053            panic!("higher recorder steps should catch progress up");
7054        };
7055        assert_eq!(progress.step, 8);
7056        assert!(progress.phase_zero_priorities.is_empty());
7057    }
7058
7059    #[test]
7060    fn wal_command_cache_rejects_same_hash_with_different_payload() {
7061        let hash = LogHash::digest(&[b"forced-cache-key"]);
7062        let first = StoredCommand::new(EntryType::Command, b"first".to_vec());
7063        let second = StoredCommand::new(EntryType::Command, b"second".to_vec());
7064        let mut commands = HashMap::new();
7065
7066        upsert_wal_command(&mut commands, hash, &first).unwrap();
7067        upsert_wal_command(&mut commands, hash, &first).unwrap();
7068        assert_eq!(
7069            upsert_wal_command(&mut commands, hash, &second),
7070            Err(Error::CommandHashMismatch)
7071        );
7072        assert_eq!(commands.len(), 1);
7073    }
7074
7075    #[test]
7076    fn normal_record_uses_one_file_sync_and_no_directory_barrier() {
7077        let root = tempfile::tempdir().unwrap();
7078        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7079        let store = RecorderFileStore::new_with_membership(
7080            root.path(),
7081            "n1",
7082            "cluster",
7083            1,
7084            1,
7085            membership.clone(),
7086        )
7087        .unwrap();
7088        let command = StoredCommand::new(EntryType::Command, b"barrier-count".to_vec());
7089        store
7090            .store_command(command.hash(), command.clone())
7091            .unwrap();
7092        let value = AcceptedValue::from_command("cluster", 8, 1, 1, LogHash::ZERO, &command);
7093        reset_sync_counts();
7094
7095        store
7096            .record_proposal(RecordRequest {
7097                cluster_id: "cluster".into(),
7098                epoch: 1,
7099                config_id: 1,
7100                config_digest: membership.digest(),
7101                slot: 8,
7102                step: 4,
7103                proposal: Proposal::new(ProposalPriority::MAX, "writer", 1, value),
7104                command: None,
7105            })
7106            .unwrap();
7107
7108        assert_eq!(sync_counts(), (1, 0));
7109        assert!(!root.path().join("slot-head.intent").exists());
7110
7111        let inline = StoredCommand::new(EntryType::Command, b"inline-command".to_vec());
7112        let inline_value = AcceptedValue::from_command("cluster", 9, 1, 1, LogHash::ZERO, &inline);
7113        reset_sync_counts();
7114        store
7115            .record_proposal(RecordRequest {
7116                cluster_id: "cluster".into(),
7117                epoch: 1,
7118                config_id: 1,
7119                config_digest: membership.digest(),
7120                slot: 9,
7121                step: 4,
7122                proposal: Proposal::new(ProposalPriority::MAX, "writer", 2, inline_value),
7123                command: Some(inline),
7124            })
7125            .unwrap();
7126
7127        assert_eq!(sync_counts(), (1, 0));
7128    }
7129
7130    #[test]
7131    fn wal_durable_command_store_adds_no_sync_and_survives_proof_checkpoint_recovery() {
7132        let root = tempfile::tempdir().unwrap();
7133        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7134        let command = StoredCommand::new(EntryType::Command, b"proof-worker-command".to_vec());
7135        let command_hash = command.hash();
7136        let value = AcceptedValue::from_command("cluster", 8, 1, 1, LogHash::ZERO, &command);
7137        let proposal = Proposal::new(ProposalPriority::MAX, "writer", 1, value);
7138        let proof = DecisionProof::FastPath {
7139            cluster_id: "cluster".into(),
7140            slot: 8,
7141            epoch: 1,
7142            config_id: 1,
7143            config_digest: membership.digest(),
7144            proposal: proposal.clone(),
7145            summaries: ["n1", "n2"]
7146                .into_iter()
7147                .map(|recorder_id| RecorderSummary {
7148                    recorder_id: recorder_id.into(),
7149                    slot: 8,
7150                    step: 4,
7151                    first_current: Some(proposal.clone()),
7152                    aggregate_prior: None,
7153                })
7154                .collect(),
7155        };
7156        let store = RecorderFileStore::new_with_membership(
7157            root.path(),
7158            "n1",
7159            "cluster",
7160            1,
7161            1,
7162            membership.clone(),
7163        )
7164        .unwrap();
7165        store
7166            .record_proposal(RecordRequest {
7167                cluster_id: "cluster".into(),
7168                epoch: 1,
7169                config_id: 1,
7170                config_digest: membership.digest(),
7171                slot: 8,
7172                step: 4,
7173                proposal,
7174                command: Some(command.clone()),
7175            })
7176            .unwrap();
7177
7178        reset_sync_counts();
7179        RecorderRpc::store_command_for(
7180            &store,
7181            "cluster".into(),
7182            1,
7183            1,
7184            membership.digest(),
7185            command_hash,
7186            command.clone(),
7187        )
7188        .unwrap();
7189        assert_eq!(sync_counts(), (0, 0));
7190        assert!(!store.command_path(command_hash).exists());
7191
7192        store
7193            .install_decision_proof_record(proof.clone(), &membership)
7194            .unwrap();
7195        drop(store);
7196
7197        let reopened = RecorderFileStore::new_with_membership(
7198            root.path(),
7199            "n1",
7200            "cluster",
7201            1,
7202            1,
7203            membership.clone(),
7204        )
7205        .unwrap();
7206        assert_eq!(
7207            reopened.fetch_command(command_hash).unwrap(),
7208            Some(command.clone())
7209        );
7210        assert_eq!(reopened.load(8).unwrap().decision_proof(), Some(&proof));
7211        reopened.checkpoint_wal_unlocked().unwrap();
7212        assert!(reopened.command_path(command_hash).exists());
7213        drop(reopened);
7214
7215        let checkpointed =
7216            RecorderFileStore::new_with_membership(root.path(), "n1", "cluster", 1, 1, membership)
7217                .unwrap();
7218        assert_eq!(
7219            checkpointed.fetch_command(command_hash).unwrap(),
7220            Some(command)
7221        );
7222        assert_eq!(checkpointed.load(8).unwrap().decision_proof(), Some(&proof));
7223    }
7224
7225    #[test]
7226    fn duplicate_command_file_store_keeps_the_root_directory_barrier() {
7227        let root = tempfile::tempdir().unwrap();
7228        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7229        let store =
7230            RecorderFileStore::new_with_membership(root.path(), "n1", "cluster", 1, 1, membership)
7231                .unwrap();
7232        let command = StoredCommand::new(EntryType::Command, b"durable-command".to_vec());
7233        store
7234            .store_command(command.hash(), command.clone())
7235            .unwrap();
7236        reset_sync_counts();
7237
7238        store.store_command(command.hash(), command).unwrap();
7239
7240        assert_eq!(sync_counts(), (0, 1));
7241    }
7242
7243    #[test]
7244    fn direct_store_command_rejects_a_claimed_hash_without_creating_a_file() {
7245        let root = tempfile::tempdir().unwrap();
7246        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7247        let store = RecorderFileStore::new_with_membership(
7248            root.path(),
7249            "n1",
7250            "cluster",
7251            1,
7252            1,
7253            membership.clone(),
7254        )
7255        .unwrap();
7256        let command = StoredCommand::new(EntryType::Command, b"mismatched-hash".to_vec());
7257        let claimed_hash = LogHash::digest(&[b"claimed-hash"]);
7258
7259        assert_eq!(
7260            store.apply(RecorderRequest::StoreCommand {
7261                cluster_id: "cluster".into(),
7262                epoch: 1,
7263                config_id: 1,
7264                config_digest: membership.digest(),
7265                command_hash: claimed_hash,
7266                command,
7267            }),
7268            Err(Error::CommandHashMismatch)
7269        );
7270        assert!(!store.command_path(claimed_hash).exists());
7271    }
7272
7273    #[test]
7274    fn wal_command_store_rejects_conflicting_bytes_without_syncing() {
7275        let root = tempfile::tempdir().unwrap();
7276        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7277        let store =
7278            RecorderFileStore::new_with_membership(root.path(), "n1", "cluster", 1, 1, membership)
7279                .unwrap();
7280        let command = StoredCommand::new(EntryType::Command, b"expected".to_vec());
7281        let conflicting = StoredCommand::new(EntryType::Command, b"conflicting".to_vec());
7282        store
7283            .wal
7284            .lock()
7285            .unwrap()
7286            .commands
7287            .insert(command.hash(), conflicting);
7288        reset_sync_counts();
7289
7290        assert_eq!(
7291            store.store_command(command.hash(), command.clone()),
7292            Err(Error::CommandHashMismatch)
7293        );
7294        assert_eq!(sync_counts(), (0, 0));
7295        assert!(!store.command_path(command.hash()).exists());
7296    }
7297
7298    #[test]
7299    fn prestored_command_resolution_revalidates_the_durable_file() {
7300        let root = tempfile::tempdir().unwrap();
7301        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7302        let store = RecorderFileStore::new_with_membership(
7303            root.path(),
7304            "n1",
7305            "cluster",
7306            1,
7307            1,
7308            membership.clone(),
7309        )
7310        .unwrap();
7311        let command = StoredCommand::new(EntryType::Command, b"pre-stored-command".to_vec());
7312        store
7313            .store_command(command.hash(), command.clone())
7314            .unwrap();
7315        reset_command_file_reads();
7316
7317        for slot in [8, 9] {
7318            let value = AcceptedValue::from_command("cluster", slot, 1, 1, LogHash::ZERO, &command);
7319            store
7320                .record_proposal(RecordRequest {
7321                    cluster_id: "cluster".into(),
7322                    epoch: 1,
7323                    config_id: 1,
7324                    config_digest: membership.digest(),
7325                    slot,
7326                    step: 4,
7327                    proposal: Proposal::new(ProposalPriority::MAX, "writer", slot, value),
7328                    command: None,
7329                })
7330                .unwrap();
7331        }
7332
7333        assert_eq!(command_file_reads(), 2);
7334    }
7335
7336    #[test]
7337    fn commandless_record_rejects_a_prestored_command_replaced_while_open() {
7338        let root = tempfile::tempdir().unwrap();
7339        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7340        let store = RecorderFileStore::new_with_membership(
7341            root.path(),
7342            "n1",
7343            "cluster",
7344            1,
7345            1,
7346            membership.clone(),
7347        )
7348        .unwrap();
7349        let command = StoredCommand::new(EntryType::Command, b"pre-stored".to_vec());
7350        let replacement = StoredCommand::new(EntryType::Command, b"replacement".to_vec());
7351        let command_hash = command.hash();
7352        store.store_command(command_hash, command.clone()).unwrap();
7353        std::fs::write(
7354            store.command_path(command_hash),
7355            encode_stored_command(&replacement),
7356        )
7357        .unwrap();
7358        let value = AcceptedValue::from_command("cluster", 8, 1, 1, LogHash::ZERO, &command);
7359
7360        assert_eq!(
7361            store.record_proposal(RecordRequest {
7362                cluster_id: "cluster".into(),
7363                epoch: 1,
7364                config_id: 1,
7365                config_digest: membership.digest(),
7366                slot: 8,
7367                step: 4,
7368                proposal: Proposal::new(ProposalPriority::MAX, "writer", 1, value.clone()),
7369                command: None,
7370            }),
7371            Err(Error::CommandHashMismatch)
7372        );
7373        assert_eq!(store.load(8).unwrap().isr.step(), 0);
7374        drop(store);
7375
7376        let reopened = RecorderFileStore::new_with_membership(
7377            root.path(),
7378            "n1",
7379            "cluster",
7380            1,
7381            1,
7382            membership.clone(),
7383        )
7384        .unwrap();
7385        assert_eq!(
7386            reopened.record_proposal(RecordRequest {
7387                cluster_id: "cluster".into(),
7388                epoch: 1,
7389                config_id: 1,
7390                config_digest: membership.digest(),
7391                slot: 8,
7392                step: 4,
7393                proposal: Proposal::new(ProposalPriority::MAX, "writer", 1, value),
7394                command: None,
7395            }),
7396            Err(Error::CommandHashMismatch)
7397        );
7398        assert_eq!(reopened.load(8).unwrap().isr.step(), 0);
7399    }
7400
7401    #[test]
7402    fn duplicate_store_rejects_a_prestored_command_replaced_while_open() {
7403        let root = tempfile::tempdir().unwrap();
7404        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7405        let store =
7406            RecorderFileStore::new_with_membership(root.path(), "n1", "cluster", 1, 1, membership)
7407                .unwrap();
7408        let command = StoredCommand::new(EntryType::Command, b"pre-stored".to_vec());
7409        let replacement = StoredCommand::new(EntryType::Command, b"replacement".to_vec());
7410        let command_hash = command.hash();
7411        store.store_command(command_hash, command.clone()).unwrap();
7412        std::fs::write(
7413            store.command_path(command_hash),
7414            encode_stored_command(&replacement),
7415        )
7416        .unwrap();
7417
7418        assert_eq!(
7419            store.store_command(command_hash, command),
7420            Err(Error::CommandHashMismatch)
7421        );
7422    }
7423
7424    #[test]
7425    fn record_rejects_mismatched_inline_command_independent_of_prestored_command_state() {
7426        for prestored in [false, true] {
7427            let root = tempfile::tempdir().unwrap();
7428            let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7429            let store = RecorderFileStore::new_with_membership(
7430                root.path(),
7431                "n1",
7432                "cluster",
7433                1,
7434                1,
7435                membership.clone(),
7436            )
7437            .unwrap();
7438            let expected = StoredCommand::new(EntryType::Command, b"expected".to_vec());
7439            if prestored {
7440                store
7441                    .store_command(expected.hash(), expected.clone())
7442                    .unwrap();
7443            }
7444            let value = AcceptedValue::from_command("cluster", 8, 1, 1, LogHash::ZERO, &expected);
7445            let mismatched = StoredCommand::new(EntryType::Command, b"mismatched".to_vec());
7446
7447            assert_eq!(
7448                store.record_proposal(RecordRequest {
7449                    cluster_id: "cluster".into(),
7450                    epoch: 1,
7451                    config_id: 1,
7452                    config_digest: membership.digest(),
7453                    slot: 8,
7454                    step: 4,
7455                    proposal: Proposal::new(ProposalPriority::MAX, "writer", 1, value),
7456                    command: Some(mismatched),
7457                }),
7458                Err(Error::Rejected(RejectReason::InvalidValue)),
7459                "prestored={prestored}"
7460            );
7461        }
7462    }
7463
7464    #[test]
7465    fn inline_record_uses_the_bound_command_without_reading_the_durable_command_file() {
7466        let root = tempfile::tempdir().unwrap();
7467        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7468        let command = StoredCommand::new(EntryType::Command, b"inline-hot-path".to_vec());
7469        let command_hash = command.hash();
7470        let store = RecorderFileStore::new_with_membership(
7471            root.path(),
7472            "n1",
7473            "cluster",
7474            1,
7475            1,
7476            membership.clone(),
7477        )
7478        .unwrap();
7479        std::fs::write(store.command_path(command_hash), b"corrupt cache entry").unwrap();
7480        let value = AcceptedValue::from_command("cluster", 8, 1, 1, LogHash::ZERO, &command);
7481        reset_command_file_reads();
7482
7483        store
7484            .record_proposal(RecordRequest {
7485                cluster_id: "cluster".into(),
7486                epoch: 1,
7487                config_id: 1,
7488                config_digest: membership.digest(),
7489                slot: 8,
7490                step: 4,
7491                proposal: Proposal::new(ProposalPriority::MAX, "writer", 1, value),
7492                command: Some(command.clone()),
7493            })
7494            .unwrap();
7495
7496        assert_eq!(command_file_reads(), 0);
7497        assert_eq!(
7498            store.fetch_command(command_hash).unwrap(),
7499            Some(command.clone())
7500        );
7501        drop(store);
7502
7503        reset_command_file_reads();
7504        let reopened =
7505            RecorderFileStore::new_with_membership(root.path(), "n1", "cluster", 1, 1, membership)
7506                .unwrap();
7507        assert_eq!(command_file_reads(), 0);
7508        assert_eq!(reopened.fetch_command(command_hash).unwrap(), Some(command));
7509    }
7510
7511    #[test]
7512    fn record_rejects_malformed_config_change_piggyback_as_invalid_value_before_parsing() {
7513        let root = tempfile::tempdir().unwrap();
7514        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7515        let store = RecorderFileStore::new_with_membership(
7516            root.path(),
7517            "n1",
7518            "cluster",
7519            1,
7520            1,
7521            membership.clone(),
7522        )
7523        .unwrap();
7524        let expected = StoredCommand::new(EntryType::Command, b"expected".to_vec());
7525        let value = AcceptedValue::from_command("cluster", 8, 1, 1, LogHash::ZERO, &expected);
7526        let malformed = StoredCommand::new(EntryType::ConfigChange, b"malformed".to_vec());
7527
7528        assert_eq!(
7529            store.record_proposal(RecordRequest {
7530                cluster_id: "cluster".into(),
7531                epoch: 1,
7532                config_id: 1,
7533                config_digest: membership.digest(),
7534                slot: 8,
7535                step: 4,
7536                proposal: Proposal::new(ProposalPriority::MAX, "writer", 1, value),
7537                command: Some(malformed),
7538            }),
7539            Err(Error::Rejected(RejectReason::InvalidValue))
7540        );
7541    }
7542
7543    #[test]
7544    fn wal_append_uses_the_platform_safe_file_sync() {
7545        let file = tempfile::tempfile().unwrap();
7546        reset_sync_counts();
7547
7548        sync_wal_append(&file).unwrap();
7549
7550        #[cfg(target_os = "linux")]
7551        assert_eq!(last_file_sync_kind(), Some(FileSyncKind::Data));
7552        #[cfg(not(target_os = "linux"))]
7553        assert_eq!(last_file_sync_kind(), Some(FileSyncKind::All));
7554    }
7555
7556    #[test]
7557    fn wal_metadata_changes_keep_full_file_sync() {
7558        let file = tempfile::tempfile().unwrap();
7559        reset_sync_counts();
7560
7561        sync_wal_metadata(&file).unwrap();
7562
7563        assert_eq!(last_file_sync_kind(), Some(FileSyncKind::All));
7564    }
7565
7566    #[test]
7567    fn wal_replays_acknowledged_records_after_reopen() {
7568        let root = tempfile::tempdir().unwrap();
7569        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7570        let command = StoredCommand::new(EntryType::Command, b"wal-reopen".to_vec());
7571        let value = AcceptedValue::from_command("cluster", 8, 1, 1, LogHash::ZERO, &command);
7572        {
7573            let store = RecorderFileStore::new_with_membership(
7574                root.path(),
7575                "n1",
7576                "cluster",
7577                1,
7578                1,
7579                membership.clone(),
7580            )
7581            .unwrap();
7582            store
7583                .record_proposal(RecordRequest {
7584                    cluster_id: "cluster".into(),
7585                    epoch: 1,
7586                    config_id: 1,
7587                    config_digest: membership.digest(),
7588                    slot: 8,
7589                    step: 4,
7590                    proposal: Proposal::new(ProposalPriority::MAX, "writer", 1, value),
7591                    command: Some(command.clone()),
7592                })
7593                .unwrap();
7594        }
7595
7596        let reopened =
7597            RecorderFileStore::new_with_membership(root.path(), "n1", "cluster", 1, 1, membership)
7598                .unwrap();
7599        assert_eq!(
7600            reopened.fetch_command(command.hash()).unwrap(),
7601            Some(command)
7602        );
7603        assert_eq!(reopened.load(8).unwrap().isr.step(), 4);
7604    }
7605
7606    #[test]
7607    fn wal_sync_fault_never_acknowledges_before_the_durable_frame_is_replayable() {
7608        let root = tempfile::tempdir().unwrap();
7609        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7610        let command = StoredCommand::new(EntryType::Command, b"wal-sync-fault".to_vec());
7611        let value = AcceptedValue::from_command("cluster", 8, 1, 1, LogHash::ZERO, &command);
7612        {
7613            let store = RecorderFileStore::new_with_membership(
7614                root.path(),
7615                "n1",
7616                "cluster",
7617                1,
7618                1,
7619                membership.clone(),
7620            )
7621            .unwrap();
7622            store
7623                .set_seal_fault(Some(SealFaultPoint::AfterWalSync))
7624                .unwrap();
7625            assert!(matches!(
7626                store.record_proposal(RecordRequest {
7627                    cluster_id: "cluster".into(),
7628                    epoch: 1,
7629                    config_id: 1,
7630                    config_digest: membership.digest(),
7631                    slot: 8,
7632                    step: 4,
7633                    proposal: Proposal::new(ProposalPriority::MAX, "writer", 1, value),
7634                    command: Some(command.clone()),
7635                }),
7636                Err(Error::Io(message)) if message.contains("AfterWalSync")
7637            ));
7638            assert_eq!(
7639                store
7640                    .configuration_state()
7641                    .unwrap()
7642                    .max_accepted_or_decided_slot(),
7643                None
7644            );
7645            assert_eq!(store.load(8).unwrap().isr.step(), 0);
7646        }
7647
7648        let reopened =
7649            RecorderFileStore::new_with_membership(root.path(), "n1", "cluster", 1, 1, membership)
7650                .unwrap();
7651        assert_eq!(
7652            reopened.fetch_command(command.hash()).unwrap(),
7653            Some(command)
7654        );
7655        assert_eq!(reopened.load(8).unwrap().isr.step(), 4);
7656    }
7657
7658    #[test]
7659    fn wal_ignores_a_torn_final_frame_but_replays_the_committed_prefix() {
7660        let root = tempfile::tempdir().unwrap();
7661        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7662        let first = StoredCommand::new(EntryType::Command, b"wal-first".to_vec());
7663        let second = StoredCommand::new(EntryType::Command, b"wal-second".to_vec());
7664        {
7665            let store = RecorderFileStore::new_with_membership(
7666                root.path(),
7667                "n1",
7668                "cluster",
7669                1,
7670                1,
7671                membership.clone(),
7672            )
7673            .unwrap();
7674            for (slot, command) in [(8, first.clone()), (9, second.clone())] {
7675                let value =
7676                    AcceptedValue::from_command("cluster", slot, 1, 1, LogHash::ZERO, &command);
7677                store
7678                    .record_proposal(RecordRequest {
7679                        cluster_id: "cluster".into(),
7680                        epoch: 1,
7681                        config_id: 1,
7682                        config_digest: membership.digest(),
7683                        slot,
7684                        step: 4,
7685                        proposal: Proposal::new(ProposalPriority::MAX, "writer", slot, value),
7686                        command: Some(command),
7687                    })
7688                    .unwrap();
7689            }
7690        }
7691        let wal = root.path().join("recorder.wal");
7692        let len = std::fs::metadata(&wal).unwrap().len();
7693        std::fs::OpenOptions::new()
7694            .write(true)
7695            .open(&wal)
7696            .unwrap()
7697            .set_len(len - 7)
7698            .unwrap();
7699
7700        let reopened =
7701            RecorderFileStore::new_with_membership(root.path(), "n1", "cluster", 1, 1, membership)
7702                .unwrap();
7703        assert_eq!(reopened.fetch_command(first.hash()).unwrap(), Some(first));
7704        assert_eq!(reopened.load(8).unwrap().isr.step(), 4);
7705        assert_eq!(reopened.fetch_command(second.hash()).unwrap(), None);
7706        assert_eq!(reopened.load(9).unwrap().isr.step(), 0);
7707    }
7708
7709    #[test]
7710    fn wal_fails_closed_on_interior_corruption() {
7711        let root = tempfile::tempdir().unwrap();
7712        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7713        {
7714            let store = RecorderFileStore::new_with_membership(
7715                root.path(),
7716                "n1",
7717                "cluster",
7718                1,
7719                1,
7720                membership.clone(),
7721            )
7722            .unwrap();
7723            for slot in [8, 9] {
7724                let command = StoredCommand::new(
7725                    EntryType::Command,
7726                    format!("wal-corrupt-{slot}").into_bytes(),
7727                );
7728                let value =
7729                    AcceptedValue::from_command("cluster", slot, 1, 1, LogHash::ZERO, &command);
7730                store
7731                    .record_proposal(RecordRequest {
7732                        cluster_id: "cluster".into(),
7733                        epoch: 1,
7734                        config_id: 1,
7735                        config_digest: membership.digest(),
7736                        slot,
7737                        step: 4,
7738                        proposal: Proposal::new(ProposalPriority::MAX, "writer", slot, value),
7739                        command: Some(command),
7740                    })
7741                    .unwrap();
7742            }
7743        }
7744        let wal = root.path().join("recorder.wal");
7745        let mut bytes = std::fs::read(&wal).unwrap();
7746        bytes[100] ^= 0x80;
7747        std::fs::write(&wal, bytes).unwrap();
7748
7749        assert!(matches!(
7750            RecorderFileStore::new_with_membership(
7751                root.path(),
7752                "n1",
7753                "cluster",
7754                1,
7755                1,
7756                membership,
7757            ),
7758            Err(Error::Decode(message)) if message.contains("WAL")
7759        ));
7760    }
7761
7762    #[test]
7763    fn wal_fails_closed_on_full_length_final_frame_corruption() {
7764        let root = tempfile::tempdir().unwrap();
7765        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7766        {
7767            let store = RecorderFileStore::new_with_membership(
7768                root.path(),
7769                "n1",
7770                "cluster",
7771                1,
7772                1,
7773                membership.clone(),
7774            )
7775            .unwrap();
7776            let command = StoredCommand::new(EntryType::Command, b"wal-final-corrupt".to_vec());
7777            let value = AcceptedValue::from_command("cluster", 8, 1, 1, LogHash::ZERO, &command);
7778            store
7779                .record_proposal(RecordRequest {
7780                    cluster_id: "cluster".into(),
7781                    epoch: 1,
7782                    config_id: 1,
7783                    config_digest: membership.digest(),
7784                    slot: 8,
7785                    step: 4,
7786                    proposal: Proposal::new(ProposalPriority::MAX, "writer", 1, value),
7787                    command: Some(command),
7788                })
7789                .unwrap();
7790        }
7791        let wal = root.path().join("recorder.wal");
7792        let mut bytes = std::fs::read(&wal).unwrap();
7793        let last = bytes.len() - 1;
7794        bytes[last] ^= 0x80;
7795        std::fs::write(&wal, bytes).unwrap();
7796
7797        assert!(matches!(
7798            RecorderFileStore::new_with_membership(
7799                root.path(),
7800                "n1",
7801                "cluster",
7802                1,
7803                1,
7804                membership,
7805            ),
7806            Err(Error::Decode(message)) if message.contains("WAL frame checksum")
7807        ));
7808    }
7809
7810    #[test]
7811    fn wal_rotation_checkpoints_before_reusing_the_stable_file() {
7812        let root = tempfile::tempdir().unwrap();
7813        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7814        let store = RecorderFileStore::new_with_membership(
7815            root.path(),
7816            "n1",
7817            "cluster",
7818            1,
7819            1,
7820            membership.clone(),
7821        )
7822        .unwrap();
7823        for slot in 1..=super::RECORDER_WAL_HARD_FRAME_LIMIT + 1 {
7824            let command = StoredCommand::new(
7825                EntryType::Command,
7826                format!("wal-rotate-{slot}").into_bytes(),
7827            );
7828            let value = AcceptedValue::from_command("cluster", slot, 1, 1, LogHash::ZERO, &command);
7829            store
7830                .record_proposal(RecordRequest {
7831                    cluster_id: "cluster".into(),
7832                    epoch: 1,
7833                    config_id: 1,
7834                    config_digest: membership.digest(),
7835                    slot,
7836                    step: 4,
7837                    proposal: Proposal::new(ProposalPriority::MAX, "writer", slot, value),
7838                    command: Some(command),
7839                })
7840                .unwrap();
7841        }
7842        let (generation, through_sequence, frames) = store.wal_stats().unwrap();
7843        assert!(generation > 1);
7844        assert!(through_sequence >= super::RECORDER_WAL_HARD_FRAME_LIMIT);
7845        assert_eq!(frames, 1);
7846        drop(store);
7847
7848        let reopened =
7849            RecorderFileStore::new_with_membership(root.path(), "n1", "cluster", 1, 1, membership)
7850                .unwrap();
7851        assert_eq!(
7852            reopened
7853                .load(super::RECORDER_WAL_HARD_FRAME_LIMIT + 1)
7854                .unwrap()
7855                .isr
7856                .step(),
7857            4
7858        );
7859    }
7860
7861    proptest! {
7862        #[test]
7863        fn wal_frame_round_trips_arbitrary_inline_commands(
7864            sequence in 1u64..u64::MAX,
7865            payload in proptest::collection::vec(any::<u8>(), 0..2048),
7866        ) {
7867            let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7868            let configuration = ConfigurationState::initial(
7869                1,
7870                membership.digest(),
7871                Some(membership),
7872            );
7873            let state = RecorderSlotState::new_with_digest(
7874                8,
7875                "cluster",
7876                1,
7877                1,
7878                configuration.config_digest(),
7879            );
7880            let command = StoredCommand::new(EntryType::Command, payload);
7881            let (encoded, digest, slot_bytes) = encode_wal_frame(
7882                3,
7883                sequence,
7884                LogHash::ZERO,
7885                &state,
7886                &configuration,
7887                &RecordedHeadProvenance::Empty,
7888                Some((command.hash(), &command)),
7889            ).unwrap();
7890            let (decoded, end) = decode_wal_frame(&encoded, 0).unwrap().unwrap();
7891            prop_assert_eq!(end, encoded.len());
7892            prop_assert_eq!(decoded.generation, 3);
7893            prop_assert_eq!(decoded.sequence, sequence);
7894            prop_assert_eq!(decoded.digest, digest);
7895            prop_assert_eq!(decoded.slot_bytes, slot_bytes);
7896            prop_assert_eq!(decoded.command, Some((command.hash(), command)));
7897        }
7898    }
7899
7900    #[test]
7901    fn fresh_initialization_recovers_when_configuration_was_published_before_head() {
7902        let root = tempfile::tempdir().unwrap();
7903        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7904        let (store, existing_format) =
7905            RecorderFileStore::open_root(root.path(), "n1", "cluster", 1, 1).unwrap();
7906        assert!(!existing_format);
7907        let configuration =
7908            ConfigurationState::initial(1, membership.digest(), Some(membership.clone()));
7909        store
7910            .set_seal_fault(Some(SealFaultPoint::AfterHeadConfiguration))
7911            .unwrap();
7912
7913        assert!(matches!(
7914            store.commit_configuration_head_unlocked(
7915                &configuration,
7916                &RecordedHeadProvenance::Empty,
7917            ),
7918            Err(Error::Io(message))
7919                if message.contains("AfterHeadConfiguration")
7920        ));
7921        assert!(root.path().join("configuration.rec").exists());
7922        assert!(!root.path().join("recorded-head.rec").exists());
7923        assert!(root.path().join("configuration-head.intent").exists());
7924        drop(store);
7925
7926        let reopened = RecorderFileStore::new_with_membership(
7927            root.path(),
7928            "n1",
7929            "cluster",
7930            1,
7931            1,
7932            membership.clone(),
7933        )
7934        .unwrap();
7935        assert_eq!(
7936            reopened.configuration_state().unwrap().membership(),
7937            Some(&membership)
7938        );
7939        assert!(root.path().join("recorded-head.rec").exists());
7940        assert!(!root.path().join("configuration-head.intent").exists());
7941    }
7942
7943    #[test]
7944    fn progress_remembers_config_change_after_later_normal_adoption() {
7945        let root = tempfile::tempdir().unwrap();
7946        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
7947        let stores: Vec<_> = membership
7948            .members()
7949            .iter()
7950            .map(|id| {
7951                RecorderFileStore::new_with_membership(
7952                    root.path().join(id),
7953                    id.clone(),
7954                    "cluster",
7955                    1,
7956                    1,
7957                    membership.clone(),
7958                )
7959                .unwrap()
7960            })
7961            .collect();
7962        let offered = StoredCommand::new(EntryType::Command, b"offered".to_vec());
7963        let transition = ConfigChange::stop(1, membership.digest()).to_stored_command();
7964        let adopted = StoredCommand::new(EntryType::Command, b"adopted".to_vec());
7965        for store in &stores {
7966            for command in [&transition, &adopted] {
7967                store
7968                    .store_command(command.hash(), command.clone())
7969                    .unwrap();
7970            }
7971        }
7972        let recorders = membership
7973            .members()
7974            .iter()
7975            .zip(&stores)
7976            .map(|(id, store)| (id.clone(), Box::new(store.clone()) as Box<dyn RecorderRpc>))
7977            .collect();
7978        let consensus =
7979            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
7980        let proposal = |command: &StoredCommand| {
7981            Proposal::new(
7982                ProposalPriority::from_u64(1),
7983                "other",
7984                1,
7985                AcceptedValue::from_command("cluster", 1, 1, 1, LogHash::ZERO, command),
7986            )
7987        };
7988        let mut progress = ProposerProgress::new(1, proposal(&offered)).with_command(offered);
7989
7990        progress.proposal = proposal(&transition);
7991        consensus.ensure_progress_command(&mut progress).unwrap();
7992        progress.proposal = proposal(&adopted);
7993        consensus.ensure_progress_command(&mut progress).unwrap();
7994
7995        assert_eq!(progress.command, Some(adopted));
7996        assert!(progress.transition_involved);
7997    }
7998
7999    #[test]
8000    fn record_broadcast_reuses_one_worker_thread_per_recorder() {
8001        let seen: Vec<_> = (0..3)
8002            .map(|_| Arc::new(Mutex::new(HashSet::new())))
8003            .collect();
8004        let recorders = ["n1", "n2", "n3"]
8005            .into_iter()
8006            .zip(&seen)
8007            .map(|(recorder_id, threads)| {
8008                (
8009                    recorder_id.into(),
8010                    Box::new(ThreadRecordingRecorder {
8011                        recorder_id,
8012                        threads: Arc::clone(threads),
8013                    }) as Box<dyn RecorderRpc>,
8014                )
8015            })
8016            .collect();
8017        let consensus =
8018            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8019
8020        for slot in 1..=16 {
8021            assert_eq!(
8022                consensus
8023                    .record_broadcast(record_requests(&consensus, slot))
8024                    .unwrap()
8025                    .len(),
8026                2
8027            );
8028        }
8029        drop(consensus);
8030
8031        assert!(seen
8032            .iter()
8033            .all(|threads| threads.lock().unwrap().len() == 1));
8034    }
8035
8036    #[test]
8037    fn unsorted_explicit_recorder_ids_preserve_rpc_pairing_across_worker_paths() {
8038        let recorders = ["n3", "n1", "n2"]
8039            .into_iter()
8040            .map(|recorder_id| {
8041                (
8042                    recorder_id.into(),
8043                    Box::new(SlotRecorder {
8044                        recorder_id,
8045                        reject_slot: None,
8046                        observed: None,
8047                    }) as Box<dyn RecorderRpc>,
8048                )
8049            })
8050            .collect();
8051        let consensus =
8052            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8053
8054        assert_eq!(
8055            consensus
8056                .record_broadcast(record_requests(&consensus, 1))
8057                .unwrap()
8058                .len(),
8059            2
8060        );
8061        assert_eq!(consensus.inspect_decision_proof_at(1).unwrap(), None);
8062        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8063    }
8064
8065    #[test]
8066    fn repeated_control_operations_reuse_one_worker_thread_per_recorder() {
8067        let seen: Vec<_> = (0..3)
8068            .map(|_| Arc::new(Mutex::new(HashSet::new())))
8069            .collect();
8070        let recorders = ["n1", "n2", "n3"]
8071            .into_iter()
8072            .zip(&seen)
8073            .map(|(recorder_id, threads)| {
8074                (
8075                    recorder_id.into(),
8076                    Box::new(ThreadRecordingControlRecorder {
8077                        threads: Arc::clone(threads),
8078                    }) as Box<dyn RecorderRpc>,
8079                )
8080            })
8081            .collect();
8082        let consensus =
8083            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8084
8085        for slot in 1..=16 {
8086            assert_eq!(consensus.inspect_decision_proof_at(slot).unwrap(), None);
8087        }
8088        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8089
8090        assert!(seen
8091            .iter()
8092            .all(|threads| threads.lock().unwrap().len() == 1));
8093    }
8094
8095    #[test]
8096    fn blocked_control_minority_does_not_delay_record_quorum() {
8097        let (started_tx, started_rx) = mpsc::sync_channel(1);
8098        let (release_tx, release_rx) = mpsc::sync_channel(0);
8099        let recorders = vec![
8100            (
8101                "n1".into(),
8102                Box::new(SlotRecorder {
8103                    recorder_id: "n1",
8104                    reject_slot: None,
8105                    observed: None,
8106                }) as Box<dyn RecorderRpc>,
8107            ),
8108            (
8109                "n2".into(),
8110                Box::new(BlockingControlRecorder {
8111                    recorder_id: "n2",
8112                    started: started_tx,
8113                    release_first: Mutex::new(release_rx),
8114                }) as Box<dyn RecorderRpc>,
8115            ),
8116            (
8117                "n3".into(),
8118                Box::new(SlotRecorder {
8119                    recorder_id: "n3",
8120                    reject_slot: None,
8121                    observed: None,
8122                }) as Box<dyn RecorderRpc>,
8123            ),
8124        ];
8125        let consensus =
8126            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8127
8128        assert_eq!(consensus.inspect_decision_proof_at(1).unwrap(), None);
8129        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8130        let replies = consensus
8131            .record_broadcast(record_requests(&consensus, 1))
8132            .unwrap();
8133        assert_eq!(replies.len(), 2);
8134
8135        release_tx.send(()).unwrap();
8136        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8137    }
8138
8139    #[test]
8140    fn blocked_control_majority_does_not_head_of_line_block_read_fence() {
8141        let (started_tx, started_rx) = mpsc::sync_channel(2);
8142        let release = Arc::new((Mutex::new(false), Condvar::new()));
8143        let recorders = ["n1", "n2", "n3"]
8144            .into_iter()
8145            .map(|recorder_id| {
8146                (
8147                    recorder_id.into(),
8148                    Box::new(BlockingInspectionReadFenceRecorder {
8149                        recorder_id,
8150                        block_inspection: recorder_id != "n3",
8151                        started: started_tx.clone(),
8152                        release: Arc::clone(&release),
8153                    }) as Box<dyn RecorderRpc>,
8154                )
8155            })
8156            .collect();
8157        let consensus = Arc::new(
8158            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap(),
8159        );
8160        let inspecting = Arc::clone(&consensus);
8161        let inspection = thread::spawn(move || inspecting.inspect_decision_at(1, LogHash::ZERO));
8162        let mut started = BTreeSet::new();
8163        started.insert(started_rx.recv_timeout(Duration::from_secs(1)).unwrap());
8164        started.insert(started_rx.recv_timeout(Duration::from_secs(1)).unwrap());
8165        assert_eq!(started, BTreeSet::from(["n1", "n2"]));
8166
8167        let before = Instant::now();
8168        assert_eq!(
8169            consensus
8170                .inspect_context_read_fence_at(1, LogHash::ZERO)
8171                .unwrap(),
8172            CertifiedDecisionInspection::Empty
8173        );
8174        assert!(before.elapsed() < Duration::from_millis(250));
8175
8176        let (released, condition) = &*release;
8177        *released.lock().unwrap() = true;
8178        condition.notify_all();
8179        assert_eq!(
8180            inspection.join().unwrap().unwrap(),
8181            DecisionInspection::Empty
8182        );
8183        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8184    }
8185
8186    #[test]
8187    fn saturated_control_queue_does_not_contaminate_later_requests() {
8188        let (started_tx, started_rx) = mpsc::sync_channel(4);
8189        let (release_tx, release_rx) = mpsc::sync_channel(0);
8190        let recorders = vec![
8191            (
8192                "n1".into(),
8193                Box::new(SlotRecorder {
8194                    recorder_id: "n1",
8195                    reject_slot: None,
8196                    observed: None,
8197                }) as Box<dyn RecorderRpc>,
8198            ),
8199            (
8200                "n2".into(),
8201                Box::new(BlockingControlRecorder {
8202                    recorder_id: "n2",
8203                    started: started_tx,
8204                    release_first: Mutex::new(release_rx),
8205                }) as Box<dyn RecorderRpc>,
8206            ),
8207            (
8208                "n3".into(),
8209                Box::new(SlotRecorder {
8210                    recorder_id: "n3",
8211                    reject_slot: None,
8212                    observed: None,
8213                }) as Box<dyn RecorderRpc>,
8214            ),
8215        ];
8216        let consensus =
8217            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8218
8219        assert_eq!(consensus.inspect_decision_proof_at(1).unwrap(), None);
8220        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8221        assert_eq!(consensus.inspect_decision_proof_at(2).unwrap(), None);
8222        assert_eq!(consensus.inspect_decision_proof_at(3).unwrap(), None);
8223
8224        release_tx.send(()).unwrap();
8225        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8226        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(2));
8227        assert!(matches!(
8228            started_rx.try_recv(),
8229            Err(mpsc::TryRecvError::Empty)
8230        ));
8231
8232        assert_eq!(consensus.inspect_decision_proof_at(4).unwrap(), None);
8233        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(4));
8234    }
8235
8236    #[test]
8237    fn command_registration_returns_no_quorum_when_all_control_queues_are_full() {
8238        let (started_tx, started_rx) = mpsc::sync_channel(3);
8239        let release = Arc::new((Mutex::new(false), Condvar::new()));
8240        let recorders = ["n1", "n2", "n3"]
8241            .into_iter()
8242            .map(|recorder_id| {
8243                (
8244                    recorder_id.into(),
8245                    Box::new(BlockingCommandStoreRecorder {
8246                        started: started_tx.clone(),
8247                        release: Arc::clone(&release),
8248                    }) as Box<dyn RecorderRpc>,
8249                )
8250            })
8251            .collect();
8252        let consensus = Arc::new(
8253            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap(),
8254        );
8255        let first = StoredCommand::new(EntryType::Command, b"first".to_vec());
8256        let registering = Arc::clone(&consensus);
8257        let first_hash = first.hash();
8258        let first_payload = first.payload.clone();
8259        let registration =
8260            thread::spawn(move || registering.register_command(first_hash, first_payload));
8261
8262        for _ in 0..3 {
8263            started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
8264        }
8265
8266        let queued = StoredCommand::new(EntryType::Command, b"queued".to_vec());
8267        let (queued_tx, _queued_rx) = mpsc::sync_channel(3);
8268        for (index, worker) in consensus.control_workers.iter().enumerate() {
8269            worker.dispatch(ControlJob::StoreCommand {
8270                index,
8271                cluster_id: "cluster".into(),
8272                epoch: 1,
8273                config_id: 1,
8274                config_digest: consensus.membership().digest(),
8275                command_hash: queued.hash(),
8276                command: queued.clone(),
8277                result: queued_tx.clone(),
8278            });
8279        }
8280
8281        let saturated = StoredCommand::new(EntryType::Command, b"saturated".to_vec());
8282        assert_eq!(
8283            consensus.register_command(saturated.hash(), saturated.payload),
8284            Err(Error::NoQuorum)
8285        );
8286
8287        let (released, condition) = &*release;
8288        *released.lock().unwrap() = true;
8289        condition.notify_all();
8290        assert_eq!(registration.join().unwrap(), Ok(()));
8291        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8292    }
8293
8294    #[test]
8295    fn saturated_control_worker_keeps_command_quorum_retryable_after_worker_failure() {
8296        let (started_tx, started_rx) = mpsc::sync_channel(1);
8297        let release = Arc::new((Mutex::new(false), Condvar::new()));
8298        let recorders = vec![
8299            (
8300                "n1".into(),
8301                Box::new(BlockingCommandStoreRecorder {
8302                    started: started_tx,
8303                    release: Arc::clone(&release),
8304                }) as Box<dyn RecorderRpc>,
8305            ),
8306            (
8307                "n2".into(),
8308                Box::new(SuccessfulCommandStoreRecorder) as Box<dyn RecorderRpc>,
8309            ),
8310            (
8311                "n3".into(),
8312                Box::new(FailingCommandStoreRecorder) as Box<dyn RecorderRpc>,
8313            ),
8314        ];
8315        let consensus = Arc::new(
8316            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap(),
8317        );
8318        let first = StoredCommand::new(EntryType::Command, b"first".to_vec());
8319        let registering = Arc::clone(&consensus);
8320        let registration =
8321            thread::spawn(move || registering.register_command(first.hash(), first.payload));
8322        started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
8323
8324        let queued = StoredCommand::new(EntryType::Command, b"queued".to_vec());
8325        let (queued_tx, _queued_rx) = mpsc::sync_channel(1);
8326        consensus.control_workers[0].dispatch(ControlJob::StoreCommand {
8327            index: 0,
8328            cluster_id: "cluster".into(),
8329            epoch: 1,
8330            config_id: 1,
8331            config_digest: consensus.membership().digest(),
8332            command_hash: queued.hash(),
8333            command: queued,
8334            result: queued_tx,
8335        });
8336
8337        let retry = StoredCommand::new(EntryType::Command, b"retry".to_vec());
8338        assert_eq!(
8339            consensus.register_command(retry.hash(), retry.payload),
8340            Err(Error::NoQuorum)
8341        );
8342
8343        let (released, condition) = &*release;
8344        *released.lock().unwrap() = true;
8345        condition.notify_all();
8346        assert_eq!(registration.join().unwrap(), Ok(()));
8347        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8348    }
8349
8350    #[test]
8351    fn control_worker_finish_and_drop_are_bounded() {
8352        let new_consensus = || {
8353            let (started_tx, started_rx) = mpsc::sync_channel(1);
8354            let (release_tx, release_rx) = mpsc::sync_channel(0);
8355            let recorders = vec![
8356                (
8357                    "n1".into(),
8358                    Box::new(SlotRecorder {
8359                        recorder_id: "n1",
8360                        reject_slot: None,
8361                        observed: None,
8362                    }) as Box<dyn RecorderRpc>,
8363                ),
8364                (
8365                    "n2".into(),
8366                    Box::new(BlockingControlRecorder {
8367                        recorder_id: "n2",
8368                        started: started_tx,
8369                        release_first: Mutex::new(release_rx),
8370                    }) as Box<dyn RecorderRpc>,
8371                ),
8372                (
8373                    "n3".into(),
8374                    Box::new(SlotRecorder {
8375                        recorder_id: "n3",
8376                        reject_slot: None,
8377                        observed: None,
8378                    }) as Box<dyn RecorderRpc>,
8379                ),
8380            ];
8381            (
8382                ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders)
8383                    .unwrap(),
8384                started_rx,
8385                release_tx,
8386            )
8387        };
8388
8389        let (consensus, started_rx, release_tx) = new_consensus();
8390        let consensus = Arc::new(consensus);
8391        assert_eq!(consensus.inspect_decision_proof_at(1).unwrap(), None);
8392        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8393        let (finished_tx, finished_rx) = mpsc::sync_channel(1);
8394        let finishing = Arc::clone(&consensus);
8395        let finisher = thread::spawn(move || {
8396            finished_tx
8397                .send(finishing.finish_pending_rpcs(Duration::from_millis(10)))
8398                .unwrap();
8399        });
8400        assert_eq!(finished_rx.recv_timeout(Duration::from_secs(1)), Ok(false));
8401        finisher.join().unwrap();
8402        release_tx.send(()).unwrap();
8403        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8404
8405        let (consensus, started_rx, release_tx) = new_consensus();
8406        assert_eq!(consensus.inspect_decision_proof_at(1).unwrap(), None);
8407        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8408        let (dropped_tx, dropped_rx) = mpsc::sync_channel(1);
8409        let dropper = thread::spawn(move || {
8410            drop(consensus);
8411            dropped_tx.send(()).unwrap();
8412        });
8413        let dropped = dropped_rx.recv_timeout(Duration::from_secs(1));
8414        release_tx.send(()).unwrap();
8415        dropper.join().unwrap();
8416        assert_eq!(dropped, Ok(()));
8417    }
8418
8419    #[test]
8420    fn saturated_minority_queue_does_not_delay_or_contaminate_later_broadcasts() {
8421        let (started_tx, started_rx) = mpsc::sync_channel(2);
8422        let (release_tx, release_rx) = mpsc::sync_channel(0);
8423        let (n1_seen_tx, n1_seen_rx) = mpsc::sync_channel(1);
8424        let (reject_seen_tx, reject_seen_rx) = mpsc::sync_channel(1);
8425        let recorders = vec![
8426            (
8427                "n1".into(),
8428                Box::new(SlotRecorder {
8429                    recorder_id: "n1",
8430                    reject_slot: None,
8431                    observed: Some(n1_seen_tx),
8432                }) as Box<dyn RecorderRpc>,
8433            ),
8434            (
8435                "n2".into(),
8436                Box::new(BlockingRecorder {
8437                    recorder_id: "n2",
8438                    started: started_tx,
8439                    release_first: Mutex::new(release_rx),
8440                }) as Box<dyn RecorderRpc>,
8441            ),
8442            (
8443                "n3".into(),
8444                Box::new(SlotRecorder {
8445                    recorder_id: "n3",
8446                    reject_slot: Some(2),
8447                    observed: Some(reject_seen_tx),
8448                }) as Box<dyn RecorderRpc>,
8449            ),
8450        ];
8451        let consensus = Arc::new(
8452            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap(),
8453        );
8454
8455        let first = consensus
8456            .record_broadcast(record_requests(&consensus, 1))
8457            .unwrap();
8458        assert_eq!(first.len(), 2);
8459        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8460        assert_eq!(n1_seen_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8461        assert_eq!(reject_seen_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8462
8463        let (done_tx, done_rx) = mpsc::sync_channel(1);
8464        let second_consensus = Arc::clone(&consensus);
8465        let second = thread::spawn(move || {
8466            done_tx
8467                .send(second_consensus.record_broadcast(record_requests(&second_consensus, 2)))
8468                .unwrap();
8469        });
8470        assert_eq!(reject_seen_rx.recv_timeout(Duration::from_secs(1)), Ok(2));
8471        assert_eq!(n1_seen_rx.recv_timeout(Duration::from_secs(1)), Ok(2));
8472        assert!(matches!(done_rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
8473
8474        let (third_done_tx, third_done_rx) = mpsc::sync_channel(1);
8475        let third_consensus = Arc::clone(&consensus);
8476        let third = thread::spawn(move || {
8477            third_done_tx
8478                .send(third_consensus.record_broadcast(record_requests(&third_consensus, 3)))
8479                .unwrap();
8480        });
8481        let third_replies = third_done_rx
8482            .recv_timeout(Duration::from_secs(1))
8483            .unwrap()
8484            .unwrap();
8485        assert_eq!(third_replies.len(), 2);
8486        assert!(third_replies.iter().all(|reply| reply.slot == 3));
8487
8488        release_tx.send(()).unwrap();
8489
8490        let replies = done_rx
8491            .recv_timeout(Duration::from_secs(1))
8492            .unwrap()
8493            .unwrap();
8494        assert_eq!(replies.len(), 2);
8495        assert!(replies.iter().all(|reply| reply.slot == 2));
8496        assert!(replies.iter().any(|reply| reply.recorder_id == "n2"));
8497        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(2));
8498        second.join().unwrap();
8499        third.join().unwrap();
8500    }
8501
8502    #[test]
8503    fn saturated_recorder_keeps_a_minority_rejection_retryable() {
8504        let (started_tx, started_rx) = mpsc::sync_channel(2);
8505        let (release_tx, release_rx) = mpsc::sync_channel(0);
8506        let recorders = vec![
8507            (
8508                "n1".into(),
8509                Box::new(SlotRecorder {
8510                    recorder_id: "n1",
8511                    reject_slot: None,
8512                    observed: None,
8513                }) as Box<dyn RecorderRpc>,
8514            ),
8515            (
8516                "n2".into(),
8517                Box::new(BlockingRecorder {
8518                    recorder_id: "n2",
8519                    started: started_tx,
8520                    release_first: Mutex::new(release_rx),
8521                }) as Box<dyn RecorderRpc>,
8522            ),
8523            (
8524                "n3".into(),
8525                Box::new(RejectFromSlotRecorder {
8526                    recorder_id: "n3",
8527                    reject_from: 3,
8528                }) as Box<dyn RecorderRpc>,
8529            ),
8530        ];
8531        let consensus =
8532            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8533
8534        assert_eq!(
8535            consensus
8536                .record_broadcast(record_requests(&consensus, 1))
8537                .unwrap()
8538                .len(),
8539            2
8540        );
8541        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8542        assert_eq!(
8543            consensus
8544                .record_broadcast(record_requests(&consensus, 2))
8545                .unwrap()
8546                .len(),
8547            2
8548        );
8549
8550        let failure = (3..=514).find_map(|slot| {
8551            let result = consensus.record_broadcast(record_requests(&consensus, slot));
8552            match &result {
8553                Ok(replies)
8554                    if replies.len() == 1
8555                        && replies[0].recorder_id == "n1"
8556                        && replies[0].slot == slot =>
8557                {
8558                    None
8559                }
8560                _ => Some((slot, result)),
8561            }
8562        });
8563        release_tx.send(()).unwrap();
8564        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8565        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(2));
8566        assert!(
8567            failure.is_none(),
8568            "a saturated recorder must keep minority rejection retryable: {failure:?}"
8569        );
8570    }
8571
8572    #[test]
8573    fn saturated_recorder_keeps_quorum_reachable_when_another_worker_fails() {
8574        let (started_tx, started_rx) = mpsc::sync_channel(2);
8575        let (release_tx, release_rx) = mpsc::sync_channel(0);
8576        let recorders = vec![
8577            (
8578                "n1".into(),
8579                Box::new(BlockingRecorder {
8580                    recorder_id: "n1",
8581                    started: started_tx,
8582                    release_first: Mutex::new(release_rx),
8583                }) as Box<dyn RecorderRpc>,
8584            ),
8585            (
8586                "n2".into(),
8587                Box::new(FailingFromSlotRecorder {
8588                    recorder_id: "n2",
8589                    fail_from: 3,
8590                }) as Box<dyn RecorderRpc>,
8591            ),
8592            (
8593                "n3".into(),
8594                Box::new(SlotRecorder {
8595                    recorder_id: "n3",
8596                    reject_slot: None,
8597                    observed: None,
8598                }) as Box<dyn RecorderRpc>,
8599            ),
8600        ];
8601        let consensus =
8602            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8603
8604        assert_eq!(
8605            consensus
8606                .record_broadcast(record_requests(&consensus, 1))
8607                .unwrap()
8608                .len(),
8609            2
8610        );
8611        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8612        assert_eq!(
8613            consensus
8614                .record_broadcast(record_requests(&consensus, 2))
8615                .unwrap()
8616                .len(),
8617            2
8618        );
8619
8620        let third = consensus.record_broadcast(record_requests(&consensus, 3));
8621        assert!(
8622            matches!(third, Ok(ref replies) if replies.len() == 1 && replies[0].recorder_id == "n3"),
8623            "a saturated healthy voter must keep the quorum retryable after a worker failure: {third:?}"
8624        );
8625
8626        release_tx.send(()).unwrap();
8627        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8628        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(2));
8629    }
8630
8631    #[test]
8632    fn command_lookup_waits_for_valid_reply_after_quorum_reports_missing() {
8633        let command = StoredCommand::new(EntryType::Command, b"available".to_vec());
8634        let value = AcceptedValue::from_command("cluster", 7, 1, 1, LogHash::ZERO, &command);
8635        let (observed_tx, observed_rx) = mpsc::sync_channel(2);
8636        let (started_tx, started_rx) = mpsc::sync_channel(1);
8637        let (release_tx, release_rx) = mpsc::sync_channel(0);
8638        let recorders = vec![
8639            (
8640                "n1".into(),
8641                Box::new(MissingCommandRecorder {
8642                    observed: observed_tx.clone(),
8643                }) as Box<dyn RecorderRpc>,
8644            ),
8645            (
8646                "n2".into(),
8647                Box::new(MissingCommandRecorder {
8648                    observed: observed_tx,
8649                }) as Box<dyn RecorderRpc>,
8650            ),
8651            (
8652                "n3".into(),
8653                Box::new(BlockingCommandRecorder {
8654                    started: started_tx,
8655                    release: Mutex::new(release_rx),
8656                    command: command.clone(),
8657                }) as Box<dyn RecorderRpc>,
8658            ),
8659        ];
8660        let consensus =
8661            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8662        let (done_tx, done_rx) = mpsc::sync_channel(1);
8663        let fetch = thread::spawn(move || {
8664            done_tx
8665                .send(consensus.fetch_verified_value(7, &value))
8666                .unwrap();
8667        });
8668
8669        assert_eq!(observed_rx.recv_timeout(Duration::from_secs(1)), Ok(()));
8670        assert_eq!(observed_rx.recv_timeout(Duration::from_secs(1)), Ok(()));
8671        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(()));
8672        assert!(matches!(done_rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
8673
8674        release_tx.send(()).unwrap();
8675        assert_eq!(
8676            done_rx.recv_timeout(Duration::from_secs(1)).unwrap(),
8677            Ok(Some(command))
8678        );
8679        fetch.join().unwrap();
8680    }
8681
8682    #[test]
8683    fn command_lookup_rejects_cryptographic_mismatch_despite_reachable_quorum() {
8684        let command = StoredCommand::new(EntryType::Command, b"mismatched".to_vec());
8685        let mut value = AcceptedValue::from_command("cluster", 7, 1, 1, LogHash::ZERO, &command);
8686        value.entry_hash = LogHash::ZERO;
8687        let (observed_tx, observed_rx) = mpsc::sync_channel(1);
8688        let recorders = vec![
8689            (
8690                "n1".into(),
8691                Box::new(AvailableCommandRecorder {
8692                    command: command.clone(),
8693                }) as Box<dyn RecorderRpc>,
8694            ),
8695            (
8696                "n2".into(),
8697                Box::new(MissingCommandRecorder {
8698                    observed: observed_tx,
8699                }) as Box<dyn RecorderRpc>,
8700            ),
8701            (
8702                "n3".into(),
8703                Box::new(FailingCommandFetchRecorder) as Box<dyn RecorderRpc>,
8704            ),
8705        ];
8706        let consensus =
8707            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8708
8709        assert_eq!(
8710            consensus.fetch_verified_value(7, &value),
8711            Err(Error::Rejected(RejectReason::InvalidValue))
8712        );
8713        assert_eq!(observed_rx.recv_timeout(Duration::from_secs(1)), Ok(()));
8714    }
8715
8716    #[test]
8717    fn command_lookup_returns_no_quorum_when_a_control_worker_queue_is_full() {
8718        let (started_tx, started_rx) = mpsc::sync_channel(1);
8719        let release = Arc::new((Mutex::new(false), Condvar::new()));
8720        let (observed_tx, observed_rx) = mpsc::sync_channel(1);
8721        let recorders = vec![
8722            (
8723                "n1".into(),
8724                Box::new(BlockingCommandStoreRecorder {
8725                    started: started_tx,
8726                    release: Arc::clone(&release),
8727                }) as Box<dyn RecorderRpc>,
8728            ),
8729            (
8730                "n2".into(),
8731                Box::new(MissingCommandRecorder {
8732                    observed: observed_tx,
8733                }) as Box<dyn RecorderRpc>,
8734            ),
8735            (
8736                "n3".into(),
8737                Box::new(FailingCommandFetchRecorder) as Box<dyn RecorderRpc>,
8738            ),
8739        ];
8740        let consensus =
8741            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8742        let blocking = StoredCommand::new(EntryType::Command, b"blocking".to_vec());
8743        let (blocking_tx, _blocking_rx) = mpsc::sync_channel(1);
8744        assert!(matches!(
8745            consensus.control_workers[0].dispatch(ControlJob::StoreCommand {
8746                index: 0,
8747                cluster_id: "cluster".into(),
8748                epoch: 1,
8749                config_id: 1,
8750                config_digest: consensus.membership().digest(),
8751                command_hash: blocking.hash(),
8752                command: blocking,
8753                result: blocking_tx,
8754            }),
8755            ControlDispatch::Accepted
8756        ));
8757        started_rx.recv_timeout(Duration::from_secs(1)).unwrap();
8758
8759        let queued = StoredCommand::new(EntryType::Command, b"queued".to_vec());
8760        let (queued_tx, _queued_rx) = mpsc::sync_channel(1);
8761        assert!(matches!(
8762            consensus.control_workers[0].dispatch(ControlJob::StoreCommand {
8763                index: 0,
8764                cluster_id: "cluster".into(),
8765                epoch: 1,
8766                config_id: 1,
8767                config_digest: consensus.membership().digest(),
8768                command_hash: queued.hash(),
8769                command: queued,
8770                result: queued_tx,
8771            }),
8772            ControlDispatch::Accepted
8773        ));
8774
8775        let command = StoredCommand::new(EntryType::Command, b"missing".to_vec());
8776        let value = AcceptedValue::from_command("cluster", 7, 1, 1, LogHash::ZERO, &command);
8777        assert_eq!(
8778            consensus.fetch_verified_value(7, &value),
8779            Err(Error::NoQuorum)
8780        );
8781        assert_eq!(observed_rx.recv_timeout(Duration::from_secs(1)), Ok(()));
8782
8783        let (released, condition) = &*release;
8784        *released.lock().unwrap() = true;
8785        condition.notify_all();
8786        assert!(consensus.finish_pending_rpcs(Duration::from_secs(1)));
8787    }
8788
8789    #[test]
8790    fn full_record_worker_queue_is_transient_unavailable_not_fatal() {
8791        let (started_tx, started_rx) = mpsc::sync_channel(2);
8792        let (release_tx, release_rx) = mpsc::sync_channel(0);
8793        let recorders = vec![
8794            (
8795                "n1".into(),
8796                Box::new(BlockingRecorder {
8797                    recorder_id: "n1",
8798                    started: started_tx,
8799                    release_first: Mutex::new(release_rx),
8800                }) as Box<dyn RecorderRpc>,
8801            ),
8802            (
8803                "n2".into(),
8804                Box::new(AlwaysIoRecorder) as Box<dyn RecorderRpc>,
8805            ),
8806            (
8807                "n3".into(),
8808                Box::new(AlwaysIoRecorder) as Box<dyn RecorderRpc>,
8809            ),
8810        ];
8811        let consensus =
8812            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8813
8814        assert!(consensus
8815            .record_broadcast(record_requests(&consensus, 1))
8816            .unwrap()
8817            .is_empty());
8818        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(1));
8819
8820        assert!(consensus
8821            .record_broadcast(record_requests(&consensus, 2))
8822            .unwrap()
8823            .is_empty());
8824
8825        let third = consensus.record_broadcast(record_requests(&consensus, 3));
8826        assert!(
8827            matches!(third, Ok(ref replies) if replies.is_empty()),
8828            "a full worker queue must remain retryable, got {third:?}"
8829        );
8830
8831        release_tx.send(()).unwrap();
8832        assert_eq!(started_rx.recv_timeout(Duration::from_secs(1)), Ok(2));
8833    }
8834
8835    #[test]
8836    fn disconnected_record_worker_is_fatal() {
8837        let membership = Membership::new(["n1", "n2", "n3"]).unwrap();
8838        let proposal = Proposal::new(
8839            ProposalPriority::MAX,
8840            "n1",
8841            1,
8842            AcceptedValue {
8843                command_hash: LogHash::ZERO,
8844                prev_hash: LogHash::ZERO,
8845                entry_hash: LogHash::ZERO,
8846            },
8847        );
8848        let request = RecordRequest {
8849            cluster_id: "cluster".into(),
8850            epoch: 1,
8851            config_id: 1,
8852            config_digest: membership.digest(),
8853            slot: 1,
8854            step: 4,
8855            proposal,
8856            command: None,
8857        };
8858        let (job_tx, job_rx) = mpsc::sync_channel(1);
8859        drop(job_rx);
8860        let pending = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8861        let worker = super::RecordWorker {
8862            sender: Some(job_tx),
8863            handle: None,
8864            pending: Arc::clone(&pending),
8865        };
8866        let (result_tx, result_rx) = mpsc::sync_channel(1);
8867
8868        worker.dispatch(super::RecordJob {
8869            index: 0,
8870            request,
8871            result: result_tx,
8872        });
8873
8874        assert_eq!(result_rx.recv().unwrap().1, Err(Error::ProposeFailed));
8875        assert_eq!(pending.load(std::sync::atomic::Ordering::Acquire), 0);
8876    }
8877
8878    #[test]
8879    fn recorder_panics_are_reported_without_panicking_the_proposer() {
8880        let recorders = vec![
8881            (
8882                "n1".into(),
8883                Box::new(SlotRecorder {
8884                    recorder_id: "n1",
8885                    reject_slot: None,
8886                    observed: None,
8887                }) as Box<dyn RecorderRpc>,
8888            ),
8889            (
8890                "n2".into(),
8891                Box::new(PanickingRecorder) as Box<dyn RecorderRpc>,
8892            ),
8893            (
8894                "n3".into(),
8895                Box::new(PanickingRecorder) as Box<dyn RecorderRpc>,
8896            ),
8897        ];
8898        let consensus =
8899            ThreeNodeConsensus::from_recorders_with_ids("cluster", "n1", 1, 1, recorders).unwrap();
8900
8901        assert_eq!(
8902            consensus.record_broadcast(record_requests(&consensus, 1)),
8903            Err(Error::ProposeFailed)
8904        );
8905    }
8906}