Skip to main content

platform_core/
outbox.rs

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}