Add support for the Jetstream v2 archive/replay API #85
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET#85
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-up to #83 / #84, which implemented the Jetstream v2 live tail and explicitly left the archive out of scope (
docs/jetstream.md→ Historical Replay).Jetstream v2 is a “full-network archive, replay, and streaming service” — the WebSocket tail is only one of three ways to consume it. The other two are worth having in the SDK, because between them they cover the thing every app actually needs: the records that already exist and every new one, with no gap at the seam.
subscribeEvents)How replay works
Replay is stateless on the server — no per-consumer cursor, no registered subscription. Three steps, all plain HTTP plus one WebSocket:
POST network.bsky.jetstream.planSnapshotwithdids/collections(exact NSIDs orapp.bsky.feed.*wildcards) and an optionalafterSeq/beforeSeqwindow. The first response reportssealedTipSeq; pin it asSfor the whole backfill. Each page also reportsplannedThroughSeq; whileplannedThroughSeq < S, re-plan withafterSeq = plannedThroughSeq,beforeSeq = Sso the range never floats. Large plans truncate at a whole segment or block-range boundary, and at least one work unit is always admitted, so progress is guaranteed.mode: "segment"(fetch the whole file withgetSegment) ormode: "blocks"(fetch the listed block ranges withgetBlock). Responses are immutable, ETag'd and CDN-cacheable, andgetSegmenthonours HTTPRange, so downloads parallelise freely and resume exactly where they stopped. The planner works from bloom filters and per-block summaries — no false negatives, but it may hand back blocks with no matching rows, so the client applies the exactdids/collectionsfilter to what it decodes.?cursor=S. The cursor is inclusive, so deduplicate by sequence number. The server replays the window between the plan and the socket itself, so there's no buffer to drain and nothing lost in the handoff.network.bsky.jetstream.listSegmentsrounds it out for a raw archive mirror (name, index,sizeBytes,checksum,eventCount,minSeq/maxSeq,minWitnessedAt/maxWitnessedAt).The
.jssdecoderThis is the bulk of the work, and the reason it didn't ride along with #84. A Jetstream sealed segment is a custom columnar binary format:
jss0magic, xxhash3 checksum, version, block/event/DID counts, seq and witnessed-at bounds, and offsets for the footer, DID bloom, per-block bloom, collection index and block index).gloom.Filter, 0.1% FP), per-block blooms, and a collection index.Inside a decompressed block: a
uint32event count, fixed-size columns (seq,witnessed_at,indexed_at,kind, the four length columns,event_len), then concatenated variable-length columns (collections,dids,rkeys,revs, and raw CBOR payloads).kindis auint8discriminator (1 create, 2 update, 3 delete, 4 identity, …).indexed_atis the display timestamp handed to clients astime_us, falling back towitnessed_atwhen0.Two behaviours worth encoding in the design rather than discovering later:
accountevent withactive: false/status: "deleted", or asyncdivergence marker, removes all of that account's records). Writes should be idempotent, keyed on theat://URI. Account-level events carry no collection and are delivered even to a collection-filtered consumer — same rule the live tail already follows.Auth and metering
The HTTP endpoints are metered on Bluesky-hosted instances (the live WebSocket stays unauthenticated and unmetered):
Authorization: Bearer <key>; a missing/malformed/revoked key returns401with{"error":"invalid bearer credential"}.429+{"error":"byte limit exceeded"}and aRetry-Afterheader; the quota refills continuously rather than resetting on a boundary.Rangerequest from its exact byte offset. Nothing already downloaded is re-charged.So the download layer needs
Retry-After-aware backoff and byte-offset resume as first-class behaviour, not as an afterthought.One more failure mode to handle: if a backfill runs long enough that
Sages out of the live socket's lookback window (36 hours on Bluesky-hosted instances), the cutover connect fails with an HTTP 400 carrying the new floor — already surfaced asJetstreamConnectExceptionby #84. The right response is to re-enter the plan loop from the last processed sequence number rather than skip the gap.Sketch of the API surface
Nothing here is settled; it's a starting point that fits what #84 already landed.
JetstreamArchiveClient— typed wrappers forplanSnapshot/listSegments/getSegment/getBlock, with the API key,Rangesupport and 429 handling.JetstreamSegmentReader— the.jssdecoder: header/footer parsing, checksum verification, block decompression, columnar →JetstreamEventprojection reusing the existing event model. Streaming (IAsyncEnumerable) rather than whole-file-in-memory; segments are ~256 MB compressed.JetstreamReplayConsumer(orJetstreamConsumer.ReplayAsync) — the plan loop, parallel download, exact filtering, and the dedup'd cutover into the existingJetstreamConsumer, so oneawait foreachspans history and live. Snapshot mode is the same thing with the cutover switched off and an optionalbeforeSeqbound.IFirehoseCursorStore, since a replay cursor is the same v2 sequence number the live tail already persists.JetstreamConsumerOptionsor a sibling:ApiKey,AfterSeq,BeforeSeq,SnapshotOnly, download parallelism.Requires
Protocol = JetstreamProtocol.V2; v1 has no archive.Suggested phasing
Each step is independently useful and shippable:
.jssreader + tests against fixture segments (the format is stable and testable offline — this is the risky part, so do it first).JetstreamArchiveClientover the four HTTP methods, incl. auth,Range, ETags, 429/Retry-After.sealedTipSeqwith seq dedup, plus the re-plan path when the tip ages out.docs/jetstream.mdreplaces the Historical Replay placeholder,docs/api-reference.md) and a sample.Open questions
gloom.Filter, Go-sideMarshalBinary) needs a compatible C# reader if we want client-side segment pruning — or we can skip it and lean entirely on the server's plan, which is the simpler first cut.ATProtoNetpackage versus somewhere optional, given the zstd and parallel-IO weight.References
planSnapshot,listSegments,getSegment,getBlockschemas.jssand block formats🤖 On it — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
Pushed to
claude/issue-85.What landed
Jetstream v2's archive, phased as suggested —
.jssreader first, then the HTTP client, then snapshot, then replay.JetstreamSegmentReader— the sealed-segment decoder. 256-byte fixed header, the run of 8-byte-length-prefixed zstd frames, and the columnar block body (event count → fixed-widthseq/witnessed_at/indexed_at/kind/length columns → concatenatedcollections/dids/rkeys/revs/CBOR payloads).ReadRowsAsync/ReadEventsAsyncstream block by block (no whole-segment buffering);DecodeBlockFrametakes a frame exactly asgetBlockreturns it.JetstreamArchiveRowis public — the raw columns including the untouched CBOR, so a mirror stays byte-auditable — andToEvent()projects to the existingJetstreamEventmodel, computing a commit's CID from its payload (the format stores no CID column), foldingkind1/2/3/7 into create/update/delete, and applying theindexed_at == 0 → witnessed_atdisplay-time fallback.JetstreamArchiveClient—planSnapshot/listSegments/getSegment/getBlockwithAuthorization: Bearer, ETags,Range, and metering-shaped behaviour:DownloadSegmentAsyncresumes from the exact byte offset it stopped at (and restarts cleanly if a proxy drops theRangeand answers 200), a 429 waits out precisely theRetry-Afterit was given, and a revoked key (401) is not retried.JetstreamArchiveExceptioncarriesStatusCode/Error/RetryAfter/IsRetryable.JetstreamReplayConsumer— pins the first page'ssealedTipSeqas the ceiling for the whole backfill and pages withafterSeq = plannedThroughSeq,beforeSeq = S; downloads with bounded parallelism (segments spooled to temp files, blocks in memory) while decoding strictly in plan order; re-applies the exact DID/collection/kind filter to what it decoded, since the planner over-returns; then cuts over once at the pinned tip with sequence dedup. A refused cutover (tip aged out of the 36 h window) re-enters the plan loop from the last delivered sequence rather than skipping the gap. Configured via the newJetstreamConsumerOptions.Archive, sharing the live tail's filters andIFirehoseCursorStore— a replay cursor is the v2 sequence number.Open questions, as resolved
JetstreamArchiveRow+DecodeBlock/DecodeBlockFrame, for exactly the mirror/audit case.gloom.FilterDID bloom? Skipped — the SDK leans on the server's plan, which is the simpler first cut. Header offsets for both blooms and the collection index are parsed and exposed, so a later reader has somewhere to hang.IJetstreamBlockDecompressor, deliberately separate fromIJetstreamDecompressor— segment blocks are dictionary-less, so the WebSocket decompressor cannot read one). The xxh3 checksum is likewise not recomputed; it's exposed for the compare-against-ETag check a mirror actually needs, and compaction rewrites mean re-listing is required anyway.Verified
dotnet buildclean (solution, Debug and Release);dotnet test tests/ATProtoNet.Tests/→ 1288 passed, 0 failed (5 pre-existing skips), including 60 new tests.segment/testdata/golden_block.bin(inflated and committed as a fixture) and against the fixed header of the reference repo's real sealed segment — reference bytes, so a shared misreading of the spec can't pass.legacy_fixed4096_blooms.jsswith a live ZstdSharp decompressor: 12 rows decoded, matching the header's event count, monotonic sequences, stopping cleanly at the footer.JetstreamArchiveTests) hit the live service and are gated behind the newATPROTO_JETSTREAM_API_KEY— I had no key, so they were not executed; they're written to stay small since the endpoints bill real bytes.Docs:
docs/jetstream.md's Historical Replay placeholder is replaced with the full flow (modes table, plan loop, metering/resume, folding semantics, the ZstdSharp block decompressor, mirror recipe);docs/api-reference.md,docs/index.md,README.md,CLAUDE.mdandCHANGELOG.mdupdated, plus the runnablesamples/JetstreamReplaySample.⏱ 26m 0s · 101 turns · tokens: 232.9k in / 120.0k out (+15813.0k cache-read)
📜 Full session transcript
→ Opened PR: #86