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
+10 -5
View File
@@ -25,6 +25,7 @@ crates/tauri-app/ Tauri 2 + Svelte 5 + Vite + TS Desktop-Client
└── src-tauri/ Rust-IPC-Layer
lexicons/app/twi/post.json Custom Lexicon mit maxLength: 160
lexicons/app/bsky/ like, repost, follow, actor.profile
migrations/pds/ PDS-DB-Schema (users, repos, blobs, sessions, plc_ops)
migrations/appview/ AppView-DB-Schema (posts, likes, follows, notifications, profiles, jetstream_cursor)
docs/ Deployment, Architektur, Tauri-Release (siehe unten)
@@ -69,6 +70,7 @@ cargo run -p appview
| 7 Polish (Tray, Notifications, Auto-Update) | ✅ done — Tray-Icon custom (`tauri::include_image!`), Notification-Click navigiert via `app://notification`-Event + `openThread`-Helper zu Thread-Detail, Auto-Update in Dev inert (Production-Weg: [`docs/tauri-release.md`](docs/tauri-release.md)) |
| 8 Social-Graph + Benachrichtigungen | ✅ done — `notifications`-Tabelle, Schreibpfad im Jetstream-Indexer (idempotent, keine Selbst-Notifications), `/api/notifications[/count|/seen]`, `/api/followers`, `/api/following`, eigene `/api/thread`-Route; im Client Notifications-View mit Unread-Badge und klickbare Follower-/Following-Listen im Profil |
| 9 Auth + Performance | ✅ done — AppView prüft Bearer-Tokens (ES256, Schlüssel aus dem neuen `/.well-known/did.json` der PDS, fail closed); Timeline und Notifications nur noch für die eigene DID; CORS-Allowlist statt `Any`; Indizes für Handle-Lookup und Cold-Start-Feed |
| 10 Lokaler Firehose | ✅ done — `com.atproto.sync.subscribeRepos` auf der PDS (Event in derselben Transaktion wie der Commit, `seq`-Cursor mit lückenfreiem Replay, WebSocket-Frames in atproto-Form); die AppView konsumiert ihn mit persistiertem Cursor. Ein verlorener Push ist damit nicht mehr endgültig. |
## Tests
@@ -103,11 +105,14 @@ Root-Workspace; `cargo test --workspace` von oben erfasst den IPC-Layer nicht.
## Bekannte Lücken
* Die eigene PDS speist **keinen** Firehose (`com.atproto.sync.subscribeRepos` fehlt) —
eigene Records erreichen die AppView nur über den Best-Effort-Push
`POST /internal/ingest-commit`.
* `aud` wird beim Token-Check nicht validiert (Signatur, Ablauf, `scope` und
`sub` schon).
* Der Firehose ist **lokal**: er verbindet die eigenen zwei Dienste. Ein fremder
Relay erfährt von dieser PDS weiterhin nichts.
* Die Frame-Hülle ist spec-konformes DAG-CBOR, die Blöcke darin nicht: CIDs
innerhalb von Commit-Blöcken sind Strings statt Tag-42-Links. Ein fremder
atproto-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.
* `firehose_events` wird nie beschnitten.
* Notifications werden nie gelöscht: Unlike/Unfollow lässt die Zeile stehen, und der
Dedupe-Key macht sie „einmal pro (Empfänger, Autor, Art, Subject) für immer".
* Auto-Update ist nur dokumentiert, nicht verdrahtet: niemand ruft `check()` auf, das
+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
+38 -5
View File
@@ -287,7 +287,7 @@ Hinweise:
* Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop`
beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte
Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird —
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 9). Das ist
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 10). Das ist
unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit
zeigt und Events idempotent verarbeitet werden.
* Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf
@@ -344,7 +344,33 @@ Instanz hinter VPN und für die fail-open-Integrationstests. Die AppView warnt
beim Start in Großbuchstaben. Öffentlich erreichbar heißt das: jeder kann die
Notifications jeder DID lesen und als gelesen markieren.
## 7. Reverse-Proxy
## 7. Firehose
Die PDS liefert `com.atproto.sync.subscribeRepos` als WebSocket aus, die
AppView konsumiert ihn. Betrieblich wichtig:
* **Das Event liegt in derselben Transaktion wie der Commit.** Es kann keinen
Commit ohne Event geben und umgekehrt.
* **Die `seq` ist lückenfrei.** Ein globaler `pg_advisory_xact_lock` sorgt
dafür, dass Commit-Reihenfolge und `seq`-Reihenfolge übereinstimmen — sonst
könnte ein Consumer eine Nummer überspringen, die erst danach sichtbar wird,
und sie nie nachholen. Preis: das Ende jeder schreibenden Transaktion ist
über alle Accounts hinweg serialisiert.
* **Cursor:** `?cursor=<seq>` liefert alles mit `seq > cursor` aus der
Datenbank nach und geht dann nahtlos live weiter. Ohne Cursor nur live. Ein
Cursor aus der Zukunft ist ein Fehler-Frame, ein zu alter ein
`#info`/`OutdatedCursor`.
* **`firehose_events` wächst unbegrenzt.** Es gibt keine Retention. Beschneiden
ist sicher, weil ein zu alter Cursor sauber behandelt wird — wer die Tabelle
aufräumt, sollte aber wissen, wie weit die eigenen Consumer zurückhängen
dürfen (`pds_firehose_seq` in `/healthz` der AppView gegen `MAX(seq)`).
* **Reverse-Proxy:** die Route braucht ein WebSocket-Upgrade (`Upgrade`/
`Connection`-Header durchreichen) und einen Read-Timeout, der längere
Ruhephasen überlebt.
* `PDS_FIREHOSE_ENABLED=false` schaltet den Consumer in der AppView ab; lokale
Commits hängen dann wieder allein am Best-Effort-Push.
## 8. Reverse-Proxy
### PDS
@@ -462,7 +488,7 @@ HTTP-Aufrufe an PDS/AppView laufen über den Rust-IPC-Layer
(`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview —
die CSP muss also für neue Backend-URLs nicht angefasst werden.
## 8. Health-Checks und Logs
## 9. Health-Checks und Logs
### PDS
@@ -526,7 +552,7 @@ Log-Zeilen, auf die es sich lohnt zu achten:
| `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar |
| `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert |
## 9. Neustart-Verhalten
## 10. Neustart-Verhalten
**PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore
(`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent
@@ -569,7 +595,7 @@ nach — zuerst über die lokale PDS (`PdsHandleResolver`, 2 s Timeout), dann PL
bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der
Zustand ist reine Anzeigekosmetik.
## 10. Was noch offen ist
## 11. Was noch offen ist
* Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur
Postgres und MinIO ab. Es gibt kein Dockerfile im Repo.
@@ -581,3 +607,10 @@ Zustand ist reine Anzeigekosmetik.
einsetzt. Signatur, Ablauf, `scope` und `sub` werden geprüft.
* Notifications werden nie gelöscht; ein Unlike/Unfollow lässt die Zeile stehen.
* Kein Backfill-Werkzeug für Jetstream-Lücken.
* **Keine Retention für `firehose_events`.** Die Tabelle wächst mit jedem
Commit und wird nie beschnitten. Pruning ist sicher — ein Consumer mit zu
altem Cursor bekommt `#info`/`OutdatedCursor` und läuft ab der ältesten
überlebenden Zeile weiter — aber es gibt weder Job noch Policy dafür.
* Der globale Advisory-Lock, der die `seq`-Vergabe ordnet, serialisiert das
Ende jeder schreibenden Transaktion über alle Accounts hinweg. Das
begrenzt den Schreibdurchsatz auf ein COMMIT nach dem anderen.