use std::error::Error;
use std::sync::Arc;
use liminal::durability::{
CountingStore, DurabilityError, DurableStore, ScanCursor, ScanPage, StoredEntry,
bridge::block_on,
};
use liminal_protocol::lifecycle::{
ObserverProgressAdvanceDecision, ObserverProgressTrackDecision, ObserverRecoveryAggregate,
};
use super::observer::{
OBSERVER_SNAPSHOT_EVERY, OBSERVER_SNAPSHOT_KEY, OBSERVER_STREAM_KEY, ObserverLog, ObserverRow,
encode_for_tests, head_after, snapshot_before, snapshot_due, snapshot_record_failures,
};
use super::tests_seed::Shared;
use super::tests_w3_restore_fixture::new_store;
type TestResult = Result<(), Box<dyn Error>>;
const HISTORY_ROWS: u64 = 5_000;
const CONVERSATIONS: u64 = 500;
fn append(
log: &ObserverLog,
head: u64,
aggregate: &ObserverRecoveryAggregate,
row: &ObserverRow,
) -> Result<u64, Box<dyn Error>> {
let snapshot = snapshot_before(head, aggregate);
let next = block_on(log.append_unflushed_for_tests(row, head, snapshot))??;
assert_eq!(Some(next), head_after(head, 1));
Ok(next)
}
fn write_history(log: &ObserverLog) -> Result<(ObserverRecoveryAggregate, u64), Box<dyn Error>> {
write_history_of(log, HISTORY_ROWS)
}
fn write_history_of(
log: &ObserverLog,
rows: u64,
) -> Result<(ObserverRecoveryAggregate, u64), Box<dyn Error>> {
let mut aggregate = ObserverRecoveryAggregate::new();
let mut head = 0_u64;
for written in 0..rows {
let conversation_id = written % CONVERSATIONS + 1;
let row = if written < CONVERSATIONS {
let row = ObserverRow::Track {
conversation_id,
observer_progress: 0,
};
head = append(log, head, &aggregate, &row)?;
aggregate = match aggregate.decide_track(conversation_id, 0) {
ObserverProgressTrackDecision::Commit(transaction) => transaction.commit(),
ObserverProgressTrackDecision::Refuse { .. } => return Err("track refused".into()),
};
continue;
} else if written % 7 == 0 {
let progress = aggregate
.observer_progress(conversation_id)
.ok_or("untracked")?;
ObserverRow::Arms {
arms: vec![(conversation_id, progress)],
}
} else {
let progress = aggregate
.observer_progress(conversation_id)
.ok_or("untracked")?
+ 1;
ObserverRow::Advance {
conversation_id,
observer_progress: progress,
}
};
head = append(log, head, &aggregate, &row)?;
aggregate = match row {
ObserverRow::Arms { arms } => aggregate
.install_restored_arms(&arms)
.map_err(|_| "arms refused")?,
ObserverRow::Advance {
conversation_id,
observer_progress,
} => match aggregate.decide_progress_advance(conversation_id, observer_progress) {
ObserverProgressAdvanceDecision::Commit(transaction) => transaction.commit().0,
ObserverProgressAdvanceDecision::Refuse { .. } => {
return Err("advance refused".into());
}
},
ObserverRow::Track { .. } | ObserverRow::Snapshot { .. } => {
return Err("unexpected row".into());
}
};
}
block_on(log.flush_for_tests())??;
Ok((aggregate, head))
}
#[test]
fn a_tail_fold_from_the_snapshot_equals_the_whole_fold_on_5000_rows() -> Result<(), Box<dyn Error>>
{
let store = new_store()?;
let (live, head) = write_history(&ObserverLog::new(Arc::clone(&store)))?;
let snapshots = head - HISTORY_ROWS;
assert_eq!(snapshots, HISTORY_ROWS / OBSERVER_SNAPSHOT_EVERY);
let whole = block_on(ObserverLog::new(Arc::clone(&store)).restore_whole_for_tests())??;
assert_eq!(whole.aggregate, live);
assert_eq!(whole.next_sequence, head);
let counting = Arc::new(CountingStore::new(Shared(Arc::clone(&store))));
let counts = counting.handle();
let counted: Arc<dyn DurableStore> = counting;
let restored = block_on(ObserverLog::new(counted).restore())??;
assert_eq!(restored.aggregate, whole.aggregate);
assert_eq!(restored.next_sequence, head);
let counted = counts.snapshot();
assert_eq!(counted.read_at_calls, 1, "the snapshot is one point read");
assert!(
counted.read_from_rows <= OBSERVER_SNAPSHOT_EVERY,
"restore read {} rows past its snapshot",
counted.read_from_rows
);
assert_eq!(counted.cas_calls, 0, "a clean restore records nothing");
Ok(())
}
#[test]
fn snapshots_fall_once_per_interval() {
let due: Vec<u64> = (0..3 * (OBSERVER_SNAPSHOT_EVERY + 1))
.filter(|head| snapshot_due(*head))
.collect();
assert_eq!(
due,
vec![
OBSERVER_SNAPSHOT_EVERY,
2 * OBSERVER_SNAPSHOT_EVERY + 1,
3 * OBSERVER_SNAPSHOT_EVERY + 2
]
);
assert_eq!(
head_after(0, OBSERVER_SNAPSHOT_EVERY),
Some(OBSERVER_SNAPSHOT_EVERY)
);
assert_eq!(
head_after(0, OBSERVER_SNAPSHOT_EVERY + 1),
Some(OBSERVER_SNAPSHOT_EVERY + 2)
);
assert_eq!(head_after(u64::MAX, 1), None);
}
#[test]
fn a_record_naming_a_non_snapshot_row_falls_back_to_the_whole_fold() -> Result<(), Box<dyn Error>> {
let store = new_store()?;
let (live, head) = write_history(&ObserverLog::new(Arc::clone(&store)))?;
let recorded = block_on(store.read_value(super::observer::OBSERVER_SNAPSHOT_KEY))??
.ok_or("no snapshot recorded")?;
block_on(store.cas(
super::observer::OBSERVER_SNAPSHOT_KEY,
recorded,
recorded + 1,
))??;
let restored = block_on(ObserverLog::new(Arc::clone(&store)).restore())??;
assert_eq!(restored.aggregate, live);
assert_eq!(restored.next_sequence, head);
Ok(())
}
#[derive(Debug)]
struct SnapshotKeyFails(Arc<dyn DurableStore>);
impl SnapshotKeyFails {
fn refuse(key: &str) -> Result<(), DurabilityError> {
if key == OBSERVER_SNAPSHOT_KEY {
Err(DurabilityError::ConfigError(
"injected snapshot key failure".to_owned(),
))
} else {
Ok(())
}
}
}
#[async_trait::async_trait]
impl DurableStore for SnapshotKeyFails {
async fn append(
&self,
stream_key: &str,
payload: Vec<u8>,
expected_seq: u64,
) -> Result<u64, DurabilityError> {
self.0.append(stream_key, payload, expected_seq).await
}
async fn append_all(
&self,
stream_key: &str,
payloads: Vec<Vec<u8>>,
expected_seq: u64,
) -> Result<u64, DurabilityError> {
self.0.append_all(stream_key, payloads, expected_seq).await
}
async fn read_from(
&self,
stream_key: &str,
offset: u64,
limit: usize,
) -> Result<Vec<StoredEntry>, DurabilityError> {
self.0.read_from(stream_key, offset, limit).await
}
async fn read_at(
&self,
stream_key: &str,
sequence: u64,
) -> Result<Option<StoredEntry>, DurabilityError> {
self.0.read_at(stream_key, sequence).await
}
async fn cas(&self, key: &str, old_value: u64, new_value: u64) -> Result<(), DurabilityError> {
Self::refuse(key)?;
self.0.cas(key, old_value, new_value).await
}
async fn read_value(&self, key: &str) -> Result<Option<u64>, DurabilityError> {
Self::refuse(key)?;
self.0.read_value(key).await
}
async fn scan_page(
&self,
prefix: &str,
cursor: Option<ScanCursor>,
limit: usize,
) -> Result<ScanPage, DurabilityError> {
self.0.scan_page(prefix, cursor, limit).await
}
async fn flush(&self) -> Result<(), DurabilityError> {
self.0.flush().await
}
}
#[test]
fn a_snapshot_record_that_fails_is_counted_and_never_a_failed_append() -> TestResult {
let store = new_store()?;
let failing: Arc<dyn DurableStore> = Arc::new(SnapshotKeyFails(Arc::clone(&store)));
let before = snapshot_record_failures();
let rows = OBSERVER_SNAPSHOT_EVERY + 44;
let (live, head) = write_history_of(&ObserverLog::new(failing), rows)?;
assert_eq!(Some(head), head_after(0, rows));
assert_eq!(
snapshot_record_failures() - before,
1,
"the one due snapshot's record failure was not counted"
);
assert_eq!(block_on(store.read_value(OBSERVER_SNAPSHOT_KEY))??, None);
let restored = block_on(ObserverLog::new(store).restore())??;
assert_eq!(restored.aggregate, live);
assert_eq!(restored.next_sequence, head);
Ok(())
}
fn legacy_stream(
store: &Arc<dyn DurableStore>,
rows: u64,
) -> Result<ObserverRecoveryAggregate, Box<dyn Error>> {
let mut aggregate = ObserverRecoveryAggregate::new();
let mut payloads = Vec::new();
for conversation_id in 1..=rows {
payloads.push(encode_for_tests(&ObserverRow::Track {
conversation_id,
observer_progress: 0,
})?);
aggregate = match aggregate.decide_track(conversation_id, 0) {
ObserverProgressTrackDecision::Commit(transaction) => transaction.commit(),
ObserverProgressTrackDecision::Refuse { .. } => return Err("track refused".into()),
};
}
block_on(store.append_all(OBSERVER_STREAM_KEY, payloads, 0))??;
block_on(store.flush())??;
Ok(aggregate)
}
#[test]
fn a_long_stream_without_a_snapshot_takes_one_at_its_first_restore() -> TestResult {
let store = new_store()?;
let rows = OBSERVER_SNAPSHOT_EVERY + 44;
let live = legacy_stream(&store, rows)?;
let first = block_on(ObserverLog::new(Arc::clone(&store)).restore())??;
assert_eq!(first.aggregate, live);
assert_eq!(first.next_sequence, rows + 1, "no snapshot row was written");
assert_eq!(
block_on(store.read_value(OBSERVER_SNAPSHOT_KEY))??,
Some(rows + 1)
);
let counting = Arc::new(CountingStore::new(Shared(Arc::clone(&store))));
let counts = counting.handle();
let counted: Arc<dyn DurableStore> = counting;
let second = block_on(ObserverLog::new(counted).restore())??;
assert_eq!(second.aggregate, live);
assert_eq!(second.next_sequence, rows + 1);
let counted = counts.snapshot();
assert_eq!(counted.read_from_rows, 0, "the second restore folded rows");
assert_eq!(counted.append_all_calls, 0, "the second restore wrote");
Ok(())
}
#[test]
fn a_stream_from_before_snapshots_restores_and_takes_its_first_snapshot() -> TestResult {
let store = new_store()?;
let legacy = OBSERVER_SNAPSHOT_EVERY - 56;
let mut aggregate = legacy_stream(&store, legacy)?;
let log = ObserverLog::new(Arc::clone(&store));
let restored = block_on(log.restore())??;
assert_eq!(restored.aggregate, aggregate);
assert_eq!(
restored.next_sequence, legacy,
"a short stream was written to"
);
assert_eq!(block_on(store.read_value(OBSERVER_SNAPSHOT_KEY))??, None);
let mut head = restored.next_sequence;
for conversation_id in legacy + 1..=legacy + 100 {
let row = ObserverRow::Track {
conversation_id,
observer_progress: 0,
};
let snapshot = snapshot_before(head, &aggregate);
let next = block_on(log.append(&row, head, snapshot))??;
assert_eq!(Some(next), head_after(head, 1));
head = next;
aggregate = match aggregate.decide_track(conversation_id, 0) {
ObserverProgressTrackDecision::Commit(transaction) => transaction.commit(),
ObserverProgressTrackDecision::Refuse { .. } => return Err("track refused".into()),
};
}
assert_eq!(
block_on(store.read_value(OBSERVER_SNAPSHOT_KEY))??,
Some(OBSERVER_SNAPSHOT_EVERY + 1),
"the first snapshot was not taken at the first due position"
);
let again = block_on(ObserverLog::new(store).restore())??;
assert_eq!(again.aggregate, aggregate);
assert_eq!(again.next_sequence, head);
Ok(())
}