use std::error::Error;
use std::sync::Arc;
use liminal::durability::{DurabilityError, DurableStore, StoredEntry};
use liminal_protocol::wire::{
ClientRequest, ConnectionConversationCapacityExceeded, ConnectionIncarnation,
EnrollmentRequest, EnrollmentToken, InvalidObserverEpoch, ObserverRecoveryHandshake,
ObserverRefusal, ServerValue,
};
use super::ProductionParticipantHandler;
use super::tests::{dispatch, open_disk_store_for_tests, test_participant_config};
const OBSERVER_STREAM_KEY: &str = "liminal:participant-observer-recovery";
#[derive(Debug)]
struct ObserverAppendFailingStore {
inner: Arc<dyn DurableStore>,
}
#[async_trait::async_trait]
impl DurableStore for ObserverAppendFailingStore {
async fn append(
&self,
stream_key: &str,
payload: Vec<u8>,
expected_seq: u64,
) -> Result<u64, DurabilityError> {
if stream_key == OBSERVER_STREAM_KEY {
return Err(DurabilityError::SequenceConflict {
expected: expected_seq,
actual: u64::MAX,
});
}
self.inner.append(stream_key, payload, expected_seq).await
}
async fn read_from(
&self,
stream_key: &str,
offset: u64,
limit: usize,
) -> Result<Vec<StoredEntry>, DurabilityError> {
self.inner.read_from(stream_key, offset, limit).await
}
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
}
}
#[test]
fn enrolled_conversation_without_track_row_recovers_classification() -> Result<(), Box<dyn Error>> {
let home = tempfile::tempdir()?;
let data_dir = home.path().join("durability");
let conversation_id = 801;
{
let disk = open_disk_store_for_tests(&data_dir)?;
let failing: Arc<dyn DurableStore> = Arc::new(ObserverAppendFailingStore { inner: disk });
let handler = ProductionParticipantHandler::new(failing, test_participant_config())?;
let result = dispatch(
&handler,
ConnectionIncarnation::new(81, 1),
ClientRequest::Enrollment(EnrollmentRequest {
conversation_id,
enrollment_token: EnrollmentToken::new([91; 16]),
}),
);
assert!(
result.is_err(),
"a lost Track append must fail the enrollment request loudly: {result:?}"
);
}
let store = open_disk_store_for_tests(&data_dir)?;
let handler = ProductionParticipantHandler::new(store, test_participant_config())?;
let value = dispatch(
&handler,
ConnectionIncarnation::new(82, 1),
ClientRequest::ObserverRecovery(ObserverRecoveryHandshake {
observer_refusals: vec![ObserverRefusal {
conversation_id,
refused_epoch: 1,
}],
}),
)?;
let ServerValue::InvalidObserverEpoch(InvalidObserverEpoch::EpochAhead {
conversation_id: refused_conversation,
presented_epoch,
current_observer_progress,
}) = value
else {
return Err(format!(
"recovered tracking must classify the epoch against durable progress: {value:?}"
)
.into());
};
assert_eq!(refused_conversation, conversation_id);
assert_eq!(presented_epoch, 1);
assert_eq!(current_observer_progress, 0);
Ok(())
}
#[test]
fn capacity_check_counts_tracked_conversation_after_owner_discard() -> Result<(), Box<dyn Error>> {
use crate::server::participant::ParticipantConnectionConversations;
use super::tests::dispatch_tracked;
let home = tempfile::tempdir()?;
let data_dir = home.path().join("durability");
let incarnation = ConnectionIncarnation::new(83, 1);
let bound_conversation = 802;
let fresh_conversation = 803;
let mut config = test_participant_config();
config.max_semantic_conversations_per_connection = 1;
let store = open_disk_store_for_tests(&data_dir)?;
let handler = ProductionParticipantHandler::new(store, config)?;
let mut conversations = ParticipantConnectionConversations::default();
let enrolled = dispatch_tracked(
&handler,
incarnation,
&mut conversations,
ClientRequest::Enrollment(EnrollmentRequest {
conversation_id: bound_conversation,
enrollment_token: EnrollmentToken::new([92; 16]),
}),
)?;
assert!(matches!(enrolled, ServerValue::EnrollBound(_)));
let cell = handler.cell(bound_conversation)?;
let mut owner = cell
.lock()
.map_err(|_| "test conversation owner lock poisoned")?;
*owner = None;
drop(owner);
let value = dispatch_tracked(
&handler,
incarnation,
&mut conversations,
ClientRequest::ObserverRecovery(ObserverRecoveryHandshake {
observer_refusals: vec![ObserverRefusal {
conversation_id: fresh_conversation,
refused_epoch: 0,
}],
}),
)?;
let ServerValue::ConnectionConversationCapacityExceeded(
ConnectionConversationCapacityExceeded::ObserverRecovery {
conversation_id: refused_conversation,
limit,
},
) = value
else {
return Err(
format!("tracked conversation must occupy the configured limit: {value:?}").into(),
);
};
assert_eq!(refused_conversation, fresh_conversation);
assert_eq!(limit, 1);
Ok(())
}