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