fix(qwp): recycle the connection after a server NACK - #67
Conversation
After the server rejects a frame it silently refuses every further frame on that connection: the error frame breaks the ordered pipeline, and the client is expected to replay from its acked watermark on a fresh connection. The DropAndContinue path kept draining on the same connection instead, waiting on acks that could not arrive — after the first rejection, ingestion stalled without erroring and clean rows behind the poisoned frame never landed. The receiver now advances the watermark past the dropped frame and returns a plain error, routing run() through the reconnect path so the refused tail replays on a fresh connection, starting past the drop. Pre-send rejections with non-halt policies recycle the same way (nothing to drop there). Mirrors the Java client's post-rejection connection recycle. The test fixture's rejectFirstNFrames now models the server contract: N error ACKs total, at most one per connection, with the connection's pipelined tail refused silently. The dec256-overflow poison expectation follows the server's SCHEMA_MISMATCH classification of deterministic data-shape rejections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThe send loop now recycles connections after non-halting rejection responses, and test servers model silent refusal of subsequent frames. Resilience tests validate replay, drain timing, and halt behavior. Poison-row ingress tests now expect schema-mismatch errors with drop-and-continue handling. ChangesConnection recycling
Schema mismatch validation
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Sender
participant ReceiverLoop
participant QWPServer
Sender->>QWPServer: send ordered frame
QWPServer-->>ReceiverLoop: non-OK rejection ACK
ReceiverLoop->>Sender: offer SenderError and return recycle error
Sender->>QWPServer: reconnect
Sender->>QWPServer: replay frames after dropped frame
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Review: PR #67OverviewAfter a server NACK, the real server silently refuses every further frame on that connection, so the old code's "keep draining on the same connection" stalled without erroring. The fix makes Findings1. Inaccurate comments (minor — addressed in a follow-up commit on this branch)
2. Small nits (take or leave)
Observation: the recycle is unpaced — superseded by pending policy workBackoff engages only on failed dials, so a server NACKing every frame drives full recycle cycles at NACK rate (measured against a mock NACKing pre-send on every connection: ~59 reconnects/sec, indefinitely). This is deliberately not a fix-item for this PR: the Java client and spec have since moved to NACK policy v2 ( Test coverage, performance, security
VerdictThe fix is correct and well-evidenced (root cause, Java cross-check, live-server validation) — approve. The comment corrections from finding 1 are in a follow-up commit on this branch; the NACK-policy-v2 port follows as its own body of work. |
Three comments still described behavior from before the connection recycle landed, and one overstated what the recycle path does: - DispatcherSwapMidFlight annotated rejectFirstNFrames=50 as "50 drops on conn 1"; the mock now issues at most one error ACK per connection, so the 50 drops span 50 connections. - DropAcrossReconnect described conn 1 reading frame 1 and closing via closeAfterFrames as the reconnect trigger. The NACK on frame 0 recycles the connection itself, so frame 1 normally goes straight to conn 2; closeAfterFrames=2 remains only as a backstop for the race where frame 1 still lands on conn 1 before teardown. The header and inline comments now describe that choreography. - receiverLoop's Drop-path comment called connectWithBackoff "the same paced path a transport failure takes". The round-walk sleeps only after failed dials, so a healthy server that just NACKed is redialed immediately and no pacing engages. The comment now states that explicitly instead of implying the recycle is backed off. Comment-only change; no behavior is affected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings in the mainline QWP work (#66 connect-string key consolidation, #67 connection recycle) on top of the qwip_go feature branch (delta symbol dictionary, NACK policy v2, the QuestDB facade, durable-ack). Conflict resolution notes: - NACK policy: kept v2 (PolicyRetriable/Terminal + poison-frame detector); main's PolicyHalt/DropAndContinue is superseded and undefined post-merge. - Config keys: adopted main's #66 policy per request — sf_max_segment_bytes rename, canonicalConfigKey aliases, duplicate-key rejection, transaction / UDP-key handling, and the strict QWP key vocabulary (gorilla, in_flight_window, token_x/token_y, protocol_version, egress auth are rejected/removed). Kept ours' orthogonal features (facade pool keys, durable-ack, connection-listener storage, delta symbol dictionary). - connect_timeout: combined both branches' edge-case fixes (reject negatives, floor sub-millisecond positives to 1ms). - README: dropped the duplicated legacy QWP section in favour of the reorganized facade-first structure. go build ./..., go vet ./..., and the unit suite all pass.
Fixes the ingestion stall behind the
qwp-fuzzfailures on #66:TestQwpFuzzIngressOraclePoisonErrorHandlertimed out with only a fraction of the clean rows landing (e.g. 30/790, then 0/235 on rerun with a different seed), and aworkflow_dispatchbaseline on untouchedmainfailed identically — the stall is pre-existing and independent of the rename PR.Root cause. After the server rejects a frame it silently refuses every further frame on that connection (
QwpIngressUpgradeProcessor.handleBinaryMessage: an unresolved sequence means "the client replays it from its acked watermark on a fresh connection"). The go client'sPolicyDropAndContinuepath advanced the watermark past the rejected frame and then kept draining on the same connection — waiting forever on acks the server would never send. Ingestion stalled without erroring after the first NACK. The server log shows the signature: onerejected [status=SCHEMA_MISMATCH...]per connection, then silence with the connection still open.Fix. The receiver still advances the watermark past the dropped frame, then returns a plain error so
run()recycles the wire through the normal reconnect path (connectWithBackoff→swapClient), replaying the refused tail on a fresh connection starting past the drop. Pre-send rejections with non-halt policies recycle the same way. This mirrors the Java client's post-rejection recycle (handleServerRejection: "recycling connection, will replay from fsn ..."), which is why the server's own Java-client e2e never hit this.Tests.
rejectFirstNFramesnow models the server contract: N error ACKs total, at most one per connection, with the connection's pipelined tail refused silently.TestQwpSfSendLoopDropAndContinueasserts the recycle;DropStreakThenHaltgot a pipeline-faithful custom mock;DispatcherDrainTimeoutCapuses per-connection rejections with a tighter close bound.SCHEMA_MISMATCHclassification of deterministic data-shape rejections (WRITE_ERRORwas stale; both resolve to the Drop policy).Validation. Local run of the CI race job's exact filter (
go test -race -run '^TestQwp(Integration|FuzzIngress)') against a freshly built server master jar: 73/73 pass, no data races. The previously stalling seed now finishes in ~4s: all 235 clean rows land andhandlerCalls == poisonedChunks == 21. Full-shortsuite green.Merging this should turn #66's
qwp-fuzzcheck green (that branch will pick the fix up via rebase or merge once this lands).🤖 Generated with Claude Code
Summary by CodeRabbit