69fb5e625e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
1587 lines
52 KiB
Rust
1587 lines
52 KiB
Rust
//! Delivery against the Eager path: the host publishes, a client folds, and the client's
|
|
//! held cells must end at what the grid draws. Evidence: `context/test-evidence/delivery.md`.
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use improvise_core::model::cell::{CellKey, CellValue};
|
|
use improvise_core::model::record::RecordHash;
|
|
use improvise_core::view::Axis;
|
|
use improvise_core::view::layout::GridLayout;
|
|
use improvise_core::workbook::Workbook;
|
|
use improvise_protocol::delivery::wire::{self, Cell, WireKey, WireValue};
|
|
use improvise_protocol::delivery::{
|
|
Ask, AxisChange, CorrectionBody, Edge, EdgeClient, EdgeError, Moved, Notice, Outcome, Reply,
|
|
Request, SessionToken, ShapeKey, Stamp, Synced, Window,
|
|
};
|
|
use improvise_protocol::{ClientFrame, ModelCommand, RecordCell, Rejection, ServerFrame};
|
|
use zset::ZSet;
|
|
|
|
use super::app::App;
|
|
use super::delivery::{InProcess, WireEdge, notice_over_the_wire, shape_of_view};
|
|
use super::host::Host;
|
|
use super::project::SessionId;
|
|
|
|
fn workbook() -> Workbook {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
for item in ["North", "South"] {
|
|
wb.add_item("Region", item).unwrap();
|
|
}
|
|
wb.add_item("_Measure", "Revenue").unwrap();
|
|
for (region, value) in [("North", 10.0), ("South", 20.0)] {
|
|
let key = CellKey::new(vec![
|
|
("Region".to_string(), region.to_string()),
|
|
("_Measure".to_string(), "Revenue".to_string()),
|
|
]);
|
|
wb.model.set_cell(key, CellValue::Number(value));
|
|
}
|
|
wb.active_view_mut().set_axis("Region", Axis::Row);
|
|
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
wb
|
|
}
|
|
|
|
fn host_instance(instance: u64) -> Host {
|
|
let mut app = App::new(workbook(), None);
|
|
app.rebuild_layout();
|
|
let mut host = Host::with_instance(app, instance);
|
|
host.attach_notices();
|
|
host
|
|
}
|
|
|
|
fn host() -> Host {
|
|
host_instance(1)
|
|
}
|
|
|
|
fn shape() -> ShapeKey {
|
|
shape_of_view("Default")
|
|
}
|
|
|
|
fn everything() -> Window {
|
|
Window {
|
|
top: 0,
|
|
left: 0,
|
|
rows: 1000,
|
|
cols: 1000,
|
|
}
|
|
}
|
|
|
|
fn record(region: &str) -> RecordCell {
|
|
let key = CellKey::new(vec![
|
|
("Region".to_string(), region.to_string()),
|
|
("_Measure".to_string(), "Revenue".to_string()),
|
|
]);
|
|
RecordCell {
|
|
record: RecordHash::of(&key),
|
|
measure: Some("Revenue".to_string()),
|
|
}
|
|
}
|
|
|
|
fn set(region: &str, value: f64) -> ModelCommand {
|
|
ModelCommand::SetCell {
|
|
view: "Default".into(),
|
|
cell: record(region),
|
|
value: CellValue::Number(value),
|
|
}
|
|
}
|
|
|
|
fn add_item(item: &str) -> ModelCommand {
|
|
ModelCommand::AddItem {
|
|
category: "Region".into(),
|
|
item: item.into(),
|
|
}
|
|
}
|
|
|
|
/// A frame of caught-up commands: the first one's basis states every record the frame names.
|
|
fn frame(host: &Host, cmds: Vec<ModelCommand>) -> ClientFrame {
|
|
let mut wire: Vec<improvise_protocol::Command> = cmds
|
|
.into_iter()
|
|
.map(|c| crate::ui::reduce::caught_up(host.app(), c))
|
|
.collect();
|
|
let records: Vec<_> = wire
|
|
.iter()
|
|
.filter_map(|c| match c {
|
|
improvise_protocol::Command::Model { basis, .. } => Some(basis.records.clone()),
|
|
_ => None,
|
|
})
|
|
.flatten()
|
|
.collect();
|
|
if let Some(improvise_protocol::Command::Model { basis, .. }) = wire.first_mut() {
|
|
basis.records = records;
|
|
}
|
|
ClientFrame::Commands(wire)
|
|
}
|
|
|
|
fn send(host: &mut Host, id: SessionId, cmds: Vec<ModelCommand>) -> Vec<ServerFrame> {
|
|
let f = frame(host, cmds);
|
|
host.handle(id, f).into_iter().map(|a| a.msg).collect()
|
|
}
|
|
|
|
fn refused(answer: &[ServerFrame]) -> bool {
|
|
answer.iter().any(|m| matches!(m, ServerFrame::Rejected(_)))
|
|
}
|
|
|
|
/// What the grid draws, derived without the delivery module's own reader.
|
|
fn drawn(host: &Host, window: &Window) -> ZSet<Cell> {
|
|
drawn_view(host, "Default", window)
|
|
}
|
|
|
|
fn drawn_view(host: &Host, view: &str, window: &Window) -> ZSet<Cell> {
|
|
let wb = &host.app().model_state.workbook;
|
|
let layout = GridLayout::with_frozen_records(&wb.model, &wb.views[view], None);
|
|
let mut cells = ZSet::new();
|
|
for row in 0..layout.row_count() {
|
|
for col in 0..layout.col_count() {
|
|
let Some(key) = layout.cell_key(row, col) else {
|
|
continue;
|
|
};
|
|
if let Some(value) = layout.evaluate(&wb.model, &key)
|
|
&& window.contains(row as u128, col as u128)
|
|
{
|
|
cells.insert((WireKey::of(&key), WireValue::of(&value)), 1);
|
|
}
|
|
}
|
|
}
|
|
cells
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
enum Fault {
|
|
DropRequest,
|
|
DropReply,
|
|
Garble,
|
|
}
|
|
|
|
/// A transport that loses things on a script and remembers every request that reached it.
|
|
struct Flaky<'a> {
|
|
host: &'a mut Host,
|
|
script: VecDeque<Option<Fault>>,
|
|
reached: Vec<Request>,
|
|
replies: Vec<Reply>,
|
|
}
|
|
|
|
impl<'a> Flaky<'a> {
|
|
fn new(host: &'a mut Host) -> Self {
|
|
Self {
|
|
host,
|
|
script: VecDeque::new(),
|
|
reached: Vec::new(),
|
|
replies: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn scripted(host: &'a mut Host, script: Vec<Option<Fault>>) -> Self {
|
|
Self {
|
|
script: script.into(),
|
|
..Self::new(host)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Edge for Flaky<'_> {
|
|
fn call(&mut self, request: Request) -> Result<Reply, EdgeError> {
|
|
let fault = self.script.pop_front().flatten();
|
|
if fault == Some(Fault::DropRequest) {
|
|
return Err(EdgeError::Failed);
|
|
}
|
|
self.reached.push(request.clone());
|
|
let reply = self.host.deliver(&request, 0);
|
|
self.replies.push(reply.clone());
|
|
match fault {
|
|
Some(Fault::DropReply) => Err(EdgeError::Failed),
|
|
Some(Fault::Garble) => Err(EdgeError::Unreadable),
|
|
_ => Ok(reply),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn session() -> Option<SessionToken> {
|
|
Some(SessionToken("s-1".into()))
|
|
}
|
|
|
|
/// A client that has fetched everything the host holds.
|
|
fn settled(host: &mut Host) -> EdgeClient {
|
|
let mut client = EdgeClient::new(session());
|
|
client.track(shape(), everything());
|
|
let (state, _) = client.sync(&mut WireEdge(InProcess { host, now: 0 }), &shape(), 6);
|
|
assert_eq!(state, Synced::Settled);
|
|
client
|
|
}
|
|
|
|
fn connect(host: &mut Host) -> SessionId {
|
|
host.connect(10, 10).0
|
|
}
|
|
|
|
fn asks(reached: &[Request]) -> Vec<&Ask> {
|
|
reached.iter().map(|r| &r.ask).collect()
|
|
}
|
|
|
|
fn deliver_all(client: &mut EdgeClient, notices: &[Notice]) {
|
|
for n in notices {
|
|
client.on_notice(¬ice_over_the_wire(n).expect("a notice crosses the wire"));
|
|
}
|
|
}
|
|
|
|
fn held_equals_drawn(client: &EdgeClient, host: &Host) {
|
|
let want = drawn(host, &everything());
|
|
assert_eq!(client.held(&shape()), Some(&want));
|
|
}
|
|
|
|
// ── one notice per committed command ───────────────────────────────────────
|
|
|
|
#[test]
|
|
fn a_frame_of_two_commands_publishes_one_notice_each_and_no_client_asks_for_a_correction() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
let before = host.delivery().stamp();
|
|
host.take_notices();
|
|
|
|
let answer = send(&mut host, id, vec![add_item("East"), set("North", 5.0)]);
|
|
assert!(!refused(&answer), "{answer:?}");
|
|
let notices = host.take_notices();
|
|
|
|
let stamps: Vec<u64> = notices.iter().map(|n| n.stamp().publication).collect();
|
|
assert_eq!(
|
|
stamps,
|
|
vec![before.publication + 1, before.publication + 2],
|
|
"one notice per committed command, numbered consecutively"
|
|
);
|
|
deliver_all(&mut client, ¬ices);
|
|
let mut edge = Flaky::new(&mut host);
|
|
let (state, _) = client.sync(&mut edge, &shape(), 6);
|
|
assert_eq!(state, Synced::Settled);
|
|
assert!(
|
|
asks(&edge.reached)
|
|
.iter()
|
|
.all(|a| !matches!(a, Ask::Correction { .. })),
|
|
"no correction was asked for: {:?}",
|
|
edge.reached
|
|
);
|
|
held_equals_drawn(&client, &host);
|
|
}
|
|
|
|
#[test]
|
|
fn a_command_that_moves_nothing_still_publishes_and_a_view_command_does_not() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
host.take_notices();
|
|
send(&mut host, id, vec![set("North", 1.0)]);
|
|
send(&mut host, id, vec![set("North", 1.0)]);
|
|
let same = host.take_notices();
|
|
assert_eq!(same.len(), 2);
|
|
assert_eq!(
|
|
same[1].moved,
|
|
Default::default(),
|
|
"an equal write moves nothing"
|
|
);
|
|
|
|
host.handle(
|
|
id,
|
|
ClientFrame::command(improvise_protocol::Command::SetSelected { row: 1, col: 0 }),
|
|
);
|
|
assert!(
|
|
host.take_notices().is_empty(),
|
|
"the cursor is the view store's"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_notice_names_the_moved_cells_including_a_formula_cell_it_did_not_write() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
send(
|
|
&mut host,
|
|
id,
|
|
vec![ModelCommand::AddFormula {
|
|
raw: "Double = Revenue * 2".into(),
|
|
target_category: "_Measure".into(),
|
|
}],
|
|
);
|
|
let _ = settled(&mut host);
|
|
host.take_notices();
|
|
send(&mut host, id, vec![set("North", 4.0)]);
|
|
let notice = host.take_notices().remove(0);
|
|
let Some(Moved::Keys(keys)) = notice.moved.get(&shape()) else {
|
|
panic!("the live shape must be listed: {notice:?}")
|
|
};
|
|
let measures: Vec<&str> = keys
|
|
.iter()
|
|
.flat_map(|k| k.0.iter())
|
|
.filter(|(c, _)| c == "_Measure")
|
|
.map(|(_, i)| i.as_str())
|
|
.collect();
|
|
assert!(
|
|
measures.contains(&"Revenue") && measures.contains(&"Double"),
|
|
"{keys:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_structural_command_reports_the_axis_change_and_a_formula_the_flag() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
host.take_notices();
|
|
send(&mut host, id, vec![add_item("East")]);
|
|
send(
|
|
&mut host,
|
|
id,
|
|
vec![ModelCommand::RemoveItem {
|
|
category: "Region".into(),
|
|
item: "North".into(),
|
|
}],
|
|
);
|
|
send(
|
|
&mut host,
|
|
id,
|
|
vec![ModelCommand::AddFormula {
|
|
raw: "Double = Revenue * 2".into(),
|
|
target_category: "_Measure".into(),
|
|
}],
|
|
);
|
|
let notices = host.take_notices();
|
|
assert_eq!(
|
|
notices[0].axis,
|
|
vec![AxisChange::ItemInserted {
|
|
category: "Region".into(),
|
|
item: "East".into(),
|
|
at: 2
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
notices[1].axis,
|
|
vec![AxisChange::ItemRemoved {
|
|
category: "Region".into(),
|
|
item: "North".into()
|
|
}]
|
|
);
|
|
assert!(notices[2].flags.formulas && !notices[1].flags.formulas);
|
|
}
|
|
|
|
// ── loss, restart, refusal ─────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn a_dropped_notice_is_detected_and_a_correction_repairs_it() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
host.take_notices();
|
|
send(&mut host, id, vec![set("North", 1.0)]);
|
|
send(&mut host, id, vec![set("South", 2.0)]);
|
|
let notices = host.take_notices();
|
|
deliver_all(&mut client, ¬ices[1..]);
|
|
|
|
let mut edge = Flaky::new(&mut host);
|
|
let (state, _) = client.sync(&mut edge, &shape(), 6);
|
|
assert_eq!(state, Synced::Settled);
|
|
assert!(
|
|
matches!(
|
|
asks(&edge.reached)[0],
|
|
Ask::Correction { watermark: Some(_) }
|
|
),
|
|
"{:?}",
|
|
edge.reached
|
|
);
|
|
held_equals_drawn(&client, &host);
|
|
}
|
|
|
|
#[test]
|
|
fn a_reconnect_after_a_host_restart_asks_for_a_correction_from_the_old_watermark() {
|
|
let mut first = host_instance(1);
|
|
let id = connect(&mut first);
|
|
send(&mut first, id, vec![set("North", 9.0)]);
|
|
let mut client = settled(&mut first);
|
|
let old = client.watermark(&shape()).expect("settled");
|
|
|
|
let mut second = host_instance(2);
|
|
client.connection_lost();
|
|
let mut edge = Flaky::new(&mut second);
|
|
let (state, _) = client.sync(&mut edge, &shape(), 6);
|
|
assert_eq!(state, Synced::Settled);
|
|
assert_eq!(
|
|
asks(&edge.reached)[0],
|
|
&Ask::Correction {
|
|
watermark: Some(old)
|
|
}
|
|
);
|
|
assert_ne!(client.watermark(&shape()).unwrap().host, old.host);
|
|
held_equals_drawn(&client, &second);
|
|
}
|
|
|
|
#[test]
|
|
fn a_notice_from_a_restarted_host_asks_for_a_correction_too() {
|
|
let mut first = host_instance(1);
|
|
let mut client = settled(&mut first);
|
|
let mut second = host_instance(2);
|
|
let id = connect(&mut second);
|
|
send(&mut second, id, vec![set("North", 3.0)]);
|
|
deliver_all(&mut client, &second.take_notices());
|
|
let mut edge = Flaky::new(&mut second);
|
|
client.sync(&mut edge, &shape(), 6);
|
|
assert!(matches!(asks(&edge.reached)[0], Ask::Correction { .. }));
|
|
held_equals_drawn(&client, &second);
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_command_consumes_no_publication_number() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
for n in 0..crate::model::MAX_CATEGORIES - 1 {
|
|
host.app_mut()
|
|
.model_state
|
|
.workbook
|
|
.add_category(format!("Filler{n}"))
|
|
.unwrap();
|
|
}
|
|
host.app_mut().rebuild_layout();
|
|
let before = host.delivery().stamp();
|
|
host.take_notices();
|
|
|
|
let answer = send(
|
|
&mut host,
|
|
id,
|
|
vec![
|
|
add_item("West"),
|
|
ModelCommand::AddCategory("Overflow".into()),
|
|
],
|
|
);
|
|
assert!(
|
|
answer
|
|
.iter()
|
|
.any(|m| matches!(m, ServerFrame::Rejected(Rejection::MutationRefused))),
|
|
"the fixture must reach the refusal: {answer:?}"
|
|
);
|
|
assert_eq!(host.delivery().stamp(), before, "nothing was committed");
|
|
assert!(host.take_notices().is_empty());
|
|
|
|
send(&mut host, id, vec![set("North", 1.0)]);
|
|
assert_eq!(
|
|
host.take_notices()[0].stamp().publication,
|
|
before.publication + 1,
|
|
"the next commit takes the next number"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_frame_is_never_read_back() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
let committed = drawn(&host, &everything());
|
|
let answer = send(
|
|
&mut host,
|
|
id,
|
|
vec![
|
|
set("North", 77.0),
|
|
ModelCommand::LoadModel("missing.improv".into()),
|
|
],
|
|
);
|
|
assert!(
|
|
answer
|
|
.iter()
|
|
.any(|m| matches!(m, ServerFrame::Rejected(Rejection::MutationRefused))),
|
|
"the write must have run before the refusal: {answer:?}"
|
|
);
|
|
let mut edge = Flaky::new(&mut host);
|
|
client.connection_lost();
|
|
client.sync(&mut edge, &shape(), 6);
|
|
assert_eq!(client.held(&shape()), Some(&committed));
|
|
let leaked = client
|
|
.held(&shape())
|
|
.unwrap()
|
|
.iter()
|
|
.any(|((_, v), _)| *v == WireValue::of(&CellValue::Number(77.0)));
|
|
assert!(!leaked, "a value the failed frame wrote was read");
|
|
}
|
|
|
|
#[test]
|
|
fn commands_before_a_refused_one_are_rolled_back_with_their_notices_under_adr_001() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
for n in 0..crate::model::MAX_CATEGORIES - 1 {
|
|
host.app_mut()
|
|
.model_state
|
|
.workbook
|
|
.add_category(format!("Filler{n}"))
|
|
.unwrap();
|
|
}
|
|
host.app_mut().rebuild_layout();
|
|
host.take_notices();
|
|
send(
|
|
&mut host,
|
|
id,
|
|
vec![
|
|
add_item("West"),
|
|
ModelCommand::AddCategory("Overflow".into()),
|
|
],
|
|
);
|
|
assert!(
|
|
host.take_notices().is_empty(),
|
|
"ADR 001 clause 4 undoes the whole frame; ADR 009 (via ADR 006) would keep `West`'s notice"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_dropped_connection_or_a_failed_request_triggers_a_correction_and_the_session_resumes() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
let at = client.watermark(&shape()).unwrap();
|
|
send(&mut host, id, vec![set("North", 8.0)]);
|
|
deliver_all(&mut client, &host.take_notices());
|
|
|
|
for fault in [Fault::DropRequest, Fault::DropReply, Fault::Garble] {
|
|
let mut edge = Flaky::scripted(&mut host, vec![Some(fault)]);
|
|
let (state, _) = client.sync(&mut edge, &shape(), 6);
|
|
assert_eq!(state, Synced::Failed, "{fault:?}");
|
|
assert!(
|
|
matches!(
|
|
client.wants(&shape()),
|
|
Some(improvise_protocol::delivery::Want::Correction { .. })
|
|
),
|
|
"{fault:?}: a failed request asks for a correction"
|
|
);
|
|
}
|
|
let mut edge = Flaky::new(&mut host);
|
|
let (state, _) = client.sync(&mut edge, &shape(), 6);
|
|
assert_eq!(state, Synced::Settled);
|
|
assert_eq!(
|
|
asks(&edge.reached)[0],
|
|
&Ask::Correction {
|
|
watermark: Some(at)
|
|
},
|
|
"it resumes from the watermark it held"
|
|
);
|
|
assert!(edge.reached.iter().all(|r| r.session == session()));
|
|
held_equals_drawn(&client, &host);
|
|
|
|
client.connection_lost();
|
|
let (state, _) = client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
assert_eq!(
|
|
state,
|
|
Synced::Settled,
|
|
"and a dropped connection settles too"
|
|
);
|
|
}
|
|
|
|
// ── the log, windows, eviction ─────────────────────────────────────────────
|
|
|
|
fn reply_for(
|
|
host: &mut Host,
|
|
ask: Ask,
|
|
window: Window,
|
|
) -> improvise_protocol::delivery::Correction {
|
|
let request = Request {
|
|
session: session(),
|
|
shape: shape(),
|
|
window,
|
|
ask,
|
|
};
|
|
match host.deliver(&request, 0) {
|
|
Reply::Correction(c) => *c,
|
|
other => panic!("expected a correction: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_watermark_the_log_covers_gets_the_moved_keys_and_one_it_does_not_gets_the_window() {
|
|
let mut host = host();
|
|
host.limit_delivery(3, 4096);
|
|
let id = connect(&mut host);
|
|
let client = settled(&mut host);
|
|
let w = client.watermark(&shape()).unwrap();
|
|
for n in 0..2 {
|
|
send(&mut host, id, vec![set("North", n as f64)]);
|
|
}
|
|
let near = reply_for(
|
|
&mut host,
|
|
Ask::Correction { watermark: Some(w) },
|
|
everything(),
|
|
);
|
|
assert!(
|
|
matches!(near.body, CorrectionBody::Moved { .. }),
|
|
"{near:?}"
|
|
);
|
|
assert_eq!(near.from, Some(w));
|
|
|
|
for n in 2..6 {
|
|
send(&mut host, id, vec![set("North", n as f64)]);
|
|
}
|
|
assert_eq!(host.delivery().log_len(), 3, "the log is bounded");
|
|
let far = reply_for(
|
|
&mut host,
|
|
Ask::Correction { watermark: Some(w) },
|
|
everything(),
|
|
);
|
|
assert!(matches!(far.body, CorrectionBody::Window), "{far:?}");
|
|
assert_eq!(far.from, None);
|
|
}
|
|
|
|
#[test]
|
|
fn an_oversized_notice_says_correct_and_costs_every_reader_a_correction() {
|
|
let mut host = host();
|
|
host.limit_delivery(64, 1);
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
host.take_notices();
|
|
send(&mut host, id, vec![set("North", 1.0)]);
|
|
let small = host.take_notices();
|
|
assert!(matches!(small[0].moved.get(&shape()), Some(Moved::Keys(k)) if k.len() == 1));
|
|
|
|
let formula = ModelCommand::AddFormula {
|
|
raw: "Double = Revenue * 2".into(),
|
|
target_category: "_Measure".into(),
|
|
};
|
|
send(&mut host, id, vec![formula]);
|
|
let big = host.take_notices();
|
|
assert_eq!(big[0].moved.get(&shape()), Some(&Moved::Correct));
|
|
deliver_all(&mut client, &small);
|
|
deliver_all(&mut client, &big);
|
|
let mut edge = Flaky::new(&mut host);
|
|
client.sync(&mut edge, &shape(), 6);
|
|
assert!(matches!(asks(&edge.reached)[0], Ask::Correction { .. }));
|
|
held_equals_drawn(&client, &host);
|
|
}
|
|
|
|
#[test]
|
|
fn a_correction_carries_only_the_clients_window() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let top_left = Window {
|
|
top: 0,
|
|
left: 0,
|
|
rows: 1,
|
|
cols: 1,
|
|
};
|
|
let all = reply_for(&mut host, Ask::Correction { watermark: None }, everything());
|
|
let one = reply_for(&mut host, Ask::Correction { watermark: None }, top_left);
|
|
assert_eq!(all.target.len(), 2);
|
|
assert_eq!(one.target.len(), 1, "{one:?}");
|
|
send(&mut host, id, vec![set("South", 5.0)]);
|
|
let w = host.delivery().stamp();
|
|
let _ = w;
|
|
let moved = reply_for(
|
|
&mut host,
|
|
Ask::Correction {
|
|
watermark: Some(Stamp {
|
|
publication: w.publication - 1,
|
|
..w
|
|
}),
|
|
},
|
|
top_left,
|
|
);
|
|
assert!(
|
|
moved.target.is_empty(),
|
|
"South is outside the window: {moved:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_axis_change_that_shifts_an_unmoved_cell_into_the_window_refetches_the_window() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let one = Window {
|
|
top: 0,
|
|
left: 0,
|
|
rows: 1,
|
|
cols: 1,
|
|
};
|
|
let mut client = EdgeClient::new(session());
|
|
client.track(shape(), one);
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
host.take_notices();
|
|
|
|
let gone = ModelCommand::RemoveItem {
|
|
category: "Region".into(),
|
|
item: "North".into(),
|
|
};
|
|
send(&mut host, id, vec![gone]);
|
|
let notices = host.take_notices();
|
|
assert_eq!(
|
|
notices[0]
|
|
.moved
|
|
.get(&shape())
|
|
.map(|m| matches!(m, Moved::Keys(k) if k.len() == 1)),
|
|
Some(true)
|
|
);
|
|
deliver_all(&mut client, ¬ices);
|
|
let mut edge = Flaky::new(&mut host);
|
|
client.sync(&mut edge, &shape(), 6);
|
|
assert!(
|
|
matches!(asks(&edge.reached)[0], Ask::Fetch { window: true, .. }),
|
|
"{:?}",
|
|
edge.reached
|
|
);
|
|
assert_eq!(client.held(&shape()), Some(&drawn(&host, &one)));
|
|
let south = WireKey(vec![
|
|
("Region".into(), "South".into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
]);
|
|
assert!(
|
|
client
|
|
.held(&shape())
|
|
.unwrap()
|
|
.iter()
|
|
.any(|((k, _), _)| *k == south)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_evicted_shape_is_announced_and_its_reader_corrects() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
assert!(host.delivery().is_live(&shape()));
|
|
let gone = host.evict_idle(100, 50);
|
|
assert_eq!(gone, vec![shape()]);
|
|
assert!(!host.delivery().is_live(&shape()));
|
|
host.take_notices();
|
|
send(&mut host, id, vec![set("North", 2.0)]);
|
|
let notices = host.take_notices();
|
|
assert!(notices[0].evicted.contains(&shape()));
|
|
deliver_all(&mut client, ¬ices);
|
|
assert!(matches!(
|
|
client.wants(&shape()),
|
|
Some(improvise_protocol::delivery::Want::Correction { .. })
|
|
));
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
held_equals_drawn(&client, &host);
|
|
assert!(
|
|
host.delivery().is_live(&shape()),
|
|
"naming it again revives it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_shape_naming_no_view_is_answered_with_nothing_and_never_goes_live() {
|
|
let mut host = host();
|
|
let ghost = shape_of_view("Nope");
|
|
let c = {
|
|
let request = Request {
|
|
session: None,
|
|
shape: ghost.clone(),
|
|
window: everything(),
|
|
ask: Ask::Correction { watermark: None },
|
|
};
|
|
match host.deliver(&request, 0) {
|
|
Reply::Correction(c) => *c,
|
|
other => panic!("{other:?}"),
|
|
}
|
|
};
|
|
assert!(c.target.is_empty());
|
|
assert!(!host.delivery().is_live(&ghost));
|
|
}
|
|
|
|
#[test]
|
|
fn a_document_swap_is_a_new_epoch_behind_every_old_watermark() {
|
|
let storage = crate::storage::MemoryFs::new();
|
|
let mut other = Workbook::new("Other");
|
|
other.add_category("Product").unwrap();
|
|
other.add_item("Product", "Widget").unwrap();
|
|
other.add_item("_Measure", "Units").unwrap();
|
|
other.active_view_mut().set_axis("Product", Axis::Row);
|
|
other.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
crate::persistence::save_to(&storage, &other, std::path::Path::new("other.improv")).unwrap();
|
|
let mut app = App::new(workbook(), None);
|
|
app.storage = Box::new(storage);
|
|
app.rebuild_layout();
|
|
let mut host = Host::with_instance(app, 1);
|
|
host.attach_notices();
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
let old = client.watermark(&shape()).unwrap();
|
|
host.take_notices();
|
|
|
|
let load = ModelCommand::LoadModel("other.improv".into());
|
|
let answer = send(&mut host, id, vec![load]);
|
|
assert!(!refused(&answer), "{answer:?}");
|
|
let notices = host.take_notices();
|
|
let now = notices
|
|
.last()
|
|
.expect("the swap is a committed command")
|
|
.stamp();
|
|
assert!(old.is_behind(&now));
|
|
assert_ne!(old.generation, now.generation);
|
|
assert!(notices[0].flags.formulas && notices[0].flags.views);
|
|
|
|
deliver_all(&mut client, ¬ices);
|
|
let mut edge = Flaky::new(&mut host);
|
|
client.sync(&mut edge, &shape(), 6);
|
|
assert!(matches!(asks(&edge.reached)[0], Ask::Correction { .. }));
|
|
held_equals_drawn(&client, &host);
|
|
}
|
|
|
|
#[test]
|
|
fn a_pull_only_client_follows_the_log_without_a_correction() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
send(&mut host, id, vec![set("North", 6.0)]);
|
|
send(&mut host, id, vec![set("South", 7.0)]);
|
|
let mut edge = Flaky::new(&mut host);
|
|
// The client has seen no pushes, so its horizon is its watermark.
|
|
let (state, _) = client.poll(&mut edge, &shape());
|
|
assert_eq!(state, Synced::Settled);
|
|
assert!(matches!(asks(&edge.reached)[0], Ask::Notices { .. }));
|
|
assert!(
|
|
asks(&edge.reached)
|
|
.iter()
|
|
.all(|a| !matches!(a, Ask::Correction { .. }))
|
|
);
|
|
held_equals_drawn(&client, &host);
|
|
}
|
|
|
|
#[test]
|
|
fn a_request_can_be_retried_and_answers_the_same() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let client = settled(&mut host);
|
|
send(&mut host, id, vec![set("North", 6.0)]);
|
|
let w = client.watermark(&shape()).unwrap();
|
|
let a = reply_for(
|
|
&mut host,
|
|
Ask::Correction { watermark: Some(w) },
|
|
everything(),
|
|
);
|
|
let b = reply_for(
|
|
&mut host,
|
|
Ask::Correction { watermark: Some(w) },
|
|
everything(),
|
|
);
|
|
assert_eq!(a, b);
|
|
}
|
|
|
|
// ── lossless through the wire ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn values_keep_every_bit_from_the_cell_to_the_client() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
let values = [
|
|
0.1 + 0.2,
|
|
-0.0,
|
|
f64::INFINITY,
|
|
f64::NEG_INFINITY,
|
|
f64::NAN,
|
|
1e308,
|
|
5e-324,
|
|
];
|
|
for (n, v) in values.iter().enumerate() {
|
|
let region = ["North", "South"][n % 2];
|
|
send(&mut host, id, vec![set(region, *v)]);
|
|
deliver_all(&mut client, &host.take_notices());
|
|
let mut edge = WireEdge(Flaky::new(&mut host));
|
|
assert_eq!(client.sync(&mut edge, &shape(), 6).0, Synced::Settled);
|
|
let held = client.held(&shape()).unwrap();
|
|
let got = held.iter().find_map(|((k, v), _)| {
|
|
(k.0.contains(&("Region".to_string(), region.to_string()))).then(|| v.to_cell_value())
|
|
});
|
|
let Some(CellValue::Number(got)) = got else {
|
|
panic!("{region} holds no number after {v:?}")
|
|
};
|
|
match v.is_nan() {
|
|
true => assert!(got.is_nan()),
|
|
false => assert_eq!(got.to_bits(), v.to_bits(), "{v:?} became {got:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── cost: what a commit does when nobody is listening ─────────────────────
|
|
|
|
/// An item whose every comparison is counted, so the cost is a number and not a clock.
|
|
#[derive(Clone, Debug)]
|
|
struct Counted(usize);
|
|
|
|
thread_local! {
|
|
static COMPARISONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
|
}
|
|
|
|
impl PartialEq for Counted {
|
|
fn eq(&self, o: &Self) -> bool {
|
|
COMPARISONS.with(|c| c.set(c.get() + 1));
|
|
self.0 == o.0
|
|
}
|
|
}
|
|
|
|
impl Eq for Counted {}
|
|
|
|
impl std::hash::Hash for Counted {
|
|
fn hash<H: std::hash::Hasher>(&self, h: &mut H) {
|
|
self.0.hash(h);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn diffing_a_category_compares_each_item_a_bounded_number_of_times() {
|
|
use super::delivery::diff_items;
|
|
for n in [1000usize, 2000, 4000] {
|
|
let was: Vec<Counted> = (0..n).map(Counted).collect();
|
|
let mut now = was.clone();
|
|
now.remove(n / 2);
|
|
now.push(Counted(n + 1));
|
|
COMPARISONS.with(|c| c.set(0));
|
|
let diff = diff_items(&was, &now);
|
|
let comparisons = COMPARISONS.with(|c| c.get());
|
|
assert_eq!(diff.removed.len() + diff.inserted.len(), 2);
|
|
assert!(
|
|
comparisons <= 8 * n,
|
|
"{n} items cost {comparisons} comparisons"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_commit_with_nothing_live_and_no_transport_reads_nothing() {
|
|
use super::delivery::STRUCTURE_READS;
|
|
let mut app = App::new(workbook(), None);
|
|
app.rebuild_layout();
|
|
let mut host = Host::with_instance(app, 1);
|
|
let id = connect(&mut host);
|
|
STRUCTURE_READS.with(|c| c.set(0));
|
|
for n in 0..3 {
|
|
send(&mut host, id, vec![set("North", n as f64)]);
|
|
}
|
|
assert_eq!(STRUCTURE_READS.with(|c| c.get()), 0);
|
|
assert_eq!(
|
|
host.delivery().stamp().publication,
|
|
3,
|
|
"the count still moves"
|
|
);
|
|
assert_eq!(host.delivery().log_len(), 0);
|
|
assert!(host.take_notices().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_shape_that_goes_live_after_idle_commits_still_hears_the_axis_changes() {
|
|
let mut app = App::new(workbook(), None);
|
|
app.rebuild_layout();
|
|
let mut host = Host::with_instance(app, 1);
|
|
let id = connect(&mut host);
|
|
send(&mut host, id, vec![set("North", 1.0)]);
|
|
let mut client = settled(&mut host);
|
|
host.attach_notices();
|
|
send(&mut host, id, vec![add_item("East")]);
|
|
let notices = host.take_notices();
|
|
assert_eq!(notices[0].axis.len(), 1, "{:?}", notices[0]);
|
|
deliver_all(&mut client, ¬ices);
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
held_equals_drawn(&client, &host);
|
|
}
|
|
|
|
#[test]
|
|
fn the_outbox_and_the_clone_a_frame_takes_are_bounded() {
|
|
let mut host = host();
|
|
host.limit_delivery(4, 4096);
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
for n in 0..20 {
|
|
send(&mut host, id, vec![set("North", n as f64)]);
|
|
}
|
|
let queued = host.take_notices();
|
|
assert_eq!(
|
|
queued.len(),
|
|
4,
|
|
"a transport that never drains keeps the newest four"
|
|
);
|
|
deliver_all(&mut client, &queued);
|
|
let mut edge = Flaky::new(&mut host);
|
|
client.sync(&mut edge, &shape(), 6);
|
|
assert!(
|
|
matches!(asks(&edge.reached)[0], Ask::Correction { .. }),
|
|
"the gap is seen"
|
|
);
|
|
held_equals_drawn(&client, &host);
|
|
}
|
|
|
|
// ── view geometry moves positions without moving a key ────────────────────
|
|
|
|
fn transpose() -> ModelCommand {
|
|
ModelCommand::TransposeAxes {
|
|
view: "Default".into(),
|
|
}
|
|
}
|
|
|
|
fn south_window() -> Window {
|
|
Window {
|
|
top: 1,
|
|
left: 0,
|
|
rows: 1,
|
|
cols: 1,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_transpose_empties_a_partial_window_of_the_cell_that_left_it() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = EdgeClient::new(session());
|
|
client.track(shape(), south_window());
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
assert_eq!(client.held(&shape()).map(|h| h.len()), Some(1));
|
|
host.take_notices();
|
|
send(&mut host, id, vec![transpose()]);
|
|
let notices = host.take_notices();
|
|
assert!(notices[0].flags.views);
|
|
deliver_all(&mut client, ¬ices);
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
assert_eq!(client.held(&shape()), Some(&drawn(&host, &south_window())));
|
|
assert_eq!(client.held(&shape()).map(|h| h.len()), Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_dropped_views_notice_is_answered_with_the_window_not_an_empty_key_list() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = EdgeClient::new(session());
|
|
client.track(shape(), south_window());
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
send(&mut host, id, vec![transpose()]);
|
|
client.connection_lost();
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
assert_eq!(client.held(&shape()), Some(&drawn(&host, &south_window())));
|
|
}
|
|
|
|
#[test]
|
|
fn a_fetch_covers_only_the_notices_it_named_when_a_later_commit_is_already_in_the_state() {
|
|
let mut host = host();
|
|
let id = connect(&mut host);
|
|
let mut client = settled(&mut host);
|
|
host.take_notices();
|
|
send(&mut host, id, vec![set("North", 1.0)]);
|
|
send(&mut host, id, vec![set("South", 2.0)]);
|
|
let notices = host.take_notices();
|
|
deliver_all(&mut client, ¬ices[..1]);
|
|
let mut edge = Flaky::new(&mut host);
|
|
client.sync(&mut edge, &shape(), 6);
|
|
assert_eq!(
|
|
client.watermark(&shape()),
|
|
Some(notices[0].stamp()),
|
|
"through, not at"
|
|
);
|
|
deliver_all(&mut client, ¬ices[1..]);
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
held_equals_drawn(&client, &host);
|
|
}
|
|
|
|
#[test]
|
|
fn a_reply_held_across_a_host_restart_cannot_pull_the_client_back() {
|
|
let mut first = host_instance(1);
|
|
let id = connect(&mut first);
|
|
let mut client = settled(&mut first);
|
|
send(&mut first, id, vec![set("North", 99.0)]);
|
|
let mut lost = Flaky::scripted(&mut first, vec![Some(Fault::DropReply)]);
|
|
client.connection_lost();
|
|
client.sync(&mut lost, &shape(), 6);
|
|
let in_flight = lost.replies.remove(0);
|
|
|
|
let mut second = host_instance(2);
|
|
client.connection_lost();
|
|
client.sync(&mut Flaky::new(&mut second), &shape(), 6);
|
|
held_equals_drawn(&client, &second);
|
|
let at = client.watermark(&shape()).unwrap();
|
|
|
|
let Reply::Correction(late) = in_flight else {
|
|
panic!("the lost reply was a correction")
|
|
};
|
|
assert_eq!(client.apply(&late).0, Outcome::Duplicate);
|
|
assert_eq!(client.watermark(&shape()), Some(at));
|
|
held_equals_drawn(&client, &second);
|
|
let (state, _) = client.sync(&mut Flaky::new(&mut second), &shape(), 6);
|
|
assert_eq!(state, Synced::Settled);
|
|
}
|
|
|
|
#[test]
|
|
fn a_late_reply_from_a_host_the_client_never_joined_cannot_strand_it() {
|
|
let window = Window {
|
|
top: 0,
|
|
left: 0,
|
|
rows: 1,
|
|
cols: 1,
|
|
};
|
|
let mut first = host_instance(1);
|
|
let mut client = EdgeClient::new(session());
|
|
client.track(shape(), window);
|
|
client.sync(&mut Flaky::new(&mut first), &shape(), 6);
|
|
|
|
let mut second = host_instance(2);
|
|
let id = connect(&mut second);
|
|
send(&mut second, id, vec![set("South", 1.0)]);
|
|
let mut lost = Flaky::scripted(&mut second, vec![Some(Fault::DropReply)]);
|
|
client.connection_lost();
|
|
client.sync(&mut lost, &shape(), 6);
|
|
let late = lost.replies.remove(0);
|
|
|
|
let mut third = host_instance(3);
|
|
client.connection_lost();
|
|
client.sync(&mut Flaky::new(&mut third), &shape(), 6);
|
|
let Reply::Correction(late) = late else {
|
|
panic!("the lost reply was a correction")
|
|
};
|
|
client.apply(&late);
|
|
client.connection_lost();
|
|
|
|
let (state, _) = client.sync(&mut Flaky::new(&mut third), &shape(), 6);
|
|
assert_eq!(
|
|
state,
|
|
Synced::Settled,
|
|
"the live host's answers must be taken"
|
|
);
|
|
assert_eq!(client.held(&shape()), Some(&drawn(&third, &window)));
|
|
}
|
|
|
|
// ── the whole thing, at random ─────────────────────────────────────────────
|
|
|
|
mod random {
|
|
use proptest::prelude::*;
|
|
|
|
use super::*;
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Op {
|
|
Set(bool, usize),
|
|
Both(usize, usize),
|
|
Clear(bool),
|
|
Add(usize),
|
|
Remove(usize),
|
|
Formula,
|
|
Transpose,
|
|
TightLimits,
|
|
Evict,
|
|
Swap,
|
|
Restart,
|
|
}
|
|
|
|
fn op() -> impl Strategy<Value = Op> {
|
|
prop_oneof![
|
|
6 => (any::<bool>(), 0usize..8).prop_map(|(r, v)| Op::Set(r, v)),
|
|
4 => (0usize..8, 0usize..8).prop_map(|(a, b)| Op::Both(a, b)),
|
|
2 => any::<bool>().prop_map(Op::Clear),
|
|
3 => (0usize..3).prop_map(Op::Add),
|
|
2 => (0usize..3).prop_map(Op::Remove),
|
|
1 => Just(Op::Formula),
|
|
1 => Just(Op::Transpose),
|
|
1 => Just(Op::TightLimits),
|
|
1 => Just(Op::Evict),
|
|
1 => Just(Op::Swap),
|
|
1 => Just(Op::Restart),
|
|
]
|
|
}
|
|
|
|
const VALUES: [f64; 8] = [
|
|
1.0,
|
|
2.5,
|
|
-0.0,
|
|
0.0,
|
|
f64::INFINITY,
|
|
f64::NEG_INFINITY,
|
|
f64::NAN,
|
|
0.30000000000000004,
|
|
];
|
|
const EXTRA: [&str; 3] = ["East", "West", "North"];
|
|
|
|
fn region(north: bool) -> &'static str {
|
|
["South", "North"][north as usize]
|
|
}
|
|
|
|
fn commands(op: &Op) -> Vec<ModelCommand> {
|
|
match op {
|
|
Op::Both(a, b) => vec![set("North", VALUES[*a]), set("South", VALUES[*b])],
|
|
other => command(other).into_iter().collect(),
|
|
}
|
|
}
|
|
|
|
fn command(op: &Op) -> Option<ModelCommand> {
|
|
Some(match op {
|
|
Op::Both(..) => return None,
|
|
Op::Set(north, v) => set(region(*north), VALUES[*v]),
|
|
Op::Clear(north) => ModelCommand::ClearCell {
|
|
view: "Default".into(),
|
|
cell: record(region(*north)),
|
|
},
|
|
Op::Add(i) => add_item(EXTRA[*i]),
|
|
Op::Remove(i) => ModelCommand::RemoveItem {
|
|
category: "Region".into(),
|
|
item: EXTRA[*i].into(),
|
|
},
|
|
Op::Formula => ModelCommand::AddFormula {
|
|
raw: "Double = Revenue * 2".into(),
|
|
target_category: "_Measure".into(),
|
|
},
|
|
Op::Transpose => transpose(),
|
|
Op::Swap => ModelCommand::LoadModel("other.improv".into()),
|
|
Op::TightLimits | Op::Evict | Op::Restart => return None,
|
|
})
|
|
}
|
|
|
|
/// How a step's notices reach the client.
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum Arrival {
|
|
Dropped,
|
|
Now,
|
|
Twice,
|
|
Split,
|
|
Late(usize),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Weather {
|
|
arrival: Arrival,
|
|
lose_connection: bool,
|
|
sync: Vec<Option<u8>>,
|
|
replay: Option<usize>,
|
|
}
|
|
|
|
fn weather() -> impl Strategy<Value = Weather> {
|
|
let arrival = prop_oneof![
|
|
2 => Just(Arrival::Dropped),
|
|
4 => Just(Arrival::Now),
|
|
1 => Just(Arrival::Twice),
|
|
4 => Just(Arrival::Split),
|
|
3 => (1usize..4).prop_map(Arrival::Late),
|
|
];
|
|
(
|
|
arrival,
|
|
0u8..8,
|
|
proptest::collection::vec(proptest::option::of(0u8..3), 0..3),
|
|
proptest::option::of(0usize..16),
|
|
)
|
|
.prop_map(|(arrival, lose, sync, replay)| Weather {
|
|
arrival,
|
|
lose_connection: lose == 0,
|
|
sync,
|
|
replay,
|
|
})
|
|
}
|
|
|
|
fn fault(code: u8) -> Fault {
|
|
[Fault::DropRequest, Fault::DropReply, Fault::Garble][code as usize]
|
|
}
|
|
|
|
fn windows() -> impl Strategy<Value = Window> {
|
|
(0u128..2, 0u128..2, 1u128..6, 1u128..4).prop_map(|(top, left, rows, cols)| Window {
|
|
top,
|
|
left,
|
|
rows,
|
|
cols,
|
|
})
|
|
}
|
|
|
|
/// A host over `wb` that can load a second document, as a restarted host would be.
|
|
fn host_over(wb: Workbook, instance: u64) -> Host {
|
|
let storage = crate::storage::MemoryFs::new();
|
|
let mut other = Workbook::new("Other");
|
|
other.add_category("Product").unwrap();
|
|
other.add_item("Product", "Widget").unwrap();
|
|
other.add_item("_Measure", "Units").unwrap();
|
|
other.active_view_mut().set_axis("Product", Axis::Row);
|
|
other.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
let mut second = other.active_view().clone();
|
|
second.name = "Second".into();
|
|
other.views.insert("Second".into(), second);
|
|
crate::persistence::save_to(&storage, &other, std::path::Path::new("other.improv"))
|
|
.unwrap();
|
|
let mut wb = wb;
|
|
if !wb.views.contains_key("Second") {
|
|
let mut second = wb.active_view().clone();
|
|
second.name = "Second".into();
|
|
wb.views.insert("Second".into(), second);
|
|
}
|
|
let mut app = App::new(wb, None);
|
|
app.storage = Box::new(storage);
|
|
app.rebuild_layout();
|
|
let mut host = Host::with_instance(app, instance);
|
|
host.attach_notices();
|
|
host
|
|
}
|
|
|
|
fn arrive(
|
|
client: &mut EdgeClient,
|
|
held_back: &mut Vec<(usize, Notice)>,
|
|
step: usize,
|
|
w: &Weather,
|
|
fresh: Vec<Notice>,
|
|
) {
|
|
for (i, n) in fresh.into_iter().enumerate() {
|
|
match w.arrival {
|
|
Arrival::Split if i == 0 => deliver_all(client, &[n]),
|
|
Arrival::Split => held_back.push((step + 1, n)),
|
|
Arrival::Dropped => {}
|
|
Arrival::Now => deliver_all(client, &[n]),
|
|
Arrival::Twice => deliver_all(client, &[n.clone(), n]),
|
|
Arrival::Late(k) => held_back.push((step + k, n)),
|
|
}
|
|
}
|
|
let (due, later): (Vec<_>, Vec<_>) = held_back.drain(..).partition(|(at, _)| *at <= step);
|
|
*held_back = later;
|
|
let mut notices: Vec<Notice> = due.into_iter().map(|(_, n)| n).collect();
|
|
if step % 2 == 1 {
|
|
notices.reverse();
|
|
}
|
|
deliver_all(client, ¬ices);
|
|
}
|
|
|
|
/// Epochs the client has applied a correction from, oldest first.
|
|
fn note_epoch(client: &EdgeClient, epochs: &mut Vec<(u64, u64)>) {
|
|
if let Some(w) = client.watermark(&shape()) {
|
|
let now = (w.host, w.generation);
|
|
if !epochs.contains(&now) {
|
|
epochs.push(now);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A late reply arrives out of band. One from an epoch the client was in and has left is refused;
|
|
/// one from an epoch it never joined may legitimately move it, and the next answer brings it back.
|
|
fn replay(
|
|
client: &mut EdgeClient,
|
|
c: &improvise_protocol::delivery::Correction,
|
|
epochs: &mut Vec<(u64, u64)>,
|
|
) -> Result<(), TestCaseError> {
|
|
let theirs = (c.through.host, c.through.generation);
|
|
let current = client.watermark(&shape()).map(|w| (w.host, w.generation));
|
|
let left = epochs.contains(&theirs) && current != Some(theirs);
|
|
let (outcome, _) = client.apply(c);
|
|
if left {
|
|
prop_assert_eq!(
|
|
outcome,
|
|
Outcome::Duplicate,
|
|
"a reply from the left epoch {:?}",
|
|
theirs
|
|
);
|
|
}
|
|
note_epoch(client, epochs);
|
|
Ok(())
|
|
}
|
|
|
|
/// The shrunk history of the second livelock: two swaps, a transpose and a limit change, with
|
|
/// the first swap's notice delivered three steps late and an old reply replayed at the end.
|
|
#[test]
|
|
fn a_late_notice_from_an_older_generation_cannot_rewind_the_client() {
|
|
let window = Window {
|
|
top: 0,
|
|
left: 0,
|
|
rows: 1,
|
|
cols: 1,
|
|
};
|
|
let mut host = host_over(workbook(), 1);
|
|
let id = connect(&mut host);
|
|
let mut client = EdgeClient::new(session());
|
|
client.track(shape(), window);
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
host.take_notices();
|
|
let mut held_back: Vec<(usize, Notice)> = Vec::new();
|
|
let mut old: Vec<Reply> = Vec::new();
|
|
let plan = [
|
|
(Some(0), Arrival::Late(3), vec![Some(0)], None),
|
|
(Some(0), Arrival::Dropped, vec![], None),
|
|
(Some(1), Arrival::Now, vec![], None),
|
|
(None, Arrival::Dropped, vec![], Some(6)),
|
|
];
|
|
for (step, (op, arrival, sync, replay)) in plan.into_iter().enumerate() {
|
|
match op {
|
|
Some(0) => {
|
|
send(
|
|
&mut host,
|
|
id,
|
|
vec![ModelCommand::LoadModel("other.improv".into())],
|
|
);
|
|
}
|
|
Some(_) => {
|
|
send(&mut host, id, vec![transpose()]);
|
|
}
|
|
None => host.limit_delivery(2, 1),
|
|
}
|
|
let w = Weather {
|
|
arrival,
|
|
lose_connection: true,
|
|
sync: sync.clone(),
|
|
replay,
|
|
};
|
|
let fresh = host.take_notices();
|
|
arrive(&mut client, &mut held_back, step, &w, fresh);
|
|
client.connection_lost();
|
|
if let Some(Reply::Correction(c)) = replay.and_then(|i| old.get(i % old.len().max(1))) {
|
|
client.apply(c);
|
|
}
|
|
let script = sync.iter().map(|f| f.map(fault)).collect();
|
|
let mut edge = Flaky::scripted(&mut host, script);
|
|
client.sync(&mut edge, &shape(), 6);
|
|
old.append(&mut edge.replies);
|
|
}
|
|
let (state, _) = client.poll(&mut Flaky::new(&mut host), &shape());
|
|
assert_eq!(state, Synced::Settled, "an old notice must not queue work");
|
|
assert_eq!(client.held(&shape()), Some(&drawn(&host, &window)));
|
|
}
|
|
|
|
proptest! {
|
|
#[test]
|
|
fn a_corrected_state_is_the_target_and_correcting_it_again_is_the_zero_zset(
|
|
steps in proptest::collection::vec((op(), weather()), 1..12),
|
|
window in windows(),
|
|
) {
|
|
let mut instance = 1;
|
|
let mut host = host_over(workbook(), instance);
|
|
let mut id = connect(&mut host);
|
|
let mut client = EdgeClient::new(session());
|
|
client.track(shape(), window);
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
host.take_notices();
|
|
let mut held_back: Vec<(usize, Notice)> = Vec::new();
|
|
let mut old: Vec<Reply> = Vec::new();
|
|
let mut epochs: Vec<(u64, u64)> = Vec::new();
|
|
|
|
for (step, (op, weather)) in steps.iter().enumerate() {
|
|
match (commands(op), op) {
|
|
(cmds, _) if !cmds.is_empty() => {
|
|
send(&mut host, id, cmds);
|
|
}
|
|
(_, Op::TightLimits) => host.limit_delivery(2, 1),
|
|
(_, Op::Evict) => {
|
|
host.evict_idle(10, 0);
|
|
}
|
|
_ => {
|
|
instance += 1;
|
|
let wb = host.app().model_state.workbook.clone();
|
|
host = host_over(wb, instance);
|
|
id = connect(&mut host);
|
|
held_back.clear();
|
|
}
|
|
}
|
|
let fresh = host.take_notices();
|
|
arrive(&mut client, &mut held_back, step, weather, fresh);
|
|
if weather.lose_connection {
|
|
client.connection_lost();
|
|
}
|
|
if let Some(i) = weather.replay.filter(|_| !old.is_empty()) {
|
|
match &old[i % old.len()] {
|
|
Reply::Correction(c) => replay(&mut client, c, &mut epochs)?,
|
|
Reply::Notices(list) => deliver_all(&mut client, list),
|
|
}
|
|
}
|
|
let script = weather.sync.iter().map(|f| f.map(fault)).collect();
|
|
let mut edge = Flaky::scripted(&mut host, script);
|
|
client.sync(&mut edge, &shape(), 6);
|
|
old.append(&mut edge.replies);
|
|
note_epoch(&client, &mut epochs);
|
|
let moved_host = matches!(op, Op::Restart | Op::Swap);
|
|
if let (true, Some(Reply::Correction(c))) = (moved_host, old.get(step % old.len().max(1))) {
|
|
replay(&mut client, c, &mut epochs)?;
|
|
}
|
|
}
|
|
|
|
let rest: Vec<Notice> = held_back.drain(..).map(|(_, n)| n).collect();
|
|
deliver_all(&mut client, &rest);
|
|
let (state, _) = client.poll(&mut WireEdge(Flaky::new(&mut host)), &shape());
|
|
prop_assert_eq!(state, Synced::Settled);
|
|
let want = drawn(&host, &window);
|
|
prop_assert_eq!(client.held(&shape()), Some(&want));
|
|
|
|
let full = reply_for(&mut host, Ask::Correction { watermark: None }, window);
|
|
let correction = &wire::decode(&full.target) - client.held(&shape()).unwrap();
|
|
prop_assert!(correction.is_zero(), "correcting a correct state: {correction:?}");
|
|
|
|
let before = client.held(&shape()).cloned();
|
|
let (outcome, _) = client.apply(&full);
|
|
match outcome {
|
|
Outcome::Applied { delta } => prop_assert!(delta.is_zero()),
|
|
Outcome::Duplicate => {}
|
|
other => prop_assert!(false, "{other:?}"),
|
|
}
|
|
prop_assert_eq!(client.held(&shape()).cloned(), before);
|
|
}
|
|
}
|
|
|
|
/// Hosts come and go often here, so a client can skip one and meet its notices later.
|
|
fn churn() -> impl Strategy<Value = Op> {
|
|
prop_oneof![
|
|
4 => (any::<bool>(), 0usize..8).prop_map(|(r, v)| Op::Set(r, v)),
|
|
4 => Just(Op::Restart),
|
|
2 => Just(Op::Transpose),
|
|
2 => Just(Op::Swap),
|
|
1 => (0usize..3).prop_map(Op::Add),
|
|
1 => Just(Op::TightLimits),
|
|
]
|
|
}
|
|
|
|
fn window_pair() -> impl Strategy<Value = (Window, Window)> {
|
|
(windows(), windows())
|
|
}
|
|
|
|
proptest! {
|
|
/// Two shapes, notices late, repeated and reordered, a dead host's notices kept across
|
|
/// restarts, and stored notices and replies replayed on their own.
|
|
#[test]
|
|
fn two_shapes_survive_late_repeated_and_dead_messages(
|
|
steps in proptest::collection::vec(
|
|
(churn(), weather(), proptest::option::of(0usize..32)),
|
|
1..12,
|
|
),
|
|
(wa, wb) in window_pair(),
|
|
) {
|
|
let second = shape_of_view("Second");
|
|
let mut instance = 1;
|
|
let mut host = host_over(workbook(), instance);
|
|
let mut id = connect(&mut host);
|
|
let mut client = EdgeClient::new(session());
|
|
client.track(shape(), wa);
|
|
client.track(second.clone(), wb);
|
|
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
|
|
client.sync(&mut Flaky::new(&mut host), &second, 6);
|
|
host.take_notices();
|
|
let mut held_back: Vec<(usize, Notice)> = Vec::new();
|
|
let mut pool: Vec<Notice> = Vec::new();
|
|
let mut old: Vec<Reply> = Vec::new();
|
|
|
|
for (step, (op, weather, ghost)) in steps.iter().enumerate() {
|
|
match (commands(op), op) {
|
|
(cmds, _) if !cmds.is_empty() => {
|
|
send(&mut host, id, cmds);
|
|
}
|
|
(_, Op::TightLimits) => host.limit_delivery(2, 1),
|
|
(_, Op::Evict) => {
|
|
host.evict_idle(10, 0);
|
|
}
|
|
_ => {
|
|
instance += 1;
|
|
let wb = host.app().model_state.workbook.clone();
|
|
host = host_over(wb, instance);
|
|
id = connect(&mut host);
|
|
}
|
|
}
|
|
let fresh = host.take_notices();
|
|
pool.extend(fresh.iter().cloned());
|
|
arrive(&mut client, &mut held_back, step, weather, fresh);
|
|
if let Some(i) = ghost.filter(|_| !pool.is_empty()) {
|
|
let n = &pool[i % pool.len()];
|
|
client.on_notice(n);
|
|
client.on_notice(&n.for_shape(&shape()));
|
|
}
|
|
if let Some(i) = weather.replay.filter(|_| !old.is_empty()) {
|
|
match &old[i % old.len()] {
|
|
Reply::Correction(c) => {
|
|
client.apply(c);
|
|
}
|
|
Reply::Notices(list) => deliver_all(&mut client, list),
|
|
}
|
|
}
|
|
if weather.lose_connection {
|
|
client.connection_lost();
|
|
}
|
|
let script = weather.sync.iter().map(|f| f.map(fault)).collect();
|
|
let mut edge = Flaky::scripted(&mut host, script);
|
|
client.sync(&mut edge, &shape(), 6);
|
|
client.sync(&mut edge, &second, 6);
|
|
old.append(&mut edge.replies);
|
|
}
|
|
|
|
let rest: Vec<Notice> = held_back.drain(..).map(|(_, n)| n).collect();
|
|
deliver_all(&mut client, &rest);
|
|
for (name, window, view) in [(shape(), wa, "Default"), (second, wb, "Second")] {
|
|
let (state, _) = client.poll(&mut WireEdge(Flaky::new(&mut host)), &name);
|
|
prop_assert_eq!(state, Synced::Settled);
|
|
prop_assert_eq!(client.held(&name), Some(&drawn_view(&host, view, &window)));
|
|
}
|
|
}
|
|
}
|
|
}
|