Files
maarcadetweet/crates/appview/src/firehose.rs
T
tomdeboneandClaude Opus 5 124a90dc07 feat(appview): PDS-Firehose konsumieren
Gegenstück zum subscribeRepos-Endpoint: WebSocket-Consumer mit
persistiertem seq-Cursor, Reconnect-Backoff und Behandlung von
#info/OutdatedCursor.

Eigene Cursor-Tabelle statt einer Zeile in jetstream_cursor: dort steht ein
time_us in der Größenordnung 1.7e15, die seq ist ein kleiner Zähler ab 1.
Geteilt hätte GREATEST den PDS-Cursor sofort in eine Zukunft geschoben, die
die PDS nie erreicht.

Kein neuer Indexer-Pfad — jede Op wird in die Single-Op-Form übersetzt, die
apply_commit schon vom Jetstream kennt. Push und Firehose liefern denselben
Commit doppelt; das ist unkritisch, weil die Schreibpfade Upserts sind und
der Dedupe-Index der Notifications den Rest abfängt. Mit einem Test
festgehalten statt vorausgesetzt.

Der CAR-Reader ist neu (es gab nur einen Writer, und der liegt in einem
Binary-Crate ohne lib-Target). Der CBOR-Reader arbeitet mit explizitem
Offset, weil ein Frame zwei hintereinander geschriebene Werte sind, und
akzeptiert CID-Links in beiden Schreibweisen — die Blöcke tragen Strings.

/healthz meldet beide Ströme getrennt; sie fallen unabhängig voneinander
aus.

Verifiziert mit totem Push-Ziel: der Post kam trotzdem an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-10 07:08:23 +02:00

317 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Connects `at-firehose::JetstreamConsumer` into the AppView indexer.
//!
//! The handler is intentionally tiny: it routes events by `kind`, delegates
//! all DB work to [`crate::indexer`], and feeds a small `mpsc` channel that
//! a background task drains to flush the cursor.
//!
//! Stats (events processed, last cursor seen, last-event timestamp for
//! connection health) live in an `Arc<Stats>` so both the consumer closure
//! and the HTTP `/healthz` handler can read them without locking.
use anyhow::Result;
use at_firehose::JetstreamEvent;
use serde_json::Value;
use sqlx::PgPool;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use crate::indexer;
/// Shared counters consumed by `/healthz`.
pub struct Stats {
pub events_processed: AtomicU64,
/// Microseconds since epoch of the most recent event seen.
pub last_event_time_us: AtomicI64,
/// Microseconds since epoch of the last persisted cursor (best-effort).
pub last_cursor_persisted_us: AtomicI64,
/// Whether the Jetstream WebSocket is currently up. The consumer
/// writes this; `/healthz` reads it. Wrapped in `Arc` so the consumer
/// can hold its own clone without borrowing from us.
pub jetstream_connected: Arc<AtomicBool>,
/// Whether the **local PDS** firehose WebSocket is currently up
/// ([`crate::pds_firehose`]). Separate from `jetstream_connected`
/// because the two streams fail independently and for different
/// reasons: a dead Jetstream means no view of the wider network, a
/// dead PDS firehose means the AppView has lost the guaranteed
/// delivery path for its *own* users' records and is running on the
/// best-effort push alone. `/healthz` has to be able to say which.
pub pds_connected: AtomicBool,
/// Number of `#commit` frames applied from the PDS firehose.
pub pds_frames_processed: AtomicU64,
/// Highest `seq` applied from the PDS firehose in this process.
pub pds_last_seq: AtomicI64,
}
impl Default for Stats {
fn default() -> Self {
Self {
events_processed: AtomicU64::new(0),
last_event_time_us: AtomicI64::new(0),
last_cursor_persisted_us: AtomicI64::new(0),
jetstream_connected: Arc::new(AtomicBool::new(false)),
pds_connected: AtomicBool::new(false),
pds_frames_processed: AtomicU64::new(0),
pds_last_seq: AtomicI64::new(0),
}
}
}
impl std::fmt::Debug for Stats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Stats")
.field("events_processed", &self.events_processed())
.field("last_event_time_us", &self.last_event_time_us.load(Ordering::Relaxed))
.field("jetstream_connected", &self.jetstream_connected())
.field("pds_connected", &self.pds_connected())
.field("pds_frames_processed", &self.pds_frames_processed())
.field("pds_last_seq", &self.pds_last_seq())
.finish()
}
}
impl Stats {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Cheap clone of the `connected` flag (what the consumer stores into).
pub fn jetstream_connected_arc(&self) -> Arc<AtomicBool> {
self.jetstream_connected.clone()
}
/// Lag in milliseconds (last event time local wall clock). When the
/// local clock is ahead (very common — Jetstream events usually look
/// "in the past" by a few seconds because the spec is `time_us` from
/// the producer), this returns 0.
pub fn lag_ms(&self) -> i64 {
let last = self.last_event_time_us.load(Ordering::Relaxed);
if last == 0 {
return 0;
}
let now_us = chrono::Utc::now().timestamp_micros();
let lag_us = (now_us - last).max(0);
lag_us / 1000
}
pub fn events_processed(&self) -> u64 {
self.events_processed.load(Ordering::Relaxed)
}
pub fn jetstream_connected(&self) -> bool {
self.jetstream_connected.load(Ordering::Relaxed)
}
/// Is the local PDS firehose connected right now?
pub fn pds_connected(&self) -> bool {
self.pds_connected.load(Ordering::Relaxed)
}
pub fn pds_frames_processed(&self) -> u64 {
self.pds_frames_processed.load(Ordering::Relaxed)
}
/// Highest PDS-firehose `seq` this process has applied. 0 before the
/// first frame — note this is the *in-process* high-water mark, not
/// the persisted cursor, which lives in `pds_firehose_cursor` and
/// survives restarts.
pub fn pds_last_seq(&self) -> i64 {
self.pds_last_seq.load(Ordering::Relaxed)
}
}
/// The thing the Jetstream consumer calls once per event.
#[derive(Clone)]
pub struct IndexHandler {
pub db: PgPool,
pub stats: Arc<Stats>,
/// Sender side of the cursor-flush channel. The consumer closure pushes
/// `event.time_us` every 100 events; a background task drains it and
/// writes the running maximum to `jetstream_cursor` at most once every
/// 500ms. The bound of 32 is plenty — the background task keeps up.
cursor_tx: mpsc::Sender<i64>,
}
impl IndexHandler {
pub fn new(db: PgPool, stats: Arc<Stats>, cursor_tx: mpsc::Sender<i64>) -> Self {
Self { db, stats, cursor_tx }
}
pub async fn handle(&self, ev: JetstreamEvent) -> Result<()> {
// Update per-event stats first so /healthz reflects liveness even
// when DB writes are slow.
self.stats.events_processed.fetch_add(1, Ordering::Relaxed);
let prev = self
.stats
.last_event_time_us
.fetch_max(ev.time_us, Ordering::Relaxed);
if ev.time_us < prev {
// Out-of-order event: keep the larger value but still try to
// process. (Jetstream delivers near-monotonically but it's
// not guaranteed.)
self.stats
.last_event_time_us
.store(prev, Ordering::Relaxed);
}
let ok: bool = match ev.kind.as_str() {
"commit" => match indexer::apply_commit(&self.db, &ev).await {
Ok(true) => true,
Ok(false) => {
debug!(kind = "commit", "apply_commit skipped event (unrecognized sub-collection)");
true // cursor can still advance — we "saw" the event
}
Err(e) => {
warn!(error = %e, kind = "commit", "apply_commit failed; NOT advancing cursor — expect reconnect replay");
false
}
},
"identity" => {
if let Err(e) = handle_identity(&self.db, &ev).await {
warn!(error = %e, did = %ev.did, "handle_identity failed");
return Ok(()); // don't advance cursor; let next replay retry
}
true
}
"account" => {
if let Err(e) = handle_account(&self.db, &ev).await {
warn!(error = %e, did = %ev.did, "handle_account failed");
return Ok(()); // don't advance cursor; let next replay retry
}
true
}
other => {
debug!(kind = %other, "ignoring event of unknown kind");
true
}
};
// ONLY advance the cursor when the event was processed or skipped
// legitimately. Failures leave the cursor pinned so reconnect replays
// the event.
if !ok {
return Ok(());
}
// Forward the cursor only every 100 events to avoid hammering the DB.
if self.stats.events_processed() % 100 == 0 {
let _ = self.cursor_tx.try_send(ev.time_us);
}
Ok(())
}
}
/// `identity` event — Jetstream tells us a DID's handle changed.
///
/// The Jetstream payload includes `identity.handle` (the *current*
/// handle, since the event fires after every handle change) and
/// optionally `identity.did` (the DID — redundant with the outer
/// `ev.did` but we accept both). We pull the handle out and run it
/// through `indexer::backfill_handle` so every existing post row for
/// that DID gets the new value. The COALESCE guard inside
/// `indexer::PostRow::from_record` keeps empty strings from
/// clobbering this backfilled value when a later `commit` event
/// arrives.
async fn handle_identity(db: &PgPool, ev: &JetstreamEvent) -> Result<()> {
let handle = extract_handle(&ev.identity).or_else(|| extract_handle(&ev.account));
let Some(handle) = handle else {
// Some identity events carry only a DID-doc rotation signal
// with no handle payload — those are uninteresting for our
// purpose. Advance the cursor anyway.
debug!(did = %ev.did, "identity event without a usable handle payload");
return Ok(());
};
let rows = indexer::backfill_handle(db, &ev.did, &handle).await?;
info!(
did = %ev.did,
handle = %handle,
rows_updated = rows,
"backfilled handle on posts"
);
Ok(())
}
/// `account` event — Jetstream tells us an account's active/deactive
/// status changed. We mirror the handle-backfill behaviour in case
/// the `account` payload carries the verified handle alongside
/// `active`; many real-world identities show the handle there even
/// when no `identity` event was emitted.
async fn handle_account(db: &PgPool, ev: &JetstreamEvent) -> Result<()> {
let Some(handle) = extract_handle(&ev.account) else {
return Ok(());
};
let rows = indexer::backfill_handle(db, &ev.did, &handle).await?;
info!(
did = %ev.did,
handle = %handle,
rows_updated = rows,
"backfilled handle on posts (account event)"
);
Ok(())
}
/// Pull a handle string out of a Jetstream event fragment. Returns
/// `None` if the fragment is absent or doesn't carry a usable
/// `handle` string field.
fn extract_handle(fragment: &Option<Value>) -> Option<String> {
fragment
.as_ref()
.and_then(|v| v.get("handle"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Spawn the background task that drains the cursor-flush channel and
/// writes the running maximum to the DB. Returns when the receiver is
/// dropped (i.e. the main process is shutting down).
pub fn spawn_cursor_flush(
db: PgPool,
mut rx: mpsc::Receiver<i64>,
stats: Arc<Stats>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut buf: Vec<i64> = Vec::with_capacity(128);
let mut interval = tokio::time::interval(Duration::from_millis(500));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
biased;
maybe = rx.recv() => {
match maybe {
Some(ts) => buf.push(ts),
None => {
// Channel closed — flush whatever's left and
// exit cleanly.
if !buf.is_empty() {
let max = buf.iter().copied().max().unwrap_or(0);
if let Err(e) = indexer::cursor_advance(&db, max).await {
warn!(error = %e, "final cursor flush failed");
} else {
stats.last_cursor_persisted_us.store(max, Ordering::Relaxed);
}
buf.clear();
}
return;
}
}
}
_ = interval.tick() => {
if buf.is_empty() { continue; }
let max = buf.iter().copied().max().unwrap_or(0);
if let Err(e) = indexer::cursor_advance(&db, max).await {
warn!(error = %e, "cursor flush failed");
} else {
stats.last_cursor_persisted_us.store(max, Ordering::Relaxed);
}
buf.clear();
}
}
}
})
}