use std::collections::VecDeque;
use std::sync::{Arc, RwLock};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::ipc::KernelMessage;
use crate::process::Pid;
pub const DEFAULT_DLQ_CAPACITY: usize = 10_000;
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DeadLetterReason {
TargetNotFound {
pid: Pid,
},
InboxFull {
pid: Pid,
},
Timeout {
duration_ms: u64,
},
GovernanceDenied {
reason: String,
},
AgentExited {
pid: Pid,
},
}
impl std::fmt::Display for DeadLetterReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TargetNotFound { pid } => write!(f, "target_not_found(pid={pid})"),
Self::InboxFull { pid } => write!(f, "inbox_full(pid={pid})"),
Self::Timeout { duration_ms } => write!(f, "timeout({duration_ms}ms)"),
Self::GovernanceDenied { reason } => write!(f, "governance_denied({reason})"),
Self::AgentExited { pid } => write!(f, "agent_exited(pid={pid})"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeadLetter {
pub message: KernelMessage,
pub reason: DeadLetterReason,
pub timestamp: DateTime<Utc>,
pub retry_count: u32,
}
pub struct DeadLetterQueue {
letters: RwLock<VecDeque<DeadLetter>>,
max_size: usize,
#[cfg(feature = "exochain")]
chain: Option<Arc<crate::chain::ChainManager>>,
}
impl DeadLetterQueue {
pub fn new(max_size: usize) -> Self {
Self {
letters: RwLock::new(VecDeque::with_capacity(max_size.min(1024))),
max_size,
#[cfg(feature = "exochain")]
chain: None,
}
}
pub fn with_default_capacity() -> Self {
Self::new(DEFAULT_DLQ_CAPACITY)
}
#[cfg(feature = "exochain")]
pub fn with_chain(mut self, cm: Arc<crate::chain::ChainManager>) -> Self {
self.chain = Some(cm);
self
}
pub fn intake(&self, message: KernelMessage, reason: DeadLetterReason) {
#[cfg(feature = "exochain")]
if let Some(ref cm) = self.chain {
use crate::chain::ChainLoggable;
let event = crate::chain::IpcDeadLetterEvent {
message_id: message.id.clone(),
from_pid: message.from,
target: format!("{:?}", message.target),
payload_type: message.payload.type_name().to_owned(),
reason: format!("{reason}"),
timestamp: Utc::now(),
};
cm.append_loggable(&event);
}
let letter = DeadLetter {
message,
reason,
timestamp: Utc::now(),
retry_count: 0,
};
let mut letters = self.letters.write().expect("DLQ write lock poisoned");
while letters.len() >= self.max_size {
letters.pop_front();
}
letters.push_back(letter);
}
pub fn len(&self) -> usize {
self.letters.read().expect("DLQ read lock poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn capacity(&self) -> usize {
self.max_size
}
pub fn query_by_target(&self, pid: Pid) -> Vec<DeadLetter> {
let letters = self.letters.read().expect("DLQ read lock poisoned");
letters
.iter()
.filter(|l| match &l.reason {
DeadLetterReason::TargetNotFound { pid: p } => *p == pid,
DeadLetterReason::InboxFull { pid: p } => *p == pid,
DeadLetterReason::AgentExited { pid: p } => *p == pid,
_ => {
matches!(&l.message.target, crate::ipc::MessageTarget::Process(p) if *p == pid)
}
})
.cloned()
.collect()
}
pub fn query_by_reason(&self, reason_name: &str) -> Vec<DeadLetter> {
let letters = self.letters.read().expect("DLQ read lock poisoned");
letters
.iter()
.filter(|l| {
let variant = match &l.reason {
DeadLetterReason::TargetNotFound { .. } => "TargetNotFound",
DeadLetterReason::InboxFull { .. } => "InboxFull",
DeadLetterReason::Timeout { .. } => "Timeout",
DeadLetterReason::GovernanceDenied { .. } => "GovernanceDenied",
DeadLetterReason::AgentExited { .. } => "AgentExited",
};
variant == reason_name
})
.cloned()
.collect()
}
pub fn query_by_time_range(
&self,
since: DateTime<Utc>,
until: DateTime<Utc>,
) -> Vec<DeadLetter> {
let letters = self.letters.read().expect("DLQ read lock poisoned");
letters
.iter()
.filter(|l| l.timestamp >= since && l.timestamp <= until)
.cloned()
.collect()
}
pub fn take_for_retry(&self, msg_id: &str) -> Option<DeadLetter> {
let mut letters = self.letters.write().expect("DLQ write lock poisoned");
let idx = letters.iter().position(|l| l.message.id == msg_id)?;
let mut letter = letters.remove(idx)?;
letter.retry_count += 1;
Some(letter)
}
pub fn re_add(&self, letter: DeadLetter) {
let mut letters = self.letters.write().expect("DLQ write lock poisoned");
while letters.len() >= self.max_size {
letters.pop_front();
}
letters.push_back(letter);
}
pub fn snapshot(&self) -> Vec<DeadLetter> {
self.letters
.read()
.expect("DLQ read lock poisoned")
.iter()
.cloned()
.collect()
}
pub fn clear(&self) {
self.letters
.write()
.expect("DLQ write lock poisoned")
.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ipc::{MessagePayload, MessageTarget};
fn make_msg(from: Pid, target: Pid) -> KernelMessage {
KernelMessage::new(
from,
MessageTarget::Process(target),
MessagePayload::Text("test".into()),
)
}
#[test]
fn intake_and_len() {
let dlq = DeadLetterQueue::new(100);
assert!(dlq.is_empty());
dlq.intake(
make_msg(0, 99),
DeadLetterReason::TargetNotFound { pid: 99 },
);
assert_eq!(dlq.len(), 1);
}
#[test]
fn fifo_eviction_at_capacity() {
let dlq = DeadLetterQueue::new(3);
for i in 0..5u64 {
dlq.intake(make_msg(0, i), DeadLetterReason::TargetNotFound { pid: i });
}
assert_eq!(dlq.len(), 3);
let snap = dlq.snapshot();
let target_pids: Vec<Pid> = snap
.iter()
.filter_map(|l| match &l.reason {
DeadLetterReason::TargetNotFound { pid } => Some(*pid),
_ => None,
})
.collect();
assert_eq!(target_pids, vec![2, 3, 4]);
}
#[test]
fn query_by_target() {
let dlq = DeadLetterQueue::new(100);
dlq.intake(
make_msg(0, 10),
DeadLetterReason::TargetNotFound { pid: 10 },
);
dlq.intake(make_msg(0, 20), DeadLetterReason::InboxFull { pid: 20 });
dlq.intake(make_msg(0, 10), DeadLetterReason::AgentExited { pid: 10 });
let results = dlq.query_by_target(10);
assert_eq!(results.len(), 2);
}
#[test]
fn query_by_reason() {
let dlq = DeadLetterQueue::new(100);
dlq.intake(make_msg(0, 1), DeadLetterReason::TargetNotFound { pid: 1 });
dlq.intake(make_msg(0, 2), DeadLetterReason::InboxFull { pid: 2 });
dlq.intake(make_msg(0, 3), DeadLetterReason::TargetNotFound { pid: 3 });
let results = dlq.query_by_reason("TargetNotFound");
assert_eq!(results.len(), 2);
let results = dlq.query_by_reason("InboxFull");
assert_eq!(results.len(), 1);
}
#[test]
fn query_by_time_range() {
let dlq = DeadLetterQueue::new(100);
let before = Utc::now();
dlq.intake(make_msg(0, 1), DeadLetterReason::TargetNotFound { pid: 1 });
let after = Utc::now();
let results = dlq.query_by_time_range(before, after);
assert_eq!(results.len(), 1);
let future = after + chrono::Duration::hours(1);
let far_future = future + chrono::Duration::hours(1);
let results = dlq.query_by_time_range(future, far_future);
assert!(results.is_empty());
}
#[test]
fn take_for_retry_increments_count() {
let dlq = DeadLetterQueue::new(100);
let msg = make_msg(0, 1);
let msg_id = msg.id.clone();
dlq.intake(msg, DeadLetterReason::TargetNotFound { pid: 1 });
let letter = dlq.take_for_retry(&msg_id).unwrap();
assert_eq!(letter.retry_count, 1);
assert!(dlq.is_empty());
}
#[test]
fn take_for_retry_not_found() {
let dlq = DeadLetterQueue::new(100);
assert!(dlq.take_for_retry("nonexistent").is_none());
}
#[test]
fn re_add_after_failed_retry() {
let dlq = DeadLetterQueue::new(100);
let msg = make_msg(0, 1);
let msg_id = msg.id.clone();
dlq.intake(msg, DeadLetterReason::TargetNotFound { pid: 1 });
let letter = dlq.take_for_retry(&msg_id).unwrap();
assert_eq!(letter.retry_count, 1);
dlq.re_add(letter);
assert_eq!(dlq.len(), 1);
let snap = dlq.snapshot();
assert_eq!(snap[0].retry_count, 1);
}
#[test]
fn clear_removes_all() {
let dlq = DeadLetterQueue::new(100);
for i in 0..5 {
dlq.intake(make_msg(0, i), DeadLetterReason::TargetNotFound { pid: i });
}
assert_eq!(dlq.len(), 5);
dlq.clear();
assert!(dlq.is_empty());
}
#[test]
fn governance_denied_reason() {
let dlq = DeadLetterQueue::new(100);
dlq.intake(
make_msg(0, 1),
DeadLetterReason::GovernanceDenied {
reason: "policy violation".into(),
},
);
let results = dlq.query_by_reason("GovernanceDenied");
assert_eq!(results.len(), 1);
match &results[0].reason {
DeadLetterReason::GovernanceDenied { reason } => {
assert_eq!(reason, "policy violation");
}
_ => panic!("expected GovernanceDenied"),
}
}
#[test]
fn timeout_reason() {
let dlq = DeadLetterQueue::new(100);
dlq.intake(
make_msg(0, 1),
DeadLetterReason::Timeout { duration_ms: 5000 },
);
let results = dlq.query_by_reason("Timeout");
assert_eq!(results.len(), 1);
}
#[test]
fn reason_display() {
let reason = DeadLetterReason::TargetNotFound { pid: 42 };
assert_eq!(format!("{reason}"), "target_not_found(pid=42)");
let reason = DeadLetterReason::InboxFull { pid: 7 };
assert_eq!(format!("{reason}"), "inbox_full(pid=7)");
}
#[test]
fn dead_letter_serde_roundtrip() {
let letter = DeadLetter {
message: make_msg(0, 1),
reason: DeadLetterReason::TargetNotFound { pid: 1 },
timestamp: Utc::now(),
retry_count: 2,
};
let json = serde_json::to_string(&letter).unwrap();
let restored: DeadLetter = serde_json::from_str(&json).unwrap();
assert_eq!(restored.retry_count, 2);
assert_eq!(restored.message.id, letter.message.id);
}
#[cfg(feature = "exochain")]
#[test]
fn intake_logs_to_chain() {
let cm = std::sync::Arc::new(crate::chain::ChainManager::new(0, 100));
let initial_len = cm.len();
let dlq = DeadLetterQueue::new(100).with_chain(cm.clone());
dlq.intake(
make_msg(0, 99),
DeadLetterReason::TargetNotFound { pid: 99 },
);
assert_eq!(dlq.len(), 1);
assert_eq!(cm.len(), initial_len + 1);
let events = cm.tail(1);
assert_eq!(events[0].source, "ipc");
assert_eq!(events[0].kind, "ipc.dead_letter");
let payload = events[0].payload.as_ref().unwrap();
assert_eq!(payload["from_pid"], 0);
assert!(
payload["reason"]
.as_str()
.unwrap()
.contains("target_not_found")
);
}
#[cfg(feature = "exochain")]
#[test]
fn intake_without_chain_does_not_log() {
let dlq = DeadLetterQueue::new(100);
dlq.intake(
make_msg(0, 1),
DeadLetterReason::Timeout { duration_ms: 1000 },
);
assert_eq!(dlq.len(), 1);
}
}