replace tokio::broadcast event bus with SQLite-backed event queue (ADR-0003)

- DomainEvent: add Serialize/Deserialize
- EventConsumer port: poll_next/ack/nack replacing recv()
- EventEnvelope: wraps event with id/retry_count/created_at
- SqliteEventPublisher/SqliteEventConsumer: INSERT/poll/DLQ
- migration: event_queue + dead_letter_queue tables
- InMemoryEventBus: combined publisher+consumer test double
- NoopEventConsumer: test stub
- webhook_consumer: polls EventConsumer instead of broadcast::Receiver
- presentation+mcp wiring: create from SqlitePool
This commit is contained in:
2026-07-12 07:23:21 +02:00
parent e2393be635
commit 826e824b58
13 changed files with 380 additions and 55 deletions

View File

@@ -8,3 +8,7 @@ domain = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
sqlx = { workspace = true, features = ["sqlite"] }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,42 +1,170 @@
use async_trait::async_trait;
use domain::errors::{DomainError, DomainResult};
use domain::events::DomainEvent;
use domain::events::{DomainEvent, EventEnvelope};
use domain::ports::events::{EventConsumer, EventPublisher};
use tokio::sync::broadcast;
use sqlx::SqlitePool;
pub struct ChannelEventBus {
tx: broadcast::Sender<DomainEvent>,
fn event_type_label(event: &DomainEvent) -> &'static str {
match event {
DomainEvent::BroadcastTransition { .. } => "broadcast_transition",
DomainEvent::NoSignal { .. } => "no_signal",
DomainEvent::ScheduleGenerated { .. } => "schedule_generated",
DomainEvent::ChannelCreated { .. } => "channel_created",
DomainEvent::ChannelUpdated { .. } => "channel_updated",
DomainEvent::ChannelDeleted { .. } => "channel_deleted",
DomainEvent::UserRegistered { .. } => "user_registered",
_ => "unknown",
}
}
impl ChannelEventBus {
pub fn new(capacity: usize) -> Self {
let (tx, _) = broadcast::channel(capacity);
Self { tx }
}
pub struct SqliteEventPublisher {
pool: SqlitePool,
}
pub fn subscriber(&self) -> broadcast::Receiver<DomainEvent> {
self.tx.subscribe()
}
pub fn sender(&self) -> broadcast::Sender<DomainEvent> {
self.tx.clone()
impl SqliteEventPublisher {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl EventPublisher for ChannelEventBus {
impl EventPublisher for SqliteEventPublisher {
async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
let _ = self.tx.send(event);
let event_type = event_type_label(&event);
let payload = serde_json::to_string(&event)
.map_err(|e| DomainError::InfrastructureError(format!("event serialize: {e}")))?;
sqlx::query(
"INSERT INTO event_queue (event_type, payload, status) VALUES (?, ?, 'pending')",
)
.bind(event_type)
.bind(&payload)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
}
#[async_trait]
impl EventConsumer for ChannelEventBus {
async fn recv(&self) -> DomainResult<DomainEvent> {
let mut rx = self.tx.subscribe();
rx.recv()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
pub struct SqliteEventConsumer {
pool: SqlitePool,
}
impl SqliteEventConsumer {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)]
struct EventRow {
id: i64,
payload: String,
retry_count: i32,
created_at: String,
max_retries: i32,
event_type: String,
}
#[async_trait]
impl EventConsumer for SqliteEventConsumer {
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>> {
let row: Option<EventRow> = sqlx::query_as(
"SELECT id, event_type, payload, retry_count, created_at, max_retries \
FROM event_queue WHERE status = 'pending' ORDER BY id ASC LIMIT 1",
)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let row = match row {
Some(r) => r,
None => return Ok(None),
};
sqlx::query("UPDATE event_queue SET status = 'processing', updated_at = datetime('now') WHERE id = ?")
.bind(row.id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match serde_json::from_str::<DomainEvent>(&row.payload) {
Ok(event) => Ok(Some(EventEnvelope::from_persistence(
row.id,
event,
row.retry_count as u32,
row.created_at,
))),
Err(e) => {
move_to_dlq(&self.pool, &row, &e.to_string()).await?;
Ok(None)
}
}
}
async fn ack(&self, event_id: i64) -> DomainResult<()> {
sqlx::query("DELETE FROM event_queue WHERE id = ?")
.bind(event_id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn nack(&self, event_id: i64, error: &str) -> DomainResult<()> {
let row: Option<EventRow> = sqlx::query_as(
"SELECT id, event_type, payload, retry_count, created_at, max_retries FROM event_queue WHERE id = ?",
)
.bind(event_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let row = match row {
Some(r) => r,
None => return Ok(()),
};
let new_retry = row.retry_count + 1;
if new_retry >= row.max_retries {
move_to_dlq(&self.pool, &row, error).await?;
} else {
sqlx::query(
"UPDATE event_queue SET status = 'pending', retry_count = ?, error_message = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(new_retry)
.bind(error)
.bind(event_id)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
Ok(())
}
}
async fn move_to_dlq(pool: &SqlitePool, row: &EventRow, error: &str) -> DomainResult<()> {
sqlx::query(
"INSERT INTO dead_letter_queue (original_event_id, event_type, payload, error_message, retry_count, original_created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(row.id)
.bind(&row.event_type)
.bind(&row.payload)
.bind(error)
.bind(row.retry_count)
.bind(&row.created_at)
.execute(pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query("DELETE FROM event_queue WHERE id = ?")
.bind(row.id)
.execute(pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}