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