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                "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}