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