//! m8p4 integration tests: session continuity and agent memory across regions. //! //! Tests use components directly (no `TidalDb` instances needed) for speed //! and determinism. No async, no sleep, no background threads. #![allow(clippy::too_many_lines, clippy::unwrap_used)] use std::{sync::Arc, time::Instant}; use tidaldb::{ replication::{ IdempotencyKey, IdempotencyStore, InProcessSessionTransportFactory, SessionReplicationBridge, ShardId, }, session::SessionSeqNoTracker, wal::format::session::{SessionSeqNo, SessionWalEvent}, }; // -- helpers ------------------------------------------------------------------ fn signal_event(session_id: u64, entity_id: u64, seqno: u64) -> SessionWalEvent { SessionWalEvent::Signal { session_id, entity_id, weight: 1.0, ts_ns: 1_000_000_000, signal_name: "view".to_string(), annotation: None, session_seqno: Some(SessionSeqNo(seqno)), idempotency_key: Some(u128::from(seqno) * 1_000_000 + u128::from(session_id)), } } fn make_two_bridges() -> (SessionReplicationBridge, SessionReplicationBridge) { let shards = [ShardId(0), ShardId(1)]; let mut transports = InProcessSessionTransportFactory::new(&shards).build(); let t0 = transports.remove(&ShardId(0)).unwrap(); let t1 = transports.remove(&ShardId(1)).unwrap(); let b0 = SessionReplicationBridge::new( t0, Arc::new(IdempotencyStore::new(1000)), Arc::new(SessionSeqNoTracker::new()), ); let b1 = SessionReplicationBridge::new( t1, Arc::new(IdempotencyStore::new(1000)), Arc::new(SessionSeqNoTracker::new()), ); (b0, b1) } // -- Test 1: SeqNo HWM rejects duplicates ------------------------------------- #[test] fn test_session_seqno_hwm_rejects_duplicates() { let tracker = SessionSeqNoTracker::new(); let session = 1u64; // Sequence 1..5 -- all accepted. for i in 1..=5u64 { assert!( tracker.should_apply(session, SessionSeqNo(i)), "seqno {i} should be accepted" ); } // Re-send seqno 3 -- rejected. assert!( !tracker.should_apply(session, SessionSeqNo(3)), "seqno 3 is a duplicate, should be rejected" ); // Seqno 6 -- accepted (monotone resume). assert!(tracker.should_apply(session, SessionSeqNo(6))); // HWM should be 6. assert_eq!(tracker.hwm(session), SessionSeqNo(6)); } // -- Test 2: 5 session signals replicated region A -> B ----------------------- #[test] fn test_session_cross_region_visibility() { let (b0, b1) = make_two_bridges(); let session_id = 10u64; let events: Vec = (1..=5) .map(|i| signal_event(session_id, 100 + i, i)) .collect(); // Ship from region A (shard 0) to region B (shard 1). let start = Instant::now(); let shipped = b0.ship(ShardId(1), session_id, &events).unwrap(); assert_eq!(shipped, 5, "all 5 events should be shipped"); // Receive and apply on region B. let mut received = Vec::new(); let applied = b1.recv_and_apply(|e| received.push(e.clone())).unwrap(); let elapsed = start.elapsed(); assert_eq!(applied, 5, "all 5 events should be applied on follower"); assert_eq!(received.len(), 5); // AC: session visible in region B within 2 seconds (in-process transport). assert!( elapsed.as_secs() < 2, "cross-region visibility must be < 2s, was {elapsed:?}" ); } // -- Test 3: Idempotent replication (no double-counting) ---------------------- #[test] fn test_session_replication_idempotent() { let (b0, b1) = make_two_bridges(); let session_id = 20u64; let events = vec![signal_event(session_id, 200, 1)]; // First ship -- gets through. b0.ship(ShardId(1), session_id, &events).unwrap(); let applied_first = b1.recv_and_apply(|_| {}).unwrap(); assert_eq!(applied_first, 1); // Second ship of the same events -- ship HWM blocks re-shipping. let reshipped = b0.ship(ShardId(1), session_id, &events).unwrap(); assert_eq!(reshipped, 0, "ship HWM should prevent re-shipping"); } // -- Test 4: Sequential events only ship the new ones ------------------------- #[test] fn test_incremental_shipping() { let (b0, b1) = make_two_bridges(); let session_id = 30u64; // Ship batch 1 (seqno 1-3). let batch1: Vec<_> = (1..=3) .map(|i| signal_event(session_id, 300 + i, i)) .collect(); let shipped1 = b0.ship(ShardId(1), session_id, &batch1).unwrap(); assert_eq!(shipped1, 3); b1.recv_and_apply(|_| {}).unwrap(); // Ship batch 2 (seqno 1-5, but 1-3 already sent -> only 4-5 shipped). let batch2: Vec<_> = (1..=5) .map(|i| signal_event(session_id, 300 + i, i)) .collect(); let shipped2 = b0.ship(ShardId(1), session_id, &batch2).unwrap(); assert_eq!(shipped2, 2, "only new events (seqno 4-5) should be shipped"); let mut count = 0; let applied = b1.recv_and_apply(|_| count += 1).unwrap(); assert_eq!(applied, 2); assert_eq!(count, 2); } // NOTE: the former Tests 5-7 exercised HardNegIndex::apply_replication_hide/unhide, // the per-index HLC-ordered hide/unhide API removed during the M0-M10 remediation // (it was dead code -- never wired to any production path). HLC-monotonic // hard-negative convergence across shards is resolved in a single place: the // LWWRegister in replication/reconcile.rs, which drives HardNegIndex // through add()/remove() (Hide -> add, Unhide -> remove). The three convergence // scenarios the removed tests covered (lower-HLC unhide loses, higher-HLC unhide // wins, multi-shard hide union) are re-expressed against that live reconcile path // in src/replication/reconcile_tests.rs. // -- Test 8: Idempotency store catches within-window duplicates --------------- #[test] fn test_idempotency_store_deduplication() { let store = IdempotencyStore::new(100); let key = IdempotencyKey::derive(1, SessionSeqNo(1), b"op"); assert!(store.check_and_record(key), "first time: accepted"); assert!(!store.check_and_record(key), "second time: duplicate"); assert!(!store.check_and_record(key), "third time: still duplicate"); // Different key is accepted. let key2 = IdempotencyKey::derive(1, SessionSeqNo(2), b"op"); assert!(store.check_and_record(key2)); }