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

aho135 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 6474032ba78 feat: Add mergeBuffer/maxSpillProximity metric for groupBy 
spill diagnosis (#19627)
6474032ba78 is described below

commit 6474032ba7820f285fa7141ff9a5d93b35aa1016
Author: aho135 <[email protected]>
AuthorDate: Tue Aug 25 13:14:08 2026 -0700

    feat: Add mergeBuffer/maxSpillProximity metric for groupBy spill diagnosis 
(#19627)
    
    * feat: add mergeBuffer/maxSpillProximity metric for groupBy spill diagnosis
    
    ConcurrentGrouper divides the acquired merge buffer into 
druid.processing.numThreads
    slices; a groupBy spills as soon as its fullest single slice fills, which 
can be far
    below druid.processing.buffer.sizeBytes. The existing merge-buffer byte 
metrics are
    per-query sums discounted by the load factor, so they never approach 
sizeBytes even
    while queries spill and can't be used to reason about spill pressure.
    
    Add mergeBuffer/maxSpillProximity, a gauge in [0.0, 1.0]: the max across 
queries of the
    max across a query's slices of that slice's peak 
size/terminalRegrowthThreshold. It is
    bucket-count based (the exact condition a BufferHashGrouper spills on), so 
1.0
    corresponds exactly to the spill trigger, independent of bucket width, 
offset-list
    overhead, and integer truncation.
    
    - ByteBufferHashTable tracks the lifetime-max ratio, pinned to 1.0 when a 
bucket
      allocation is rejected in findBucketWithAutoGrowth and preserved across 
reset(). The
      denominator is the FIXED terminal-level regrowthThreshold 
(computeSpillRegrowthThreshold()
      replays the arena geometry, memoized), so the ratio is monotonic in size 
and rises
      smoothly to 1.0 rather than sawtoothing near (N-1)/N on every table 
doubling.
    - A recordsFillProximity() hook lets the alternating limit-push-down table 
opt out of
      fill recording: it heap-trims (never disk-spills) and would otherwise 
saturate near
      (T-1)/T; it now reads 0.0, and only a genuine bucket rejection reports 
1.0.
    - GroupByStatsProvider keeps the per-slice max (clamped to [0,1], NaN 
ignored),
      aggregated as a max across queries; SpillingGrouper reports each slice's 
peak proximity
      in close(); GroupByStatsMonitor emits the metric; 
docs/operations/metrics.md documents
      it and clarifies the slicing semantics of mergeBuffer/bytesUsed and 
maxBytesUsed.
    
    * refactor: share reset()/adjustTableWhenFull() geometry with 
computeSpillRegrowthThreshold()
    
    computeSpillRegrowthThreshold() replayed the arena-geometry math of reset()
    (initial placement) and adjustTableWhenFull() (one growth step). Extract 
both
    pure decisions into shared private helpers so the geometry lives in one 
place:
    
    - initialTableStart(maxBuckets): the initial-placement loop, used by reset()
      (which then slices the buffer) and by the replay.
    - nextGrowthLevel(tableStart, buckets) -> GrowthLevel: the grow-up-or-wrap
      decision, used by adjustTableWhenFull() (which then allocates + rehashes) 
and
      by the replay.
    
    computeSpillRegrowthThreshold() now seeds with initialTableStart() and loops
    nextGrowthLevel() to the terminal level, dropping its duplicated while(true)
    placement loop. Behavior is unchanged (geometry asserted by
    testLimitAndBufferSwapping / testMinBufferSize / BufferHashGrouperTest).
    
    Also note that computeSpillRegrowthThreshold() is valid only for the 
standard
    grow-by-doubling layout and must not be reached by the alternating 
limit-pushdown
    table (guaranteed by recordsFillProximity() == false); a future fixed-layout
    variant must override it rather than rely on the replay.
    
    * docs: tighten verbose maxSpillProximity code comments
    
    Consolidate the terminal-denominator/sawtooth rationale to a single place 
(the
    maxSpillProximity field) and make the method Javadocs 
(updateMaxMergeBufferUsedBytes,
    computeSpillRegrowthThreshold, recordsFillProximity, getMaxSpillProximity, 
the
    geometry helpers, and the GroupByStatsProvider/SpillingGrouper comments) 
terse,
    removing duplicated explanations. No logic changes; also fixes a few stale
    'size / regrowthThreshold' references to reflect the terminal denominator.
    
    * Register mergeBuffer/maxSpillProximity in the default Prometheus mapping
    
    Per review: the emitter emits mergeBuffer/maxSpillProximity, but with the 
default
    dimensionMapPath the Prometheus emitter only exports metrics listed in
    defaultMetrics.json, so the event was silently dropped for the default 
config.
    Add it as a no-dimension gauge and cover the mapping in MetricsTest.
    
    * Make spill-regrowth-threshold helpers private
    
    Per review: getSpillRegrowthThreshold() and computeSpillRegrowthThreshold() 
are
    only used within ByteBufferHashTable (no subclass overrides them — the 
alternating
    table opts out via recordsFillProximity()), so scope them private. Also 
drop the
    now-inapplicable 'must override it' note from the Javadoc.
    
    * Simplify spill-proximity clamp with Math.min
    
    Per review nit: use Math.min for the upper bound instead of a nested 
ternary.
    
    * Rename PerQueryStats.sliceUsage to spillProximity
    
    Per review: sliceUsage(double) read like per-slice byte usage (it sits next 
to
    addMergeBufferUsedBytes). Rename to spillProximity(double), pairing with the
    existing getSpillProximity() getter and matching the noun-style accumulators
    (spilledBytes, dictionarySize). No behavior change.
    
    * Fold merge-buffer stats reporting in SpillingGrouper.close() under one 
isInitialized() check
    
    Per review: getMaxMergeBufferUsedBytes() already guarded on 
grouper.isInitialized(),
    duplicating the check used to gate spill-proximity reporting. Report both 
merge-buffer
    usage and spill proximity under a single isInitialized() block and drop the 
now
    single-use private helper. Behavior-preserving: the uninitialized path 
previously did an
    inert addMergeBufferUsedBytes(0L).
---
 docs/operations/metrics.md                         |  15 +-
 .../src/main/resources/defaultMetrics.json         |   1 +
 .../druid/emitter/prometheus/MetricsTest.java      |  15 ++
 .../druid/query/groupby/GroupByStatsProvider.java  |  80 ++++++-
 .../epinephelinae/AbstractBufferHashGrouper.java   |  10 +
 .../groupby/epinephelinae/ByteBufferHashTable.java | 201 +++++++++++++---
 .../epinephelinae/LimitedBufferHashGrouper.java    |  13 ++
 .../groupby/epinephelinae/SpillingGrouper.java     |  12 +-
 .../query/groupby/GroupByStatsProviderTest.java    | 243 ++++++++++++++++++-
 .../epinephelinae/BufferHashGrouperTest.java       | 256 +++++++++++++++++++++
 .../LimitedBufferHashGrouperTest.java              |  80 +++++++
 .../groupby/epinephelinae/SpillingGrouperTest.java | 127 +++++++++-
 .../druid/server/metrics/GroupByStatsMonitor.java  |   1 +
 .../server/metrics/GroupByStatsMonitorTest.java    |  38 ++-
 14 files changed, 1025 insertions(+), 67 deletions(-)

diff --git a/docs/operations/metrics.md b/docs/operations/metrics.md
index c05f6cc91f9..07a4922945f 100644
--- a/docs/operations/metrics.md
+++ b/docs/operations/metrics.md
@@ -94,8 +94,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`.| [...]
+|`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 th [...]
 |`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|
@@ -124,8 +125,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`.| [...]
+|`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 th [...]
 |`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|
@@ -157,8 +159,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`.  [...]
+|`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`, `t 
[...]
+|`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 th [...]
 |`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/extensions-contrib/prometheus-emitter/src/main/resources/defaultMetrics.json 
b/extensions-contrib/prometheus-emitter/src/main/resources/defaultMetrics.json
index ae7a4900dff..e1af9b007bd 100644
--- 
a/extensions-contrib/prometheus-emitter/src/main/resources/defaultMetrics.json
+++ 
b/extensions-contrib/prometheus-emitter/src/main/resources/defaultMetrics.json
@@ -53,6 +53,7 @@
   "mergeBuffer/maxAcquisitionTimeNs": { "dimensions":  [], "type":  "timer", 
"help":  "Maximum time in nanoseconds to acquire merge buffer for any single 
groupBy query within the emission period."},
   "mergeBuffer/bytesUsed" : { "dimensions":  [], "type":  "gauge", "help":  
"Total number of bytes used by merge buffers to process groupBy queries."},
   "mergeBuffer/maxBytesUsed" : { "dimensions":  [], "type":  "gauge", "help":  
"Maximum number of bytes used by merge buffers for any single groupBy query 
within the emission period."},
+  "mergeBuffer/maxSpillProximity" : { "dimensions":  [], "type":  "gauge", 
"help":  "How close any single groupBy query came to spilling within the 
emission period, in [0,1]; 1.0 corresponds to the spill trigger."},
   "mergeBuffer/queries": { "dimensions":  [], "type":  "gauge", "help":  
"Number of groupBy queries that acquired a batch of buffers from the merge 
buffer pool."},
   "groupBy/spilledQueries": { "dimensions":  [], "type":  "gauge", "help":  
"Number of groupBy queries that have spilled onto the disk."},
   "groupBy/maxSpilledBytes": { "dimensions":  [], "type":  "gauge", "help":  
"Maximum number of bytes spilled to disk by any single groupBy query within the 
emission period."},
diff --git 
a/extensions-contrib/prometheus-emitter/src/test/java/org/apache/druid/emitter/prometheus/MetricsTest.java
 
b/extensions-contrib/prometheus-emitter/src/test/java/org/apache/druid/emitter/prometheus/MetricsTest.java
index 6dfaaec1ee2..58479a029a5 100644
--- 
a/extensions-contrib/prometheus-emitter/src/test/java/org/apache/druid/emitter/prometheus/MetricsTest.java
+++ 
b/extensions-contrib/prometheus-emitter/src/test/java/org/apache/druid/emitter/prometheus/MetricsTest.java
@@ -19,6 +19,7 @@
 
 package org.apache.druid.emitter.prometheus;
 
+import io.prometheus.client.Gauge;
 import io.prometheus.client.Histogram;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.ISE;
@@ -102,6 +103,20 @@ public class MetricsTest
     Assertions.assertTrue(actualMessage.contains(expectedMessage));
   }
 
+  @Test
+  public void testMergeBufferMaxSpillProximityRegisteredAsGauge()
+  {
+    // mergeBuffer/maxSpillProximity must be in the default mapping (a 
no-dimension gauge); otherwise the default
+    // Prometheus configuration would silently drop the metric.
+    PrometheusEmitterConfig config = new PrometheusEmitterConfig(null, 
"test_spill", null, null, null, true, true, null, null, null, null);
+    Metrics metrics = new Metrics(config);
+    DimensionsAndCollector dimensionsAndCollector = 
metrics.getByName("mergeBuffer/maxSpillProximity", "broker");
+    Assertions.assertNotNull(dimensionsAndCollector);
+    Assertions.assertTrue(dimensionsAndCollector.getCollector() instanceof 
Gauge);
+    // No metric-specific dimensions, only the standard service/host labels.
+    Assertions.assertArrayEquals(new String[]{"druid_service", "host_name"}, 
dimensionsAndCollector.getDimensions());
+  }
+
   @Test
   public void testMetricsConfigurationWithNonExistentMetric()
   {
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 f6b92a7b62c..62c19ff183e 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 org.apache.druid.query.QueryResourceId;
 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 class GroupByStatsProvider
     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 class GroupByStatsProvider
           aggregateStats.maxMergeBufferAcquisitionTimeNs,
           aggregateStats.totalMergeBufferUsedBytes,
           aggregateStats.maxMergeBufferUsedBytes,
+          aggregateStats.maxSpillProximity,
           aggregateStats.spilledQueries,
           aggregateStats.spilledBytes,
           aggregateStats.maxSpilledBytes,
@@ -105,6 +108,7 @@ public class GroupByStatsProvider
         long maxMergeBufferAcquisitionTimeNs,
         long totalMergeBufferUsedBytes,
         long maxMergeBufferUsedBytes,
+        double maxSpillProximity,
         long spilledQueries,
         long spilledBytes,
         long maxSpilledBytes,
@@ -117,6 +121,7 @@ public class GroupByStatsProvider
       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 class GroupByStatsProvider
       return maxMergeBufferUsedBytes;
     }
 
+    public double getMaxSpillProximity()
+    {
+      return maxSpillProximity;
+    }
+
     public long getSpilledQueries()
     {
       return spilledQueries;
@@ -174,6 +184,18 @@ public class GroupByStatsProvider
       return maxMergeDictionarySize;
     }
 
+    /**
+     * Folds a completed query's stats into the running aggregate. For 
merge-buffer usage:
+     * <ul>
+     *   <li>{@code totalMergeBufferUsedBytes} (emitted as {@code 
mergeBuffer/bytesUsed}) sums each query's usage
+     *       across all queries, where each query's usage is itself the sum 
across the query's slices.</li>
+     *   <li>{@code maxMergeBufferUsedBytes} (emitted as {@code 
mergeBuffer/maxBytesUsed}) is the max such per-query
+     *       summed usage across queries.</li>
+     *   <li>{@code maxSpillProximity} (emitted as {@code 
mergeBuffer/maxSpillProximity}) is the max across queries of
+     *       each query's fullest-slice peak fill ratio — a per-slice MAX (not 
a byte sum), so it reflects the slice
+     *       that drives a spill; 1.0 iff a slice actually spilled.</li>
+     * </ul>
+     */
     public void addQueryStats(PerQueryStats perQueryStats)
     {
       if (perQueryStats.getMergeBufferAcquisitionTimeNs() > 0) {
@@ -183,8 +205,9 @@ public class GroupByStatsProvider
             maxMergeBufferAcquisitionTimeNs,
             perQueryStats.getMergeBufferAcquisitionTimeNs()
         );
-        totalMergeBufferUsedBytes += 
perQueryStats.getMaxMergeBufferUsedBytes();
-        maxMergeBufferUsedBytes = Math.max(maxMergeBufferUsedBytes, 
perQueryStats.getMaxMergeBufferUsedBytes());
+        totalMergeBufferUsedBytes += perQueryStats.getMergeBufferUsedBytes();
+        maxMergeBufferUsedBytes = Math.max(maxMergeBufferUsedBytes, 
perQueryStats.getMergeBufferUsedBytes());
+        maxSpillProximity = Math.max(maxSpillProximity, 
perQueryStats.getSpillProximity());
       }
 
       if (perQueryStats.getSpilledBytes() > 0) {
@@ -204,6 +227,7 @@ public class GroupByStatsProvider
       this.maxMergeBufferAcquisitionTimeNs = 0;
       this.totalMergeBufferUsedBytes = 0;
       this.maxMergeBufferUsedBytes = 0;
+      this.maxSpillProximity = 0.0;
       this.spilledQueries = 0;
       this.spilledBytes = 0;
       this.maxSpilledBytes = 0;
@@ -215,7 +239,22 @@ public class GroupByStatsProvider
   public static class PerQueryStats
   {
     private final AtomicLong mergeBufferAcquisitionTimeNs = new AtomicLong(0);
-    private final AtomicLong maxMergeBufferUsedBytes = new AtomicLong(0);
+    /**
+     * Sum of the peak merge-buffer usage of every grouper (slice) this query 
held. A
+     * {@code ConcurrentGrouper} slices a single merge buffer into one slice 
per processing thread, and each slice
+     * reports its own peak via
+     * {@link #addMergeBufferUsedBytes(long)} when closed, so the per-query 
value is the SUM across the query's slices.
+     */
+    private final AtomicLong mergeBufferUsedBytes = new AtomicLong(0);
+    /**
+     * Spill proximity of the fullest slice this query held, in [0.0, 1.0]. 
Each {@link #spillProximity} call contributes
+     * one slice's peak fill ratio and this keeps the MAX across slices: a 
query spills as soon as its hottest slice
+     * fills, so proximity is driven by that slice, not the byte sum in {@link 
#mergeBufferUsedBytes}. Keeping each
+     * slice's ratio intact (rather than maxing numerator and denominator 
separately) stays correct when a query mixes
+     * groupers of different sizes (e.g. {@code ConcurrentGrouper} slices 
alongside a full-buffer grouper). 1.0 iff a
+     * slice actually spilled.
+     */
+    private final DoubleAccumulator maxSpillProximity = new 
DoubleAccumulator(Math::max, 0.0);
     private final AtomicLong spilledBytes = new AtomicLong(0);
     private final AtomicLong mergeDictionarySize = new AtomicLong(0);
 
@@ -224,9 +263,26 @@ public class GroupByStatsProvider
       mergeBufferAcquisitionTimeNs.addAndGet(delay);
     }
 
-    public void maxMergeBufferUsedBytes(long bytes)
+    /**
+     * Accumulates the peak merge-buffer usage of one grouper (slice). Despite 
the previous "max" naming, this method
+     * sums across the slices a query holds; see {@link #mergeBufferUsedBytes}.
+     */
+    public void addMergeBufferUsedBytes(long bytes)
     {
-      maxMergeBufferUsedBytes.addAndGet(bytes);
+      mergeBufferUsedBytes.addAndGet(bytes);
+    }
+
+    /**
+     * Records one slice's peak fill ratio (1.0 iff it spilled), kept as a max 
across the query's slices; see
+     * {@link #maxSpillProximity}. Clamped to [0, 1]; NaN is ignored so a 
never-initialized grouper contributes nothing.
+     */
+    public void spillProximity(double proximity)
+    {
+      if (Double.isNaN(proximity)) {
+        return;
+      }
+      final double clamped = proximity < 0.0 ? 0.0 : Math.min(proximity, 1.0);
+      maxSpillProximity.accumulate(clamped);
     }
 
     public void spilledBytes(long bytes)
@@ -244,9 +300,19 @@ public class GroupByStatsProvider
       return mergeBufferAcquisitionTimeNs.get();
     }
 
-    public long getMaxMergeBufferUsedBytes()
+    public long getMergeBufferUsedBytes()
+    {
+      return mergeBufferUsedBytes.get();
+    }
+
+    /**
+     * Spill proximity for this query in [0.0, 1.0]: the fullest slice's peak 
{@code size / regrowthThreshold} over
+     * that slice's lifetime. 1.0 corresponds exactly to the spill trigger (a 
bucket allocation was rejected). Returns
+     * 0.0 when no slice usage was recorded (e.g. a grouper that never 
initialized).
+     */
+    public double getSpillProximity()
     {
-      return maxMergeBufferUsedBytes.get();
+      return maxSpillProximity.get();
     }
 
     public long getSpilledBytes()
diff --git 
a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/AbstractBufferHashGrouper.java
 
b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/AbstractBufferHashGrouper.java
index a5edb38cfa4..726852a6ec1 100644
--- 
a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/AbstractBufferHashGrouper.java
+++ 
b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/AbstractBufferHashGrouper.java
@@ -185,6 +185,16 @@ public abstract class AbstractBufferHashGrouper<KeyType> 
implements Grouper<KeyT
     return hashTable.getMaxMergeBufferUsedBytes();
   }
 
+  /**
+   * Peak fraction of the way to a spill over this grouper's lifetime, in 
[0.0, 1.0]; 1.0 means it actually spilled.
+   * The value {@code SpillingGrouper} reports for {@code 
mergeBuffer/maxSpillProximity}. Preserved across
+   * {@link #reset()}; 0.0 if the grouper was never initialized. See {@link 
ByteBufferHashTable#getMaxSpillProximity()}.
+   */
+  public double getMaxSpillProximity()
+  {
+    return hashTable == null ? 0.0 : hashTable.getMaxSpillProximity();
+  }
+
   /**
    * Populate a {@link ReusableEntry} with values from a particular bucket.
    */
diff --git 
a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/ByteBufferHashTable.java
 
b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/ByteBufferHashTable.java
index 0f64d08613c..550bbee08b1 100644
--- 
a/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/ByteBufferHashTable.java
+++ 
b/processing/src/main/java/org/apache/druid/query/groupby/epinephelinae/ByteBufferHashTable.java
@@ -90,6 +90,17 @@ public class ByteBufferHashTable
   // Tracks maximum bytes used for the entire lifecycle of this hash table.
   protected long maxMergeBufferUsedBytes;
 
+  // Peak {@code size / terminalRegrowthThreshold} over this table's lifetime, 
in [0.0, 1.0]; the denominator is the
+  // fixed bucket-count ceiling of the final growth level (see {@link 
#getSpillRegrowthThreshold()}). A fixed denominator
+  // over monotonically-growing size makes this rise smoothly to exactly 1.0 
at the real spill trigger; dividing by the
+  // CURRENT level's threshold would instead sawtooth and pin near (N-1)/N 
after any growth. Preserved across
+  // {@link #reset()} so a grouper that spilled still reads 1.0 after size 
returns to 0.
+  protected double maxSpillProximity;
+
+  // Cached denominator for {@link #maxSpillProximity} (terminal-level 
regrowthThreshold). Zero = not yet computed;
+  // {@link #maxSizeForBuckets} is always >= 1, so zero is a safe sentinel. 
Depends only on final geometry, so cached.
+  private int spillRegrowthThreshold;
+
   public ByteBufferHashTable(
       float maxLoadFactor,
       int initialBuckets,
@@ -109,6 +120,8 @@ public class ByteBufferHashTable
     this.tableArenaSize = buffer.capacity();
     this.bucketUpdateHandler = bucketUpdateHandler;
     this.maxMergeBufferUsedBytes = 0;
+    this.maxSpillProximity = 0.0;
+    this.spillRegrowthThreshold = 0;
   }
 
   public void reset()
@@ -127,25 +140,7 @@ public class ByteBufferHashTable
     }
 
     // Start table part-way through the buffer so the last growth can start 
from zero and thereby use more space.
-    tableStart = tableArenaSize - maxBuckets * bucketSizeWithHash;
-    int nextBuckets = maxBuckets * 2;
-    while (true) {
-      long nextBucketsSize = (long) nextBuckets * bucketSizeWithHash;
-      if (nextBucketsSize > Integer.MAX_VALUE) {
-        break;
-      }
-      final int nextTableStart = tableStart - nextBuckets * bucketSizeWithHash;
-      if (nextTableStart > tableArenaSize / 2) {
-        tableStart = nextTableStart;
-        nextBuckets = nextBuckets * 2;
-      } else {
-        break;
-      }
-    }
-
-    if (tableStart < tableArenaSize / 2) {
-      tableStart = 0;
-    }
+    tableStart = initialTableStart(maxBuckets);
 
     final ByteBuffer bufferDup = buffer.duplicate();
     bufferDup.position(tableStart);
@@ -166,20 +161,10 @@ public class ByteBufferHashTable
       return;
     }
 
-    final int newBuckets;
-    final int newMaxSize;
-    final int newTableStart;
-
-    if (((long) maxBuckets * 3 * bucketSizeWithHash) > (long) tableArenaSize - 
tableStart) {
-      // Not enough space to grow upwards, start back from zero
-      newTableStart = 0;
-      newBuckets = tableStart / bucketSizeWithHash;
-      newMaxSize = maxSizeForBuckets(newBuckets);
-    } else {
-      newTableStart = tableStart + tableBuffer.limit();
-      newBuckets = maxBuckets * 2;
-      newMaxSize = maxSizeForBuckets(newBuckets);
-    }
+    final GrowthLevel next = nextGrowthLevel(tableStart, maxBuckets);
+    final int newTableStart = next.tableStart;
+    final int newBuckets = next.buckets;
+    final int newMaxSize = maxSizeForBuckets(newBuckets);
 
     if (newBuckets < maxBuckets) {
       throw new ISE("newBuckets[%,d] < maxBuckets[%,d]", newBuckets, 
maxBuckets);
@@ -290,6 +275,12 @@ public class ByteBufferHashTable
       }
     }
 
+    if (bucket < 0) {
+      // Spill trigger: no bucket even after attempting to grow. Pin proximity 
to 1.0 (also covers a rejection before
+      // size reaches the terminal threshold, e.g. a full-probe wraparound). 
See {@link #maxSpillProximity}.
+      maxSpillProximity = 1.0;
+    }
+
     return bucket;
   }
 
@@ -442,12 +433,143 @@ public class ByteBufferHashTable
   }
 
   /**
-   * To maintain an accurate tracking of the maximum bytes used per query, 
this function is to be called immediately
-   * whenever either of {@link #size} or {@link #bucketSizeWithHash} is 
changed.
+   * Called whenever {@link #size} or {@link #bucketSizeWithHash} changes, to 
track {@link #maxMergeBufferUsedBytes} and
+   * the {@link #maxSpillProximity} peak. Proximity is recorded while {@code 
size < regrowthThreshold} (the transient hit
+   * at intermediate growth boundaries is skipped, since the table then 
grows); at the terminal level, parking at the
+   * threshold is the spill point and pins 1.0. Trim-and-swap tables ({@link 
#recordsFillProximity()} == false) skip
+   * proximity entirely — see {@link #findBucketWithAutoGrowth} for their only 
spill signal.
    */
   protected void updateMaxMergeBufferUsedBytes()
   {
     maxMergeBufferUsedBytes = Math.max(maxMergeBufferUsedBytes, (long) size * 
bucketSizeWithHash);
+    if (!recordsFillProximity()) {
+      return;
+    }
+    final int denominator = getSpillRegrowthThreshold();
+    if (denominator <= 0) {
+      return;
+    }
+    if (size < regrowthThreshold) {
+      // size < regrowthThreshold <= terminal denominator keeps this below 
1.0; the clamp is defensive.
+      final double ratio = Math.min(1.0, (double) size / denominator);
+      if (ratio > maxSpillProximity) {
+        maxSpillProximity = ratio;
+      }
+    } else if (isTerminalTableLevel()) {
+      // At the load-factor limit with no room to grow: parking here IS the 
spill point.
+      maxSpillProximity = 1.0;
+    }
+  }
+
+  /**
+   * Denominator for {@link #maxSpillProximity}: the {@code regrowthThreshold} 
at the terminal growth level, where
+   * {@link #findBucketWithAutoGrowth} can no longer allocate a bucket. 
Computed once from fixed geometry and cached.
+   */
+  private int getSpillRegrowthThreshold()
+  {
+    if (spillRegrowthThreshold == 0) {
+      spillRegrowthThreshold = computeSpillRegrowthThreshold();
+    }
+    return spillRegrowthThreshold;
+  }
+
+  /**
+   * Replays the arena geometry to the terminal growth level — via the same 
{@link #initialTableStart} /
+   * {@link #nextGrowthLevel} primitives {@link #reset()} and {@link 
#adjustTableWhenFull()} use — and returns
+   * {@link #maxSizeForBuckets} of its bucket count. Allocates no buffers.
+   *
+   * <p>Valid only for the standard grow-by-doubling layout. Fixed-layout 
variants (the alternating limit-pushdown
+   * table) never reach this — guaranteed by {@link #recordsFillProximity()} 
== false — so one that ever needs a spill
+   * denominator would have to revisit this method.
+   */
+  private int computeSpillRegrowthThreshold()
+  {
+    int buckets = Math.min(tableArenaSize / bucketSizeWithHash, 
initialBuckets);
+    int start = initialTableStart(buckets);
+    while (start != 0) {
+      final GrowthLevel next = nextGrowthLevel(start, buckets);
+      start = next.tableStart;
+      buckets = next.buckets;
+    }
+    return maxSizeForBuckets(buckets);
+  }
+
+  /**
+   * Placement of the initial (smallest) table: far enough from the front that 
successive doublings stack above it and
+   * the final growth can wrap to offset 0. Pure geometry, shared by {@link 
#reset()} and
+   * {@link #computeSpillRegrowthThreshold()}. Returns 0 when no upward 
doublings fit.
+   */
+  private int initialTableStart(int initialMaxBuckets)
+  {
+    int start = tableArenaSize - initialMaxBuckets * bucketSizeWithHash;
+    int nextBuckets = initialMaxBuckets * 2;
+    while (true) {
+      final long nextBucketsSize = (long) nextBuckets * bucketSizeWithHash;
+      if (nextBucketsSize > Integer.MAX_VALUE) {
+        break;
+      }
+      final int nextTableStart = start - nextBuckets * bucketSizeWithHash;
+      if (nextTableStart > tableArenaSize / 2) {
+        start = nextTableStart;
+        nextBuckets = nextBuckets * 2;
+      } else {
+        break;
+      }
+    }
+    return start < tableArenaSize / 2 ? 0 : start;
+  }
+
+  /**
+   * The next growth level given the current start offset and bucket count — 
the pure decision shared by
+   * {@link #adjustTableWhenFull()} and {@link 
#computeSpillRegrowthThreshold()}. Doubles upward while the arena has room
+   * for the next 3x, else wraps to offset 0 as the final level ({@code 
tableStart == 0}).
+   */
+  private GrowthLevel nextGrowthLevel(int curTableStart, int curBuckets)
+  {
+    if (((long) curBuckets * 3 * bucketSizeWithHash) > (long) tableArenaSize - 
curTableStart) {
+      // Not enough space to grow upwards; start back from zero.
+      return new GrowthLevel(0, curTableStart / bucketSizeWithHash);
+    } else {
+      // curBuckets * bucketSizeWithHash is the current table's byte length 
(== tableBuffer.limit()).
+      return new GrowthLevel(curTableStart + curBuckets * bucketSizeWithHash, 
curBuckets * 2);
+    }
+  }
+
+  /**
+   * A growth level's placement: the table's start offset within the arena and 
its bucket count. {@code tableStart == 0}
+   * marks the terminal (final) level.
+   */
+  private static final class GrowthLevel
+  {
+    private final int tableStart;
+    private final int buckets;
+
+    GrowthLevel(int tableStart, int buckets)
+    {
+      this.tableStart = tableStart;
+      this.buckets = buckets;
+    }
+  }
+
+  /**
+   * True when the table can't grow further ({@link #tableStart} at the front 
of the arena), matching
+   * {@link #adjustTableWhenFull()}'s early return. Overridden by 
trim-and-swap variants whose "table full" is not a
+   * spill.
+   */
+  protected boolean isTerminalTableLevel()
+  {
+    return tableStart == 0;
+  }
+
+  /**
+   * Whether {@code size / terminalRegrowthThreshold} is a meaningful 
proximity-to-spill signal. True for the base
+   * grow-by-doubling table (spills when it can't grow). Overridden false by 
trim-and-swap variants (the alternating
+   * limit-pushdown table), which never spill via fill and would otherwise 
saturate near 1.0; for those, only a
+   * rejection in {@link #findBucketWithAutoGrowth} pins 1.0.
+   */
+  protected boolean recordsFillProximity()
+  {
+    return true;
   }
 
   public long getMaxMergeBufferUsedBytes()
@@ -455,6 +577,15 @@ public class ByteBufferHashTable
     return maxMergeBufferUsedBytes;
   }
 
+  /**
+   * Peak fraction of the way to a spill over this table's lifetime, in [0.0, 
1.0]; equals 1.0 iff the table actually
+   * spilled. See {@link #maxSpillProximity} for how it is computed.
+   */
+  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 2bb544d0cda..167b21ee77c 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,18 @@ public class LimitedBufferHashGrouper<KeyType> extends 
AbstractBufferHashGrouper
       tableBuffer = newTableBuffer;
       growthCount++;
     }
+
+    /**
+     * The alternating table trims-and-swaps to keep the top-{@code limit} 
entries in memory and never spills via the
+     * fill path (see base {@link #recordsFillProximity()}), so suppress 
fill-proximity recording — such queries read
+     * 0.0. Its only spill signal is a rejection in {@link 
#findBucketWithAutoGrowth}, which does NOT fire on ordinary
+     * swaps: post-swap {@code size == numCopied <= limit}, and construction 
guarantees
+     * {@code regrowthThreshold >= limit + 1}, so the next insert always finds 
a bucket.
+     */
+    @Override
+    protected boolean recordsFillProximity()
+    {
+      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 96e55907b21..602aa2e0e9f 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,12 @@ public class SpillingGrouper<KeyType> implements 
Grouper<KeyType>
   public void close()
   {
     perQueryStats.dictionarySize(getDictionarySizeEstimate());
-    perQueryStats.maxMergeBufferUsedBytes(getMaxMergeBufferUsedBytes());
+    if (grouper.isInitialized()) {
+      // Merge-buffer usage and spill proximity are only meaningful once the 
grouper touched the buffer;
+      // an untouched slice contributes nothing.
+      
perQueryStats.addMergeBufferUsedBytes(grouper.getMaxMergeBufferUsedBytes());
+      perQueryStats.spillProximity(grouper.getMaxSpillProximity());
+    }
     // Record spilled bytes before deleteFiles() decrements bytesUsed in 
temporaryStorage.
     long spilledBytes = 0;
     for (final File file : files) {
@@ -267,11 +272,6 @@ public class SpillingGrouper<KeyType> implements 
Grouper<KeyType>
     deleteFiles();
   }
 
-  private long getMaxMergeBufferUsedBytes()
-  {
-    return grouper.isInitialized() ? grouper.getMaxMergeBufferUsedBytes() : 0L;
-  }
-
   private long getDictionarySizeEstimate()
   {
     return keySerde.getDictionarySize();
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 dafd381668d..ff39474f67f 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 @@ import org.junit.jupiter.api.Test;
 
 public class GroupByStatsProviderTest
 {
+  private static final double DELTA = 1e-9;
+
   @Test
   public void testMetricCollection()
   {
@@ -35,7 +37,12 @@ public class GroupByStatsProviderTest
 
     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.spillProximity(0.05);
+    stats1.addMergeBufferUsedBytes(30);
+    stats1.spillProximity(0.03);
     stats1.spilledBytes(200);
     stats1.spilledBytes(400);
     stats1.dictionarySize(100);
@@ -46,7 +53,9 @@ public class GroupByStatsProviderTest
 
     stats2.mergeBufferAcquisitionTime(500);
     stats2.mergeBufferAcquisitionTime(600);
-    stats2.maxMergeBufferUsedBytes(100);
+    // Single slice at 0.05.
+    stats2.addMergeBufferUsedBytes(100);
+    stats2.spillProximity(0.05);
     stats2.spilledBytes(400);
     stats2.spilledBytes(600);
     stats2.dictionarySize(300);
@@ -58,6 +67,7 @@ public class GroupByStatsProviderTest
     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 class GroupByStatsProviderTest
     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 class GroupByStatsProviderTest
     QueryResourceId r1 = new QueryResourceId("r1");
     GroupByStatsProvider.PerQueryStats stats1 = 
statsProvider.getPerQueryStatsContainer(r1);
     stats1.mergeBufferAcquisitionTime(2000);
-    stats1.maxMergeBufferUsedBytes(50);
+    stats1.addMergeBufferUsedBytes(50);
+    stats1.spillProximity(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.spillProximity(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.spillProximity(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.spillProximity(0.075);
     stats4.spilledBytes(200);
     stats4.dictionarySize(1500);
 
@@ -122,6 +141,8 @@ public class GroupByStatsProviderTest
 
     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 class GroupByStatsProviderTest
     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.spillProximity(0.01);
+    stats.addMergeBufferUsedBytes(20);
+    stats.spillProximity(0.02);
+    stats.addMergeBufferUsedBytes(30);
+    stats.spillProximity(0.03);
+    stats.addMergeBufferUsedBytes(40);
+    stats.spillProximity(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.spillProximity(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.spillProximity(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.spillProximity(-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.spillProximity(Double.NaN);
+    Assertions.assertEquals(0.0, nan.getSpillProximity(), DELTA);
+  }
+
+  @Test
+  public void testSpillProximityZeroWhenNoSliceUsageRecorded()
+  {
+    GroupByStatsProvider.PerQueryStats stats = new 
GroupByStatsProvider.PerQueryStats();
+    // No spillProximity() 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.spillProximity(0.2);
+    stats.spillProximity(0.9);
+    stats.spillProximity(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 spillProximity 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.spillProximity(1.0);        // small sliced grouper at its spill 
point
+    stats.spillProximity(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.spillProximity(0.3);
+    agg.addQueryStats(low);
+
+    GroupByStatsProvider.PerQueryStats high = new 
GroupByStatsProvider.PerQueryStats();
+    high.mergeBufferAcquisitionTime(10);
+    high.spillProximity(0.8);
+    agg.addQueryStats(high);
+
+    GroupByStatsProvider.PerQueryStats mid = new 
GroupByStatsProvider.PerQueryStats();
+    mid.mergeBufferAcquisitionTime(10);
+    mid.spillProximity(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.spillProximity(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.spillProximity(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 9a491afe840..1333f612703 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,262 @@ public class BufferHashGrouperTest extends 
InitializedNullHandlingTest
     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<IntKey> grouper = new BufferHashGrouper<>(
+        Suppliers.ofInstance(ByteBuffer.allocate(1000)),
+        GrouperTestUtil.intKeySerde(),
+        AggregatorAdapters.factorizeBuffered(
+            columnSelectorFactory,
+            ImmutableList.of(
+                new LongSumAggregatorFactory("valueSum", "value"),
+                new CountAggregatorFactory("count")
+            )
+        ),
+        /* bufferGrouperMaxSize */ 1,
+        0,
+        0,
+        true
+    );
+    grouper.init();
+
+    // Before any aggregation, proximity is 0.0 (empty table).
+    Assertions.assertEquals(0.0, grouper.getMaxSpillProximity(), 0.0);
+
+    // First key fits. Proximity is still strictly below 1.0 (size < 
regrowthThreshold after growth).
+    Assertions.assertTrue(grouper.aggregate(new IntKey(1)).isOk());
+    Assertions.assertTrue(
+        grouper.getMaxSpillProximity() < 1.0,
+        "proximity should stay below 1.0 while the table can still accept more 
keys: " + grouper.getMaxSpillProximity()
+    );
+
+    // Second key triggers a spill (findBucketWithAutoGrowth returns -1). 
Proximity is pinned to exactly 1.0.
+    Assertions.assertFalse(grouper.aggregate(new IntKey(2)).isOk());
+    Assertions.assertEquals(1.0, grouper.getMaxSpillProximity(), 0.0);
+
+    // Reset preserves the peak: the grouper spilled at some point in its life.
+    grouper.reset();
+    Assertions.assertEquals(1.0, grouper.getMaxSpillProximity(), 0.0);
+
+    grouper.close();
+  }
+
+  @Test
+  public void testMaxSpillProximityBelowOneWhenNoSpill()
+  {
+    // A generously-sized table that never rejects a bucket. Proximity should 
be strictly below 1.0 for the entire
+    // aggregation. This is the "operator reads <1.0, so no spill happened" 
invariant.
+    final GroupByTestColumnSelectorFactory columnSelectorFactory = 
GrouperTestUtil.newColumnSelectorFactory();
+    columnSelectorFactory.setRow(new MapBasedRow(0, ImmutableMap.of("value", 
1L)));
+    final BufferHashGrouper<IntKey> grouper = new BufferHashGrouper<>(
+        Suppliers.ofInstance(ByteBuffer.allocate(10_000)),
+        GrouperTestUtil.intKeySerde(),
+        AggregatorAdapters.factorizeBuffered(
+            columnSelectorFactory,
+            ImmutableList.of(
+                new LongSumAggregatorFactory("valueSum", "value"),
+                new CountAggregatorFactory("count")
+            )
+        ),
+        Integer.MAX_VALUE,
+        0,
+        0,
+        true
+    );
+    grouper.init();
+
+    for (int i = 0; i < 20; i++) {
+      Assertions.assertTrue(grouper.aggregate(new IntKey(i)).isOk());
+      Assertions.assertTrue(
+          grouper.getMaxSpillProximity() < 1.0,
+          "no spill occurred; proximity must remain strictly < 1.0: " + 
grouper.getMaxSpillProximity()
+      );
+    }
+
+    grouper.close();
+  }
+
+  @Test
+  public void testMaxSpillProximityAtTerminalThresholdWithoutRejection()
+  {
+    // Adversarial "at exactly the spill point, but no further insert 
attempted" case. Fill the grouper until one more
+    // insert would trigger a rejection, then stop calling aggregate. Because 
the table is at its terminal growth level
+    // (arena exhausted, no room to enlarge regrowthThreshold), size == 
regrowthThreshold means the next insert would
+    // fail — that's the spill point. Proximity must be exactly 1.0, not 
(T-1)/T. The base-class updateMax records 1.0
+    // at the terminal level via isTerminalTableLevel().
+    final GroupByTestColumnSelectorFactory columnSelectorFactory = 
GrouperTestUtil.newColumnSelectorFactory();
+    columnSelectorFactory.setRow(new MapBasedRow(0, ImmutableMap.of("value", 
1L)));
+    final BufferHashGrouper<IntKey> grouper = new BufferHashGrouper<>(
+        Suppliers.ofInstance(ByteBuffer.allocate(10_000)),
+        GrouperTestUtil.intKeySerde(),
+        AggregatorAdapters.factorizeBuffered(
+            columnSelectorFactory,
+            ImmutableList.of(
+                new LongSumAggregatorFactory("valueSum", "value"),
+                new CountAggregatorFactory("count")
+            )
+        ),
+        Integer.MAX_VALUE,
+        0.75f,
+        4,
+        true
+    );
+    grouper.init();
+
+    // Fill until the first rejection.
+    int inserted = 0;
+    while (inserted < 10_000 && grouper.aggregate(new 
IntKey(inserted)).isOk()) {
+      inserted++;
+    }
+    // A rejection occurred, so the grouper is definitively at its spill 
trigger.
+    Assertions.assertEquals(1.0, grouper.getMaxSpillProximity(), 0.0);
+
+    // The stricter Case #5 check: rebuild and STOP one insert before the 
rejection. size == regrowthThreshold, but
+    // aggregate() was never called after that, so findBucketWithAutoGrowth 
was never invoked with a rejection. Still,
+    // being at the terminal level means proximity must be 1.0.
+    grouper.close();
+
+    final BufferHashGrouper<IntKey> parked = new BufferHashGrouper<>(
+        Suppliers.ofInstance(ByteBuffer.allocate(10_000)),
+        GrouperTestUtil.intKeySerde(),
+        AggregatorAdapters.factorizeBuffered(
+            columnSelectorFactory,
+            ImmutableList.of(
+                new LongSumAggregatorFactory("valueSum", "value"),
+                new CountAggregatorFactory("count")
+            )
+        ),
+        Integer.MAX_VALUE,
+        0.75f,
+        4,
+        true
+    );
+    parked.init();
+    for (int i = 0; i < inserted; i++) {
+      Assertions.assertTrue(parked.aggregate(new IntKey(i)).isOk());
+    }
+    // Confirm the arithmetic: `inserted` == regrowthThreshold_final, so after 
`inserted` successful inserts the parked
+    // grouper's size sits exactly at getMaxSize() (i.e. 
regrowthThreshold_final). The next new-key aggregate() would
+    // find no bucket and return not-ok — this IS the spill point.
+    Assertions.assertEquals(inserted, parked.getSize());
+    Assertions.assertEquals(inserted, parked.getMaxSize());
+    // No further aggregate call. Grouper is parked exactly at its terminal 
threshold; the next insert WOULD spill.
+    Assertions.assertEquals(1.0, parked.getMaxSpillProximity(), 0.0);
+    parked.close();
+  }
+
+  @Test
+  public void testMaxSpillProximityIsProportionalNotSawtooth()
+  {
+    // Regression guard for the sawtooth denominator bug. The old metric 
divided size by the CURRENT growth level's
+    // regrowthThreshold. Since the table grows by doubling, 
size/currentRegrowthThreshold resets to ~0.5 on every
+    // doubling and climbs back to ~1.0 before the next — so a table that 
grows even a couple of times pinned its
+    // lifetime max near (N-1)/N ~ 0.99, regardless of how much headroom 
remained before the real spill. The fix
+    // divides by the FIXED terminal-level regrowthThreshold, so the value is 
the true fraction of the way to the spill.
+    final GroupByTestColumnSelectorFactory columnSelectorFactory = 
GrouperTestUtil.newColumnSelectorFactory();
+    columnSelectorFactory.setRow(new MapBasedRow(0, ImmutableMap.of("value", 
1L)));
+
+    // First, discover the terminal threshold T by filling an identical 
grouper until it rejects.
+    final BufferHashGrouper<IntKey> probe = new BufferHashGrouper<>(
+        Suppliers.ofInstance(ByteBuffer.allocate(100_000)),
+        GrouperTestUtil.intKeySerde(),
+        AggregatorAdapters.factorizeBuffered(
+            columnSelectorFactory,
+            ImmutableList.of(
+                new LongSumAggregatorFactory("valueSum", "value"),
+                new CountAggregatorFactory("count")
+            )
+        ),
+        Integer.MAX_VALUE,
+        0.75f,
+        4,
+        true
+    );
+    probe.init();
+    int terminalThreshold = 0;
+    while (terminalThreshold < 100_000 && probe.aggregate(new 
IntKey(terminalThreshold)).isOk()) {
+      terminalThreshold++;
+    }
+    Assertions.assertEquals(1.0, probe.getMaxSpillProximity(), 0.0);
+    probe.close();
+
+    // Now fill a fresh grouper to only a quarter of the terminal threshold. 
This is enough distinct keys to force
+    // several doublings (so the OLD metric would have pinned near ~0.99), but 
the table is nowhere near spilling.
+    final BufferHashGrouper<IntKey> grouper = new BufferHashGrouper<>(
+        Suppliers.ofInstance(ByteBuffer.allocate(100_000)),
+        GrouperTestUtil.intKeySerde(),
+        AggregatorAdapters.factorizeBuffered(
+            columnSelectorFactory,
+            ImmutableList.of(
+                new LongSumAggregatorFactory("valueSum", "value"),
+                new CountAggregatorFactory("count")
+            )
+        ),
+        Integer.MAX_VALUE,
+        0.75f,
+        4,
+        true
+    );
+    grouper.init();
+
+    final int inserted = terminalThreshold / 4;
+    for (int i = 0; i < inserted; i++) {
+      Assertions.assertTrue(grouper.aggregate(new IntKey(i)).isOk());
+    }
+
+    // The table must actually have grown multiple times — otherwise this 
wouldn't exercise the sawtooth path.
+    Assertions.assertTrue(
+        grouper.getGrowthCount() >= 2,
+        "expected multiple growths to exercise the sawtooth path, got " + 
grouper.getGrowthCount()
+    );
+
+    // The value tracks size/terminalThreshold ~ 0.25, NOT the ~0.99 the old 
current-level denominator produced.
+    final double proximity = grouper.getMaxSpillProximity();
+    final double expected = (double) inserted / terminalThreshold;
+    Assertions.assertEquals(
+        expected,
+        proximity,
+        0.05,
+        "proximity should be proportional to fill fraction, was " + proximity
+    );
+    Assertions.assertTrue(
+        proximity < 0.5,
+        "proximity must be far below 1.0 for a quarter-full table, was " + 
proximity
+    );
+
+    grouper.close();
+  }
+
+  @Test
+  public void testMaxSpillProximityBeforeInitIsZero()
+  {
+    // Grouper never initialized: no hash table has been created, so proximity 
is 0.0 rather than NaN or a crash.
+    final GroupByTestColumnSelectorFactory columnSelectorFactory = 
GrouperTestUtil.newColumnSelectorFactory();
+    final BufferHashGrouper<IntKey> grouper = new BufferHashGrouper<>(
+        Suppliers.ofInstance(ByteBuffer.allocate(1000)),
+        GrouperTestUtil.intKeySerde(),
+        AggregatorAdapters.factorizeBuffered(
+            columnSelectorFactory,
+            ImmutableList.of(
+                new LongSumAggregatorFactory("valueSum", "value"),
+                new CountAggregatorFactory("count")
+            )
+        ),
+        Integer.MAX_VALUE,
+        0,
+        0,
+        true
+    );
+    Assertions.assertEquals(0.0, grouper.getMaxSpillProximity(), 0.0);
+  }
+
   private ResourceHolder<Grouper<IntKey>> makeGrouper(
       GroupByTestColumnSelectorFactory columnSelectorFactory,
       int bufferSize,
diff --git 
a/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/LimitedBufferHashGrouperTest.java
 
b/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/LimitedBufferHashGrouperTest.java
index 0833a8ee490..aa3884cbcce 100644
--- 
a/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/LimitedBufferHashGrouperTest.java
+++ 
b/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/LimitedBufferHashGrouperTest.java
@@ -370,6 +370,86 @@ public class LimitedBufferHashGrouperTest extends 
InitializedNullHandlingTest
     Assertions.assertEquals(expectedPeakUsage, 
grouper.getMaxMergeBufferUsedBytes());
   }
 
+  @Test
+  public void testMaxSpillProximityIsZeroOnHeapTrimSwaps()
+  {
+    // A LimitedBufferHashGrouper's Alternating hash table swaps sub-buffers 
on every "full" event and trims to `limit`
+    // entries — a heap trim for limit push-down, NOT a disk spill. size hits 
regrowthThreshold on every swap (see
+    // testMinBufferSize: size == getMaxSize() == 101 at steady state after 
899 swaps). This path never spills, so its
+    // fill ratio is not proximity to a spill: 
AlternatingByteBufferHashTable.recordsFillProximity() returns false,
+    // suppressing fill-proximity recording entirely. A Limited grouper that 
trims but never spills must report exactly
+    // 0.0 — not the ~(T-1)/T saturation that the raw fill ratio would produce.
+    final GroupByTestColumnSelectorFactory columnSelectorFactory = 
GrouperTestUtil.newColumnSelectorFactory();
+    final LimitedBufferHashGrouper<IntKey> grouper = 
makeGrouper(columnSelectorFactory, 12120);
+
+    columnSelectorFactory.setRow(new MapBasedRow(0, ImmutableMap.of("value", 
10L)));
+    for (int i = 0; i < NUM_ROWS; i++) {
+      Assertions.assertTrue(grouper.aggregate(new IntKey(i + 
KEY_BASE)).isOk(), String.valueOf(i + KEY_BASE));
+    }
+
+    // Confirms this exercised the heap-trim swap path (matches 
testMinBufferSize's observations).
+    Assertions.assertTrue(
+        grouper.getGrowthCount() > 100,
+        "expected multiple heap-trim swaps: " + grouper.getGrowthCount()
+    );
+    Assertions.assertEquals(101, grouper.getMaxSize());
+    Assertions.assertEquals(101, grouper.getSize());
+
+    // Despite size == regrowthThreshold at every swap, proximity must be 
exactly 0.0: heap-trim swaps are not a spill,
+    // and no bucket rejection ever occurred (post-swap size <= limit < 
regrowthThreshold guarantees the next insert
+    // succeeds), so nothing sets proximity above its 0.0 initial value.
+    Assertions.assertEquals(
+        0.0,
+        grouper.getMaxSpillProximity(),
+        0.0,
+        "heap-trim swaps must not register any spill proximity: " + 
grouper.getMaxSpillProximity()
+    );
+  }
+
+  @Test
+  public void testMaxSpillProximityIsOneOnGenuineRejection()
+  {
+    // The only real spill signal for the Alternating table is a genuine 
bucket rejection in findBucketWithAutoGrowth.
+    // Force one by capping bufferGrouperMaxSize (maxSizeForTesting) below 
regrowthThreshold: once size reaches the cap
+    // no new bucket is allowed AND no swap is attempted (size < 
maxSizeForTesting is false), so findBucketWithAutoGrowth
+    // returns -1 and pins proximity to exactly 1.0 — even though 
recordsFillProximity() zeroes the ordinary heap-trim
+    // path.
+    final GroupByTestColumnSelectorFactory columnSelectorFactory = 
GrouperTestUtil.newColumnSelectorFactory();
+    final int maxSize = 50;
+    final LimitedBufferHashGrouper<IntKey> grouper = new 
LimitedBufferHashGrouper<>(
+        Suppliers.ofInstance(ByteBuffer.allocate(12120)),
+        GrouperTestUtil.intKeySerde(),
+        AggregatorAdapters.factorizeBuffered(
+            columnSelectorFactory,
+            ImmutableList.of(
+                new LongSumAggregatorFactory("valueSum", "value"),
+                new CountAggregatorFactory("count")
+            )
+        ),
+        maxSize,
+        0.5f,
+        2,
+        LIMIT,
+        false
+    );
+    grouper.init();
+
+    columnSelectorFactory.setRow(new MapBasedRow(0, ImmutableMap.of("value", 
10L)));
+
+    // Fill up to the cap; every insert is accepted and proximity stays 0.0 
(heap-trim/fill path is suppressed).
+    for (int i = 0; i < maxSize; i++) {
+      Assertions.assertTrue(grouper.aggregate(new IntKey(i + 
KEY_BASE)).isOk());
+    }
+    Assertions.assertEquals(0.0, grouper.getMaxSpillProximity(), 0.0);
+
+    // The next distinct key cannot be placed and cannot trigger a swap (size 
== maxSizeForTesting): a genuine
+    // rejection. Proximity is pinned to exactly 1.0.
+    Assertions.assertFalse(grouper.aggregate(new IntKey(maxSize + 
KEY_BASE)).isOk());
+    Assertions.assertEquals(1.0, grouper.getMaxSpillProximity(), 0.0);
+
+    grouper.close();
+  }
+
   private static LimitedBufferHashGrouper<IntKey> makeGrouper(
       GroupByTestColumnSelectorFactory columnSelectorFactory,
       int bufferSize
diff --git 
a/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/SpillingGrouperTest.java
 
b/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/SpillingGrouperTest.java
index 1a96081e91c..a0f68646366 100644
--- 
a/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/SpillingGrouperTest.java
+++ 
b/processing/src/test/java/org/apache/druid/query/groupby/epinephelinae/SpillingGrouperTest.java
@@ -372,6 +372,116 @@ public class SpillingGrouperTest extends 
InitializedNullHandlingTest
     }
   }
 
+  @Test
+  public void testSpillProximityReflectsSliceFillOnClose() throws IOException
+  {
+    // Fill a grouper part-way (no spill) and confirm close() records a 
proximity strictly in (0, 1). The proximity is
+    // the underlying hash table's peak size/terminalRegrowthThreshold, so a 
lightly-filled slice is well below 1.0.
+    final GroupByStatsProvider.PerQueryStats stats = new 
GroupByStatsProvider.PerQueryStats();
+    final int bufferSize = 100_000;
+    final SpillingGrouper<IntKey> grouper = makeGrouper(
+        bufferSize,
+        new LimitedTemporaryStorage(temporaryFolder.newFolder(), 1024 * 1024, 
100, new GroupByStatsProvider.PerQueryStats()),
+        1024 * 1024L,
+        stats,
+        true
+    );
+
+    // A handful of distinct keys: some buckets used, well short of the spill 
threshold.
+    for (int i = 0; i < 10; i++) {
+      Assertions.assertTrue(grouper.aggregate(new IntKey(i)).isOk());
+    }
+    grouper.close();
+
+    final double proximity = stats.getSpillProximity();
+    Assertions.assertTrue(proximity > 0.0, "proximity should be positive after 
aggregating: " + proximity);
+    Assertions.assertTrue(proximity < 1.0, "a lightly-filled slice should be 
well below the spill point: " + proximity);
+  }
+
+  @Test
+  public void testSpillProximityNotRecordedWhenGrouperNeverInitialized() 
throws IOException
+  {
+    // A grouper that never initialized (never touched the merge buffer) must 
not contribute to spill proximity, per the
+    // isInitialized() gate in close(). Otherwise idle slices would report a 
spurious 0-of-threshold data point.
+    final GroupByStatsProvider.PerQueryStats stats = new 
GroupByStatsProvider.PerQueryStats();
+    final SpillingGrouper<IntKey> grouper = makeGrouper(
+        100_000,
+        new LimitedTemporaryStorage(temporaryFolder.newFolder(), 1024 * 1024, 
100, new GroupByStatsProvider.PerQueryStats()),
+        1024 * 1024L,
+        stats,
+        false // do not init
+    );
+    grouper.close();
+
+    // No spillProximity() call was made, so proximity stays at its initial 
0.0.
+    Assertions.assertEquals(0.0, stats.getSpillProximity(), 1e-9);
+  }
+
+  @Test
+  public void testSpillProximityStaysOneAfterSpillThenLightRefill() throws 
IOException
+  {
+    // Force a real spill, then aggregate a few more keys that don't refill 
the table, then close. The underlying hash
+    // table gets reset() by spill() (size returns to 0, regrowthThreshold 
shrinks to its initial small value), so the
+    // ratio in isolation at close time would be tiny. What must save us is 
peak preservation: the 1.0 pinned inside
+    // findBucketWithAutoGrowth (or at the terminal threshold) before the 
reset survives it. Assert the reported
+    // proximity is exactly 1.0 despite the low post-spill fill.
+    final GroupByStatsProvider.PerQueryStats stats = new 
GroupByStatsProvider.PerQueryStats();
+    final int bufferSize = 50;
+    final SpillingGrouper<IntKey> grouper = makeGrouper(
+        bufferSize,
+        new LimitedTemporaryStorage(temporaryFolder.newFolder(), 1024 * 1024, 
100, new GroupByStatsProvider.PerQueryStats()),
+        1024 * 1024L,
+        stats,
+        true
+    );
+
+    // Enough keys to trigger multiple spills.
+    for (int i = 0; i < 50; i++) {
+      Assertions.assertTrue(grouper.aggregate(new IntKey(i)).isOk());
+    }
+    // Just a couple more keys — table has been reset() by the last spill so 
it's lightly filled at close.
+    Assertions.assertTrue(grouper.aggregate(new IntKey(9001)).isOk());
+    Assertions.assertTrue(grouper.aggregate(new IntKey(9002)).isOk());
+    grouper.close();
+
+    Assertions.assertEquals(
+        1.0,
+        stats.getSpillProximity(),
+        0.0,
+        "spilled slice with post-spill light refill must still report 1.0 
(peak preserved across reset)"
+    );
+  }
+
+  @Test
+  public void testSpillProximityExactlyOneWhenSliceSpills() throws IOException
+  {
+    // A tiny 50-byte buffer with 100 unique keys forces the underlying 
BufferHashGrouper to reject a bucket allocation
+    // (findBucketWithAutoGrowth returns -1), which is the real spill trigger. 
SpillingGrouper.aggregate then invokes
+    // spill(); the peak size/terminalRegrowthThreshold at that instant is 
pinned to exactly 1.0 and preserved across
+    // the subsequent grouper.reset(). This is the "1.0 <=> actually spilled" 
invariant.
+    final GroupByStatsProvider.PerQueryStats stats = new 
GroupByStatsProvider.PerQueryStats();
+    final int bufferSize = 50;
+    final SpillingGrouper<IntKey> grouper = makeGrouper(
+        bufferSize,
+        new LimitedTemporaryStorage(temporaryFolder.newFolder(), 1024 * 1024, 
100, new GroupByStatsProvider.PerQueryStats()),
+        1024 * 1024L,
+        stats,
+        true
+    );
+
+    for (int i = 0; i < 100; i++) {
+      Assertions.assertTrue(grouper.aggregate(new IntKey(i)).isOk());
+    }
+    grouper.close();
+
+    Assertions.assertEquals(
+        1.0,
+        stats.getSpillProximity(),
+        0.0,
+        "a slice that reached its spill trigger must report proximity == 1.0 
exactly"
+    );
+  }
+
   private SpillingGrouper<IntKey> makeGrouper(
       int bufferSize,
       File storageDir,
@@ -410,6 +520,17 @@ public class SpillingGrouperTest extends 
InitializedNullHandlingTest
       LimitedTemporaryStorage temporaryStorage,
       long minSpillFileSize
   )
+  {
+    return makeGrouper(bufferSize, temporaryStorage, minSpillFileSize, new 
GroupByStatsProvider.PerQueryStats(), true);
+  }
+
+  private SpillingGrouper<IntKey> makeGrouper(
+      int bufferSize,
+      LimitedTemporaryStorage temporaryStorage,
+      long minSpillFileSize,
+      GroupByStatsProvider.PerQueryStats perQueryStats,
+      boolean init
+  )
   {
     final GroupByTestColumnSelectorFactory columnSelectorFactory = 
GrouperTestUtil.newColumnSelectorFactory();
     columnSelectorFactory.setRow(new MapBasedRow(0, ImmutableMap.of("value", 
1L)));
@@ -429,9 +550,11 @@ public class SpillingGrouperTest extends 
InitializedNullHandlingTest
         false,
         bufferSize,
         minSpillFileSize,
-        new GroupByStatsProvider.PerQueryStats()
+        perQueryStats
     );
-    grouper.init();
+    if (init) {
+      grouper.init();
+    }
     return grouper;
   }
 
diff --git 
a/server/src/main/java/org/apache/druid/server/metrics/GroupByStatsMonitor.java 
b/server/src/main/java/org/apache/druid/server/metrics/GroupByStatsMonitor.java
index e5f46020fe0..9c69f182f8f 100644
--- 
a/server/src/main/java/org/apache/druid/server/metrics/GroupByStatsMonitor.java
+++ 
b/server/src/main/java/org/apache/druid/server/metrics/GroupByStatsMonitor.java
@@ -68,6 +68,7 @@ public class GroupByStatsMonitor extends AbstractMonitor
       emitter.emit(builder.setMetric("mergeBuffer/maxAcquisitionTimeNs", 
statsContainer.getMaxMergeBufferAcquisitionTimeNs()));
       emitter.emit(builder.setMetric("mergeBuffer/bytesUsed", 
statsContainer.getTotalMergeBufferUsedBytes()));
       emitter.emit(builder.setMetric("mergeBuffer/maxBytesUsed", 
statsContainer.getMaxMergeBufferUsedBytes()));
+      emitter.emit(builder.setMetric("mergeBuffer/maxSpillProximity", 
statsContainer.getMaxSpillProximity()));
     }
 
     if (statsContainer.getSpilledQueries() > 0) {
diff --git 
a/server/src/test/java/org/apache/druid/server/metrics/GroupByStatsMonitorTest.java
 
b/server/src/test/java/org/apache/druid/server/metrics/GroupByStatsMonitorTest.java
index ce5b4f13e9d..96021fb906e 100644
--- 
a/server/src/test/java/org/apache/druid/server/metrics/GroupByStatsMonitorTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/metrics/GroupByStatsMonitorTest.java
@@ -65,6 +65,7 @@ public class GroupByStatsMonitorTest
             100L,
             200L,
             200L,
+            0.85,
             2L,
             200L,
             200L,
@@ -95,7 +96,7 @@ public class GroupByStatsMonitorTest
     // Trigger metric emission
     monitor.doMonitor(emitter);
 
-    Assertions.assertEquals(12, emitter.getNumEmittedEvents());
+    Assertions.assertEquals(13, emitter.getNumEmittedEvents());
     emitter.verifyValue("mergeBuffer/pendingRequests", 0L);
     emitter.verifyValue("mergeBuffer/used", 0L);
     emitter.verifyValue("mergeBuffer/queries", 1L);
@@ -103,6 +104,7 @@ public class GroupByStatsMonitorTest
     emitter.verifyValue("mergeBuffer/maxAcquisitionTimeNs", 100L);
     emitter.verifyValue("mergeBuffer/bytesUsed", 200L);
     emitter.verifyValue("mergeBuffer/maxBytesUsed", 200L);
+    emitter.verifyValue("mergeBuffer/maxSpillProximity", 0.85);
     emitter.verifyValue("groupBy/spilledQueries", 2L);
     emitter.verifyValue("groupBy/spilledBytes", 200L);
     emitter.verifyValue("groupBy/maxSpilledBytes", 200L);
@@ -139,6 +141,7 @@ public class GroupByStatsMonitorTest
     verifyMetricValue(emitter, "mergeBuffer/maxAcquisitionTimeNs", dimFilters, 
100L);
     verifyMetricValue(emitter, "mergeBuffer/bytesUsed", dimFilters, 200L);
     verifyMetricValue(emitter, "mergeBuffer/maxBytesUsed", dimFilters, 200L);
+    verifyMetricValue(emitter, "mergeBuffer/maxSpillProximity", dimFilters, 
0.85);
     verifyMetricValue(emitter, "groupBy/spilledQueries", dimFilters, 2L);
     verifyMetricValue(emitter, "groupBy/spilledBytes", dimFilters, 200L);
     verifyMetricValue(emitter, "groupBy/maxSpilledBytes", dimFilters, 200L);
@@ -205,21 +208,24 @@ public class GroupByStatsMonitorTest
     QueryResourceId r1 = new QueryResourceId("r1");
     GroupByStatsProvider.PerQueryStats stats1 = 
statsProvider.getPerQueryStatsContainer(r1);
     stats1.mergeBufferAcquisitionTime(100);
-    stats1.maxMergeBufferUsedBytes(50);
+    stats1.addMergeBufferUsedBytes(50);
+    stats1.spillProximity(0.05);
     stats1.spilledBytes(200);
     stats1.dictionarySize(100);
 
     QueryResourceId r2 = new QueryResourceId("r2");
     GroupByStatsProvider.PerQueryStats stats2 = 
statsProvider.getPerQueryStatsContainer(r2);
     stats2.mergeBufferAcquisitionTime(500);
-    stats2.maxMergeBufferUsedBytes(30);
+    stats2.addMergeBufferUsedBytes(30);
+    stats2.spillProximity(0.015);
     stats2.spilledBytes(100);
     stats2.dictionarySize(300);
 
     QueryResourceId r3 = new QueryResourceId("r3");
     GroupByStatsProvider.PerQueryStats stats3 = 
statsProvider.getPerQueryStatsContainer(r3);
     stats3.mergeBufferAcquisitionTime(200);
-    stats3.maxMergeBufferUsedBytes(150);
+    stats3.addMergeBufferUsedBytes(150);
+    stats3.spillProximity(0.1);
     stats3.spilledBytes(800);
     stats3.dictionarySize(200);
 
@@ -242,10 +248,34 @@ public class GroupByStatsMonitorTest
 
     emitter.verifyValue("mergeBuffer/maxAcquisitionTimeNs", 500L);
     emitter.verifyValue("mergeBuffer/maxBytesUsed", 150L);
+    // Spill proximity is the MAX per-query fullest-slice fill fraction: 
max(0.05, 0.015, 0.1) = 0.1.
+    emitter.verifyValue("mergeBuffer/maxSpillProximity", 0.1);
     emitter.verifyValue("groupBy/maxSpilledBytes", 800L);
     emitter.verifyValue("groupBy/maxMergeDictionarySize", 300L);
   }
 
+  @Test
+  public void testMaxSpillProximityNotEmittedWhenNoMergeBufferQueries()
+  {
+    // No query records any merge-buffer acquisition time, so the entire 
mergeBuffer/* block is skipped.
+    GroupByStatsProvider statsProvider = new GroupByStatsProvider();
+
+    QueryResourceId r1 = new QueryResourceId("r1");
+    GroupByStatsProvider.PerQueryStats stats1 = 
statsProvider.getPerQueryStatsContainer(r1);
+    // dictionary-only activity, no merge buffer acquisition
+    stats1.dictionarySize(100);
+    statsProvider.closeQuery(r1);
+
+    final GroupByStatsMonitor monitor = new GroupByStatsMonitor(statsProvider, 
mergeBufferPool);
+    final StubServiceEmitter emitter = new StubServiceEmitter("service", 
"host");
+    emitter.start();
+    monitor.doMonitor(emitter);
+
+    
Assertions.assertTrue(emitter.getMetricEvents("mergeBuffer/queries").isEmpty());
+    
Assertions.assertTrue(emitter.getMetricEvents("mergeBuffer/maxBytesUsed").isEmpty());
+    
Assertions.assertTrue(emitter.getMetricEvents("mergeBuffer/maxSpillProximity").isEmpty());
+  }
+
   private void verifyMetricValue(StubServiceEmitter emitter, String 
metricName, Map<String, Object> dimFilters, Number expectedValue)
   {
     final List<ServiceMetricEvent> observedMetricEvents = 
emitter.getMetricEvents(metricName);


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

Reply via email to