Files
improvise/crates/zset/tests/rollback.rs
T

310 lines
9.2 KiB
Rust

//! `Circuit::begin`/`rollback`/`commit` (ADR 009 clause 11): a failed step is undone, and
//! the circuit then behaves as if the step never ran. Evidence: `context/test-evidence/zset.md`.
use proptest::prelude::*;
use std::panic::{AssertUnwindSafe, catch_unwind};
use zset::{Circuit, Input, ZSet};
type Z<K> = ZSet<K>;
type P = (u8, u8);
/// A circuit using every stateful operator, and two closures: every stream's
/// `current()`, and every sink drained.
struct Rig {
c: Circuit,
a: Input<P>,
b: Input<P>,
view: Box<dyn Fn() -> String>,
drain: Box<dyn Fn() -> String>,
}
fn rig() -> Rig {
let mut c = Circuit::new();
let (a, sa) = c.input::<P>();
let (b, sb) = c.input::<P>();
let ia = c.integrate(&sa);
let da = c.delay(&sa);
let df = c.differentiate(&sa);
let ds = c.distinct(&sa);
let j = c.join_indexed(&sa, &sb, |k, x, y| (*k, *x, *y));
let ij = c.integrate(&j);
let ag = c.aggregate(&sa, |_, vals| {
Some(vals.iter().fold(0i64, |s, (v, w)| {
s.wrapping_add(i64::from(*v).wrapping_mul(*w))
}))
});
let pl = c.plus(&ia, &sb);
let sinks = (
c.output(&ia),
c.output(&da),
c.output(&df),
c.output(&ds),
c.output(&j),
c.output(&ij),
c.output(&ag),
c.output(&pl),
);
let (s1, s2) = (sa.clone(), sb.clone());
let view = move || {
format!(
"{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}",
s1.current(),
s2.current(),
ia.current(),
da.current(),
df.current(),
ds.current(),
j.current(),
ij.current(),
ag.current(),
pl.current(),
s1.current().len(),
)
};
let drain = move || {
format!(
"{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}",
sinks.0.take(),
sinks.1.take(),
sinks.2.take(),
sinks.3.take(),
sinks.4.take(),
sinks.5.take(),
sinks.6.take(),
sinks.7.take(),
)
};
Rig {
c,
a,
b,
view: Box::new(view),
drain: Box::new(drain),
}
}
impl Rig {
fn tick(&mut self, a: &Z<P>, b: &Z<P>) {
self.a.push(a.clone());
self.b.push(b.clone());
self.c.step();
}
}
fn delta() -> impl Strategy<Value = Z<P>> {
prop::collection::vec(((0u8..4, 0u8..3), -3i64..=3), 0..6).prop_map(|p| p.into_iter().collect())
}
fn ticks(n: std::ops::Range<usize>) -> impl Strategy<Value = Vec<(Z<P>, Z<P>)>> {
prop::collection::vec((delta(), delta()), n)
}
/// The key that sits near `i64::MAX` once preloaded, so a later `+5` overflows it.
const BIG: P = (9, 9);
fn poison() -> Z<P> {
// `(0, 0)` sorts first, so operators have applied it before `BIG` overflows.
[((0, 0), 1), (BIG, 5)].into_iter().collect()
}
proptest! {
/// A step that fails half way, then a rollback, leaves the circuit as it was; the ticks
/// after run as if the failed step had never been attempted.
#[test]
fn a_rolled_back_step_leaves_the_circuit_as_it_was_and_the_ticks_after_unchanged(
pre in ticks(0..4),
post in ticks(1..4),
carry in delta(),
bad in (delta(), delta()),
fail in any::<bool>(),
) {
let (mut x, mut y) = (rig(), rig());
let preload: Z<P> = [(BIG, i64::MAX - 1)].into_iter().collect();
for (a, b) in pre.iter().chain([&(preload, Z::new())]) {
x.tick(a, b);
y.tick(a, b);
prop_assert_eq!((x.view)(), (y.view)(), "[pre] state");
prop_assert_eq!((x.drain)(), (y.drain)(), "[pre] output");
}
x.a.push(carry.clone());
y.a.push(carry);
let (view_before, drain_before) = ((x.view)(), (x.drain)());
x.c.begin();
let mut a = bad.0.clone();
if fail {
a += poison();
}
x.a.push(a);
x.b.push(bad.1.clone());
let outcome = catch_unwind(AssertUnwindSafe(|| x.c.step()));
prop_assert_eq!(outcome.is_err(), fail, "[panic] the poison step panics, the others do not");
x.c.rollback();
prop_assert_eq!((x.view)(), view_before, "[rollback] every stream's current()");
prop_assert_eq!((x.drain)(), drain_before, "[rollback] every sink");
for (i, (a, b)) in post.iter().enumerate() {
x.tick(a, b);
y.tick(a, b);
prop_assert_eq!((x.view)(), (y.view)(), "[post] state, tick {}", i);
prop_assert_eq!((x.drain)(), (y.drain)(), "[post] output, tick {}", i);
}
}
/// `commit` keeps what the steps did; a transaction that commits is not observable.
#[test]
fn a_committed_transaction_equals_the_same_steps_outside_one(
steps in ticks(1..6),
split in 0usize..6,
) {
let (mut x, mut y) = (rig(), rig());
for (i, (a, b)) in steps.iter().enumerate() {
let open = i >= split;
if open {
x.c.begin();
}
x.tick(a, b);
if open {
x.c.commit();
}
y.tick(a, b);
prop_assert_eq!((x.view)(), (y.view)(), "[commit] state, tick {}", i);
prop_assert_eq!((x.drain)(), (y.drain)(), "[commit] output, tick {}", i);
}
}
}
#[test]
fn an_operator_built_inside_a_transaction_goes_back_to_empty() {
let mut c = Circuit::new();
let (input, s) = c.input::<u8>();
c.begin();
let i = c.integrate(&s);
input.push([(1u8, 1)].into_iter().collect());
c.step();
c.rollback();
input.push([(2u8, 1)].into_iter().collect());
c.step();
let want: Z<u8> = [(2u8, 1)].into_iter().collect();
assert_eq!(
i.current(),
want,
"[built] the integral forgot the rolled-back tick"
);
}
/// One key at `MAX` that a transaction lowers by one and raises again. Undone oldest first,
/// the first undo would take it to `MAX + 1`; newest first, nothing leaves `i64`.
#[test]
fn rollback_undoes_newest_first_so_no_undo_leaves_i64() {
let mut c = Circuit::new();
let (input, s) = c.input::<P>();
let (i, d) = (c.integrate(&s), c.distinct(&s));
let g = c.aggregate(&s, |_, vals| Some(vals.len() as i64));
let one = |w: i64| -> Z<P> { [((1, 1), w)].into_iter().collect() };
input.push(one(i64::MAX));
c.step();
let before = (i.current(), d.current(), g.current());
c.begin();
for w in [-1, 1] {
input.push(one(w));
c.step();
}
c.rollback();
assert_eq!(
(i.current(), d.current(), g.current()),
before,
"[order] streams"
);
input.push(one(-1));
c.step();
assert_eq!(
i.current(),
one(i64::MAX - 1),
"[order] the integral is back at MAX first"
);
}
/// `i64::MIN` has no negation: a trace undoes it in two applications.
#[test]
fn a_minimum_weight_applied_to_every_kind_of_state_is_taken_back() {
let mut x = rig();
let mut y = rig();
let min: Z<P> = [((1, 1), i64::MIN)].into_iter().collect();
x.c.begin();
x.tick(&min, &Z::new());
x.c.rollback();
let right: Z<P> = [((1, 2), 1)].into_iter().collect();
x.tick(&Z::new(), &right);
y.tick(&Z::new(), &right);
assert_eq!((x.view)(), (y.view)(), "[min] state");
assert_eq!(
(x.drain)(),
(y.drain)(),
"[min] output: the join saw no left side"
);
}
#[test]
fn rollback_undoes_the_clock() {
let mut c = Circuit::new();
let (input, _s) = c.input::<u8>();
c.step();
c.begin();
input.push([(1u8, 1)].into_iter().collect());
c.step();
assert_eq!(c.ticks(), 2);
c.rollback();
assert_eq!(c.ticks(), 1, "[ticks] the rolled-back step is not counted");
}
#[test]
#[should_panic(expected = "already open")]
fn a_second_begin_is_refused_loudly() {
let mut c = Circuit::new();
c.begin();
c.begin();
}
#[test]
#[should_panic(expected = "no transaction is open")]
fn a_rollback_with_nothing_open_is_refused_loudly() {
Circuit::new().rollback();
}
#[test]
#[should_panic(expected = "no transaction is open")]
fn a_commit_with_nothing_open_is_refused_loudly() {
Circuit::new().commit();
}
/// A tick in two pieces, rolled back: the edge's whole value comes back, not the last piece.
#[test]
fn a_rollback_across_a_split_tick_restores_the_whole_value() {
let (mut x, mut y) = (rig(), rig());
let first: Z<P> = [((1, 1), 2), ((2, 1), 1)].into_iter().collect();
x.tick(&first, &Z::new());
y.tick(&first, &Z::new());
// The tick is already split when the transaction opens, so every edge holds a `whole`.
for r in [&mut x, &mut y] {
r.a.push([((3, 1), 1)].into_iter().collect());
r.c.step_continue();
}
x.c.begin();
for k in [4u8, 5] {
x.a.push([((k, 1), 1)].into_iter().collect());
x.c.step_continue();
}
x.c.rollback();
assert_eq!((x.view)(), (y.view)(), "[split] state after the rollback");
assert_eq!(
(x.drain)(),
(y.drain)(),
"[split] output after the rollback"
);
let next: Z<P> = [((5, 1), 1)].into_iter().collect();
x.tick(&next, &Z::new());
y.tick(&next, &Z::new());
assert_eq!((x.view)(), (y.view)(), "[split] the tick after");
}