Milestone 8 (phases 1-4): - Shard-aware WAL segment naming, BatchHeader v2, ShardRouter - Transport trait, InProcessTransport, WalShipper, FollowerDb - HLC, PNCounter, LWWRegister, CrdtSignalState, ReconciliationEngine - Session replication bridge with SeqNo/HWM, idempotency store Forage application: - Multi-source discovery engine with MAB exploration - Embedding-based label system, server handlers, UI refresh Other: - QUICKSTART.md, README.md, milestone-8 planning docs - Hard negative union semantics, RLHF export enhancements - Recovery benchmark and visibility test expansions - Split 8 oversized source files per CODING_GUIDELINES §9 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.3 KiB
7.3 KiB
Task 05: Cross-Region Session Integration Tests
Delivers
Integration test suite in tidal/tests/m8p4_session.rs verifying: agent roaming between regions, session visibility within 2 seconds, idempotent writes, and hard-negative monotonicity across regions.
Complexity: M
Dependencies
- Tasks 01–04 complete
Technical Design
// tidal/tests/m8p4_session.rs
use tidaldb::replication::{
InProcessTransportFactory, SessionReplicationBridge, IdempotencyStore,
};
use tidaldb::session::{SessionId, SessionManager, SessionSeqNoTracker};
use tidaldb::entities::HardNegAction;
use tidaldb::replication::crdt::HlcTimestamp;
/// Helper: create a pair of TidalDb instances linked by InProcessTransport.
async fn setup_two_region_cluster() -> (TidalDb, TidalDb, Arc<InProcessTransportFactory>) {
let factory = Arc::new(InProcessTransportFactory::new());
let transport_a = factory.connect(RegionId(0));
let transport_b = factory.connect(RegionId(1));
let db_a = TidalDb::builder()
.ephemeral()
.with_schema(schema())
.with_cluster(NodeConfig {
region_id: RegionId(0),
shard_id: ShardId(0),
role: NodeRole::Leader,
})
.with_transport(transport_a)
.open()
.unwrap();
let db_b = TidalDb::builder()
.ephemeral()
.with_schema(schema())
.with_cluster(NodeConfig {
region_id: RegionId(1),
shard_id: ShardId(0),
role: NodeRole::Follower,
})
.with_transport(transport_b)
.open()
.unwrap();
(db_a, db_b, factory)
}
/// Agent roaming: session started in us-east, readable in eu-west.
#[tokio::test]
async fn test_session_cross_region_visibility() {
let (db_a, db_b, _factory) = setup_two_region_cluster().await;
let user = EntityId::new(1);
let session_id = db_a.start_session(user, Default::default()).unwrap();
// Write 5 preference signals in region A.
for i in 0..5u64 {
let item = EntityId::new(100 + i);
db_a.signal_in_session(session_id, "view", item, 1.0, Timestamp::now()).unwrap();
}
// Allow replication to propagate (< 2 seconds using InProcessTransport).
tokio::time::sleep(Duration::from_millis(200)).await;
// Read session signals from region B.
let session_b = db_b.get_session(session_id).unwrap();
assert!(session_b.is_some(), "session should be visible in region B");
let signals = db_b.session_signals(session_id).unwrap();
assert_eq!(signals.len(), 5, "all 5 preference signals should be visible in region B");
}
/// Idempotent replication: duplicate session events produce no double-counting.
#[tokio::test]
async fn test_session_replication_idempotent() {
let (db_a, db_b, factory) = setup_two_region_cluster().await;
let user = EntityId::new(2);
let session_id = db_a.start_session(user, Default::default()).unwrap();
let item = EntityId::new(200);
db_a.signal_in_session(session_id, "like", item, 1.0, Timestamp::now()).unwrap();
// Let it replicate once.
tokio::time::sleep(Duration::from_millis(100)).await;
// Force re-send (simulated duplicate).
factory.replay_last_session_batch(RegionId(1)).await;
tokio::time::sleep(Duration::from_millis(50)).await;
// Signal count on B should still be 1, not 2.
let count = db_b.read_windowed_count(item, "like", Window::OneHour).unwrap();
// (Session signals are user-scoped; verify via session data, not global ledger)
let session_data = db_b.session_signals(session_id).unwrap();
assert_eq!(session_data.len(), 1, "no double-counting from duplicate replication");
}
/// Hard negative monotonicity: hide in region A, unhide (lower HLC) in region B.
/// After replication: item is suppressed in BOTH regions.
#[tokio::test]
async fn test_hardneg_monotonicity_hide_wins() {
let (db_a, db_b, _factory) = setup_two_region_cluster().await;
let user = EntityId::new(3);
let item = EntityId::new(300);
// Region A hides item at t=100 (higher HLC).
let ts_hide = HlcTimestamp { wall_ns: 100, logical: 0, node_id: 0 };
db_a.hide_item_with_ts(user, item, ts_hide).unwrap();
// Region B has an earlier unhide at t=50 (already in state before partition).
let ts_unhide = HlcTimestamp { wall_ns: 50, logical: 0, node_id: 1 };
db_b.unhide_item_with_ts(user, item, ts_unhide).unwrap();
// Allow replication to propagate.
tokio::time::sleep(Duration::from_millis(200)).await;
// After replication: both regions should suppress the item.
let results_b = db_b.retrieve(&Retrieve::builder()
.for_user(user)
.candidates(vec![item])
.build()
.unwrap()
).unwrap();
assert!(
results_b.items.is_empty(),
"hidden item must not appear in region B results after replication"
);
}
/// Hard negative: explicit unhide with HIGHER HLC does clear the hide.
#[tokio::test]
async fn test_hardneg_explicit_unhide_with_higher_hlc() {
let (db_a, db_b, _factory) = setup_two_region_cluster().await;
let user = EntityId::new(4);
let item = EntityId::new(400);
// Both regions: hide at t=50.
let ts_hide = HlcTimestamp { wall_ns: 50, logical: 0, node_id: 0 };
db_a.hide_item_with_ts(user, item, ts_hide).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
// User explicitly un-hides at t=200 (higher than hide).
let ts_unhide = HlcTimestamp { wall_ns: 200, logical: 0, node_id: 1 };
db_b.unhide_item_with_ts(user, item, ts_unhide).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
// After full replication + LWW resolution: item should appear (unhide wins).
let results_a = db_a.retrieve(&Retrieve::builder()
.for_user(user)
.candidates(vec![item])
.build()
.unwrap()
).unwrap();
assert_eq!(results_a.items.len(), 1, "unhide with higher HLC should make item visible again");
}
/// Seqno HWM: writes with seqno <= HWM are idempotent no-ops on receiver.
#[tokio::test]
async fn test_session_seqno_hwm_rejects_duplicates() {
let tracker = SessionSeqNoTracker::new();
let session = SessionId::new();
// Sequence 1..5 -- all accepted.
for i in 1..=5u64 {
assert!(tracker.should_apply(session, SessionSeqNo(i)));
}
// Re-send seqno 3 -- rejected.
assert!(!tracker.should_apply(session, SessionSeqNo(3)));
// Seqno 6 -- accepted.
assert!(tracker.should_apply(session, SessionSeqNo(6)));
// HWM should be 6 now.
assert_eq!(tracker.hwm(session), SessionSeqNo(6));
}
Acceptance Criteria
test_session_cross_region_visibility: 5 session signals written in region A visible in region B within 200ms (in-process transport)test_session_replication_idempotent: duplicate session batch replay produces no double-countingtest_hardneg_monotonicity_hide_wins: hide at higher HLC suppresses item in both regions after cross-region replicationtest_hardneg_explicit_unhide_with_higher_hlc: unhide at strictly higher HLC restores visibility after replicationtest_session_seqno_hwm_rejects_duplicates: HWM tracker unit test with 5 monotonic accepts + 1 duplicate reject + 1 resume accept- All 5 tests pass in
cargo test --test m8p4_session cargo clippy -D warningsandcargo fmtpass