Persist chat history to Postgres via durable JetStream consumer
DATABASE_URL required at startup; schema self-initializes. Compose gains a postgres service for standalone dev. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6b97ec3b0f
commit
ec3771f2e7
@@ -1,5 +1,6 @@
|
||||
pub mod oidc;
|
||||
pub mod sse;
|
||||
pub mod store;
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use leptos::prelude::LeptosOptions;
|
||||
@@ -10,6 +11,7 @@ pub struct AppState {
|
||||
pub leptos_options: LeptosOptions,
|
||||
pub nats: async_nats::Client,
|
||||
pub oidc: Arc<oidc::Oidc>,
|
||||
pub pool: sqlx::PgPool,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for LeptosOptions {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Message archive: a durable JetStream pull consumer drains `chat.room.*`
|
||||
//! into Postgres, so history survives restarts and includes messages
|
||||
//! published by any client on the bus (not just this app).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_nats::jetstream;
|
||||
use futures::StreamExt;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::chat::ChatMessage;
|
||||
|
||||
const STREAM_NAME: &str = "CHAT";
|
||||
const CONSUMER_NAME: &str = "cnats-postgres";
|
||||
|
||||
pub async fn init_schema(pool: &PgPool) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
room TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
time TEXT NOT NULL,
|
||||
ts BIGINT NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS messages_room_ts ON messages (room, ts DESC, id)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs forever; (re)creates the stream/consumer and retries on any failure,
|
||||
/// so a NATS or Postgres outage never takes the chat server down.
|
||||
pub async fn run_consumer(nats: async_nats::Client, pool: PgPool) {
|
||||
loop {
|
||||
if let Err(err) = consume(&nats, &pool).await {
|
||||
tracing::error!("archive consumer failed: {err:#}; retrying in 5s");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn consume(nats: &async_nats::Client, pool: &PgPool) -> anyhow::Result<()> {
|
||||
let js = jetstream::new(nats.clone());
|
||||
|
||||
let stream = js
|
||||
.get_or_create_stream(jetstream::stream::Config {
|
||||
name: STREAM_NAME.to_string(),
|
||||
subjects: vec!["chat.room.*".to_string()],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("get_or_create_stream: {e}"))?;
|
||||
|
||||
let consumer = stream
|
||||
.get_or_create_consumer(
|
||||
CONSUMER_NAME,
|
||||
jetstream::consumer::pull::Config {
|
||||
durable_name: Some(CONSUMER_NAME.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("get_or_create_consumer: {e}"))?;
|
||||
|
||||
tracing::info!(stream = STREAM_NAME, consumer = CONSUMER_NAME, "archiving to postgres");
|
||||
|
||||
let mut messages = consumer
|
||||
.messages()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("consumer messages: {e}"))?;
|
||||
|
||||
while let Some(msg) = messages.next().await {
|
||||
let msg = msg.map_err(|e| anyhow::anyhow!("pull next: {e}"))?;
|
||||
match serde_json::from_slice::<ChatMessage>(&msg.payload) {
|
||||
Ok(chat) => insert(pool, &chat).await?,
|
||||
// Malformed payloads (e.g. hand-typed CLI publishes) are logged
|
||||
// and acked so they don't wedge the consumer.
|
||||
Err(err) => tracing::warn!("skipping unparseable message: {err}"),
|
||||
}
|
||||
msg.ack()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("ack: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert(pool: &PgPool, m: &ChatMessage) -> anyhow::Result<()> {
|
||||
// Idempotent on message id: JetStream is at-least-once, so redeliveries
|
||||
// after an unacked crash must not duplicate rows.
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (id, room, username, display_name, text, time, ts)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (id) DO NOTHING",
|
||||
)
|
||||
.bind(&m.id)
|
||||
.bind(&m.room)
|
||||
.bind(&m.username)
|
||||
.bind(&m.display_name)
|
||||
.bind(&m.text)
|
||||
.bind(&m.time)
|
||||
.bind(m.ts)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The latest `limit` messages for a room, oldest first.
|
||||
pub async fn recent(pool: &PgPool, room: &str, limit: i64) -> anyhow::Result<Vec<ChatMessage>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
room: String,
|
||||
username: String,
|
||||
display_name: String,
|
||||
text: String,
|
||||
time: String,
|
||||
ts: i64,
|
||||
}
|
||||
|
||||
let mut rows: Vec<Row> = sqlx::query_as(
|
||||
"SELECT id, room, username, display_name, text, time, ts
|
||||
FROM messages
|
||||
WHERE room = $1
|
||||
ORDER BY ts DESC, id
|
||||
LIMIT $2",
|
||||
)
|
||||
.bind(room)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.reverse();
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| ChatMessage {
|
||||
id: r.id,
|
||||
room: r.room,
|
||||
username: r.username,
|
||||
display_name: r.display_name,
|
||||
text: r.text,
|
||||
time: r.time,
|
||||
ts: r.ts,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
Reference in New Issue
Block a user