use crate::harness;
use async_trait::async_trait;
use bytes::Bytes;
use laser_sdk::prelude::full::*;
use laser_sdk::wire::agent::AgentDeadLetter;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
use tokio::time::sleep;
struct SlowHandler {
started: Arc<AtomicBool>,
finished: Arc<AtomicBool>,
work: Duration,
}
impl AgentHandler for SlowHandler {
async fn handle(&self, _message: &AgentMessage, _ctx: &AgentCtx<'_>) -> Result<(), LaserError> {
self.started.store(true, Ordering::SeqCst);
sleep(self.work).await;
self.finished.store(true, Ordering::SeqCst);
Ok(())
}
}
#[tokio::test]
#[serial_test::serial(integration)]
async fn given_a_message_in_flight_when_shut_down_then_should_drain_before_returning() {
let laser = harness::laser().await;
let started = Arc::new(AtomicBool::new(false));
let finished = Arc::new(AtomicBool::new(false));
let handle = Agent::builder()
.id("drainer".parse().expect("valid agent id"))
.listen_on(AgentTopic::Commands)
.handler(SlowHandler {
started: started.clone(),
finished: finished.clone(),
work: Duration::from_millis(800),
})
.build()
.spawn(laser.clone());
let provenance = Provenance::builder()
.conversation_id(ConversationId::new())
.build();
laser
.send_agent(
AgentTopic::Commands,
Bytes::from_static(b"tick"),
&provenance,
)
.await
.expect("send");
harness::eventually(|| {
let started = started.clone();
async move { started.load(Ordering::SeqCst).then_some(()) }
})
.await;
handle
.shutdown()
.await
.expect("graceful shutdown returns Ok");
assert!(
finished.load(Ordering::SeqCst),
"shutdown must drain the in-flight message, not abort it"
);
}
struct RecordingSink {
capsules: Arc<std::sync::Mutex<Vec<AgentDeadLetter>>>,
}
#[async_trait]
impl DeadLetterSink for RecordingSink {
async fn on_dead_letter(
&self,
_message: Option<&AgentMessage>,
capsule: &AgentDeadLetter,
_publish_result: &Result<(), LaserError>,
) {
self.capsules
.lock()
.expect("sink lock is not poisoned")
.push(capsule.clone());
}
}
struct CountingMiddleware {
after: Arc<AtomicUsize>,
}
#[async_trait]
impl AgentMiddleware for CountingMiddleware {
async fn after_handle(
&self,
_message: &AgentMessage,
_result: &Result<(), LaserError>,
_attempt: u32,
) {
self.after.fetch_add(1, Ordering::SeqCst);
}
}
struct RejectingHandler;
impl AgentHandler for RejectingHandler {
async fn handle(&self, _message: &AgentMessage, _ctx: &AgentCtx<'_>) -> Result<(), LaserError> {
Err(LaserError::rejected("nope"))
}
}
#[tokio::test]
#[serial_test::serial(integration)]
async fn given_a_rejected_message_when_dead_lettered_then_should_notify_the_sink_and_middleware() {
let laser = harness::laser().await;
let capsules = Arc::new(std::sync::Mutex::new(Vec::new()));
let after = Arc::new(AtomicUsize::new(0));
let handle = Agent::builder()
.id("rejecter".parse().expect("valid agent id"))
.listen_on(AgentTopic::Commands)
.handler(RejectingHandler)
.on_dead_letter(Arc::new(RecordingSink {
capsules: capsules.clone(),
}))
.middleware(vec![Arc::new(CountingMiddleware {
after: after.clone(),
})])
.build()
.spawn(laser.clone());
let provenance = Provenance::builder()
.conversation_id(ConversationId::new())
.build();
laser
.send_agent(
AgentTopic::Commands,
Bytes::from_static(b"work"),
&provenance,
)
.await
.expect("send");
let seen = harness::eventually(|| {
let capsules = capsules.clone();
async move {
let items = capsules.lock().expect("lock").clone();
(!items.is_empty()).then_some(items)
}
})
.await;
assert_eq!(seen.len(), 1, "the sink observes exactly one dead-letter");
assert!(
after.load(Ordering::SeqCst) >= 1,
"the middleware observed the handler outcome"
);
handle.shutdown().await.expect("shutdown");
}
struct LaneHandler {
slow_in_flight: Arc<AtomicBool>,
fast_during_slow: Arc<AtomicUsize>,
work: Duration,
}
impl AgentHandler for LaneHandler {
async fn handle(&self, message: &AgentMessage, _ctx: &AgentCtx<'_>) -> Result<(), LaserError> {
if message.body() == b"slow" {
self.slow_in_flight.store(true, Ordering::SeqCst);
sleep(self.work).await;
self.slow_in_flight.store(false, Ordering::SeqCst);
} else if self.slow_in_flight.load(Ordering::SeqCst) {
self.fast_during_slow.fetch_add(1, Ordering::SeqCst);
}
Ok(())
}
}
#[tokio::test]
#[serial_test::serial(integration)]
async fn given_serial_per_partition_when_one_partition_blocks_then_others_still_progress() {
let laser = harness::laser().await;
let slow_in_flight = Arc::new(AtomicBool::new(false));
let fast_during_slow = Arc::new(AtomicUsize::new(0));
let handle = Agent::builder()
.id("lanes".parse().expect("valid agent id"))
.listen_on(AgentTopic::Commands)
.handler(LaneHandler {
slow_in_flight: slow_in_flight.clone(),
fast_during_slow: fast_during_slow.clone(),
work: Duration::from_millis(2000),
})
.concurrency(ConcurrencyPolicy::SerialPerPartition { max_partitions: 4 })
.build()
.spawn(laser.clone());
let slow = Provenance::builder()
.conversation_id(ConversationId::new())
.build();
laser
.send_agent(AgentTopic::Commands, Bytes::from_static(b"slow"), &slow)
.await
.expect("send slow");
harness::eventually(|| {
let slow_in_flight = slow_in_flight.clone();
async move { slow_in_flight.load(Ordering::SeqCst).then_some(()) }
})
.await;
for _ in 0..8 {
let fast = Provenance::builder()
.conversation_id(ConversationId::new())
.build();
laser
.send_agent(AgentTopic::Commands, Bytes::from_static(b"fast"), &fast)
.await
.expect("send fast");
}
harness::eventually(|| {
let fast_during_slow = fast_during_slow.clone();
async move { (fast_during_slow.load(Ordering::SeqCst) >= 1).then_some(()) }
})
.await;
handle.shutdown().await.expect("shutdown");
}