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