xiangfu0 commented on code in PR #19795:
URL: https://github.com/apache/pinot/pull/19795#discussion_r4218023744


##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/PercentileTDigestValueAggregator.java:
##########
@@ -527,33 +508,21 @@ private byte[] getSerializedBytes(ByteBuffer 
scratchBuffer) {
         }
 
         ByteBuffer finite = ByteBuffer.wrap(finiteBytes);
-        int encoding = finite.getInt();
-        finite.position(finite.position() + 2 * Double.BYTES);
-        int finiteCentroidCount;
-        boolean finiteVerbose;
-        if (encoding == VERBOSE_ENCODING) {
-          finite.getDouble();
-          finiteCentroidCount = finite.getInt();
-          finiteVerbose = true;
-        } else if (encoding == SMALL_ENCODING) {
-          finite.getFloat();
-          finite.getShort();
-          finite.getShort();
-          finiteCentroidCount = finite.getShort();
-          finiteVerbose = false;
-        } else {
-          throw new IllegalStateException("Invalid format for serialized 
histogram");
-        }
+        SerializedTDigestMetadata metadata = 
TDigestUtils.readSerializedHeader(finite, false);

Review Comment:
   [code-review] The re-encode loop below (`verbose.putDouble(... 
finite.getFloat())`) transcribes SMALL-encoded finite float means into the 
verbose payload without clamping them to the extrema, while the header writes 
`getMin()`/`getMax()` (−∞/+∞ once infinity mass exists). `fromBytes` and 
`SerializedTDigestInput.decode()` both clamp small means to `[min, max]` 
because float rounding can land just outside the double extrema — but here a 
mean equal to `(double)(float) max > max` is emitted into a VERBOSE payload, 
and `inspectSerialized`'s float-rounding allowance applies only to SMALL 
encoding, so the final `makeLegacyCompatible` throws `TDigest centroid mean is 
outside its extrema`.
   
   Reproduced trigger: a stored pre-aggregated digest in small encoding whose 
max (e.g. 0.3) is not float-representable enters via 
`deserializeAggregatedValue` (kept as pending bytes, re-emitted verbatim by the 
finite digest), then the same star-tree/realtime group receives a −Infinity raw 
value; `serializeAggregatedValue` → `getSerializedBytes` throws and the 
star-tree build / merge-rollup fails. Symmetric case: +Infinity mass with a 
non-float-exact min. Clamp the re-widened means like `decode()` does.



##########
pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/PercentileRawTDigestAggregationFunctionTest.java:
##########
@@ -155,17 +153,32 @@ static byte[] createSmallUnitCentroidDigest(int 
numCentroids, double compression
     return buffer.array();
   }
 
-  private static void assertLegacyCompatibleShape(byte[] bytes, double 
compression) {
+  static void assertLegacyCompatibleShape(byte[] bytes, double compression) {

Review Comment:
   [code-review] With the tdunning dependency gone, the wire-compat tests lost 
their real legacy-reader oracle: cross-version readability is now asserted only 
via Pinot's own lenient decoder (`TDigestUtils.deserialize`, which repairs 
boundary centroids and accepts counts up to `max(default, legacy)` capacity) 
plus this structural shape check (header/count/capacity arithmetic only). A 
regression in `makeLegacyCompatible` that emits in-capacity verbose bytes whose 
first/last centroids have weight > 1 would pass CI but trip t-digest 3.3's 
`assert weight[0] == 1` (or corrupt its interpolation) on an old server during 
a rolling upgrade — the exact contract this PR is preserving. Consider keeping 
`com.tdunning:t-digest` as a test-scoped dependency so 
`MergingDigest.fromBytes` + a subsequent merge still gate the emitted bytes.



##########
pinot-segment-local/src/test/java/org/apache/pinot/segment/local/aggregator/PercentileTDigestValueAggregatorTest.java:
##########
@@ -46,9 +52,10 @@ public void testRawValuesTrackMaxVerboseSize(int 
compression, int numValues,
     for (int i = 1; i < numValues; i++) {
       aggregator.applyRawValue(digest, i);
     }
-    assertEquals(aggregator.getMaxAggregatedValueByteSize(), 
expectedMaxByteSize);
+    int registeredBound = aggregator.getMaxAggregatedValueByteSize();
+    assertTrue(registeredBound <= expectedMaxByteSize);

Review Comment:
   [code-review] This assertion (and the similar one at the `6_592` check 
below) was weakened from equality to `<=`, so the test no longer verifies that 
the registered max-byte-size actually *expands* when a higher-compression 
pre-aggregated digest enters the aggregator. `assertTrue(registeredBound <= 
expectedMaxByteSize)` passes even if `updateMaxByteSize` ignored the input's 
larger compression entirely, and the companion `serialized.length <= 
registeredBound` check can't catch that because the test digest holds a single 
value (~tens of bytes). An under-registration regression here can overflow 
star-tree var-byte writers sized from `getMaxAggregatedValueByteSize()`. 
Suggest also asserting a lower bound (e.g. the bound computed for the larger 
compression) or serializing a digest large enough to be discriminating.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -240,167 +271,89 @@ public void add(List<? extends TDigest> others) {
   }
 
   private void addAccumulator(PercentileTDigestAccumulator other) {
+    _hasFractionalWeights |= other._hasFractionalWeights;
+    if (other == this) {
+      addSerializedTDigest(serialize());
+      return;
+    }
     if (other._pendingSerializedTDigest != null) {
-      addSerializedTDigest(other._pendingSerializedTDigest);
+      SerializedTDigestInput input = new SerializedTDigestInput();

Review Comment:
   [code-review] (perf nit) This path allocates a fresh 
`SerializedTDigestInput` on every pending-state merge (and `add(TDigest)` does 
the same for the invalid-statistics path), while the class already maintains 
the reusable `_serializedTDigestInput` field for exactly this purpose (used by 
`addSerializedTDigest(byte[])` and `materializePendingSerializedTDigest`). 
`merge(TDigest, TDigest)` runs per group per segment during combine and per 
group during broker reduce, so a high-cardinality group-by allocates millions 
of short-lived inputs the reused field would amortize. Reuse is safe here 
because `addSerializedTDigest` retains only the bytes, never the input object.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/PercentileTDigestValueAggregator.java:
##########
@@ -152,128 +150,111 @@ private static NonFiniteAwareTDigest 
asNonFiniteAware(TDigest value) {
   }
 
   private void updateMaxByteSize(TDigest value) {
-    long defaultCapacity = getDefaultCentroidCapacity(value.compression());
-    long maxCentroids = Math.min(value.size(), defaultCapacity);
+    long defaultCapacity = 
TDigestUtils.getDefaultCentroidCapacity(value.compression());
+    long maxCentroids = (long) Math.min(Math.ceil(value.getTotalWeight()), 
defaultCapacity);
+    if (value instanceof NonFiniteAwareTDigest) {
+      TDigest finite = ((NonFiniteAwareTDigest) value)._finiteDigest;
+      if (finite instanceof PercentileTDigestAccumulator) {
+        PercentileTDigestAccumulator accumulator = 
(PercentileTDigestAccumulator) finite;
+        NonFiniteAwareTDigest wrapped = (NonFiniteAwareTDigest) value;
+        boolean fractionalMass = accumulator.hasFractionalWeights()
+            || wrapped._negativeInfinityWeight != 
Math.rint(wrapped._negativeInfinityWeight)
+            || wrapped._positiveInfinityWeight != 
Math.rint(wrapped._positiveInfinityWeight);
+        long bufferedBound = accumulator.getCentroidCountUpperBound() + 6L;

Review Comment:
   [code-review] (sizing nit) For fractional-mass (or invalid-statistics) 
inputs, `bufferedBound` is not capped by the default centroid capacity, and 
`getCentroidCountUpperBound()` counts un-flushed buffered inputs — up to the 
pending-buffer limit (~1050 at compression 100, 10000 max). `_maxByteSize` can 
therefore be registered as ~17KB–160KB while actual serialized payloads stay 
~3.4KB (`makeLegacyCompatible` reduces to legacy capacity). This only wastes 
forward-index chunk headroom (no corruption — `serializeAggregatedValue` 
records actual lengths), and the mass-based bound is genuinely invalid for 
fractional weights, but it may be worth flushing before computing the bound (or 
documenting the inflation) since the estimate depends on call-time buffer fill.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -433,20 +405,39 @@ public void compress() {
 
   @Override
   public long size() {
-    if (_pendingSerializedTDigest != null) {
-      if (Double.isNaN(_pendingSerializedTotalWeight)) {
-        _pendingSerializedTotalWeight = 
getSerializedTotalWeight(_pendingSerializedTDigest);
-      }
-      return (long) _pendingSerializedTotalWeight;
-    }
-    return (long) (_totalWeight + _incomingWeight + _numRawValues);
+    return (long) getTotalWeight();
+  }
+
+  @Override
+  public boolean hasValidStatistics() {
+    return !_legacyDegraded;
+  }
+
+  @Override
+  public double getTotalWeight() {
+    return _pendingSerializedTDigest != null ? _pendingSerializedTotalWeight
+        : _totalWeight + _incomingWeight + _numRawValues;
+  }
+
+  /// Bounds output centroids from the actual buffered inputs without flushing 
or using their potentially huge mass.
+  public long getCentroidCountUpperBound() {

Review Comment:
   [code-review] (doc nit) "Bounds output centroids" can be exceeded by up to 
2: `serialize()` normalizes boundaries twice (once inside the merge via 
`normalizeBoundaryCentroids()` on existing centroids before merging, +≤2 
inputs; once again before `toVerboseBytes`, +≤2), so output can reach 
`_numCentroids + _numRawValues + _numIncomingCentroids + 4`. Example at 
compression 100: centroids `[[email protected], [email protected]]` plus buffered `[email protected], [email protected]` → 
bound 6, serialized 7 (both-end variants reach 8). The sole current caller's `+ 
6L` provably absorbs it (infinity centroids are ≤4 when finite values exist), 
but any future caller sizing a buffer from this "upper bound" under-allocates. 
Suggest `+ 4L` here or correcting the doc.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -741,8 +752,9 @@ private void recompressCentroids(double compression) {
   }
 
   private byte[] toVerboseBytes() {
-    ByteBuffer buffer = ByteBuffer.allocate(VERBOSE_HEADER_SIZE + 
VERBOSE_CENTROID_SIZE * _numCentroids);
-    buffer.putInt(VERBOSE_ENCODING);
+    ByteBuffer buffer = ByteBuffer.allocate(
+        TDigestUtils.VERBOSE_HEADER_SIZE + TDigestUtils.VERBOSE_CENTROID_SIZE 
* _numCentroids);

Review Comment:
   [code-review] (hardening nit) This size expression uses unchecked int 
arithmetic, unlike `smallByteSize()`'s `Math.addExact`/`multiplyExact`: with 
query-controlled compression (`PERCENTILETDIGEST(col, p, compression)` has no 
upper bound — `validateCompression` only requires positive finite) and ≥ ~134M 
centroids, `VERBOSE_CENTROID_SIZE * _numCentroids` wraps and 
`ByteBuffer.allocate` fails with a confusing `capacity < 0` instead of a clear 
error. Needs a multi-GB heap to reach, so purely latent — but a one-line 
`multiplyExact` makes the failure mode match the small-encoding path.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1041,22 +986,40 @@ private void ensureIncomingCapacity(int capacity) {
   private void ensureIncomingSortCapacity(int capacity) {
     ensureSortedIncomingCapacity(capacity);
     if (_incomingOrder == null || capacity > _incomingOrder.length) {
-      int newCapacity = _incomingOrder == null ? Math.max(capacity, 
_centroidCapacity)
+      int newCapacity = _incomingOrder == null
+          ? Math.max(capacity, Math.min(_centroidCapacity, 
_pendingCentroidCapacity))
           : Math.max(capacity, Math.multiplyExact(_incomingOrder.length, 2));
       _incomingOrder = new int[newCapacity];
     }
   }
 
+  private void stableSortIncoming(int count) {

Review Comment:
   [code-review] (perf nit) This replaces tdunning's primitive stable sort with 
`it.unimi.dsi.fastutil.Arrays.mergeSort` driven by two capturing lambdas: every 
comparison takes two levels of indirection 
(`_incomingMeans[_incomingOrder[i]]`) through an interface call, plus the 
identity-init loop and a gather pass. `flushIncoming` runs once per 
pending-buffer fill (up to ~1050 elements at compression 100, 10000 max) on 
every weighted/serialized merge path. A hand-rolled primitive stable merge of 
the mean/weight pairs directly into the existing 
`_sortedIncomingMeans`/`_sortedIncomingWeights` scratch arrays would drop the 
`_incomingOrder` indirection and lambda dispatch entirely.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to