1use crate::db::{DbPool, DbTransaction};
2use crate::error::{AppError, AppResult, ErrorCode};
3use crate::events::EventEnvelope;
4use crate::execution_logs::{
5 ExecutionLogRecord, ExecutionLogSeverity, insert_execution_log_projection,
6};
7use crate::{RuntimeSpanAttributes, record_runtime_span_attributes, trace_context_from_headers};
8use async_trait::async_trait;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use std::collections::BTreeMap;
13use std::fmt::Debug;
14use std::sync::Arc;
15use tracing::Instrument;
16
17const OUTBOX_RETRY_DELAY_SECONDS: i64 = 5;
18const STALE_PROCESSING_LOCK_SECONDS: i64 = 300;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum OutboxStatus {
23 Pending,
24 Processing,
25 Published,
26 Failed,
27 Dead,
28}
29
30impl OutboxStatus {
31 fn as_str(self) -> &'static str {
32 match self {
33 Self::Pending => "pending",
34 Self::Processing => "processing",
35 Self::Published => "published",
36 Self::Failed => "failed",
37 Self::Dead => "dead",
38 }
39 }
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
43pub struct OutboxEvent {
44 pub id: String,
45 pub event_name: String,
46 pub event_version: u16,
47 pub source_module: String,
48 pub aggregate_type: String,
49 pub aggregate_id: String,
50 pub correlation_id: String,
51 pub causation_id: Option<String>,
52 pub occurred_at: DateTime<Utc>,
53 pub payload: Value,
54 pub headers: Value,
55}
56
57impl OutboxEvent {
58 pub fn from_envelope(aggregate_type: impl Into<String>, event: &EventEnvelope) -> Self {
59 Self {
60 id: event.event_id.clone(),
61 event_name: event.event_name.clone(),
62 event_version: event.event_version,
63 source_module: event.source_module.clone(),
64 aggregate_type: aggregate_type.into(),
65 aggregate_id: event.subject.clone(),
66 correlation_id: event.correlation_id.0.clone(),
67 causation_id: event.causation_id.clone(),
68 occurred_at: event.occurred_at,
69 payload: event.payload.clone(),
70 headers: json!({
71 "actor": event.actor,
72 "schema_ref": event.schema_ref,
73 "trace": event.trace,
74 }),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Deserialize, Serialize)]
80pub struct ClaimedOutboxEvent {
81 pub id: String,
82 pub event_name: String,
83 pub event_version: u16,
84 pub source_module: String,
85 pub aggregate_type: String,
86 pub aggregate_id: String,
87 pub correlation_id: String,
88 pub causation_id: Option<String>,
89 pub occurred_at: DateTime<Utc>,
90 pub payload: Value,
91 pub headers: Value,
92 pub attempts: i32,
93 pub max_attempts: i32,
94}
95
96#[derive(Debug, Clone, Default)]
97pub struct OutboxPublisher;
98
99impl OutboxPublisher {
100 pub async fn publish_in_tx(
101 &self,
102 tx: &mut DbTransaction<'_>,
103 event: &OutboxEvent,
104 ) -> AppResult<()> {
105 let span = tracing::info_span!(
106 "outbox_publish",
107 lenso.correlation_id = tracing::field::Empty,
108 lenso.story_id = tracing::field::Empty,
109 lenso.outbox_event_id = tracing::field::Empty,
110 lenso.execution.kind = tracing::field::Empty,
111 lenso.execution.name = tracing::field::Empty,
112 );
113 record_runtime_span_attributes(
114 &span,
115 &RuntimeSpanAttributes::outbox(
116 event.correlation_id.clone(),
117 event.id.clone(),
118 event.event_name.clone(),
119 ),
120 );
121
122 async {
123 sqlx::query(
124 r#"
125 insert into platform.outbox (
126 id,
127 event_name,
128 event_version,
129 source_module,
130 aggregate_type,
131 aggregate_id,
132 correlation_id,
133 causation_id,
134 occurred_at,
135 payload,
136 headers
137 )
138 values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
139 "#,
140 )
141 .bind(&event.id)
142 .bind(&event.event_name)
143 .bind(i32::from(event.event_version))
144 .bind(&event.source_module)
145 .bind(&event.aggregate_type)
146 .bind(&event.aggregate_id)
147 .bind(&event.correlation_id)
148 .bind(&event.causation_id)
149 .bind(event.occurred_at)
150 .bind(&event.payload)
151 .bind(&event.headers)
152 .execute(&mut **tx)
153 .await
154 .map(|_| ())
155 .map_err(map_outbox_error)
156 }
157 .instrument(span)
158 .await
159 }
160
161 pub async fn pending_count(&self, pool: &DbPool) -> AppResult<i64> {
162 sqlx::query_scalar(
163 r#"
164 select count(*)
165 from platform.outbox
166 where status = 'pending'
167 "#,
168 )
169 .fetch_one(pool)
170 .await
171 .map_err(map_outbox_error)
172 }
173}
174
175#[async_trait]
176pub trait EventDispatcher: Debug + Send + Sync {
177 async fn dispatch(&self, event: &ClaimedOutboxEvent) -> AppResult<()>;
178}
179
180#[async_trait]
181pub trait EventHandler: Debug + Send + Sync {
182 fn event_name(&self) -> &str;
183 async fn handle(&self, event: &ClaimedOutboxEvent) -> AppResult<()>;
184}
185
186#[derive(Debug, Clone, Default)]
187pub struct EventHandlerRegistry {
188 handlers: BTreeMap<String, Vec<Arc<dyn EventHandler>>>,
189}
190
191impl EventHandlerRegistry {
192 pub fn new() -> Self {
193 Self::default()
194 }
195
196 pub fn register(&mut self, handler: Arc<dyn EventHandler>) {
197 self.handlers
198 .entry(handler.event_name().to_owned())
199 .or_default()
200 .push(handler);
201 }
202
203 pub fn register_all(&mut self, handlers: impl IntoIterator<Item = Arc<dyn EventHandler>>) {
204 for handler in handlers {
205 self.register(handler);
206 }
207 }
208
209 pub fn handler_count(&self, event_name: &str) -> usize {
210 self.handlers.get(event_name).map_or(0, Vec::len)
211 }
212}
213
214#[async_trait]
215impl EventDispatcher for EventHandlerRegistry {
216 async fn dispatch(&self, event: &ClaimedOutboxEvent) -> AppResult<()> {
217 let Some(handlers) = self.handlers.get(event.event_name.as_str()) else {
218 tracing::debug!(
219 event_name = %event.event_name,
220 outbox_id = %event.id,
221 "no in-process event handlers registered"
222 );
223 return Ok(());
224 };
225
226 for handler in handlers {
227 handler.handle(event).await?;
228 }
229
230 Ok(())
231 }
232}
233
234#[derive(Debug, Default)]
235pub struct LoggingEventDispatcher;
236
237#[async_trait]
238impl EventDispatcher for LoggingEventDispatcher {
239 async fn dispatch(&self, event: &ClaimedOutboxEvent) -> AppResult<()> {
240 tracing::info!(
241 outbox_id = %event.id,
242 event_name = %event.event_name,
243 event_version = event.event_version,
244 aggregate_id = %event.aggregate_id,
245 correlation_id = %event.correlation_id,
246 "outbox event dispatched"
247 );
248 Ok(())
249 }
250}
251
252#[derive(Debug, Clone)]
253pub struct OutboxRelay {
254 pool: DbPool,
255 worker_id: String,
256}
257
258impl OutboxRelay {
259 pub fn new(pool: DbPool, worker_id: impl Into<String>) -> Self {
260 Self {
261 pool,
262 worker_id: worker_id.into(),
263 }
264 }
265
266 pub async fn claim_batch(&self, batch_size: i64) -> AppResult<Vec<ClaimedOutboxEvent>> {
267 let span = tracing::info_span!(
268 "outbox_claim_batch",
269 worker_id = %self.worker_id,
270 lenso.execution.kind = "outbox_claim",
271 lenso.execution.name = "outbox.claim_batch",
272 );
273
274 async {
275 let events = sqlx::query_as::<_, OutboxRow>(
276 r#"
277 with claimed as (
278 select id
279 from platform.outbox
280 where (
281 status in ('pending', 'failed')
282 and available_at <= now()
283 )
284 or (
285 status = 'processing'
286 and locked_at <= now() - ($1::double precision * interval '1 second')
287 )
288 order by available_at asc, created_at asc
289 limit $2
290 for update skip locked
291 )
292 update platform.outbox outbox
293 set status = 'processing',
294 locked_at = now(),
295 locked_by = $3,
296 last_error = null
297 from claimed
298 where outbox.id = claimed.id
299 returning
300 outbox.id,
301 outbox.event_name,
302 outbox.event_version,
303 outbox.source_module,
304 outbox.aggregate_type,
305 outbox.aggregate_id,
306 outbox.correlation_id,
307 outbox.causation_id,
308 outbox.occurred_at,
309 outbox.payload,
310 outbox.headers,
311 outbox.attempts,
312 outbox.max_attempts
313 "#,
314 )
315 .bind(stale_processing_lock_seconds())
316 .bind(batch_size)
317 .bind(&self.worker_id)
318 .fetch_all(&self.pool)
319 .await
320 .map(|rows| rows.into_iter().map(Into::into).collect())
321 .map_err(map_outbox_error)?;
322
323 for event in &events {
324 self.record_outbox_execution_log(
325 event,
326 ExecutionLogSeverity::Info,
327 "Outbox event claimed",
328 json!({
329 "attempt": event.attempts + 1,
330 "max_attempts": event.max_attempts,
331 "worker_id": self.worker_id,
332 }),
333 )
334 .await;
335 }
336
337 Ok(events)
338 }
339 .instrument(span)
340 .await
341 }
342
343 pub async fn relay_once(
344 &self,
345 dispatcher: &dyn EventDispatcher,
346 batch_size: i64,
347 ) -> AppResult<usize> {
348 let span = tracing::info_span!(
349 "outbox_relay_once",
350 worker_id = %self.worker_id,
351 lenso.execution.kind = "outbox_relay",
352 lenso.execution.name = "outbox.relay_once",
353 );
354
355 async {
356 let events = self.claim_batch(batch_size).await?;
357 let count = events.len();
358
359 for event in events {
360 let event_span = tracing::info_span!(
361 "outbox_dispatch",
362 lenso.correlation_id = tracing::field::Empty,
363 lenso.story_id = tracing::field::Empty,
364 lenso.outbox_event_id = tracing::field::Empty,
365 lenso.execution.kind = tracing::field::Empty,
366 lenso.execution.name = tracing::field::Empty,
367 );
368 record_runtime_span_attributes(
369 &event_span,
370 &RuntimeSpanAttributes::outbox(
371 event.correlation_id.clone(),
372 event.id.clone(),
373 event.event_name.clone(),
374 ),
375 );
376
377 async {
378 self.record_outbox_execution_log(
379 &event,
380 ExecutionLogSeverity::Info,
381 "Outbox event dispatch started",
382 json!({
383 "event_name": event.event_name,
384 "attempt": event.attempts + 1,
385 "worker_id": self.worker_id,
386 }),
387 )
388 .await;
389 match dispatcher.dispatch(&event).await {
390 Ok(()) => self.mark_published(&event).await?,
391 Err(error) => self.mark_dispatch_failed(&event, &error).await?,
392 }
393
394 Ok::<(), AppError>(())
395 }
396 .instrument(event_span)
397 .await?;
398 }
399
400 Ok(count)
401 }
402 .instrument(span)
403 .await
404 }
405
406 pub async fn mark_published(&self, event: &ClaimedOutboxEvent) -> AppResult<()> {
407 sqlx::query(
408 r#"
409 update platform.outbox
410 set status = 'published',
411 published_at = now(),
412 locked_at = null,
413 locked_by = null,
414 last_error = null
415 where id = $1
416 "#,
417 )
418 .bind(&event.id)
419 .execute(&self.pool)
420 .await
421 .map(|_| ())
422 .map_err(map_outbox_error)?;
423
424 self.record_outbox_execution_log(
425 event,
426 ExecutionLogSeverity::Info,
427 "Outbox event published",
428 json!({
429 "event_name": event.event_name,
430 "attempt": event.attempts + 1,
431 "worker_id": self.worker_id,
432 }),
433 )
434 .await;
435
436 Ok(())
437 }
438
439 pub async fn mark_dispatch_failed(
440 &self,
441 event: &ClaimedOutboxEvent,
442 error: &AppError,
443 ) -> AppResult<()> {
444 let next_attempt = event.attempts + 1;
445 let status = if next_attempt >= event.max_attempts {
446 OutboxStatus::Dead
447 } else if error.retryable {
448 OutboxStatus::Failed
449 } else {
450 OutboxStatus::Dead
451 };
452
453 let span = tracing::info_span!(
454 "outbox_retry",
455 lenso.correlation_id = tracing::field::Empty,
456 lenso.story_id = tracing::field::Empty,
457 lenso.outbox_event_id = tracing::field::Empty,
458 lenso.execution.kind = tracing::field::Empty,
459 lenso.execution.name = tracing::field::Empty,
460 );
461 record_runtime_span_attributes(
462 &span,
463 &RuntimeSpanAttributes::outbox(
464 event.correlation_id.clone(),
465 event.id.clone(),
466 event.event_name.clone(),
467 ),
468 );
469
470 async {
471 sqlx::query(
472 r#"
473 update platform.outbox
474 set status = $2,
475 attempts = attempts + 1,
476 available_at = case
477 when $2 = 'failed' then now() + ($4::double precision * interval '1 second')
478 else available_at
479 end,
480 locked_at = null,
481 locked_by = null,
482 last_error = $3
483 where id = $1
484 "#,
485 )
486 .bind(&event.id)
487 .bind(status.as_str())
488 .bind(error.public_message.as_str())
489 .bind(outbox_retry_delay_seconds())
490 .execute(&self.pool)
491 .await
492 .map(|_| ())
493 .map_err(map_outbox_error)?;
494
495 self.record_outbox_execution_log(
496 event,
497 ExecutionLogSeverity::Error,
498 if status == OutboxStatus::Dead {
499 "Outbox event marked dead"
500 } else {
501 "Outbox event failed"
502 },
503 json!({
504 "attempt": next_attempt,
505 "max_attempts": event.max_attempts,
506 "status": status.as_str(),
507 "retryable": error.retryable,
508 "error": error.public_message,
509 "worker_id": self.worker_id,
510 }),
511 )
512 .await;
513
514 Ok(())
515 }
516 .instrument(span)
517 .await
518 }
519
520 async fn record_outbox_execution_log(
521 &self,
522 event: &ClaimedOutboxEvent,
523 severity: ExecutionLogSeverity,
524 body: &'static str,
525 attributes: Value,
526 ) {
527 emit_outbox_lifecycle_event(event, severity, body, &attributes, Some(&self.worker_id));
528 if let Err(error) = insert_execution_log_projection(
529 &self.pool,
530 outbox_log_record(event, severity, body, attributes),
531 )
532 .await
533 {
534 tracing::warn!(
535 error = ?error,
536 outbox_id = %event.id,
537 "failed to write outbox execution log"
538 );
539 }
540 }
541}
542
543type OutboxRow = (
544 String,
545 String,
546 i32,
547 String,
548 String,
549 String,
550 String,
551 Option<String>,
552 DateTime<Utc>,
553 Value,
554 Value,
555 i32,
556 i32,
557);
558
559impl From<OutboxRow> for ClaimedOutboxEvent {
560 fn from(row: OutboxRow) -> Self {
561 let (
562 id,
563 event_name,
564 event_version,
565 source_module,
566 aggregate_type,
567 aggregate_id,
568 correlation_id,
569 causation_id,
570 occurred_at,
571 payload,
572 headers,
573 attempts,
574 max_attempts,
575 ) = row;
576
577 Self {
578 id,
579 event_name,
580 event_version: event_version
581 .try_into()
582 .expect("event_version should fit into u16"),
583 source_module,
584 aggregate_type,
585 aggregate_id,
586 correlation_id,
587 causation_id,
588 occurred_at,
589 payload,
590 headers,
591 attempts,
592 max_attempts,
593 }
594 }
595}
596
597fn map_outbox_error(source: sqlx::Error) -> AppError {
598 AppError::new(ErrorCode::Internal, "Outbox operation failed").with_source(source)
599}
600
601fn outbox_retry_delay_seconds() -> f64 {
602 OUTBOX_RETRY_DELAY_SECONDS as f64
603}
604
605fn stale_processing_lock_seconds() -> f64 {
606 STALE_PROCESSING_LOCK_SECONDS as f64
607}
608
609fn emit_outbox_lifecycle_event(
610 event: &ClaimedOutboxEvent,
611 severity: ExecutionLogSeverity,
612 body: &'static str,
613 attributes: &Value,
614 worker_id: Option<&str>,
615) {
616 match severity {
617 ExecutionLogSeverity::Error => {
618 tracing::error!(
619 outbox_id = %event.id,
620 event_name = %event.event_name,
621 correlation_id = %event.correlation_id,
622 worker_id = worker_id.unwrap_or(""),
623 attributes = %attributes,
624 "{body}"
625 );
626 }
627 ExecutionLogSeverity::Warn => {
628 tracing::warn!(
629 outbox_id = %event.id,
630 event_name = %event.event_name,
631 correlation_id = %event.correlation_id,
632 worker_id = worker_id.unwrap_or(""),
633 attributes = %attributes,
634 "{body}"
635 );
636 }
637 _ => {
638 tracing::info!(
639 outbox_id = %event.id,
640 event_name = %event.event_name,
641 correlation_id = %event.correlation_id,
642 worker_id = worker_id.unwrap_or(""),
643 attributes = %attributes,
644 "{body}"
645 );
646 }
647 }
648}
649
650fn outbox_log_record(
651 event: &impl OutboxLogSource,
652 severity: ExecutionLogSeverity,
653 body: impl Into<String>,
654 attributes: Value,
655) -> ExecutionLogRecord {
656 ExecutionLogRecord::from_runtime_attrs(
657 RuntimeSpanAttributes::outbox(event.correlation_id(), event.id(), event.execution_name()),
658 severity,
659 body,
660 )
661 .with_attributes(attributes)
662 .with_trace(trace_context_from_headers(event.headers()))
663}
664
665trait OutboxLogSource {
666 fn id(&self) -> String;
667 fn correlation_id(&self) -> String;
668 fn execution_name(&self) -> String;
669 fn headers(&self) -> &Value;
670}
671
672impl OutboxLogSource for OutboxEvent {
673 fn id(&self) -> String {
674 self.id.clone()
675 }
676
677 fn correlation_id(&self) -> String {
678 self.correlation_id.clone()
679 }
680
681 fn execution_name(&self) -> String {
682 self.event_name.clone()
683 }
684
685 fn headers(&self) -> &Value {
686 &self.headers
687 }
688}
689
690impl OutboxLogSource for ClaimedOutboxEvent {
691 fn id(&self) -> String {
692 self.id.clone()
693 }
694
695 fn correlation_id(&self) -> String {
696 self.correlation_id.clone()
697 }
698
699 fn execution_name(&self) -> String {
700 self.event_name.clone()
701 }
702
703 fn headers(&self) -> &Value {
704 &self.headers
705 }
706}