LeVCS/crates/levcs-protocol/tests/phase0_oracles.rs

385 lines
14 KiB
Rust

mod support;
use std::fs::OpenOptions;
use std::io::Write;
use levcs_protocol::oracle::*;
use levcs_protocol::v2::{PendingPhase, TransactionStatusV1};
use support::*;
#[test]
fn every_append_through_publication_boundary_has_a_deterministic_oracle() {
assert_eq!(
REJECTED_OR_STAGED_VISIBILITY,
VisibilitySurface::default(),
"rejected operations and unfinalized stage chunks expose nothing"
);
assert_eq!(APPEND_PUBLICATION_FAILPOINTS.len(), 17);
for point in APPEND_PUBLICATION_FAILPOINTS {
let expectation = append_publication_expectation(*point);
// Every point's recovery outcome is pinned explicitly here, not by a
// catch-all: the three-way split (deterministically absent before
// any frame write; either-whole once a complete frame may have
// reached the device with the fence outcome undetermined;
// deterministically committed once the fence itself is known good)
// is the core of the append-through-publication contract.
let expected_recovery = match point {
AppendFailpoint::BeforeAppend
| AppendFailpoint::AfterMarkedResolving
| AppendFailpoint::DuringFrameWriteTorn
| AppendFailpoint::EvidenceHandoffFailure => RecoveryOutcome::AbsentRetriable,
AppendFailpoint::AfterFrameWrite
| AppendFailpoint::BeforeFence
| AppendFailpoint::FenceFailed
| AppendFailpoint::FenceAmbiguous
| AppendFailpoint::WriterPanicBeforeFence => RecoveryOutcome::EitherWhole,
AppendFailpoint::AfterSuccessfulFence
| AppendFailpoint::DuringCommittedRootBuild
| AppendFailpoint::AllocationFailureBeforePublication
| AppendFailpoint::BeforeRootCas
| AppendFailpoint::DuringRootCasRetry
| AppendFailpoint::WriterPanicAfterFence
| AppendFailpoint::AfterRootCasBeforeWaiterWake
| AppendFailpoint::BeforeResponse => RecoveryOutcome::Committed,
};
assert_eq!(
expectation.recovery_outcome, expected_recovery,
"{point:?} recovery outcome"
);
match point {
// `EvidenceHandoffFailure` sits here, not with the poisoning
// rows, as of contract review 2026-07-26-A: it fires before the
// group is marked `Resolving` and before any byte is written, so
// the outcome is definitively absent and the shard stays in
// service. See the rationale in `oracle.rs`.
AppendFailpoint::BeforeAppend | AppendFailpoint::EvidenceHandoffFailure => {
assert!(!expectation.shard_poisoned);
assert_eq!(
expectation.immediate_status,
ImmediateStatus::DefinitiveAbsent
);
assert!(expectation.later_append_allowed_before_recovery);
assert!(!expectation.acknowledgment_allowed);
}
AppendFailpoint::AfterRootCasBeforeWaiterWake | AppendFailpoint::BeforeResponse => {
assert!(!expectation.shard_poisoned);
assert_eq!(expectation.immediate_status, ImmediateStatus::Committed);
assert!(expectation.acknowledgment_allowed);
assert!(expectation.later_append_allowed_before_recovery);
}
// Listed exhaustively rather than by a catch-all arm. This test
// pins a frozen classification, and a `_` arm silently absorbs a
// newly added failpoint into "poisoned" — which is exactly how a
// wrong classification ships past its own test. Adding a row must
// fail to compile until someone classifies it.
AppendFailpoint::AfterMarkedResolving
| AppendFailpoint::DuringFrameWriteTorn
| AppendFailpoint::AfterFrameWrite
| AppendFailpoint::BeforeFence
| AppendFailpoint::FenceFailed
| AppendFailpoint::FenceAmbiguous
| AppendFailpoint::WriterPanicBeforeFence
| AppendFailpoint::AfterSuccessfulFence
| AppendFailpoint::DuringCommittedRootBuild
| AppendFailpoint::AllocationFailureBeforePublication
| AppendFailpoint::BeforeRootCas
| AppendFailpoint::DuringRootCasRetry
| AppendFailpoint::WriterPanicAfterFence => {
assert!(expectation.shard_poisoned);
assert_eq!(expectation.immediate_status, ImmediateStatus::Resolving);
assert!(!expectation.later_append_allowed_before_recovery);
assert!(!expectation.acknowledgment_allowed);
}
}
}
assert_eq!(
resolve_ambiguous_tail(RecoveredTailFact::AbsentOrTorn),
RecoveryOutcome::AbsentRetriable
);
assert_eq!(
resolve_ambiguous_tail(RecoveredTailFact::CompleteChecksumValid),
RecoveryOutcome::Committed
);
}
#[test]
fn deadline_crossing_changes_at_append_start_and_resolution_dominates_expiry() {
assert_eq!(
deadline_decision(5_000, 5_000, AppendDeadlinePhase::PreAppend),
DeadlineDecision::ContinuePreAppend
);
assert_eq!(
deadline_decision(5_001, 5_000, AppendDeadlinePhase::PreAppend),
DeadlineDecision::RejectReceiptExpired
);
assert_eq!(
deadline_decision(50_000, 5_000, AppendDeadlinePhase::AppendStarted),
DeadlineDecision::RemainResolving
);
let preappend_expired = deadline_expectation(5_001, 5_000, AppendDeadlinePhase::PreAppend);
assert!(!preappend_expired.append_allowed);
assert!(!preappend_expired.reservation_retained);
assert!(!preappend_expired.durable_status_created);
let resolving = deadline_expectation(50_000, 5_000, AppendDeadlinePhase::AppendStarted);
assert!(resolving.reservation_retained);
assert!(resolving.durable_status_created);
}
#[test]
fn coalescing_and_linearizable_status_precedence_never_mask_durable_state() {
let receipt = receipt();
let digest = receipt.operation_digest;
let committed = TransactionStatusV1::Committed(receipt.clone());
let pending = TransactionStatusV1::Pending {
operation_digest: digest,
retry_until_micros: receipt.retry_until_micros,
phase: PendingPhase::Validating,
};
assert_eq!(
coalescing_decision(TransactionStatusV1::Unknown, None, digest),
CoalescingDecision::BecomeLeader
);
assert_eq!(
coalescing_decision(TransactionStatusV1::Unknown, Some(digest), digest),
CoalescingDecision::AttachToLeader
);
assert_eq!(
coalescing_decision(TransactionStatusV1::Unknown, Some(id(99)), digest),
CoalescingDecision::OperationIdMismatch
);
assert_eq!(
coalescing_decision(committed.clone(), Some(id(99)), digest),
CoalescingDecision::ReturnDurable(committed.clone())
);
assert_eq!(
coalescing_decision(committed.clone(), None, id(200)),
CoalescingDecision::OperationIdMismatch,
"same ID/different digest rejects while a durable terminal record exists"
);
assert_eq!(
two_root_status_read(None, None, Some(committed.clone())),
committed,
"reader paused after committed-root A must recheck B"
);
assert_eq!(
public_status_read(
TransactionStatusV1::Unknown,
Some(pending),
TransactionStatusV1::Committed(receipt.clone())
),
TransactionStatusV1::Committed(receipt),
"a stale registry entry cannot mask durable publication"
);
}
#[test]
fn committed_receipt_then_tombstone_then_reuse_window_is_exact() {
let receipt = receipt();
assert!(matches!(
retained_terminal_status(&receipt, 6_500, 500).unwrap(),
TransactionStatusV1::Committed(_)
));
assert!(matches!(
retained_terminal_status(&receipt, 6_501, 500).unwrap(),
TransactionStatusV1::Expired { .. }
));
assert_eq!(
retained_terminal_status(&receipt, 7_001, 500).unwrap(),
TransactionStatusV1::Unknown
);
let checkpointed = recovered_receipt_visibility(5_000, Some(4_500), 10_000, 2_000).unwrap();
assert_eq!(checkpointed, (4_500, 6_500));
let reset_after_crash = recovered_receipt_visibility(5_000, None, 10_000, 2_000).unwrap();
assert_eq!(reset_after_crash, (10_000, 12_000));
assert!(reset_after_crash.1 >= receipt.receipt_visible_until_micros);
}
#[test]
fn replay_guard_rejects_same_nonce_until_its_checked_horizon_and_never_early() {
let mut guard = ReplayGuardOracle::new();
let key = [7; 32];
let nonce = [9; 16];
assert!(guard.reserve(key, nonce, 1_000, 60_000_000, 1_000_000, 1_000));
assert!(
!guard.reserve(key, nonce, 1_000, 60_000_000, 1_000_000, 1_000),
"same (key, nonce) must be rejected while it is still live"
);
assert!(
!guard.reserve(key, nonce, 1_000, 60_000_000, 1_000_000, 61_000_999),
"eviction before issued_at + clock_skew + timer_resolution is forbidden"
);
assert_eq!(guard.live_count(), 1);
assert!(
guard.reserve(key, nonce, 1_000, 60_000_000, 1_000_000, 61_001_001),
"the same nonce becomes reusable once its horizon has passed"
);
assert!(
!guard.reserve(key, [1; 16], i64::MAX, 1, 1, 0),
"overflowing the checked expiry must never yield a live reservation"
);
}
#[test]
fn external_ack_journal_recovers_fenced_records_and_ignores_only_torn_tail() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("acks.lv");
let first = AckRecord {
repo_id: id(1),
operation_id: [1; 16],
operation_digest: id(2),
receipt_digest: id(3),
repo_sequence: 10,
blob_ids: vec![id(10)],
tree_ids: vec![id(11)],
commit_ids: vec![id(12)],
};
let second = AckRecord {
repo_id: id(1),
operation_id: [3; 16],
operation_digest: id(4),
receipt_digest: id(5),
repo_sequence: 11,
blob_ids: vec![id(13)],
tree_ids: vec![id(14)],
commit_ids: vec![id(15)],
};
{
let mut journal = ExternalAckJournal::open(&path).unwrap();
journal.append_durable(&first).unwrap();
journal.append_durable(&second).unwrap();
}
assert_eq!(
ExternalAckJournal::recover(&path).unwrap(),
vec![first.clone(), second.clone()]
);
{
let mut file = OpenOptions::new().append(true).open(&path).unwrap();
file.write_all(&[0x38, 0, 0]).unwrap();
file.sync_data().unwrap();
}
assert_eq!(
ExternalAckJournal::recover(&path).unwrap(),
vec![first.clone(), second]
);
assert_eq!(ExternalAckJournal::encoded_record_len(&first).unwrap(), 256);
}
#[test]
fn external_ack_journal_rejects_corrupt_complete_records() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("bad-acks.lv");
{
let mut journal = ExternalAckJournal::open(&path).unwrap();
journal
.append_durable(&AckRecord {
repo_id: id(1),
operation_id: [1; 16],
operation_digest: id(2),
receipt_digest: id(3),
repo_sequence: 1,
blob_ids: vec![id(4)],
tree_ids: vec![id(5)],
commit_ids: vec![id(6)],
})
.unwrap();
}
let mut bytes = std::fs::read(&path).unwrap();
*bytes.last_mut().unwrap() ^= 1;
std::fs::write(&path, bytes).unwrap();
assert!(matches!(
ExternalAckJournal::recover(&path),
Err(AckJournalError::Checksum)
));
let invalid = AckRecord {
repo_id: id(1),
operation_id: [1; 16],
operation_digest: id(2),
receipt_digest: id(3),
repo_sequence: 1,
blob_ids: vec![],
tree_ids: vec![],
commit_ids: vec![],
};
assert!(matches!(
ExternalAckJournal::encoded_record_len(&invalid),
Err(AckJournalError::InvalidRecord(_))
));
}
#[test]
fn exact_restore_is_byte_exact_event_neutral_and_absent_destination_only() {
let exported = ExactRestoreFixture {
format_marker: b"FORMAT-v2".to_vec(),
current_manifest: b"CURRENT-7".to_vec(),
frames: vec![b"frame-a".to_vec(), b"frame-b".to_vec()],
event_count: 2,
receipt_count: 2,
};
verify_exact_restore(&exported, &exported).unwrap();
let mut changed = exported.clone();
changed.frames[0][0] ^= 1;
assert_eq!(
verify_exact_restore(&exported, &changed),
Err(RestoreOracleError::BytesChanged)
);
let mut added_event = exported.clone();
added_event.event_count += 1;
assert_eq!(
verify_exact_restore(&exported, &added_event),
Err(RestoreOracleError::EventCountChanged)
);
RestorePreconditions {
staging_device: 7,
destination_parent_device: 7,
destination_absent: true,
format_marker_valid: true,
}
.validate()
.unwrap();
assert_eq!(
RestorePreconditions {
staging_device: 7,
destination_parent_device: 8,
destination_absent: true,
format_marker_valid: true,
}
.validate(),
Err(RestoreOracleError::CrossDevice)
);
assert_eq!(
RestorePreconditions {
staging_device: 7,
destination_parent_device: 7,
destination_absent: false,
format_marker_valid: true,
}
.validate(),
Err(RestoreOracleError::DestinationOccupied)
);
assert_eq!(RESTORE_FAILPOINTS.len(), 6);
for point in RESTORE_FAILPOINTS {
let expected = restore_failpoint_expectation(*point);
assert!(!expected.new_transaction_or_event_allowed);
if expected.destination != RestoredDestinationOutcome::Absent {
assert!(expected.production_recovery_required);
}
}
assert_eq!(
restore_failpoint_expectation(RestoreFailpoint::AfterRenameBeforeParentSync).destination,
RestoredDestinationOutcome::AbsentOrExact
);
assert_eq!(
restore_failpoint_expectation(RestoreFailpoint::AfterParentSyncBeforeProductionRecovery)
.destination,
RestoredDestinationOutcome::ExactNotReady
);
}