use std::error::Error;
use std::sync::Arc;
use std::time::{Duration, Instant};
use liminal::durability::DurableStore;
use liminal::durability::bridge::block_on;
use liminal_protocol::lifecycle::BindingState;
use liminal_protocol::wire::{
AttachAttemptToken, ClientRequest, CredentialAttachRequest, EnrollBound, EnrollmentRequest,
EnrollmentToken, Generation, ParticipantAck, ParticipantRecord, RecordAdmission,
RecordAdmissionAttemptToken, ServerPush, ServerValue,
};
use super::ProductionParticipantHandler;
use super::e2e_tests::{SocketFixture, SocketPeer};
use super::log::{
DecodedStoredOperation, OperationLog, StoredDetachedCause, StoredDetachedSource,
StoredOperation, StoredTerminalDisposition,
};
use super::tests::test_participant_config;
const CONVERSATION: u64 = 607;
fn small_retention_config() -> crate::config::types::ParticipantConfig {
let mut config = test_participant_config();
config.max_retained_record_rows = 4;
config
}
fn enroll_bound(
value: Result<ServerValue, Box<dyn Error>>,
what: &str,
) -> Result<EnrollBound, Box<dyn Error>> {
match value? {
ServerValue::EnrollBound(receipt) => Ok(receipt),
other => Err(format!("{what} did not bind: {other:?}").into()),
}
}
fn committed_seq(
value: Result<ServerValue, Box<dyn Error>>,
what: &str,
) -> Result<u64, Box<dyn Error>> {
match value? {
ServerValue::RecordCommitted(record) => Ok(record.delivery_seq()),
other => Err(format!("{what} did not commit: {other:?}").into()),
}
}
fn wait_for_pending_detached_residence(
store: &Arc<dyn DurableStore>,
participant_id: u64,
capability_generation: u64,
) -> Result<(), Box<dyn Error>> {
let log = OperationLog::new(Arc::clone(store), CONVERSATION);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let mut sequence = 0_u64;
while let Some(entry) = block_on(log.read_at(sequence))?? {
if let DecodedStoredOperation::V3(StoredOperation::Detached { row }) = entry.operation {
if row.participant_id == participant_id
&& row.disposition == StoredTerminalDisposition::Pending
&& row.cause == StoredDetachedCause::CleanDeregister
&& row.binding_epoch.to_epoch()?.capability_generation.get()
== capability_generation
{
return Ok(());
}
}
sequence = sequence
.checked_add(1)
.ok_or("durable log sequence overflowed")?;
}
if Instant::now() >= deadline {
return Err(format!(
"victim's pending Detached residence (generation {capability_generation}) \
never became durable"
)
.into());
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn drained_row_count(
store: &Arc<dyn DurableStore>,
participant_id: u64,
) -> Result<usize, Box<dyn Error>> {
let log = OperationLog::new(Arc::clone(store), CONVERSATION);
let mut count = 0;
let mut sequence = 0_u64;
while let Some(entry) = block_on(log.read_at(sequence))?? {
if let DecodedStoredOperation::V3(StoredOperation::Detached { row }) = entry.operation {
if row.participant_id == participant_id
&& matches!(row.source, StoredDetachedSource::Drained { .. })
&& matches!(row.disposition, StoredTerminalDisposition::Committed { .. })
{
count += 1;
}
}
sequence = sequence
.checked_add(1)
.ok_or("durable log sequence overflowed")?;
}
Ok(count)
}
fn publish(peer: u64, token: u8, payload: Vec<u8>) -> ClientRequest {
ClientRequest::RecordAdmission(RecordAdmission {
conversation_id: CONVERSATION,
participant_id: peer,
capability_generation: Generation::ONE,
record_admission_attempt_token: RecordAdmissionAttemptToken::new([token; 16]),
payload,
})
}
fn drain_replay(resumed: &mut SocketPeer) -> Vec<u64> {
let mut sequences = Vec::new();
while let Ok(ServerPush::ParticipantDelivery(delivery)) = resumed.read_push() {
if let ParticipantRecord::OrdinaryRecord { .. } = delivery.record {
sequences.push(delivery.delivery_seq);
}
}
sequences
}
struct MintedResidence {
victim_id: u64,
victim_generation: Generation,
victim_secret: liminal_protocol::wire::AttachSecret,
peer_id: u64,
}
fn mint_live_residence(server: &mut SocketFixture) -> Result<MintedResidence, Box<dyn Error>> {
let mut victim_socket = server.spawn_peer()?;
let victim = enroll_bound(
victim_socket.request(ClientRequest::Enrollment(EnrollmentRequest {
conversation_id: CONVERSATION,
enrollment_token: EnrollmentToken::new([0x81; 16]),
})),
"victim enrollment",
)?;
let attached =
victim_socket.request(ClientRequest::CredentialAttach(CredentialAttachRequest {
conversation_id: CONVERSATION,
participant_id: victim.participant_id(),
capability_generation: Generation::ONE,
attach_secret: victim.attach_secret(),
attach_attempt_token: AttachAttemptToken::new([0x82; 16]),
accept_marker_delivery_seq: None,
}))?;
let ServerValue::AttachBound(attached) = attached else {
return Err(format!("victim attach did not bind: {attached:?}").into());
};
let victim_generation = attached.origin_binding_epoch().capability_generation;
let peer = enroll_bound(
server.request(ClientRequest::Enrollment(EnrollmentRequest {
conversation_id: CONVERSATION,
enrollment_token: EnrollmentToken::new([0x85; 16]),
})),
"peer enrollment",
)?;
let r0_seq = committed_seq(
victim_socket.request(ClientRequest::RecordAdmission(RecordAdmission {
conversation_id: CONVERSATION,
participant_id: victim.participant_id(),
capability_generation: victim_generation,
record_admission_attempt_token: RecordAdmissionAttemptToken::new([0x83; 16]),
payload: vec![0x84],
})),
"victim debt record r0",
)?;
let victim_ack = victim_socket.request(ClientRequest::ParticipantAck(ParticipantAck {
conversation_id: CONVERSATION,
participant_id: victim.participant_id(),
capability_generation: victim_generation,
through_seq: r0_seq
.checked_sub(1)
.ok_or("victim debt record committed at the sequence origin")?,
}))?;
if !matches!(victim_ack, ServerValue::AckCommitted(_)) {
return Err(format!("victim pre-crash ack did not commit: {victim_ack:?}").into());
}
let peer_ack = server.request(ClientRequest::ParticipantAck(ParticipantAck {
conversation_id: CONVERSATION,
participant_id: peer.participant_id(),
capability_generation: Generation::ONE,
through_seq: r0_seq,
}))?;
if !matches!(peer_ack, ServerValue::AckCommitted(_)) {
return Err(format!("peer pre-crash ack did not commit: {peer_ack:?}").into());
}
victim_socket.disconnect()?;
victim_socket.shutdown_transport()?;
drop(victim_socket);
wait_for_pending_detached_residence(
&server.durable_store(),
victim.participant_id(),
victim_generation.get(),
)?;
Ok(MintedResidence {
victim_id: victim.participant_id(),
victim_generation,
victim_secret: attached.attach_secret(),
peer_id: peer.participant_id(),
})
}
fn assert_cold_replay_faithful(
store: Arc<dyn DurableStore>,
victim_id: u64,
) -> Result<(), Box<dyn Error>> {
let handler = ProductionParticipantHandler::new(Arc::clone(&store), small_retention_config())?;
let replay_log = OperationLog::new(store, CONVERSATION);
let replayed = handler.replay_aggregate_reference(CONVERSATION, &replay_log)?;
let victim_slot = replayed
.slots
.get(&victim_id)
.ok_or("cold replay erased the drained victim's slot")?;
if !matches!(victim_slot.binding, BindingState::Detached) {
return Err(format!(
"cold replay did not settle the victim at committed Detached: {:?}",
victim_slot.binding
)
.into());
}
if !replayed.tokens.values().any(|mapped| *mapped == victim_id) {
return Err("cold replay unmapped the drained victim's enrollment token".into());
}
let residual = replayed
.frontier()
.ok_or("cold replay lost its frontier")?
.frontiers()
.sequence()
.immutable_candidates()
.len();
if residual != 0 {
return Err(format!("cold replay kept {residual} residual candidates").into());
}
Ok(())
}
#[test]
fn live_socket_publish_drains_pending_detached_then_victim_resumes_and_restart_serves()
-> Result<(), Box<dyn Error>> {
let home = tempfile::tempdir()?;
let data_dir = home.path().join("durability");
let mut server = SocketFixture::start_with_config(&data_dir, small_retention_config())?;
let minted = mint_live_residence(&mut server)?;
let victim_id = minted.victim_id;
let victim_generation = minted.victim_generation;
let victim_secret = minted.victim_secret;
let peer_id = minted.peer_id;
let drain_publish_seq = committed_seq(
server.request(publish(peer_id, 0x87, vec![0x91, 0x92])),
"residence publish",
)?;
if drained_row_count(&server.durable_store(), victim_id)? != 1 {
return Err("drain transaction left no durable Drained Detached row".into());
}
assert_cold_replay_faithful(server.durable_store(), victim_id)?;
let mut resumed = server.spawn_peer()?;
let resumed_bound =
resumed.request(ClientRequest::CredentialAttach(CredentialAttachRequest {
conversation_id: CONVERSATION,
participant_id: victim_id,
capability_generation: victim_generation,
attach_secret: victim_secret,
attach_attempt_token: AttachAttemptToken::new([0x86; 16]),
accept_marker_delivery_seq: None,
}))?;
let ServerValue::AttachBound(resumed_receipt) = resumed_bound else {
return Err(format!(
"drained victim's exact-secret attach did not bind on the wire: {resumed_bound:?}"
)
.into());
};
let replayed_seqs = drain_replay(&mut resumed);
if !replayed_seqs.contains(&drain_publish_seq) {
return Err(format!(
"parked publication {drain_publish_seq} did not replay to the resumed victim: \
{replayed_seqs:?}"
)
.into());
}
let resumed_ack = resumed.request(ClientRequest::ParticipantAck(ParticipantAck {
conversation_id: CONVERSATION,
participant_id: victim_id,
capability_generation: resumed_receipt.origin_binding_epoch().capability_generation,
through_seq: drain_publish_seq,
}))?;
if !matches!(resumed_ack, ServerValue::AckCommitted(_)) {
return Err(format!("resumed victim's ack did not commit: {resumed_ack:?}").into());
}
let repeated = server.request(publish(peer_id, 0x88, vec![0x93]))?;
if !matches!(repeated, ServerValue::RecordCommitted(_)) {
return Err(format!("repeat publish after drain did not commit: {repeated:?}").into());
}
resumed.disconnect()?;
resumed.shutdown_transport()?;
drop(resumed);
wait_for_pending_detached_residence(
&server.durable_store(),
victim_id,
resumed_receipt
.origin_binding_epoch()
.capability_generation
.get(),
)?;
let second_drain = server.request(publish(peer_id, 0x8A, vec![0x94]))?;
if !matches!(second_drain, ServerValue::RecordCommitted(_)) {
return Err(format!("second residence publish did not commit: {second_drain:?}").into());
}
if drained_row_count(&server.durable_store(), victim_id)? != 2 {
return Err("second drain left no second durable Drained Detached row".into());
}
drop(server);
let reopened = SocketFixture::start_with_config(&data_dir, small_retention_config())?;
let mut fresh = reopened.spawn_peer()?;
enroll_bound(
fresh.request(ClientRequest::Enrollment(EnrollmentRequest {
conversation_id: 608,
enrollment_token: EnrollmentToken::new([0x89; 16]),
})),
"post-restart enrollment",
)?;
drop(fresh);
reopened.stop();
Ok(())
}