574 lines
18 KiB
Rust
574 lines
18 KiB
Rust
//! Integration tests for the Chapter 6 concurrent-reduction contract.
|
|
//!
|
|
//! These exercise, through the public API only, the v0 acceptance criteria that
|
|
//! route through `epiphany-ops`:
|
|
//!
|
|
//! * **Convergence** (criterion 1): overlapping edits from two replicas
|
|
//! converge to byte-identical materialized state regardless of delivery
|
|
//! order.
|
|
//! * **Equivocation** (criterion 3): an injected duplicate `OperationId` with
|
|
//! different canonical bytes equivocates at both replicas, regardless of which
|
|
//! arrived first.
|
|
//! * **Reduction determinism** (criterion 5): a 1,000-envelope set reduced in
|
|
//! ten different orders produces byte-identical materialized state.
|
|
//!
|
|
//! plus the chapter's transaction-atomicity, descriptor-precedence, conflict,
|
|
//! anomaly-exclusion, and forward-undo rules.
|
|
|
|
use epiphany_core::{
|
|
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId,
|
|
StaffInstanceId, TransactionId, TypedObjectId, VoiceId, WallClockTime,
|
|
};
|
|
use epiphany_determinism::fuzz::SplitMix64;
|
|
use epiphany_ops::{
|
|
canonical_reduction_order, valuegen, well_formed, AuthorId, CausalContext, ConflictKind,
|
|
HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect,
|
|
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
|
|
PendingReason, PreconditionFailureReason, RespellPitchOp, TransactionDescriptor,
|
|
TupletCompensation, UndoPolicy, UndoTransactionPayload,
|
|
};
|
|
|
|
// --- Builders. --------------------------------------------------------------
|
|
|
|
fn op(replica: u64, counter: u64) -> OperationId {
|
|
OperationId::new(ReplicaId(replica), counter)
|
|
}
|
|
|
|
fn envelope(
|
|
replica: u64,
|
|
counter: u64,
|
|
physical: i64,
|
|
ctx: CausalContext,
|
|
transaction: Option<TransactionId>,
|
|
payload: OperationPayload,
|
|
) -> OperationEnvelope {
|
|
let id = op(replica, counter);
|
|
OperationEnvelope {
|
|
id,
|
|
author: AuthorId(replica as u128),
|
|
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
|
|
causal_context: ctx,
|
|
transaction,
|
|
payload,
|
|
}
|
|
}
|
|
|
|
fn insert(voice: u64, event: u64, pos: i64) -> OperationPayload {
|
|
insert_span(
|
|
voice,
|
|
event,
|
|
RationalTime::from_int(pos as i32),
|
|
RationalTime::one(),
|
|
)
|
|
}
|
|
|
|
fn insert_span(
|
|
voice: u64,
|
|
event: u64,
|
|
position: RationalTime,
|
|
duration: RationalTime,
|
|
) -> OperationPayload {
|
|
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
|
staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
|
|
event: valuegen::insert_event_value(
|
|
EventId::new(ReplicaId(9), event),
|
|
VoiceId::new(ReplicaId(9), voice),
|
|
MusicalPosition(position),
|
|
MusicalDuration(duration),
|
|
&[PitchId::new(ReplicaId(9), event)],
|
|
),
|
|
}))
|
|
}
|
|
|
|
fn respell(pitch: u64, spelling: u8) -> OperationPayload {
|
|
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
|
pitch: PitchId::new(ReplicaId(9), pitch),
|
|
spelling: valuegen::spelling(spelling),
|
|
}))
|
|
}
|
|
|
|
fn reduce_in_order(envs: &[OperationEnvelope]) -> Vec<u8> {
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(envs.iter().cloned());
|
|
set.reduce().canonical_bytes()
|
|
}
|
|
|
|
fn shuffle<T>(items: &mut [T], rng: &mut SplitMix64) {
|
|
for i in (1..items.len()).rev() {
|
|
let j = (rng.next_u64() % (i as u64 + 1)) as usize;
|
|
items.swap(i, j);
|
|
}
|
|
}
|
|
|
|
// --- Convergence (v0 criterion 1). ------------------------------------------
|
|
|
|
#[test]
|
|
fn overlapping_edits_from_two_replicas_converge() {
|
|
// Replica 1 builds a small passage; replica 2 concurrently edits the same
|
|
// objects (a concurrent same-position insert and a respelling).
|
|
let envs = vec![
|
|
envelope(1, 0, 10, CausalContext::new(), None, insert(0, 100, 0)),
|
|
envelope(1, 1, 11, CausalContext::new(), None, insert(0, 101, 1)),
|
|
envelope(1, 2, 12, CausalContext::new(), None, respell(100, 3)),
|
|
// Replica 2, concurrent (no causal link to replica 1's ops).
|
|
envelope(2, 0, 10, CausalContext::new(), None, insert(0, 200, 1)), // collides with 101 @pos 1
|
|
envelope(2, 1, 11, CausalContext::new(), None, respell(100, 7)), // conflicts with op(1,2)
|
|
];
|
|
|
|
let reference = reduce_in_order(&envs);
|
|
let mut rng = SplitMix64::new(0xA11CE);
|
|
for _ in 0..8 {
|
|
let mut perm = envs.clone();
|
|
shuffle(&mut perm, &mut rng);
|
|
assert_eq!(
|
|
reduce_in_order(&perm),
|
|
reference,
|
|
"materialized state must be delivery-order independent"
|
|
);
|
|
}
|
|
|
|
// The concurrent respelling produced a structural-field-collision conflict.
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(envs);
|
|
let state = set.reduce();
|
|
assert!(
|
|
state
|
|
.conflicts
|
|
.records()
|
|
.iter()
|
|
.any(|r| matches!(r.kind, ConflictKind::StructuralFieldCollision { .. })),
|
|
"concurrent differing respellings must record a conflict"
|
|
);
|
|
}
|
|
|
|
// --- Equivocation (v0 criterion 3). -----------------------------------------
|
|
|
|
#[test]
|
|
fn duplicate_id_with_different_bytes_equivocates_at_both_replicas() {
|
|
let id_replica = 1;
|
|
let id_counter = 4;
|
|
let a = envelope(
|
|
id_replica,
|
|
id_counter,
|
|
5,
|
|
CausalContext::new(),
|
|
None,
|
|
respell(0, 1),
|
|
);
|
|
let b = envelope(
|
|
id_replica,
|
|
id_counter,
|
|
9,
|
|
CausalContext::new(),
|
|
None,
|
|
respell(0, 2),
|
|
);
|
|
assert_ne!(a.envelope_hash(), b.envelope_hash());
|
|
|
|
// Replica X sees A then B; replica Y sees B then A.
|
|
let mut x = OperationSet::new();
|
|
x.accept(a.clone());
|
|
x.accept(b.clone());
|
|
let mut y = OperationSet::new();
|
|
y.accept(b);
|
|
y.accept(a);
|
|
|
|
let id = op(id_replica, id_counter);
|
|
assert!(x.slot(id).unwrap().is_equivocated());
|
|
assert!(y.slot(id).unwrap().is_equivocated());
|
|
|
|
// Both reduce identically, with no effect for the equivocated id and an
|
|
// OperationSlotEquivocated anomaly.
|
|
let sx = x.reduce();
|
|
let sy = y.reduce();
|
|
assert_eq!(sx.canonical_bytes(), sy.canonical_bytes());
|
|
assert!(sx.effects.iter().all(|(e, _)| *e != id));
|
|
assert!(sx.anomalies.iter().any(|an| matches!(
|
|
an.kind,
|
|
IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == id
|
|
)));
|
|
}
|
|
|
|
// --- Reduction determinism (v0 criterion 5). --------------------------------
|
|
|
|
#[test]
|
|
fn thousand_envelope_set_reduces_identically_in_ten_orders() {
|
|
let mut rng = SplitMix64::new(0x5EED);
|
|
let base = epiphany_ops::fuzz::gen_envelope_set(&mut rng, 1000);
|
|
let reference = reduce_in_order(&base);
|
|
for _ in 0..10 {
|
|
let mut perm = base.clone();
|
|
shuffle(&mut perm, &mut rng);
|
|
assert_eq!(reduce_in_order(&perm), reference);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn causal_predecessor_dominates_inverted_cross_replica_hlc() {
|
|
let predecessor = envelope(1, 0, 100, CausalContext::new(), None, insert(0, 100, 0));
|
|
let successor = envelope(
|
|
2,
|
|
0,
|
|
1,
|
|
CausalContext::new().with_dot(predecessor.id),
|
|
None,
|
|
insert(0, 101, 1),
|
|
);
|
|
|
|
let ordered = canonical_reduction_order(&[&successor, &predecessor]);
|
|
assert_eq!(
|
|
ordered.iter().map(|env| env.id).collect::<Vec<_>>(),
|
|
vec![predecessor.id, successor.id]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn absent_vector_predecessor_holds_operation_pending() {
|
|
let present = envelope(1, 0, 1, CausalContext::new(), None, insert(0, 99, -1));
|
|
let dependent = envelope(
|
|
2,
|
|
0,
|
|
10,
|
|
CausalContext::new().with_seen(ReplicaId(1), 2),
|
|
None,
|
|
insert(0, 100, 0),
|
|
);
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(vec![present.clone(), dependent.clone()]);
|
|
let state = set.reduce();
|
|
|
|
assert_eq!(state.effects.len(), 1);
|
|
assert_eq!(state.effects[0].0, present.id);
|
|
assert_eq!(
|
|
state.pending,
|
|
vec![(
|
|
dependent.id,
|
|
PendingReason::MissingCausalPredecessor { missing: op(1, 1) }
|
|
)]
|
|
);
|
|
}
|
|
|
|
// --- Transactions (Chapter 6 §6.6). -----------------------------------------
|
|
|
|
fn declare_tx(replica: u64, counter: u64, physical: i64, tx: TransactionId) -> OperationEnvelope {
|
|
envelope(
|
|
replica,
|
|
counter,
|
|
physical,
|
|
CausalContext::new(),
|
|
None,
|
|
OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor {
|
|
id: tx,
|
|
label: "edit".to_string(),
|
|
category: None,
|
|
})),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn clean_transaction_applies_atomically() {
|
|
let tx = TransactionId::from_raw(77);
|
|
let d = declare_tx(1, 0, 10, tx);
|
|
// Members causally depend on the descriptor (with_seen covers counter 0).
|
|
let ctx = CausalContext::new().with_seen(ReplicaId(1), 0);
|
|
let m1 = envelope(1, 1, 11, ctx.clone(), Some(tx), insert(0, 100, 0));
|
|
let m2 = envelope(1, 2, 12, ctx, Some(tx), insert(0, 101, 1));
|
|
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(vec![d, m1.clone(), m2.clone()]);
|
|
let state = set.reduce();
|
|
|
|
// Both members applied; no transaction conflict.
|
|
for id in [m1.id, m2.id] {
|
|
let eff = state.effects.iter().find(|(e, _)| *e == id).map(|(_, e)| e);
|
|
assert_eq!(eff, Some(&OperationEffect::Applied), "member {id:?}");
|
|
}
|
|
assert!(state.conflicts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn transaction_with_a_failing_member_conflicts_wholesale() {
|
|
let tx = TransactionId::from_raw(88);
|
|
let d = declare_tx(1, 0, 10, tx);
|
|
let ctx = CausalContext::new().with_seen(ReplicaId(1), 0);
|
|
let m1 = envelope(1, 1, 11, ctx.clone(), Some(tx), insert(0, 100, 0));
|
|
// m2 deletes an event that does not exist → invariant precondition fails.
|
|
let m2 = envelope(
|
|
1,
|
|
2,
|
|
12,
|
|
ctx,
|
|
Some(tx),
|
|
OperationPayload::Primitive(OperationKind::DeleteEvent(epiphany_ops::DeleteEventOp {
|
|
event: EventId::new(ReplicaId(9), 999),
|
|
tuplet_compensation: TupletCompensation::NotInTuplet,
|
|
})),
|
|
);
|
|
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(vec![d, m1.clone(), m2.clone()]);
|
|
let state = set.reduce();
|
|
|
|
// No member applied; both reduce to TransactionConflict; a conflict record exists.
|
|
for id in [m1.id, m2.id] {
|
|
let eff = state.effects.iter().find(|(e, _)| *e == id).map(|(_, e)| e);
|
|
assert_eq!(
|
|
eff,
|
|
Some(&OperationEffect::NoOp {
|
|
reason: NoOpReason::TransactionConflict
|
|
}),
|
|
"member {id:?} must not be independently visible"
|
|
);
|
|
}
|
|
// The would-be-inserted event of m1 must NOT be live (atomic rollback).
|
|
assert!(!state
|
|
.objects
|
|
.contains_key(&TypedObjectId::Event(EventId::new(ReplicaId(9), 100))));
|
|
assert!(state
|
|
.conflicts
|
|
.records()
|
|
.iter()
|
|
.any(|r| matches!(r.kind, ConflictKind::TransactionConflict { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn failed_transaction_rolls_back_member_generated_conflicts() {
|
|
let seed = envelope(2, 0, 1, CausalContext::new(), None, insert(0, 100, 0));
|
|
let initial_spelling = envelope(
|
|
2,
|
|
1,
|
|
2,
|
|
CausalContext::new().with_seen(ReplicaId(2), 0),
|
|
None,
|
|
respell(100, 1),
|
|
);
|
|
let tx = TransactionId::from_raw(89);
|
|
let descriptor = declare_tx(1, 0, 10, tx);
|
|
let tx_ctx = CausalContext::new().with_seen(ReplicaId(1), 0);
|
|
// This member conflicts with the concurrent initial spelling.
|
|
let conflicting = envelope(1, 1, 11, tx_ctx.clone(), Some(tx), respell(100, 2));
|
|
// This member fails, forcing the whole block to roll back.
|
|
let failing = envelope(
|
|
1,
|
|
2,
|
|
12,
|
|
tx_ctx,
|
|
Some(tx),
|
|
OperationPayload::Primitive(OperationKind::DeleteEvent(epiphany_ops::DeleteEventOp {
|
|
event: EventId::new(ReplicaId(9), 999),
|
|
tuplet_compensation: TupletCompensation::NotInTuplet,
|
|
})),
|
|
);
|
|
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(vec![
|
|
seed,
|
|
initial_spelling,
|
|
descriptor,
|
|
conflicting,
|
|
failing,
|
|
]);
|
|
let state = set.reduce();
|
|
|
|
assert_eq!(
|
|
state.spellings.get(&PitchId::new(ReplicaId(9), 100)),
|
|
Some(&valuegen::spelling(1))
|
|
);
|
|
assert_eq!(state.conflicts.records().len(), 1);
|
|
assert!(matches!(
|
|
state.conflicts.records()[0].kind,
|
|
ConflictKind::TransactionConflict { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn member_without_its_descriptor_is_a_transaction_conflict() {
|
|
// A member that declares membership in a transaction whose descriptor is
|
|
// absent from the set is malformed against the transaction model. This
|
|
// transaction-specific conflict takes precedence over ordinary pending.
|
|
let tx = TransactionId::from_raw(99);
|
|
let ctx = CausalContext::new().with_seen(ReplicaId(1), 0);
|
|
let orphan = envelope(1, 1, 11, ctx, Some(tx), insert(0, 100, 0));
|
|
|
|
let mut set = OperationSet::new();
|
|
set.accept(orphan.clone());
|
|
let state = set.reduce();
|
|
assert!(state.pending.is_empty());
|
|
let eff = state
|
|
.effects
|
|
.iter()
|
|
.find(|(e, _)| *e == orphan.id)
|
|
.map(|(_, e)| e);
|
|
assert_eq!(
|
|
eff,
|
|
Some(&OperationEffect::NoOp {
|
|
reason: NoOpReason::TransactionConflict
|
|
})
|
|
);
|
|
}
|
|
|
|
// --- Anomaly exclusion (Chapter 6 §6.6). ------------------------------------
|
|
|
|
#[test]
|
|
fn hlc_monotonicity_violation_excludes_the_segment() {
|
|
// Two ops from replica 1: counter 0 with a high stamp, counter 1 with a low
|
|
// stamp — a monotonicity violation. Both are excluded from reduction.
|
|
let a = envelope(1, 0, 1000, CausalContext::new(), None, insert(0, 100, 0));
|
|
let b = envelope(1, 1, 1, CausalContext::new(), None, insert(0, 101, 1));
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(vec![a.clone(), b.clone()]);
|
|
let state = set.reduce();
|
|
|
|
assert!(
|
|
state.effects.is_empty(),
|
|
"every envelope from the offending replica at/after first_bad_counter is excluded"
|
|
);
|
|
assert!(state.anomalies.iter().any(|an| matches!(
|
|
an.kind,
|
|
IntegrityAnomalyKind::ReplicaStreamQuarantined { replica, .. } if replica == ReplicaId(1)
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn causally_ordered_same_position_insert_is_not_promoted() {
|
|
let first = envelope(1, 0, 10, CausalContext::new(), None, insert(0, 100, 0));
|
|
let second = envelope(
|
|
1,
|
|
1,
|
|
11,
|
|
CausalContext::new().with_seen(ReplicaId(1), 0),
|
|
None,
|
|
insert(0, 101, 0),
|
|
);
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(vec![first, second.clone()]);
|
|
let state = set.reduce();
|
|
|
|
let effect = state
|
|
.effects
|
|
.iter()
|
|
.find(|(id, _)| *id == second.id)
|
|
.map(|(_, effect)| effect);
|
|
assert_eq!(
|
|
effect,
|
|
Some(&OperationEffect::NoOp {
|
|
reason: NoOpReason::PreconditionFailedUnderReduction {
|
|
reason: PreconditionFailureReason::EventDurationInvalid,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_partial_interval_overlap_promotes_the_greater_id() {
|
|
let first = envelope(
|
|
1,
|
|
0,
|
|
10,
|
|
CausalContext::new(),
|
|
None,
|
|
insert_span(0, 100, RationalTime::zero(), RationalTime::one()),
|
|
);
|
|
let second = envelope(
|
|
2,
|
|
0,
|
|
10,
|
|
CausalContext::new(),
|
|
None,
|
|
insert_span(
|
|
0,
|
|
200,
|
|
RationalTime::new(1, 2).unwrap(),
|
|
RationalTime::one(),
|
|
),
|
|
);
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(vec![first, second.clone()]);
|
|
let state = set.reduce();
|
|
|
|
assert!(matches!(
|
|
state
|
|
.effects
|
|
.iter()
|
|
.find(|(id, _)| *id == second.id)
|
|
.map(|(_, effect)| effect),
|
|
Some(OperationEffect::AppliedWithRepair { repairs })
|
|
if repairs.iter().any(|repair| matches!(repair.kind, epiphany_ops::RepairKind::VoicePromoted { .. }))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn adjacent_half_open_intervals_do_not_collide() {
|
|
let first = envelope(
|
|
1,
|
|
0,
|
|
10,
|
|
CausalContext::new(),
|
|
None,
|
|
insert_span(0, 100, RationalTime::zero(), RationalTime::one()),
|
|
);
|
|
let second = envelope(
|
|
2,
|
|
0,
|
|
10,
|
|
CausalContext::new(),
|
|
None,
|
|
insert_span(0, 200, RationalTime::one(), RationalTime::one()),
|
|
);
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(vec![first, second]);
|
|
let state = set.reduce();
|
|
|
|
assert!(state
|
|
.effects
|
|
.iter()
|
|
.all(|(_, effect)| *effect == OperationEffect::Applied));
|
|
}
|
|
|
|
// --- Forward undo (Chapter 6 §6.8). -----------------------------------------
|
|
|
|
#[test]
|
|
fn undo_transaction_tombstones_minted_objects() {
|
|
let tx = TransactionId::from_raw(5);
|
|
let d = declare_tx(1, 0, 10, tx);
|
|
let ctx = CausalContext::new().with_seen(ReplicaId(1), 0);
|
|
let m1 = envelope(1, 1, 11, ctx, Some(tx), insert(0, 100, 0));
|
|
// Undo op causally after the transaction.
|
|
let undo_ctx = CausalContext::new().with_seen(ReplicaId(1), 1);
|
|
let undo = envelope(
|
|
1,
|
|
2,
|
|
20,
|
|
undo_ctx,
|
|
None,
|
|
OperationPayload::UndoTransaction(UndoTransactionPayload {
|
|
target: tx,
|
|
policy: UndoPolicy::StrictInverse,
|
|
}),
|
|
);
|
|
|
|
let mut set = OperationSet::new();
|
|
set.accept_all(vec![d, m1, undo]);
|
|
let state = set.reduce();
|
|
|
|
// The event minted by the transaction is tombstoned by the forward undo.
|
|
let ev = TypedObjectId::Event(EventId::new(ReplicaId(9), 100));
|
|
assert!(matches!(
|
|
state.objects.get(&ev),
|
|
Some(epiphany_ops::ObjectState::Tombstoned { .. })
|
|
));
|
|
}
|
|
|
|
// --- Well-formedness is a precondition for everything above. ----------------
|
|
|
|
#[test]
|
|
fn malformed_envelopes_never_enter_the_set() {
|
|
let mut set = OperationSet::new();
|
|
// stamp.id != id.
|
|
let mut e = envelope(1, 1, 5, CausalContext::new(), None, respell(0, 1));
|
|
e.stamp.id = op(1, 2);
|
|
assert!(well_formed(&e).is_err());
|
|
set.accept(e);
|
|
assert!(set.is_empty());
|
|
}
|