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


##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -566,22 +613,16 @@ public double quantile(double quantile) {
     for (int i = 0; i < lastIndex; i++) {
       double halfWeight = (_centroidWeights[i] + _centroidWeights[i + 1]) / 
2.0;
       if (weightSoFar + halfWeight > index) {
-        double leftUnitWeight = 0.0;
-        if (_centroidWeights[i] == 1.0) {
-          if (index - weightSoFar < 0.5) {
-            return _centroidMeans[i];
-          }
-          leftUnitWeight = 0.5;
+        double leftExcludedWeight = getPointMassHalfWeight(i);

Review Comment:
   **Bug (reproduced): sub-unit fractional total mass breaks 
`quantile()`/`cdf()`.** The kernel keeps the legacy unit-mass guards (`if 
(index < 1.0) { return _min; }` above, and the `1 - 0.5/_totalWeight` tail 
forms in `cdf`), but the new SPI `add(double value, double weight)` explicitly 
supports fractional mass. Repro: `forReduction(100); add(1.0, 0.3); add(2.0, 
0.3)` (total weight 0.6, two distinct centroids) gives `quantile(0.5) = 
quantile(0.99) = quantile(1.0) = 1.0` — p100 returns the minimum instead of the 
maximum — and a non-monotone CDF: `cdf(1.0)=0.25, cdf(1.5)=0.5, 
cdf(2.0)=0.1667`. Reachable from any stored BYTES payload whose fractional 
weights sum below 1 — the exact shape the new tests pin as supported 
(`testFractionalMassIsNotEmptyDuringAggregationMergeOrFinalExtraction`).



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1287,10 +1383,15 @@ private void ensureOutputCapacity(int capacity) {
     }
   }
 
-  private static double getTotalWeight(double[] weights, int count) {
-    double totalWeight = 0.0;
-    for (int i = 0; i < count; i++) {
-      totalWeight += weights[i];
+  private static void checkTotalWeight(double totalWeight) {
+    if (!(totalWeight >= 0.0) || !Double.isFinite(totalWeight)) {
+      throw new IllegalArgumentException("Invalid TDigest total weight: " + 
totalWeight);
+    }
+  }
+
+  private static double checkedLegacyTotalWeight(double totalWeight) {

Review Comment:
   **Bug (reproduced): the degraded-path weight check permits negative 
totals.** `checkedLegacyTotalWeight` only rejects non-finite values, while 
every valid-statistics path goes through `checkTotalWeight`, which also rejects 
negatives. A historical payload with negative centroid weights (the very 
artifact this fallback exists for) merged via `mergeLegacyDegradedInput` 
silently subtracts mass: a healthy 100-value accumulator merged with a verbose 
payload containing one weight `-200` centroid yields `getTotalWeight() == -100` 
and `size() == -100`, and the negative weight round-trips through 
serialize/deserialize into downstream merges (merging that state into a 
1000-value digest yields 900). Suggest rejecting (or clamping and logging) 
negative legacy totals here as well.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1118,12 +1207,65 @@ private void bufferIncomingCentroids(double[] means, 
double[] weights, int count
   private void materializePendingSerializedTDigest() {
     if (_pendingSerializedTDigest != null) {
       byte[] bytes = _pendingSerializedTDigest;
+      SerializedTDigestMetadata metadata = _pendingSerializedMetadata;
       _pendingSerializedTDigest = null;
-      _pendingSerializedTotalWeight = Double.NaN;
-      mergeSerializedTDigest(bytes);
+      _pendingSerializedMetadata = null;
+      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) {
+    double totalWeight = checkedLegacyTotalWeight(_totalWeight + 
_incomingWeight + _numRawValues + input._totalWeight);
+    if (retainOriginal) {
+      _legacySerializedBytes = input.retainBytes();
+    } else {
+      _legacySerializedDirty = true;
+    }
+    markLegacyDegraded(input);
+    for (int i = 0; i < _numRawValues; i++) {
+      _min = Math.min(_min, _rawValues[i]);
+      _max = Math.max(_max, _rawValues[i]);
+    }
+    _totalWeight = totalWeight;
+    _hasFractionalWeights |= input._metadata.fractionalWeights();
+    _incomingWeight = 0.0;
+    _numIncomingCentroids = 0;
+    _numRawValues = 0;
+    _min = Math.min(_min, input._min);
+    _max = Math.max(_max, input._max);
+    _hasSerializedInput = true;
+  }
+
+  private void markLegacyDegraded(SerializedTDigestInput input) {
+    if (!_legacyDegraded) {
+      LOGGER.warn("Retaining historical TDigest numerical corruption: input 
centroids={}, input weight={}, "
+              + "accumulated weight={}. Percentiles for this aggregation state 
are NaN.",
+          input._numCentroids, input._totalWeight, _totalWeight + 
_incomingWeight + _numRawValues);
+      _legacyDegraded = true;
     }
   }
 
+  private byte[] toLegacyDegradedBytes() {

Review Comment:
   **Rolling-upgrade hazard: `toLegacyDegradedBytes` mints a wire shape 
pre-upgrade readers cannot handle.** Once a degraded digest is mutated, 
`serialize()` emits a synthetic verbose payload with one centroid 
`{weight=totalWeight, mean=NaN}`. An old (pre-PR) node receiving it as an 
intermediate result or stored value either throws — old 
`NonFiniteAwareTDigest.fromBytes`: `"Cannot deserialize a TDigest with a NaN 
centroid mean"`; old re-serialize validation: `"Invalid TDigest centroid"` — or 
boundary-repairs the NaN into a tdunning digest returning garbage. Notably, the 
finite out-of-bounds-mean corruption class (beyond the permitted endpoint 
roundings) worked end-to-end on an old cluster (no bounds check existed), so 
during a rolling upgrade a previously-working query can start failing on 
not-yet-upgraded peers. The class docs only warn about reverse upgrades of 
retained payloads, not this newly minted format.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1194,7 +1292,7 @@ private static int normalizeBoundaries(double[] means, 
double[] weights, int cou
     }
     if (count == 1) {
       double weight = weights[0];
-      if (weight <= 1.0) {
+      if (weight < 2.0) {

Review Comment:
   **Rolling-upgrade hazard (reproduced): a `(1,2)`-weight singleton is 
serialized unsplit and old readers mint a negative-weight centroid.** The guard 
changed from the legacy `weight <= 1.0` to `weight < 2.0`, so `add(42.0, 1.5); 
serialize()` emits a single centroid with weight 1.5 (verified). A pre-PR 
reader's boundary repair then computes `weights[1] = 1.5 - 2.0 = -0.5` 
(verified: `w0=1.0@min, w1=-0.5, w2=1.0@max`) and feeds it to 
`MergingDigest.fromBytes` with no validation — persistent quantile corruption 
on the un-upgraded node; raw tdunning 3.3 consumers hit `assert weight[0] == 1` 
under `-ea`. Only reachable for fractional-weight SPI users (in-repo writers 
stay integral), but the SPI documents fractional mass as supported. Consider 
splitting/widening these singletons at serialization time, or materializing 
them via the same path used when `weightedBoundaries` is true.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1118,12 +1207,65 @@ private void bufferIncomingCentroids(double[] means, 
double[] weights, int count
   private void materializePendingSerializedTDigest() {
     if (_pendingSerializedTDigest != null) {
       byte[] bytes = _pendingSerializedTDigest;
+      SerializedTDigestMetadata metadata = _pendingSerializedMetadata;
       _pendingSerializedTDigest = null;
-      _pendingSerializedTotalWeight = Double.NaN;
-      mergeSerializedTDigest(bytes);
+      _pendingSerializedMetadata = null;
+      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) {
+    double totalWeight = checkedLegacyTotalWeight(_totalWeight + 
_incomingWeight + _numRawValues + input._totalWeight);
+    if (retainOriginal) {
+      _legacySerializedBytes = input.retainBytes();
+    } else {
+      _legacySerializedDirty = true;
+    }
+    markLegacyDegraded(input);
+    for (int i = 0; i < _numRawValues; i++) {
+      _min = Math.min(_min, _rawValues[i]);
+      _max = Math.max(_max, _rawValues[i]);
+    }
+    _totalWeight = totalWeight;
+    _hasFractionalWeights |= input._metadata.fractionalWeights();
+    _incomingWeight = 0.0;
+    _numIncomingCentroids = 0;
+    _numRawValues = 0;
+    _min = Math.min(_min, input._min);
+    _max = Math.max(_max, input._max);
+    _hasSerializedInput = true;
+  }
+
+  private void markLegacyDegraded(SerializedTDigestInput input) {

Review Comment:
   **Silent NaN propagation is persisted and unpinned.** Intentional per the 
class docs, but two consequences are untested: (1) minion merge-rollup over one 
historically corrupted digest now absorbs the healthy group's weight into 
degraded state and persists the NaN payload into the rolled-up segment — where 
the base commit failed the task loudly (`makeLegacyCompatible` threw) — and no 
merge-rollup test covers the degraded path; (2) `SerializedTDigest.compareTo` 
uses `Double.compare` on `quantile()`, so a degraded digest's NaN now sorts 
above every finite value in `PERCENTILERAWTDIGEST ... ORDER BY`, and no test 
pins that placement. Worth a merge-rollup degraded-path test and an explicit 
decision on NaN ordering.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -240,167 +286,134 @@ public void add(List<? extends TDigest> others) {
   }
 
   private void addAccumulator(PercentileTDigestAccumulator other) {
+    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) {
+      double totalWeight = checkedLegacyTotalWeight(_totalWeight + 
other._totalWeight);
+      _legacySerializedDirty = true;
+      _totalWeight = totalWeight;
+      _hasFractionalWeights |= other._hasFractionalWeights;
+      _min = Math.min(_min, other._min);
+      _max = Math.max(_max, other._max);
+      return;
+    }
+    checkTotalWeight(_totalWeight + _incomingWeight + _numRawValues + 
other._totalWeight);
+    _hasFractionalWeights |= other._hasFractionalWeights;
     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() {

Review Comment:
   **Perf: every accumulator-to-accumulator merge deep-copies the source.** 
`addAccumulator` calls `publiclyCompressedMergeInput()`, which copies up to 
five arrays (centroids, raw values, incoming buffers) and runs a full K1 
recompression on the copy — per segment-level merge in the combine phase — 
purely to avoid mutating `other`, which the combine operator discards 
immediately afterwards. tdunning's `MergingDigest.add(other)` compressed 
`other` in place with zero allocation. Consider consuming-merge semantics 
(flush/compress `other` in place) or merging directly from its flushed arrays.



##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -638,10 +702,44 @@ public int byteSize() {
     return serialize().length;

Review Comment:
   **Perf: `byteSize()` performs a complete serialization just to return a 
length, so the documented `byteSize()`-then-`asBytes()` pattern serializes 
every digest twice** (each pass allocating the full verbose array plus a 
possible compact re-encode; old `MergingDigest.byteSize()` was O(1)). Consider 
caching the bytes from `byteSize()` for the subsequent `asBytes()`, or 
computing the size arithmetically after a single `compress()`.



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