tidaldb/docs/planning/milestone-8/phase-4/task-05-cross-region-session-tests.md
jordan f4cfd6c81f feat: complete M8 replication primitives + forage enhancements + docs
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>
2026-02-24 13:17:19 -07:00

203 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 0104 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