bug: the bloom index hides a row whose FLOAT/DOUBLE value is a signed zero
Search before asking
- I had searched in the issues and found no similar issues.
Version
8.0.90-v1.2.945-nightly-3ee958b464(rust-1.94.0-nightly-2026-09-17T11:34:15.268283012Z)Also reproduces on released v1.2.881 (ca29960f5c). Nothing is configured in any of this —
default databend-query.toml, no SET.
What's Wrong?
SELECT count(*) FROM t WHERE k = 0.0 returns 0 on a table that holds a -0.0. The same
predicate evaluated without the index returns 1. No error, no warning — the query succeeds
and reports a count that is wrong.
The bloom index hashes -0.0 and +0.0 to different keys, while the engine's = treats them
as one value. Both halves of that are visible from SQL in one query:
SELECT siphash64('-0.0'::DOUBLE) AS h_neg,
siphash64(0.0::DOUBLE) AS h_pos,
('-0.0'::DOUBLE = 0.0) AS engine_says_equal;h_neg h_pos engine_says_equal
17224059632725561478 13646096770106105413 truesiphash64 is the digest the bloom filter is built with, so the filter's answer to "could this
block contain 0.0?" is no for a block whose only zero is negative, and the block is never
read. An index may return extra rows for the filter above it to discard; it may not return fewer
rows than the predicate selects.
How to Reproduce?
CREATE DATABASE b41; USE b41;
CREATE TABLE t (i BIGINT, k DOUBLE);
INSERT INTO t SELECT number,
CASE number WHEN 0 THEN '-0.0'::DOUBLE ELSE number::DOUBLE + 10 END
FROM numbers(2);
SELECT to_string(k) AS stored_value FROM t WHERE i = 0; -- -0 the row is there
SELECT count(*) AS with_index FROM t WHERE k = 0.0; -- 0 <-- WRONG
SELECT count_if(k = 0.0) AS full_scan FROM t; -- 1 the same predicateThe same two rows with the index removed return the row:
CREATE TABLE t2 (i BIGINT, k DOUBLE) BLOOM_INDEX_COLUMNS='';
-- same INSERT
SELECT count(*) FROM t2 WHERE k = 0.0; -- 1 correctSame server, same values, same query — only the index differs, so the index is what loses the row.
A note on writing the value: Databend's literal parser turns a written -0.0 into a DECIMAL
(SELECT -0.0 returns 0.0), so the reproduction casts from text to get a genuine negative-zero
DOUBLE into the column. That is probably why this has not been hit before.
It takes two rows
A one-row block gets no bloom filter (bloom_filter_size = 0) and is correct. From two rows on,
a filter is written and the row disappears. Measured on main today:
| rows in the table | bloom_filter_size |
WHERE k = 0.0 |
count_if(k = 0.0) |
|---|---|---|---|
| 1 | 0 | 1 | 1 |
| 2 | 623 | 0 | 1 |
| 3 | 629 | 0 | 1 |
| 10 | 647 | 0 | 1 |
| 100 | 867 | 0 | 1 |
| 10 000 | 25 238 | 0 | 1 |
No spelling of zero finds the row
Once the negative zero shares a block with other rows, it is unreachable through the index by
any equality predicate. Stored -0.0, 100-row block, main:
| probe | WHERE k = probe |
count_if(k = probe) |
|---|---|---|
0.0 |
0 | 1 |
-0.0 |
0 | 1 |
'-0.0'::DOUBLE |
0 | 1 |
'0'::DOUBLE |
0 | 1 |
0 |
0 | 1 |
0e0 |
0 | 1 |
0.00 |
0 | 1 |
0.0 + 0 |
0 | 1 |
including '-0.0'::DOUBLE, the exact expression that wrote the row.
Both signs and both float types are affected — whichever sign is stored, the other one cannot find it:
| column | stored | probed with | WHERE |
full scan |
|---|---|---|---|---|
DOUBLE |
'-0.0'::DOUBLE |
0.0 |
0 | 1 |
DOUBLE |
0.0 |
'-0.0'::DOUBLE |
0 | 1 |
FLOAT |
'-0.0'::FLOAT |
0.0 |
0 | 1 |
FLOAT |
0.0 |
'-0.0'::FLOAT |
0 | 1 |
A fourth item in an IN list changes the answer
max_inlist_to_or is 3: a list of three or fewer items is rewritten into a chain of ORs,
which consults the bloom index; four or more stays an IN and takes a path that does not. On a
table with one -0.0 among a hundred rows, appending a fourth value that matches nothing turns
a wrong answer into a right one:
| items in the list | v IN (0.0, …) |
v = 0.0 OR … |
|---|---|---|
| 1 | 0 | 0 |
| 2 | 0 | 0 |
| 3 | 0 | 0 |
| 4 | 1 ✅ | 0 |
| 5, 6, 8, 12, 16, 24, 32, 48, 64, 96, 128 | 1 ✅ | 0 |
Swept at 15 list lengths; the flip is exactly at 4, where max_inlist_to_or says it should be.
The OR spelling of the same predicate is wrong at every length, which is what identifies the
rewrite — rather than the list length — as what matters. Same root cause, not a second bug; it
is just the sharpest demonstration that two spellings of one predicate disagree because only one
of them asks the index.
Everything else tried is clean
Same 100-row block shape, value planted at row 0 and probed for:
BIGINT (42, 0), VARCHAR, DATE, TIMESTAMP, and the non-zero floats 1.5, 1e308,
5e-324, nan — all found by the indexed path, all matching the full scan. So this is specific
to signed zero rather than general distrust of the index. (DECIMAL and BOOLEAN columns get no
bloom filter written at all — bloom_filter_size does not grow when the column is added — so they
are untested rather than clean. DECIMAL has no signed zero, so it is moot there.)
Expected behavior
SELECT count(*) FROM t WHERE k = 0.0 should return the same answer as
SELECT count_if(k = 0.0) FROM t, because they are the same predicate.
Concretely: the bloom digest for FLOAT/DOUBLE should normalise -0.0 to +0.0 before
hashing — both when the filter is built and when it is probed — so that values the engine's =
considers equal hash to the same key. Normalising only one side would make it worse.
Are you willing to submit PR?
- Yes I am willing to submit a PR!
Source: databendlabs/databend