epiphany/crates/epiphany-ops/tests/concurrent_reduction.rs

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());
}