Repository navigation
Conversation
doSend() waited on EPOLLOUT before every send(), costing epoll_ctl + epoll_wait per WebSocket frame even when the socket was writable. Try send() first and fall back to ioWait only when the socket is full (send returns ERETRY/0), mirroring recvOrDie's recv-first pattern.
trySendOne sent one WebSocket frame per send() syscall even with many frames already published. Walk all fully published frames up to the send buffer's capacity, encode them back to back (exact-size masked headers), and flush with one doSend. ACK fairness is now bounded by the byte cap rather than by one frame per iteration. Per-frame checks and bookkeeping are unchanged; early exits flush appended frames first so behaviour matches the old path where earlier frames were already sent.
tryParseFrame compacted the unread tail after every parsed frame, so a burst of N small frames in one recv cost N memmoves of a shrinking tail (quadratic in the burst; 82% of the I/O thread's CPU under per-frame server acks). Rewind the cursors only when the buffer is fully consumed and compact a partial tail once, right before the next recv and before the grow check, so the buffer state at every recv is unchanged.
The batch cap followed the send buffer's live capacity, which only ever grows, so one large frame raised the ACK-fairness window for the rest of the connection. Use the default send buffer size as a constant cap; a frame larger than it still goes out alone.
Review: perf(qwp): batch WebSocket sends and cut per-frame syscallsVerdict: approve with comments. There are no Critical findings and the test gate passes. I'd like the two Moderate items fixed in this PR, but neither blocks the merge. Reviewed head What I verified:
Moderate1. A failed batch flush inflates
|
sendRow() had just timestamped that row, so its age is zero and the check could never fire; it cost a clock read per flush for per-row producers (~10% of producer throughput at saturation).
Explicit opt-out only; the default interval is unchanged. With the interval off, row-cap and byte-cap auto-flush and explicit flush() still apply, and the sender reads no clock on the row path at all (measured +21% producer throughput for per-row flushers together with the previous commit).
Review: PR #121 at level 3 (
|
The branch accepted auto_flush=off / disableAutoFlush() on ws:: along with auto_flush_interval=off, because both store the interval as Integer.MAX_VALUE and the WebSocket validation keyed on that value. Fully disabling auto-flush on WebSocket left auto_flush_bytes in force, so auto_flush=off;auto_flush_bytes=N still flushed every N bytes, contrary to the disableAutoFlush() contract. The builder now records disableAutoFlush() in its own flag, and the WebSocket validation rejects that flag again, as main does. Only the time-based trigger can be turned off on WebSocket, with auto_flush_interval=off; auto_flush_rows=off and auto_flush_bytes=off remain the per-trigger opt-outs. The disableAutoFlush() javadoc and the tests that the branch had flipped are restored, and QuestDBBuilderTest pins both sides: auto_flush=off is rejected, also with auto_flush_bytes, while auto_flush_interval=off is accepted alone and with the row trigger off.
|
Reviewed PR #121 at level 3, head Verdict: approve. No Critical or Moderate findings were admitted, and the test gate passes. Two Minor test-code items remain. CriticalNone. ModerateNone. Minor1. New test methods are out of alphabetical order
2. Unused
Coverage map
Summary
|
BlockingDoorbellClient overrode the one-slice sendBinary to append and flush a single frame, but the send loop no longer calls that overload: data frames go through batchAppendBinary and batchFlush, and the dictionary catch-up uses the two-slice sendBinary. Drop the dead override and its comment.
[PR Coverage check]😍 pass : 108 / 121 (89.26%) file detail
|
|
I reviewed PR #121 at level 3, the full review. Verdict: approve. I found no Critical or Moderate issues, the test gate passes, and only a few cosmetic items remain in the tests. PR metadata: the title follows Conventional Commits, and the description explains the user impact and cross-links both tandem PRs. The label "QUEUED FOR MERGE" is already set. Submodules: no submodule pointer moved. The only submodule is zstd, which is untouched. No binary files are committed. CriticalNone. ModerateNone. Minor1. Three new test methods are out of alphabetical order (raised in earlier review rounds and still not fixed)
2. A new boolean field lacks the
Coverage map
Summary
|
Tandem: questdb/questdb#8240, questdb/questdb-enterprise#1396
Makes the QWP WebSocket sender much cheaper per frame. Applications that call
flush()per row, or run many small flushes, get up to 2x more throughput per connection on a real network and a fraction of the client CPU; applications that flush large batches are unchanged. Wire bytes are identical, only how frames are grouped into TCP writes changes.What changes
doSendno longer waits onepollbefore everysend; it waits only when the socket is full. Two syscalls fewer per frame.send. The I/O loop now sends every frame already published, up to 64 KiB per write, instead of one frame per pass. ACKs are still read between batches, so the fairness window is bounded by the cap, and a frame larger than the cap goes out alone.auto_flush_interval=offis now accepted forws::/wss::as an explicit opt-out; the default stays 100 ms. With it off,auto_flush_rows,auto_flush_bytesand explicitflush()still apply, and the sender reads no clock on the row path. Disabling auto-flush entirely (auto_flush=off,disableAutoFlush()) stays rejected on WebSocket.recv.Measured
Client on a Ryzen desktop, server a Rust QWP mock on a Raspberry Pi 5 over 1 GbE (0.17 ms RTT), main vs this branch, 3 interleaved reps:
flush(), saturationflush(), 10k flushes/s, I/O thread CPUflush(), 50k flushes/s, I/O thread CPUflush()Same client against the mock on a 20-core x86 box over 2.5 GbE (0.31 ms RTT), client pinned to one chiplet, 3 interleaved reps:
flush(), saturationauto_flush_interval=off), both client threads ~100% (client-bound, link not saturated)flush(), 10k flushes/s, I/O thread CPUflush(), 50k flushes/s, I/O thread CPUflush()On loopback (no link limit) the per-row saturation case goes from 136k to 2.8M rows/s with client CPU per flush dropping from 14.7 µs to 0.6 µs. The lazy compaction is neutral against a server that acks per read pass and bounds a pathological cost when hundreds of acks arrive in one recv (I/O thread 100% to 2% in that stress).
Notes
sendtimeout now makes one attempt before timing out; no caller passes one.init_buf_size/max_buf_sizeremain rejected forws::; the 64 KiB batch cap is a constant, not a knob, because measurements showed batch size is set by how far the producer runs ahead, not by the cap.