This is an automated email from the ASF dual-hosted git repository.

FrankChen021 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 12dcc915662 fix: HyperLogLogCollector returns zero cardinality when a 
single element overflows into sparse mode (#19678)
12dcc915662 is described below

commit 12dcc915662dfb65774128cb4925b57fa491e75f
Author: Benedict Jin <[email protected]>
AuthorDate: Mon Sep 21 10:01:10 2026 +0800

    fix: HyperLogLogCollector returns zero cardinality when a single element 
overflows into sparse mode (#19678)
    
    * fix: HyperLogLogCollector returns zero cardinality when a single element 
overflows into sparse mode
    
    When an element's positionOf1 exceeds the 4-bit nibble range, its value is 
kept only in the overflow register and no sparse entry is written. If that 
register is the only data the collector holds, estimateSparse() never visits 
it, zeroCount stays at NUM_BUCKETS, and linear counting estimates a cardinality 
of 0. Account for a non-zero overflow register that was not folded into a 
visited entry.
    
    * Normalize sparse position before matching the overflow register
    
    The serialized sparse position is the payload byte index plus the header 
size, while overflowPosition is overflowRegister >>> 1, so the in-loop 
comparison never matched the overflow bucket's own entry. When a bucket held 
both an in-range and an overflow value, the register was counted twice (its 
sparse entry plus the standalone compensation), estimating 2 for a single 
populated register. Normalize the position to the payload byte index before 
comparing, and cover both cases with regre [...]
    
    * Check decoded register values against zero when applying the overflow 
register
    
    The zeroCount updates in estimateSparse and estimateDense applied nibble 
masks to decoded scalar values. An overflow value such as 16 folded into the 
lower nibble of an odd bucket satisfied (lowerNibble & 0x0f) == 0 and was 
counted as an empty register, collapsing the estimate of a single populated 
register to 0. Decoded in-range values 1-15 in the upper position were 
miscounted by (upperNibble & 0xf0) == 0 the same way. Test the decoded values 
against zero directly, and add regressio [...]
---
 .../org/apache/druid/hll/HyperLogLogCollector.java |  30 +++++-
 .../apache/druid/hll/HyperLogLogCollectorTest.java | 101 +++++++++++++++++++++
 2 files changed, 126 insertions(+), 5 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/hll/HyperLogLogCollector.java 
b/processing/src/main/java/org/apache/druid/hll/HyperLogLogCollector.java
index d285f1cd044..2e0460a5649 100644
--- a/processing/src/main/java/org/apache/druid/hll/HyperLogLogCollector.java
+++ b/processing/src/main/java/org/apache/druid/hll/HyperLogLogCollector.java
@@ -171,16 +171,21 @@ public abstract class HyperLogLogCollector implements 
Comparable<HyperLogLogColl
       final byte minNum,
       final byte overflowValue,
       final short overflowPosition,
-      final boolean isUpperNibble
+      final boolean isUpperNibble,
+      final int numHeaderBytes
   )
   {
     final ByteBuffer copy = buf.asReadOnlyBuffer();
     double e = 0.0d;
     int zeroCount = NUM_BUCKETS - 2 * (buf.remaining() / 3);
+    boolean overflowRegisterApplied = false;
     while (copy.hasRemaining()) {
       short position = copy.getShort();
       final int register = (int) copy.get() & 0xff;
-      if (overflowValue != 0 && position == overflowPosition) {
+      // `position` is the serialized offset: the payload byte index plus the 
header size. Normalize it back to the
+      // payload byte index before comparing with overflowPosition 
(overflowRegister >>> 1); otherwise the overflow
+      // bucket's own entry is never matched here and the fallback below 
double-counts that register.
+      if (overflowValue != 0 && (position - numHeaderBytes) == 
overflowPosition) {
         int upperNibble = ((register & 0xf0) >>> BITS_PER_BUCKET) + minNum;
         int lowerNibble = (register & 0x0f) + minNum;
         if (isUpperNibble) {
@@ -189,13 +194,25 @@ public abstract class HyperLogLogCollector implements 
Comparable<HyperLogLogColl
           lowerNibble = Math.max(lowerNibble, overflowValue);
         }
         e += 1.0d / Math.pow(2, upperNibble) + 1.0d / Math.pow(2, lowerNibble);
-        zeroCount += (((upperNibble & 0xf0) == 0) ? 1 : 0) + (((lowerNibble & 
0x0f) == 0) ? 1 : 0);
+        // upperNibble/lowerNibble hold decoded scalar values here (nibble + 
minNum, possibly the overflow value),
+        // so test them against zero directly: nibble masks would wrongly flag 
decoded values like 16 as empty.
+        zeroCount += ((upperNibble == 0) ? 1 : 0) + ((lowerNibble == 0) ? 1 : 
0);
+        overflowRegisterApplied = true;
       } else {
         e += MIN_NUM_REGISTER_LOOKUP[minNum][register];
         zeroCount += NUM_ZERO_LOOKUP[register];
       }
     }
 
+    // A sparse buffer only stores registers set through the regular nibble 
range. When the only information about
+    // a bucket lives in the overflow register (positionOf1 exceeded 
registerOffset + RANGE) and that bucket is not
+    // among the entries above, it was implicitly counted as an empty 
register. Account for it explicitly so linear
+    // counting does not treat the collector as empty and estimate a 
cardinality of zero.
+    if (overflowValue != 0 && !overflowRegisterApplied) {
+      zeroCount -= 1;
+      e += 1.0d / Math.pow(2, overflowValue);
+    }
+
     e += zeroCount;
     return applyCorrection(e, zeroCount);
   }
@@ -223,7 +240,9 @@ public abstract class HyperLogLogCollector implements 
Comparable<HyperLogLogColl
           lowerNibble = Math.max(lowerNibble, overflowValue);
         }
         e += 1.0d / Math.pow(2, upperNibble) + 1.0d / Math.pow(2, lowerNibble);
-        zeroCount += (((upperNibble & 0xf0) == 0) ? 1 : 0) + (((lowerNibble & 
0x0f) == 0) ? 1 : 0);
+        // upperNibble/lowerNibble hold decoded scalar values here (nibble + 
minNum, possibly the overflow value),
+        // so test them against zero directly: nibble masks would wrongly flag 
decoded values like 16 as empty.
+        zeroCount += ((upperNibble == 0) ? 1 : 0) + ((lowerNibble == 0) ? 1 : 
0);
       } else {
         e += MIN_NUM_REGISTER_LOOKUP[minNum][register];
         zeroCount += NUM_ZERO_LOOKUP[register];
@@ -535,7 +554,8 @@ public abstract class HyperLogLogCollector implements 
Comparable<HyperLogLogColl
             registerOffset,
             overflowValue,
             overflowPosition,
-            isUpperNibble
+            isUpperNibble,
+            getNumHeaderBytes()
         );
       } else {
         estimatedCardinality = estimateDense(
diff --git 
a/processing/src/test/java/org/apache/druid/hll/HyperLogLogCollectorTest.java 
b/processing/src/test/java/org/apache/druid/hll/HyperLogLogCollectorTest.java
index fc4d97a9fd6..34a36b50f4e 100644
--- 
a/processing/src/test/java/org/apache/druid/hll/HyperLogLogCollectorTest.java
+++ 
b/processing/src/test/java/org/apache/druid/hll/HyperLogLogCollectorTest.java
@@ -609,6 +609,107 @@ public class HyperLogLogCollectorTest
     Assertions.assertEquals(expectedVals[valsToCheckIndex], 
collector.estimateCardinality(), 0.0d);
   }
 
+  @Test
+  public void testSparseOverflowRegisterEstimation()
+  {
+    // Reproduces the case where a single element whose positionOf1 exceeds 
the 4-bit nibble range
+    // (registerOffset 0 + RANGE 15) is stored only in the overflow register, 
leaving the sparse buffer empty.
+    // estimateSparse() must still account for that one non-empty register 
instead of reporting a cardinality of 0.
+
+    // Direct form: bucket 5 with positionOf1 = 16 (> RANGE) goes straight to 
the overflow register.
+    HyperLogLogCollector direct = HyperLogLogCollector.makeLatestCollector();
+    direct.add((short) 5, (byte) 16);
+    Assert.assertEquals(1L, direct.estimateCardinalityRound());
+    Assert.assertEquals(1.0d, direct.estimateCardinality(), 0.05d);
+
+    // Same situation reached through the byte[] hashing path used during 
ingestion: leading bytes 0x00 0x80
+    // give positionOf1 = 16, and the trailing bytes select the bucket.
+    byte[] hashedValue = new byte[16];
+    hashedValue[1] = (byte) 0x80;
+    hashedValue[15] = 0x05;
+    HyperLogLogCollector hashed = HyperLogLogCollector.makeLatestCollector();
+    hashed.add(hashedValue);
+    Assert.assertEquals(1L, hashed.estimateCardinalityRound());
+  }
+
+  @Test
+  public void testSparseOverflowRegisterOnPopulatedBucket()
+  {
+    // Adding an in-range value converts the collector to dense storage, so 
estimateSparse's overflow handling is
+    // only reachable through a sparse-serialized buffer. A single bucket that 
first receives an in-range value and
+    // then an overflow value is still one populated register (the overflow 
value supersedes the nibble). After a
+    // sparse round-trip that register must be counted exactly once: 
estimateSparse has to recognize that the
+    // overflow register already has a sparse entry (comparing positions in 
the same coordinate system) and skip the
+    // standalone compensation, otherwise the register is counted twice and a 
single element estimates as 2.
+    HyperLogLogCollector source = HyperLogLogCollector.makeLatestCollector();
+    source.add((short) 4, (byte) 3);   // in-range value -> nibble for bucket 4
+    source.add((short) 4, (byte) 16);  // overflow on the same bucket -> 
overflow register
+
+    HyperLogLogCollector sparse = 
HyperLogLogCollector.makeCollector(ByteBuffer.wrap(source.toByteArray()));
+    Assert.assertEquals(1L, sparse.estimateCardinalityRound());
+  }
+
+  @Test
+  public void testSparseOverflowRegisterAmongOtherEntries()
+  {
+    // A sparse buffer holding an overflow register plus other populated 
registers: estimateSparse must fold the
+    // overflow into the overflow bucket's own entry and leave the other 
entries untouched, which exercises both
+    // sides of the normalized position comparison. Two distinct populated 
registers should estimate as 2.
+    HyperLogLogCollector source = HyperLogLogCollector.makeLatestCollector();
+    source.add((short) 4, (byte) 3);   // bucket 4 in-range value
+    source.add((short) 4, (byte) 16);  // overflow on bucket 4 -> overflow 
register
+    source.add((short) 20, (byte) 5);  // an unrelated populated register in a 
different byte
+
+    HyperLogLogCollector sparse = 
HyperLogLogCollector.makeCollector(ByteBuffer.wrap(source.toByteArray()));
+    Assert.assertEquals(2L, sparse.estimateCardinalityRound());
+  }
+
+  @Test
+  public void testSparseOverflowRegisterOnOddBucket()
+  {
+    // Odd bucket: the overflow value is folded into the decoded lowerNibble 
(16), which is a scalar and no longer
+    // fits the 4-bit nibble mask. zeroCount must treat it as populated, 
otherwise the single register is counted
+    // as empty and the estimate collapses to 0.
+    HyperLogLogCollector source = HyperLogLogCollector.makeLatestCollector();
+    source.add((short) 5, (byte) 3);   // in-range value -> lower nibble of 
byte 2
+    source.add((short) 5, (byte) 16);  // overflow on the same odd bucket
+
+    HyperLogLogCollector sparse = 
HyperLogLogCollector.makeCollector(ByteBuffer.wrap(source.toByteArray()));
+    Assert.assertEquals(1L, sparse.estimateCardinalityRound());
+  }
+
+  @Test
+  public void testSparseOverflowRegisterOnOddBucketWithPopulatedNeighbor()
+  {
+    // The overflow byte also holds an in-range value for the neighboring even 
bucket. Both decoded nibbles are
+    // non-zero scalars (2 and 16), so neither register may be counted as 
empty: two populated registers should
+    // estimate as 2.
+    HyperLogLogCollector source = HyperLogLogCollector.makeLatestCollector();
+    source.add((short) 4, (byte) 2);   // neighbor bucket in the same byte, 
upper nibble
+    source.add((short) 5, (byte) 3);   // odd bucket in-range value, lower 
nibble
+    source.add((short) 5, (byte) 16);  // overflow on the odd bucket
+
+    HyperLogLogCollector sparse = 
HyperLogLogCollector.makeCollector(ByteBuffer.wrap(source.toByteArray()));
+    Assert.assertEquals(2L, sparse.estimateCardinalityRound());
+  }
+
+  @Test
+  public void testDenseOverflowRegisterEstimation()
+  {
+    // Adding an in-range value converts the collector to dense storage, so 
estimating without a sparse round-trip
+    // exercises estimateDense's overflow handling. The same decoded-scalar 
zero check applies there: one populated
+    // register must estimate as 1 for both nibble parities.
+    HyperLogLogCollector even = HyperLogLogCollector.makeLatestCollector();
+    even.add((short) 4, (byte) 3);
+    even.add((short) 4, (byte) 16);
+    Assert.assertEquals(1L, even.estimateCardinalityRound());
+
+    HyperLogLogCollector odd = HyperLogLogCollector.makeLatestCollector();
+    odd.add((short) 5, (byte) 3);
+    odd.add((short) 5, (byte) 16);
+    Assert.assertEquals(1L, odd.estimateCardinalityRound());
+  }
+
   @Test
   public void testEstimationReadOnlyByteBuffers()
   {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to