#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Block {
Heading(String),
Text(String),
Fields(Vec<(String, String)>),
Divider,
Action {
label: String,
url: String,
},
}
impl Block {
pub fn heading(s: impl Into<String>) -> Self {
Block::Heading(s.into())
}
pub fn text(s: impl Into<String>) -> Self {
Block::Text(s.into())
}
pub fn fields(kv: Vec<(String, String)>) -> Self {
Block::Fields(kv)
}
pub fn action(label: impl Into<String>, url: impl Into<String>) -> Self {
Block::Action {
label: label.into(),
url: url.into(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Notification {
pub title: Option<String>,
pub blocks: Vec<Block>,
}
impl Notification {
pub fn new() -> Self {
Self::default()
}
pub fn title(mut self, t: impl Into<String>) -> Self {
self.title = Some(t.into());
self
}
pub fn block(mut self, b: Block) -> Self {
self.blocks.push(b);
self
}
pub fn to_plain_text(&self) -> String {
let mut out = String::new();
if let Some(t) = &self.title {
out.push_str(t);
out.push_str("\n\n");
}
for block in &self.blocks {
match block {
Block::Heading(h) => {
out.push_str(h);
out.push('\n');
}
Block::Text(t) => {
out.push_str(t);
out.push('\n');
}
Block::Fields(kv) => {
for (k, v) in kv {
out.push_str(&format!("{k}: {v}\n"));
}
}
Block::Divider => out.push_str("---\n"),
Block::Action { label, url } => out.push_str(&format!("{label}: {url}\n")),
}
}
out.trim_end().to_string()
}
}
#[derive(Debug, thiserror::Error)]
pub enum NotifyError {
#[error("no sender registered for channel '{0}'")]
UnknownChannel(String),
#[error("transport '{transport}' failed: {reason}")]
Transport {
transport: String,
reason: String,
},
}
#[async_trait]
pub trait Sender: Send + Sync {
fn name(&self) -> &str;
async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError>;
}
#[derive(Debug, Clone, PartialEq)]
pub struct OutboxItem {
pub id: String,
pub tenant_id: String,
pub subject_id: String,
pub channel: String,
pub recipient: String,
pub notification: Notification,
pub attempts: u32,
pub lease_version: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NewOutboxItem {
pub tenant_id: String,
pub subject_id: String,
pub idempotency_key: String,
pub channel: String,
pub recipient: String,
pub notification: Notification,
}
#[async_trait]
pub trait DurableOutbox: Send + Sync {
async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String>;
async fn claim(
&self,
worker_id: &str,
lease_secs: i64,
batch: usize,
) -> Result<Vec<OutboxItem>, String>;
async fn mark_sent(&self, id: &str, lease_version: i64) -> Result<(), String>;
async fn retry(
&self,
id: &str,
lease_version: i64,
error: &str,
delay_secs: i64,
) -> Result<(), String>;
}
#[derive(Default, Clone)]
pub struct Dispatcher {
senders: HashMap<String, Arc<dyn Sender>>,
}
impl Dispatcher {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, sender: Arc<dyn Sender>) -> &mut Self {
self.senders.insert(sender.name().to_string(), sender);
self
}
pub fn channels(&self) -> impl Iterator<Item = &str> {
self.senders.keys().map(|s| s.as_str())
}
pub async fn dispatch(
&self,
channel: &str,
recipient: &str,
notif: &Notification,
) -> Result<(), NotifyError> {
let sender = self
.senders
.get(channel)
.ok_or_else(|| NotifyError::UnknownChannel(channel.to_string()))?;
sender.send(recipient, notif).await
}
pub async fn drain(
&self,
outbox: &dyn DurableOutbox,
worker_id: &str,
lease_secs: i64,
batch: usize,
retry_delay_secs: i64,
) -> Result<usize, String> {
let items = outbox
.claim(worker_id, lease_secs, batch.clamp(1, 100))
.await?;
for item in &items {
match self
.dispatch(&item.channel, &item.recipient, &item.notification)
.await
{
Ok(()) => outbox.mark_sent(&item.id, item.lease_version).await?,
Err(error) => {
outbox
.retry(
&item.id,
item.lease_version,
&error.to_string(),
retry_delay_secs,
)
.await?
}
}
}
Ok(items.len())
}
}
pub struct LogSender;
#[async_trait]
impl Sender for LogSender {
fn name(&self) -> &str {
"log"
}
async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
tracing::info!(target: "notify", recipient, body = %notif.to_plain_text(), "notification");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct MemoryOutbox {
items: Mutex<Vec<OutboxItem>>,
sent: Mutex<Vec<String>>,
}
#[async_trait]
impl DurableOutbox for MemoryOutbox {
async fn enqueue(&self, item: NewOutboxItem) -> Result<String, String> {
let id = item.idempotency_key.clone();
self.items.lock().unwrap().push(OutboxItem {
id: id.clone(),
tenant_id: item.tenant_id,
subject_id: item.subject_id,
channel: item.channel,
recipient: item.recipient,
notification: item.notification,
attempts: 0,
lease_version: 1,
});
Ok(id)
}
async fn claim(&self, _: &str, _: i64, _: usize) -> Result<Vec<OutboxItem>, String> {
Ok(self.items.lock().unwrap().clone())
}
async fn mark_sent(&self, id: &str, _: i64) -> Result<(), String> {
self.sent.lock().unwrap().push(id.into());
Ok(())
}
async fn retry(&self, _: &str, _: i64, _: &str, _: i64) -> Result<(), String> {
Ok(())
}
}
#[test]
fn renders_plain_text() {
let n = Notification::new()
.title("Run finished")
.block(Block::heading("Summary"))
.block(Block::text("All good."))
.block(Block::fields(vec![("duration".into(), "1.2s".into())]))
.block(Block::action("View", "https://x/y"));
let txt = n.to_plain_text();
assert!(txt.starts_with("Run finished"));
assert!(txt.contains("duration: 1.2s"));
assert!(txt.contains("View: https://x/y"));
}
struct CapturingSender(Mutex<Vec<String>>);
#[async_trait]
impl Sender for CapturingSender {
fn name(&self) -> &str {
"capture"
}
async fn send(&self, recipient: &str, notif: &Notification) -> Result<(), NotifyError> {
self.0
.lock()
.unwrap()
.push(format!("{recipient}|{}", notif.to_plain_text()));
Ok(())
}
}
#[tokio::test]
async fn dispatch_routes_to_named_sender() {
let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
let mut d = Dispatcher::new();
d.register(sender.clone());
let n = Notification::new().block(Block::text("hi"));
d.dispatch("capture", "user1", &n).await.unwrap();
assert_eq!(sender.0.lock().unwrap().len(), 1);
assert!(sender.0.lock().unwrap()[0].starts_with("user1|hi"));
}
#[tokio::test]
async fn unknown_channel_errors() {
let d = Dispatcher::new();
let n = Notification::new();
assert!(matches!(
d.dispatch("nope", "x", &n).await,
Err(NotifyError::UnknownChannel(_))
));
}
#[tokio::test]
async fn durable_dispatch_marks_claimed_messages_sent() {
let sender = Arc::new(CapturingSender(Mutex::new(Vec::new())));
let mut dispatcher = Dispatcher::new();
dispatcher.register(sender);
let outbox = MemoryOutbox::default();
outbox
.enqueue(NewOutboxItem {
tenant_id: "tenant".into(),
subject_id: "subject".into(),
idempotency_key: "one".into(),
channel: "capture".into(),
recipient: "recipient".into(),
notification: Notification::new().block(Block::text("hello")),
})
.await
.unwrap();
assert_eq!(
dispatcher
.drain(&outbox, "worker", 30, 10, 5)
.await
.unwrap(),
1
);
assert_eq!(&*outbox.sent.lock().unwrap(), &["one"]);
}
}