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