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

7.3 KiB
Raw Blame History

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

// 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