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

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use crate::value_objects::{ChannelId, ScheduleId, SlotId};
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub enum DomainEvent {
BroadcastTransition {
@@ -18,6 +20,58 @@ pub enum DomainEvent {
UserRegistered { user_id: crate::value_objects::UserId },
}
pub struct EventEnvelope {
id: i64,
event: DomainEvent,
retry_count: u32,
created_at: String,
}
impl EventEnvelope {
pub fn new(id: i64, event: DomainEvent) -> Self {
Self {
id,
event,
retry_count: 0,
created_at: String::new(),
}
}
pub fn from_persistence(
id: i64,
event: DomainEvent,
retry_count: u32,
created_at: String,
) -> Self {
Self {
id,
event,
retry_count,
created_at,
}
}
pub fn id(&self) -> i64 {
self.id
}
pub fn event(&self) -> &DomainEvent {
&self.event
}
pub fn into_event(self) -> DomainEvent {
self.event
}
pub fn retry_count(&self) -> u32 {
self.retry_count
}
pub fn created_at(&self) -> &str {
&self.created_at
}
}
#[cfg(test)]
#[path = "tests/mod.rs"]
mod tests;

View File

@@ -1,7 +1,7 @@
use async_trait::async_trait;
use crate::errors::DomainResult;
pub use crate::events::DomainEvent;
pub use crate::events::{DomainEvent, EventEnvelope};
#[async_trait]
pub trait EventPublisher: Send + Sync {
@@ -10,7 +10,9 @@ pub trait EventPublisher: Send + Sync {
#[async_trait]
pub trait EventConsumer: Send + Sync {
async fn recv(&self) -> DomainResult<DomainEvent>;
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>>;
async fn ack(&self, event_id: i64) -> DomainResult<()>;
async fn nack(&self, event_id: i64, error: &str) -> DomainResult<()>;
}
#[async_trait]

View File

@@ -13,7 +13,7 @@ pub mod user;
pub use activity::{ActivityLogCommand, ActivityLogQuery};
pub use auth::{AuthService, TokenService};
pub use channel::{ChannelCommand, ChannelQuery};
pub use events::{DomainEvent, EventConsumer, EventHandler, EventPublisher};
pub use events::{DomainEvent, EventConsumer, EventEnvelope, EventHandler, EventPublisher};
pub use library::{LibraryCommand, LibraryQuery, LibrarySyncAdapter};
pub use media::{
Collection, IMediaProvider, IProviderRegistry, ProviderCapabilities, SeriesSummary,

View File

@@ -1,11 +1,12 @@
use std::cmp::Reverse;
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use crate::errors::DomainResult;
use crate::events::{DomainEvent, EventEnvelope};
use crate::models::{
ActivityEvent, Channel, ChannelConfigSnapshot, GeneratedSchedule, LibraryCollection,
LibrarySyncLogEntry, LibrarySyncResult, MediaItem, PlaybackRecord, ProviderConfigRow,
@@ -13,8 +14,9 @@ use crate::models::{
};
use crate::ports::{
ActivityLogCommand, ActivityLogQuery, AppSettingsRepository, ChannelCommand, ChannelQuery,
LibraryCommand, LibraryQuery, ProviderConfigCommand, ProviderConfigQuery, ScheduleCommand,
ScheduleQuery, TranscodeSettingsRepository, UserCommand, UserQuery,
EventConsumer, EventPublisher, LibraryCommand, LibraryQuery, ProviderConfigCommand,
ProviderConfigQuery, ScheduleCommand, ScheduleQuery, TranscodeSettingsRepository, UserCommand,
UserQuery,
};
use crate::value_objects::{
BlockId, ChannelId, ContentType, LibrarySearchFilter, MediaItemId, ScheduleId, SnapshotId,
@@ -777,3 +779,63 @@ impl TranscodeSettingsRepository for InMemoryTranscodeSettings {
Ok(())
}
}
pub struct InMemoryEventBus {
queue: Mutex<VecDeque<(i64, DomainEvent)>>,
next_id: Mutex<i64>,
}
impl InMemoryEventBus {
pub fn new() -> Self {
Self {
queue: Mutex::new(VecDeque::new()),
next_id: Mutex::new(1),
}
}
pub fn events(&self) -> Vec<DomainEvent> {
self.queue
.lock()
.unwrap()
.iter()
.map(|(_, e)| e.clone())
.collect()
}
}
impl Default for InMemoryEventBus {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl EventPublisher for InMemoryEventBus {
async fn publish(&self, event: DomainEvent) -> DomainResult<()> {
let mut next = self.next_id.lock().unwrap();
let id = *next;
*next += 1;
self.queue.lock().unwrap().push_back((id, event));
Ok(())
}
}
#[async_trait]
impl EventConsumer for InMemoryEventBus {
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>> {
let front = self.queue.lock().unwrap().front().cloned();
Ok(front.map(|(id, event)| EventEnvelope::new(id, event)))
}
async fn ack(&self, event_id: i64) -> DomainResult<()> {
self.queue
.lock()
.unwrap()
.retain(|(id, _)| *id != event_id);
Ok(())
}
async fn nack(&self, _event_id: i64, _error: &str) -> DomainResult<()> {
Ok(())
}
}

View File

@@ -1,13 +1,13 @@
use async_trait::async_trait;
use crate::errors::DomainResult;
use crate::events::DomainEvent;
use crate::events::{DomainEvent, EventEnvelope};
use crate::models::{
ActivityEvent, LibrarySyncResult, MediaItem,
};
use crate::ports::{
ActivityLogCommand, ActivityLogQuery, EventPublisher, IMediaProvider, LibrarySyncAdapter,
ProviderCapabilities, StreamQuality, StreamingProtocol,
ActivityLogCommand, ActivityLogQuery, EventConsumer, EventPublisher, IMediaProvider,
LibrarySyncAdapter, ProviderCapabilities, StreamQuality, StreamingProtocol,
};
use crate::value_objects::{ChannelId, MediaFilter, MediaItemId};
@@ -32,6 +32,35 @@ impl EventPublisher for NoopEventPublisher {
}
}
pub struct NoopEventConsumer;
impl NoopEventConsumer {
pub fn new() -> Self {
Self
}
}
impl Default for NoopEventConsumer {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl EventConsumer for NoopEventConsumer {
async fn poll_next(&self) -> DomainResult<Option<EventEnvelope>> {
Ok(None)
}
async fn ack(&self, _event_id: i64) -> DomainResult<()> {
Ok(())
}
async fn nack(&self, _event_id: i64, _error: &str) -> DomainResult<()> {
Ok(())
}
}
pub struct NoopMediaProvider;
impl NoopMediaProvider {