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 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 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 Observe {
819 cluster_id: ClusterId,
820 epoch: Epoch,
821 config_id: ConfigId,
822 config_digest: LogHash,
823 slot: Slot,
824 ballot: Ballot,
825 },
826 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;
1165const 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 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 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 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_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 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 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 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 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 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 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 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 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 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}