Repository navigation
Incorrect result HASH SEMI JOIN with UUID key #9158
Copy link
Copy link
Open
Description
WI-V5.0.5.1816 Firebird 5.0 2f91fa0
SubQueryConversion = true
recreate table uuid_1M_a(id binary(16) not null);
recreate table uuid_1M_b(id binary(16) not null);
create index idx_uuid_1M_b_id on uuid_1M_b(id);
set term ^;
execute block
as
declare variable i int = 0;
begin
while (i < 1000000) do
begin
insert into uuid_1M_a(id) values (gen_uuid());
i = i + 1;
end
end^
set term ;^
insert into uuid_1M_b(id)
select a.id from uuid_1M_a a;
commit;select count(distinct id) from uuid_1M_a; COUNT
=====================
1000000
select count(distinct id) from uuid_1M_b; COUNT
=====================
1000000
set explain on;
select count(*)
from uuid_1M_a a
join uuid_1M_b b on a.id = b.id;Select Expression
-> Aggregate
-> Filter
-> Hash Join (inner)
-> Table "UUID_1M_A" as "A" Full Scan
-> Record Buffer (record length: 41)
-> Table "UUID_1M_B" as "B" Full Scan
COUNT
=====================
1000000
select count(*)
from uuid_1M_a a
where exists (select * from uuid_1M_b b where a.id = b.id);Select Expression
-> Aggregate
-> Filter
-> Hash Join (semi)
-> Table "UUID_1M_A" as "A" Full Scan
-> Record Buffer (record length: 41)
-> Table "UUID_1M_B" as "B" Full Scan
COUNT
=====================
999883 <----- What ????????????????????
But:
select count(*)
from uuid_1M_a a
where exists (select * from uuid_1M_b b where a.id = b.id and nullif(a.id, b.id) is null);Sub-query
-> Filter
-> Table "UUID_1M_B" as "B" Access By ID
-> Bitmap
-> Index "IDX_UUID_1M_B_ID" Range Scan (full match)
Select Expression
-> Aggregate
-> Filter
-> Table "UUID_1M_A" as "A" Full Scan
COUNT
=====================
1000000
The issue is reproducible in both version 5.0.5 and version 6.0.
The value 999883 can vary significantly if the test is rerun from scratch. It all depends on the generated UUID. Sometimes, everything works fine.
If SubQueryConversion = false result:
select count(*)
from uuid_1M_a a
where exists (select * from uuid_1M_b b where a.id = b.id);Sub-query
-> Filter
-> Table "UUID_1M_B" as "B" Access By ID
-> Bitmap
-> Index "IDX_UUID_1M_B_ID" Range Scan (full match)
Select Expression
-> Aggregate
-> Filter
-> Table "UUID_1M_A" as "A" Full Scan
COUNT
=====================
1000000
Reactions are currently unavailable
Activity
sim1984 commentedon Sep 19, 2026
The error is not reproducible if the keys are integers
sim1984 commentedon Sep 21, 2026
Root cause analysis of the SEMI JOIN bug
I believe I've identified the root cause. It is not in the hash table implementation itself (which is why
INNER JOINproduces correct results), but in the way theSEMI JOINpath interacts with the bucket iteration logic.The problem
When a hash collision occurs, multiple distinct join keys share the same 32-bit hash and therefore end up in the same bucket. Within the bucket, entries are ordered by hash, but entries with equal hashes may appear in any order relative to each other.
For
SEMI JOIN, the current code stops after finding the first entry in the bucket whose hash matches the leader's hash:If the first entry with the matching hash happens to have a different join key (a false positive due to a hash collision), the
EXISTSpredicate above the join rejects it, but the join node has already marked the leading row as processed:On the next call, a new leading row is fetched, and the row that actually had a match later in the same bucket is never re-examined. The result is a missing row in the output.
Why INNER JOIN is not affected
For INNER JOIN,
irsb_mustreadis not set on success. On subsequent calls, control flows into the else branch, which callsfetchRecord()again. The iterator inside the bucket advances, and eventually the correct entry is found. INNER JOIN effectively scans all entries with the same hash; SEMI JOIN does not.Verification
This explains the observed behaviour:
EPluribusUnum commentedon Sep 22, 2026
Hi @sim1984!
This sounds scary.
Every HASH JOIN affected? Every 5.x version affected?
Thank you!
sim1984 commentedon Sep 22, 2026
No. Only if
SubQueryConversion = true(disabled by default) and if theEXISTSclause is converted into a semi-join executed via hashing.Note that reproducing this with integer keys is very difficult (though easy with UUIDs), but it is theoretically possible. The larger the table being hashed, the higher the likelihood of reproducing the issue.
An
INNER JOINexecuted via hashing always produces the correct result.Fix #9158: Incorrect result HASH SEMI JOIN with UUID key. More comple…
mariuz commentedon Oct 3, 2026
A deterministic, two-row reproduction of the same SEMI-join defect, on plain
INTEGERkeys and with default settings on Firebird 6.0. It's caused by a second, non-random key collision: aNULLkey and a0key always hash to the same value.Environment: Firebird 6.0.0 (
LI-T6.0.0.2179, sourcemaster@84f30a54c6), defaultfirebird.conf. In this 6.0 source I can't find aSubQueryConversionsetting:RseNode::processPossibleJoinsconvertsIN/EXISTSwithout a config check, so on 6.0 this is reachable out of the box.The engine gives the right answer for the same predicate any other way:
SELECT ID, IIF(N IN (SELECT N FROM T), 'TRUE', 'NOT TRUE') FROM T→ row 2 isTRUE... WHERE N IN (SELECT N FROM T) OR 1 = 0(no semi-join) →2... WHERE N IN (SELECT N FROM T ORDER BY N DESC)(the0streamed before theNULL) →2... WHERE N IN (SELECT COALESCE(N, -1) FROM T)(noNULL) →25instead of0→2(only0collides withNULL)It also reproduces on
NUMERICandDOUBLE PRECISIONcolumns and with= ANY (...).Why
NULLand0collide:HashJoin::computeHash()starts withmemset(keyBuffer, 0, sub.totalKeyLength). When a key expression evaluates toNULL(EVL_expr()returnsnullptr), nothing is written, so the key stays all-zero bytes. That's identical to a numeric0, whose key is also all-zero (and floating-point zero is explicitly normalised to+0). TheNULLrow and the0row therefore always share a hash. The semi-join's first-candidate-only iteration, analysed above, then offers only theNULLrow to the residualN = N, which is not TRUE, and the matching0behind it is never examined.The SEMI iteration fix discussed here should cover this case too. Separately, encoding
NULLdistinctly in the key (or keepingNULLkeys out of the hash table for equality joins, cf. #7769) would remove this collision at the source.Found with the fire-crab differential test suite, which runs the same statements against a live Firebird 6 server and a re-implementation and flags any difference.
Report the semi-join NULL/0 bug upstream on FirebirdSQL/firebird#9158
Bump fire-crab: report the semi-join NULL/0 bug on FirebirdSQL/firebi…