use crate::dbnexus_entities::{
EventOutboxActiveModel, EventOutboxColumn, EventOutboxEntity, EventOutboxModel,
};
use crate::error::{LimiteronError, StorageError};
use chrono::Utc;
use dbnexus::{DbPool, Session};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, QuerySelect, Set,
};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutboxDialect {
Postgres,
Sqlite,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutboxEventKind {
BanApplied,
BanRemoved,
QuotaAlert,
}
impl OutboxEventKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::BanApplied => "ban_applied",
Self::BanRemoved => "ban_removed",
Self::QuotaAlert => "quota_alert",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OutboxEntry {
pub id: i64,
pub event_type: String,
pub aggregate_id: String,
pub payload: serde_json::Value,
}
pub struct EventOutboxStore {
pool: Arc<DbPool>,
}
impl EventOutboxStore {
pub fn new(pool: Arc<DbPool>) -> Self {
Self { pool }
}
async fn session(&self) -> Result<Session, LimiteronError> {
Ok(self
.pool
.get_session("admin")
.await
.map_err(|e| StorageError::ConnectionError(e.to_string()))?)
}
fn conn(session: &Session) -> Result<&sea_orm::DatabaseConnection, StorageError> {
session
.connection()
.map_err(|e| StorageError::ConnectionError(e.to_string()))
}
pub async fn init(&self, dialect: OutboxDialect) -> Result<(), LimiteronError> {
let ddl = match dialect {
OutboxDialect::Postgres => crate::dbnexus_entities::event_outbox::create_table_ddl(),
OutboxDialect::Sqlite => {
crate::dbnexus_entities::event_outbox::create_table_ddl_sqlite()
}
};
let session = self.session().await?;
let conn = Self::conn(&session)?;
conn.execute_unprepared(ddl)
.await
.map_err(|e| StorageError::QueryError(e.to_string()))?;
Ok(())
}
pub async fn append_event(
&self,
kind: OutboxEventKind,
aggregate_id: &str,
payload: serde_json::Value,
) -> Result<i64, LimiteronError> {
let active = EventOutboxActiveModel {
event_type: Set(kind.as_str().to_string()),
aggregate_id: Set(aggregate_id.to_string()),
payload: Set(payload.to_string()),
status: Set("pending".to_string()),
created_at: Set(Utc::now()),
published_at: Set(None),
..Default::default()
};
let session = self.session().await?;
let conn = Self::conn(&session)?;
let inserted = EventOutboxEntity::insert(active)
.exec(conn)
.await
.map_err(|e| StorageError::QueryError(e.to_string()))?;
Ok(inserted.last_insert_id)
}
pub async fn pending(&self, limit: u64) -> Result<Vec<OutboxEntry>, LimiteronError> {
let session = self.session().await?;
let conn = Self::conn(&session)?;
let rows: Vec<EventOutboxModel> = EventOutboxEntity::find()
.filter(EventOutboxColumn::Status.eq("pending"))
.order_by_asc(EventOutboxColumn::Id)
.limit(limit)
.all(conn)
.await
.map_err(|e| StorageError::QueryError(e.to_string()))?;
Ok(rows
.into_iter()
.map(|m| {
let payload = match serde_json::from_str(&m.payload) {
Ok(v) => v,
Err(e) => {
log::warn!(
target: "limiteron",
"outbox entry {} has corrupt payload, delivering as null: {}",
m.id,
e
);
serde_json::Value::Null
}
};
OutboxEntry {
id: m.id,
event_type: m.event_type,
aggregate_id: m.aggregate_id,
payload,
}
})
.collect())
}
pub async fn mark_published(&self, id: i64) -> Result<(), LimiteronError> {
let session = self.session().await?;
let conn = Self::conn(&session)?;
let mut active: EventOutboxActiveModel = EventOutboxEntity::find_by_id(id)
.one(conn)
.await
.map_err(|e| StorageError::QueryError(e.to_string()))?
.ok_or_else(|| StorageError::NotFound(format!("outbox entry {id}")))?
.into();
active.status = Set("published".to_string());
active.published_at = Set(Some(Utc::now()));
active
.update(conn)
.await
.map_err(|e| StorageError::QueryError(e.to_string()))?;
Ok(())
}
pub async fn pending_count(&self) -> Result<u64, LimiteronError> {
let session = self.session().await?;
let conn = Self::conn(&session)?;
let count = EventOutboxEntity::find()
.filter(EventOutboxColumn::Status.eq("pending"))
.count(conn)
.await
.map_err(|e| StorageError::QueryError(e.to_string()))?;
Ok(count)
}
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use super::*;
fn temp_db_url(tag: &str) -> (String, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!(
"limiteron_outbox_{}_{}.db",
tag,
std::process::id()
));
(format!("sqlite:{}?mode=rwc", path.display()), path)
}
async fn make_store(tag: &str) -> (EventOutboxStore, std::path::PathBuf) {
let (url, path) = temp_db_url(tag);
let pool = Arc::new(DbPool::new(&url).await.expect("sqlite pool"));
let store = EventOutboxStore::new(pool);
store.init(OutboxDialect::Sqlite).await.expect("init ddl");
(store, path)
}
#[tokio::test]
async fn test_t616_outbox_append_pending_publish_lifecycle() {
let (store, _path) = make_store("lifecycle").await;
assert_eq!(store.pending_count().await.unwrap(), 0);
let id1 = store
.append_event(
OutboxEventKind::BanApplied,
"ip:192.0.2.1",
serde_json::json!({"reason": "abuse", "duration_secs": 600}),
)
.await
.unwrap();
let id2 = store
.append_event(
OutboxEventKind::QuotaAlert,
"tenant:t1:user:u1",
serde_json::json!({"usage_percent": 92}),
)
.await
.unwrap();
assert_ne!(id1, id2, "inserted rows get distinct ids");
assert_eq!(store.pending_count().await.unwrap(), 2);
let pending = store.pending(10).await.unwrap();
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].id, id1);
assert_eq!(pending[0].event_type, "ban_applied");
assert_eq!(pending[0].aggregate_id, "ip:192.0.2.1");
assert_eq!(pending[0].payload["reason"], serde_json::json!("abuse"));
store.mark_published(id1).await.unwrap();
store.mark_published(id2).await.unwrap();
assert_eq!(store.pending_count().await.unwrap(), 0);
assert!(store.pending(10).await.unwrap().is_empty());
}
#[tokio::test]
async fn test_t616_outbox_pending_limit_and_crash_recovery() {
let (store, _path) = make_store("limit").await;
for i in 0..5 {
store
.append_event(
OutboxEventKind::BanRemoved,
&format!("user:u{i}"),
serde_json::json!({"seq": i}),
)
.await
.unwrap();
}
let batch = store.pending(2).await.unwrap();
assert_eq!(batch.len(), 2);
assert_eq!(store.pending_count().await.unwrap(), 5);
store.mark_published(batch[0].id).await.unwrap();
assert_eq!(store.pending_count().await.unwrap(), 4);
store.mark_published(batch[0].id).await.unwrap();
assert_eq!(store.pending_count().await.unwrap(), 4);
}
#[tokio::test]
async fn test_t616_outbox_init_is_idempotent() {
let (store, _path) = make_store("idem").await;
store.init(OutboxDialect::Sqlite).await.expect("re-init ok");
}
}