feat(qwp): stop resending the full symbol dictionary on every message#66
feat(qwp): stop resending the full symbol dictionary on every message#66glasstiger wants to merge 2 commits into
Conversation
Previously every QWP ingress message re-sent the entire symbol
dictionary, so a connection with many distinct symbols paid to
retransmit the whole dictionary on every message. The client now
sends each symbol id to the server only once per connection.
Memory mode:
- The producer keeps a monotonic "sent" watermark and each frame
carries only the ids above it (a delta section), instead of the
full dictionary from id 0.
- On reconnect or failover the fresh server has an empty dictionary,
so the I/O thread replays the whole dictionary as a catch-up frame
before any post-reconnect traffic, keeping the producer's monotonic
baseline valid across the wire boundary.
Store-and-forward (file mode):
- Each slot persists its dictionary to a dot-prefixed side-file
(PersistedSymbolDict) using write-ahead ordering: new symbols are
appended before the referencing frame is published, so a recovered
or orphan-drained slot on a fresh process can always rebuild the
dictionary that a delta frame references.
- The persistence does not fsync, matching the rest of
store-and-forward, which is process-crash durable (the page cache
survives) but not host-crash durable. A host crash that tears the
dictionary is caught at replay by a guard that fails the send
cleanly ("resend required") instead of transmitting a gapped frame
that would corrupt the table.
Catch-up split:
- The reconnect/recovery catch-up splits across as many frames as the
server's advertised batch cap requires, so a dictionary larger than
the cap is re-registered without any single frame exceeding it. The
frames carry contiguous id ranges and reassemble on the server
exactly as the original per-frame deltas would.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the java-questdb-client submodule to de86197, which makes the QWP client register each symbol id with the server only once per connection (delta symbol dictionary) instead of re-sending the whole dictionary on every ingress message. The OSS server already parses delta symbol-dictionary frames, so this is the OSS half of a tandem pair with the client PR questdb/java-questdb-client#66 and needs no server change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Tandem OSS PR (submodule bump): questdb/questdb#7374 — merge together. |
[PR Coverage check]😍 pass : 297 / 347 (85.59%) file detail
|
bluestreak01
left a comment
There was a problem hiding this comment.
Review of PR #66 — feat(qwp): stop resending the full symbol dictionary on every message
Reviewing at level 3 (full mission-critical pass: all steps, all reviewer dimensions, per-finding source verification). Note: the subagent tool is unavailable in this environment, so the parallel-reviewer passes and per-finding verification were run inline by the parent session using read/bash against the source and a local build+test run — not delegated. Every finding below was verified against the cited source lines; false positives are listed in Downgraded.
Build/test evidence: mvn -pl core compile clean on JDK 25; DeltaDictCatchUpTest, DeltaDictRecoveryTest, PersistedSymbolDictTest, SelfSufficientFramesTest, ReconnectTest → 15 tests, 0 failures.
Committed-binary gate: PASS — git diff --numstat shows no binary files; all 10 changed files are .java with numeric line counts.
Critical
C1 — Persisted .symbol-dict accumulates duplicate entries when appendBlocking fails and a later flush succeeds → silent symbol corruption on recovery (file mode, delta enabled). [in-diff]
File: core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java:3660-3676 (persistNewSymbolsBeforePublish), triggered via flushPendingRows (3491/3498) and flushPendingRowsSplit (3574/3582).
Code-path trace (verified):
flushPendingRows runs, in order:
persistNewSymbolsBeforePublish(); // 3491 — appends [sentMaxSymbolId+1 .. currentBatchMaxSymbolId] to .symbol-dict (Files.write, no fsync)
activeBuffer.write(...); // 3494
sealAndSwapBuffer(); // 3495 — calls cursorEngine.appendBlocking(); CAN THROW
advanceSentMaxSymbolId(); // 3498 — SKIPPED on throw
...
resetTableBuffersAfterFlush(keys); // SKIPPED on throw → rows + currentBatchMaxSymbolId preservedsealAndSwapBuffer → appendBlocking throws LineSenderException("cursor SF append failed", …) on the two documented conditions (QwpWebSocketSender.java:3768,3783-3785): backpressure deadline (the SF ring hit sf_max_total_bytes and did not drain — i.e. exactly the store-and-forward stress scenario, server slow/down) and PAYLOAD_TOO_LARGE. The I/O loop is not failed, so cursorSendLoop.checkError() passes and the sender stays open and usable.
On the throw: the frame's new symbols are already durably on disk (persist ran before sealAndSwapBuffer), but sentMaxSymbolId was not advanced (advanceSentMaxSymbolId at 3498 skipped) and the table buffers/currentBatchMaxSymbolId are not reset (resetTableBuffersAfterFlush skipped — verified: currentBatchMaxSymbolId is reset only at 3607, 3686, and inside resetTableBuffersAfterFlush, none of which run on this path).
The next successful flush() (a transient backpressure clears the moment the server catches up) re-enters persistNewSymbolsBeforePublish with the same from = sentMaxSymbolId + 1 (3668) and to = currentBatchMaxSymbolId (3669) — because pd.appendSymbol has no dedup (PersistedSymbolDict.java:appendSymbol) and nothing rolled back the earlier append, the failed frame's symbols are written to the file a second time. The file's positional invariant ("symbol id i is the i-th entry", PersistedSymbolDict.java class doc) is now broken.
Impact on recovery/orphan-drain (a fresh process reads the file):
seedGlobalDictionaryFromPersisted(2243/3695) callsgetOrAddSymbol, which de-dupes → producerglobalSymbolDictionary.size()andsentMaxSymbolIdare below the file's entry count.- The send loop's constructor seeds the mirror directly from the raw file bytes with
sentDictCount = pd.size()(CursorWebSocketSendLoop.java:515-522), i.e. including the duplicate. sendDictCatchUpre-registers the duplicated mirror on the fresh server, so every global id above the duplicate is shifted by +1.- Symbol column cells are encoded as absolute global ids (
QwpColumnWriter.writeSymbolColumnWithGlobalIds, line 277buffer.putVarint(globalId)). The replayed frames carry the original ids, which now resolve against the shifted server dictionary → rows get the wrong symbol values, silently. The torn-dictionary guard does not catch this (deltaStartnever exceeds the now-largersentDictCount, sotrySendOneat 2223-2238 passes).
This is a store-and-forward data-integrity violation triggered by an ordinary transient outage — the exact failure class SF exists to survive.
Suggested fix: base the append range on the true persist watermark, not the wire baseline. pd.size() already tracks how many symbols are durably persisted at contiguous ids 0..size-1:
int from = pd.size(); // instead of sentMaxSymbolId + 1
int to = currentBatchMaxSymbolId;
if (to < from) return;
for (int id = from; id <= to; id++) pd.appendSymbol(globalSymbolDictionary.getSymbol(id));In the happy path pd.size() == sentMaxSymbolId + 1, so behavior is identical; after a failed append it skips the already-persisted ids, making the operation idempotent across retries. Add a regression test: file mode + delta, force an appendBlocking failure (small sf_max_bytes + silent server), then a successful flush, then assert .symbol-dict has no duplicate and a fresh-process recovery reconstructs the dictionary gap-free.
C2 — Required Enterprise failover tandem is missing/unlinked; the HA path this feature targets is UNTESTED in CI (Step 2.7 gate). [tandem]
Verification (commands recorded):
- OSS tandem:
gh pr list --repo questdb/questdb --head qwp-delta-symbol-dict→ #7374 present, matching branch, bidirectionally linked (body: "Tandem OSS half of #66"; a PR comment links back). It is a submodule bump only — "The OSS server already parses delta symbol-dictionary frames, so no server change is required." Its CI covers single-node QWP e2e. - Enterprise tandem:
gh pr list --repo questdb/questdb-enterprise --head qwp-delta-symbol-dict→ empty.ghcan reach the private enterprise repo (confirmed), and a scan of the 60 most-recent enterprise PRs shows no client-bump/qwp-symbol-dict PR.SqlFailoverQwpClientLosslessTestexists in enterprise (questdb-ent/src/test/java/com/questdb/lifecycle/), and the PR body claims it "passes end-to-end against a real server" — but with no enterprise PR bumping the client submodule, that test runs against the old client in enterprise CI, not this change.
Why this trips the gate: the change is squarely HA-facing — it rewrites the SF drainer's on-the-wire framing, adds reconnect/failover dictionary catch-up (swapClient → setWireBaselineWithCatchUp → sendDictCatchUp), and adds recovery/orphan-drain dictionary rebuild. The headline benefit (dictionary survives a reconnect/failover) is only proven end-to-end by the enterprise failover suite the PR itself names. Per Step 2.7, a required-but-missing tandem is Critical and every behavior it would cover is treated as UNTESTED. The client-local loopback tests (C-tier coverage below) are strong, but they cannot prove (a) a real server accepts and correctly registers a 0-table catch-up frame mid-stream, or (b) primary→replica failover preserves the dictionary.
Required action: open (or link) the enterprise tandem that bumps the client submodule to this SHA and runs SqlFailoverQwpClientLosslessTest (and, ideally, a kill-9 recovery variant in the enterprise e2e-python suite for the file-mode host-crash/torn-dict path, which the unit test only simulates by truncating the file). Also confirm OSS #7374's e2e actually drives a reconnect (so the catch-up frame is exercised against a real server), not just a single connected ingest.
Moderate
M1 — One Files.write syscall per new symbol on the producer thread. [in-diff]
persistNewSymbolsBeforePublish (3660-3676) loops pd.appendSymbol(...), and each appendSymbol (PersistedSymbolDict.java) issues its own Files.write(fd, …) (one pwrite). A frame that introduces K new symbols does K syscalls on the user/producer thread. This is per-new-symbol (not per-row), so it's bounded by dictionary growth, but a high-cardinality first batch will burst syscalls synchronously in the flush path. Batch the frame's whole new-symbol range into a single scratch buffer and one Files.write. Not zero-GC-blocking (no allocation), but avoidable syscall amplification on the ingestion path.
M2 — accumulateSentDict silently drops symbols on a partial-overlap delta. [in-diff]
CursorWebSocketSendLoop.java:1946-1960: the guard is if (deltaCount <= 0 || deltaStart != sentDictCount) return;. A delta with deltaStart < sentDictCount and deltaStart + deltaCount > sentDictCount (overlaps the tip and extends past it) is dropped entirely — the new tail symbols never enter the mirror, so a later catch-up would be incomplete (→ the same shifted-id corruption as C1). I verified this is currently unreachable: the producer emits strictly contiguous, non-overlapping deltas (beginMessage computes deltaStart = confirmedMaxId+1; advanceSentMaxSymbolId moves the baseline to exactly currentBatchMaxSymbolId), and recovery seeds sentDictCount from a superset, so deltaStart < sentDictCount ⇒ deltaStart+deltaCount ≤ sentDictCount. But it is load-bearing correctness resting on an invariant enforced elsewhere. Harden it: handle the partial overlap (accumulate only the [sentDictCount .. deltaStart+deltaCount) tail) or assert deltaStart + deltaCount <= sentDictCount so a future producer change fails loudly instead of silently corrupting the mirror.
Minor
m1 — Stale "self-sufficient / delta from id 0" comments now contradict delta mode.
QwpWebSocketSender.java:3392, 3398-3399, and 3777 still say cursor frames are "self-sufficient (every frame carries … a symbol-dict delta from id 0)". In delta mode frames are explicitly not self-sufficient (the whole point of the PR), and the 3777 comment ("next batch re-emits … symbol-dict delta from id 0") describes behavior that no longer happens. Update to match the new baseline semantics to avoid misleading a future reader on the recovery/retry path (which is exactly where C1 lives).
m2 — Memory-mode mirror double-stores the dictionary.
The I/O-thread mirror (sentDictBytes*) holds every symbol's UTF-8 bytes while globalSymbolDictionary already holds them as Java Strings. Bounded by distinct-symbol count (not per-row), so acceptable, but worth a comment that memory-mode steady-state native footprint is ~2× the dictionary size for the reconnect-catch-up capability.
Downgraded (false positives — verified against source)
- Negative
fsnAtZeroon fresh recovery (replayStart=0⇒fsnAtZero = -catchUpFrames) corrupts ack accounting — dismissed.SegmentRing.acknowledgeclamps topublishedFsnand no-ops whenseq ≤ ackedFsn(339-349); the catch-up frame maps to an already-acked/nonexistent low FSN and its ack is a harmless no-op.DeltaDictRecoveryTestexercises exactly this (silent server, nothing acked) and passes. pd.size()read race in the send-loop constructor vs producerappendSymbol— dismissed. The loop is constructed during sender build/startCursorSendLoop(or on the drainer thread with no producer at all), which happens-before the first user send; no concurrent append occurs, sosentDictCount == loadedEntriescount.- Catch-up frame double-advances the durable-ack watermark — dismissed. The catch-up frame's OK enqueues a
tableCount=0(trivially durable) pending entry mapping to an ≤ackedFsnFSN;drainPendingDurableacks a no-op. Cumulative ack semantics make a missing catch-up OK harmless too. - Catch-up (non-
DEFER_COMMIT) frame prematurely commits deferred WAL on reconnect — dismissed. It is the first frame on a fresh server connection, which holds no pending WAL state; committing nothing is a no-op before the deferred replay frames arrive. positionCursorForStartre-sends a catch-up when retiring an orphan tail — dismissed. That branch is guarded bynextWireSeq == 0(trySendOne2166-2175), which cannot hold aftersendDictCatchUpincrementednextWireSeq; whensentDictCount==0there is nothing to re-send.- A symbol larger than the batch cap breaks catch-up — dismissed. The original data frame carrying that symbol (plus row data) would already exceed the cap and fail; the catch-up (symbol only, less overhead) is strictly smaller, so
sendDictCatchUp'sentryBytes > budgetterminal is consistent, not a new failure. - Java 8 floor violations in new code — dismissed. No
var, text blocks,instanceofpatterns,List.of, etc. in the changed main files; the one->is a pre-existing lambda. Compiles clean on JDK 25. PersistedSymbolDictuses slf4j instead of QuestDBLog— dismissed. Its sibling SF-cursor classes (AckWatermark,SegmentRing,CursorSendEngine, the send loop) all use slf4j; this is consistent.
Coverage map
| # | Behavioral change | Test (local unless noted) | Failure link | Dimensions | Verdict |
|---|---|---|---|---|---|
| 1 | Memory-mode monotonic delta (symbolDeltaBaseline in beginMessage) |
SelfSufficientFramesTest.testMemoryModeShipsMonotonicDelta |
asserts batch-2 deltaStart=1,deltaCount=1 — fails if baseline reverts to -1 |
happy ✓; NULL N-A; boundary (2 symbols) ✓; concurrency N-A | TESTED |
| 2 | File-mode delta + write-ahead persist | SelfSufficientFramesTest.testFileModeShipsMonotonicDeltaAndPersistsDict |
asserts monotonic delta + .symbol-dict retains both symbols |
happy ✓; resource (dict file) ✓ | TESTED |
| 3 | Reconnect catch-up (memory) | DeltaDictCatchUpTest.testReconnectCatchUpRebuildsDictionary |
reconstructs conn-2 dict from wire; fails on null gap | happy ✓; reconnect ✓ (loopback) | TESTED |
| 4 | Split catch-up under batch cap | DeltaDictCatchUpTest.testReconnectCatchUpSplitsLargeDictionaryAcrossFrames |
asserts ≥2 zero-table frames + gap-free reassembly | boundary (cap) ✓ | TESTED |
| 5 | File-mode recovery replay to fresh server | DeltaDictRecoveryTest.testRecoveredSlotReplaysDeltaFramesAgainstFreshServer |
asserts catch-up frame seen + gap-free dict | recovery ✓ (loopback); memory-leak N-A | TESTED |
| 6 | Torn-dictionary guard (simulated host crash) | DeltaDictRecoveryTest.testTornDictionaryFailsCleanlyInsteadOfCorrupting |
asserts 0 frames replayed + terminal "incomplete" error | error path ✓ | TESTED |
| 7 | PersistedSymbolDict open/append/reopen/torn-tail/bad-magic/removeOrphan |
PersistedSymbolDictTest (5 tests, assertMemoryLeak) |
round-trip + self-heal asserts | happy/boundary/empty-symbol/resource ✓ | TESTED |
| 8 | appendBlocking failure → persist-then-retry dict duplication (file mode) |
none (recorded search: no test references appendBlocking/backpressure/dup + persisted dict) |
— | error+retry ✗; recovery-after-retry ✗ | UNTESTED → Critical (C1) |
| 9 | Real-server 0-table catch-up acceptance + primary→replica failover | OSS tandem #7374 (single-node only); Enterprise tandem missing | — | real-server/failover ✗ | UNTESTED → Critical (C2) |
| 10 | seedGlobalDictionaryFromPersisted id/baseline resume on recovery |
indirect via DeltaDictRecoveryTest #5 |
dict reconstructed gap-free implies correct seed | happy ✓; retry-dup interaction ✗ (see C1) | TESTED (partial) |
Summary
Verdict: REQUEST CHANGES.
The design is careful and the write-ahead/torn-dictionary reasoning is largely sound, but two blocking issues stand:
- C1 (data integrity): a transient
appendBlockingbackpressure failure followed by any successful flush duplicates the failed frame's symbols in the persisted.symbol-dict; a later recovery/orphan-drain then silently misattributes symbol values via shifted global ids. This is a store-and-forward correctness violation on the very outage class SF exists to survive, it has no regression test, and the fix is small (base the persist range onpd.size()). - C2 (test gate): the HA failover behavior the feature targets has no linked, CI-running enterprise tandem; the OSS tandem #7374 covers single-node only.
Test & tandem gate: FAILS — one UNTESTED-Critical bug-fix-worthy path (C1, no regression test) and a required-but-missing Enterprise tandem (C2). Cannot approve.
Zero-GC gate: PASSES — no steady-state per-row/per-producer-call allocation on the ingestion path; producer-side additions (symbolDeltaBaseline, advanceSentMaxSymbolId, persistNewSymbolsBeforePublish) allocate nothing (M1 is syscall amplification, not GC). Catch-up/mirror allocations are I/O-thread, reconnect-only.
Coverage map: 10 behavioral-change groups — 8 tested locally (loopback), 2 UNTESTED (dict-dup-on-retry; HA-failover tandem).
Tandem status: OSS e2e tandem linked (#7374, single-node); Enterprise failover tandem required and missing; enterprise e2e-python kill-recovery coverage for the host-crash/torn-dict path recommended.
Findings: 6 verified (2 Critical, 2 Moderate, 2 Minor); 8 draft findings dropped as false positives after source verification.
In-diff vs out-of-diff: 4 in-diff (C1, M1, M2, m1), 1 tandem/process (C2), 1 cross-cutting (m2). The C1 mechanism spans the new persistNewSymbolsBeforePublish (in-diff) and the pre-existing sealAndSwapBuffer/appendBlocking failure path (out-of-diff) it now interacts with — the classic "diff quietly changed a contract at an unchanged callsite" case.
Summary
Every QWP ingress message used to carry the entire symbol dictionary, so a connection that ingests many distinct symbols re-transmitted the whole dictionary on every message. This change makes the client register each symbol id with the server only once per connection and send only new ids (a delta) thereafter, re-registering the full dictionary when a connection is replaced.
The bandwidth saving grows with symbol cardinality and message count; for low-cardinality or short-lived connections it is negligible, and the change adds the costs described under Tradeoffs.
What changed
Memory mode
Store-and-forward (file mode)
PersistedSymbolDict) so a recovered or orphan-drained slot on a fresh process -- which has no in-memory dictionary -- can rebuild what its (non-self-sufficient) delta frames reference.Catch-up split
Durability
The persisted dictionary intentionally does not fsync, matching the rest of store-and-forward: it is process-crash durable (the OS page cache survives a JVM crash) but not host-crash durable. Rather than fsync only the dictionary -- which would not make the frame data itself host-crash durable -- a host crash that tears the dictionary is caught at replay: the send loop detects a delta frame whose start id exceeds the recovered dictionary and fails cleanly with a "resend required" error instead of transmitting a gapped frame that would corrupt the table.
Tradeoffs
Test plan
DeltaDictCatchUpTest-- reconnect catch-up rebuilds the dictionary (memory mode); a large dictionary splits across multiple catch-up frames under a small advertised batch cap and reassembles gap-free.DeltaDictRecoveryTest-- a recovered file-mode slot replays its delta frames against a fresh server; a torn (host-crash) dictionary fails cleanly instead of corrupting.PersistedSymbolDictTest-- side-file append/read/orphan-removal round trips.SelfSufficientFramesTest,ReconnectTest-- full-dict fallback and reconnect replay still hold.SqlFailoverQwpClientLosslessTest(file-mode failover) passes end-to-end against a real server.🤖 Generated with Claude Code