fix: BIT_AND/BIT_OR/BIT_XOR return neutral values for empty or all-NULL groups#25320
fix: BIT_AND/BIT_OR/BIT_XOR return neutral values for empty or all-NULL groups#25320ck89119 wants to merge 10 commits into
Conversation
…LL groups BIT_AND(), BIT_OR(), BIT_XOR() now return MySQL-compatible neutral values for groups with no non-NULL input values: - BIT_AND: all bits set (max uint64 / 8 bytes of 0xFF) - BIT_OR: 0 - BIT_XOR: 0 Previously, these returned NULL for empty or all-NULL groups. Fixes matrixorigin#25285 Co-Authored-By: Claude <noreply@anthropic.com>
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
- Move neutralBytesForAnd allocation out of inner loop - Propagate SetBytesAt error instead of silently discarding - Fix gofmt alignment in test file Co-Authored-By: Claude <noreply@anthropic.com>
Empty or all-NULL groups now return MySQL-compatible neutral values: - BIT_AND: 18446744073709551615 (all bits set) - BIT_OR/BIT_XOR: 0 Co-Authored-By: Claude <noreply@anthropic.com>
XuPeng-SH
left a comment
There was a problem hiding this comment.
Requesting changes for one correctness blocker in the binary-string path.
bitOpExecBytes.Flush() currently converts NULL aggregate slots by using a hard-coded 8-byte 0xff value for BIT_AND and leaving BIT_OR / BIT_XOR as empty bytes. That does not match the binary-string semantics of these aggregates. This executor is used when the argument type is BINARY / VARBINARY and the return type is the original argument type, so the neutral value has to preserve the argument byte length. MySQL documents the binary-string result as the same length as the argument values, and the all-NULL/no-row neutral value follows that same length rule.
Concrete failure I verified locally with a temporary unit repro:
typ := types.New(types.T_binary, 3, 0)
// two NULL input rows, then Flush()Expected neutral values for BINARY(3) are:
BIT_AND:[]byte{0xff, 0xff, 0xff}BIT_OR:[]byte{0x00, 0x00, 0x00}BIT_XOR:[]byte{0x00, 0x00, 0x00}
Actual PR output is:
BIT_AND: 8 bytes of0xffBIT_OR: empty bytesBIT_XOR: empty bytes
The updated test/distributed/cases/dtype/binary.result also exposes this for the existing binary(10) case: group d is all NULL, but bit_and(b) is emitted as 8 bytes and bit_or(b) / bit_xor(b) as empty values instead of 10-byte neutral values.
Suggested fix direction:
- Do not hard-code byte neutral values in
Flush(). - Track/derive the neutral width for bytes aggregates. For
BINARY(N),param.Widthshould drive the neutral byte length. ForVARBINARY, please verify the intended MySQL-compatible behavior for all-NULL / empty input and encode it explicitly instead of relying on the zero varlena default. - Fill NULL slots with
bytes.Repeat(0xff, width)forBIT_ANDandbytes.Repeat(0x00, width)forBIT_OR/BIT_XOR. - Add focused coverage for
hex()andlength()onBINARY(N)all-NULL groups, empty input withoutGROUP BY, mixed NULL/non-NULL groups, and the direct aggexec unit path. This will avoid invisible NUL bytes hiding result-length regressions in BVT output.
Unhappy-path pass: I did not find a hang/leak/unbounded-accumulation issue in this PR. The blocker is deterministic result corruption for the byte aggregate path.
… values on binary types Address XuPeng-SH review: bitOpExecBytes.Flush() hard-coded an 8-byte 0xFF neutral for BIT_AND and left BIT_OR/BIT_XOR empty, which does not match the binary-string semantics of these aggregates. The neutral value must preserve the argument byte length (BINARY(N)/VARBINARY(N)). Track the argument width on bitOpExecBytes and build the neutral value with bytes.Repeat: width bytes of 0xFF for BIT_AND, width bytes of 0x00 for BIT_OR/BIT_XOR. Add focused unit coverage for BINARY(3)/BINARY(10)/ VARBINARY(4) empty, all-NULL, multi-group and merge paths, and regenerate test/distributed/cases/dtype/binary.result for the binary(10) all-NULL group. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
….Flush When vector.SetBytesAt fails midway (e.g. OOM on varlena allocation), the vectors already transferred from exec.state become orphaned — the caller receives nil, err and cannot free them. Free vecs[0..i] before returning the error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
XuPeng-SH
left a comment
There was a problem hiding this comment.
I still see one blocker on the latest head (3397d677). The earlier binary-width neutral-value issue looks fixed: the bytes flush path now tracks param.Width, and the added BINARY(10)/VARBINARY tests cover empty and all-NULL groups.
Blocking finding:
bitOpExecBytes.BatchFill and bitOpExecBytes.BatchMerge still ignore vector.SetBytesAt errors when copying the first non-NULL binary value into an empty aggregate state (pkg/sql/colexec/aggexec/bitops2.go, around the SetBytesAt calls in both methods). This is not just theoretical: for non-inline binary values (len > types.VarlenaInlineSize), SetBytesAt can fail through BuildVarlenaFromByteSlice -> BuildVarlenaNoInline -> mp.Grow2. Because the code clears the NULL marker before the unchecked write, a failed allocation leaves the group marked non-NULL with an empty/default payload. Flush() then skips neutral replacement and returns a silent wrong result instead of propagating OOM/internal error.
I verified this locally with a constrained mpool probe:
BINARY(64)input vector allocated from a normal mpool.- aggregate state allocated from a 1MiB capped mpool.
- after
GroupGrow(1), fill the remaining mpool capacity so the stateSetBytesAtarea allocation fails. - current
BulkFill()/Merge()return nil, andFlush()returns a non-NULL value with length 0 instead of the 64-byte value or an error.
Please close the write contract for both paths:
if exec.state[x].vecs[0].IsNull(uint64(y)) {
if err := vector.SetBytesAt(exec.state[x].vecs[0], int(y), value, exec.mp); err != nil {
return err
}
exec.state[x].vecs[0].UnsetNull(uint64(y))
}Do the same in BatchMerge. The important part is: return the SetBytesAt error and only clear NULL after the write succeeds, so failure leaves the state unchanged.
Please also add regression coverage for both unhappy paths:
BulkFillfirst non-NULLBINARY(64)/VARBINARY(64)with constrained exec mpool returns an error and does not produce a non-NULL empty payload.Mergecopying a first non-NULL binary partial result into an empty target group under the same constrained mpool returns an error.
Local verification on the PR as-is:
go test ... ./pkg/sql/colexec/aggexec -run 'TestBitOps'passes.go test ... ./pkg/sql/colexec/aggexecpasses.- The constrained-mpool probe above fails on current code for both fill and merge, reproducing the state corruption.
In bitOpExecBytes.BatchFill and BatchMerge, the first non-NULL binary value was written by calling UnsetNull BEFORE SetBytesAt. If SetBytesAt fails (e.g. OOM on varlena off-heap allocation), the state slot is left marked non-NULL with an empty/default payload. Flush() then skips neutral replacement and returns a wrong result instead of propagating the error. Fix: call SetBytesAt first, check its error, and only UnsetNull after the write succeeds. Add regression coverage for both unhappy paths using a capped mpool to force SetBytesAt failure, verifying the state stays NULL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aunjgr
left a comment
There was a problem hiding this comment.
Straightforward bug: BINARY(N) neutral values hard-coded to 8 bytes instead of N. Should use bytes.Repeat with param.Width. Narrow fix.
…OM coverage, and hex/length BVT coverage - P2: Save/restore global mpool cap in OOM tests, free exhaustion allocations - P3b: Add VARBINARY(64) OOM coverage alongside BINARY(64) via table-driven tests - P3a: Add hex(bit_and(b))/length(bit_and(b)) queries to binary BVT for neutral value verification - P3c: Regenerate binary.result with ASCII-only hex output (no NUL byte pollution) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…LL BVT coverage - OOM tests now register all teardown via t.Cleanup (LIFO), so cleanup runs even when a require assertion aborts the subtest, and the capped mpool is released via mpool.DeleteMPool instead of leaking in globalPools - Add bit02 BVT cases covering zero-row aggregate without GROUP BY (returns neutral values) and same-group mixed NULL/non-NULL rows Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… GROUP BY cases - Revert two unrelated NUL drifts: the column headers for `c = 'a\0\0'` in the binary(3)/varbinary(3) tests were regenerated into real NUL bytes; restore them to the literal `\0\0` text - Add ORDER BY id to the six new hex/length GROUP BY queries (bit01 and bit02) so row order is deterministic Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What type of PR is this?
Which issue(s) this PR fixes:
Fixes #25285
What this PR does / why we need it:
BIT_AND(), BIT_OR(), BIT_XOR() now return MySQL-compatible neutral values for groups with no non-NULL input values:
Previously these returned NULL for empty or all-NULL groups, which differs from MySQL behavior.
The fix modifies the Flush() methods to replace NULL entries with the appropriate neutral value (identity element for each bitwise operation).
Special notes for your reviewer:
pkg/sql/colexec/aggexec/bitops2.go,pkg/sql/colexec/aggexec/bitops2_test.go🤖 Generated with Claude Code