refactor(streaming)!: one streaming core, correct cursors, faster consumers #168
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!168
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-126-streaming"
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?
Closes #126.
One streaming core for the firehose, label, chat moderation and Jetstream streams: a single WebSocket helper, one reconnect policy, one cursor tracker and one cursor store; correct cursors; typed error frames; and less CPU per event. Also covers the scope added in the issue comments (typed
CollectionFilter,IRecordEvent, one operation enum, thedocs/firehose.mdcursor example, a drop hook, the$bytespadding bug,subscribeModEvents) and the entries #123 deferred to this issue.Consolidation
StreamSocket, internal): connect (subprotocol,Authorization), receive with reassembly into one reused buffer (capped at 64 MiB), graceful close on dispose. Every stream client and consumer goes through it; each subscription owns its socket, which fixes the leak whenFirehoseClient.SubscribeAsyncwas called twice. AStreamConnectordelegate is the test seam.EventStreamLoop+EventStreamHandler<T>, internal): header parsing, error frames, reconnect and cancellation live once; the firehose, label and chat moderation handlers only turn bodies into messages and track their position.StreamReconnectPolicy(InitialDelay5 s,MaxDelay30 s,MaxAttempts10,null= unlimited), exponential with up to 10 % jitter, on every consumer through the newStreamConsumerOptionsbase. The attempt count resets when a connection delivers a frame. Exhausted attempts throwEventStreamException(inner = last failure). A non-retryable failure (FutureCursor, a 4xx other than 408/429) throws at once.CursorTracker(internal): monotonic; filtered and failed-verification events advance it; saves are single-flight on a background task everyCursorPersistIntervalevents; the final save runs infinallyand never throws (a failed save is logged). Delivered events are recorded when the caller asks for the next one (at-least-once).IStreamCursorStore(ValueTask) replacesIFirehoseCursorStore;InMemoryStreamCursorStore.FirehoseConsumerfolded intoTypedFirehoseConsumer: each frame is parsed once. Frames that are skipped (filtered, unknown type, unparseable) move the cursor with aseq-only CBOR scan.TypedFirehoseConsumer: with aCollectionFilter(nowIReadOnlySet<Nsid>),ops[].pathandseqare read straight from the CBOR before deserializing; a verifier verifies exactly when set (theVerifySignaturesbool is gone) and its signature path also checks every block's CID, so the CAR is parsed and hashed once;#identitystill invalidates the verifier's DID cache.op = -1) surface asEventStreamException(Error,IsRetryable) and are reported toOnStreamError; consumers reconnect afterConsumerTooSlowand throwFutureCursor.FirehoseEventParser.Parsethrows for an error frame.subscribeLabelsfinished:LabelStreamMessage→LabelsEvent/LabelInfoEvent, typedFirehoseClient.SubscribeLabelsAsync, and a reconnectingLabelStreamConsumer(a separate options type, so the label-signing issue #135 can add a verifier to it).JetstreamConsumerOptions.Validate()and kind names in oneJetstreamKindshelper;JetstreamDictionaryClientfolded intoJetstreamArchiveClient.GetZstdDictionaryAsync(no API key sent to the public endpoint); query strings throughXrpcParams(new internalToQueryString()); oneJetstreamException(subclass ofEventStreamException); shared event builders (JetstreamEvents) for the live parser and the segment reader, since a segment row's payload has the same fields the live frame nests;JetstreamEventParserkeepsParseFrame(ReadOnlyMemory<byte>, protocol); dead members removed (JetstreamPlanStats/Stats,IsConnected,PinnedTipSeq, the no-opDispose, the hand-rolledReadExactlyAsync;DecodeBlockis internal).FirehoseClientandJetstreamClientareIAsyncDisposable; disposing ends their running subscriptions.Protocol
JetstreamCursor.FromTimestamp/IsTimestamp/TimestampThreshold(10^15);FromTimestamprefuses times that would read as a sequence number. A v2 timestamp seek sets no dedup floor (never deduplicated bytime_us); the consumer continues by sequence number from the first event, so a stored v1 cursor migrates to v2 unchanged (documented). A v2 sequence start cursor now also dedupes the server's inclusive replay of it.JetstreamReplayConsumerrejects a timestamp cursor, since the archive is sequence-addressed.HEADprobes (#347):ProbeSegmentAsync/ProbeBlockAsync→JetstreamArchiveProbe(ContentLength, ETag). Health:GetHealthAsync(/xrpc/_health→{version}), plusJetstreamHealthCheckandAddHealthChecks().AddJetstream(url)inATProtoNet.Server/Aspire(next to the PDS check).HandleEvent/TombstoneEventremoved;Seq/Timemove to a newFirehoseEventbase, soInfoEventno longer declares fields#infolacks;TooBig,Rebase,Blobsare[Obsolete](still bound). The #126 drift allow-list entries are removed; new entries cover the new union bases.chat.bsky.moderation.subscribeModEvents:ChatModerationEventConsumerwith a per-connection bearer token provider (GetAccessTokenAsync, so each reconnect can mint a fresh service-auth token), resume after the lastRev, andChatModerationEventmodels for all 11 variants plusUnknownChatModerationEvent(open[AtProtoUnion]).$bytespadding:DagCborDecoder(the Wrapped form of the shared transcoder) now writes unpadded base64, pinned against RFC 4648 vectors and the atproto-interop data-model fixtures' reference JSON.Also from the issue comments
IRecordEvent(Did, Collection, Rkey, Operation, Rev, Cid, Record, Uri,GetRecord<T>), implemented byJetstreamCommitEventand byFirehoseRecordEventfromCommitEvent.GetRecordEvents()(records decoded from the commit's blocks).JetstreamOperationremoved in favour ofRepoOpAction.OnEventDroppedhook withDroppedStreamEvent(Reason, Cursor, Detail)on every consumer (andJetstreamClient).docs/firehose.mdcursor example fixed (it readmessage.Seqoff a raw frame); both streaming docs,chat.md,error-handling.mdandapi-reference.mdupdated; both samples updated.Performance
ToEvent(); matching rows alias the decompressed block instead of copying payloads; DID and collection (and rev) strings are interned per block; blocks are decoded in the download stage (getBlockunits in their download task, whole segmentsDownloadParallelismblocks ahead), still delivered in order.Breaking changes (migration)
ReconnectDelay/MaxReconnectAttempts→Reconnect = new StreamReconnectPolicy { InitialDelay = …, MaxAttempts = null }.EventStreamException; cancellation ends every consumer normally (the replay consumer threwOperationCanceledExceptionmid-backfill before).IFirehoseCursorStore/InMemoryFirehoseCursorStore→IStreamCursorStore/InMemoryStreamCursorStore, returningValueTask.FirehoseConsumer,FirehoseFrame,FirehoseEventParser.Parse(FirehoseFrame)/TryParse,FirehoseClient.DisconnectAsync,JetstreamClient.DisconnectAsyncremoved;FirehoseClient.SubscribeAsyncyieldsFirehoseMessage; the clients areIAsyncDisposable(await using);TypedFirehoseConsumer/JetstreamConsumerare no longerIDisposable.VerifySignaturesremoved (setVerifier);CollectionFilterisIReadOnlySet<Nsid>.HandleEvent/TombstoneEventremoved;Seq/TimeonFirehoseEvent(message is FirehoseEvent e ? e.Seq);LabelsEventderives fromLabelStreamMessage.JetstreamOperation→RepoOpAction.JetstreamConnectException/JetstreamArchiveException→JetstreamException;JetstreamStreamError→EventStreamError;JetstreamClientthrows on an error frame.JetstreamDictionaryClient→JetstreamArchiveClient.GetZstdDictionaryAsync.JetstreamEventParser.Parse(...)and the span/stringParseFrameoverloads removed.JetstreamPlanStats,JetstreamSnapshotPlan.Stats,JetstreamConsumer.IsConnected,JetstreamReplayConsumer.PinnedTipSeqremoved;JetstreamSegmentReader.DecodeBlockinternal.Each has a
Breaking changesbullet with its migration note in the CHANGELOG.Verification
dotnet build(whole solution): 0 warnings, 0 errors.TypedFirehoseConsumerTests(filter advances the stored cursor, path peek before parse,break/cancellation keep the cursor, resume/explicit cursor, reconnect dedupe, exhausted attempts throw,FutureCursorthrows /ConsumerTooSlowreconnects, drop reporting, verifier/VerifyCids/no verification);EventStreamClientTests(single-connection client, per-subscription sockets, dispose, labels,LabelStreamConsumer, chat moderation typed + unknown events, per-connection tokens, rev resume, backoff curve, retryability);StreamSocketTestsagainst a real Kestrel WebSocket on loopback (fragment reassembly past the buffer, empty messages, text/binary, subprotocol andAuthorization, refused upgrade status, close on dispose);CursorTrackerTests(monotonic, final flush, background interval save, slow store does not block and saves the newest, failing store does not throw);JetstreamClientTests(info out of band, receive-buffer parsing, error frame, refused upgrade, drop hook, decompressor); Jetstream timestamp-cursor, migration and error-frame consumer tests; replay cutover (inclusive seam dedupe, refused cutover re-plans from the last delivered event), timestamp rejection, cancellation, column filter;RecordEventTests; archive dictionary/health/HEADprobe tests;JetstreamHealthCheck.$bytes(Zg,Zm8,Zm9v,+/8), and the three atproto-interopdata-model-fixtures.jsonentries now decode to their reference JSON exactly./xrpc/_health, and aHEADprobe matchinglistSegments(all gated; skipped locally).#commitframe (old cursor path, and every frame a filter dropped)#commitbodyToEvent()each, then filter (old order)ToEvent()Deliberately deferred / decisions to flag
prevData, per-repo rev tracking) stays with the follow-up issue; theFirehoseVerifierdoc no longer claims MST-proof verification.IStreamCursorStore: it is a revision string, not a sequence number. The consumer resumes after its lastRevacross reconnects and exposesLastRev; persisting it across restarts is the caller's job (documented).CommitEvent.TooBig/Rebase/Blobsare[Obsolete], not removed, since the Lexicon still lists them as required and the drift test expects them declared.#syncevents are CID-checked when eitherVerifyCidsor aVerifieris set; their signature check is part of the Sync 1.1 follow-up.[JsonPolymorphic]with short#typediscriminators (the header names the type), excluded from the open-union guard likeFirehoseMessagealready was; the chat moderation union uses full NSID discriminators and[AtProtoUnion].ILoggerrather than moving toILoggerFactory(WP15.1 mentions it; not in this issue's checklist).PlcClient.StreamExportAsyncstill has its own WebSocket code; moving it ontoStreamSocketis a possible follow-up outside this issue's area.Streaming/:Http/XrpcParams.cs(internalToQueryString()),Repo/DagCborJson.cs(unpadded$bytes), the Sync/Label/Chat-moderation model files,AtProtoException.cs(doc),ATProtoNet.Server/Aspire/JetstreamHealthCheck.cs(new). No files owned by #125 or #132 were touched.🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T06:20:44.459Z) — nothing to re-trigger.
@claude /review — large (71 files); the description has the migration notes and benchmarks. The diff will be truncated, so please read these in full from the branch:
Streaming/StreamSocket.cs,EventStreamLoop.cs,StreamReconnectPolicy.cs,CursorTracker.cs(cursor correctness: filtered events advance, saves serialized, final save in finally),EventStreamFrame.cs,TypedFirehoseConsumer.cs(parse-once path, verification when a Verifier is set, #sync CID check),FirehoseClient.cs,JetstreamClient.cs,JetstreamConsumer.cs,JetstreamReplayConsumer.cs,JetstreamSegmentReader.cs(column filtering before decode, parallel decode, interning),LabelStreamConsumer.cs,ChatModerationEventConsumer.cs, andRepo/DagCborJson.cs(unpadded$bytes). Focus on socket/subscription lifetime and disposal, reconnect/backoff and cancellation, cursor loss or regression on reconnect, concurrency in the background cursor saves, and unbounded memory (buffers, interning tables, parallel decode).🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
Reviewed the core streaming primitives introduced by this refactor in depth:
StreamSocket(connect/reassembly/dispose),EventStreamFrame/EventStreamLoop(header parsing, error-frame handling, reconnect vs. single-connection semantics),StreamReconnectPolicy/ReconnectBackoff(backoff math,MaxAttempts = 0giving up immediately, attempt reset on frame receipt),CursorTracker(monotonic advance, single-flight background saves, final flush-in-finally),TypedFirehoseConsumer(CBOR path-peek filtering before full parse, inclusive-cursor dedup viaSeq <= cursor.Current, drop reporting),JetstreamConsumer(v1 rewind vs. v2 inclusive-replay dedup, the timestamp-seek-then-sequence-number transition logic),JetstreamReplayConsumer(pinned-tip cutover,IsNewdedup shared between backfill and live tail, re-planning after a refused cutover),RecordEvents.cs, and the new unpadded-base64 writer inDagCborJson.Everything checked out. The unpadded-base64 routine (
WriteUnpaddedBase64) looks confusing at first glance (index arithmetic against a buffer offset by 1) but is correct:buffer[written]after encoding intobuffer[1..]really is the last encoded byte, and the empty-input case produces""correctly. The cursor dedup/backoff/reconnect state machines handle the edge cases the PR description calls out (inclusive replay, timestamp-to-sequence migration, cutover refusal, cancellation-is-not-failure) consistently with their documentation, andAdvance/IsNeware defensively monotonic so a stale or out-of-order frame can't rewind the stored cursor.This is a very large, carefully engineered refactor (~3100 lines touched in
Streaming/alone, plus docs and samples kept in sync) and I could not exhaustively re-derive every code path in the remaining files (JetstreamEventParser,JetstreamSegmentReader,FirehoseVerifier,JetstreamArchiveClient, and the full test suite) given the scope, but the primitives that most other code depends on are sound, the breaking-change/CHANGELOG bookkeeping matches the actual diff, and the docs were updated consistently with the API changes. No blocking issues found.⏱ 2m 23s · 16 turns · tokens: 118.7k in / 13.8k out (+1864.2k cache-read)
📜 Full session transcript