diff --git a/docs/operations/metrics.md b/docs/operations/metrics.md index 8d71c6044f85..5989039391b7 100644 --- a/docs/operations/metrics.md +++ b/docs/operations/metrics.md @@ -93,8 +93,9 @@ Most metric values reset each emission period, as specified in `druid.monitoring |`mergeBuffer/queries`|Number of groupBy queries that acquired a batch of buffers from the merge buffer pool.|This metric is only available if the `GroupByStatsMonitor` module is included.|Depends on the number of groupBy queries needing merge buffers.| |`mergeBuffer/acquisitionTimeNs`|Total time in nanoseconds to acquire merge buffer for groupBy queries.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| |`mergeBuffer/maxAcquisitionTimeNs`|Maximum time in nanoseconds to acquire merge buffer for any single groupBy query within the emission period.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| -|`mergeBuffer/bytesUsed`|Number of bytes used by merge buffers to process groupBy queries.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| -|`mergeBuffer/maxBytesUsed`|Maximum number of bytes used by merge buffers for any single groupBy query within the emission period.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| +|`mergeBuffer/bytesUsed`|Total merge buffer bytes used to process groupBy queries, summed across queries. Each query's value is itself the sum across the slices it held: a merge buffer is divided among `druid.processing.numThreads` concurrent query slices, so a query can spill once a single slice fills even though this summed usage looks well below `druid.processing.buffer.sizeBytes`. To gauge spill pressure, use `mergeBuffer/maxSpillProximity` rather than comparing this to `sizeBytes`.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| +|`mergeBuffer/maxBytesUsed`|Maximum merge buffer bytes used by any single groupBy query within the emission period, where each query's usage is the sum across the slices it held. Because the buffer is sliced among `druid.processing.numThreads` query slices, this value is not directly comparable to `druid.processing.buffer.sizeBytes`; use `mergeBuffer/maxSpillProximity` to gauge spill pressure.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| +|`mergeBuffer/maxSpillProximity`|How close any single groupBy query came to spilling within the emission period, as a fraction in [0.0, 1.0]. A merge buffer is divided into `druid.processing.numThreads` slices and a query spills as soon as its fullest single slice's hash table cannot allocate another bucket; this metric is that fullest slice's peak fill ratio (bucket count over the load-factor bucket limit), taken as a max across slices and across queries. **1.0 corresponds exactly to the spill trigger** — the value is bucket-count based, so it is not distorted by bucket width, offset-list overhead, or integer truncation. If you see 1.0, raise `druid.processing.buffer.sizeBytes` or lower `druid.processing.numThreads` to widen each slice. Read alongside `groupBy/spilledQueries`.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| |`groupBy/spilledQueries`|Number of groupBy queries that have spilled onto the disk.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| |`groupBy/spilledBytes`|Number of bytes spilled on the disk by the groupBy queries.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| |`groupBy/maxSpilledBytes`|Maximum number of bytes spilled to disk by any single groupBy query within the emission period.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| @@ -122,8 +123,9 @@ Most metric values reset each emission period, as specified in `druid.monitoring |`mergeBuffer/queries`|Number of groupBy queries that acquired a batch of buffers from the merge buffer pool.|This metric is only available if the `GroupByStatsMonitor` module is included.|Depends on the number of groupBy queries needing merge buffers.| |`mergeBuffer/acquisitionTimeNs`|Total time in nanoseconds to acquire merge buffer for groupBy queries.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| |`mergeBuffer/maxAcquisitionTimeNs`|Maximum time in nanoseconds to acquire merge buffer for any single groupBy query within the emission period.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| -|`mergeBuffer/bytesUsed`|Number of bytes used by merge buffers to process groupBy queries.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| -|`mergeBuffer/maxBytesUsed`|Maximum number of bytes used by merge buffers for any single groupBy query within the emission period.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| +|`mergeBuffer/bytesUsed`|Total merge buffer bytes used to process groupBy queries, summed across queries. Each query's value is itself the sum across the slices it held: a merge buffer is divided among `druid.processing.numThreads` concurrent query slices, so a query can spill once a single slice fills even though this summed usage looks well below `druid.processing.buffer.sizeBytes`. To gauge spill pressure, use `mergeBuffer/maxSpillProximity` rather than comparing this to `sizeBytes`.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| +|`mergeBuffer/maxBytesUsed`|Maximum merge buffer bytes used by any single groupBy query within the emission period, where each query's usage is the sum across the slices it held. Because the buffer is sliced among `druid.processing.numThreads` query slices, this value is not directly comparable to `druid.processing.buffer.sizeBytes`; use `mergeBuffer/maxSpillProximity` to gauge spill pressure.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| +|`mergeBuffer/maxSpillProximity`|How close any single groupBy query came to spilling within the emission period, as a fraction in [0.0, 1.0]. A merge buffer is divided into `druid.processing.numThreads` slices and a query spills as soon as its fullest single slice's hash table cannot allocate another bucket; this metric is that fullest slice's peak fill ratio (bucket count over the load-factor bucket limit), taken as a max across slices and across queries. **1.0 corresponds exactly to the spill trigger** — the value is bucket-count based, so it is not distorted by bucket width, offset-list overhead, or integer truncation. If you see 1.0, raise `druid.processing.buffer.sizeBytes` or lower `druid.processing.numThreads` to widen each slice. Read alongside `groupBy/spilledQueries`.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| |`groupBy/spilledQueries`|Number of groupBy queries that have spilled onto the disk.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| |`groupBy/spilledBytes`|Number of bytes spilled on the disk by the groupBy queries.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| |`groupBy/maxSpilledBytes`|Maximum number of bytes spilled to disk by any single groupBy query within the emission period.|This metric is only available if the `GroupByStatsMonitor` module is included.|Varies| @@ -154,8 +156,9 @@ to represent the task ID are deprecated and will be removed in a future release. |`mergeBuffer/queries`|Number of groupBy queries that acquired a batch of buffers from the merge buffer pool. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Depends on the number of groupBy queries needing merge buffers.| |`mergeBuffer/acquisitionTimeNs`|Total time in nanoseconds to acquire merge buffer for groupBy queries. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| |`mergeBuffer/maxAcquisitionTimeNs`|Maximum time in nanoseconds to acquire merge buffer for any single groupBy query within the emission period. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| -|`mergeBuffer/bytesUsed`|Number of bytes used by merge buffers to process groupBy queries.|This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| -|`mergeBuffer/maxBytesUsed`|Maximum number of bytes used by merge buffers for any single groupBy query within the emission period. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| +|`mergeBuffer/bytesUsed`|Total merge buffer bytes used to process groupBy queries, summed across queries. Each query's value is itself the sum across the slices it held: a merge buffer is divided among `druid.processing.numThreads` concurrent query slices, so a query can spill once a single slice fills even though this summed usage looks well below `druid.processing.buffer.sizeBytes`. To gauge spill pressure, use `mergeBuffer/maxSpillProximity` rather than comparing this to `sizeBytes`. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| +|`mergeBuffer/maxBytesUsed`|Maximum merge buffer bytes used by any single groupBy query within the emission period, where each query's usage is the sum across the slices it held. Because the buffer is sliced among `druid.processing.numThreads` query slices, this value is not directly comparable to `druid.processing.buffer.sizeBytes`; use `mergeBuffer/maxSpillProximity` to gauge spill pressure. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| +|`mergeBuffer/maxSpillProximity`|How close any single groupBy query came to spilling within the emission period, as a fraction in [0.0, 1.0]. A merge buffer is divided into `druid.processing.numThreads` slices and a query spills as soon as its fullest single slice's hash table cannot allocate another bucket; this metric is that fullest slice's peak fill ratio (bucket count over the load-factor bucket limit), taken as a max across slices and across queries. **1.0 corresponds exactly to the spill trigger** — the value is bucket-count based, so it is not distorted by bucket width, offset-list overhead, or integer truncation. If you see 1.0, raise `druid.processing.buffer.sizeBytes` or lower `druid.processing.numThreads` to widen each slice. Read alongside `groupBy/spilledQueries`. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| |`groupBy/spilledQueries`|Number of groupBy queries that have spilled onto the disk. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| |`groupBy/spilledBytes`|Number of bytes spilled on the disk by the groupBy queries. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| |`groupBy/maxSpilledBytes`|Maximum number of bytes spilled to disk by any single groupBy query within the emission period. This metric is only available if the `GroupByStatsMonitor` module is included.|`dataSource`, `taskId`|Varies| diff --git a/processing/src/main/java/org/apache/druid/query/groupby/GroupByStatsProvider.java b/processing/src/main/java/org/apache/druid/query/groupby/GroupByStatsProvider.java index f6b92a7b62c1..6fb05154ba68 100644 --- a/processing/src/main/java/org/apache/druid/query/groupby/GroupByStatsProvider.java +++ b/processing/src/main/java/org/apache/druid/query/groupby/GroupByStatsProvider.java @@ -25,6 +25,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.DoubleAccumulator; /** * Collects groupBy query metrics (spilled bytes, merge buffer usage, dictionary size) per-query, then @@ -73,6 +74,7 @@ public static class AggregateStats private long maxMergeBufferAcquisitionTimeNs = 0; private long totalMergeBufferUsedBytes = 0; private long maxMergeBufferUsedBytes = 0; + private double maxSpillProximity = 0.0; private long spilledQueries = 0; private long spilledBytes = 0; private long maxSpilledBytes = 0; @@ -91,6 +93,7 @@ public AggregateStats(AggregateStats aggregateStats) aggregateStats.maxMergeBufferAcquisitionTimeNs, aggregateStats.totalMergeBufferUsedBytes, aggregateStats.maxMergeBufferUsedBytes, + aggregateStats.maxSpillProximity, aggregateStats.spilledQueries, aggregateStats.spilledBytes, aggregateStats.maxSpilledBytes, @@ -105,6 +108,7 @@ public AggregateStats( long maxMergeBufferAcquisitionTimeNs, long totalMergeBufferUsedBytes, long maxMergeBufferUsedBytes, + double maxSpillProximity, long spilledQueries, long spilledBytes, long maxSpilledBytes, @@ -117,6 +121,7 @@ public AggregateStats( this.maxMergeBufferAcquisitionTimeNs = maxMergeBufferAcquisitionTimeNs; this.totalMergeBufferUsedBytes = totalMergeBufferUsedBytes; this.maxMergeBufferUsedBytes = maxMergeBufferUsedBytes; + this.maxSpillProximity = maxSpillProximity; this.spilledQueries = spilledQueries; this.spilledBytes = spilledBytes; this.maxSpilledBytes = maxSpilledBytes; @@ -149,6 +154,11 @@ public long getMaxMergeBufferUsedBytes() return maxMergeBufferUsedBytes; } + public double getMaxSpillProximity() + { + return maxSpillProximity; + } + public long getSpilledQueries() { return spilledQueries; @@ -174,6 +184,20 @@ public long getMaxMergeDictionarySize() return maxMergeDictionarySize; } + /** + * Folds a completed query's stats into the running aggregate. For merge-buffer usage: + *
The ratio is captured strictly while {@code size < regrowthThreshold}. At intermediate growth boundaries + * {@code size} hits {@code regrowthThreshold} transiently — but the arena still has room to grow, so the very next + * mutation triggers {@link #adjustTableWhenFull()} which enlarges {@code regrowthThreshold}. Recording 1.0 there + * would falsely mark ordinary growth as a spill. The exception is the TERMINAL growth level, guarded by + * {@link #isTerminalTableLevel()}: at that level no further growth is possible, so parking at + * {@code size == regrowthThreshold} IS the spill point and 1.0 is recorded. The other definitive spill trigger, + * where 1.0 is also pinned, is in {@link #findBucketWithAutoGrowth} when a bucket rejection actually occurs.
*/ protected void updateMaxMergeBufferUsedBytes() { maxMergeBufferUsedBytes = Math.max(maxMergeBufferUsedBytes, (long) size * bucketSizeWithHash); + if (regrowthThreshold <= 0) { + return; + } + if (size < regrowthThreshold) { + final double ratio = (double) size / regrowthThreshold; + if (ratio > maxSpillProximity) { + maxSpillProximity = ratio; + } + } else if (isTerminalTableLevel()) { + // Table is at the load-factor limit and no further growth is possible: functionally the spill point. + maxSpillProximity = 1.0; + } + } + + /** + * True when the current table level cannot be enlarged any further. Base implementation: {@link #tableStart} has + * reached the front of the arena, matching {@link #adjustTableWhenFull()}'s early-return. Overridden by + * hash-table variants (e.g. the alternating heap-trim variant in {@code LimitedBufferHashGrouper}) whose + * "table full" event is NOT a spill trigger — those return false so proximity is only pinned via the explicit + * spill path in {@link #findBucketWithAutoGrowth}. + */ + protected boolean isTerminalTableLevel() + { + return tableStart == 0; } public long getMaxMergeBufferUsedBytes() @@ -455,6 +504,19 @@ public long getMaxMergeBufferUsedBytes() return maxMergeBufferUsedBytes; } + /** + * Peak {@code size / regrowthThreshold} observed over this table's lifetime, in [0.0, 1.0]. Equals 1.0 exactly when + * a new-bucket insert has been rejected inside {@link #findBucketWithAutoGrowth} — the true spill trigger. This is + * bucket-count based, so the value is independent of bucket width, offset-list overhead, and integer truncation in + * the arena-size calculation. Preserved across {@link #reset()} so a grouper that already spilled retains the 1.0 + * peak even after {@code size} returns to 0. Ordinary table growth (when {@link #adjustTableWhenFull()} enlarges + * {@code regrowthThreshold} instead of triggering a spill) does not push this to 1.0. + */ + public double getMaxSpillProximity() + { + return maxSpillProximity; + } + public interface BucketUpdateHandler { void handleNewBucket(int bucketOffset); diff --git a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/LimitedBufferHashGrouper.java b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/LimitedBufferHashGrouper.java index 2bb544d0cda7..b428c31816c5 100644 --- a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/LimitedBufferHashGrouper.java +++ b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/LimitedBufferHashGrouper.java @@ -587,5 +587,27 @@ public void adjustTableWhenFull() tableBuffer = newTableBuffer; growthCount++; } + + /** + * Alternating sub-buffers can always accept another heap-trim swap; hitting {@code size == regrowthThreshold} here + * is not a spill trigger (the limit push-down grouper doesn't spill from this path). Return false so the base + * class's terminal-level 1.0 pin does not fire on ordinary swaps. + * + *Note that a raw {@code tableStart == 0} check (the base implementation) would ALSO be wrong here: + * {@code tableStart} stays at 0 for this table's entire life (it's never set by the ctor or {@link #reset()}), + * so a base implementation would spuriously fire on every swap where {@code size == regrowthThreshold}. The + * override is what keeps limit-push-down queries from a false-positive 1.0.
+ * + *Why the Alternating table's true spill trigger is safe to rely on {@link #findBucketWithAutoGrowth} + * returning -1: after a swap, {@code size = numCopied} and {@code numCopied <= limit}. The grouper is + * validated at construction to have {@code regrowthThreshold >= limit + 1}, so + * {@code size < regrowthThreshold} always holds post-swap and the subsequent findBucket succeeds. Only a + * genuinely-full-and-cannot-swap condition would return -1 — the real spill case.
+ */ + @Override + protected boolean isTerminalTableLevel() + { + return false; + } } } diff --git a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/SpillingGrouper.java b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/SpillingGrouper.java index 96e55907b21f..b149519b1428 100644 --- a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/SpillingGrouper.java +++ b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/SpillingGrouper.java @@ -249,7 +249,15 @@ public void reset() public void close() { perQueryStats.dictionarySize(getDictionarySizeEstimate()); - perQueryStats.maxMergeBufferUsedBytes(getMaxMergeBufferUsedBytes()); + final long sliceUsedBytes = getMaxMergeBufferUsedBytes(); + perQueryStats.addMergeBufferUsedBytes(sliceUsedBytes); + if (grouper.isInitialized()) { + // Report this slice's peak fill ratio (bucket-count based, tracked by the underlying hash table across the + // grouper's lifetime and preserved across reset()). 1.0 means the grouper actually hit its spill trigger; a + // lightly-filled slice reports <1.0 exactly. Only recorded when the grouper was initialized, so an untouched + // slice contributes nothing. + perQueryStats.sliceUsage(grouper.getMaxSpillProximity()); + } // Record spilled bytes before deleteFiles() decrements bytesUsed in temporaryStorage. long spilledBytes = 0; for (final File file : files) { diff --git a/processing/src/test/java/org/apache/druid/query/groupby/GroupByStatsProviderTest.java b/processing/src/test/java/org/apache/druid/query/groupby/GroupByStatsProviderTest.java index dafd381668d8..1a726dfccb44 100644 --- a/processing/src/test/java/org/apache/druid/query/groupby/GroupByStatsProviderTest.java +++ b/processing/src/test/java/org/apache/druid/query/groupby/GroupByStatsProviderTest.java @@ -25,6 +25,8 @@ public class GroupByStatsProviderTest { + private static final double DELTA = 1e-9; + @Test public void testMetricCollection() { @@ -35,7 +37,12 @@ public void testMetricCollection() stats1.mergeBufferAcquisitionTime(300); stats1.mergeBufferAcquisitionTime(400); - stats1.maxMergeBufferUsedBytes(50); + // Two slices of the same query: usage SUMS to 80, while spill proximity is the per-slice MAX. Both slices report + // their own peak fill ratio (bucket-count based, in [0,1]); the fullest (0.05) drives proximity. + stats1.addMergeBufferUsedBytes(50); + stats1.sliceUsage(0.05); + stats1.addMergeBufferUsedBytes(30); + stats1.sliceUsage(0.03); stats1.spilledBytes(200); stats1.spilledBytes(400); stats1.dictionarySize(100); @@ -46,7 +53,9 @@ public void testMetricCollection() stats2.mergeBufferAcquisitionTime(500); stats2.mergeBufferAcquisitionTime(600); - stats2.maxMergeBufferUsedBytes(100); + // Single slice at 0.05. + stats2.addMergeBufferUsedBytes(100); + stats2.sliceUsage(0.05); stats2.spilledBytes(400); stats2.spilledBytes(600); stats2.dictionarySize(300); @@ -58,6 +67,7 @@ public void testMetricCollection() Assertions.assertEquals(0L, aggregateStats.getMaxMergeBufferAcquisitionTimeNs()); Assertions.assertEquals(0L, aggregateStats.getTotalMergeBufferUsedBytes()); Assertions.assertEquals(0L, aggregateStats.getMaxMergeBufferUsedBytes()); + Assertions.assertEquals(0.0, aggregateStats.getMaxSpillProximity(), DELTA); Assertions.assertEquals(0L, aggregateStats.getSpilledQueries()); Assertions.assertEquals(0L, aggregateStats.getSpilledBytes()); Assertions.assertEquals(0L, aggregateStats.getMaxSpilledBytes()); @@ -71,8 +81,13 @@ public void testMetricCollection() Assertions.assertEquals(2, aggregateStats.getMergeBufferQueries()); Assertions.assertEquals(1800L, aggregateStats.getMergeBufferAcquisitionTimeNs()); Assertions.assertEquals(1100L, aggregateStats.getMaxMergeBufferAcquisitionTimeNs()); - Assertions.assertEquals(150L, aggregateStats.getTotalMergeBufferUsedBytes()); + // bytesUsed sums across queries AND across each query's slices: (50 + 30) + 100 = 180. + Assertions.assertEquals(180L, aggregateStats.getTotalMergeBufferUsedBytes()); + // maxBytesUsed is the max per-query summed usage: max(80, 100) = 100. Assertions.assertEquals(100L, aggregateStats.getMaxMergeBufferUsedBytes()); + // maxSpillProximity is the max per-query proximity, where each query's proximity is its fullest slice's + // fill ratio: q1 -> max(0.05, 0.03) = 0.05, q2 -> 0.05, so max = 0.05. + Assertions.assertEquals(0.05, aggregateStats.getMaxSpillProximity(), DELTA); Assertions.assertEquals(2L, aggregateStats.getSpilledQueries()); Assertions.assertEquals(1600L, aggregateStats.getSpilledBytes()); Assertions.assertEquals(1000L, aggregateStats.getMaxSpilledBytes()); @@ -88,28 +103,32 @@ public void testMetricsWithMultipleQueries() QueryResourceId r1 = new QueryResourceId("r1"); GroupByStatsProvider.PerQueryStats stats1 = statsProvider.getPerQueryStatsContainer(r1); stats1.mergeBufferAcquisitionTime(2000); - stats1.maxMergeBufferUsedBytes(50); + stats1.addMergeBufferUsedBytes(50); + stats1.sliceUsage(0.05); stats1.spilledBytes(100); stats1.dictionarySize(200); QueryResourceId r2 = new QueryResourceId("r2"); GroupByStatsProvider.PerQueryStats stats2 = statsProvider.getPerQueryStatsContainer(r2); stats2.mergeBufferAcquisitionTime(100); - stats2.maxMergeBufferUsedBytes(500); + stats2.addMergeBufferUsedBytes(500); + stats2.sliceUsage(0.5); stats2.spilledBytes(150); stats2.dictionarySize(250); QueryResourceId r3 = new QueryResourceId("r3"); GroupByStatsProvider.PerQueryStats stats3 = statsProvider.getPerQueryStatsContainer(r3); stats3.mergeBufferAcquisitionTime(200); - stats3.maxMergeBufferUsedBytes(100); + stats3.addMergeBufferUsedBytes(100); + stats3.sliceUsage(0.1); stats3.spilledBytes(3000); stats3.dictionarySize(300); QueryResourceId r4 = new QueryResourceId("r4"); GroupByStatsProvider.PerQueryStats stats4 = statsProvider.getPerQueryStatsContainer(r4); stats4.mergeBufferAcquisitionTime(300); - stats4.maxMergeBufferUsedBytes(75); + stats4.addMergeBufferUsedBytes(75); + stats4.sliceUsage(0.075); stats4.spilledBytes(200); stats4.dictionarySize(1500); @@ -122,6 +141,8 @@ public void testMetricsWithMultipleQueries() Assertions.assertEquals(2000L, aggregateStats.getMaxMergeBufferAcquisitionTimeNs()); Assertions.assertEquals(500L, aggregateStats.getMaxMergeBufferUsedBytes()); + // Max per-query proximity across the four queries: max(0.05, 0.5, 0.1, 0.075) = 0.5. + Assertions.assertEquals(0.5, aggregateStats.getMaxSpillProximity(), DELTA); Assertions.assertEquals(3000L, aggregateStats.getMaxSpilledBytes()); Assertions.assertEquals(1500L, aggregateStats.getMaxMergeDictionarySize()); @@ -132,4 +153,212 @@ public void testMetricsWithMultipleQueries() Assertions.assertEquals(3450L, aggregateStats.getSpilledBytes()); Assertions.assertEquals(2250L, aggregateStats.getMergeDictionarySize()); } + + @Test + public void testPerQueryUsedBytesSumsWhileSpillProximityTakesSliceMax() + { + GroupByStatsProvider.PerQueryStats stats = new GroupByStatsProvider.PerQueryStats(); + + // Simulate a ConcurrentGrouper closing four equally-sized slices. Each slice reports its own peak fill ratio (in + // [0, 1]) and its own peak used bytes. Used bytes accumulate (sum); spill proximity is driven by the fullest slice. + stats.addMergeBufferUsedBytes(10); + stats.sliceUsage(0.01); + stats.addMergeBufferUsedBytes(20); + stats.sliceUsage(0.02); + stats.addMergeBufferUsedBytes(30); + stats.sliceUsage(0.03); + stats.addMergeBufferUsedBytes(40); + stats.sliceUsage(0.04); + + // Used bytes are summed across slices. + Assertions.assertEquals(100L, stats.getMergeBufferUsedBytes()); + // Proximity is the fullest slice's fill ratio (0.04), NOT anything computed from the summed usage. + Assertions.assertEquals(0.04, stats.getSpillProximity(), DELTA); + } + + @Test + public void testSpillProximityClampsToRange() + { + // A slice at exactly the spill point. + GroupByStatsProvider.PerQueryStats atSpill = new GroupByStatsProvider.PerQueryStats(); + atSpill.sliceUsage(1.0); + Assertions.assertEquals(1.0, atSpill.getSpillProximity(), DELTA); + + // Defensive clamping: a caller that somehow passes >1.0 must not produce >1.0. + GroupByStatsProvider.PerQueryStats over = new GroupByStatsProvider.PerQueryStats(); + over.sliceUsage(1.5); + Assertions.assertEquals(1.0, over.getSpillProximity(), DELTA); + + // Defensive clamping on the low end: a negative ratio is treated as 0.0 (never contributes to the max). + GroupByStatsProvider.PerQueryStats neg = new GroupByStatsProvider.PerQueryStats(); + neg.sliceUsage(-0.25); + Assertions.assertEquals(0.0, neg.getSpillProximity(), DELTA); + + // NaN is ignored entirely so a never-initialized grouper does not corrupt the accumulator. + GroupByStatsProvider.PerQueryStats nan = new GroupByStatsProvider.PerQueryStats(); + nan.sliceUsage(Double.NaN); + Assertions.assertEquals(0.0, nan.getSpillProximity(), DELTA); + } + + @Test + public void testSpillProximityZeroWhenNoSliceUsageRecorded() + { + GroupByStatsProvider.PerQueryStats stats = new GroupByStatsProvider.PerQueryStats(); + // No sliceUsage() call: proximity stays at its initial 0.0. + stats.addMergeBufferUsedBytes(500); + Assertions.assertEquals(0.0, stats.getSpillProximity(), DELTA); + } + + @Test + public void testSpillProximityPicksFullestSliceWhenSlicesDiffer() + { + GroupByStatsProvider.PerQueryStats stats = new GroupByStatsProvider.PerQueryStats(); + // Three slices with differing fill; proximity is the fullest. + stats.sliceUsage(0.2); + stats.sliceUsage(0.9); + stats.sliceUsage(0.1); + Assertions.assertEquals(0.9, stats.getSpillProximity(), DELTA); + } + + @Test + public void testSpillProximityKeepsPerSliceRatioWhenThresholdsDiffer() + { + // A single query can pass one PerQueryStats through both small sliced groupers (from a ConcurrentGrouper) and a + // full-buffer SpillingGrouper (subtotal/nested processing). A small slice can saturate (proximity 1.0) while a much + // larger full-buffer grouper stays lightly filled (proximity ~0.005). Because sliceUsage records the ratio directly, + // the saturated slice's 1.0 is preserved verbatim — there is no shared byte threshold to dilute it. + GroupByStatsProvider.PerQueryStats stats = new GroupByStatsProvider.PerQueryStats(); + stats.sliceUsage(1.0); // small sliced grouper at its spill point + stats.sliceUsage(0.005); // large full-buffer grouper barely filled + Assertions.assertEquals(1.0, stats.getSpillProximity(), DELTA); + } + + @Test + public void testAggregateStatsResetZeroesSpillProximity() + { + GroupByStatsProvider.AggregateStats aggregateStats = new GroupByStatsProvider.AggregateStats( + 1L, + 100L, + 100L, + 200L, + 200L, + 0.75, + 2L, + 200L, + 200L, + 300L, + 300L + ); + Assertions.assertEquals(0.75, aggregateStats.getMaxSpillProximity(), DELTA); + + aggregateStats.reset(); + Assertions.assertEquals(0.0, aggregateStats.getMaxSpillProximity(), DELTA); + } + + @Test + public void testAggregateStatsCopyConstructorRoundTripsSpillProximity() + { + GroupByStatsProvider.AggregateStats original = new GroupByStatsProvider.AggregateStats( + 1L, + 100L, + 100L, + 200L, + 200L, + 0.42, + 2L, + 200L, + 200L, + 300L, + 300L + ); + GroupByStatsProvider.AggregateStats copy = new GroupByStatsProvider.AggregateStats(original); + + Assertions.assertEquals(0.42, copy.getMaxSpillProximity(), DELTA); + // Spill proximity is the 6th ctor arg, sitting between maxBytesUsed and spilledQueries; verify neighbours + // did not shift position. + Assertions.assertEquals(200L, copy.getMaxMergeBufferUsedBytes()); + Assertions.assertEquals(2L, copy.getSpilledQueries()); + } + + @Test + public void testAggregateStatsTakesMaxSpillProximityAcrossQueries() + { + GroupByStatsProvider.AggregateStats agg = new GroupByStatsProvider.AggregateStats(); + + GroupByStatsProvider.PerQueryStats low = new GroupByStatsProvider.PerQueryStats(); + low.mergeBufferAcquisitionTime(10); + low.sliceUsage(0.3); + agg.addQueryStats(low); + + GroupByStatsProvider.PerQueryStats high = new GroupByStatsProvider.PerQueryStats(); + high.mergeBufferAcquisitionTime(10); + high.sliceUsage(0.8); + agg.addQueryStats(high); + + GroupByStatsProvider.PerQueryStats mid = new GroupByStatsProvider.PerQueryStats(); + mid.mergeBufferAcquisitionTime(10); + mid.sliceUsage(0.5); + agg.addQueryStats(mid); + + Assertions.assertEquals(0.8, agg.getMaxSpillProximity(), DELTA); + } + + @Test + public void testSpillProximityDroppedWhenNoAcquisitionTimeRecorded() + { + // A PerQueryStats with slice usage but no acquisition time is not folded into the mergeBuffer block, mirroring + // the monitor guard. In practice this never happens: acquisition time is recorded in + // GroupByResourcesReservationPool.reserve() before any grouper initializes. + GroupByStatsProvider.AggregateStats agg = new GroupByStatsProvider.AggregateStats(); + GroupByStatsProvider.PerQueryStats stats = new GroupByStatsProvider.PerQueryStats(); + stats.sliceUsage(0.9); + agg.addQueryStats(stats); + + Assertions.assertEquals(0L, agg.getMergeBufferQueries()); + Assertions.assertEquals(0.0, agg.getMaxSpillProximity(), DELTA); + } + + /** + * End-to-end through {@link GroupByStatsProvider} reproducing the user's scenario: a 125MiB merge buffer divided + * into 240 per-thread slices (sliceSize ~= 546KiB). Each slice fills well below the configured buffer size, yet the + * fullest slice reaches its spill trigger (peak size/regrowthThreshold == 1.0) and the query spills. The summed + * {@code bytesUsed} is far below {@code sizeBytes}, which is exactly why comparing it to {@code sizeBytes} was + * misleading; {@code maxSpillProximity} instead reports 1.0, correctly indicating the query was at the spill point. + */ + @Test + public void testEndToEndSlicedBufferSpillScenario() + { + final long sizeBytes = 125L * 1024 * 1024; // druid.processing.buffer.sizeBytes (125MiB) + final int numThreads = 240; // concurrencyHint / numThreads + final long sliceSize = sizeBytes / numThreads; // per-slice capacity (~546KiB) + + GroupByStatsProvider statsProvider = new GroupByStatsProvider(); + QueryResourceId id = new QueryResourceId("spilly"); + GroupByStatsProvider.PerQueryStats stats = statsProvider.getPerQueryStatsContainer(id); + + stats.mergeBufferAcquisitionTime(42); + long expectedUsed = 0; + for (int i = 0; i < numThreads; i++) { + // Most slices stay light; one slice (i == 0) reaches its spill trigger (proximity 1.0). + final long sliceUsed = (i == 0) ? sliceSize / 2 : sliceSize / 20; + final double sliceProximity = (i == 0) ? 1.0 : 0.1; + stats.addMergeBufferUsedBytes(sliceUsed); + stats.sliceUsage(sliceProximity); + expectedUsed += sliceUsed; + } + stats.spilledBytes(1_000_000L); + + statsProvider.closeQuery(id); + GroupByStatsProvider.AggregateStats aggregateStats = statsProvider.getStatsSince(); + + // The fullest slice hit its spill trigger, so proximity is exactly 1.0. + Assertions.assertEquals(1.0, aggregateStats.getMaxSpillProximity(), DELTA); + // ...even though the summed usage across slices is a tiny fraction of the configured buffer size. + Assertions.assertEquals(expectedUsed, aggregateStats.getTotalMergeBufferUsedBytes()); + Assertions.assertTrue( + aggregateStats.getTotalMergeBufferUsedBytes() < sizeBytes / 2, + "summed bytesUsed should look small next to sizeBytes, despite the spill" + ); + Assertions.assertEquals(1L, aggregateStats.getSpilledQueries()); + } } diff --git a/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/BufferHashGrouperTest.java b/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/BufferHashGrouperTest.java index 9a491afe840a..4d8ac80f3405 100644 --- a/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/BufferHashGrouperTest.java +++ b/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/BufferHashGrouperTest.java @@ -202,6 +202,180 @@ public void testMaxMergeBufferUsedBytes() grouper.close(); } + @Test + public void testMaxSpillProximityAtSpillTrigger() + { + // A tiny fixed-size table (maxSizeForTesting=1) forces a spill trigger on the 2nd distinct key: no bucket can be + // allocated even after growth attempts, so findBucketWithAutoGrowth returns -1 and pins proximity to exactly 1.0. + // The invariant we care about: 1.0 corresponds to the real spill point, and is independent of bucket width / + // offset-list overhead / integer truncation. Contrast with the byte-based numerator, which topped out below 1.0. + final GroupByTestColumnSelectorFactory columnSelectorFactory = GrouperTestUtil.newColumnSelectorFactory(); + columnSelectorFactory.setRow(new MapBasedRow(0, ImmutableMap.of("value", 1L))); + final BufferHashGrouper