Files
Trusty/crates/adapters/trusty-tokio/tests/runtime.rs

281 lines
8.6 KiB
Rust

//! Does the async facade actually keep the runtime free?
//!
//! That is the only claim it makes, so it is the one worth testing.
//! Everything else here exists because it is a way the facade could be
//! quietly wrong: a lost value, a swallowed panic, a truncation that
//! reads as a complete chain.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use trusty_store::{
KvCrossCheckReport, LastRow, RowInsert, Store, StoreError, StoreResult, StoredRow, TipRow,
TipUpsert,
};
use trusty_tokio::{AsyncStore, BlockingStore};
/// A store that is deliberately slow, so blocking is observable.
struct SlowStore {
delay: Duration,
rows: usize,
inserts: Arc<AtomicUsize>,
panic_on_insert: bool,
}
impl SlowStore {
fn new(delay_ms: u64) -> Self {
Self {
delay: Duration::from_millis(delay_ms),
rows: 0,
inserts: Arc::new(AtomicUsize::new(0)),
panic_on_insert: false,
}
}
fn with_rows(mut self, rows: usize) -> Self {
self.rows = rows;
self
}
}
fn stored_row(sequence: i64) -> StoredRow {
StoredRow {
id: sequence + 1,
entity_id: "e".into(),
chain_id: "c".into(),
event_type: "MUTATION".into(),
sequence,
prev_hash: String::new(),
row_hash: Some(format!("{sequence:064}")),
anchor_hash: None,
raw_payload: None,
detected_at: String::new(),
received_at: String::new(),
source_url: String::new(),
extraction_method: "trusty".into(),
}
}
impl Store for SlowStore {
fn last_row(&self, _entity_id: &str) -> StoreResult<Option<LastRow>> {
std::thread::sleep(self.delay);
Ok(Some(LastRow {
row_hash: "aa".into(),
sequence: 7,
}))
}
fn insert_row(&mut self, _row: &RowInsert) -> StoreResult<i64> {
std::thread::sleep(self.delay);
if self.panic_on_insert {
panic!("a bug inside a store operation");
}
Ok(self.inserts.fetch_add(1, Ordering::SeqCst) as i64 + 1)
}
fn iter_chain<'a>(
&'a self,
_entity_id: &str,
) -> StoreResult<Box<dyn Iterator<Item = StoreResult<StoredRow>> + 'a>> {
Ok(Box::new((0..self.rows as i64).map(|i| Ok(stored_row(i)))))
}
fn get_tip(&self, _chain_id: &str) -> StoreResult<Option<TipRow>> {
Ok(None)
}
fn get_tip_for_entity(&self, _entity_id: &str) -> StoreResult<Option<TipRow>> {
Ok(None)
}
fn set_tip(&mut self, _tip: &TipUpsert) -> StoreResult<()> {
Ok(())
}
fn kv_cross_check(&self) -> StoreResult<KvCrossCheckReport> {
Err(StoreError::Backend("not implemented for this test".into()))
}
fn with_txn(
&mut self,
f: &mut dyn FnMut(&mut dyn Store) -> StoreResult<()>,
) -> StoreResult<()> {
let mut scratch = SlowStore::new(0);
f(&mut scratch)
}
}
fn row_insert() -> RowInsert {
RowInsert {
entity_id: "e".into(),
chain_id: "c".into(),
event_type: "MUTATION".into(),
sequence: 0,
prev_hash: String::new(),
row_hash: None,
anchor_hash: None,
raw_payload: None,
detected_at: String::new(),
received_at: String::new(),
source_url: String::new(),
extraction_method: "trusty".into(),
}
}
/// **The claim.** A single-worker runtime, one slow store call, and an
/// unrelated task that must keep running while it is in flight.
///
/// Single-threaded on purpose: with more than one worker the test would
/// pass even if the call blocked, because another worker would pick the
/// second task up. One worker is the only configuration where blocking
/// is fatal and therefore the only one that proves anything.
#[tokio::test(flavor = "current_thread")]
async fn a_slow_store_call_does_not_park_the_runtime() {
let store = BlockingStore::new(SlowStore::new(300));
let ticks = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&ticks);
let ticker = tokio::spawn(async move {
for _ in 0..20 {
tokio::time::sleep(Duration::from_millis(10)).await;
counter.fetch_add(1, Ordering::SeqCst);
}
});
store.insert_row(row_insert()).await.expect("insert");
let progressed = ticks.load(Ordering::SeqCst);
ticker.abort();
assert!(
progressed > 5,
"the other task must have kept running while the store was busy. \
It ticked {progressed} times; blocking the worker would have left \
it at or near zero, which is exactly the runtime starvation this \
facade exists to prevent"
);
}
#[tokio::test(flavor = "current_thread")]
async fn concurrent_callers_serialise_rather_than_race() {
let store = BlockingStore::new(SlowStore::new(20));
let mut handles = Vec::new();
for _ in 0..8 {
let s = store.clone();
handles.push(tokio::spawn(async move { s.insert_row(row_insert()).await }));
}
let mut ids = Vec::new();
for h in handles {
ids.push(h.await.expect("join").expect("insert"));
}
ids.sort_unstable();
assert_eq!(
ids,
(1..=8).collect::<Vec<i64>>(),
"every caller must get its own row and none may be lost. The \
backend serialises writes; that is its property, made visible \
here rather than hidden"
);
}
#[tokio::test]
async fn a_panic_in_the_store_becomes_an_error_not_a_dead_task() {
let mut slow = SlowStore::new(0);
slow.panic_on_insert = true;
let store = BlockingStore::new(slow);
let result = store.insert_row(row_insert()).await;
assert!(
matches!(result, Err(StoreError::Backend(_))),
"a panic inside a store operation is a bug, but one that unwinds \
into an unrelated task is far harder to find than one arriving as \
an error at the call site that caused it"
);
}
#[tokio::test]
async fn a_bounded_read_says_when_it_left_something_behind() {
let store = BlockingStore::new(SlowStore::new(0).with_rows(10));
let (rows, more) = store.chain_rows_bounded("e", 4).await.expect("bounded");
assert_eq!(rows.len(), 4);
assert!(
more,
"a truncated history that looks complete is how a verifier \
concludes a chain is intact having seen only the part that is"
);
let (all, more) = store.chain_rows_bounded("e", 50).await.expect("bounded");
assert_eq!(all.len(), 10);
assert!(!more, "with nothing left behind it must not claim there is");
}
#[tokio::test]
async fn the_whole_chain_can_still_be_read_when_that_is_wanted() {
let store = BlockingStore::new(SlowStore::new(0).with_rows(5));
let rows = store.chain_rows("e").await.expect("chain");
assert_eq!(
rows.len(),
5,
"iter_chain cannot cross a thread boundary, so this materialises \
instead of streaming -- but it must still return everything"
);
}
#[tokio::test]
async fn a_transaction_carries_its_result_out_by_value() {
let store = BlockingStore::new(SlowStore::new(0));
let answer = store
.with_txn_owned(|txn| {
txn.insert_row(&row_insert())?;
Ok("committed".to_string())
})
.await
.expect("txn");
assert_eq!(
answer, "committed",
"the blocking with_txn captures results through outer state \
because its closure cannot return one; an owned closure can, \
which removes the chance of reading a captured slot on a path \
where the transaction rolled back"
);
}
#[tokio::test]
async fn a_failing_transaction_reports_the_failure_not_a_missing_value() {
let store = BlockingStore::new(SlowStore::new(0));
let result: StoreResult<String> = store
.with_txn_owned(|_txn| Err(StoreError::Backend("deliberate".into())))
.await;
match result {
Err(StoreError::Backend(msg)) => assert_eq!(
msg, "deliberate",
"the body's own error must survive, not be replaced by a \
complaint that no value was produced"
),
other => panic!("expected the body's error, got {other:?}"),
}
}
#[tokio::test]
async fn clones_share_one_store() {
let store = BlockingStore::new(SlowStore::new(0));
let clone = store.clone();
store.insert_row(row_insert()).await.expect("first");
let second = clone.insert_row(row_insert()).await.expect("second");
assert_eq!(
second, 2,
"a clone must talk to the same store, or two halves of an \
application would be appending to different chains while \
believing they shared one"
);
}