#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use af_context::{NotificationAttemptId, NotificationId, RequestContext, SubjectId, TenantId};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
#[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(value: impl Into<String>) -> Self {
Self::Heading(value.into())
}
pub fn text(value: impl Into<String>) -> Self {
Self::Text(value.into())
}
pub fn fields(value: Vec<(String, String)>) -> Self {
Self::Fields(value)
}
pub fn action(label: impl Into<String>, url: impl Into<String>) -> Self {
Self::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 from_template(
catalog: &af_i18n::I18n,
locale: &str,
title_key: &str,
body_key: &str,
args: &[(&str, &str)],
) -> Self {
Self::new()
.title(catalog.t(locale, title_key, args))
.block(Block::text(catalog.t(locale, body_key, args)))
}
pub fn title(mut self, value: impl Into<String>) -> Self {
self.title = Some(value.into());
self
}
pub fn block(mut self, value: Block) -> Self {
self.blocks.push(value);
self
}
pub fn to_plain_text(&self) -> String {
let mut out = String::new();
if let Some(title) = &self.title {
out.push_str(title);
out.push_str("\n\n");
}
for block in &self.blocks {
match block {
Block::Heading(value) | Block::Text(value) => {
out.push_str(value);
out.push('\n');
}
Block::Fields(fields) => {
for (key, value) in fields {
out.push_str(&format!("{key}: {value}\n"));
}
}
Block::Divider => out.push_str("---\n"),
Block::Action { label, url } => out.push_str(&format!("{label}: {url}\n")),
}
}
out.trim_end().to_owned()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationStatus {
Pending,
Sending,
RetryScheduled,
Sent,
DeadLetter,
}
impl NotificationStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Sending => "sending",
Self::RetryScheduled => "retry_scheduled",
Self::Sent => "sent",
Self::DeadLetter => "dead_letter",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeliveryClass {
Retryable,
Permanent,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NotifyError {
#[error("no sender registered for channel '{0}'")]
UnknownChannel(String),
#[error("transport temporarily unavailable")]
Retryable,
#[error("transport rejected the notification")]
Permanent,
#[error("transport outcome is unknown")]
Unknown,
#[error("notification delivery cancelled")]
Cancelled,
#[error("notification delivery deadline exceeded")]
DeadlineExceeded,
}
impl NotifyError {
pub fn class(&self) -> DeliveryClass {
match self {
Self::Retryable | Self::DeadlineExceeded => DeliveryClass::Retryable,
Self::Permanent | Self::UnknownChannel(_) => DeliveryClass::Permanent,
Self::Unknown | Self::Cancelled => DeliveryClass::Unknown,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NotifyStoreError {
#[error("invalid notification request: {0}")]
Invalid(String),
#[error("notification not found")]
NotFound,
#[error("idempotency key was reused with different notification content")]
IdempotencyConflict,
#[error("notification lease was lost")]
LeaseLost,
#[error("dead-letter retry is not authorized")]
Unauthorized,
#[error("notification store unavailable: {0}")]
Unavailable(String),
}
#[derive(Clone)]
pub struct DeliveryContext {
pub cancellation: CancellationToken,
pub deadline: Instant,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NewOutboxItem {
pub tenant_id: TenantId,
pub subject_id: SubjectId,
pub idempotency_key: String,
pub channel: String,
pub recipient: String,
pub notification: Notification,
pub max_attempts: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboxItem {
pub id: NotificationId,
pub tenant_id: TenantId,
pub subject_id: SubjectId,
pub channel: String,
pub recipient: String,
pub notification: Notification,
pub status: NotificationStatus,
pub attempts: u32,
pub max_attempts: u32,
pub lease_version: i64,
pub last_error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AttemptEvent {
pub id: NotificationAttemptId,
pub notification_id: NotificationId,
pub attempt: u32,
pub lease_version: i64,
pub kind: String,
pub class: Option<DeliveryClass>,
pub created_at: DateTime<Utc>,
}
#[async_trait]
pub trait DurableOutbox: Send + Sync {
fn subscribe(&self) -> tokio::sync::broadcast::Receiver<NotificationId>;
async fn enqueue(&self, item: NewOutboxItem) -> Result<OutboxItem, NotifyStoreError>;
async fn get(
&self,
context: &RequestContext,
id: &NotificationId,
) -> Result<OutboxItem, NotifyStoreError>;
async fn list(
&self,
context: &RequestContext,
status: Option<NotificationStatus>,
limit: usize,
after: Option<&NotificationId>,
) -> Result<Vec<OutboxItem>, NotifyStoreError>;
async fn attempts(
&self,
context: &RequestContext,
id: &NotificationId,
) -> Result<Vec<AttemptEvent>, NotifyStoreError>;
async fn claim(
&self,
worker_id: &str,
lease_secs: i64,
batch: usize,
) -> Result<Vec<OutboxItem>, NotifyStoreError>;
async fn mark_sent(
&self,
id: &NotificationId,
lease_version: i64,
) -> Result<(), NotifyStoreError>;
async fn record_failure(
&self,
id: &NotificationId,
lease_version: i64,
class: DeliveryClass,
delay: Duration,
) -> Result<NotificationStatus, NotifyStoreError>;
async fn retry_dead_letter(
&self,
context: &RequestContext,
id: &NotificationId,
) -> Result<OutboxItem, NotifyStoreError>;
}
#[async_trait]
pub trait Sender: Send + Sync {
fn name(&self) -> &str;
async fn send(
&self,
context: &DeliveryContext,
recipient: &str,
notification: &Notification,
) -> Result<(), NotifyError>;
}
#[derive(Debug, Clone, Copy)]
pub struct RetryPolicy {
pub base: Duration,
pub cap: Duration,
}
#[derive(Debug, Clone)]
pub struct WorkerConfig {
pub worker_id: String,
pub lease_secs: i64,
pub batch: usize,
pub request_timeout: Duration,
pub retry: RetryPolicy,
}
impl RetryPolicy {
pub fn delay(self, id: &NotificationId, attempt: u32) -> Duration {
let exponent = attempt.saturating_sub(1).min(20);
let raw = self
.base
.as_millis()
.saturating_mul(1u128 << exponent)
.min(self.cap.as_millis());
let hash = id.as_bytes().iter().fold(0u64, |value, byte| {
value.wrapping_mul(31).wrapping_add(u64::from(*byte))
});
let jitter = 90 + hash % 21;
Duration::from_millis(
u64::try_from(raw.saturating_mul(u128::from(jitter)) / 100).unwrap_or(u64::MAX),
)
}
}
#[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_owned(), sender);
self
}
pub fn channels(&self) -> impl Iterator<Item = &str> {
self.senders.keys().map(String::as_str)
}
pub async fn dispatch(
&self,
context: &DeliveryContext,
channel: &str,
recipient: &str,
notification: &Notification,
) -> Result<(), NotifyError> {
let sender = self
.senders
.get(channel)
.ok_or_else(|| NotifyError::UnknownChannel(channel.to_owned()))?;
sender.send(context, recipient, notification).await
}
pub async fn drain(
&self,
outbox: &dyn DurableOutbox,
config: &WorkerConfig,
cancellation: &CancellationToken,
) -> Result<usize, NotifyStoreError> {
let items = outbox
.claim(
&config.worker_id,
config.lease_secs.max(
i64::try_from(
config
.request_timeout
.as_secs()
.saturating_mul(config.batch.clamp(1, 100) as u64)
.saturating_add(1),
)
.unwrap_or(i64::MAX),
),
config.batch.clamp(1, 100),
)
.await?;
for item in &items {
if cancellation.is_cancelled() {
break;
}
let context = DeliveryContext {
cancellation: cancellation.child_token(),
deadline: Instant::now() + config.request_timeout,
};
match self
.dispatch(&context, &item.channel, &item.recipient, &item.notification)
.await
{
Ok(()) => outbox.mark_sent(&item.id, item.lease_version).await?,
Err(error) => {
outbox
.record_failure(
&item.id,
item.lease_version,
error.class(),
config.retry.delay(&item.id, item.attempts),
)
.await?;
}
}
}
Ok(items.len())
}
}
pub struct LogSender;
#[async_trait]
impl Sender for LogSender {
fn name(&self) -> &str {
"log"
}
async fn send(
&self,
_context: &DeliveryContext,
recipient: &str,
notification: &Notification,
) -> Result<(), NotifyError> {
tracing::info!(target: "notify", recipient, body = %notification.to_plain_text(), "notification");
Ok(())
}
}
pub mod testing;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rendering_and_backoff_are_stable() {
let notification = Notification::new()
.title("Completed")
.block(Block::fields(vec![("duration".into(), "1s".into())]));
assert_eq!(notification.to_plain_text(), "Completed\n\nduration: 1s");
let id = NotificationId::parse("n-1").unwrap();
let policy = RetryPolicy {
base: Duration::from_secs(1),
cap: Duration::from_secs(60),
};
assert_eq!(policy.delay(&id, 2), policy.delay(&id, 2));
assert!(policy.delay(&id, 3) > policy.delay(&id, 2));
let mut catalog = af_i18n::I18n::new("en").unwrap();
catalog
.load_locale(
"en",
serde_json::json!({"title":"Done", "body":"Hello {name}"}),
)
.unwrap();
assert_eq!(
Notification::from_template(&catalog, "en", "title", "body", &[("name", "Ada")])
.to_plain_text(),
"Done\n\nHello Ada"
);
}
}