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


##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1118,12 +1151,54 @@ private void bufferIncomingCentroids(double[] means, 
double[] weights, int count
   private void materializePendingSerializedTDigest() {
     if (_pendingSerializedTDigest != null) {
       byte[] bytes = _pendingSerializedTDigest;
+      SerializedTDigestMetadata metadata = _pendingSerializedMetadata;
       _pendingSerializedTDigest = null;
+      _pendingSerializedMetadata = null;
       _pendingSerializedTotalWeight = Double.NaN;
-      mergeSerializedTDigest(bytes);
+      SerializedTDigestInput input = getSerializedTDigestInput();
+      input.reset(bytes, metadata);
+      input._retainedBytes = bytes;
+      if (_legacyDegraded) {
+        mergeLegacyDegradedInput(input, true);
+      } else {
+        input.decode();
+        mergeSerializedTDigest(input, false);
+      }
     }
   }
 
+  private void mergeLegacyDegradedInput(SerializedTDigestInput input, boolean 
retainOriginal) {
+    if (retainOriginal) {
+      _legacySerializedBytes = input.retainBytes();
+    } else {
+      _legacySerializedDirty = true;
+    }
+    _legacyDegraded = true;

Review Comment:
   **[correctness/behavior] One degraded legacy input silently poisons an 
entire aggregation group to NaN, and rollup persists the loss.** When any 
merged input has `needsLegacyFallback` (e.g. a single zero-weight centroid in a 
stored 3.2-era digest), `mergeLegacyDegradedInput` flips the whole accumulator 
to `_legacyDegraded`, after which `quantile()` returns `NaN` for the group even 
if it also holds millions of healthy rows (verified empirically: old code 
returned p50=10.0 for a zero-weight-centroid digest; new code returns NaN, and 
merging it into a 1M-row group turns the group's percentile to NaN with 
`size()=1000002`). `toLegacyDegradedBytes` then serializes the group as a 
single NaN centroid, so a merge-rollup task **succeeds** and permanently 
replaces real data with NaN digests (pre-PR the task failed loudly with IAE, 
leaving source segments intact). If retaining NaN statistics for poisoned 
inputs is intended, consider (a) not infecting healthy already-accumulated mass 
when one
  input is degraded, or at minimum (b) logging/metric-ing the degradation so it 
is distinguishable from genuinely empty data.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TDigestUtils.java:
##########
@@ -624,19 +462,22 @@ private static double weightedMean(double firstMean, 
double firstWeight, double
       }
       return Double.POSITIVE_INFINITY;
     }
-    if (Math.copySign(1.0, firstMean) == Math.copySign(1.0, secondMean)) {
-      return firstMean + (secondMean - firstMean) * secondWeight / totalWeight;
-    }
-    return firstMean * (firstWeight / totalWeight) + secondMean * 
(secondWeight / totalWeight);
+    double average = Math.copySign(1.0, firstMean) == Math.copySign(1.0, 
secondMean)
+        ? firstMean + (secondMean - firstMean) * (secondWeight / totalWeight)
+        : firstMean * (firstWeight / totalWeight) + secondMean * (secondWeight 
/ totalWeight);
+    return Math.max(Math.min(firstMean, secondMean), Math.min(average, 
Math.max(firstMean, secondMean)));
   }
 
-  private static int getLegacyDefaultCentroidCapacity(double compression) {
-    return Math.addExact(Math.multiplyExact(2, (int) Math.ceil(compression)), 
LEGACY_CAPACITY_PADDING);
+  /// Default centroid capacity allocated by the verbose t-digest 3.2 reader.
+  public static int getLegacyDefaultCentroidCapacity(double compression) {
+    return (int) Math.min(Integer.MAX_VALUE,

Review Comment:
   **[correctness/rolling-upgrade] `makeLegacyCompatible` can emit payloads the 
real t-digest 3.2 reader cannot hold.** Two gaps: (1) 
`getLegacyDefaultCentroidCapacity` now normalizes compression to >= 10, but the 
actual 3.2 verbose reader sizes its arrays from the RAW header compression 
(`size = (int)(2*Math.ceil(compression)) + 10;` with no clamp), so for a header 
compression of e.g. 5 the pass-through threshold is treated as 30 while the old 
reader only allocates 20 cells -> `ArrayIndexOutOfBoundsException` in a 
mixed-version cluster (the previous revision of this PR reduced such payloads 
instead). (2) When `metadata.needsLegacyFallback()` is true the payload passes 
through verbatim even when `centroidCount > legacyCapacity`. Both only trigger 
for corrupt or externally-crafted payloads (no legitimate Pinot/tdunning writer 
emits them), but the function's documented contract is 'readable by both 
t-digest 3.2 and 3.3', so either enforce the raw-compression capacity here or 
document t
 he exception.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/PercentileTDigestValueAggregator.java:
##########
@@ -284,51 +266,81 @@ public void add(double value) {
     }
 
     @Override
-    public void add(double value, int weight) {
+    public void add(double value, double weight) {
       if (Double.isNaN(value)) {
         throw new IllegalArgumentException("Cannot add NaN to t-digest");
       }
-      if (weight <= 0) {
+      if (!(weight > 0.0) || !Double.isFinite(weight)) {
         throw new IllegalArgumentException("TDigest weight must be positive: " 
+ weight);
       }
+      if (hasValidStatistics()) {
+        checkTotalWeight(getTotalWeight() + weight);
+      }
       invalidateCaches();
       if (value == Double.NEGATIVE_INFINITY) {
-        _negativeInfinityWeight = Math.addExact(_negativeInfinityWeight, 
weight);
+        _negativeInfinityWeight = _negativeInfinityWeight + weight;
       } else if (value == Double.POSITIVE_INFINITY) {
-        _positiveInfinityWeight = Math.addExact(_positiveInfinityWeight, 
weight);
+        _positiveInfinityWeight = _positiveInfinityWeight + weight;
       } else {
         _finiteDigest.add(value, weight);
       }
     }
 
     @Override
     public void add(TDigest other) {
+      if (hasValidStatistics() && other.hasValidStatistics()) {
+        checkTotalWeight(getTotalWeight() + other.getTotalWeight());
+      }
       if (other instanceof NonFiniteAwareTDigest) {
         NonFiniteAwareTDigest wrapped = (NonFiniteAwareTDigest) other;
-        long negativeInfinityWeight = wrapped._negativeInfinityWeight;
-        long positiveInfinityWeight = wrapped._positiveInfinityWeight;
-        // MergingDigest.add(List) copies the implementation's 
double-precision weight arrays directly. The generic
-        // add(TDigest) path goes through Centroid.count(), which is limited 
to an int and truncates large weights.
-        wrapped.invalidateCaches();
+        double negativeInfinityWeight = wrapped._negativeInfinityWeight;
+        double positiveInfinityWeight = wrapped._positiveInfinityWeight;
         invalidateCaches();
-        if (wrapped._finiteDigest.size() > 0L) {
-          _finiteDigest.add(List.of(wrapped._finiteDigest));
+        if (!wrapped._finiteDigest.isEmpty()) {
+          _finiteDigest.add(wrapped._finiteDigest);
         }
-        _negativeInfinityWeight = Math.addExact(_negativeInfinityWeight, 
negativeInfinityWeight);
-        _positiveInfinityWeight = Math.addExact(_positiveInfinityWeight, 
positiveInfinityWeight);
+        _negativeInfinityWeight += negativeInfinityWeight;
+        _positiveInfinityWeight += positiveInfinityWeight;
         return;
       }
 
-      if (other.size() == 0L) {
+      if (other.isEmpty()) {
         return;
       }
       invalidateCaches();
-      if (Double.isFinite(other.getMin()) && Double.isFinite(other.getMax())) {
-        _finiteDigest.add(List.of(other));
+      if (!other.hasValidStatistics()
+          || Double.isFinite(other.getMin()) && 
Double.isFinite(other.getMax())) {
+        _finiteDigest.add(other);
         return;
       }
-      for (Centroid centroid : other.centroids()) {
-        add(centroid.mean(), centroid.count());
+      // Read precise centroid weights directly; generic sources need no full 
digest serialization for their tails.
+      Collection<Centroid> centroids = other.centroids();
+      double[] finiteMeans = new double[centroids.size()];
+      double[] finiteWeights = new double[centroids.size()];
+      int finiteCount = 0;
+      double finiteMin = Double.POSITIVE_INFINITY;
+      double finiteMax = Double.NEGATIVE_INFINITY;
+      for (Centroid centroid : centroids) {
+        double mean = centroid.mean();
+        double weight = centroid.weight();
+        if (mean == Double.NEGATIVE_INFINITY) {
+          _negativeInfinityWeight += weight;
+        } else if (mean == Double.POSITIVE_INFINITY) {
+          _positiveInfinityWeight += weight;
+        } else {
+          finiteMeans[finiteCount] = mean;
+          finiteWeights[finiteCount++] = weight;
+          finiteMin = Math.min(finiteMin, mean);
+          finiteMax = Math.max(finiteMax, mean);
+        }
+      }
+      if (finiteCount > 0) {
+        finiteMin = Double.isFinite(other.getMin()) ? other.getMin() : 
finiteMin;
+        finiteMax = Double.isFinite(other.getMax()) ? other.getMax() : 
finiteMax;
+        SerializedTDigestInput finiteInput = new SerializedTDigestInput();
+        finiteInput.reset(toVerboseBytes(finiteMin, finiteMax, 
other.compression(),
+            finiteMeans, finiteWeights, finiteCount));

Review Comment:
   **[latent bug] Inconsistent capacity check on a self-built payload.** This 
`reset(byte[])` overload validates with `checkCapacity=true`, while `fromBytes` 
deliberately uses `reset(ByteBuffer.wrap(bytes), false)` for the same kind of 
internally generated finite payload. A third-party SPI `TDigest` with an 
infinite tail (so the finite-extrema branch above is skipped) whose 
`centroids()` view exposes more centroids than 
`getDefaultCentroidCapacity(compression)` -- which the SPI contract explicitly 
permits for working-compression flushes -- makes this throw `TDigest centroid 
count exceeds capacity` and aborts the segment/star-tree build instead of 
merging.
   ```suggestion
           finiteInput.reset(ByteBuffer.wrap(toVerboseBytes(finiteMin, 
finiteMax, other.compression(),
               finiteMeans, finiteWeights, finiteCount)), false);
   ```



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -240,167 +271,128 @@ 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);
+      // Materialize before resetting the reusable reader: materialization 
uses that same reader.
+      materializePendingSerializedTDigest();
+      SerializedTDigestInput input = getSerializedTDigestInput();
+      input.reset(other._pendingSerializedTDigest, 
other._pendingSerializedMetadata);
+      input._retainedBytes = other._pendingSerializedTDigest;
+      addSerializedTDigest(input);
+      return;
+    }
+    if (other._legacyDegraded) {
+      addSerializedTDigest(other.serialize());
       return;
     }
 
-    other.compress();
+    // Legacy merge inputs use public compression. Apply it to a snapshot so 
reading a source leaves its
+    // distribution unchanged, and copy before flushing to preserve the legacy 
single-pass raw merge.
+    other = other.publiclyCompressedMergeInput();
     materializePendingSerializedTDigest();
+    if (_legacyDegraded) {
+      _legacySerializedDirty = true;
+      _totalWeight += other._totalWeight;
+      _min = Math.min(_min, other._min);
+      _max = Math.max(_max, other._max);
+      return;
+    }
+    checkTotalWeight(_totalWeight + _incomingWeight + _numRawValues + 
other._totalWeight);
     preserveSerializedCapacity(other._serializedMainCapacity, 
other._serializedBufferCapacity);
     bufferIncomingCentroids(other._centroidMeans, other._centroidWeights, 
other._numCentroids);
     _min = Math.min(_min, other._min);
     _max = Math.max(_max, other._max);
   }
 
-  void addSerializedTDigest(byte[] bytes) {
-    if (!_hasSerializedInput && _numCentroids == 0 && _totalWeight == 0.0 && 
_numRawValues == 0
-        && _numIncomingCentroids == 0) {
-      byte[] retainedBytes = bytes.clone();
-      _pendingSerializedTotalWeight = getSerializedTotalWeight(retainedBytes);
-      _pendingSerializedTDigest = retainedBytes;
-      _hasSerializedInput = true;
-      return;
-    }
+  private PercentileTDigestAccumulator publiclyCompressedMergeInput() {
+    if (_publiclyCompressed && _numRawValues == 0 && _numIncomingCentroids == 
0) {
+      return this;
+    }
+    PercentileTDigestAccumulator copy =
+        new PercentileTDigestAccumulator(_compression, false, false, 
_useTwoLevelRawCompression);
+    copy._numCentroids = _numCentroids;
+    copy._numRawValues = _numRawValues;
+    copy._numIncomingCentroids = _numIncomingCentroids;
+    copy._centroidMeans = _numCentroids == 0 ? null : 
Arrays.copyOf(_centroidMeans, _numCentroids);
+    copy._centroidWeights = _numCentroids == 0 ? null : 
Arrays.copyOf(_centroidWeights, _numCentroids);
+    copy._rawValues = _numRawValues == 0 ? null : Arrays.copyOf(_rawValues, 
_numRawValues);
+    copy._incomingMeans = _numIncomingCentroids == 0 ? null : 
Arrays.copyOf(_incomingMeans, _numIncomingCentroids);
+    copy._incomingWeights = _numIncomingCentroids == 0 ? null : 
Arrays.copyOf(_incomingWeights, _numIncomingCentroids);
+    copy._serializedMainCapacity = _serializedMainCapacity;
+    copy._serializedBufferCapacity = _serializedBufferCapacity;
+    copy._mergeCount = _mergeCount;
+    copy._publiclyCompressed = _publiclyCompressed;
+    copy._incomingCentroidsSorted = _incomingCentroidsSorted;
+    copy._hasSerializedInput = _hasSerializedInput;
+    copy._hasFractionalWeights = _hasFractionalWeights;
+    copy._totalWeight = _totalWeight;
+    copy._incomingWeight = _incomingWeight;
+    copy._min = _min;
+    copy._max = _max;
+    copy.compress();
+    return copy;
+  }
+
+  public void addSerializedTDigest(byte[] bytes) {
     materializePendingSerializedTDigest();
+    SerializedTDigestInput input = getSerializedTDigestInput();
+    input.reset(bytes);
+    addSerializedTDigest(input);
+  }
+
+  private SerializedTDigestInput getSerializedTDigestInput() {
     if (_serializedTDigestInput == null) {
       _serializedTDigestInput = new SerializedTDigestInput();
     }
-    _serializedTDigestInput.reset(bytes);
-    _serializedTDigestInput.decode();
-    mergeSerializedTDigest(_serializedTDigestInput);
-    _hasSerializedInput = true;
+    return _serializedTDigestInput;
   }
 
-  void addSerializedTDigest(SerializedTDigestInput input) {
-    if (!_hasSerializedInput && _numCentroids == 0 && _totalWeight == 0.0 && 
_numRawValues == 0
-        && _numIncomingCentroids == 0) {
-      _pendingSerializedTDigest = input.retainBytes();
-      _pendingSerializedTotalWeight = Double.NaN;
-      _hasSerializedInput = true;
-      return;
+  public void addSerializedTDigest(SerializedTDigestInput input) {
+    if (prepareSerializedTDigest(input)) {
+      mergeSerializedTDigest(input, false);
     }
-    materializePendingSerializedTDigest();
-    input.decode();
-    mergeSerializedTDigest(input);
-    _hasSerializedInput = true;
   }
 
-  void addSerializedTDigestDirect(SerializedTDigestInput input) {
-    if (!_hasSerializedInput && _numCentroids == 0 && _totalWeight == 0.0 && 
_numRawValues == 0
-        && _numIncomingCentroids == 0) {
+  public void addSerializedTDigestDirect(SerializedTDigestInput input) {
+    if (prepareSerializedTDigest(input)) {
+      mergeSerializedTDigest(input, true);
+    }
+  }
+
+  private boolean prepareSerializedTDigest(SerializedTDigestInput input) {
+    if (!_hasSerializedInput && hasNoInputs()) {
+      input.inspectMetadata();
+      _hasFractionalWeights |= input._metadata.fractionalWeights();
       _pendingSerializedTDigest = input.retainBytes();
-      _pendingSerializedTotalWeight = Double.NaN;
+      _pendingSerializedTotalWeight = input._totalWeight;
+      _pendingSerializedMetadata = input._metadata;
+      _legacyDegraded = input._metadata.needsLegacyFallback();
       _hasSerializedInput = true;
-      return;
+      return false;
     }
     materializePendingSerializedTDigest();
     input.decode();
-    preserveSerializedCapacity(input._mainCapacity, input._bufferCapacity);
-    boolean initialize = _numCentroids == 0 && _totalWeight == 0.0 && 
_numRawValues == 0
-        && _numIncomingCentroids == 0;
-    if (initialize) {
-      if (input._totalWeight != 0.0) {
-        ensureCentroidCapacity(input._numCentroids);
-        System.arraycopy(input._means, 0, _centroidMeans, 0, 
input._numCentroids);
-        System.arraycopy(input._weights, 0, _centroidWeights, 0, 
input._numCentroids);
-        _numCentroids = input._numCentroids;
-        _totalWeight = input._totalWeight;
-        _min = input._min;
-        _max = input._max;
-      }
-    } else {
-      flush();
-      boolean runBackwards = (_mergeCount++ & 1) != 0;
-      if (mergeSorted(input._means, input._weights, input._numCentroids, 
input._totalWeight,
-          getIncrementalCompression(),
-          runBackwards)) {
-        _min = Math.min(_min, input._min);
-        _max = Math.max(_max, input._max);
-      }
+    _hasFractionalWeights |= input._metadata.fractionalWeights();
+    if (_legacyDegraded || input._metadata.needsLegacyFallback()) {
+      mergeLegacyDegradedInput(input, false);
+      return false;
     }
+    checkTotalWeight(_totalWeight + _incomingWeight + _numRawValues + 
input._totalWeight);

Review Comment:
   **[behavior change, please confirm intent] Merging digests whose combined 
mass overflows double now hard-fails the query.** Two individually valid legacy 
digests with totalWeight ~1e308 each pass inspection, but merging them throws 
`IllegalArgumentException("Invalid TDigest total weight: Infinity")` 
(reproduced). Pre-PR the merge completed (with saturated statistics). A hard 
failure may well be the better contract, but it is a new failure mode for 
previously-queryable (if garbage) data -- worth an explicit note in the PR 
description/migration notes.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -638,10 +686,17 @@ public int byteSize() {
     return serialize().length;
   }
 
+  /// Returns the small-encoding size for valid statistics. Historical 
degraded state retains its original encoding
+  /// until modified, then uses verbose bytes so poisoned values and precise 
weights survive without narrowing.
   @Override
   public int smallByteSize() {

Review Comment:
   **[SPI contract] `smallByteSize()`/`asSmallBytes()` can disagree, and 
degraded digests emit VERBOSE bytes under the small-encoding API.** (1) 
`smallByteSize()` returns `30 + 8*n` for any n, but `asSmallBytes` -> 
`toCapacityPreservingBytes` throws `IllegalStateException` for n > 
Short.MAX_VALUE, so a caller following the size-then-write contract allocates 
successfully and then crashes (tdunning silently wrapped the short instead -- 
failing fast is better, but the sizing method should fail too). (2) In 
`_legacyDegraded` state both methods return `serialize()` output, which is 
VERBOSE-encoded (flag 1) under an API whose tdunning contract always emitted 
SMALL_ENCODING (flag 2); a consumer hardcoding the compact layout misparses. No 
in-repo caller is affected -- but this is now the public migration target for 
plugins, so at minimum document both deviations on the SPI 
`TDigest.asSmallBytes`/`smallByteSize` javadoc.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1029,34 +1032,64 @@ private void finishMerge(int numOutputCentroids, double 
totalWeight, double comp
 
   private void ensureIncomingCapacity(int capacity) {
     if (_incomingMeans == null || capacity > _incomingMeans.length) {
-      int newCapacity = _incomingMeans == null ? Math.max(capacity, 
_centroidCapacity)
-          : Math.max(capacity, Math.min(_pendingCentroidCapacity,
-              Math.multiplyExact(_incomingMeans.length, 
DEFAULT_MERGE_BUFFER_MULTIPLIER)));
+      int newCapacity = _incomingMeans == null
+          ? Math.max(capacity, Math.min(_centroidCapacity, 
_pendingCentroidCapacity))
+          : Math.max(capacity, (int) Math.min(_pendingCentroidCapacity,
+              (long) _incomingMeans.length * 
TDigestUtils.DEFAULT_MERGE_BUFFER_MULTIPLIER));
       _incomingMeans = _incomingMeans == null ? new double[newCapacity] : 
Arrays.copyOf(_incomingMeans, newCapacity);
       _incomingWeights =
           _incomingWeights == null ? new double[newCapacity] : 
Arrays.copyOf(_incomingWeights, newCapacity);
     }
   }
 
-  private void ensureIncomingSortCapacity(int capacity) {
-    ensureSortedIncomingCapacity(capacity);
-    if (_incomingOrder == null || capacity > _incomingOrder.length) {
-      int newCapacity = _incomingOrder == null ? Math.max(capacity, 
_centroidCapacity)
-          : Math.max(capacity, Math.multiplyExact(_incomingOrder.length, 2));
-      _incomingOrder = new int[newCapacity];
+  private void stableSortIncoming(int count) {

Review Comment:
   **[efficiency] `stableSortIncoming` replaces tdunning's primitive stable 
sort with fastutil's generic `mergeSort` driven by two capturing lambdas** 
(comparator + swapper), adding per-comparison/per-swap functional-interface 
indirection plus an index-array init on every flush of the incoming buffer (up 
to ~1050 centroids at default compression, recurring on every server-combine / 
broker-reduce / group-by trim merge). Consider a primitive stable sort over an 
index array keyed by the means, or packing (meanBits, index) into a `long[]` 
and using `Arrays.sort`.



-- 
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