Go SDK 4/8: resilience core - #515
Open
bkeroack wants to merge 1 commit into
Open
Conversation
Adds the reconnect-and-replay layer the Go SDK's watch surface will build on: CursorStore (with a file-backed implementation), Backoff, LagPolicy, and ResilientSubscription. The durability contract is commit-on-poll, matching the Rust SDK: an event's cursor is persisted only once the caller comes back for the next one, which is an implicit ack. The store therefore never advances past an event the caller has not finished with, so a crash mid-processing replays that event rather than skipping it - at-least-once, not at-most-once. Arming happens in Next rather than on the pump goroutine, so an immediately following Commit checkpoints the event just returned and not the one before it. Cancel safety comes from the shape rather than an explicit state machine: the reconnect loop runs on its own goroutine and hands events over an unbuffered channel, so it is never more than one event ahead and a cancelled Next cannot consume one. A gRPC Recv cannot be abandoned mid-flight, which is why the pump exists at all. Also here: - FileCursorStore writes via os.CreateTemp + fsync + rename. The temp name must be unique, not derived: two subscriptions sharing a cursor path in one process would otherwise collide and rename each other's partial file. The on-disk format is byte-identical to the Rust SDK's, so the two can share a file. - A lag re-anchor bumps a generation counter. The pump cannot simply clear an armed cursor, because the caller may not have armed it yet; stamping generations lets the superseded arm be dropped whenever it lands. - Subscribe now waits for the server's response headers before returning. grpc-go starts a server-streaming call without waiting for the server to accept it, so "subscribe, then mine a block" could silently miss the block. tonic awaits headers as a matter of course, so this is parity. Deliberately not done for the bidirectional Watch stream, where a server may withhold headers until it has something to send. - E2E harness: per-node environment (preserved across restart) for the node-side capacity knobs, the RPC cookie re-read on every readiness poll (satd mints a fresh one per start, so caching it made a restarted node look dead), and mineUntilSeen for the Watch control path, which has no per-message ack. E2E covers restart-resume across a real satd restart, reconnect through a cut TCP proxy, and both lag policies - the latter forced deterministically with a shrunk node broadcast buffer and pinned HTTP/2 windows, and asserting that auto-resume loses no block. Every new assertion was perturbed once and observed to fail.
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fourth of eight PRs implementing the Go SDK per
SATD_GO_SDK_PLAN.md. Stacked on #514 — merge order: #512 → #513 → #514 → this.Adds the reconnect-and-replay layer:
CursorStore(+FileCursorStore),Backoff,LagPolicy,ResilientSubscription.Durability
Commit-on-poll, matching the Rust SDK: an event's cursor is persisted only once the caller comes back for the next one, which is an implicit ack. The store never advances past an event the caller has not finished with, so a crash mid-processing replays that event rather than skipping it — at-least-once, not at-most-once. Arming happens in
Nextrather than on the pump goroutine, so an immediately followingCommitcheckpoints the event just returned.FileCursorStorewrites viaos.CreateTemp+fsync+rename. The temp name must be unique rather than derived — two subscriptions sharing a cursor path in one process would otherwise collide and rename each other's partial file (caught by a test that runs eight concurrent writers). The on-disk format is byte-identical to the Rust SDK's, so a Go and a Rust consumer can share a cursor file.Cancel safety
From the shape rather than an explicit state machine: the reconnect loop runs on its own goroutine and hands events over an unbuffered channel, so it is never more than one event ahead of the caller and a cancelled
Nextcannot consume one. A gRPCRecvcannot be abandoned mid-flight, which is why the pump exists at all.Lag
A lag re-anchor bumps a generation counter. The pump cannot simply clear an armed cursor because the caller may not have armed it yet; stamping generations lets the superseded arm be recognized and dropped whenever it lands. Under
LagAutoResumethe notice is consumed and the recovery cursor persisted immediately; underLagSurfaceit reaches the caller unchanged.Behavior change:
Subscribewaits for stream acceptanceSubscribenow waits for the server's response headers before returning. grpc-go starts a server-streaming call without waiting for the server to accept it, so "subscribe, then mine a block" could silently miss the block — a bad default for an event API. tonic awaits headers as a matter of course, so this is parity with the Rust SDK, not a new invention; it costs one round trip (~1ms measured). Deliberately not done for the bidirectionalWatchstream, where a server is free to withhold headers until it has something to send.Tests
Unit: backoff growth/clamping, cursor-store round trip and corruption rejection (each field parsed at its real width, so a wrapped u32 errors instead of silently resuming from the wrong height), concurrent writers, reconnect replay anchoring, commit-on-poll ordering, store seeding and write elision,
ReplayGapsynthesis on a clamped replay, both lag policies, non-retryable surfacing, retry budget, cancel safety, store-failure surfacing without losing the held-back event.E2E: restart-resume across a real satd restart (new port, new publisher instance id), reconnect through a cut TCP proxy, and both lag policies — forced deterministically with a shrunk node broadcast buffer (
SATD_EVENT_BROADCAST_CAPACITY) plus pinned HTTP/2 windows, since grpc-go floors the window at 64 KiB where tonic can go lower. The auto-resume test asserts no block is lost, not merely that noLaggedsurfaced.All 16 new assertions were perturbed once and observed to fail.
Harness fixes
Per-node environment (preserved across restart) for node-side test knobs; the RPC cookie re-read on every readiness poll — satd mints a fresh one per start, so caching it made a restarted node look dead;
mineUntilSeenfor the Watch control path, which has no per-message ack.