fix(qwp): empty arrays are distinct from NULL in the columnar codec#63
Conversation
QuestDB stores a non-null empty array (cardinality 0) as a value distinct
from a NULL array, and emits it on QWP egress inline with a 0-length
dimension. The egress decoder rejected dl < 1, so reading an empty array
back failed ("ARRAY dim N must be >= 1"); the 1D/2D/3D/ND array setters
also collapsed a nil slice into a (malformed) empty array instead of NULL.
- parseArray: accept dl >= 0, keeping nDims >= 1 and a per-dimension cap
so a 0 in one dimension can't slip an arbitrary value past the
element-count cap. Matches the server's QwpArrayColumnCursor.
- Array setters (Float64/Int64 1D/2D/3D + ND): a nil slice is a NULL
array (null bitmap); a non-nil empty slice is a distinct empty array
(inline, 0 elements).
- Tests: encode->decode round-trip for empty vs NULL and nil/empty setter
semantics (unit), plus a real-server ingest->egress round-trip
(TestQwpIntegrationEmptyAndNullArray). Flip the stale H27b zero-dim
hardening case to assert the empty array now decodes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
More reviews will be available in 54 minutes and 27 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughDefines and enforces a nil-slice-is-NULL / empty-slice-is-empty-array distinction for all QWP typed array column methods ( ChangesQWP Array nil-vs-empty semantics
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 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 |
The qwp-fuzz CI job builds QuestDB master and launches the SNAPSHOT jar as the named module io.questdb. Recent master adds io.questdb.mp.continuation.WorkerContinuation, which reaches the JDK-internal jdk.internal.vm.ContinuationScope. java.base does not export jdk.internal.vm to io.questdb by default, so every worker thread dies at class-init with IllegalAccessError / NoClassDefFoundError and the HTTP service never comes up. Pass --add-exports=java.base/jdk.internal.vm=io.questdb, the flag QuestDB's own launcher (questdb.sh, docker-entrypoint.sh) and the canonical c-questdb-client fixture.py both use. The fixture's launch args were ported from fixture.py before WorkerContinuation existed, so they predate this requirement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review: PR #63Reviewing at level 2 (Agents 1–8 + a fresh adversarial pass, batched source verification). Verdict up front: the core fix is correct, safe, and well-reasoned. The decoder relaxation is sound, the encode/decode path is byte-exact for empty arrays, every one of the 12 array read-accessors is guarded against the newly-reachable non-null CriticalNone. ModerateM1 — M2 — The exported array setters don't document the Minor
Summary
|
The empty-vs-NULL array fix landed with decode coverage only for
DOUBLE_ARRAY and with the nil/empty contract recorded only in
implementation comments. This fills both gaps.
- TestQwpDecoderRoundTripEmptyVsNullArray now carries a second
LONG_ARRAY column alongside the existing DOUBLE_ARRAY one, decoding
the same regular/empty-1D/NULL/empty-2D shapes via Int64Array. The
double-array column precedes the long one in the frame, so the long
column decoding correctly also pins that the empty and NULL rows of
the first column consumed exactly their wire bytes (inter-column
alignment), since parseArray reads columns sequentially with no
length prefix.
- TestQwpSenderArrayNilIsNullEmptyIsEmpty now exercises
Float64ArrayNDColumn(nil), pinning that a nil NdArray creates the
column and marks the row NULL instead of being silently dropped.
- The LineSender Float64Array{1,2,3}D/ND and QwpSender
Int64Array{1,2,3}D doc comments now state the nil=NULL versus
non-nil-empty=empty-array contract.
No production logic changes; the doc comments are the only non-test
edits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The empty-vs-NULL array fix left three test comments describing the old decoder behavior, where a 0-length dimension was rejected. They are now wrong or misleading; this updates them to present-tense facts. - qwp_cursor_bounds_check_fuzz_test.go: the array seed comment no longer claims the decoder rejects a 0-length dim. It now explains the real reason for 1-3 elements — the seed carries element-data bytes for the corruption and truncation passes to mutate, which still reach the 0-length-dim form. - buildArrayHardeningFrame: the padding comment no longer asserts the decoder always rejects before reading element bytes (untrue for the flipped H27b case). It now covers all callers: each shape is either rejected at the shape/nDims check or accepted as a 0-element array, so no element bytes are consumed. - TestQwpIntegrationEmptyAndNullArray: dropped the changelog-style framing for a present-tense property — an empty array reads back as a non-null, zero-element row distinct from NULL. Comment-only; no test logic changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
qwp_sender_test.go (1)
1042-1065: ⚡ Quick winAssert empty-array inline header bytes, not only length.
The check currently allows malformed encodings with the same
arrayDatalength. Please also assert the exact header bytes for empty arrays (e.g.nDims=1,dim0=0) so wire-shape regressions are caught.Suggested patch
@@ import ( "bytes" @@ "testing" "time" @@ const emptyArrayInlineLen = 5 + empty1DHeader := []byte{1, 0, 0, 0, 0} check := func(name string, wantNullCount, wantArrayDataLen int) { @@ if len(col.arrayData) != wantArrayDataLen { t.Fatalf("%s: len(arrayData) = %d, want %d", name, len(col.arrayData), wantArrayDataLen) } + if wantArrayDataLen == emptyArrayInlineLen && !bytes.Equal(col.arrayData, empty1DHeader) { + t.Fatalf("%s: arrayData header = %v, want %v", name, col.arrayData, empty1DHeader) + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@qwp_sender_test.go` around lines 1042 - 1065, The check function currently only validates the length of col.arrayData but does not verify the actual header bytes, which allows malformed encodings to pass if they have the correct length. Enhance the check function to also assert the exact header byte values for empty arrays when wantArrayDataLen equals emptyArrayInlineLen, specifically verifying that the first byte (nDims) is 1 and the following 4 bytes (dim0) are 0 to catch wire-shape regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@qwp_query_integration_test.go`:
- Around line 135-163: The sender variable created by newQwpLineSender in the
test is only closed on the happy path at the end, meaning any early t.Fatalf
call will exit without closing the resource. Add a defer statement immediately
after successfully creating the sender s to ensure s.Close(ctx) is called
regardless of whether subsequent operations fail, preventing resource leaks that
could cause test instability.
---
Nitpick comments:
In `@qwp_sender_test.go`:
- Around line 1042-1065: The check function currently only validates the length
of col.arrayData but does not verify the actual header bytes, which allows
malformed encodings to pass if they have the correct length. Enhance the check
function to also assert the exact header byte values for empty arrays when
wantArrayDataLen equals emptyArrayInlineLen, specifically verifying that the
first byte (nDims) is 1 and the following 4 bytes (dim0) are 0 to catch
wire-shape regressions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 72689d46-d357-40cd-bf45-b2f4fa52d109
📒 Files selected for processing (7)
qwp_fuzz_fixture_test.goqwp_query_decoder.goqwp_query_decoder_test.goqwp_query_integration_test.goqwp_sender.goqwp_sender_test.gosender.go
bluestreak01
left a comment
There was a problem hiding this comment.
Approve.
Reviewed at level 3 (full mission-critical pass). The core fix is correct, safe, and internally consistent:
- Decoder relaxation (parseArray) is a net hardening: the new per-dimension cap (dl < 0 || int64(dl) > qwpMaxArrayElements) closes the gap the old product-only check left when a 0 zeroes the running product. No int64 overflow (max intermediate max*max ≈ 7.2e16 < 2^63). nDims==0 and negative dims still rejected.
- All 12 array accessors safely handle the newly-reachable non-null elems==0 state (make(...,0) non-nil empty; unsafe.Slice guarded behind elems>0; *Into short-circuits).
- nil->NULL setter mapping is uniform across all six typed QWP setters + the ND path; array columns are always nullable so addNull takes the bitmap branch (the non-nullable-array panic is an unreachable internal-invariant guard). rowCount advances exactly once per call.
- Interface doc holds for all transports (ILP buffer.go already mapped nil->omit (=server NULL) and non-nil-empty->inline empty array).
- go build, go vet, staticcheck, and the new unit + decoder round-trip (DOUBLE+LONG) + hardening + fuzz-seed tests all pass.
Non-blocking follow-ups:
- M1 (Moderate, visibility): ensure the release note explicitly calls out the QWP nil->NULL semantics change (the user-facing risk is the nil path, not the empty path).
- m1 (Minor): integration test leaks the sender on early t.Fatalf paths — add defer s.Close(ctx) after creation.
- m2 (Minor): TestQwpSenderArrayNilIsNullEmptyIsEmpty should assert the empty-array header bytes {1,0,0,0,0}, not only len==5.
- m3 (Minor): add a QwpColumn.*ArrayInto test on a non-null empty array (only IsNull distinguishes empty from NULL there).
No Critical or Moderate-correctness issues.
What
QuestDB stores a non-null empty array (cardinality 0) as a value distinct from a NULL array, and emits it on QWP egress inline with a 0-length dimension. The egress decoder rejected
dl < 1, so reading an empty array back failed (ARRAY dim N must be >= 1); the 1D/2D/3D/ND array setters also collapsed anilslice into a (malformed) empty array instead of NULL.Changes
parseArray): acceptdl >= 0, keepingnDims >= 1and a per-dimension cap so a0in one dimension can't slip an arbitrary value past the element-count cap. Matches the server'sQwpArrayColumnCursor.Float64Array{1,2,3}D,Int64Array{1,2,3}D,Float64ArrayNDColumn): anilslice is a NULL array (null bitmap); a non-nil empty slice is a distinct empty array (inline, 0 elements).TestQwpIntegrationEmptyAndNullArray). Flipped the staleH27bzero-dim hardening case.Verification
go build,go vet,staticcheck, focused unit tests, fuzz seed corpora, and the real-server integration test all pass. End-to-end: the unfixed decoder rejects the server's empty-array frame withARRAY dim 0 out of range [...]: 0; the fix round-trips it as a non-null 0-element array distinct from NULL.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests
Documentation