use std::collections::BTreeSet;
use std::error::Error;
use std::sync::Arc;
use std::time::Instant;
use liminal::durability::DurableStore;
use liminal::durability::bridge::block_on;
use liminal_protocol::wire::{
ClientRequest, ConnectionIncarnation, EnrollmentRequest, EnrollmentToken, Generation,
RecordAdmission, RecordAdmissionAttemptToken, ServerValue,
};
use crate::server::participant::{
ConnectionFateClass, ConnectionFateWorkItem, ParticipantConnectionConversations,
};
use super::ProductionParticipantHandler;
use super::log::{
DecodedStoredOperation, GENERATION_KEY_PREFIX, GENERATION_STREAM_PREFIX, OperationLog,
STREAM_PREFIX, StoredOperation, StoredTerminalDisposition,
};
use super::observer::{ObserverLog, ObserverRow, encode_for_tests};
use super::outbox_log::OUTBOX_STREAM_PREFIX;
use super::registry::{ConversationRegistry, REGISTRY_STREAM_KEY};
use super::registry_snapshot::SNAPSHOT_KEY;
use super::restore_checkpoint_locator::checkpoint_conversation_route;
use super::route_keys::conversation_windows;
use super::tests::{dispatch_tracked, open_disk_store_for_tests, test_participant_config};
use super::tests_store_open::open_store_for_fixture_build;
use super::tests_store_tally::{Asked, Tally, under};
use super::tests_terminal_in_place::{admit, drop_connection, enroll_and_attach};
type TestResult<T = ()> = Result<T, Box<dyn Error>>;
const HISTORIES: [u64; 2] = [100, 1_000];
const LIVE: u64 = 0x7_0000;
const FIRST_FINISHED: u64 = 0x7_0001;
const TABLE: &str = "conversation_retirement_lifetime";
fn at_cap_config() -> crate::config::types::ParticipantConfig {
let mut config = test_participant_config();
config.max_retained_record_rows = 4;
config.max_retired_identity_slots_server = 4_096;
config
}
fn committed(value: &ServerValue) -> TestResult {
if matches!(value, ServerValue::RecordCommitted(_)) {
Ok(())
} else {
Err(format!("the record did not commit: {value:?}").into())
}
}
#[derive(Debug)]
struct Built {
commits: u64,
millis: u128,
}
fn died_pending(store: Arc<dyn DurableStore>, conversation_id: u64) -> TestResult<bool> {
let log = OperationLog::new(store, conversation_id);
let mut sequence = 0_u64;
let mut pending = false;
while let Some(entry) = block_on(log.read_at(sequence))?? {
if let DecodedStoredOperation::V3(StoredOperation::Died { row }) = entry.operation {
pending =
row.disposition == StoredTerminalDisposition::Pending && row.drained.is_none();
}
sequence = sequence.checked_add(1).ok_or("log sequence overflowed")?;
}
Ok(pending)
}
struct Peer {
connection: ConnectionIncarnation,
conversations: ParticipantConnectionConversations,
participant_id: u64,
}
fn enroll_peer(
handler: &ProductionParticipantHandler,
conversation_id: u64,
connection: ConnectionIncarnation,
) -> TestResult<Peer> {
let mut conversations = ParticipantConnectionConversations::default();
let enrolled = dispatch_tracked(
handler,
connection,
&mut conversations,
ClientRequest::Enrollment(EnrollmentRequest {
conversation_id,
enrollment_token: EnrollmentToken::new([0x31; 16]),
}),
)?;
let ServerValue::EnrollBound(peer) = enrolled else {
return Err(format!("the peer did not bind: {enrolled:?}").into());
};
Ok(Peer {
connection,
conversations,
participant_id: peer.participant_id(),
})
}
fn peer_drains_then_dies(
handler: &ProductionParticipantHandler,
conversation_id: u64,
mut peer: Peer,
open_sequence: u64,
) -> TestResult {
committed(&dispatch_tracked(
handler,
peer.connection,
&mut peer.conversations,
ClientRequest::RecordAdmission(RecordAdmission {
conversation_id,
participant_id: peer.participant_id,
capability_generation: Generation::ONE,
record_admission_attempt_token: RecordAdmissionAttemptToken::new([0x32; 16]),
payload: vec![0x32],
}),
)?)?;
handler
.apply_connection_fate_with_impacts(&ConnectionFateWorkItem {
open_sequence,
connection_incarnation: peer.connection,
class: ConnectionFateClass::ConnectionLost,
tracked_conversations: peer.conversations.tracked_conversations(),
})
.into_result()?;
Ok(())
}
fn finish_conversations(store: &Arc<Tally>, count: u64) -> TestResult<Built> {
let handler = ProductionParticipantHandler::new(store.shared(), at_cap_config())?;
let mut live = enroll_and_attach(&handler, LIVE, ConnectionIncarnation::new(7, 1), 0x11)?;
committed(&admit(&handler, LIVE, &mut live, 0x12)?)?;
drop_connection(&handler, &live, ConnectionFateClass::CleanDisconnect, 1)?;
store.take()?;
let started = Instant::now();
for index in 0..count {
let conversation_id = FIRST_FINISHED + index;
let ordinal = index
.checked_mul(2)
.ok_or("connection ordinal overflowed")?;
let sender = ConnectionIncarnation::new(7, ordinal + 2);
let mut member = enroll_and_attach(&handler, conversation_id, sender, 0x21)?;
let peer = enroll_peer(
&handler,
conversation_id,
ConnectionIncarnation::new(7, ordinal + 3),
)?;
committed(&admit(&handler, conversation_id, &mut member, 0x22)?)?;
drop_connection(
&handler,
&member,
ConnectionFateClass::ConnectionLost,
1_000 + ordinal,
)?;
if !died_pending(store.shared(), conversation_id)? {
return Err(format!(
"conversation {conversation_id}: the sender's Died did not pend in the lane"
)
.into());
}
peer_drains_then_dies(&handler, conversation_id, peer, 1_001 + ordinal)?;
if !died_pending(store.shared(), conversation_id)? {
return Err(format!(
"conversation {conversation_id}: the peer's Died did not pend in the lane, so no boot drain will close it"
)
.into());
}
}
let millis = started.elapsed().as_millis();
let commits = store.take()?.commits;
drop(handler);
block_on(store.flush())??;
Ok(Built { commits, millis })
}
#[derive(Debug, PartialEq, Eq)]
struct LiveSized {
restored: Vec<u64>,
observer_progress: usize,
observer_armed: usize,
observer_checkpoint_bytes: usize,
registry_live: BTreeSet<u64>,
registry_sweeping: usize,
conversation_streams: BTreeSet<String>,
conversation_rows: u64,
scan_routed_calls: u64,
read_value_calls: u64,
}
#[derive(Debug)]
struct Booted {
asked: Asked,
millis: u128,
live: LiveSized,
registry_snapshot_bytes: usize,
}
fn boot(store: &Arc<Tally>) -> TestResult<Booted> {
store.take()?;
let started = Instant::now();
let handler = ProductionParticipantHandler::new_restored(store.shared(), at_cap_config())?;
let millis = started.elapsed().as_millis();
let asked = store.take()?;
let restored = handler.restored_conversation_ids()?;
drop(handler);
let observer = block_on(ObserverLog::new(store.shared()).restore())??;
let checkpoint = encode_for_tests(&ObserverRow::snapshot_of(&observer.aggregate))?;
let sets = ConversationRegistry::new(store.shared()).read_sets()?;
let snapshot = block_on(store.get_routed(REGISTRY_STREAM_KEY, SNAPSHOT_KEY))??
.ok_or("the registry holds no snapshot after its history")?;
let live = LiveSized {
restored,
observer_progress: observer.aggregate.progress_rows().len(),
observer_armed: observer.aggregate.armed_rows().len(),
observer_checkpoint_bytes: checkpoint.len(),
registry_live: sets.live,
registry_sweeping: sets.sweeping.len(),
conversation_streams: asked.conversation_streams.clone(),
conversation_rows: asked.conversation_rows,
scan_routed_calls: asked.scan_routed_calls,
read_value_calls: asked.read_value_calls,
};
Ok(Booted {
asked,
millis,
live,
registry_snapshot_bytes: snapshot.len(),
})
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct Residue {
keys: u64,
bytes: u64,
}
fn residue(store: &dyn DurableStore, conversation_id: u64) -> TestResult<Residue> {
let mut streams = vec![
format!("{STREAM_PREFIX}{conversation_id}"),
format!("{OUTBOX_STREAM_PREFIX}{conversation_id}"),
];
streams.extend(
(1..=4)
.map(|generation| format!("{GENERATION_STREAM_PREFIX}{generation}:{conversation_id}")),
);
let mut left = Residue::default();
let mut count = |keys: usize, bytes: usize| {
left.keys += u64::try_from(keys).unwrap_or(u64::MAX);
left.bytes += u64::try_from(bytes).unwrap_or(u64::MAX);
};
for stream in &streams {
let rows = block_on(store.read_from(stream, 0, usize::MAX))??;
count(rows.len(), rows.iter().map(|row| row.payload.len()).sum());
if block_on(store.read_stream_next_seq(stream))??.is_some() {
count(1, std::mem::size_of::<u64>());
}
}
if block_on(store.read_value(&format!("{GENERATION_KEY_PREFIX}{conversation_id}")))??.is_some()
{
count(1, std::mem::size_of::<u64>());
}
let route = checkpoint_conversation_route(conversation_id);
for (from, to) in conversation_windows(conversation_id, &streams)? {
let entries = block_on(store.scan_routed(&route, &from, &to))??;
count(
entries.len(),
entries
.iter()
.map(|(key, value)| key.len() + value.len())
.sum(),
);
}
Ok(left)
}
fn worst_residue(store: &Arc<Tally>, count: u64) -> TestResult<Residue> {
let mut worst = Residue::default();
for index in 0..count {
let left = residue(store.inner.as_ref(), FIRST_FINISHED + index)?;
if (left.keys, left.bytes) > (worst.keys, worst.bytes) {
worst = left;
}
}
Ok(worst)
}
struct Measured {
closing_commits: u64,
booted: Booted,
worst: Residue,
}
fn measure(history: u64) -> TestResult<Measured> {
let name = format!("retirement-history-{history}");
let copy = crate::test_fixture::fixture_copy(&name, |data| {
let store = Tally::new(open_store_for_fixture_build(&data.join("store"))?);
let built = finish_conversations(&store, history)?;
let closing = boot(&store)?;
if closing.live.registry_live != BTreeSet::from([LIVE])
|| closing.live.registry_sweeping != 0
{
return Err(format!(
"the closing boot after {history} conversations left the registry at {:?} live and {} sweeping",
closing.live.registry_live, closing.live.registry_sweeping
)
.into());
}
println!(
"history {history}: built once in {} ms with {} commits; closing boot {} ms with {} commits",
built.millis, built.commits, closing.millis, closing.asked.commits
);
block_on(store.flush())??;
std::fs::write(
data.join("closing-commits"),
closing.asked.commits.to_string(),
)?;
Ok(())
})?;
let closing_commits = std::fs::read_to_string(copy.path().join("closing-commits"))?.parse()?;
let store = Tally::new(open_disk_store_for_tests(©.path().join("store"))?);
let booted = boot(&store)?;
let worst = worst_residue(&store, history)?;
println!(
"history {history}: measured boot {} ms; asked {:?}; holds {:?}; worst residue {worst:?}",
booted.millis, booted.asked, booted.live
);
Ok(Measured {
closing_commits,
booted,
worst,
})
}
#[test]
fn a_boot_after_a_thousand_finished_conversations_costs_what_a_boot_after_a_hundred_does()
-> TestResult {
let small = measure(HISTORIES[0])?;
let large = measure(HISTORIES[1])?;
let extra = HISTORIES[1] - HISTORIES[0];
if large.closing_commits < small.closing_commits + extra {
return Err(format!(
"the larger history's closing boot committed {} against the smaller's {}: fewer than its {extra} extra closes",
large.closing_commits, small.closing_commits
)
.into());
}
assert_eq!(
small.booted.live, large.booted.live,
"a boot's live-sized work grew with finished conversations: after {} against after {}",
HISTORIES[0], HISTORIES[1]
);
assert_eq!(large.booted.live.registry_live, BTreeSet::from([LIVE]));
assert_eq!(
large.booted.live.conversation_streams,
BTreeSet::from([format!("{OUTBOX_STREAM_PREFIX}{LIVE}")]),
"the measured boot read one stream: the live conversation's outbox, never a finished one's and never a log"
);
let worst = |read: fn(&Measured) -> u64| read(&small).max(read(&large));
under(
TABLE,
"observer_rows_folded",
worst(|m| m.booted.asked.observer_rows),
)?;
under(
TABLE,
"registry_snapshot_bytes",
worst(|m| u64::try_from(m.booted.registry_snapshot_bytes).unwrap_or(u64::MAX)),
)?;
under(
TABLE,
"read_from_calls",
worst(|m| m.booted.asked.read_from_calls),
)?;
under(
TABLE,
"get_routed_calls",
worst(|m| m.booted.asked.get_routed_calls),
)?;
under(
TABLE,
"scan_routed_calls",
worst(|m| m.booted.asked.scan_routed_calls),
)?;
under(
TABLE,
"read_value_calls",
worst(|m| m.booted.asked.read_value_calls),
)?;
under(
TABLE,
"residue_keys_per_retired_conversation",
worst(|m| m.worst.keys),
)?;
println!(
"residue per retired conversation: {} keys, {} bytes (never zero: stream heads and the tombstone stay)",
worst(|m| m.worst.keys),
worst(|m| m.worst.bytes)
);
Ok(())
}