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>
203 lines
7.3 KiB
Markdown
203 lines
7.3 KiB
Markdown
# 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
|
||
|
||
```rust
|
||
// 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-counting
|
||
- [ ] `test_hardneg_monotonicity_hide_wins`: hide at higher HLC suppresses item in both regions after cross-region replication
|
||
- [ ] `test_hardneg_explicit_unhide_with_higher_hlc`: unhide at strictly higher HLC restores visibility after replication
|
||
- [ ] `test_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 warnings` and `cargo fmt` pass
|