deployment.md bekommt einen eigenen Abschnitt zur Authentifizierung (Schlüsselweg, geschützte Endpoints, Fehlercodes, der Schalter für VPN-Instanzen) und eine CORS-Beschreibung, die die Allowlist statt des alten Wildcards erklärt — inklusive der Tauri-Origins, die sonst am Preflight scheitern. Im README steht jetzt der Hinweis, der diese Runde am meisten gekostet hat: ohne DATABASE_URL_APPVIEW in der Umgebung überspringen sich die DB-Tests selbst und `cargo test --workspace` meldet grün, ohne sie ausgeführt zu haben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
25 KiB
Deployment
Wie der Stack real betrieben wird: zwei Rust-Binaries (pds-server, appview),
zwei Postgres-Datenbanken und ein MinIO-Bucket-Paar. Der Tauri-Client wird
separat gebaut (siehe tauri-release.md), der Aufbau der
Komponenten steht in architecture.md.
Voraussetzungen
| Komponente | Version | Woher belegt |
|---|---|---|
| Rust-Toolchain | ≥ 1.80 | rust-version in Cargo.toml |
| Docker + Compose | aktuell | docker-compose.yml |
| PostgreSQL | 16 | postgres:16-alpine im Compose-File |
| MinIO | latest | minio/minio im Compose-File |
| Node + npm | Node 20, npm 10.8.2 | packageManager in crates/tauri-app/package.json — nur für den Desktop-Client nötig |
Ein C-Compiler/Linker wird für rusqlite (Feature bundled) und die
Crypto-Crates gebraucht.
1. Infrastruktur starten
docker compose up -d
Das Compose-File startet vier Container:
| Container | Dienst | Host-Port | Zugangsdaten (Default) |
|---|---|---|---|
maarcadetweet-pds-db |
Postgres für die PDS | 5434 → 5432 |
pds / pds, DB pds |
maarcadetweet-appview-db |
Postgres für die AppView | 5435 → 5432 |
appview / appview, DB appview |
maarcadetweet-minio |
S3-kompatibler Blob-Store | 9100 (API), 9101 (Konsole) |
minioadmin / minioadmin |
maarcadetweet-minio-init |
Einmal-Job | — | legt die Buckets an und beendet sich |
minio-init legt maarcadetweet-pds und maarcadetweet-appview an und setzt
beide auf anonymous download. Das ist kein Zufall: at-blob liest Blobs über
ein einfaches GET ${endpoint}/${bucket}/${key} ohne Signatur.
Alle drei Langläufer haben Healthchecks (pg_isready bzw.
/minio/health/live), Zustand prüfen mit docker compose ps.
Für Produktion ändern: Die Passwörter im Compose-File sind Dev-Defaults und
stehen im Klartext. Wer den Stack öffentlich betreibt, ersetzt sie (und die
korrespondierenden Werte in .env) oder betreibt Postgres/MinIO ganz außerhalb
von Compose. Die Ports 5434/5435/9100/9101 sind auf allen Interfaces gemappt —
in Produktion auf 127.0.0.1: binden.
2. Migrationen
Migrationen laufen automatisch beim Start der Binaries. Es gibt keinen
separaten Migrationsschritt und kein Skript in scripts/ (das Verzeichnis ist
leer).
crates/pds-server/src/main.rs:sqlx::migrate!("../../migrations/pds").run(&db)crates/appview/src/main.rs:sqlx::migrate!("../../migrations/appview").run(&db)
sqlx::migrate! ist ein Makro: die .sql-Dateien werden zur Compile-Zeit in
das Binary eingebettet. Konsequenzen für den Betrieb:
- Eine neue Migration wird erst nach einem Rebuild wirksam — das Binary allein neu zu starten reicht nicht.
- Der Migrationsstand liegt in der Tabelle
_sqlx_migrationsder jeweiligen DB. Wird eine bereits angewendete Datei nachträglich geändert, bricht der Start mit einem Checksum-Fehler ab. - Die Datenbanken müssen die Extensions
uuid-ossp,pgcrypto(PDS) bzw.uuid-ossp,pg_trgm(AppView) anlegen dürfen (CREATE EXTENSIONin0001_init.sql). Beim offiziellen Postgres-Image ist der Compose-User Superuser; bei managed Postgres müssen die Extensions vorab freigeschaltet sein. migrations/appview/0001_init.sqllegt die Cursor-Zeile an (INSERT INTO jetstream_cursor (id, cursor) VALUES (1, 0)) — ohne sie fällt die AppView auf Cursor0zurück und startet ohne Resume.
Maßgeblich ist immer der Inhalt von migrations/pds/ bzw.
migrations/appview/ — beim Schreiben dieses Dokuments 0001_init und
0002_blob_mime auf der PDS-Seite, 0001_init bis 0008_notifications auf
der AppView-Seite.
Die Dateinamen folgen dem <version>_<name>.sql-Schema von sqlx, ein manueller
Lauf mit sqlx-cli (sqlx migrate run --source migrations/appview) ist damit
prinzipiell kompatibel. sqlx-cli ist aber nicht Teil des Workspaces und
wird nirgends im Code oder in CI verwendet — der dokumentierte Weg bleibt der
automatische Lauf beim Start.
Im Code gibt es keine sqlx::query!-Makros (nur die Laufzeit-Varianten
sqlx::query / query_as / query_scalar). Für den Build wird also keine
erreichbare Datenbank und kein .sqlx-Offline-Cache gebraucht.
3. Umgebungsvariablen
cp .env.example .env und anpassen — das reicht für den Dev-Betrieb. Beide
Binaries rufen als erstes in main() dotenvy::dotenv() auf (dotenvy ist
Workspace-Dependency und in crates/pds-server wie crates/appview
eingebunden) und laden damit eine .env aus dem Arbeitsverzeichnis bzw. den
übergeordneten Verzeichnissen. Echte Umgebungsvariablen gewinnen über die
Werte aus der Datei; ist keine .env vorhanden, passiert schlicht nichts.
Im Serverbetrieb ist die Datei damit optional: systemd (EnvironmentFile=)
oder die Container-Runtime setzen die Variablen direkt, und sie überschreiben
eine versehentlich mitdeployte .env.
Pflicht (fehlt eine, bricht AppConfig::from_env() mit missing env: X ab)
Quelle: crates/at-shared/src/config.rs. Beide Binaries lesen dieselbe
AppConfig, also braucht auch die AppView die PDS-Variablen und umgekehrt.
| Variable | Bedeutung |
|---|---|
PDS_HOST |
Listen-Adresse der PDS (format!("{host}:{port}") → TcpListener::bind) |
PDS_PORT |
Listen-Port der PDS, u16 |
PDS_PUBLIC_URL |
Öffentliche Basis-URL der PDS. Geht in den DID-Doc-serviceEndpoint (routes/auth.rs), in den JWT-iss (jwt_issuer.rs), als public_base in den Blob-Store und als Fallback für PDS_INTERNAL_URL |
PDS_HANDLE_DNS_ZONE |
Handle-Suffix inkl. führendem Punkt, z. B. .maarcadetweet.local. Wird in com.atproto.identity.resolveHandle beim Suffix-Matching benutzt und (ohne Punkt) als availableUserDomains in describeServer gemeldet |
PDS_JWT_SECRET |
Hex-String (≥ 32 Bytes, gültiger P-256-Skalar), aus dem der Serverschlüssel für die Access-/Refresh-JWTs abgeleitet wird — siehe Fallstricke unten |
APPVIEW_HOST |
Listen-Adresse der AppView |
APPVIEW_PORT |
Listen-Port der AppView, u16 |
APPVIEW_PUBLIC_URL |
Öffentliche Basis-URL der AppView; die PDS benutzt sie als Fallback-Ziel für den Ingest-Push |
JETSTREAM_URL |
WebSocket-URL des Jetstream-Relays |
JETSTREAM_COLLECTIONS |
Komma-separierte Liste der Collections im subscribe-Filter. Leere Einträge werden verworfen |
DATABASE_URL_PDS |
Postgres-DSN der PDS-DB |
DATABASE_URL_APPVIEW |
Postgres-DSN der AppView-DB |
S3_ENDPOINT |
Basis-URL des Blob-Stores (MinIO) |
S3_REGION |
Region-String; wird im aktuellen at-blob nur mitgeführt, nicht signiert |
S3_ACCESS_KEY |
MinIO-Access-Key |
S3_SECRET_KEY |
MinIO-Secret-Key |
S3_BUCKET_PDS |
Bucket, in den die PDS Blobs schreibt |
S3_BUCKET_APPVIEW |
Bucket für die AppView — Pflichtfeld in der Config, wird aber von keinem Code-Pfad gelesen |
PLC_DIRECTORY_URL |
PLC-Directory. Die PDS submittet dort neue did:plc:-Ops (best effort), die AppView löst darüber Handles auf |
Optional
| Variable | Default | Bedeutung |
|---|---|---|
PDS_INTERNAL_URL |
PDS_PUBLIC_URL |
Cluster-interne PDS-URL, die die AppView für ihren PdsHandleResolver nutzt |
APPVIEW_INTERNAL_URL |
APPVIEW_PUBLIC_URL |
Cluster-interne AppView-URL für den Commit-Push der PDS (crates/pds-server/src/state.rs) |
APPVIEW_INGEST_SECRET |
unset | Shared Secret für POST /internal/ingest-commit. Unset = Dev-Modus, der Endpoint akzeptiert anonyme Requests. Gesetzt = Header X-Ingest-Secret muss (konstantzeitverglichen) passen. Muss auf beiden Seiten identisch gesetzt sein |
APPVIEW_HANDLE_SYNC_INTERVAL_SECS |
300 |
Intervall des Handle-Sync-Workers. Nicht parsbare Werte fallen still auf den Default zurück |
RUST_LOG |
info |
tracing_subscriber::EnvFilter::try_from_default_env() in beiden Binaries und im Tauri-Client |
Nur für den Desktop-Client
Gelesen in crates/tauri-app/src-tauri/src/lib.rs:
| Variable | Default |
|---|---|
MAARCADETWEET_PDS_URL |
http://127.0.0.1:2583 |
MAARCADETWEET_APPVIEW_URL |
http://127.0.0.1:2584 |
Nur für Tests
Die Integrationstests in crates/appview/tests/ und
crates/pds-server/tests/ lesen DATABASE_URL_APPVIEW bzw. DATABASE_URL_PDS
direkt und überspringen sich selbst, wenn die Variable fehlt.
Fallstricke
.env.example deckt inzwischen alle Variablen ab, die der Code liest — auch
die optionalen (PDS_INTERNAL_URL, APPVIEW_INTERNAL_URL,
APPVIEW_HANDLE_SYNC_INTERVAL_SECS, APPVIEW_INGEST_SECRET,
MAARCADETWEET_PDS_URL, MAARCADETWEET_APPVIEW_URL), die dort auskommentiert
mit ihrem Default stehen. Zwei Punkte bleiben trotzdem erklärungsbedürftig:
-
PDS_JWT_SECRETist kein beliebiger String, sondern ein P-256-Skalar.jwt_issuer.rs::server_p256_keypairmachthex::decode(secret.trim_start_matches("0x")), verlangt ≥ 32 Bytes und gibt die ersten 32 Bytes anp256::SecretKey::from_bytes(). Der Wert muss also Hex sein und ein gültiger Skalar der P-256-Kurve — lauter Nullen (oder ein Wert ≥ Gruppenordnung) wird abgelehnt.Der Fehler zeigt sich spät und breit: Der Prozess startet normal, weil der Schlüssel erst beim ersten Zugriff abgeleitet wird. Danach antwortet aber alles mit
500, wasserver_p256_public_multibasebzw.server_p256_keypairbenutzt — nicht nurcreateAccount/createSession/refreshSession(routes/auth.rs), sondern auch jeder Record-Write, weil dort der Access-Token gegen genau diesen Serverschlüssel verifiziert wird:routes/repo.rs,routes/feed.rs,routes/blob.rs,routes/profile.rs..env.exampleenthält einen gültigen Zufallswert als Beispiel. Für jede echte Instanz einen eigenen erzeugen:openssl rand -hex 32 -
S3_BUCKET_APPVIEWist Pflicht, aber ungenutzt.AppConfigverlangt die Variable, kein Code-Pfad greift aufcfg.s3_bucket_appviewzu. Der Wert muss trotzdem gesetzt sein, sonst startet keins der Binaries. (In.env.exampleist das entsprechend vermerkt.)
Beachtenswert bleibt außerdem JETSTREAM_COLLECTIONS: Die Liste in
.env.example enthält jetzt alle Collections, die der Indexer kennt —
app.twi.post (das eigene 160-Zeichen-Lexicon, POST_COLLECTIONS in
crates/appview/src/indexer.rs), app.bsky.feed.post, app.bsky.feed.like,
app.bsky.feed.repost, app.bsky.graph.follow und
app.bsky.actor.profile. Wird app.twi.post daraus entfernt, tauchen eigene
Posts nur noch über den direkten PDS→AppView-Push auf, nicht über den
Firehose-Weg.
4. Release-Build
cargo build --release -p pds-server -p appview
Ergebnis: target/release/pds-server und target/release/appview. Das
Release-Profil im Workspace-Cargo.toml setzt lto = "thin",
codegen-units = 1, opt-level = 3, strip = true — die Binaries sind ohne
Debug-Symbole, für Crash-Analysen also vorher separat sichern.
Da die Migrationen einkompiliert werden, gehören migrations/ und die
lexicons/*.json (via include_str! in crates/pds-server/src/state.rs) zum
Build-Input, nicht zum Laufzeit-Deployment. Auf dem Zielhost muss nur das
Binary liegen.
5. systemd
Beispiel für einen Host, auf dem die Binaries unter /opt/maarcadetweet/bin
liegen und /etc/maarcadetweet/env die Variablen aus Abschnitt 3 enthält
(Modus 0600, wegen PDS_JWT_SECRET und APPVIEW_INGEST_SECRET).
/etc/systemd/system/maarcadetweet-pds.service:
[Unit]
Description=maarcadetweet PDS
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=maarcadetweet
Group=maarcadetweet
EnvironmentFile=/etc/maarcadetweet/env
ExecStart=/opt/maarcadetweet/bin/pds-server
Restart=on-failure
RestartSec=5
# Logs gehen über tracing auf stdout/stderr ins Journal
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
/etc/systemd/system/maarcadetweet-appview.service:
[Unit]
Description=maarcadetweet AppView
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=maarcadetweet
Group=maarcadetweet
EnvironmentFile=/etc/maarcadetweet/env
ExecStart=/opt/maarcadetweet/bin/appview
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
Hinweise:
- Beide Units brauchen dieselbe
EnvironmentFile— beide Binaries bauen die vollständigeAppConfig. dotenvy::dotenv()sucht die.envab dem Arbeitsverzeichnis aufwärts. OhneWorkingDirectory=ist das unter systemd/, es wird also keine gefunden — dieEnvironmentFileist die einzige Quelle. Läge doch eine.envim Arbeitsverzeichnis, hätten die systemd-Variablen trotzdem Vorrang.Type=simpleist korrekt: keins der Binaries forkt oder meldet Readiness. EineType=notify-Integration existiert nicht.- Es gibt keinen Signal-Handler für graceful Shutdown.
systemctl stopbeendet 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 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 demselben Host laufen. Die AppView bricht beim Start ab, wenn ihre DB nicht erreichbar ist (PgPool::connectohne Retry); die PDS nutztPgPoolOptionsmitacquire_timeout(10s), bricht aber ebenfalls ab, wenn der erste Connect scheitert.Restart=on-failurefängt das ab.
6. Authentifizierung
Die AppView prüft seit Phase 9 Bearer-Tokens. Wie das zusammenhängt:
- Die PDS stellt beim Login ein ES256-Access-JWT aus (
sub= DID,scope=com.atproto.access, eine Stunde gültig). - Die PDS veröffentlicht den öffentlichen Teil ihres P-256-Schlüssels unter
GET /.well-known/did.json.PDS_JWT_SECRETverlässt den PDS-Prozess nicht. - Die AppView holt dieses Dokument beim Start von
PDS_INTERNAL_URL(Fallback:PDS_PUBLIC_URL), cached den Schlüssel und lädt ihn bei einem Verifikationsfehler einmal nach — höchstens einmal pro Minute, damit Müll-Tokens kein Werkzeug werden, die PDS zu fluten. Ein Schlüsselwechsel braucht also keinen Neustart der AppView.
Ist die PDS beim Start nicht erreichbar, warnt die AppView nur und startet
trotzdem — sie indiziert den Firehose, was von der lokalen PDS unabhängig ist.
Der Schlüssel wird dann beim ersten authentifizierten Request geholt. Klappt
auch das nicht, antwortet sie 503 AuthUnavailable: fail closed, nie
fail open.
Welche Endpoints
| Endpoint | Zugriff |
|---|---|
/api/timeline/home, /api/notifications, /api/notifications/count, /api/notifications/seen |
Token nötig, sub muss dem did-Parameter entsprechen |
/api/profile*, /api/search, /api/post/*, /api/thread*, /api/followers, /api/following |
öffentlich (in AT Proto öffentliche Records) |
/internal/ingest-commit |
APPVIEW_INGEST_SECRET, server-zu-server |
Fehlercodes
| Fall | Status | error |
|---|---|---|
| Header fehlt oder ist kein Bearer | 401 | AuthMissing |
Signatur falsch, abgelaufen, falscher scope |
401 | TokenInvalid |
Token gültig, aber sub ≠ did |
403 | Forbidden |
| Schlüssel der PDS nicht beschaffbar | 503 | AuthUnavailable |
TokenInvalid ist ein Vertrag mit dem Desktop-Client: daran erkennt er, dass
er sein Access-JWT erneuern und den Request einmal wiederholen muss. Wer den
Code umbenennt, loggt jeden Nutzer eine Stunde nach dem Login aus.
APPVIEW_AUTH_REQUIRED=false
Schaltet die Prüfung ab und stellt das alte Verhalten her — gedacht für eine 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
PDS
Die PDS muss öffentlich erreichbar sein, damit andere AT-Protocol-Dienste und
der Client sie ansprechen können. Routen aus crates/pds-server/src/main.rs:
GET /
GET /healthz
GET /xrpc/com.atproto.server.describeServer
POST /xrpc/com.atproto.server.createAccount
POST /xrpc/com.atproto.server.createSession
POST /xrpc/com.atproto.server.refreshSession
POST /xrpc/com.atproto.identity.resolveHandle
POST /xrpc/com.atproto.repo.createRecord
POST /xrpc/com.atproto.repo.deleteRecord
POST /xrpc/com.atproto.feed.like.create
POST /xrpc/com.atproto.uploadBlob
GET /xrpc/com.atproto.sync.getRepo
GET /xrpc/com.atproto.sync.getBlocks
GET /xrpc/com.atproto.sync.getLatestCommit
GET /xrpc/com.atproto.sync.getRecord
GET /xrpc/com.atproto.sync.listRepos
GET /xrpc/com.atproto.sync.getBlob
GET /xrpc/app.bsky.actor.profile.get
POST /xrpc/app.bsky.actor.profile.set
GET /blob/:cid
nginx-Skizze:
server {
listen 443 ssl http2;
server_name pds.example.org;
# TLS-Konfiguration hier
location / {
proxy_pass http://127.0.0.1:2583;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# uploadBlob: MAX_BLOB_SIZE in routes/blob.rs ist 1 MiB.
# Der Proxy darf nicht enger sein, sonst kommt statt der
# XRPC-Fehlerantwort ein nginx-413 beim Client an.
client_max_body_size 1m;
}
}
PDS_PUBLIC_URL muss auf genau diese öffentliche URL zeigen — der Wert landet
im DID-Doc-serviceEndpoint, im JWT-iss und als Basis der Blob-URLs.
Noch offen: Die PDS liefert selbst kein /.well-known/did.json und
kein /.well-known/atproto-did aus (im Router nicht vorhanden). at-identity
kann solche Dokumente auflösen, aber wer did:web-Handles auf dieser PDS
betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern.
Die Service-DID wird inzwischen aus PDS_PUBLIC_URL abgeleitet
(AppConfig::pds_did(), did:web mit %3A-kodiertem Port) und von
describeServer und /.well-known/did.json identisch ausgeliefert. Das
heißt auch: ändert sich PDS_PUBLIC_URL, ändert sich die Service-DID.
AppView
Die AppView setzt ihr CORS selbst (cors_layer() in
crates/appview/src/routes.rs). Ohne APPVIEW_CORS_ORIGINS bleibt es beim
alten Access-Control-Allow-Origin: * — die AppView warnt dann beim Start.
Mit gesetzter Variable gilt eine Allowlist:
APPVIEW_CORS_ORIGINS=tauri://localhost,http://tauri.localhost,http://127.0.0.1:1430
Der Tauri-Webview ruft die AppView von einem anderen Origin aus auf — im Dev
die Vite-Adresse, im Bundle tauri://localhost (macOS/Linux) bzw.
http://tauri.localhost (Windows). Alle drei gehören in die Liste, sonst
scheitert der Preflight.
/internal/ingest-commit liegt bewusst außerhalb der CORS-Schicht: die
Route wird von der PDS server-zu-server aufgerufen, nie von einem Browser. Ein
Access-Control-Allow-Origin darauf würde ausschließlich einer Webseite
helfen, in den Index zu schreiben.
Für ein öffentliches Deployment heißt das:
POST /internal/ingest-commitgehört nicht ins Internet. Entweder im Proxy blocken (location /internal/ { deny all; }) oder die AppView nur auf dem internen Interface lauschen lassen und den Public-Vhost nur auf/api/und/healthzmappen.APPVIEW_INGEST_SECRETsetzen, sobald die AppView irgendwie erreichbar ist — ohne das Secret ist der Ingest-Endpoint unauthentifiziert.- Wer den Wildcard-Origin einschränken will, muss den Header im Proxy überschreiben, nicht nur ergänzen — sonst stehen zwei Werte drin:
location /api/ {
proxy_pass http://127.0.0.1:2584;
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin "tauri://localhost" always;
}
location /internal/ { deny all; }
Eine Konfigurierbarkeit der erlaubten Origins über eine Umgebungsvariable gibt es im Code nicht — das ist noch offen.
CSP im Client
tauri.conf.json setzt connect-src 'self' ipc: http://ipc.localhost. Die
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
PDS
GET /healthz → {"ok": true}
GET / → {"name":"maarcadetweet-pds","version":"0.1.0"}
/healthz ist ein reiner Liveness-Probe ohne DB-Zugriff — ein 200 bedeutet
nicht, dass Postgres erreichbar ist.
Beim Start pingt die PDS zusätzlich MinIO (S3BlobStore::ping, ein HEAD auf
${S3_ENDPOINT}/${S3_BUCKET_PDS}; 2xx und 404 gelten als erreichbar). Ein
Fehlschlag ist kein Startabbruch, sondern eine Warnung:
s3 ping failed at startup; uploadBlob will serve from local blockstore only
AppView
GET /healthz → {"ok":true,"lag_ms":…,"events_processed":…,"jetstream_connected":…}
GET / → {"name":"maarcadetweet-appview","version":"0.1.0"}
Diese drei Felder sind der eigentliche Monitoring-Hook:
jetstream_connected—falseheißt: WebSocket ist weg, der Consumer reconnectet mit Backoff (1 s, verdoppelnd bismax_backoff_secs = 30).events_processed— monoton steigender Zähler. Stagniert er beijetstream_connected: true, kommt nichts an.lag_ms— Differenz zwischen lokaler Uhr undtime_usdes letzten Events, auf0geklemmt, wenn negativ.0heißt also auch „noch kein Event gesehen".
Logs
Beide Binaries loggen über tracing_subscriber::fmt() mit
EnvFilter::try_from_default_env() auf stdout/stderr, Fallback-Filter info.
Unter systemd landet alles im Journal:
journalctl -u maarcadetweet-pds -f
journalctl -u maarcadetweet-appview -f
Empfehlung für den Betrieb (aus .env.example abgeleitet, sqlx sonst sehr
gesprächig):
RUST_LOG=info,sqlx=warn
Log-Zeilen, auf die es sich lohnt zu achten:
| Meldung | Bedeutung |
|---|---|
jetstream error: … / reconnecting in Ns |
Firehose-Verbindung verloren |
apply_commit failed; NOT advancing cursor |
Indexer-Fehler, Event wird beim nächsten Replay erneut versucht |
cursor flush failed |
Cursor-Schreibvorgang auf die DB schlug fehl |
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
PDS. Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore
(MemoryBlockstore in state.rs) wird beim Start neu aufgebaut; persistent
sind repo_blocks, repos, users, blobs, sessions, plc_ops in der
PDS-DB. Migrationen laufen bei jedem Start (idempotent über
_sqlx_migrations).
AppView — Jetstream-Cursor. Der Fortschritt liegt in der Tabelle
jetstream_cursor (genau eine Zeile, id = 1, Spalte cursor =
Mikrosekunden seit Epoch). Der Ablauf:
- Beim Start liest
indexer::cursor_getdie Zeile. Ist der Wert> 0, wird der Consumer mitwith_cursor(...)gebaut und schicktcursorimsubscribe-Options-Frame an Jetstream — die Verbindung setzt dort fort statt am Live-Ende. Bei0startet er ohne Cursor. - Der Handler zählt jedes Event. Nur jedes 100. Event schickt er einen Tick
in einen
mpsc-Kanal (Kapazität 32,try_send— bei vollem Kanal wird der Tick verworfen). - Ein Hintergrund-Task sammelt die Ticks und schreibt alle 500 ms das Maximum
per
UPDATE jetstream_cursor SET cursor = GREATEST(cursor, $1). Der Cursor kann dadurch nie zurückspringen. - Schlägt
apply_commitfehl, wird der Cursor für dieses Event nicht vorgerückt — der Reconnect liefert es erneut.
Praktische Folge: Nach einem Neustart werden bis zu ~100 Events (plus die letzten 500 ms) erneut verarbeitet. Alle Indexer-Schreibpfade sind als Upserts gebaut, das Replay ist also unschädlich. Umgekehrt gilt: War die AppView länger weg als Jetstreams Backfill-Fenster, sind die Events dazwischen verloren; ein Backfill-Werkzeug dafür existiert nicht (noch offen).
Eigene Posts sind vom Cursor unabhängig. Die PDS pusht jeden lokalen Commit
direkt an POST /internal/ingest-commit. Der Push ist best effort mit 5 s
Timeout und blockiert den Record-Write nie — fällt er aus, kommt der Record
später über Jetstream (sofern die Collection in JETSTREAM_COLLECTIONS steht,
siehe Abschnitt 3).
Handle-Sync. Läuft als eigener Task alle
APPVIEW_HANDLE_SYNC_INTERVAL_SECS Sekunden und füllt leere posts.handle
nach — zuerst über die lokale PDS (PdsHandleResolver, 2 s Timeout), dann PLC
bzw. did:web. Nach einem Neustart holt der erste Durchlauf das nach; der
Zustand ist reine Anzeigekosmetik.
10. 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. at-blobspricht ausschließlich MinIO ohne Signature V4 (siehe Modul-Doku incrates/at-blob/src/s3.rs); echtes AWS S3 funktioniert damit nicht.- Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe.
audwird beim Token-Check nicht validiert (verify_jwtsetztvalidate_aud = false), obwohl die PDSdid:web:appview.maarcadetweet.localeinsetzt. Signatur, Ablauf,scopeundsubwerden geprüft.- Notifications werden nie gelöscht; ein Unlike/Unfollow lässt die Zeile stehen.
- Kein Backfill-Werkzeug für Jetstream-Lücken.