tidaldb/tidal/docs/planning/milestone-8/phase-4/task-05-cross-region-session-tests.md
jx12n 3bcfb3c576 feat: Bazel build, crate docs/ai-lookup, docker images, and engine hardening
- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build
- Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference
- Add docker standalone/cluster/deploy images, compose, and prometheus config
- Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry
- Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites
- Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
2026-06-07 18:29:38 -06: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