| 792 |
792 |
|
);
|
| 793 |
793 |
|
}
|
| 794 |
794 |
|
|
|
795 |
+ |
/// A model of the pull pipeline.
|
|
796 |
+ |
///
|
|
797 |
+ |
/// Aimed at [`resolve_pull`] with a real `Connection`, deliberately, and not
|
|
798 |
+ |
/// at the pure conflict layer one step down. The one-entry-per-row invariant
|
|
799 |
+ |
/// only exists after the collapse, so `resolve_pull` is the lowest layer
|
|
800 |
+ |
/// where a max-HLC-wins specification is an honest thing to assert; an
|
|
801 |
+ |
/// earlier attempt at this aimed at `CleanChanges::gated_at`, which only ever
|
|
802 |
+ |
/// promised committed-clock filtering, and failed three of four properties
|
|
803 |
+ |
/// against correct code. See wiki `testing-posture`, Phase 3.
|
|
804 |
+ |
///
|
|
805 |
+ |
/// The four properties are the ones a sync engine lives or dies on, and each
|
|
806 |
+ |
/// is a different way for two devices to end up holding different bytes.
|
|
807 |
+ |
mod model {
|
|
808 |
+ |
use super::*;
|
|
809 |
+ |
use proptest::prelude::*;
|
|
810 |
+ |
|
|
811 |
+ |
/// A fixed instant, so the poisoning guard is deterministic. Generated
|
|
812 |
+ |
/// walls sit near it and well inside `MAX_HLC_DRIFT_MS`; poisoning is
|
|
813 |
+ |
/// covered by its own example test, and letting it fire here would mean
|
|
814 |
+ |
/// the properties were quietly asserting over an empty batch.
|
|
815 |
+ |
const BASE_MS: i64 = 1_700_000_000_000;
|
|
816 |
+ |
|
|
817 |
+ |
fn now() -> DateTime<Utc> {
|
|
818 |
+ |
DateTime::from_timestamp_millis(BASE_MS).unwrap()
|
|
819 |
+ |
}
|
|
820 |
+ |
|
|
821 |
+ |
/// Three rows, three devices, three wall readings, and every range here
|
|
822 |
+ |
/// is narrow on purpose.
|
|
823 |
+ |
///
|
|
824 |
+ |
/// The interesting case is two changes for one row at an *exact* HLC
|
|
825 |
+ |
/// tie with differing payloads, because that is the only case the
|
|
826 |
+ |
/// payload tiebreak in `change_order` serves. A wider generator makes it
|
|
827 |
+ |
/// unreachable: a first attempt drew walls from a 100ms window and
|
|
828 |
+ |
/// produced roughly one tie across a whole 256-case run, few enough that
|
|
829 |
+ |
/// deleting the tiebreak outright left every property passing. Ties have
|
|
830 |
+ |
/// to be common for these properties to observe anything, so the clock
|
|
831 |
+ |
/// is generated with almost no entropy in it and the payload carries the
|
|
832 |
+ |
/// variation instead.
|
|
833 |
+ |
fn batch() -> impl Strategy<Value = Vec<PulledChange>> {
|
|
834 |
+ |
let one = (0u8..3, 0u8..3, 0i64..3, 0u32..2, 0u8..4).prop_map(
|
|
835 |
+ |
|(row, dev, wall_off, counter, payload)| {
|
|
836 |
+ |
let node = node(u128::from(dev));
|
|
837 |
+ |
PulledChange {
|
|
838 |
+ |
entry: ChangeEntry {
|
|
839 |
+ |
table: "note".into(),
|
|
840 |
+ |
op: ChangeOp::Update,
|
|
841 |
+ |
row_id: format!("r{row}"),
|
|
842 |
+ |
timestamp: now(),
|
|
843 |
+ |
hlc: Hlc {
|
|
844 |
+ |
wall_ms: BASE_MS + wall_off,
|
|
845 |
+ |
counter,
|
|
846 |
+ |
node,
|
|
847 |
+ |
},
|
|
848 |
+ |
data: Some(serde_json::json!({
|
|
849 |
+ |
"id": format!("r{row}"),
|
|
850 |
+ |
"name": format!("v{payload}"),
|
|
851 |
+ |
})),
|
|
852 |
+ |
extra: serde_json::Map::default(),
|
|
853 |
+ |
},
|
|
854 |
+ |
device_id: node,
|
|
855 |
+ |
seq: 0,
|
|
856 |
+ |
}
|
|
857 |
+ |
},
|
|
858 |
+ |
);
|
|
859 |
+ |
proptest::collection::vec(one, 0..6)
|
|
860 |
+ |
}
|
|
861 |
+ |
|
|
862 |
+ |
/// The whole observable state of a device: what each row holds, and what
|
|
863 |
+ |
/// the committed ledger says about it. Both matter. A pipeline that
|
|
864 |
+ |
/// wrote the right value but recorded the wrong committed clock would
|
|
865 |
+ |
/// gate its own next pull incorrectly, and comparing only the rows would
|
|
866 |
+ |
/// not see it.
|
|
867 |
+ |
fn state(conn: &Connection) -> Vec<(String, Option<String>, Option<Hlc>)> {
|
|
868 |
+ |
["r0", "r1", "r2"]
|
|
869 |
+ |
.iter()
|
|
870 |
+ |
.map(|r| {
|
|
871 |
+ |
(
|
|
872 |
+ |
(*r).to_string(),
|
|
873 |
+ |
note_name(conn, r),
|
|
874 |
+ |
committed_hlc(conn, "note", r).unwrap(),
|
|
875 |
+ |
)
|
|
876 |
+ |
})
|
|
877 |
+ |
.collect()
|
|
878 |
+ |
}
|
|
879 |
+ |
|
|
880 |
+ |
/// Run a batch through the real pipeline on a fresh device.
|
|
881 |
+ |
fn pull(conn: &mut Connection, node: DeviceId, pulled: Vec<PulledChange>) {
|
|
882 |
+ |
let s = schema();
|
|
883 |
+ |
let resolved = resolve_pull(conn, &s, node, pulled, now(), "").unwrap();
|
|
884 |
+ |
apply_remote_changes(conn, &s, &resolved, "").unwrap();
|
|
885 |
+ |
record_committed(conn, resolved.as_slice()).unwrap();
|
|
886 |
+ |
}
|
|
887 |
+ |
|
|
888 |
+ |
/// The specification the pipeline is supposed to implement: per row, the
|
|
889 |
+ |
/// highest wall clock wins, then the highest counter, then the highest
|
|
890 |
+ |
/// device, then the highest payload bytes.
|
|
891 |
+ |
///
|
|
892 |
+ |
/// Spelled out rather than delegated to `change_order`, and that is the
|
|
893 |
+ |
/// whole point of it. An oracle that called `change_order` would agree
|
|
894 |
+ |
/// with a broken `change_order`, which is the imitation-oracle failure
|
|
895 |
+ |
/// from wiki `testing-posture` wearing a different hat: it was the first
|
|
896 |
+ |
/// version of this function, and deleting the payload tiebreak left all
|
|
897 |
+ |
/// four properties passing. This version fails when the rule changes,
|
|
898 |
+ |
/// because it is a second statement of the rule rather than a reference
|
|
899 |
+ |
/// to the first.
|
|
900 |
+ |
fn expected_winner(pulled: &[PulledChange], row: &str) -> Option<String> {
|
|
901 |
+ |
fn rank(e: &ChangeEntry) -> (i64, u32, Uuid, Vec<u8>) {
|
|
902 |
+ |
(
|
|
903 |
+ |
e.hlc.wall_ms,
|
|
904 |
+ |
e.hlc.counter,
|
|
905 |
+ |
e.hlc.node.as_uuid(),
|
|
906 |
+ |
serde_json::to_vec(e.data.as_ref().unwrap()).unwrap(),
|
|
907 |
+ |
)
|
|
908 |
+ |
}
|
|
909 |
+ |
pulled
|
|
910 |
+ |
.iter()
|
|
911 |
+ |
.map(|p| &p.entry)
|
|
912 |
+ |
.filter(|e| e.row_id == row)
|
|
913 |
+ |
.max_by_key(|e| rank(e))
|
|
914 |
+ |
.map(|e| e.data.as_ref().unwrap()["name"].as_str().unwrap().into())
|
|
915 |
+ |
}
|
|
916 |
+ |
|
|
917 |
+ |
proptest! {
|
|
918 |
+ |
/// **The pipeline implements max-HLC-wins.** The value each row ends
|
|
919 |
+ |
/// up holding is the one from the change that wins under
|
|
920 |
+ |
/// `change_order`, whatever else the batch contained.
|
|
921 |
+ |
#[test]
|
|
922 |
+ |
fn final_value_is_the_winner_under_change_order(pulled in batch()) {
|
|
923 |
+ |
let (mut conn, n) = device(1);
|
|
924 |
+ |
pull(&mut conn, n, pulled.clone());
|
|
925 |
+ |
for row in ["r0", "r1", "r2"] {
|
|
926 |
+ |
prop_assert_eq!(
|
|
927 |
+ |
note_name(&conn, row),
|
|
928 |
+ |
expected_winner(&pulled, row),
|
|
929 |
+ |
"row {} does not hold the winner",
|
|
930 |
+ |
row
|
|
931 |
+ |
);
|
|
932 |
+ |
}
|
|
933 |
+ |
}
|
|
934 |
+ |
|
|
935 |
+ |
/// **Batch order does not change the final state.** The server may
|
|
936 |
+ |
/// deliver a batch in any order; two devices that see the same
|
|
937 |
+ |
/// changes in different orders must agree afterwards. This is the
|
|
938 |
+ |
/// property the whole change-ordering unification was for.
|
|
939 |
+ |
#[test]
|
|
940 |
+ |
fn order_within_a_batch_does_not_matter(pulled in batch()) {
|
|
941 |
+ |
let (mut forwards, fnode) = device(1);
|
|
942 |
+ |
pull(&mut forwards, fnode, pulled.clone());
|
|
943 |
+ |
|
|
944 |
+ |
let mut reversed_batch = pulled;
|
|
945 |
+ |
reversed_batch.reverse();
|
|
946 |
+ |
let (mut backwards, bnode) = device(1);
|
|
947 |
+ |
pull(&mut backwards, bnode, reversed_batch);
|
|
948 |
+ |
|
|
949 |
+ |
prop_assert_eq!(
|
|
950 |
+ |
state(&forwards),
|
|
951 |
+ |
state(&backwards),
|
|
952 |
+ |
"two devices diverged on batch order alone"
|
|
953 |
+ |
);
|
|
954 |
+ |
}
|
|
955 |
+ |
|
|
956 |
+ |
/// **Replaying a batch is a no-op.** A pull that is retried, or a
|
|
957 |
+ |
/// cursor that rewinds, must not change anything the first pass
|
|
958 |
+ |
/// already settled. This is what the committed ledger exists for.
|
|
959 |
+ |
#[test]
|
|
960 |
+ |
fn replaying_a_batch_changes_nothing(pulled in batch()) {
|
|
961 |
+ |
let (mut conn, n) = device(1);
|
|
962 |
+ |
pull(&mut conn, n, pulled.clone());
|
|
963 |
+ |
let after_first = state(&conn);
|
|
964 |
+ |
pull(&mut conn, n, pulled);
|
|
965 |
+ |
prop_assert_eq!(after_first, state(&conn), "a replayed batch moved the state");
|
|
966 |
+ |
}
|
|
967 |
+ |
|
|
968 |
+ |
/// **Committed clocks never go backwards.** The ledger is what gates
|
|
969 |
+ |
/// stale re-pulls, so a regression there un-gates a change the device
|
|
970 |
+ |
/// already superseded, and an old value overwrites a newer one.
|
|
971 |
+ |
///
|
|
972 |
+ |
/// Two independent mechanisms hold this up, and this property
|
|
973 |
+ |
/// observes the pair rather than either one: the committed-HLC gate
|
|
974 |
+ |
/// drops a stale change before `set_committed` sees it, and
|
|
975 |
+ |
/// `set_committed` refuses to regress even if one reaches it.
|
|
976 |
+ |
/// Breaking either alone leaves this passing, which is what
|
|
977 |
+ |
/// defense in depth means and is worth knowing before trusting a
|
|
978 |
+ |
/// green run here; breaking both fails it. The individual clause in
|
|
979 |
+ |
/// `set_committed` has its own example test,
|
|
980 |
+ |
/// `committed_ledger_advances_only`.
|
|
981 |
+ |
#[test]
|
|
982 |
+ |
fn committed_clocks_only_advance(first in batch(), second in batch()) {
|
|
983 |
+ |
let (mut conn, n) = device(1);
|
|
984 |
+ |
pull(&mut conn, n, first);
|
|
985 |
+ |
let before: Vec<Option<Hlc>> =
|
|
986 |
+ |
state(&conn).into_iter().map(|(_, _, h)| h).collect();
|
|
987 |
+ |
pull(&mut conn, n, second);
|
|
988 |
+ |
let after: Vec<Option<Hlc>> =
|
|
989 |
+ |
state(&conn).into_iter().map(|(_, _, h)| h).collect();
|
|
990 |
+ |
|
|
991 |
+ |
for (before, after) in before.into_iter().zip(after) {
|
|
992 |
+ |
match (before, after) {
|
|
993 |
+ |
(Some(b), Some(a)) => prop_assert!(
|
|
994 |
+ |
a >= b,
|
|
995 |
+ |
"committed clock went backwards: {:?} -> {:?}",
|
|
996 |
+ |
b, a
|
|
997 |
+ |
),
|
|
998 |
+ |
(Some(b), None) => {
|
|
999 |
+ |
prop_assert!(false, "committed clock for a row disappeared: {:?}", b);
|
|
1000 |
+ |
}
|
|
1001 |
+ |
_ => {}
|
|
1002 |
+ |
}
|
|
1003 |
+ |
}
|
|
1004 |
+ |
}
|
|
1005 |
+ |
}
|
|
1006 |
+ |
}
|
|
1007 |
+ |
|
| 795 |
1008 |
|
// ── Conflict stash ──
|
| 796 |
1009 |
|
//
|
| 797 |
1010 |
|
// LWW always discards one side; these pin that the discarded bytes are kept
|