Files
maarcadetweet/crates/pds-server/src/state.rs
T
tomdeboneandClaude Opus 5 0646fbeebe feat(pds): com.atproto.sync.subscribeRepos — lokaler Firehose
Bisher erreichten eigene Records die AppView nur über den Best-Effort-Push
/internal/ingest-commit. Ging der verloren (AppView kurz weg, Netzwerk-
fehler), war der Post dauerhaft weg: der öffentliche Jetstream kennt diese
PDS nicht, es gab also keinen zweiten Weg.

Jeder Commit schreibt sein Event in derselben Transaktion nach
firehose_events. Damit kann es keinen Commit ohne Event geben — und keine
Sequenz ohne Commit.

Die seq muss lückenfrei sein, sonst ist sie als Cursor wertlos: BIGSERIAL
vergibt Nummern bei INSERT, nicht bei COMMIT, also können zwei Schreiber 5
und 6 ziehen und in umgekehrter Reihenfolge sichtbar werden — ein Leser
dazwischen sieht 6, merkt sich das und erfährt von 5 nie. Ein globaler
pg_advisory_xact_lock unmittelbar vor dem INSERT erzwingt Commit-Reihenfolge
== seq-Reihenfolge. Er wird nach dem per-Repo-FOR-UPDATE genommen, überall in
derselben Reihenfolge, also ohne Deadlock-Risiko. Preis: das Ende jeder
schreibenden Transaktion ist global serialisiert; das steht im Modulkopf.

Der WebSocket-Handler abonniert den Broadcast, *bevor* er die Datenbank
liest, und filtert Live-Events auf seq > Wasserstand. Aus einem Rennen wird
so eine Dublette, die sich filtern lässt, statt einer Lücke, die es nicht
gibt. Ein zu langsamer Consumer bekommt #info/OutdatedCursor und fällt auf
den DB-Replay zurück, statt getrennt zu werden — die Events sind durabel,
also ist der Rückfall verlustfrei.

Frame-Hülle ist konformes DAG-CBOR mit Tag-42-Links (neues Modul dag_cbor,
aus car.rs herausgezogen statt dupliziert). Die Blöcke darin behalten die
Konvention dieses Repos: CIDs als Strings. Ein fremder Consumer liest die
Frames, scheitert aber an den Blockinhalten — das zu ändern hieße, jede CID
im System zu ändern, inklusive der did:plc-Ableitung. Steht so im Modulkopf.

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

90 lines
3.9 KiB
Rust

use crate::appview_push::AppViewPushClient;
use crate::firehose::Firehose;
use at_blob::S3BlobStore;
use at_identity::plc::PlcClient;
use at_lexicon::{Lex, LexRegistry};
use at_repo::blockstore::MemoryBlockstore;
use at_shared::config::AppConfig;
use sqlx::PgPool;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub cfg: AppConfig,
pub db: PgPool,
pub blob: S3BlobStore,
pub lex: Arc<LexRegistry>,
pub blockstore: Arc<MemoryBlockstore>,
pub plc: PlcClient,
pub appview: AppViewPushClient,
/// Live fan-out for `com.atproto.sync.subscribeRepos`.
///
/// Lives on the shared state rather than in the route module because the
/// *write* paths publish into it — `routes::helpers::apply_repo_write`
/// hands every committed event over here — while the WebSocket handler
/// only subscribes. Cloning `AppState` clones the sender, which is the
/// intended way to reach it from a handler.
pub firehose: Firehose,
}
impl AppState {
pub async fn new(cfg: AppConfig, db: PgPool, blob: S3BlobStore) -> Self {
let mut lex = LexRegistry::new();
lex.lexicons.insert(
"app.twi.post".to_string(),
Lex::from_json(include_str!("../../../lexicons/app/twi/post.json")).unwrap(),
);
// AT-Protocol standard collections: only the records the user
// might legitimately create server-side (feed.like, feed.repost,
// graph.follow). The full atproto collection library is out of
// scope — for anything else, callers pass `validate: false` in the
// createRecord body.
lex.lexicons.insert(
"app.bsky.feed.like".to_string(),
Lex::from_json(include_str!("../../../lexicons/app/bsky/feed/like.json")).unwrap(),
);
lex.lexicons.insert(
"app.bsky.feed.repost".to_string(),
Lex::from_json(include_str!("../../../lexicons/app/bsky/feed/repost.json")).unwrap(),
);
// Follow record. Its absence was a real outage: the desktop
// client creates follows through `createRecord`, which validates
// by default, so every follow came back
// `unknown lexicon: app.bsky.graph.follow` — the button could
// never have worked. `subject` is a bare DID string here, not a
// strongRef like like/repost use, matching what the client sends
// and what the AppView's `follow_subject_did` reads.
lex.lexicons.insert(
"app.bsky.graph.follow".to_string(),
Lex::from_json(include_str!("../../../lexicons/app/bsky/graph/follow.json")).unwrap(),
);
// Profile record — avatar/banner/display name/description.
// Validates the createRecord body when the Tauri client calls
// its setProfile command. Other fields stay optional so a
// brand-new account with an empty profile is legal.
lex.lexicons.insert(
"app.bsky.actor.profile".to_string(),
Lex::from_json(include_str!("../../../lexicons/app/bsky/actor/profile.json")).unwrap(),
);
let plc_url = cfg.plc_directory_url.clone();
// The PDS speaks to the AppView via the cluster-internal URL —
// never the public one, because the ingest endpoint is unauth'd
// in dev mode (and uses a shared secret in prod). The base URL
// is the same as `appview_public_url` in our single-host dev
// setup, but operators can override with `APPVIEW_INTERNAL_URL`.
let appview_url = std::env::var("APPVIEW_INTERNAL_URL")
.unwrap_or_else(|_| cfg.appview_public_url.clone());
let appview = AppViewPushClient::new(appview_url, cfg.appview_ingest_secret.clone());
Self {
cfg,
db,
blob,
lex: Arc::new(lex),
blockstore: Arc::new(MemoryBlockstore::new()),
plc: PlcClient::new(plc_url),
appview,
firehose: Firehose::new(),
}
}
}