#![cfg(feature = "test-utils")] //! Integration tests for Task 17's per-backend queued writer task. //! //! These exercise the full enqueue → batch → coalesce → dispatch pipeline //! end-to-end by constructing real writer tasks against `MockBackend` //! instances and driving them through the `Archivist` coordinator. //! //! The tests are timing-sensitive: the batch window is 25ms and the //! backpressure test artificially slows the backend. Assertions use //! tolerant margins so they survive CI jitter. use std::sync::Arc; use std::time::Duration; use dirigent_archivist::backend::mock::MockBackend; use dirigent_archivist::backend::{ArchiveBackend, HealthStatus}; use dirigent_archivist::coordinator::Archivist; use dirigent_archivist::registry::writer::spawn_writer; use dirigent_archivist::registry::{ ArchiveRegistration, FailureMode, OverflowPolicy, WritePolicy, }; use uuid::Uuid; fn sample_message(scroll: Uuid) -> dirigent_archivist::types::MessageRecord { dirigent_archivist::types::MessageRecord { version: 1, message_id: Uuid::now_v7(), session: scroll, parent_id: None, ts: chrono::Utc::now(), role: "user".into(), author: None, content_md: "hi".into(), content_parts: None, attachments: vec![], metadata: serde_json::Value::Null, } } fn queued_reg( name: &str, backend: Arc, priority: u32, overflow: OverflowPolicy, ) -> Arc { let initial_health = HealthStatus::Healthy; let policy = WritePolicy::Queued { batch_window_ms: 25, capacity: 8, overflow, }; let health = Arc::new(tokio::sync::RwLock::new(initial_health)); let last_error = Arc::new(tokio::sync::RwLock::new(None)); let consecutive = Arc::new(tokio::sync::RwLock::new(0u32)); let writer = Some(spawn_writer( backend.clone() as Arc, name.into(), 8, Duration::from_millis(25), overflow, health.clone(), last_error.clone(), consecutive.clone(), )); Arc::new(ArchiveRegistration::new_with_shared_state( name.into(), "mock", backend as Arc, true, FailureMode::Required, priority, true, policy, writer, health, last_error, consecutive, )) } #[tokio::test] async fn queued_write_returns_immediately_then_eventually_lands() { let mock = Arc::new(MockBackend::new()); let archivist = Archivist::from_registrations(vec![queued_reg( "queued", mock.clone(), 0, OverflowPolicy::Block, )]); let scroll = Uuid::new_v4(); archivist .append_messages(scroll, vec![sample_message(scroll)], None) .await .unwrap(); // Wait up to 500ms for the writer to drain. let mut landed = false; for _ in 0..50 { if mock.appended_count(scroll) > 0 { landed = true; break; } tokio::time::sleep(Duration::from_millis(10)).await; } assert!(landed, "writer task did not drain within 500ms"); assert_eq!(mock.appended_count(scroll), 1); archivist.shutdown().await.unwrap(); } #[tokio::test] async fn coalescing_merges_consecutive_appends_for_same_scroll() { let mock = Arc::new(MockBackend::new()); let archivist = Archivist::from_registrations(vec![queued_reg( "queued", mock.clone(), 0, OverflowPolicy::Block, )]); let scroll = Uuid::new_v4(); for _ in 0..5 { archivist .append_messages(scroll, vec![sample_message(scroll)], None) .await .unwrap(); } // Give the writer time to drain + coalesce, then shut down to guarantee // any still-queued ops are flushed before we assert. tokio::time::sleep(Duration::from_millis(200)).await; archivist.shutdown().await.unwrap(); // Five enqueued ops may have been coalesced into fewer backend calls. // The only strict invariant we can reliably assert is: the total number // of backend `append_messages` INVOCATIONS is <= 5. assert!( mock.append_call_count(scroll) <= 5, "expected <= 5 backend calls, got {}", mock.append_call_count(scroll) ); assert_eq!( mock.appended_count(scroll), 5, "all 5 messages should land" ); } #[tokio::test] async fn overflow_block_applies_backpressure() { // For backpressure to visibly stall the sender, we need four things: // 1. A tight queue (capacity=2) so the channel actually fills up. // 2. A slow backend (per-op 50ms) so the writer stalls in dispatch // long enough for the channel to fill. // 3. batch_window=0 so the writer spends (almost) all its time in // the 50ms per-op sleep instead of draining fast inside the // batch-collection phase. // 4. Distinct scroll IDs so the writer's same-scroll coalescing // doesn't merge everything into one dispatch call (which would // collapse the entire batch into a single 50ms sleep). // With those, the writer dispatches N serial 50ms calls; while it's // sleeping the sender can't fit its next op into the full channel // and must wait for a drain. let mock = Arc::new(MockBackend::new()); mock.set_per_op_delay(Duration::from_millis(50)); let capacity = 2usize; let overflow = OverflowPolicy::Block; // batch_window=0 means the writer dispatches each op immediately and // spends (almost) all its time in the 50ms per-op sleep — so the // channel stays full and the sender has to wait on every drain. let policy = WritePolicy::Queued { batch_window_ms: 0, capacity, overflow, }; let health = Arc::new(tokio::sync::RwLock::new(HealthStatus::Healthy)); let last_error = Arc::new(tokio::sync::RwLock::new(None)); let consecutive = Arc::new(tokio::sync::RwLock::new(0u32)); let writer = Some(spawn_writer( mock.clone() as Arc, "queued".into(), capacity, Duration::from_millis(0), overflow, health.clone(), last_error.clone(), consecutive.clone(), )); let reg = Arc::new(ArchiveRegistration::new_with_shared_state( "queued".into(), "mock", mock.clone() as Arc, true, FailureMode::Required, 0, true, policy, writer, health, last_error, consecutive, )); let archivist = Archivist::from_registrations(vec![reg]); // Prime the writer with one op and wait just long enough for it to // enter its first 50ms dispatch sleep. After that the writer is NOT // recv'ing, so the tight capacity=2 channel fills and further sends // must wait for a drain. let scroll0 = Uuid::new_v4(); archivist .append_messages(scroll0, vec![sample_message(scroll0)], None) .await .unwrap(); tokio::time::sleep(Duration::from_millis(10)).await; // Now measure the cost of many more sends with distinct scroll IDs // so the writer can't coalesce them. Each dispatch call is 50ms, the // queue holds only 2, so the sender must wait repeatedly for the // writer to drain cycles. let start = std::time::Instant::now(); for _ in 0..24 { let scroll = Uuid::new_v4(); archivist .append_messages(scroll, vec![sample_message(scroll)], None) .await .unwrap(); } let elapsed = start.elapsed(); // With 24 distinct-scroll sends, a capacity=2 queue, batch_window=0, // and a 50ms per-op delay, the sender cannot finish instantly — the // writer needs many drain cycles and the sender waits on each. A // 100ms floor keeps the test meaningful (a non-blocking run measures // in microseconds) while being lenient on CI jitter. assert!( elapsed >= Duration::from_millis(100), "block policy did not apply backpressure (elapsed: {:?})", elapsed ); archivist.shutdown().await.unwrap(); }