Repository navigation
Conversation
A QWP sender burned ~70% of a core per connection even when idle: the I/O thread polled the socket and the segment ring every 50 us. Kafka Connect users saw 30% CPU on QWP vs 3% on HTTP for the same quiet sink. The I/O thread now blocks until there is something to do: socket readable, a producer published a frame, close(), or a PING is due. The producer wakes it through a native doorbell (eventfd on Linux, kqueue EVFILT_USER on macOS/FreeBSD, a kernel Event on Windows), and only when the thread is parked, so a busy connection never pays a syscall per flush. Ack waiters, the segment manager and the dispatchers are event-driven as well: a waiter in awaitAckedFsn/drain is unparked on every ack advance, terminal and durability latch; the segment manager parks until a rotation, ACK or timer needs it and persists the ack watermark on a 1 ms cadence while ACKs stream; the progress dispatcher thread starts only when a handler is installed. No change to the Sender API or configuration. Idle CPU drops to 0, flush-to-ack latency falls 3-15x and saturation throughput rises ~20%. Above ~25k small flushes per second per connection the client uses 1.1-1.45x the CPU of the polling loop: the I/O thread takes two prompt wakes per flush (doorbell, then ACK). design/tla holds TLA+ models of the two wake protocols.
… in column lookup ensureCapacity() carried the realloc in the same body, which was large enough that the JIT would not inline it into every putLong/putDouble. The check is now one compare against a precomputed limit address with the realloc in grow(). lookupColumn() first compares the column name by identity, which callers passing String constants hit every row. asipc, mock server, 60M rows, 1000 rows/flush, 3 interleaved reps: instructions/row -26..-68, rows/s +1..2% across numeric/symbol/string.
Only recovery reads a frame's CRC, and a memory-backed segment is never
recovered, yet every frame paid a CRC pass over all its bytes: 13% of
the producer's cycles at one row per flush. The CRC field stays, zeroed,
so both backings keep one frame layout; disk segments are unchanged
(inverting the condition fails the recovery tests).
Measured on asipc against the batching mock, producer and I/O thread
pinned to CPUs 2 and 4, 5 interleaved reps, medians:
1 row/flush: rows/s +7.2%, producer instructions/row -4.4%
1000-row numeric: rows/s +2.5% against the baseline's fast mode
(+22.9% against its median: the baseline runs are
bimodal, 28.0-28.8M or 34.6M; this one is steady
at 34.8-36.4M), instructions/row -3.2%
1000-row symbol: +4.3%; 1000-row string: +3.9%
Encode on a local cursor and leave for the general loop at the first non-ASCII char. Writing the appendAddress field per char, interleaved with Unsafe stores, kept the JIT from holding the address in a register. String column (80M rows, 1000 rows/flush, pinned): +8.6% rows/s, producer instructions -9.0%. Other shapes unchanged.
Vect.memcpy copies up to 64 bytes with 8-byte moves instead of the Unsafe.copyMemory stub, and NativeBufferWriter.putBlockOfBytes goes through it. At one row per flush each column is a few bytes, and the stub's call and dispatch cost more than the copy: 7.3% of the producer. One row per flush, pinned: +8.3% rows/s, producer instructions/row -7.5%. 1000-row shapes unchanged.
Review (level 3): approve with commentsReviewed HEAD Nothing blocks the merge. Two Moderate findings have runtime evidence on HEAD and base; please address #1 before merging. CriticalNone. Moderate1. A failed rotation followed by close leaks one segment from the pool's shared budget
Path:
Base, same trigger: the spare stayed installed as Limits:
Suggested fix: make Repro output (scratch test: disk mode, 64 KiB segments, shared budget, manifest fsync made to fail)2. With
|
| Setup | Base | HEAD |
|---|---|---|
| tmpfs, unpaced | 3009–3042 MB/s, rotation 24 µs | 2192–2443 MB/s, rotation 338–496 µs |
| tmpfs, 100 MB/s | 0.63 ms/s in rotations | 11.7 ms/s |
| tmpfs, 300 MB/s | 1.8 ms/s | 33.7 ms/s |
| ext4/NVMe | — | equal or better: no spare-wait stalls, lower max latency, about 1.75× unpaced throughput |
- Magnitude: one stall per 4 MiB of encoded data, about 1–3% of producer time at 100–300 MB/s. The stall lands on whichever
flush()triggers the rotation. - Base: the manager thread pre-created a hot spare, so the producer only rebased it and updated the manifest. Nothing rejects tmpfs for
sf_dir. - Optional fix: keep synchronous creation as the fallback, but have the manager pre-create a spare off the producer thread once the active segment is about ¾ full. That keeps the ext4 gain and takes the tmpfs
fallocateout offlush(). - Description: the PR description doesn't mention this design change. Please add it there: the hot spare is gone, segments are created on the producer thread, and the ack watermark is now written by the I/O thread on every ACK.
Minor
None.
Coverage
- Test gate: passes. There is no Critical coverage gap.
- Moderate gap, no tandem: the change touches store-and-forward behaviour (idle wait, reconnect replay, ack waiters), but there is no
questdborquestdb-enterprisePR onperf/qwp-idle-cpu-low-risk(searched withgh pr list --head).- The local tests and the TLA+ models cover the wake protocol.
- A real-server failover/reconnect run against the event-driven loop is missing.
- Please run the Enterprise failover suite against this branch before bumping the submodule.
- CI: GitHub Actions, the macOS Azure leg and the coverage check are green. The Windows and linux-x64 Azure legs were still pending when I checked; the Windows
WSAEventSelectwait code only runs there.
Summary
- Verdict: approve with comments. Please address ci(build): set file encoding to utf-8 for logging #1.
- Findings: 0 Critical, 2 Moderate, 0 Minor. Both are in the diff itself.
- Tradeoff: idle CPU drops to zero and disk-mode throughput on ext4/NVMe goes up. In exchange, rotations are slower when
sf_diris on tmpfs (chore(conf): update JDK version from 17 to 11 and change tag to 1.0.0 #2). - Checked, no defect found:
- the I/O loop's park/publish handshake (
ioParkedagainstpublishStamp) - the doorbell's lifecycle against close
recvDrainedand TLS buffered inputAckWaiters- concurrent writes and syncs of the ack watermark (a torn record only causes duplicates)
- manager wakeups covering every trim case
- all
Vect.memcpycallers (none depends on overlap-safe copies at 64 bytes or less) OffHeapAppendMemory's capacity limit staying consistent- readers of the frame CRC that memory segments now leave at 0
- the store-and-forward invariants: no reconnect time budget, and no transport errors surfaced to the producer
- the I/O loop's park/publish handshake (
- The perf(qwp): event-driven I/O loop, zero CPU when idle #114 review items are fixed on this branch.
A rotation whose manifest update throws hands its new segment back to the SegmentProvisioner, still charged to the SegmentBudget, so a retry can reuse it. SegmentManager.deregister released only SegmentRing.totalSegmentBytes(), which did not count that segment, and SegmentProvisioner.close() closed it without a release. A pooled sender closed after such a flush failure therefore left one segment charged to the pool's shared budget for the life of the pool. SegmentSource gains heldBytes(), which the provisioner reports for its retained segment, and totalSegmentBytes() includes it. Deregister now releases the whole ring, as it did for the hot spare before. Close still does not release, so the charge is returned exactly once. SegmentManagerSharedBudgetTest injects a manifest fsync failure and checks that deregister returns the budget to zero, and that repeated failed rotations and the retry that finally commits charge one segment only.
Preserve event-driven I/O and ACK wakeups with tier-aware durable acknowledgements. Keep constructor entry points from both parents and cover idle local and replicated ACK waits.
Drop boolean QWP forwarding overloads, obsolete HTTP factories and polling constructors. Remove the unused progress-dispatcher capacity and migrate internal callers and tests to the current interfaces.
Review (level 3): approve with commentsReviewed HEAD CriticalNone. Moderate1. With
|
| Backing | Metric | Base | Head |
|---|---|---|---|
| tmpfs | rotating append | 28–35 µs | 485–570 µs |
| tmpfs | throughput | 2300–2800 MB/s | 1770–2030 MB/s |
| ext4/NVMe | rotating append | ~31 ms | ~18 ms |
| ext4/NVMe | throughput | ~119 MB/s | ~197 MB/s |
| memory | p50 append | 21 µs | 1 µs |
Path: Sender.flush() → QwpWebSocketSender.sealAndSwapBuffer → CursorSendEngine.appendBlocking → SegmentRing.appendOrFsn:1061 → SegmentProvisioner.next. That method runs MmapSegment.create (preallocates 4 MiB), syncHeader and fsyncDir (SegmentProvisioner.java:169-176). In base, the manager thread built the spare ahead of time, triggered at ¾ fill. The producer's rotation then only synced the header and updated the manifest.
Who and how often:
- Only senders whose SF directory is on tmpfs, such as
/dev/shmor a memory-backedemptyDir. Memory mode and ext4 are faster on head. - One stall per 4 MiB published, landing on whichever
flush()triggers the rotation. Real encoding caps throughput far below the probe's rate, so the share of producer time is smaller than the unpaced figures suggest. - It is a bounded cost and not blocking.
Suggested fix:
- Record the design change in the description, since it becomes the squash commit message:
- the hot spare is gone;
- the producer thread now creates segments, including
fsyncDir; - the I/O thread now writes the ack watermark on every ACK.
- Optionally, have the manager pre-create the next segment off-thread (for example at ¾ fill) and keep synchronous creation only as the fallback.
Probe method and raw output
- Setup: scratch JUnit probe in worktrees at
28c84830and084cb471, samelibquestdb.so, JDK 25.0.4 (Corretto), Linux x86-64. tmpfs =/dev/shm, ext4 = NVMe. - Workload:
new CursorSendEngine(dir, 4 MiB)(owned manager, default MEMORY durability). 200 warmup appends, then 4000 × 64 KiBappendBlocking, withacknowledge(fsn)after each append. topN: the mean of the 62 slowest appends, about one per rotation.- Command:
mvn -q -o -pl core test -Dtest=RotationLatencyProbeTest -Dprobe.runs=3 -Dprobe.appends=4000 -Dprobe.dirs=/dev/shm/pr116probe,/tmp/pr116probe-ext4,MEMORY - Order: base, head, base, head, with 3 runs inside each.
== base iter 1
PROBE /dev/shm/pr116probe MB/s= 2793.7 p50= 22us p99= 26us max= 52us topN(62)mean= 28us
PROBE /tmp/pr116probe-ext4 MB/s= 118.5 p50= 27us p99= 30890us max= 40949us topN(62)mean= 31666us
PROBE memory MB/s= 2801.4 p50= 21us p99= 31us max= 222us topN(62)mean= 34us
PROBE /dev/shm/pr116probe MB/s= 2301.8 p50= 27us p99= 32us max= 42us topN(62)mean= 34us
PROBE /tmp/pr116probe-ext4 MB/s= 119.4 p50= 29us p99= 30806us max= 36547us topN(62)mean= 31283us
PROBE memory MB/s= 2799.3 p50= 21us p99= 30us max= 104us topN(62)mean= 32us
PROBE /dev/shm/pr116probe MB/s= 2438.6 p50= 24us p99= 34us max= 50us topN(62)mean= 35us
PROBE /tmp/pr116probe-ext4 MB/s= 120.1 p50= 25us p99= 30853us max= 36363us topN(62)mean= 31302us
PROBE memory MB/s= 2799.1 p50= 21us p99= 33us max= 55us topN(62)mean= 38us
== head iter 1
PROBE /dev/shm/pr116probe MB/s= 1959.8 p50= 22us p99= 502us max= 802us topN(62)mean= 570us
PROBE /tmp/pr116probe-ext4 MB/s= 199.1 p50= 25us p99= 18437us max= 24718us topN(62)mean= 18218us
PROBE memory MB/s= 45431.0 p50= 1us p99= 10us max= 13us topN(62)mean= 10us
PROBE /dev/shm/pr116probe MB/s= 1874.7 p50= 27us p99= 443us max= 870us topN(62)mean= 485us
PROBE /tmp/pr116probe-ext4 MB/s= 196.0 p50= 29us p99= 18615us max= 23879us topN(62)mean= 18420us
PROBE memory MB/s= 54012.7 p50= 1us p99= 3us max= 7us topN(62)mean= 3us
PROBE /dev/shm/pr116probe MB/s= 1771.5 p50= 27us p99= 448us max= 689us topN(62)mean= 489us
PROBE /tmp/pr116probe-ext4 MB/s= 195.3 p50= 29us p99= 18580us max= 22721us topN(62)mean= 18483us
PROBE memory MB/s= 53671.2 p50= 1us p99= 3us max= 6us topN(62)mean= 3us
== base iter 2
PROBE /dev/shm/pr116probe MB/s= 2800.8 p50= 21us p99= 26us max= 44us topN(62)mean= 28us
PROBE /tmp/pr116probe-ext4 MB/s= 118.8 p50= 25us p99= 30874us max= 41352us topN(62)mean= 31612us
PROBE memory MB/s= 2798.6 p50= 21us p99= 31us max= 212us topN(62)mean= 37us
PROBE /dev/shm/pr116probe MB/s= 2398.8 p50= 26us p99= 30us max= 49us topN(62)mean= 32us
PROBE /tmp/pr116probe-ext4 MB/s= 118.1 p50= 29us p99= 30873us max= 39855us topN(62)mean= 31737us
PROBE memory MB/s= 2800.2 p50= 21us p99= 31us max= 68us topN(62)mean= 36us
PROBE /dev/shm/pr116probe MB/s= 2694.6 p50= 21us p99= 29us max= 57us topN(62)mean= 30us
PROBE /tmp/pr116probe-ext4 MB/s= 120.4 p50= 29us p99= 30695us max= 35333us topN(62)mean= 31092us
PROBE memory MB/s= 2810.4 p50= 21us p99= 31us max= 46us topN(62)mean= 31us
== head iter 2
PROBE /dev/shm/pr116probe MB/s= 2030.2 p50= 22us p99= 488us max= 722us topN(62)mean= 510us
PROBE /tmp/pr116probe-ext4 MB/s= 198.9 p50= 24us p99= 18576us max= 24121us topN(62)mean= 18301us
PROBE memory MB/s= 46759.5 p50= 1us p99= 9us max= 15us topN(62)mean= 9us
PROBE /dev/shm/pr116probe MB/s= 1820.5 p50= 27us p99= 512us max= 927us topN(62)mean= 553us
PROBE /tmp/pr116probe-ext4 MB/s= 195.4 p50= 29us p99= 18528us max= 21929us topN(62)mean= 18483us
PROBE memory MB/s= 54079.9 p50= 1us p99= 3us max= 30us topN(62)mean= 4us
PROBE /dev/shm/pr116probe MB/s= 1811.0 p50= 27us p99= 541us max= 811us topN(62)mean= 542us
PROBE /tmp/pr116probe-ext4 MB/s= 200.3 p50= 29us p99= 18150us max= 27440us topN(62)mean= 17942us
PROBE memory MB/s= 52999.2 p50= 1us p99= 3us max= 16us topN(62)mean= 3us
2. design/wake-protocol.md doesn't match the code it describes
- Problem: the new wake-protocol contract has dead references and some rows that are wrong.
- Net impact: affects maintainers, who are told to keep this document as the list of every wake source.
- Evidence: static, at
084cb471.- All 17 commits it cites (459f909, 3362671, …) fail
git merge-base --is-ancestor <sha> HEAD. They exist only onqwp-event-driven-io-loopandperf/client-breakthrough, so they will dangle after the squash merge. - Its line numbers are "as of 3362671". For example, it puts ioLoop step 5 at
:2838-2847; at head it is:2797-2808. - The Consumer C table still marks
recycleFailureandclosedas "none … Open 1/Open 2", yet the code checks both incheckAwaitAckedFsnErrors(QwpWebSocketSender.java:1519-1528) and signals the waiters at:5863. The same table callsrecycleFailurea "plain field", but it is nowvolatile. - It says the fix adds
ackWaiters.signal()right afterclosed = true. There is no such call (:1698-1700). Waiters are actually woken by the loop'sclose()→signalAckProgress()after it clearsrunning(CursorWebSocketSendLoop.java:1391,1407). Behaviour is correct; only the document is wrong.
- All 17 commits it cites (459f909, 3362671, …) fail
Suggested fix:
- Replace the SHAs and line numbers with symbol names.
- Update the two Consumer C rows.
- Describe the close wake as it is actually implemented.
3. testHandlerInstalledAfterSkippedOffersDelivers doesn't test the wake it describes
- Problem: the test passes with the wake in
setHandlerremoved. - Net impact: nothing protects a late progress handler from never receiving the current watermark.
- Evidence: in a scratch worktree at
084cb471, I replaced thesetHandlerguard (SenderProgressDispatcher.java:188) withif (false). The test still passed (tests="1" failures="0"). The cause is thatoffer(1001)atSenderProgressDispatcherTest.java:195runs aftersetHandlerand starts delivery by itself. No other test callssetHandlerafter skipped offers.
Suggested fix: don't offer after setHandler. Wait for delivery of 1000 instead.
Minor
- Comments still describe the removed path scratch or the 1 ms polling:
CursorSendEngine.java:95, 362, 371, 397, 846SegmentManager.java:50("Polls each ring on a configurable tick (default 1 ms)")SegmentManagerUnlinkFailureTest.java:126("A spare is already in place…")
SegmentProvisioner.java:169-170now allocates on the producer thread at every disk rotation: aString.formatcall, string concatenation, and the String overload ofMmapSegment.create(abyte[]plus a native path malloc). Base reused a native buffer (pathScratch) on the manager thread. It happens once per 4 MiB; reusing a sink would restore zero allocation.
Coverage
- Test gate: passes, with 1 Moderate coverage gap.
- Gap: no real-server or failover run of the new event-driven I/O loop.
- This touches the store-and-forward drainer: the idle wait, replay after reconnect, and ack waiters. That calls for a
questdbmatching-branch PR, and an Enterprise one for failover. - I searched with
gh pr list --head perf/qwp-idle-cpu-low-riskagainst both repos and found nothing. The local tests use loopback servers only. - If it regressed: a missed wake would hold delivery until the next publish, and
close()could hit its drain timeout. - Why not blocking: the TLA+ models,
CursorWebSocketSendLoopIdleWaitTest, the TLS idle tests and CI on Linux, macOS and Windows already cover the wake protocol. - Cheapest fix, no new test code: a
questdbPR that bumps the submodule to this branch, so its QWP e2e suite runs, plus the Enterprise failover suite.
- This touches the store-and-forward drainer: the idle wait, replay after reconnect, and ack waiters. That calls for a
Summary
-
Verdict: approve with comments. Please address ci(build): set file encoding to utf-8 for logging #1 (at least the description) and chore(conf): update JDK version from 17 to 11 and change tag to 1.0.0 #2.
-
Findings: 0 Critical, 3 Moderate, 2 Minor, all in the diff itself.
-
Trade-off:
- Idle CPU goes to zero.
- Memory-mode appends are much faster; the CRC skip took p50 from 21 µs to 1 µs.
- ext4 disk mode is faster too.
- The cost is slower rotations with
sf_diron tmpfs.
-
Previous review: its Moderate ci(build): set file encoding to utf-8 for logging #1 (budget leak after a failed rotation) is fixed by
b48a0127. Its Moderate chore(conf): update JDK version from 17 to 11 and change tag to 1.0.0 #2 is still open; it is ci(build): set file encoding to utf-8 for logging #1 above. -
API removals: removing the
QwpWebSocketSender,CursorWebSocketSendLoopandcreateLineSenderoverloads is consistent withdesign/entra-id-qwp-auth.md:186(onlySenderand its builder are public API). Nothing inquestdbmaster orquestdb-enterprisemain references the removed symbols. -
Scope: the out-of-diff callers I checked were:
- every
Vect.memcpycaller (including the one overlapping copy); - the
tryReceiveFrameandhasBufferedInputcallers; - the
SegmentManager.registercallers; - the
cursorEngineassignments that wire up the ack listener.
None of them is broken by this change.
- every
Review (level 3): approve with commentsI reviewed HEAD
CriticalNone. Moderate1. With
|
| Backing | Rotating append, BASE | Rotating append, HEAD | BASE → HEAD |
|---|---|---|---|
| tmpfs, paced at 100 MB/s | 36–50 µs | 640–720 µs | 41–43 → 55–57 ms/s |
| tmpfs, paced at 300 MB/s | 28–46 µs | 530–610 µs | 115–126 → 150–157 ms/s |
| tmpfs, unpaced | 26–37 µs | 440–600 µs | 2.6–2.7 → 1.9–2.1 GB/s |
| ext4/NVMe | 3.4–10 ms | 3.3–6 ms | HEAD equal or better |
| memory mode | p50 18 µs | p50 1 µs | HEAD much better |
- Counterevidence:
- Pinning the JVM to one CPU closes most of the gap.
- ext4 and memory mode come out ahead.
- Real producers spend most of their time encoding rows, so they lose a smaller share than this raw-engine probe shows.
- Severity: it is a bounded cost, paid once per 4 MiB and only on tmpfs, so it doesn't block the merge.
- Fix:
- At minimum, describe the trade-off in the PR description (see build: 1.0.0 #3).
- Optionally, have the manager pre-create the next segment off-thread when the active one is about ¾ full, and keep synchronous creation as the fallback.
2. Disk rotation now allocates on the producer's flush() path
- Problem:
SegmentProvisioner.nextbuilds the segment path withString.formatand the String-based overloads. - Net impact: about 0.8 KB (JDK 25) or 2.4 KB (JDK 8) of garbage per 4 MiB rotation, on the producer thread.
- Evidence:
getThreadAllocatedBytesaroundappendBlocking:- HEAD: 776–824 B per rotation on JDK 25, 2320–2480 B on JDK 8.
- BASE: 0 B on the producer thread.
- Non-rotating appends: 0 B in both.
- Whole process: about 0.5 KB (JDK 25) or 1.8 KB (JDK 8) more garbage per rotation than BASE. BASE allocated the rest on the manager thread.
Where it allocates:
String.format("%016x", …)and the string concatenation (:169);- the String overload of
MmapSegment.create(:170); ff.fsyncDir(String)(:174).
BASE avoided this on purpose with a reused native pathScratch. Fix: build the path in a reused DirectUtf8Sink with Numbers.appendHex and pass the pointer to MmapSegment.create, as the removed nextSparePath did.
3. The PR description, which becomes the squash commit, leaves out the structural changes
- Problem: it says "no behaviour change", but several behaviours change.
- Net impact: the commit message misleads maintainers.
- Evidence: static comparison of the PR body with the diff.
Not mentioned:
- The hot spare is gone; segments are now created on the producer thread at rotation, with the tmpfs cost from ci(build): set file encoding to utf-8 for logging #1.
- The I/O thread now writes the ack watermark on every ACK; the manager only syncs it.
- A truncated TLS record at EOF now reports a disconnect and reconnects (
JavaTlsClientSocket.java:237); BASE returned "no data". - A progress handler installed late now receives the current watermark straight away.
- The internal
connect/constructor overloads are removed.
4. In SegmentRingAckOffMonitorTest, a failing assertion on the producer thread doesn't fail the test
- Problem:
SegmentRingAckOffMonitorTest.java:82asserts inside a barenew Thread(...)lambda. - Net impact: the test never checks the rotation's return value, or whether it threw.
- Evidence: I changed only the expected value from
4Lto an impossible value. The log printedException in thread "producer" … expected:<999> but was:<4>, and the result was stillTests run: 1, Failures: 0. An independent falsifier reproduced this and found no rule or handler that would catch it.
Fix: capture the result or throwable in an AtomicReference (or use a FutureTask), and assert after join().
Minor
- Comments that the hot-spare removal made stale:
- The
SegmentRing.nextSeqHint()javadoc says it serves the manager's spare. Only tests use it now. - The
MmapSegment.rebaseSeqjavadoc says the manager pre-creates spares. snapshotSealedSegmentsstill says "per tick".
- The
SegmentManagerTrimDeregisterRaceTest: the comments this PR edited (lines 94 and 110-126) still describe spares and say "Acknowledge does NOT wake the worker". An ACK that completes the oldest sealed segment now wakes it.- The PR has no labels.
Coverage
Test gate: passes. There are 2 Moderate coverage gaps and no Critical ones.
Gap A: no test covers the spill-buffer half of JavaTlsClientSocket.hasBufferedInput().
- No test catches its removal: with
unwrapOutputBuffer.position() != 0deleted (so onlyunprocessedRecordPendingremains), the fullmvn -pl core teststill passes: 3780 tests, 0 failures. An independent run got the same result. - The existing test doesn't reach that branch: at 150 KiB,
TlsLargeAckIdleWaitTestnever takes the spill path. - What breaks without the condition: a sweep of ACK sizes over TLS showed the I/O loop blocking with plaintext stuck in the spill buffer.
awaitAckedFsnandclose()then time out ("drain timed out … ackedFsn=-1").- With the stock test server, ACKs from about 1.05 MB upward hang: 18/18 at the selected sizes.
- With a server that writes in 10 000-byte chunks, ACKs from 65 KiB upward hang: 123/200 sizes.
- Unmodified HEAD passed every one of those sizes.
- Real frames that can get that large: STATUS_OK and durable-ack frames, whose size grows with the number of tables they list.
- Cheapest test: a
TlsLargeAckIdleWaitTestcase with a ~1.1 MB ACK, or one where the server writes the 150 KiB frame in 10 000-byte chunks.
Gap B: no Enterprise tandem run for the HA/failover store-and-forward path.
- The OSS real-server suites pass with this client. I ran questdb master with
-P local-clienton this HEAD: 383 tests, 0 failures. The suites included:- QwpSenderE2ETest
- QwpWebSocketSenderTlsE2ETest
- QwpIngressServerRestartFuzzTest
- QwpSymbolDictRecycle*
- QwpLocalDurableAckTest
- QwpIngressDeferredCloseDurableAckTest
- QwpServerCloseDrainTest
- QwpSenderFuzzTest
- Not run: Enterprise failover and the replicated durable-ack flow.
- Cheapest fix: run the Enterprise QWP failover/SF suite against this branch before bumping the submodule. It needs no new test code.
Summary
- Verdict: approve with comments. Please address build: 1.0.0 #3, build: 1.0.1 #4 and Gap A. For ci(build): set file encoding to utf-8 for logging #1, at least update the description.
- Findings: 0 Critical, 4 Moderate, 2 Moderate coverage gaps, 3 Minor, all in the diff itself.
- Trade-off:
- Gains: idle CPU drops to zero, memory-mode appends are about 15× faster at p50, and ext4 rotations are faster.
- Costs: slower rotations on tmpfs (ci(build): set file encoding to utf-8 for logging #1) and a small allocation per rotation (chore(conf): update JDK version from 17 to 11 and change tag to 1.0.0 #2).
- Checked, no defect found:
- the
ioParked/publishStamphandshake; - the doorbell lifecycle against close and fd reuse;
recvDrained;- every
AckWaiterssignal source; - the manager's wake conditions;
- moving the watermark writer to the I/O thread, including the order of engine close;
- the watermark rewrite at recovery;
- every
Vect.memcpycaller, including the overlapping TLS compaction; - the
PAYLOAD_TOO_LARGEpre-check; - the segment-size check in
register; - the dispatchers'
take()/POISON shutdown.
- the
- CI: green on Linux, macOS and Windows.
[PR Coverage check]😍 pass : 435 / 457 (95.19%) file detail
|
Problem
A QWP sender burns ~70% of a core per connection even when idle: the I/O thread polls the socket and the segment ring every 50 µs.
Change
close()runs, or a PING is due. Producers ring a native doorbell (eventfd on Linux, kqueueEVFILT_USERon macOS/FreeBSD, a kernel Event on Windows) only when the thread is parked. Ack waiters, the segment manager and the dispatchers are event-driven too.OffHeapAppendMemory; identity check first in column lookupOffHeapAppendMemory.putUtf8Vect.memcpy)No change to the
SenderAPI or configuration.Numbers
main.putUtf8), 1 row/flush +8.3% (small copies) and +7.2% (CRC skip). They have not been re-measured together on this branch.Testing
mvn -pl core test: 3748 pass on JDK 25; JDK 8 test-compile clean.putUtf8againstString.getBytes(UTF_8);Vect.memcpyover every length 0–160 at every source and destination alignment.