docs: Firehose, Follow-Lexicon und Phase 10

architecture.md zeigt den subscribeRepos-Pfad im Diagramm (er stand zuerst
irrtümlich am Pfeil zum externen Relay) und erklärt, warum es Push *und*
Firehose gibt: der eine ist schnell, der andere verlässlich. Dazu, wo die
Spec-Treue endet — die Frame-Hülle ist konform, die Blöcke darin nicht.

deployment.md bekommt einen Firehose-Abschnitt: Transaktionsgarantie, warum
die seq lückenfrei ist und was der globale Advisory-Lock an Durchsatz
kostet, Cursor-Semantik, WebSocket-Upgrade im Proxy, und die fehlende
Retention für firehose_events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-10 07:08:46 +02:00
co-authored by Claude Opus 5
parent 6fbea4fe6f
commit b7ce114677
3 changed files with 87 additions and 27 deletions
+39 -17
View File
@@ -25,7 +25,8 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
│ /healthz │ │ GET /api/post|thread/*uri │
│ /.well-known/did.json ──────────┼───┼─▶ Schlüssel für 🔒 │
│ │ │ GET /api/notifications… 🔒 │
GET /api/followers|following
/xrpc/…sync.subscribeRepos ─────┼───┼─▶ pds_firehose.rs (WS)
│ (WebSocket, seq-Cursor) │ │ GET /api/followers|following│
│ │ │ GET /healthz │
│ at-lexicon Validierung (160) │ │ │
│ at-repo/at-mst MST + Commit │ │ indexer.rs Upserts │
@@ -41,13 +42,13 @@ auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
┌───────────┐ ┌────────┐ ┌─────────────────┐ │
│ Postgres │ │ MinIO │ │ Postgres │ │
│ pds :5434 │ │ :9100 │ │ appview :5435 │ │
└───────────┘ └────────┘ └─────────────────┘
│ (heute: kein eigener Firehose-Ausgang)
│ +firehose │ └────────┘ │ +cursor │
_events└─────────────────┘
└───────────┘
┌──────────────────────────────────────────┐ │
│ Jetstream-Relay (extern, WebSocket) │──────────────────┘
│ JETSTREAM_URL │ at-firehose
│ JETSTREAM_URL — kennt diese PDS nicht │ at-firehose
└──────────────────────────────────────────┘ JetstreamConsumer
```
@@ -56,27 +57,46 @@ entsprechen. Die AppView verifiziert die ES256-Signatur mit dem öffentlichen
Schlüssel, den die PDS in ihrem DID-Dokument veröffentlicht — `PDS_JWT_SECRET`
verlässt die PDS nie. Details in [`deployment.md`](deployment.md), Abschnitt 6.
Zwei Wege führen in die AppView, und das ist Absicht:
Drei Wege führen in die AppView, und das ist Absicht:
1. **Direkter Push (schnell, lokal).** Jeder erfolgreiche Commit auf der PDS
wird per `POST /internal/ingest-commit` an die AppView geschoben
(`crates/pds-server/src/appview_push.rs`). Best effort, 5 s Timeout, blockiert
den Record-Write nie. Damit sieht der Nutzer seinen eigenen Post sofort.
2. **Jetstream (global, verzögert).** `at-firehose::JetstreamConsumer` hängt an
2. **PDS-Firehose (lokal, garantiert).** Die PDS führt in derselben
Transaktion wie den Commit ein Event in `firehose_events` und liefert es
über `com.atproto.sync.subscribeRepos` als WebSocket aus
(`crates/pds-server/src/firehose.rs`). Die AppView konsumiert das mit
persistiertem Cursor (`crates/appview/src/pds_firehose.rs`).
3. **Jetstream (global, verzögert).** `at-firehose::JetstreamConsumer` hängt an
einem externen Jetstream-Relay und liefert alles, was in den konfigurierten
Collections weltweit passiert.
Wichtig für das Verständnis der Topologie: **die eigene PDS speist den
Jetstream nicht.** Es gibt keinen `com.atproto.sync.subscribeRepos`-Endpoint im
PDS-Router. Der Firehose-Weg ist ein reiner Konsum-Pfad für fremde Repos; die
eigenen Records erreichen die AppView ausschließlich über den Push aus
Punkt 1 (noch offen).
Warum 1 **und** 2: Der Push ist der schnelle Weg, der Firehose der
verlässliche. Ein verlorener Push (AppView kurz weg, Netzwerkfehler) war
früher endgültig — der öffentliche Jetstream kennt diese PDS nicht, also
wäre der Post nie angekommen. Jetzt holt der Cursor-Replay ihn nach.
Dass beide Wege denselben Commit liefern, ist unkritisch: die Indexer-Pfade
sind Upserts, und der Dedupe-Index der Notifications fängt den Rest.
Wichtig für das Verständnis der Topologie bleibt: **die eigene PDS speist den
*öffentlichen* Jetstream nicht.** Weg 2 ist ein lokaler Firehose zwischen den
eigenen zwei Diensten; ein fremder Relay erfährt von dieser PDS weiterhin
nichts.
Zur Spec-Treue: Die Frame-Hülle ist konformes DAG-CBOR mit Tag-42-CID-Links.
Die Blöcke *darin* tragen die Konvention dieses Codebases — CIDs innerhalb von
Commit-Blöcken sind Strings, nicht Links (`at-repo/src/commit.rs`). Ein
fremder atproto-Consumer kann die Frames also lesen, scheitert aber beim
Validieren der Blockinhalte. Das zu ändern hieße, die Blockkodierung zu
ändern, und damit ändern sich sämtliche CIDs inklusive der
`did:plc:`-Ableitung — eine eigene, bewusste Migration.
## Crates
| Crate | Typ | Aufgabe |
|---|---|---|
| `at-lexicon` | lib | Lexicon-Schemas laden (`Lex::from_json`) und Records validieren. `LexRegistry` in der PDS kennt `app.twi.post` (160 Zeichen), `app.bsky.feed.like`, `app.bsky.feed.repost`, `app.bsky.actor.profile` — alle vier per `include_str!` einkompiliert |
| `at-lexicon` | lib | Lexicon-Schemas laden (`Lex::from_json`) und Records validieren. `LexRegistry` in der PDS kennt `app.twi.post` (160 Zeichen), `app.bsky.feed.like`, `app.bsky.feed.repost`, `app.bsky.graph.follow`, `app.bsky.actor.profile` — alle per `include_str!` einkompiliert |
| `at-crypto` | lib | secp256k1/P-256-Keypairs, DAG-CBOR-CIDs, multibase/base58btc, JWT (`issue_jwt` / `verify_jwt`), PLC-Operationen inkl. `did_plc_from_op` |
| `at-identity` | lib | Handle- und DID-Auflösung. Drei Resolver hinter dem Trait `DidHandleResolver`: `PlcClient` (PLC-Directory), `WebResolver` (`.well-known/did.json`), `PdsHandleResolver` (fragt die lokale PDS) |
| `at-mst` | lib | Merkle-Search-Tree: Knoten, `split_around`, `wrap_with_split`, spec-konformes `encode_key` |
@@ -164,9 +184,11 @@ Alle Schreibpfade sind Upserts, das Replay nach einem Reconnect ist damit
unschädlich. Fehlerhafte Events rücken den Cursor **nicht** vor.
`ingest.rs` bedient denselben Indexer über HTTP, mit den Aktionen
`create` / `delete`; für `app.bsky.graph.follow`-Deletes braucht der Aufrufer
`subject_did` im Body, weil der Record-Wert bei Deletes nicht garantiert
mitkommt.
`create` / `delete`. Für `app.bsky.graph.follow`-Deletes gibt es zwei Wege:
der Push schickt `subject_did` im Body mit, der Firehose kennt nur `did` +
`rkey` — deshalb speichert `follows` seit Migration 0011 den rkey des
Follow-Records und löst darüber auf. Ohne den rkey war ein Unfollow über den
Firehose nicht anwendbar und hing allein am Push.
## Datenbanken und Tabellen