Skip to content

fix: BIT_AND/BIT_OR/BIT_XOR return neutral values for empty or all-NULL groups#25320

Open
ck89119 wants to merge 10 commits into
matrixorigin:mainfrom
ck89119:issue-25285-main
Open

fix: BIT_AND/BIT_OR/BIT_XOR return neutral values for empty or all-NULL groups#25320
ck89119 wants to merge 10 commits into
matrixorigin:mainfrom
ck89119:issue-25285-main

Conversation

@ck89119

@ck89119 ck89119 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?

  • Bug fix

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:

Function Empty/All-NULL group result
BIT_AND all bits set (max uint64)
BIT_OR 0
BIT_XOR 0

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:

  • Modified files: pkg/sql/colexec/aggexec/bitops2.go, pkg/sql/colexec/aggexec/bitops2_test.go
  • Test coverage: 12 test cases covering normal values, empty groups, all-NULL groups, mixed NULL/non-NULL, multiple groups, and bytes types
  • All existing aggexec tests continue to pass with no regressions

🤖 Generated with Claude Code

…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>
@ck89119 ck89119 requested review from aunjgr and ouyuanning as code owners July 1, 2026 08:17
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

- 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 XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of 0xff
  • BIT_OR: empty bytes
  • BIT_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:

  1. Do not hard-code byte neutral values in Flush().
  2. Track/derive the neutral width for bytes aggregates. For BINARY(N), param.Width should drive the neutral byte length. For VARBINARY, please verify the intended MySQL-compatible behavior for all-NULL / empty input and encode it explicitly instead of relying on the zero varlena default.
  3. Fill NULL slots with bytes.Repeat(0xff, width) for BIT_AND and bytes.Repeat(0x00, width) for BIT_OR / BIT_XOR.
  4. Add focused coverage for hex() and length() on BINARY(N) all-NULL groups, empty input without GROUP 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 XuPeng-SH left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 state SetBytesAt area allocation fails.
  • current BulkFill()/Merge() return nil, and Flush() 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:

  • BulkFill first non-NULL BINARY(64)/VARBINARY(64) with constrained exec mpool returns an error and does not produce a non-NULL empty payload.
  • Merge copying 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/aggexec passes.
  • 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 aunjgr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Straightforward bug: BINARY(N) neutral values hard-coded to 8 bytes instead of N. Should use bytes.Repeat with param.Width. Narrow fix.

ck89119 and others added 3 commits July 4, 2026 20:25
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Something isn't working size/L Denotes a PR that changes [500,999] lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: BIT_AND/BIT_OR/BIT_XOR return NULL for empty or all-NULL groups, unlike MySQL neutral values

5 participants