use std::error::Error;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use liminal::durability::{DurabilityError, DurableStore, StoredEntry, open_ephemeral};
use liminal_protocol::wire::{
ClientRequest, ConnectionIncarnation, EnrollmentRequest, EnrollmentToken, ServerValue,
};
use crate::config::types::ParticipantConfig;
use crate::server::participant::{ParticipantConnectionConversations, ParticipantSemanticError};
use super::ProductionParticipantHandler;
use super::log::STREAM_PREFIX;
use super::tests::{dispatch_tracked, test_participant_config};
use super::tests_w1b_pending_died_restart::pending_restart_fixture;
pub(super) const POISONED_CONVERSATION: u64 = 7_201;
pub(super) const HEALTHY_CONVERSATION: u64 = 7_202;
const POISONED_TOKEN: [u8; 16] = [0xC1; 16];
const HEALTHY_TOKEN: [u8; 16] = [0xC2; 16];
const PENDING_CONVERSATION: u64 = 67;
#[derive(Debug, Clone, Copy)]
pub(super) enum FaultMode {
CorruptRecordNow(u64),
RefuseAppend,
CorruptAfterAppend(u64),
}
#[derive(Debug)]
pub(super) struct OneConversationFault {
inner: Arc<dyn DurableStore>,
target_key: String,
mode: FaultMode,
armed: Arc<AtomicBool>,
corrupted_reads: Arc<AtomicUsize>,
}
impl OneConversationFault {
pub(super) fn new(inner: Arc<dyn DurableStore>, conversation_id: u64, mode: FaultMode) -> Self {
Self {
inner,
target_key: format!("{STREAM_PREFIX}{conversation_id}"),
mode,
armed: Arc::new(AtomicBool::new(false)),
corrupted_reads: Arc::new(AtomicUsize::new(0)),
}
}
fn armed_sequence(&self) -> Option<u64> {
match self.mode {
FaultMode::CorruptRecordNow(sequence) => Some(sequence),
FaultMode::CorruptAfterAppend(sequence) if self.armed.load(Ordering::SeqCst) => {
Some(sequence)
}
FaultMode::CorruptAfterAppend(_) | FaultMode::RefuseAppend => None,
}
}
fn corrupt(&self, stream_key: &str, mut entries: Vec<StoredEntry>) -> Vec<StoredEntry> {
if stream_key != self.target_key {
return entries;
}
let Some(target_sequence) = self.armed_sequence() else {
return entries;
};
for entry in &mut entries {
if entry.sequence == target_sequence {
entry.payload = vec![0xFF; 16];
self.corrupted_reads.fetch_add(1, Ordering::SeqCst);
}
}
entries
}
}
#[async_trait::async_trait]
impl DurableStore for OneConversationFault {
async fn append(
&self,
stream_key: &str,
payload: Vec<u8>,
expected_seq: u64,
) -> Result<u64, DurabilityError> {
if stream_key == self.target_key && matches!(self.mode, FaultMode::RefuseAppend) {
return Err(DurabilityError::SequenceConflict {
expected: expected_seq,
actual: expected_seq.saturating_add(1),
});
}
let sequence = self.inner.append(stream_key, payload, expected_seq).await?;
if stream_key == self.target_key {
self.armed.store(true, Ordering::SeqCst);
}
Ok(sequence)
}
async fn read_from(
&self,
stream_key: &str,
offset: u64,
limit: usize,
) -> Result<Vec<StoredEntry>, DurabilityError> {
let entries = self.inner.read_from(stream_key, offset, limit).await?;
Ok(self.corrupt(stream_key, entries))
}
async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
self.inner.cas(key, old_value, new_value).await
}
async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
self.inner.read_value(key).await
}
async fn scan(&self, prefix: &str) -> Result<Vec<StoredEntry>, DurabilityError> {
self.inner.scan(prefix).await
}
async fn flush(&self) -> Result<(), DurabilityError> {
self.inner.flush().await
}
}
fn enroll(
handler: &ProductionParticipantHandler,
conversation_id: u64,
token: [u8; 16],
connection: ConnectionIncarnation,
) -> Result<ServerValue, Box<dyn Error>> {
let mut conversations = ParticipantConnectionConversations::default();
let value = dispatch_tracked(
handler,
connection,
&mut conversations,
ClientRequest::Enrollment(EnrollmentRequest {
conversation_id,
enrollment_token: EnrollmentToken::new(token),
}),
)?;
Ok(value)
}
pub(super) fn seed_two_conversations() -> Result<Arc<dyn DurableStore>, Box<dyn Error>> {
let store: Arc<dyn DurableStore> = Arc::new(open_ephemeral(1)?);
let handler = ProductionParticipantHandler::new(Arc::clone(&store), test_participant_config())?;
for (conversation_id, token, incarnation) in [
(POISONED_CONVERSATION, POISONED_TOKEN, 1_u64),
(HEALTHY_CONVERSATION, HEALTHY_TOKEN, 2_u64),
] {
let enrolled = enroll(
&handler,
conversation_id,
token,
ConnectionIncarnation::new(701, incarnation),
)?;
if !matches!(enrolled, ServerValue::EnrollBound(_)) {
return Err(format!("seed enrollment did not bind: {enrolled:?}").into());
}
}
drop(handler);
Ok(store)
}
fn pending_boot_config() -> ParticipantConfig {
let mut config = test_participant_config();
config.max_retained_record_rows = 4;
config
}
fn pending_lane_store_with_a_healthy_neighbour() -> Result<Arc<dyn DurableStore>, Box<dyn Error>> {
let fixture = pending_restart_fixture()?;
if fixture.conversation_id != PENDING_CONVERSATION {
return Err(format!(
"the pending-restart fixture moved to conversation {}; this file targets \
{PENDING_CONVERSATION} and would otherwise fault a stream nothing reads",
fixture.conversation_id
)
.into());
}
let enrolled = enroll(
&fixture.handler,
HEALTHY_CONVERSATION,
HEALTHY_TOKEN,
ConnectionIncarnation::new(711, 1),
)?;
if !matches!(enrolled, ServerValue::EnrollBound(_)) {
return Err(format!("neighbour enrollment did not bind: {enrolled:?}").into());
}
let store = Arc::clone(&fixture.handler.store);
drop(fixture);
Ok(store)
}
fn healthy_neighbour_is_served(
handler: &ProductionParticipantHandler,
connection_id: u64,
) -> Result<(), Box<dyn Error>> {
let served = enroll(
handler,
HEALTHY_CONVERSATION,
[0xC5; 16],
ConnectionIncarnation::new(connection_id, 1),
)?;
assert!(
matches!(served, ServerValue::EnrollBound(_)),
"CONTAINMENT: the healthy conversation {HEALTHY_CONVERSATION} stopped serving because \
another conversation on the same store is unloadable: {served:?}"
);
Ok(())
}
#[test]
fn control_uncorrupted_store_boots_and_serves_both_conversations() -> Result<(), Box<dyn Error>> {
let store = seed_two_conversations()?;
let handler = ProductionParticipantHandler::new(Arc::clone(&store), test_participant_config())?;
let ids = handler.registered_conversation_ids()?;
assert!(
ids.contains(&POISONED_CONVERSATION) && ids.contains(&HEALTHY_CONVERSATION),
"control boot lost a conversation: {ids:?}"
);
Ok(())
}
#[test]
fn one_unloadable_record_must_not_prevent_the_node_from_starting() -> Result<(), Box<dyn Error>> {
let inner = seed_two_conversations()?;
let poisoned: Arc<dyn DurableStore> = Arc::new(OneConversationFault::new(
inner,
POISONED_CONVERSATION,
FaultMode::CorruptRecordNow(0),
));
let outcome = ProductionParticipantHandler::new(poisoned, test_participant_config());
assert!(
outcome.is_ok(),
"CONTAINMENT: one unloadable record on conversation {POISONED_CONVERSATION} took the \
whole node down. The boot loop at handler.rs:250 propagated a per-conversation replay \
failure out of a loop over every conversation. Error: {:?}",
outcome.err()
);
Ok(())
}
#[test]
fn one_unloadable_conversation_must_not_take_down_the_others() -> Result<(), Box<dyn Error>> {
let inner = seed_two_conversations()?;
let poisoned: Arc<dyn DurableStore> = Arc::new(OneConversationFault::new(
inner,
POISONED_CONVERSATION,
FaultMode::CorruptRecordNow(0),
));
let handler = ProductionParticipantHandler::new(poisoned, test_participant_config())
.map_err(|error| format!("node did not boot (assertion 1 already covers this): {error}"))?;
healthy_neighbour_is_served(&handler, 702)
}
#[test]
fn the_refusal_must_name_the_conversation_it_refused() -> Result<(), Box<dyn Error>> {
let inner = seed_two_conversations()?;
let poisoned: Arc<dyn DurableStore> = Arc::new(OneConversationFault::new(
inner,
POISONED_CONVERSATION,
FaultMode::CorruptRecordNow(0),
));
let handler = ProductionParticipantHandler::new(poisoned, test_participant_config())
.map_err(|error| format!("node did not boot (assertion 1 already covers this): {error}"))?;
let refusal = enroll(
&handler,
POISONED_CONVERSATION,
[0xC4; 16],
ConnectionIncarnation::new(703, 1),
);
let error = match refusal {
Ok(value) => {
return Err(format!(
"the poisoned conversation answered a request instead of refusing: {value:?}"
)
.into());
}
Err(error) => error.to_string(),
};
assert!(
error.contains(&POISONED_CONVERSATION.to_string()),
"ATTRIBUTION: the refusal names the invariant and not its subject. Nothing in this \
message identifies conversation {POISONED_CONVERSATION}: {error}"
);
Ok(())
}
#[test]
fn control_pending_terminal_lane_and_neighbour_boot_clean() -> Result<(), Box<dyn Error>> {
let store = pending_lane_store_with_a_healthy_neighbour()?;
let handler = ProductionParticipantHandler::new(Arc::clone(&store), pending_boot_config())?;
let ids = handler.registered_conversation_ids()?;
assert!(
ids.contains(&PENDING_CONVERSATION) && ids.contains(&HEALTHY_CONVERSATION),
"control boot lost a conversation: {ids:?}"
);
healthy_neighbour_is_served(&handler, 712)
}
#[test]
fn a_refused_boot_drain_must_not_take_down_the_node() -> Result<(), Box<dyn Error>> {
let inner = pending_lane_store_with_a_healthy_neighbour()?;
let faulted: Arc<dyn DurableStore> = Arc::new(OneConversationFault::new(
inner,
PENDING_CONVERSATION,
FaultMode::RefuseAppend,
));
match ProductionParticipantHandler::new(faulted, pending_boot_config()) {
Ok(handler) => healthy_neighbour_is_served(&handler, 713),
Err(ParticipantSemanticError::BootDrainRefused {
conversation_id,
refusal,
reason,
..
}) => {
panic!(
"CONTAINMENT: the boot drain refused conversation {conversation_id} \
({refusal:?}: {reason}) and handler.rs:265 propagated that refusal out of a loop \
over every conversation, so the whole node did not start."
)
}
Err(other) => Err(format!(
"ROUTE AXIS BROKEN, NOT A CONTAINMENT FAILURE: this arm exists to drive \
handler.rs:265, the boot-drain verdict, but the fault took a different route and \
surfaced as: {other}. Redesign the fault — do not read this as coverage of :265."
)
.into()),
}
}
#[test]
fn control_a_deferred_fault_is_inert_when_boot_appends_nothing() -> Result<(), Box<dyn Error>> {
let inner = seed_two_conversations()?;
let fault = Arc::new(OneConversationFault::new(
inner,
POISONED_CONVERSATION,
FaultMode::CorruptAfterAppend(0),
));
let armed = Arc::clone(&fault.armed);
let corrupted_reads = Arc::clone(&fault.corrupted_reads);
let store: Arc<dyn DurableStore> = fault;
ProductionParticipantHandler::new(store, test_participant_config()).map_err(|error| {
format!("a deferred fault that never armed still broke the boot: {error}")
})?;
assert!(
!armed.load(Ordering::SeqCst),
"the deferred fault armed on a boot that was supposed to append nothing"
);
assert_eq!(
corrupted_reads.load(Ordering::SeqCst),
0,
"the deferred fault served a corrupted read before it was armed"
);
Ok(())
}
#[test]
fn a_post_drain_replay_failure_must_not_take_down_the_node() -> Result<(), Box<dyn Error>> {
let inner = pending_lane_store_with_a_healthy_neighbour()?;
let fault = Arc::new(OneConversationFault::new(
inner,
PENDING_CONVERSATION,
FaultMode::CorruptAfterAppend(0),
));
let armed = Arc::clone(&fault.armed);
let corrupted_reads = Arc::clone(&fault.corrupted_reads);
let store: Arc<dyn DurableStore> = fault;
let outcome = ProductionParticipantHandler::new(store, pending_boot_config());
if !armed.load(Ordering::SeqCst) {
return Err(format!(
"ROUTE AXIS BROKEN, NOT A CONTAINMENT FAILURE: boot never appended to conversation \
{PENDING_CONVERSATION}, so the deferred fault never armed and handler.rs:275 was \
never reached. Boot outcome was {:?}. Redesign the fixture — do not read this as \
coverage of :275.",
outcome.as_ref().err()
)
.into());
}
if corrupted_reads.load(Ordering::SeqCst) == 0 {
return Err(format!(
"ROUTE AXIS BROKEN, NOT A CONTAINMENT FAILURE: the fault armed but nothing read the \
corrupted record afterwards, so handler.rs:275 did not consume it. Boot outcome was \
{:?}.",
outcome.as_ref().err()
)
.into());
}
let handler = match outcome {
Ok(handler) => handler,
Err(error) => panic!(
"CONTAINMENT: conversation {PENDING_CONVERSATION} became unloadable through boot's \
OWN drain append, and handler.rs:275 propagated that out of a loop over every \
conversation, so the whole node did not start. A fix at handler.rs:250 alone leaves \
this route fatal. Error: {error}"
),
};
healthy_neighbour_is_served(&handler, 714)
}