1use std::collections::BTreeMap;
4use std::sync::Mutex;
5use std::time::Duration;
6
7use af_context::{NotificationId, RequestContext};
8use async_trait::async_trait;
9use chrono::Utc;
10
11use crate::{
12 AttemptEvent, DeliveryClass, DeliveryContext, DurableOutbox, NewOutboxItem, Notification,
13 NotificationStatus, NotifyError, NotifyStoreError, OutboxItem, Sender,
14};
15
16pub struct MemoryOutbox {
18 notifications: bool,
19 items: Mutex<BTreeMap<NotificationId, OutboxItem>>,
20 idempotency: Mutex<BTreeMap<(String, String, String), NotificationId>>,
21 changes: tokio::sync::broadcast::Sender<NotificationId>,
22}
23
24impl Default for MemoryOutbox {
25 fn default() -> Self {
26 let (changes, _) = tokio::sync::broadcast::channel(32);
27 Self {
28 notifications: true,
29 items: Mutex::new(BTreeMap::new()),
30 idempotency: Mutex::new(BTreeMap::new()),
31 changes,
32 }
33 }
34}
35
36impl MemoryOutbox {
37 pub fn without_notifications() -> Self {
39 Self {
40 notifications: false,
41 ..Self::default()
42 }
43 }
44}
45
46#[async_trait]
47impl DurableOutbox for MemoryOutbox {
48 fn subscribe(&self) -> tokio::sync::broadcast::Receiver<NotificationId> {
49 if self.notifications {
50 self.changes.subscribe()
51 } else {
52 let (_, receiver) = tokio::sync::broadcast::channel(1);
53 receiver
54 }
55 }
56 async fn enqueue(&self, item: NewOutboxItem) -> Result<OutboxItem, NotifyStoreError> {
57 let key = (
58 item.tenant_id.to_string(),
59 item.subject_id.to_string(),
60 item.idempotency_key.clone(),
61 );
62 let mut idempotency = self
63 .idempotency
64 .lock()
65 .unwrap_or_else(std::sync::PoisonError::into_inner);
66 let mut items = self
67 .items
68 .lock()
69 .unwrap_or_else(std::sync::PoisonError::into_inner);
70 if let Some(existing) = idempotency.get(&key).and_then(|id| items.get(id)) {
71 return if existing.channel == item.channel
72 && existing.recipient == item.recipient
73 && existing.notification == item.notification
74 && existing.max_attempts == item.max_attempts.max(1)
75 {
76 Ok(existing.clone())
77 } else {
78 Err(NotifyStoreError::IdempotencyConflict)
79 };
80 }
81 let id = NotificationId::parse(format!("notification-{}", items.len() + 1))
82 .map_err(|error| NotifyStoreError::Invalid(error.to_string()))?;
83 let now = Utc::now();
84 let record = OutboxItem {
85 id: id.clone(),
86 tenant_id: item.tenant_id,
87 subject_id: item.subject_id,
88 channel: item.channel,
89 recipient: item.recipient,
90 notification: item.notification,
91 status: NotificationStatus::Pending,
92 attempts: 0,
93 max_attempts: item.max_attempts.max(1),
94 lease_version: 0,
95 last_error: None,
96 created_at: now,
97 updated_at: now,
98 };
99 items.insert(id.clone(), record.clone());
100 idempotency.insert(key, id);
101 let _ = self.changes.send(record.id.clone());
102 Ok(record)
103 }
104 async fn get(
105 &self,
106 context: &RequestContext,
107 id: &NotificationId,
108 ) -> Result<OutboxItem, NotifyStoreError> {
109 self.items
110 .lock()
111 .unwrap_or_else(std::sync::PoisonError::into_inner)
112 .get(id)
113 .filter(|item| {
114 item.tenant_id == context.tenant_id && item.subject_id == context.subject_id
115 })
116 .cloned()
117 .ok_or(NotifyStoreError::NotFound)
118 }
119 async fn list(
120 &self,
121 context: &RequestContext,
122 status: Option<NotificationStatus>,
123 limit: usize,
124 after: Option<&NotificationId>,
125 ) -> Result<Vec<OutboxItem>, NotifyStoreError> {
126 Ok(self
127 .items
128 .lock()
129 .unwrap_or_else(std::sync::PoisonError::into_inner)
130 .values()
131 .filter(|item| {
132 item.tenant_id == context.tenant_id
133 && item.subject_id == context.subject_id
134 && status.is_none_or(|status| item.status == status)
135 && after.is_none_or(|after| item.id > *after)
136 })
137 .take(limit.clamp(1, 101))
138 .cloned()
139 .collect())
140 }
141 async fn attempts(
142 &self,
143 context: &RequestContext,
144 id: &NotificationId,
145 ) -> Result<Vec<AttemptEvent>, NotifyStoreError> {
146 self.get(context, id).await.map(|_| Vec::new())
147 }
148 async fn claim(
149 &self,
150 _worker_id: &str,
151 _lease_secs: i64,
152 batch: usize,
153 ) -> Result<Vec<OutboxItem>, NotifyStoreError> {
154 let mut items = self
155 .items
156 .lock()
157 .unwrap_or_else(std::sync::PoisonError::into_inner);
158 let ids: Vec<_> = items
159 .values()
160 .filter(|item| {
161 matches!(
162 item.status,
163 NotificationStatus::Pending | NotificationStatus::RetryScheduled
164 )
165 })
166 .take(batch.clamp(1, 100))
167 .map(|item| item.id.clone())
168 .collect();
169 let claimed: Vec<_> = ids
170 .into_iter()
171 .filter_map(|id| {
172 let item = items.get_mut(&id)?;
173 item.status = NotificationStatus::Sending;
174 item.attempts += 1;
175 item.lease_version += 1;
176 Some(item.clone())
177 })
178 .collect();
179 for item in &claimed {
180 let _ = self.changes.send(item.id.clone());
181 }
182 Ok(claimed)
183 }
184 async fn mark_sent(
185 &self,
186 id: &NotificationId,
187 lease_version: i64,
188 ) -> Result<(), NotifyStoreError> {
189 let mut items = self
190 .items
191 .lock()
192 .unwrap_or_else(std::sync::PoisonError::into_inner);
193 let item = items.get_mut(id).ok_or(NotifyStoreError::NotFound)?;
194 if item.lease_version != lease_version {
195 return Err(NotifyStoreError::LeaseLost);
196 }
197 item.status = NotificationStatus::Sent;
198 let _ = self.changes.send(id.clone());
199 Ok(())
200 }
201 async fn record_failure(
202 &self,
203 id: &NotificationId,
204 lease_version: i64,
205 class: DeliveryClass,
206 _delay: Duration,
207 ) -> Result<NotificationStatus, NotifyStoreError> {
208 let mut items = self
209 .items
210 .lock()
211 .unwrap_or_else(std::sync::PoisonError::into_inner);
212 let item = items.get_mut(id).ok_or(NotifyStoreError::NotFound)?;
213 if item.lease_version != lease_version {
214 return Err(NotifyStoreError::LeaseLost);
215 }
216 item.status = if class == DeliveryClass::Permanent || item.attempts >= item.max_attempts {
217 NotificationStatus::DeadLetter
218 } else {
219 NotificationStatus::RetryScheduled
220 };
221 let _ = self.changes.send(id.clone());
222 Ok(item.status)
223 }
224 async fn retry_dead_letter(
225 &self,
226 context: &RequestContext,
227 id: &NotificationId,
228 ) -> Result<OutboxItem, NotifyStoreError> {
229 let mut items = self
230 .items
231 .lock()
232 .unwrap_or_else(std::sync::PoisonError::into_inner);
233 let item = items
234 .get_mut(id)
235 .filter(|item| item.tenant_id == context.tenant_id)
236 .ok_or(NotifyStoreError::NotFound)?;
237 if item.status != NotificationStatus::DeadLetter {
238 return Err(NotifyStoreError::Invalid(
239 "notification is not dead-lettered".into(),
240 ));
241 }
242 item.status = NotificationStatus::Pending;
243 item.attempts = 0;
244 let _ = self.changes.send(id.clone());
245 Ok(item.clone())
246 }
247}
248
249#[derive(Default)]
251pub struct CapturingSender {
252 pub deliveries: Mutex<Vec<(String, String)>>,
254}
255
256#[async_trait]
257impl Sender for CapturingSender {
258 fn name(&self) -> &str {
259 "capture"
260 }
261 async fn send(
262 &self,
263 context: &DeliveryContext,
264 recipient: &str,
265 notification: &Notification,
266 ) -> Result<(), NotifyError> {
267 if context.cancellation.is_cancelled() {
268 return Err(NotifyError::Cancelled);
269 }
270 self.deliveries
271 .lock()
272 .unwrap_or_else(std::sync::PoisonError::into_inner)
273 .push((recipient.to_owned(), notification.to_plain_text()));
274 Ok(())
275 }
276}
277
278pub struct FailingSender(pub NotifyError);
280
281#[async_trait]
282impl Sender for FailingSender {
283 fn name(&self) -> &str {
284 "fail"
285 }
286 async fn send(
287 &self,
288 _context: &DeliveryContext,
289 _recipient: &str,
290 _notification: &Notification,
291 ) -> Result<(), NotifyError> {
292 Err(self.0.clone())
293 }
294}