Trusty: provenance library workspace (initial import)
This commit is contained in:
280
crates/adapters/trusty-tokio/tests/runtime.rs
Normal file
280
crates/adapters/trusty-tokio/tests/runtime.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user