Skip to content

Fix DistributionData percentile sketch data race (AIOOBE) and partial-read deserialization bug#169

Open
Zhangyx39 wants to merge 1 commit into
li_trunkfrom
yixzhang/fix-distributiondata-sketch-race
Open

Fix DistributionData percentile sketch data race (AIOOBE) and partial-read deserialization bug#169
Zhangyx39 wants to merge 1 commit into
li_trunkfrom
yixzhang/fix-distributiondata-sketch-race

Conversation

@Zhangyx39

Copy link
Copy Markdown

Summary

DistributionData (LI Beam fork) holds a single mutable, non-thread-safe DataSketches UpdateDoublesSketch and mutated it in place with no synchronization. The per-record writer (DistributionCell.update on the task/mailbox thread) races concurrent readers:

  • percentiles() / extractResult() during metric extraction (the AIOOBE site),
  • writeObject() during accumulator serialization (TM→JM),
  • combine(DistributionData) during JobManager accumulator merge.

A read (getQuantiles, which sizes a destination array from getN() then copies) concurrent with a write (update, which grows the sketch) overruns the array and throws:

java.lang.ArrayIndexOutOfBoundsException: Index N out of bounds for length N
    at org.apache.datasketches.quantiles.DoublesAuxiliary.populateFromDoublesSketch(...)
    at org.apache.datasketches.quantiles.DoublesSketch.getQuantiles(...)

DataSketches UpdateDoublesSketch is documented single-threaded, so this is a caller-side concurrency bug, independent of the datasketches version.

Fix — snapshot-under-lock

  • Add a private final ReentrantLock lock guarding all sketch/primitive access. ReentrantLock (rather than synchronized on a field) is used because it is final + Serializable: it survives the TM→JM accumulator round-trip and always deserializes unlocked, so no transient re-init is needed. (Also satisfies ErrorProne SynchronizeOnNonFinalField under -Werror.)
  • Writers guarded (O(1)/O(sketch)): update, combine(long), combine(long,long,long,long), combine(DistributionData), reset.
  • Reader percentiles(): copies the bounded (k=256) sketch under the lock, then runs the expensive O(n) getQuantiles on the immutable snapshot outside the lock — so per-record writers never block on the quantile computation.
  • combine(DistributionData): snapshots other under its own lock first (copying sketch bytes, not aliasing) → deadlock-free, no shared mutable sketch.
  • writeObject guarded; sum/count/min/max getters guarded for consistent snapshots.

Additional fix (tightly coupled)

readObject used InputStream.read(byte[]) (reads up to len bytes), which returned a partial sketch image when the array spanned the stream buffer — corrupting the deserialized sketch and yielding wrong percentiles after the TM→JM transfer. Switched to readFully. This was caught by the new serialization parity test.

Testing

  • Concurrency stress test (writers + readers + serializers): reproduces the exact ArrayIndexOutOfBoundsException (DoublesAuxiliary.populateFromDoublesSketchgetQuantiles) on the pre-fix code and passes with the fix — the regression guard.
  • Serialization round-trip + populated/empty percentile + combine parity tests.
  • All 8 DistributionDataTest tests pass; Spotless/ErrorProne clean. Pre-existing unrelated failures in DistributionCellTest/MetricsContainerImplTest were confirmed present on the untouched baseline.

Performance

Write path adds one uncontended monitor acquire (nanoseconds, lock-elidable). Read/serialize path holds the lock only for a bounded few-KB toByteArray() copy, not the O(n) getQuantiles. No measurable throughput regression expected — the fix removes a crash, not adds a bottleneck.

…-read deserialization bug

DistributionData holds a single mutable, non-thread-safe DataSketches
UpdateDoublesSketch and mutated it in place with no synchronization. The
per-record writer (DistributionCell.update on the task/mailbox thread) races
concurrent readers -- percentiles()/extractResult() during metric extraction
and writeObject() during accumulator serialization (TM->JM), plus combine()
during JobManager merge. A read (getQuantiles, which sizes a destination array
from getN() then copies) concurrent with a write (update, which grows the
sketch) overruns the array and throws
ArrayIndexOutOfBoundsException in DoublesAuxiliary.populateFromDoublesSketch.

Fix with snapshot-under-lock: guard all sketch/primitive access with a final
ReentrantLock, but keep the expensive O(n) getQuantiles outside the lock by
copying the bounded (k=256) sketch under the lock and computing on the
immutable snapshot. A ReentrantLock (rather than synchronized on a field) is
used because it is final and Serializable -- it survives the accumulator
round-trip and deserializes unlocked, so no transient re-initialization is
needed. combine(DistributionData) snapshots the other instance under its own
lock first to stay deadlock-free and avoid aliasing a mutable sketch.

Also fix a tightly-coupled deserialization bug: readObject used
InputStream.read(byte[]) (reads up to len bytes) which returned a partial
sketch image when the array spanned the stream buffer, corrupting the
deserialized sketch and yielding wrong percentiles after TM->JM transfer.
Switched to readFully.

Add regression tests: a concurrency stress test (writers + readers +
serializers) that reproduces the AIOOBE on the old code and passes with the
fix, plus serialization round-trip and combine parity tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant