Files
maarcadetweet/crates/pds-server/tests/firehose_integration.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

678 lines
24 KiB
Rust

//! Integration tests for `com.atproto.sync.subscribeRepos`.
//!
//! Same contract as the other integration suites in this crate: they talk to a
//! PDS listening on `127.0.0.1:2583` and **fail open** — if nothing answers
//! `/healthz`, the test prints a note and returns green rather than failing a
//! developer's `cargo test` on a machine with no server running. Start the
//! server (`./target/debug/pds-server` with `.env` sourced) to actually
//! exercise them.
//!
//! ## Why the frames are decoded by hand here
//!
//! `pds-server` is a binary, so a test crate cannot import its `dag_cbor`
//! module — and that is a feature, not a limitation. These tests are the
//! *consumer* side of the wire contract, and a consumer that reuses the
//! producer's encoder proves nothing: it would happily agree with a frame
//! that no other implementation can read. The decoder below is written from
//! the spec (tag 42, identity prefix, length-first map keys) and knows
//! nothing about how the server produced the bytes.
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::time::Duration;
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
const PDS_URL: &str = "http://127.0.0.1:2583";
const PDS_WS: &str = "ws://127.0.0.1:2583";
// -- harness ---------------------------------------------------------------
fn http() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap()
}
async fn wait_for_pds() -> bool {
let c = http();
for _ in 0..20 {
if let Ok(r) = c.get(format!("{PDS_URL}/healthz")).send().await {
if r.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
false
}
async fn fresh_user(prefix: &str) -> (reqwest::Client, String, String) {
let c = http();
let handle = format!(
"{}_{}.maarcadetweet.local",
prefix,
uuid::Uuid::new_v4().simple()
);
let acc: Value = c
.post(format!("{PDS_URL}/xrpc/com.atproto.server.createAccount"))
.json(&json!({"handle": handle, "password": "hunter2hunter2"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let did = acc["did"].as_str().expect("createAccount did").to_string();
let jwt = acc["access_jwt"].as_str().expect("access_jwt").to_string();
(c, did, jwt)
}
async fn create_post(c: &reqwest::Client, did: &str, jwt: &str, text: &str) -> Value {
c.post(format!("{PDS_URL}/xrpc/com.atproto.repo.createRecord"))
.bearer_auth(jwt)
.json(&json!({
"repo": did,
"collection": "app.twi.post",
"record": { "text": text, "createdAt": "2026-09-10T12:00:00Z" },
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap()
}
type Socket = tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>;
async fn subscribe(cursor: Option<i64>) -> Socket {
let url = match cursor {
Some(c) => format!("{PDS_WS}/xrpc/com.atproto.sync.subscribeRepos?cursor={c}"),
None => format!("{PDS_WS}/xrpc/com.atproto.sync.subscribeRepos"),
};
let (socket, _resp) = tokio_tungstenite::connect_async(&url)
.await
.unwrap_or_else(|e| panic!("subscribeRepos connect to {url}: {e}"));
socket
}
/// Read the next **binary** message, or `None` on timeout.
///
/// Text messages would be a protocol violation on this endpoint and are
/// asserted against rather than skipped.
async fn next_frame(socket: &mut Socket) -> Option<Vec<u8>> {
let deadline = Duration::from_secs(10);
loop {
match tokio::time::timeout(deadline, socket.next()).await {
Err(_) => return None,
Ok(None) => return None,
Ok(Some(Ok(Message::Binary(b)))) => return Some(b),
Ok(Some(Ok(Message::Ping(_)))) | Ok(Some(Ok(Message::Pong(_)))) => continue,
Ok(Some(Ok(Message::Close(_)))) => return None,
Ok(Some(Ok(other))) => panic!("subscribeRepos sent a non-binary frame: {other:?}"),
Ok(Some(Err(e))) => panic!("subscribeRepos socket error: {e}"),
}
}
}
/// Give the server a moment to finish `on_upgrade` and actually subscribe to
/// the broadcast channel before we trigger a write.
///
/// The TCP handshake completing does not mean the handler has run. Without
/// this the test would occasionally write before the subscription exists and
/// then wait for a frame that was published to nobody. (The *cursor* replay
/// path is the real fix for that race in production; the live-only test is
/// deliberately testing the raceable path, so it waits.)
async fn settle() {
tokio::time::sleep(Duration::from_millis(300)).await;
}
// -- an independent DAG-CBOR reader ----------------------------------------
#[derive(Debug, Clone, PartialEq)]
enum Cbor {
Null,
Bool(bool),
Int(i64),
Bytes(Vec<u8>),
Text(String),
Array(Vec<Cbor>),
Map(BTreeMap<String, Cbor>),
/// tag(42) + bytes(0x00 || cid) — the binary CID is kept raw and
/// re-encoded to a `bafy…` string on demand, so the test never depends on
/// the server's own CID formatting.
Link(Vec<u8>),
}
impl Cbor {
fn get(&self, key: &str) -> &Cbor {
match self {
Cbor::Map(m) => m
.get(key)
.unwrap_or_else(|| panic!("missing key `{key}` in {self:?}")),
other => panic!("not a map: {other:?}"),
}
}
fn opt(&self, key: &str) -> Option<&Cbor> {
match self {
Cbor::Map(m) => m.get(key),
_ => None,
}
}
fn int(&self) -> i64 {
match self {
Cbor::Int(i) => *i,
other => panic!("not an int: {other:?}"),
}
}
fn text(&self) -> &str {
match self {
Cbor::Text(s) => s,
other => panic!("not text: {other:?}"),
}
}
fn bool(&self) -> bool {
match self {
Cbor::Bool(b) => *b,
other => panic!("not a bool: {other:?}"),
}
}
fn bytes(&self) -> &[u8] {
match self {
Cbor::Bytes(b) => b,
other => panic!("not bytes: {other:?}"),
}
}
fn array(&self) -> &[Cbor] {
match self {
Cbor::Array(a) => a,
other => panic!("not an array: {other:?}"),
}
}
/// The link's CID rendered as a base32 `bafy…` string, for comparison
/// against what the XRPC JSON responses return.
fn link_cid(&self) -> String {
match self {
Cbor::Link(raw) => cid::Cid::read_bytes(&raw[..])
.expect("tag-42 payload must be a valid binary CID")
.to_string(),
other => panic!("not a link: {other:?}"),
}
}
}
fn read_head(b: &[u8], p: usize) -> (u8, u64, usize) {
let first = b[p];
let major = first >> 5;
let low = first & 0x1f;
let (arg, extra) = match low {
0..=23 => (low as u64, 0usize),
24 => (b[p + 1] as u64, 1),
25 => (u16::from_be_bytes([b[p + 1], b[p + 2]]) as u64, 2),
26 => (
u32::from_be_bytes([b[p + 1], b[p + 2], b[p + 3], b[p + 4]]) as u64,
4,
),
27 => {
let mut n = 0u64;
for i in 0..8 {
n = (n << 8) | b[p + 1 + i] as u64;
}
(n, 8)
}
other => panic!("indefinite or reserved CBOR head 0x{other:02x} — illegal in DAG-CBOR"),
};
(major, arg, p + 1 + extra)
}
fn decode_at(b: &[u8], p: usize) -> (Cbor, usize) {
let (major, arg, mut p) = read_head(b, p);
match major {
0 => (Cbor::Int(arg as i64), p),
1 => (Cbor::Int(-(arg as i64) - 1), p),
2 => {
let end = p + arg as usize;
(Cbor::Bytes(b[p..end].to_vec()), end)
}
3 => {
let end = p + arg as usize;
(
Cbor::Text(std::str::from_utf8(&b[p..end]).unwrap().to_string()),
end,
)
}
4 => {
let mut items = Vec::new();
for _ in 0..arg {
let (v, next) = decode_at(b, p);
items.push(v);
p = next;
}
(Cbor::Array(items), p)
}
5 => {
let mut m = BTreeMap::new();
let mut prev_key: Option<String> = None;
for _ in 0..arg {
let (k, next) = decode_at(b, p);
p = next;
let key = k.text().to_string();
// Canonical DAG-CBOR order: shorter keys first, then
// bytewise. Asserted here because a consumer written against
// a strict codec (cborg's `dag-cbor` decoder, for one) will
// reject a frame whose keys are out of order.
if let Some(prev) = &prev_key {
let ordered = (prev.len(), prev.as_bytes()) < (key.len(), key.as_bytes());
assert!(ordered, "map keys out of canonical order: {prev:?} then {key:?}");
}
prev_key = Some(key.clone());
let (v, next) = decode_at(b, p);
p = next;
m.insert(key, v);
}
(Cbor::Map(m), p)
}
6 => {
assert_eq!(arg, 42, "DAG-CBOR permits only tag 42");
let (inner, next) = decode_at(b, p);
let raw = match inner {
Cbor::Bytes(v) => v,
other => panic!("tag 42 must wrap bytes, got {other:?}"),
};
assert_eq!(
raw.first(),
Some(&0x00),
"a binary CID link must start with the 0x00 multibase identity prefix"
);
(Cbor::Link(raw[1..].to_vec()), next)
}
7 => match arg {
20 => (Cbor::Bool(false), p),
21 => (Cbor::Bool(true), p),
22 => (Cbor::Null, p),
other => panic!("unsupported CBOR simple value {other}"),
},
other => panic!("unsupported CBOR major type {other}"),
}
}
/// Split one binary frame into its header and body values, and assert that
/// the two together consume the whole message — a frame with trailing bytes
/// would silently desynchronise a streaming consumer.
fn parse_frame(bytes: &[u8]) -> (Cbor, Cbor) {
let (header, after_header) = decode_at(bytes, 0);
let (body, end) = decode_at(bytes, after_header);
assert_eq!(end, bytes.len(), "frame must be exactly two CBOR values");
(header, body)
}
/// Minimal CAR v1 reader: returns the root CIDs and the block CIDs, both as
/// `bafy…` strings.
fn parse_car(bytes: &[u8]) -> (Vec<String>, Vec<String>) {
fn varint(b: &[u8], p: &mut usize) -> u64 {
let (mut v, mut shift) = (0u64, 0u32);
loop {
let byte = b[*p];
*p += 1;
v |= ((byte & 0x7f) as u64) << shift;
if byte & 0x80 == 0 {
return v;
}
shift += 7;
}
}
let mut p = 0usize;
let header_len = varint(bytes, &mut p) as usize;
let header = &bytes[p..p + header_len];
p += header_len;
// The CAR header is a CBOR map; walk it with the same head reader. Note
// that this server's CAR header tags its roots *without* the 0x00
// identity prefix (a documented deviation in `car.rs`), so the roots are
// read as plain tagged byte strings rather than through `decode_at`.
let mut hp = 0usize;
let (major, n, next) = read_head(header, hp);
assert_eq!(major, 5, "CAR header must be a map");
hp = next;
let mut roots = Vec::new();
for _ in 0..n {
let (m, len, next) = read_head(header, hp);
assert_eq!(m, 3);
hp = next;
let key = std::str::from_utf8(&header[hp..hp + len as usize]).unwrap().to_string();
hp += len as usize;
if key == "version" {
let (m, v, next) = read_head(header, hp);
assert_eq!(m, 0);
assert_eq!(v, 1, "CAR must be v1");
hp = next;
} else if key == "roots" {
let (m, count, next) = read_head(header, hp);
assert_eq!(m, 4);
hp = next;
for _ in 0..count {
let (m, tag, next) = read_head(header, hp);
assert_eq!((m, tag), (6, 42), "root must be a tag-42 link");
hp = next;
let (m, len, next) = read_head(header, hp);
assert_eq!(m, 2);
hp = next;
// A DAG-CBOR link wraps `0x00 || <binary CID>`; the 0x00 is
// the multibase identity prefix and is not part of the CID.
// Assert on it rather than skipping it silently — this
// reader stands in for a foreign consumer, and dropping the
// check would let the header regress unnoticed.
let raw = &header[hp..hp + len as usize];
assert_eq!(
raw.first(),
Some(&0x00),
"CAR root link must carry the multibase identity prefix"
);
roots.push(cid::Cid::read_bytes(&raw[1..]).unwrap().to_string());
hp += len as usize;
}
} else {
panic!("unexpected CAR header key {key}");
}
}
let mut blocks = Vec::new();
while p < bytes.len() {
let section_len = varint(bytes, &mut p) as usize;
let section = &bytes[p..p + section_len];
let cid = cid::Cid::read_bytes(section).unwrap();
blocks.push(cid.to_string());
p += section_len;
}
(roots, blocks)
}
// -- tests -----------------------------------------------------------------
/// A live subscriber receives a `#commit` frame for a record created after it
/// connected, and every field of that frame says what it should.
#[tokio::test]
async fn live_subscriber_receives_a_commit_frame() {
if !wait_for_pds().await {
eprintln!("pds not running, skipping");
return;
}
let (c, did, jwt) = fresh_user("fhlive").await;
let mut socket = subscribe(None).await;
settle().await;
let created = create_post(&c, &did, &jwt, "hello firehose").await;
let record_cid = created["cid"].as_str().expect("createRecord cid").to_string();
let commit_cid = created["commit"]["cid"]
.as_str()
.expect("createRecord commit.cid")
.to_string();
let commit_rev = created["commit"]["rev"].as_str().unwrap().to_string();
let uri = created["uri"].as_str().unwrap().to_string();
let rkey = uri.rsplit('/').next().unwrap().to_string();
// The account was created moments ago and has never written before, so
// the first frame we see for it is this post's. Other accounts may be
// writing concurrently, so filter by DID rather than taking frame 1.
let (header, body) = loop {
let bytes = next_frame(&mut socket)
.await
.expect("expected a #commit frame within the timeout");
let (header, body) = parse_frame(&bytes);
if header.opt("t").map(|t| t.text()) == Some("#commit")
&& body.get("repo").text() == did
{
break (header, body);
}
};
assert_eq!(header.get("op").int(), 1, "regular frames carry op = 1");
assert_eq!(header.get("t").text(), "#commit");
assert!(body.get("seq").int() > 0, "seq must be a real cursor value");
assert_eq!(body.get("repo").text(), did);
assert_eq!(body.get("rev").text(), commit_rev);
assert_eq!(body.get("commit").link_cid(), commit_cid);
assert!(!body.get("rebase").bool());
assert!(!body.get("tooBig").bool());
assert!(body.get("blobs").array().is_empty());
// First-ever commit on a brand new repo — there is no predecessor.
assert_eq!(*body.get("since"), Cbor::Null, "first commit has since = null");
// RFC 3339 with a Z offset.
let time = body.get("time").text();
assert!(
chrono_like_rfc3339(time),
"time must be RFC 3339, got {time}"
);
// ops
let ops = body.get("ops").array();
assert_eq!(ops.len(), 1, "one record write means one op");
assert_eq!(ops[0].get("action").text(), "create");
assert_eq!(ops[0].get("path").text(), format!("app.twi.post/{rkey}"));
assert_eq!(ops[0].get("cid").link_cid(), record_cid);
// blocks: a CAR rooted at the commit, containing the commit block and the
// new record block.
let (roots, blocks) = parse_car(body.get("blocks").bytes());
assert_eq!(roots, vec![commit_cid.clone()], "CAR root is the commit");
assert!(
blocks.contains(&commit_cid),
"CAR must carry the commit block itself; got {blocks:?}"
);
assert!(
blocks.contains(&record_cid),
"CAR must carry the new record block; got {blocks:?}"
);
let _ = socket.send(Message::Close(None)).await;
}
/// The same event, fetched again from the durable log with a cursor, is
/// byte-identical to the live frame.
///
/// Byte-identity is the strong form of the claim and the one that matters: a
/// consumer that deduplicates by hashing frames, or that verifies a signature
/// over them, must not see two different representations of one event.
#[tokio::test]
async fn cursor_replay_reproduces_the_live_frame_exactly() {
if !wait_for_pds().await {
eprintln!("pds not running, skipping");
return;
}
let (c, did, jwt) = fresh_user("fhreplay").await;
let mut live = subscribe(None).await;
settle().await;
create_post(&c, &did, &jwt, "replay me").await;
let live_bytes = loop {
let bytes = next_frame(&mut live)
.await
.expect("expected a live #commit frame");
let (header, body) = parse_frame(&bytes);
if header.opt("t").map(|t| t.text()) == Some("#commit")
&& body.get("repo").text() == did
{
break bytes;
}
};
let (_h, live_body) = parse_frame(&live_bytes);
let seq = live_body.get("seq").int();
let _ = live.send(Message::Close(None)).await;
// Reconnect asking for everything after the event *before* ours, so the
// replay's first matching frame is the one we just saw.
let mut replayed = subscribe(Some(seq - 1)).await;
let replay_bytes = loop {
let bytes = next_frame(&mut replayed)
.await
.expect("expected the event to come back from the replay");
let (header, body) = parse_frame(&bytes);
if header.opt("t").map(|t| t.text()) == Some("#commit")
&& body.get("seq").int() == seq
{
break bytes;
}
};
assert_eq!(
replay_bytes, live_bytes,
"a replayed frame must be byte-identical to the live one"
);
let _ = replayed.send(Message::Close(None)).await;
}
/// Handing over from replay to live loses nothing and duplicates nothing.
///
/// Connect with a cursor at the current head (so the replay is empty), then
/// write twice: both events must arrive, in order, exactly once each.
#[tokio::test]
async fn replay_to_live_handover_has_no_gap_and_no_duplicate() {
if !wait_for_pds().await {
eprintln!("pds not running, skipping");
return;
}
let (c, did, jwt) = fresh_user("fhhandover").await;
// Establish where the log currently ends by writing one event and reading
// its seq off the live stream.
let mut probe = subscribe(None).await;
settle().await;
create_post(&c, &did, &jwt, "probe").await;
let head_seq = loop {
let bytes = next_frame(&mut probe).await.expect("probe frame");
let (header, body) = parse_frame(&bytes);
if header.opt("t").map(|t| t.text()) == Some("#commit")
&& body.get("repo").text() == did
{
break body.get("seq").int();
}
};
let _ = probe.send(Message::Close(None)).await;
// Now reconnect at that exact cursor: nothing to replay, straight to live.
let mut socket = subscribe(Some(head_seq)).await;
settle().await;
create_post(&c, &did, &jwt, "after handover one").await;
create_post(&c, &did, &jwt, "after handover two").await;
let mut seen: Vec<i64> = Vec::new();
while seen.len() < 2 {
let bytes = next_frame(&mut socket)
.await
.expect("expected both post-handover frames");
let (header, body) = parse_frame(&bytes);
if header.opt("t").map(|t| t.text()) != Some("#commit") {
continue;
}
let seq = body.get("seq").int();
assert!(
seq > head_seq,
"the cursor said we already had seq {head_seq}; got {seq} again"
);
if body.get("repo").text() == did {
assert!(!seen.contains(&seq), "event {seq} delivered twice");
seen.push(seq);
}
}
assert_eq!(seen.len(), 2);
assert!(seen[0] < seen[1], "events must arrive in seq order: {seen:?}");
let _ = socket.send(Message::Close(None)).await;
}
/// A cursor past the end of the log is a terminal error frame, not silence.
#[tokio::test]
async fn future_cursor_gets_an_error_frame() {
if !wait_for_pds().await {
eprintln!("pds not running, skipping");
return;
}
let mut socket = subscribe(Some(i64::MAX / 2)).await;
let bytes = next_frame(&mut socket)
.await
.expect("expected an error frame for a future cursor");
let (header, body) = parse_frame(&bytes);
assert_eq!(header.get("op").int(), -1, "error frames carry op = -1");
assert!(
header.opt("t").is_none(),
"an error header has no `t`, only `op`"
);
assert_eq!(body.get("error").text(), "FutureCursor");
assert!(
!body.get("message").text().is_empty(),
"the error should say what went wrong"
);
}
/// A record deleted through `deleteRecord` produces a `delete` op with a null
/// CID — the one op shape that is not a link.
#[tokio::test]
async fn delete_produces_a_delete_op_with_a_null_cid() {
if !wait_for_pds().await {
eprintln!("pds not running, skipping");
return;
}
let (c, did, jwt) = fresh_user("fhdelete").await;
let created = create_post(&c, &did, &jwt, "to be deleted").await;
let rkey = created["uri"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.to_string();
let mut socket = subscribe(None).await;
settle().await;
let resp = c
.post(format!("{PDS_URL}/xrpc/com.atproto.repo.deleteRecord"))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.twi.post",
"rkey": rkey,
}))
.send()
.await
.unwrap();
assert!(resp.status().is_success(), "deleteRecord: {:?}", resp.status());
let (_header, body) = loop {
let bytes = next_frame(&mut socket).await.expect("expected a delete frame");
let (header, body) = parse_frame(&bytes);
if header.opt("t").map(|t| t.text()) == Some("#commit")
&& body.get("repo").text() == did
{
break (header, body);
}
};
let ops = body.get("ops").array();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].get("action").text(), "delete");
assert_eq!(ops[0].get("path").text(), format!("app.twi.post/{rkey}"));
assert_eq!(*ops[0].get("cid"), Cbor::Null, "a delete has no resulting CID");
// The second commit on this repo, so `since` is the previous revision.
assert!(
matches!(body.get("since"), Cbor::Text(_)),
"a follow-up commit must name its predecessor's rev, got {:?}",
body.get("since")
);
let _ = socket.send(Message::Close(None)).await;
}
/// Shape check for the frame's `time`: RFC 3339, UTC, with a `Z` suffix.
fn chrono_like_rfc3339(s: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(s).is_ok() && s.ends_with('Z')
}