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
This commit is contained in:
co-authored by
Claude Opus 5
parent
6fd046417a
commit
0646fbeebe
@@ -1,4 +1,5 @@
|
||||
use crate::appview_push::AppViewPushClient;
|
||||
use crate::firehose::Firehose;
|
||||
use at_blob::S3BlobStore;
|
||||
use at_identity::plc::PlcClient;
|
||||
use at_lexicon::{Lex, LexRegistry};
|
||||
@@ -16,6 +17,14 @@ pub struct AppState {
|
||||
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 {
|
||||
@@ -26,9 +35,9 @@ impl AppState {
|
||||
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).
|
||||
// The full atproto collection library is out of scope — for
|
||||
// anything else, callers pass `validate: false` in the
|
||||
// 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(),
|
||||
@@ -38,6 +47,17 @@ impl AppState {
|
||||
"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
|
||||
@@ -63,6 +83,7 @@ impl AppState {
|
||||
blockstore: Arc::new(MemoryBlockstore::new()),
|
||||
plc: PlcClient::new(plc_url),
|
||||
appview,
|
||||
firehose: Firehose::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user