feat(infra): transactional outbox — OutboxWriter port, PgOutboxWriter, OutboxRelay, TestOutbox; update create_thought + delete_thought
This commit is contained in:
@@ -17,6 +17,7 @@ pub struct WorkerHandlers {
|
||||
}
|
||||
|
||||
pub struct WorkerInfra {
|
||||
pub pool: PgPool,
|
||||
pub consumer: event_transport::EventConsumerAdapter<nats::NatsMessageSource>,
|
||||
pub handlers: WorkerHandlers,
|
||||
pub dlq_store: Arc<PgFailedEventStore>,
|
||||
@@ -85,7 +86,7 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
|
||||
};
|
||||
|
||||
// DLQ store
|
||||
let dlq_store = Arc::new(PgFailedEventStore::new(pool));
|
||||
let dlq_store = Arc::new(PgFailedEventStore::new(pool.clone()));
|
||||
|
||||
// NATS consumer + publisher
|
||||
let nats_client = async_nats::connect(nats_url)
|
||||
@@ -102,6 +103,7 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker
|
||||
);
|
||||
|
||||
WorkerInfra {
|
||||
pool,
|
||||
consumer,
|
||||
handlers,
|
||||
dlq_store,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod dlq;
|
||||
mod factory;
|
||||
mod handlers;
|
||||
mod outbox_relay;
|
||||
|
||||
use domain::ports::EventConsumer;
|
||||
use futures::StreamExt;
|
||||
@@ -26,6 +27,16 @@ async fn main() {
|
||||
infra.event_publisher.clone(),
|
||||
));
|
||||
|
||||
// Spawn outbox relay — polls DB for undelivered events and publishes them.
|
||||
tokio::spawn(
|
||||
outbox_relay::OutboxRelay {
|
||||
pool: infra.pool.clone(),
|
||||
publisher: infra.event_publisher.clone(),
|
||||
poll_interval: std::time::Duration::from_secs(5),
|
||||
}
|
||||
.run(),
|
||||
);
|
||||
|
||||
tracing::info!("Worker started, consuming events...");
|
||||
let mut stream = infra.consumer.consume();
|
||||
while let Some(result) = stream.next().await {
|
||||
|
||||
88
crates/worker/src/outbox_relay.rs
Normal file
88
crates/worker/src/outbox_relay.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use domain::{events::DomainEvent, ports::EventPublisher};
|
||||
use event_payload::EventPayload;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct OutboxRelay {
|
||||
pub pool: PgPool,
|
||||
pub publisher: Arc<dyn EventPublisher>,
|
||||
pub poll_interval: Duration,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct OutboxRow {
|
||||
seq: i64,
|
||||
event_type: String,
|
||||
payload: serde_json::Value,
|
||||
}
|
||||
|
||||
impl OutboxRelay {
|
||||
pub async fn run(self) {
|
||||
loop {
|
||||
if let Err(e) = self.process_batch().await {
|
||||
tracing::error!("outbox relay error: {e}");
|
||||
}
|
||||
tokio::time::sleep(self.poll_interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_batch(&self) -> Result<(), sqlx::Error> {
|
||||
let rows = sqlx::query_as::<_, OutboxRow>(
|
||||
"SELECT seq, event_type, payload \
|
||||
FROM outbox_events \
|
||||
WHERE delivered = false \
|
||||
ORDER BY seq ASC \
|
||||
LIMIT 100 \
|
||||
FOR UPDATE SKIP LOCKED",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
for row in rows {
|
||||
let payload: EventPayload = match serde_json::from_value(row.payload.clone()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(seq = row.seq, event_type = row.event_type, "outbox: failed to deserialize payload: {e}");
|
||||
// Mark delivered to avoid blocking; investigate manually.
|
||||
self.mark_delivered(row.seq).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let domain_event = match DomainEvent::try_from(payload) {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => {
|
||||
tracing::error!(seq = row.seq, "outbox: failed to convert to DomainEvent: {e}");
|
||||
self.mark_delivered(row.seq).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match self.publisher.publish(&domain_event).await {
|
||||
Ok(()) => {
|
||||
self.mark_delivered(row.seq).await?;
|
||||
tracing::debug!(seq = row.seq, event_type = row.event_type, "outbox: delivered");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}");
|
||||
// Leave delivered=false — will be retried next poll.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_delivered(&self, seq: i64) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE outbox_events \
|
||||
SET delivered = true, delivered_at = now() \
|
||||
WHERE seq = $1",
|
||||
)
|
||||
.bind(seq)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user