use crate::agent::clock::{Clock, SystemClock};
use crate::agent::ctx::AgentCtx;
use crate::error::LaserError;
use crate::laser::Laser;
use crate::provenance::{AgentTopic, Provenance};
use crate::types::{AgentId, ConsumerGroupName, ConversationId, MessageId};
use async_trait::async_trait;
use iggy::consumer_ext::MessageConsumer;
use iggy::prelude::*;
use laser_wire::agent::{
AgentDeadLetter, AgentEnvelope, AgentKind, DeadLetterReason, LogPosition, OPERATION_TASK,
SignatureContext, TaskState, features, validate,
};
use laser_wire::codes::AGENT_OP_VERSION;
use laser_wire::content::ContentType;
use laser_wire::framing::{decode_named, encode_named};
use laser_wire::headers::{AGENT_VERSION, CONTENT_TYPE, FENCE};
use std::collections::{BTreeMap, HashSet, VecDeque};
use std::str::FromStr;
use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::time::sleep;
use tracing::{debug, error, warn};
fn backoff_for(attempt: u32) -> Duration {
const BASE_MILLIS: u64 = 50;
const CEILING_MILLIS: u64 = 1000;
let scaled = BASE_MILLIS.saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1).min(16)));
Duration::from_millis(scaled.min(CEILING_MILLIS))
}
const DEDUP_SCOPE_SEP: char = '\u{1f}';
const FENCE_MAP_SOFT_CAP: usize = 16_384;
#[cfg(feature = "sign")]
const VERIFIED_RECORD_WINDOW: usize = 16_384;
const FENCE_ENTRY_TTL_MICROS: u64 = 600_000_000;
const FENCE_SWEEP_INTERVAL_MICROS: u64 = 1_000_000;
const POLL_INTERVAL: Duration = Duration::from_millis(10);
const DEFAULT_SHUTDOWN_GRACE: Duration = Duration::from_secs(30);
const DEFAULT_MAX_QUEUED_RECORDS: usize = 4_096;
const DEFAULT_MAX_QUEUED_BYTES: usize = 64 * 1024 * 1024;
#[trait_variant::make(AgentHandler: Send)]
pub trait LocalAgentHandler {
async fn handle(&self, message: &AgentMessage, ctx: &AgentCtx<'_>) -> Result<(), LaserError>;
}
#[async_trait]
pub trait AgentMiddleware: Send + Sync {
async fn before_handle(&self, message: &AgentMessage) -> Result<(), LaserError> {
let _ = message;
Ok(())
}
async fn after_handle(
&self,
message: &AgentMessage,
result: &Result<(), LaserError>,
attempt: u32,
) {
let _ = (message, result, attempt);
}
}
#[async_trait]
pub trait DeadLetterSink: Send + Sync {
async fn on_dead_letter(
&self,
message: Option<&AgentMessage>,
capsule: &AgentDeadLetter,
publish_result: &Result<(), LaserError>,
);
}
#[derive(Debug, Clone)]
pub struct AgentMessage {
pub provenance: Provenance,
pub payload: Vec<u8>,
pub id: MessageId,
pub envelope: Option<AgentEnvelope>,
pub content_type: Option<ContentType>,
pub verified_principal: Option<String>,
}
impl AgentMessage {
pub fn body(&self) -> &[u8] {
match &self.envelope {
Some(envelope) => &envelope.body,
None => &self.payload,
}
}
fn from_received(
received: ReceivedMessage,
understood_features: u64,
) -> Result<DecodedAgentMessage, (Box<LaserError>, Vec<u8>)> {
let id = MessageId::new(received.partition_id, received.message.header.offset);
let payload = received.message.payload.to_vec();
let decoded = match decode_agent_record(&received.message, understood_features) {
Ok(decoded) => decoded,
Err(error) => return Err((Box::new(error), payload)),
};
Ok(DecodedAgentMessage {
message: Self {
provenance: decoded.provenance,
payload,
id,
envelope: decoded.envelope,
content_type: decoded.content_type,
verified_principal: None,
},
#[cfg(feature = "sign")]
signature_context: decoded.signature_context,
#[cfg(feature = "sign")]
observed_at_micros: decoded.observed_at_micros,
})
}
}
struct DecodedAgentMessage {
message: AgentMessage,
#[cfg(feature = "sign")]
signature_context: Option<SignatureContext>,
#[cfg(feature = "sign")]
observed_at_micros: u64,
}
pub(crate) struct DecodedAgentRecord {
pub(crate) provenance: Provenance,
pub(crate) envelope: Option<AgentEnvelope>,
pub(crate) content_type: Option<ContentType>,
pub(crate) signature_context: Option<SignatureContext>,
pub(crate) observed_at_micros: u64,
}
pub(crate) fn decode_agent_record(
message: &IggyMessage,
understood_features: u64,
) -> Result<DecodedAgentRecord, LaserError> {
let headers = message.user_headers_map()?.unwrap_or_default();
let content_type_key = HeaderKey::from_str(CONTENT_TYPE)?;
let content_type_code = headers
.get(&content_type_key)
.map(HeaderValue::as_uint8)
.transpose()?;
let content_type = content_type_code.and_then(ContentType::from_code);
let version_key = HeaderKey::from_str(AGENT_VERSION)?;
let version = headers
.get(&version_key)
.map(HeaderValue::as_uint32)
.transpose()?;
let (provenance, envelope, signature_context) = match version {
Some(version) => {
if version != AGENT_OP_VERSION {
return Err(LaserError::Invalid(format!(
"unsupported agent envelope version {version}"
)));
}
let envelope: AgentEnvelope = decode_named(&message.payload)?;
validate(&envelope)?;
let unmet = envelope.unmet_requirements(understood_features);
if unmet != features::NONE {
return Err(LaserError::Invalid(format!(
"agent envelope requires unsupported features 0x{unmet:016x}"
)));
}
let provenance = provenance_from_envelope(&envelope);
let context = SignatureContext {
content_type: content_type_code,
agent_version: Some(version),
};
(provenance, Some(envelope), Some(context))
}
None => (
crate::provenance::provenance_from_headers(&headers)?,
None,
None,
),
};
Ok(DecodedAgentRecord {
provenance,
envelope,
content_type,
signature_context,
observed_at_micros: message.header.timestamp,
})
}
pub(crate) fn provenance_and_envelope(
message: &IggyMessage,
) -> Result<(Provenance, Option<AgentEnvelope>), LaserError> {
let decoded = decode_agent_record(message, features::NONE)?;
Ok((decoded.provenance, decoded.envelope))
}
fn provenance_from_envelope(envelope: &AgentEnvelope) -> Provenance {
Provenance::builder()
.conversation_id(envelope.conversation.into())
.maybe_agent(AgentId::try_from(envelope.source.as_str()).ok())
.maybe_target_agent_id(
envelope
.target
.as_ref()
.and_then(|target| AgentId::try_from(target.as_str()).ok()),
)
.maybe_idempotency_key(
envelope
.idempotency_key
.as_ref()
.map(|key| key.as_str().to_owned()),
)
.maybe_correlation_id(
envelope
.correlation
.map(|correlation| correlation.to_string()),
)
.maybe_deadline(envelope.deadline_micros.map(IggyTimestamp::from))
.maybe_fence_token(envelope.metadata.as_ref().and_then(
|metadata| match metadata.get(FENCE) {
Some(laser_wire::query::Value::Uint(fence)) => Some(*fence),
Some(laser_wire::query::Value::Int(fence)) => u64::try_from(*fence).ok(),
_ => None,
},
))
.build()
}
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub base_delay: Duration,
}
impl RetryPolicy {
pub fn backoff(max_attempts: u32, base_delay: Duration) -> Self {
Self {
max_attempts,
base_delay,
}
}
fn delay_for(&self, attempt: u32) -> Duration {
self.base_delay
.saturating_mul(2u32.saturating_pow(attempt.min(16)))
}
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_attempts: 5,
base_delay: Duration::from_millis(200),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ConcurrencyPolicy {
#[default]
Serial,
SerialPerPartition {
max_partitions: usize,
},
}
#[derive(bon::Builder)]
pub struct ReliableConsumer {
pub group: ConsumerGroupName,
pub agent: Option<AgentId>,
#[builder(into)]
pub topic: String,
#[builder(default = 10_000)]
pub dedup_window: usize,
#[builder(default)]
pub retry: RetryPolicy,
#[builder(default = features::NONE)]
pub understood_features: u64,
#[builder(default = POLL_INTERVAL)]
pub poll_interval: Duration,
#[builder(default = DEFAULT_SHUTDOWN_GRACE)]
pub shutdown_grace: Duration,
#[builder(default)]
pub concurrency: ConcurrencyPolicy,
#[builder(default = DEFAULT_MAX_QUEUED_RECORDS)]
pub max_queued_records: usize,
#[builder(default = DEFAULT_MAX_QUEUED_BYTES)]
pub max_queued_bytes: usize,
pub respond_on: Option<AgentTopic<'static>>,
#[builder(default)]
pub inbox_route: crate::agent::router::InboxRoute,
#[builder(default)]
pub ack_on_pickup: bool,
pub deduplicator: Option<Box<dyn Deduplicator>>,
#[builder(default)]
pub warm_dedup: bool,
#[builder(default)]
pub middleware: Vec<std::sync::Arc<dyn AgentMiddleware>>,
pub on_dead_letter: Option<std::sync::Arc<dyn DeadLetterSink>>,
#[cfg(feature = "sign")]
pub verifier: Option<std::sync::Arc<crate::sign::KeyRegistry>>,
#[cfg(feature = "sign")]
pub signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
}
impl ReliableConsumer {
pub async fn run<H>(
self,
laser: &Laser,
handler: H,
ready: oneshot::Sender<()>,
shutdown: oneshot::Receiver<()>,
) -> Result<(), LaserError>
where
H: AgentHandler + Sync + Send + 'static,
{
let shutdown_grace = self.shutdown_grace;
let concurrency = self.concurrency;
let auto_commit = AutoCommit::Disabled;
let mut consumer = laser
.client()
.consumer_group(self.group.as_str(), laser.stream_required()?, &self.topic)?
.auto_commit(auto_commit)
.create_consumer_group_if_not_exists()
.auto_join_consumer_group()
.poll_interval(IggyDuration::new(self.poll_interval))
.build();
consumer.init().await?;
let deduplicator = self
.deduplicator
.unwrap_or_else(|| Box::new(SlidingWindow::new(self.dedup_window)));
if self.warm_dedup {
warm_dedup_window(
laser,
self.group.as_str(),
&self.topic,
deduplicator.as_ref(),
self.dedup_window,
)
.await?;
}
let _ = ready.send(());
let agent = self.agent;
let stream_ident = Identifier::named(laser.stream_required()?)?;
let topic_ident = Identifier::named(&self.topic)?;
let stream_id = laser
.client()
.get_stream(&stream_ident)
.await?
.map(|details| details.id);
let topic_id = laser
.client()
.get_topic(&stream_ident, &topic_ident)
.await?
.map(|details| details.id);
if stream_id.is_none() || topic_id.is_none() {
warn!(
topic = %self.topic,
"could not resolve the numeric stream/topic id, dead-letter capsules \
carry 0 for the unresolved locator half (partition and offset stay correct)"
);
}
let (stream_id, topic_id) = (stream_id.unwrap_or_default(), topic_id.unwrap_or_default());
let group_consumer = Consumer::group(Identifier::named(self.group.as_str())?);
let reliable = ReliableWorker {
handler,
laser: laser.clone(),
retry: self.retry,
understood_features: self.understood_features,
dedup: deduplicator,
agent,
respond_on: self.respond_on,
inbox_route: self.inbox_route,
ack_on_pickup: self.ack_on_pickup,
stream_id,
topic_id,
middleware: self.middleware,
on_dead_letter: self.on_dead_letter,
high_water_fence: dashmap::DashMap::new(),
fence_last_sweep: std::sync::atomic::AtomicU64::new(0),
#[cfg(feature = "sign")]
verifier: self.verifier,
#[cfg(feature = "sign")]
signing_key: self.signing_key,
#[cfg(feature = "sign")]
verified_records: Mutex::new(DedupWindow::new(VERIFIED_RECORD_WINDOW)),
};
match concurrency {
ConcurrencyPolicy::Serial => {
run_serial(
consumer,
reliable,
group_consumer,
stream_ident,
topic_ident,
shutdown,
shutdown_grace,
)
.await
}
ConcurrencyPolicy::SerialPerPartition { max_partitions } => {
run_per_partition(
consumer,
reliable,
max_partitions.max(1),
self.max_queued_records.max(1),
self.max_queued_bytes.max(1),
shutdown,
shutdown_grace,
)
.await
}
}
}
}
struct SerialCommitter<'a, H> {
worker: &'a ReliableWorker<H>,
client: &'a IggyClient,
group: &'a Consumer,
stream: &'a Identifier,
topic: &'a Identifier,
failure: tokio::sync::mpsc::Sender<IggyError>,
}
impl<H> SerialCommitter<'_, H> {
async fn stop(&self, error: IggyError) -> Result<(), IggyError> {
let _ = self.failure.send(error).await;
std::future::pending().await
}
}
impl<H> MessageConsumer for SerialCommitter<'_, H>
where
H: AgentHandler + Sync,
{
async fn consume(&self, received: ReceivedMessage) -> Result<(), IggyError> {
let partition = received.partition_id;
let offset = received.message.header.offset;
if let Err(error) = self.worker.consume(received).await {
return self.stop(error).await;
}
if let Err(error) = self
.client
.store_consumer_offset(self.group, self.stream, self.topic, Some(partition), offset)
.await
{
return self.stop(error).await;
}
Ok(())
}
}
async fn run_serial<H>(
mut consumer: IggyConsumer,
reliable: ReliableWorker<H>,
group: Consumer,
stream: Identifier,
topic: Identifier,
shutdown: oneshot::Receiver<()>,
shutdown_grace: Duration,
) -> Result<(), LaserError>
where
H: AgentHandler + Sync + Send + 'static,
{
let (drain_tx, drain_rx) = oneshot::channel();
let (failure_tx, mut failure_rx) = tokio::sync::mpsc::channel(1);
let client = reliable.laser.client();
let committer = SerialCommitter {
worker: &reliable,
client: &client,
group: &group,
stream: &stream,
topic: &topic,
failure: failure_tx,
};
let mut drained = true;
let result = {
let consume = consumer.consume_messages(&committer, drain_rx);
tokio::pin!(consume);
tokio::select! {
result = &mut consume => result.map_err(LaserError::from),
Some(error) = failure_rx.recv() => Err(LaserError::from(error)),
signal = shutdown => match signal {
Ok(()) => {
let _ = drain_tx.send(());
let finish = async {
tokio::select! {
result = &mut consume => result.map_err(LaserError::from),
Some(error) = failure_rx.recv() => Err(LaserError::from(error)),
}
};
match tokio::time::timeout(shutdown_grace, finish).await {
Ok(result) => result,
Err(_) => {
drained = false;
Err(LaserError::Timeout("agent shutdown drain"))
}
}
}
Err(_) => tokio::select! {
result = &mut consume => result.map_err(LaserError::from),
Some(error) = failure_rx.recv() => Err(LaserError::from(error)),
},
},
}
};
if drained && let Err(error) = consumer.shutdown().await {
warn!(%error, "failed to leave the consumer group on shutdown");
}
result
}
async fn run_per_partition<H>(
mut consumer: IggyConsumer,
reliable: ReliableWorker<H>,
max_partitions: usize,
max_queued_records: usize,
max_queued_bytes: usize,
shutdown: oneshot::Receiver<()>,
shutdown_grace: Duration,
) -> Result<(), LaserError>
where
H: AgentHandler + Sync + Send + 'static,
{
use futures::StreamExt;
let max_queued_records = max_queued_records.min(u32::MAX as usize);
let max_queued_bytes = max_queued_bytes.min(u32::MAX as usize);
let worker = std::sync::Arc::new(reliable);
let mut lanes: std::collections::HashMap<u32, tokio::sync::mpsc::Sender<QueuedMessage>> =
std::collections::HashMap::new();
let mut lane_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
let (commit_tx, mut commit_rx) =
tokio::sync::mpsc::channel::<Result<(u32, u64), IggyError>>(max_partitions * 2);
let notify = std::sync::Arc::new(tokio::sync::Notify::new());
let record_capacity = std::sync::Arc::new(tokio::sync::Semaphore::new(max_queued_records));
let byte_capacity = std::sync::Arc::new(tokio::sync::Semaphore::new(max_queued_bytes));
tokio::pin!(shutdown);
const MAX_CONSECUTIVE_POLL_ERRORS: u32 = 10;
let mut consecutive_poll_errors: u32 = 0;
let stopped = 'polling: loop {
while let Ok(completion) = commit_rx.try_recv() {
match completion {
Ok((partition, offset)) => {
if let Err(error) = consumer.store_offset(offset, Some(partition)).await {
break 'polling Err(LaserError::from(error));
}
}
Err(error) => break 'polling Err(LaserError::from(error)),
}
}
tokio::select! {
_ = &mut shutdown => break Ok(()),
_ = notify.notified() => {}
message = consumer.next() => match message {
Some(Ok(received)) => {
consecutive_poll_errors = 0;
let partition = received.partition_id;
let known = lanes.contains_key(&partition);
if !known && lanes.len() >= max_partitions {
let offset = received.message.header.offset;
if let Err(error) = worker.consume(received).await {
break 'polling Err(LaserError::from(error));
}
if let Err(error) = consumer.store_offset(offset, Some(partition)).await {
break 'polling Err(LaserError::from(error));
}
continue;
}
let lane = lanes.entry(partition).or_insert_with(|| {
let (tx, mut rx) = tokio::sync::mpsc::channel::<QueuedMessage>(max_queued_records);
let worker = worker.clone();
let commit_tx = commit_tx.clone();
let notify = notify.clone();
lane_handles.push(tokio::spawn(async move {
while let Some(queued) = rx.recv().await {
let received = queued.received;
let partition = received.partition_id;
let offset = received.message.header.offset;
let result = worker.consume(received).await;
drop(queued.record_permit);
drop(queued.byte_permit);
let failed = result.is_err();
let completion = result.map(|()| (partition, offset));
let _ = commit_tx.send(completion).await;
notify.notify_one();
if failed {
break;
}
}
}));
tx
});
let buffered_bytes = received.message.payload.len()
+ received.message.header.user_headers_length as usize;
let record_permit = record_capacity
.clone()
.acquire_owned()
.await
.map_err(|_| LaserError::HandlerConfig("record queue closed".to_owned()))?;
let byte_permits = buffered_bytes.clamp(1, max_queued_bytes) as u32;
let byte_permit = byte_capacity
.clone()
.acquire_many_owned(byte_permits)
.await
.map_err(|_| LaserError::HandlerConfig("byte queue closed".to_owned()))?;
let queued = QueuedMessage {
received,
record_permit,
byte_permit,
};
if lane.send(queued).await.is_err() {
break 'polling Err(LaserError::HandlerConfig(
"partition lane stopped before accepting a message".to_owned(),
));
}
}
Some(Err(error)) => match error {
IggyError::Disconnected
| IggyError::CannotEstablishConnection
| IggyError::StaleClient
| IggyError::InvalidServerAddress
| IggyError::InvalidClientAddress
| IggyError::NotConnected
| IggyError::ClientShutdown => break Err(LaserError::from(error)),
other => {
consecutive_poll_errors += 1;
if consecutive_poll_errors >= MAX_CONSECUTIVE_POLL_ERRORS {
break 'polling Err(LaserError::from(other));
}
warn!(
error = %other,
attempt = consecutive_poll_errors,
"agent consumer poll failed, backing off"
);
sleep(backoff_for(consecutive_poll_errors)).await;
}
},
None => break Ok(()),
},
}
};
drop(lanes);
drop(commit_tx);
let drain = async {
for handle in lane_handles {
let _ = handle.await;
}
while let Some(completion) = commit_rx.recv().await {
match completion {
Ok((partition, offset)) => {
if let Err(error) = consumer.store_offset(offset, Some(partition)).await {
return Err(LaserError::from(error));
}
}
Err(error) => return Err(LaserError::from(error)),
}
}
Ok::<(), LaserError>(())
};
match tokio::time::timeout(shutdown_grace, drain).await {
Ok(Ok(())) => {
if let Err(error) = consumer.shutdown().await {
warn!(%error, "failed to leave the consumer group on shutdown");
}
stopped
}
Ok(Err(error)) => Err(error),
Err(_) => Err(LaserError::Timeout("agent shutdown drain")),
}
}
struct QueuedMessage {
received: ReceivedMessage,
record_permit: tokio::sync::OwnedSemaphorePermit,
byte_permit: tokio::sync::OwnedSemaphorePermit,
}
fn dedup_key(provenance: &Provenance) -> Option<String> {
let key = provenance.idempotency_key.as_ref()?;
Some(match &provenance.agent {
Some(agent) => format!("{}{DEDUP_SCOPE_SEP}{key}", agent.as_str()),
None => key.clone(),
})
}
#[derive(Clone, Copy)]
struct FenceEntry {
fence: u64,
touched_micros: u64,
}
fn accept_fence(
high_water: &dashmap::DashMap<ConversationId, FenceEntry>,
last_sweep_micros: &std::sync::atomic::AtomicU64,
task: ConversationId,
fence: u64,
now_micros: u64,
) -> bool {
if high_water.len() > FENCE_MAP_SOFT_CAP {
use std::sync::atomic::Ordering;
let previous = last_sweep_micros.load(Ordering::Relaxed);
if now_micros.saturating_sub(previous) > FENCE_SWEEP_INTERVAL_MICROS
&& last_sweep_micros
.compare_exchange(previous, now_micros, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
high_water.retain(|_, entry| {
now_micros.saturating_sub(entry.touched_micros) < FENCE_ENTRY_TTL_MICROS
});
}
}
let mut entry = high_water.entry(task).or_insert(FenceEntry {
fence: 0,
touched_micros: now_micros,
});
if fence < entry.fence {
return false;
}
entry.fence = fence;
entry.touched_micros = now_micros;
true
}
struct ReliableWorker<H> {
handler: H,
laser: Laser,
retry: RetryPolicy,
understood_features: u64,
dedup: Box<dyn Deduplicator>,
agent: Option<AgentId>,
respond_on: Option<AgentTopic<'static>>,
inbox_route: crate::agent::router::InboxRoute,
ack_on_pickup: bool,
stream_id: u32,
topic_id: u32,
middleware: Vec<std::sync::Arc<dyn AgentMiddleware>>,
on_dead_letter: Option<std::sync::Arc<dyn DeadLetterSink>>,
#[cfg(feature = "sign")]
verifier: Option<std::sync::Arc<crate::sign::KeyRegistry>>,
#[cfg(feature = "sign")]
signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
high_water_fence: dashmap::DashMap<ConversationId, FenceEntry>,
fence_last_sweep: std::sync::atomic::AtomicU64,
#[cfg(feature = "sign")]
verified_records: Mutex<DedupWindow>,
}
impl<H> ReliableWorker<H> {
fn log_position(&self, id: MessageId) -> LogPosition {
LogPosition::new(self.stream_id, self.topic_id, id.partition_id, id.offset)
}
async fn dead_letter(
&self,
message: &AgentMessage,
reason: DeadLetterReason,
attempts: u32,
detail: &str,
) -> Result<(), LaserError> {
let capsule = AgentDeadLetter {
source: self.log_position(message.id),
reason,
attempts,
detail: Some(detail.to_owned()),
payload: message.payload.clone(),
};
let mut provenance = message.provenance.clone();
provenance.causal_parent = Some(message.id);
provenance.deadline = None;
self.publish_dead_letter(provenance, message.id, capsule, Some(message))
.await
}
async fn dead_letter_undecodable(
&self,
source: MessageId,
payload: Vec<u8>,
) -> Result<(), LaserError> {
let capsule = AgentDeadLetter {
source: self.log_position(source),
reason: DeadLetterReason::DecodeFailed,
attempts: 0,
detail: None,
payload,
};
let provenance = Provenance::builder()
.conversation_id(ConversationId::new())
.causal_parent(source)
.build();
self.publish_dead_letter(provenance, source, capsule, None)
.await
}
async fn publish_dead_letter(
&self,
provenance: Provenance,
source: MessageId,
capsule: AgentDeadLetter,
message: Option<&AgentMessage>,
) -> Result<(), LaserError> {
let reason = capsule.reason;
let result = self.send_dead_letter(&provenance, &capsule).await;
if let Err(error) = &result {
error!(%error, source = %source, ?reason, "failed to publish the dead-letter capsule, leaving the source offset uncommitted");
}
if let Some(sink) = &self.on_dead_letter {
sink.on_dead_letter(message, &capsule, &result).await;
}
result
}
async fn send_dead_letter(
&self,
provenance: &Provenance,
capsule: &AgentDeadLetter,
) -> Result<(), LaserError> {
let payload = encode_named(capsule)
.map_err(|error| LaserError::Codec(format!("dead-letter capsule: {error}")))?;
let mut headers = BTreeMap::<HeaderKey, HeaderValue>::try_from(provenance)
.map_err(|error| LaserError::Codec(format!("dead-letter headers: {error}")))?;
let key = HeaderKey::from_str(CONTENT_TYPE)?;
headers.insert(key, HeaderValue::from(ContentType::Cbor.code()));
let topic = AgentTopic::Dlq.topic_string();
let partition_key = provenance.partition_key();
self.laser
.send_with_headers(&topic, payload, headers, Some(&partition_key))
.await
.map(|_| ())
}
}
impl<H> MessageConsumer for ReliableWorker<H>
where
H: AgentHandler + Sync,
{
#[tracing::instrument(target = "laser", level = "debug", skip_all, fields(conversation = tracing::field::Empty, operation = "handle"))]
async fn consume(&self, received: ReceivedMessage) -> Result<(), IggyError> {
let source = MessageId::new(received.partition_id, received.message.header.offset);
let decoded = match AgentMessage::from_received(received, self.understood_features) {
Ok(decoded) => decoded,
Err((error, payload)) => {
warn!(%error, source = %source, "undecodable provenance, dead-lettering raw payload");
self.dead_letter_undecodable(source, payload)
.await
.map_err(|_| IggyError::Error)?;
return Ok(());
}
};
#[cfg(feature = "sign")]
let DecodedAgentMessage {
mut message,
signature_context,
observed_at_micros,
} = decoded;
#[cfg(not(feature = "sign"))]
let DecodedAgentMessage { message, .. } = decoded;
tracing::Span::current().record(
"conversation",
tracing::field::display(&message.provenance.conversation_id),
);
if let (Some(target), Some(agent)) = (&message.provenance.target_agent_id, &self.agent)
&& target != agent
{
debug!(target = %target, agent = %agent, source = %message.id, "skipping message targeted at another agent");
return Ok(());
}
#[cfg(feature = "sign")]
if let Some(registry) = &self.verifier {
let verified = message.envelope.as_ref().and_then(|envelope| {
signature_context.as_ref().and_then(|context| {
registry
.verify_observed_at(envelope, context, observed_at_micros)
.ok()
})
});
let Some(verified) = verified else {
warn!(source = %message.id, "unsigned or unverified message on a verified topic, dead-lettering");
self.dead_letter(
&message,
DeadLetterReason::Rejected,
0,
"signature verification failed",
)
.await
.map_err(|_| IggyError::Error)?;
return Ok(());
};
if let Some(record) = message
.envelope
.as_ref()
.and_then(|envelope| envelope.record)
{
let first_delivery = self
.verified_records
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(&record.to_string());
if !first_delivery {
warn!(source = %message.id, %record, "replayed signed record, dropping");
return Ok(());
}
}
message.verified_principal = Some(verified.principal);
}
if let Some(fence) = message.provenance.fence_token
&& !accept_fence(
&self.high_water_fence,
&self.fence_last_sweep,
message.provenance.conversation_id,
fence,
SystemClock.now_micros(),
)
{
debug!(source = %message.id, fence, "stale-holder fence, dropping replay");
return Ok(());
}
if let Some(key) = dedup_key(&message.provenance) {
if !self.dedup.observe(&key).await {
debug!(dedup_key = %key, source = %message.id, "skipping duplicate message");
return Ok(());
}
}
if let Some(deadline) = message.provenance.deadline
&& IggyTimestamp::now().as_micros() > deadline.as_micros()
{
warn!(source = %message.id, "message past its deadline, dead-lettering");
self.dead_letter(
&message,
DeadLetterReason::DeadlineExceeded,
0,
"message past its deadline",
)
.await
.map_err(|_| IggyError::Error)?;
return Ok(());
}
if self.ack_on_pickup
&& let (Some(agent), Some(respond_on), Some(envelope)) =
(&self.agent, &self.respond_on, &message.envelope)
&& envelope.kind == AgentKind::Command
&& let Some(correlation) = envelope.correlation
{
let producer =
self.laser
.agdx(respond_on.clone(), agent.wire_id(), envelope.conversation);
let ack = producer
.status(OPERATION_TASK)
.with_correlation(correlation)
.with_task_state(TaskState::Working);
#[cfg(feature = "sign")]
let ack = match &self.signing_key {
Some(signing_key) => ack.signed_by(signing_key),
None => ack,
};
if let Err(error) = ack.send().await {
warn!(source = %message.id, %error, "failed to emit ack-on-pickup status");
}
}
let ctx = AgentCtx::new(
&self.laser,
&message,
self.agent.clone(),
self.respond_on.clone(),
self.inbox_route.clone(),
#[cfg(feature = "sign")]
self.signing_key.clone(),
);
for middleware in &self.middleware {
if let Err(error) = middleware.before_handle(&message).await {
warn!(%error, source = %message.id, "middleware rejected message before handling, dead-lettering");
self.dead_letter(&message, DeadLetterReason::Rejected, 0, &error.to_string())
.await
.map_err(|_| IggyError::Error)?;
return Ok(());
}
}
let mut attempt = 0;
loop {
let result = self.handler.handle(&message, &ctx).await;
for middleware in &self.middleware {
middleware
.after_handle(&message, &result, attempt + 1)
.await;
}
match result {
Ok(()) => {
debug!(source = %message.id, "message handled");
return Ok(());
}
Err(error) => {
if !error.is_retryable() {
warn!(%error, source = %message.id, "handler rejected message, dead-lettering without retry");
self.dead_letter(
&message,
DeadLetterReason::Rejected,
attempt + 1,
&error.to_string(),
)
.await
.map_err(|_| IggyError::Error)?;
return Ok(());
}
if attempt + 1 >= self.retry.max_attempts {
error!(%error, source = %message.id, attempts = attempt + 1, "handler exhausted retries, dead-lettering");
self.dead_letter(
&message,
DeadLetterReason::RetryExhausted,
attempt + 1,
&error.to_string(),
)
.await
.map_err(|_| IggyError::Error)?;
return Ok(());
}
warn!(%error, source = %message.id, attempt = attempt + 1, "handler failed, retrying");
sleep(self.retry.delay_for(attempt)).await;
attempt += 1;
}
}
}
}
}
async fn warm_dedup_window(
laser: &Laser,
group: &str,
topic: &str,
dedup: &dyn Deduplicator,
depth: usize,
) -> Result<(), LaserError> {
let stream = Identifier::named(laser.stream_required()?)?;
let topic_id = Identifier::named(topic)?;
let Some(details) = laser.client().get_topic(&stream, &topic_id).await? else {
return Ok(());
};
let group_consumer = Consumer::group(Identifier::named(group)?);
let reader = Consumer::new(Identifier::named("laser-dedup-warmer")?);
let depth = u64::try_from(depth).unwrap_or(u64::MAX);
for partition in 0..crate::poll::bounded_partitions(details.partitions_count) {
let Some(offset) = laser
.client()
.get_consumer_offset(&group_consumer, &stream, &topic_id, Some(partition))
.await?
else {
continue;
};
let stored = offset.stored_offset;
let start = stored.saturating_sub(depth.saturating_sub(1));
let count =
u32::try_from(stored.saturating_sub(start).saturating_add(1)).unwrap_or(u32::MAX);
let polled = laser
.client()
.poll_messages(
&stream,
&topic_id,
Some(partition),
&reader,
&PollingStrategy::offset(start),
count,
false,
)
.await?;
for message in polled.messages {
if message.header.offset > stored {
continue;
}
if let Ok(provenance) = Provenance::try_from(&message)
&& let Some(key) = dedup_key(&provenance)
{
dedup.observe(&key).await;
}
}
}
Ok(())
}
#[async_trait]
pub trait Deduplicator: Send + Sync {
async fn observe(&self, key: &str) -> bool;
}
pub struct SlidingWindow {
inner: Mutex<DedupWindow>,
}
impl SlidingWindow {
pub fn new(capacity: usize) -> Self {
Self {
inner: Mutex::new(DedupWindow::new(capacity)),
}
}
}
#[async_trait]
impl Deduplicator for SlidingWindow {
async fn observe(&self, key: &str) -> bool {
self.inner
.lock()
.expect("the dedup mutex is not poisoned")
.insert(key)
}
}
struct DedupWindow {
capacity: usize,
seen: HashSet<String>,
order: VecDeque<String>,
}
impl DedupWindow {
fn new(capacity: usize) -> Self {
Self {
capacity: capacity.max(1),
seen: HashSet::new(),
order: VecDeque::new(),
}
}
fn insert(&mut self, key: &str) -> bool {
if self.seen.contains(key) {
return false;
}
if self.order.len() >= self.capacity
&& let Some(evicted) = self.order.pop_front()
{
self.seen.remove(&evicted);
}
self.seen.insert(key.to_owned());
self.order.push_back(key.to_owned());
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_a_small_positive_fence_when_decoded_then_should_preserve_the_token() {
let envelope = AgentEnvelope::command(
laser_wire::agent::RecordId::from_u128(3),
laser_wire::agent::ConversationId::from_u128(1),
"orchestrator".parse().expect("agent id parses"),
laser_wire::agent::CorrelationId::from_u128(2),
Vec::new(),
)
.with_metadata(FENCE, laser_wire::query::Value::Int(7));
assert_eq!(provenance_from_envelope(&envelope).fence_token, Some(7));
}
#[test]
fn given_a_seen_key_when_inserting_again_then_should_report_a_duplicate() {
let mut window = DedupWindow::new(8);
assert!(window.insert("a"));
assert!(!window.insert("a"));
assert!(window.insert("b"));
}
#[test]
fn given_a_full_window_when_inserting_then_should_evict_the_oldest_key() {
let mut window = DedupWindow::new(2);
assert!(window.insert("a"));
assert!(window.insert("b"));
assert!(window.insert("c"));
assert!(window.insert("a"));
}
#[test]
fn given_increasing_attempts_when_computing_backoff_then_should_grow_and_stay_bounded() {
let policy = RetryPolicy::backoff(5, Duration::from_millis(100));
assert_eq!(policy.delay_for(0), Duration::from_millis(100));
assert_eq!(policy.delay_for(1), Duration::from_millis(200));
assert_eq!(policy.delay_for(2), Duration::from_millis(400));
assert!(policy.delay_for(60) >= policy.delay_for(2));
}
#[test]
fn given_two_agents_with_the_same_idempotency_key_when_scoped_then_should_differ() {
let conversation = ConversationId::new();
let with_agent = |agent: &str| {
Provenance::builder()
.conversation_id(conversation)
.agent(agent.parse().expect("valid agent id"))
.idempotency_key("attempt-1".to_owned())
.build()
};
assert_ne!(
dedup_key(&with_agent("planner")),
dedup_key(&with_agent("worker"))
);
let anon = Provenance::builder()
.conversation_id(conversation)
.idempotency_key("attempt-1".to_owned())
.build();
assert_eq!(dedup_key(&anon).as_deref(), Some("attempt-1"));
}
#[test]
fn given_a_fence_gate_when_a_stale_token_arrives_then_should_drop_it_and_keep_per_task_scope() {
let high_water = dashmap::DashMap::new();
let sweep = std::sync::atomic::AtomicU64::new(0);
let task_a = ConversationId::new();
let task_b = ConversationId::new();
assert!(accept_fence(&high_water, &sweep, task_a, 1, 100));
assert!(accept_fence(&high_water, &sweep, task_a, 2, 101));
assert!(!accept_fence(&high_water, &sweep, task_a, 1, 102));
assert!(accept_fence(&high_water, &sweep, task_a, 2, 103));
assert!(accept_fence(&high_water, &sweep, task_b, 1, 104));
}
}