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:
@@ -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 }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user