236 lines
7.3 KiB
Rust
236 lines
7.3 KiB
Rust
mod migrations;
|
|
mod payload;
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use domain::{
|
|
errors::DomainError,
|
|
events::{AckHandle, DomainEvent, EventEnvelope},
|
|
ports::{EventConsumer, EventPublisher},
|
|
};
|
|
use futures::stream::{self, BoxStream};
|
|
use sqlx::PgPool;
|
|
use tokio::sync::{Mutex, mpsc};
|
|
|
|
use payload::DbEventPayload;
|
|
|
|
pub struct DbEventQueueConfig {
|
|
pub poll_interval_ms: u64,
|
|
pub batch_size: i64,
|
|
pub max_attempts: i32,
|
|
}
|
|
|
|
impl DbEventQueueConfig {
|
|
pub fn from_env() -> Self {
|
|
Self {
|
|
poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(500),
|
|
batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(10),
|
|
max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(5),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct PostgresEventQueue {
|
|
pool: PgPool,
|
|
config: Arc<DbEventQueueConfig>,
|
|
}
|
|
|
|
impl PostgresEventQueue {
|
|
pub async fn create(pool: PgPool, config: DbEventQueueConfig) -> anyhow::Result<Self> {
|
|
migrations::run(&pool).await?;
|
|
Ok(Self {
|
|
pool,
|
|
config: Arc::new(config),
|
|
})
|
|
}
|
|
|
|
pub async fn create_publisher(pool: PgPool) -> anyhow::Result<Arc<dyn EventPublisher>> {
|
|
let q = Self::create(pool, DbEventQueueConfig::from_env()).await?;
|
|
Ok(Arc::new(q))
|
|
}
|
|
|
|
pub async fn create_channel(
|
|
pool: PgPool,
|
|
) -> anyhow::Result<(Arc<dyn EventPublisher>, Arc<dyn EventConsumer>)> {
|
|
let q = Self::create(pool, DbEventQueueConfig::from_env()).await?;
|
|
Ok((Arc::new(q.clone()), Arc::new(q)))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl EventPublisher for PostgresEventQueue {
|
|
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
|
let db_payload = DbEventPayload::from(event);
|
|
let event_type = db_payload.event_type();
|
|
let payload_json = serde_json::to_string(&db_payload)
|
|
.map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?;
|
|
|
|
sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES ($1, $2)")
|
|
.bind(event_type)
|
|
.bind(payload_json)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl EventConsumer for PostgresEventQueue {
|
|
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
|
let pool = self.pool.clone();
|
|
let config = Arc::clone(&self.config);
|
|
let (tx, rx) = mpsc::channel(128);
|
|
let rx = Arc::new(Mutex::new(rx));
|
|
|
|
tokio::spawn(async move {
|
|
let poll_interval = Duration::from_millis(config.poll_interval_ms);
|
|
loop {
|
|
match claim_batch(&pool, &config).await {
|
|
Err(e) => {
|
|
tracing::error!("postgres event queue claim error: {e}");
|
|
tokio::time::sleep(poll_interval).await;
|
|
}
|
|
Ok(rows) if rows.is_empty() => {
|
|
tokio::time::sleep(poll_interval).await;
|
|
}
|
|
Ok(rows) => {
|
|
for row in rows {
|
|
let envelope = decode_row(&pool, row, config.max_attempts);
|
|
if tx.send(envelope).await.is_err() {
|
|
tracing::info!("postgres event queue consumer closed");
|
|
return;
|
|
}
|
|
}
|
|
// no sleep — re-poll immediately when batch was non-empty
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Box::pin(stream::unfold(rx, |rx| async move {
|
|
let item = rx.lock().await.recv().await?;
|
|
Some((item, rx))
|
|
}))
|
|
}
|
|
}
|
|
|
|
// ── Internal types ────────────────────────────────────────────────────────────
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct QueueRow {
|
|
id: i64,
|
|
payload: String,
|
|
attempts: i32,
|
|
}
|
|
|
|
async fn claim_batch(
|
|
pool: &PgPool,
|
|
config: &DbEventQueueConfig,
|
|
) -> Result<Vec<QueueRow>, DomainError> {
|
|
// CTE with FOR UPDATE SKIP LOCKED — atomic and safe for multiple workers
|
|
let rows = sqlx::query_as::<_, QueueRow>(
|
|
r#"
|
|
WITH claimed AS (
|
|
SELECT id FROM event_queue
|
|
WHERE status = 'pending' AND next_attempt_at <= NOW()
|
|
ORDER BY next_attempt_at ASC
|
|
LIMIT $1
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
UPDATE event_queue q
|
|
SET status = 'processing'
|
|
FROM claimed
|
|
WHERE q.id = claimed.id
|
|
RETURNING q.id, q.payload, q.attempts
|
|
"#,
|
|
)
|
|
.bind(config.batch_size)
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?;
|
|
|
|
Ok(rows)
|
|
}
|
|
|
|
fn decode_row(
|
|
pool: &PgPool,
|
|
row: QueueRow,
|
|
max_attempts: i32,
|
|
) -> Result<EventEnvelope, DomainError> {
|
|
let db_payload: DbEventPayload = serde_json::from_str(&row.payload)
|
|
.map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?;
|
|
let event = DomainEvent::try_from(db_payload)?;
|
|
Ok(EventEnvelope::new(
|
|
event,
|
|
Box::new(DbAckHandle {
|
|
pool: pool.clone(),
|
|
row_id: row.id,
|
|
attempts: row.attempts,
|
|
max_attempts,
|
|
}),
|
|
))
|
|
}
|
|
|
|
struct DbAckHandle {
|
|
pool: PgPool,
|
|
row_id: i64,
|
|
attempts: i32,
|
|
max_attempts: i32,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AckHandle for DbAckHandle {
|
|
async fn ack(&self) -> Result<(), DomainError> {
|
|
sqlx::query("UPDATE event_queue SET status = 'done' WHERE id = $1")
|
|
.bind(self.row_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| DomainError::InfrastructureError(format!("ack: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn nack(&self) -> Result<(), DomainError> {
|
|
let new_attempts = self.attempts + 1;
|
|
if new_attempts >= self.max_attempts {
|
|
sqlx::query(
|
|
"UPDATE event_queue SET status = 'dead_lettered', attempts = $1, last_error = 'max attempts reached' WHERE id = $2"
|
|
)
|
|
.bind(new_attempts)
|
|
.bind(self.row_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| DomainError::InfrastructureError(format!("nack dead-letter: {e}")))?;
|
|
} else {
|
|
let backoff = backoff_seconds(new_attempts).to_string();
|
|
sqlx::query(
|
|
"UPDATE event_queue SET status = 'pending', attempts = $1, next_attempt_at = NOW() + ($2 || ' seconds')::interval, last_error = 'nack' WHERE id = $3"
|
|
)
|
|
.bind(new_attempts)
|
|
.bind(backoff)
|
|
.bind(self.row_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(|e| DomainError::InfrastructureError(format!("nack retry: {e}")))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn backoff_seconds(attempts: i32) -> i64 {
|
|
let base: i64 = 5 * (1i64 << attempts.min(6));
|
|
base.min(300)
|
|
}
|