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