Visitar URL original
Make broker routing components update incrementally on assignment changes by gortiz · Pull Request #19783 · apache/pinot · GitHub
Skip to content

Make broker routing components update incrementally on assignment changes - #19783

Open
gortiz wants to merge 3 commits into
apache:masterfrom
gortiz:routing-a-components
Open

gortiz wants to merge 3 commits into
apache:masterfrom
gortiz:routing-a-components

Conversation

@gortiz

@gortiz gortiz commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

PR flow

Broker routing components now update incrementally, recomputing only when inputs change and sharing partition functions/sets.

flowchart TD
  N0["SegmentPartitionMetadataManager#46;onAssignmentChange #40;F1#41;"]:::stModified
  N1["Detect changes #40;partition id#47;creation time#44; online servers#44; segment removal#41; #40;F1#41;"]:::stModified
  N2["ComputeTablePartitionInfo #40;F1#41;"]:::stModified
  N3["ComputeTablePartitionReplicatedServersInfo #40;F1#41;"]:::stModified
  N4["TimeBoundaryManager#46;onAssignmentChange #40;F3#41;"]:::stModified
  N5["Batch read ZK metadata for new segments #40;F3#41;"]:::stModified
  N6["Update end time map and max end time incrementally #40;F3#41;"]:::stModified
  N7["UpdateTimeBoundaryInfo #40;F3#41;"]:::stModified
  N8["SegmentPartitionUtils#46;extractPartitionInfo #40;F2#41;"]:::stModified
  N9["getPartitionFunction #40;cache lookup#47;creation#41; #40;F2#41;"]:::stModified
  N10["getPartitions #40;shared singleton#41; #40;F2#41;"]:::stModified
  N0 -->|"triggers detection"| N1
  N1 -->|"if partitions changed"| N2
  N1 -->|"if online servers changed or expiration"| N3
  N4 -->|"initiates batch ZK read"| N5
  N5 -->|"updates end times and max"| N6
  N6 -->|"updates time boundary info"| N7
  N8 -->|"gets partition function"| N9
  N8 -->|"gets partition set"| N10
  classDef stAdded fill:#dafbe1,stroke:#1a7f37,color:#1f2328,stroke-width:2px
  classDef stModified fill:#fff8c5,stroke:#9a6700,color:#1f2328,stroke-width:2px
  classDef stRemoved fill:#ffebe9,stroke:#cf222e,color:#1f2328,stroke-width:2px
  classDef stUnchanged fill:#f6f8fa,stroke:#656d76,color:#1f2328,stroke-width:1px
Loading

AI-generated · Green: added · Yellow: modified · Red: removed · Gray: existing

Diff evidence
  • F1: pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionMetadataManager.java — before · after
  • F2: pinot-broker/src/main/java/org/apache/pinot/broker/routing/segmentpartition/SegmentPartitionUtils.java — before · after
  • F3: pinot-broker/src/main/java/org/apache/pinot/broker/routing/timeboundary/TimeBoundaryManager.java — before · after
  • Regenerate PR flow

Summary

Part of #19782.

This PR makes three broker routing components do less work on each ideal state or external view (IS/EV) change. SegmentPartitionMetadataManager recomputes its published partition info only when its inputs change. TimeBoundaryManager reads the ZK metadata of new segments in one batch and keeps the max end time up to date incrementally. SegmentPartitionUtils shares one PartitionFunction and one immutable one-element partition set across segments with equal metadata.

Each component compares the new inputs with its writer-private state, and builds a new published object only when the result can be different. Most of the cost in #19782 comes from the IS/EV parse and the instance selector. These three components are a small part of it.

Changes

1. SegmentPartitionMetadataManager: recompute only when inputs change

Before, onAssignmentChange() did two full passes over all segments on each change. Now it finds what changed and recomputes only what depends on it:

Change TablePartitionInfo TablePartitionReplicatedServersInfo
A segment is added or removed, or a partition id or creation time changes recomputed recomputed
Only the online servers of a segment change kept recomputed
A new segment stops being new (see below) kept recomputed
Nothing relevant changes kept kept
  • A pulled segment counts as a change only when it is new, or its partition id or creation time is different. Segments without ZK metadata are pulled on each change, so without this check every change counts.
  • hasSameOnlineServers() compares without allocation. Equal lists of online servers are shared through a per-call cache.

New-segment expiry. TablePartitionReplicatedServersInfo treats a segment as "new" for a configured time after its creation. Before, every change recomputed the object and so evaluated the clock. Now the compute step stores the earliest expiration time of the new segments in _nextNewSegmentExpirationTimeMs (Long.MAX_VALUE if none). If the current time is past it, the next change recomputes the object. Thus a new segment still becomes visible on the first change after it expires.

2. TimeBoundaryManager: batched ZK reads and incremental max end time

  • One ZkHelixPropertyStore.get(List, ...) call reads the ZK metadata of all new ONLINE segments, as init() and SegmentZkMetadataFetcher already do. It returns null for a missing node and for a failed read. Then the manager does not record the segment, and the next change reads it again.
  • Trade-off: a segment whose ZK metadata is missing for good is read again on every change, with a WARN log each time. Before this PR, the exception path also read it again on every change.
  • The writer-private _maxEndTimeMs holds the max of _endTimeMsMap. An added segment updates it with Math.max(). A removed segment causes a full scan only when it held the max. refreshSegment() causes a full scan only when the segment held the max and its end time decreased.
  • updateTimeBoundaryInfo() still runs on each change. Thus a change of the explicit time boundary in the ideal state still takes effect.

3. SegmentPartitionUtils: share partition functions and partition sets

  • One PartitionFunction is shared per (function name, number of partitions, function config) through a static ConcurrentHashMap. The cache holds at most 1024 entries. Past that limit, functions are created and not cached.
  • A one-element partition set becomes a shared immutable IntSets.singleton() for partition ids less than 1024. All callers only read the partition sets.
  • The PartitionFunction contract already requires stateless implementations that are safe for concurrent use. The query path already calls one instance from many threads.
  • SinglePartitionColumnSegmentPruner compares functions with equals() and keys a HashMap by function. Shared instances only help there through the usual this == o fast path.

Concurrency

This PR keeps the concurrency rule of #19782.

  • Published: TablePartitionInfo, TablePartitionReplicatedServersInfo and TimeBoundaryInfo, swapped with one volatile write as before. When nothing changes, the old object stays published.
  • Writer-private: _segmentInfoMap, _nextNewSegmentExpirationTimeMs, _endTimeMsMap and _maxEndTimeMs. synchronized methods serialize the writers, as before.
  • Shared: the lists of online servers are unmodifiable, and the recompute code copies them before any change. Partition functions and sets are stateless or immutable.

Tests

  • SegmentPartitionMetadataManagerTest.testRecomputeOnlyOnChange: equal inputs keep both objects, an online server change replaces only TablePartitionReplicatedServersInfo, and a removed segment replaces both. ONLINE to CONSUMING is not a change. A segment without ZK metadata does not cause a recompute on each change.
  • SegmentPartitionMetadataManagerTest.testNewSegmentExpiresOnNoOpChange: a new segment that misses a replica is excluded. After it expires, a no-op change includes it. After that, a no-op change keeps the published object.
  • TimeBoundaryManagerTest.testBatchedReadsAndIncrementalMaxEndTime: two added segments cause one batched read. A change with no new segment does no ZK read. A removal or a refresh moves the time boundary back only when it affects the max end time. A new segment without ZK metadata is read again on the next change.
  • SegmentPartitionUtilsTest (new): sharing for equal metadata, distinct functions for a different name, number of partitions or config, and multi-partition sets kept as they are.

All tests under org.apache.pinot.broker.routing pass (19 classes, 257 tests, 0 failures, 0 skipped).

Measured effect

TimeBoundaryManager runs only for the offline side of a hybrid table. We replayed the segments of a real 380k-segment table as the OFFLINE side of a hybrid table. Per change, TimeBoundaryManager went from 22 MB and about 440 ms to 14 MB and about 40 ms.

Compatibility

There is no change to configs, wire formats, or public APIs. All changed code is broker-internal.

gortiz added 3 commits October 7, 2026 10:18
SegmentPartitionMetadataManager.onAssignmentChange() rebuilt the online-server
list of every segment and then ran computeAllTablePartitionInfo(), two full
passes over all segments, on every ideal state or external view change of the
table, even when nothing relevant to the partition info changed.

The method now detects changes and recomputes only what depends on them:
- A pulled segment counts as a change only when it is new or its partition id
  or creation time differs. Segments without ZK metadata are pulled again on
  every change, so this avoids a spurious change for them.
- hasSameOnlineServers() compares the stored online-server list with the
  external view instance state map without allocating. A new list is built
  only for segments whose online servers changed, and equal lists are shared
  through a per-call cache instead of one list per segment.
- A change of the segment set or of a partition id recomputes both
  TablePartitionInfo and TablePartitionReplicatedServersInfo. A change of the
  online servers recomputes only TablePartitionReplicatedServersInfo, since
  TablePartitionInfo does not depend on them.

The rebuild is also the clock for new segments: newness is re-evaluated only
when TablePartitionReplicatedServersInfo is computed. To keep that behavior,
computeTablePartitionReplicatedServersInfo() records the earliest expiration
time among the new segments in a writer-private field, and an otherwise no-op
change still recomputes once that time has passed. A new segment that was
excluded therefore becomes visible on the first change after it expires, as
before.

Published objects are still replaced with a single volatile write and never
mutated, so the query path is unaffected.
TimeBoundaryManager.onAssignmentChange() read the ZK metadata of each new
ONLINE segment with its own ZkHelixPropertyStore.get() call, and scanned the
whole end time map to find the max end time on every ideal state or external
view change, even when no segment was added or removed.

onAssignmentChange() now collects the paths of the new ONLINE segments and
reads them with a single batched ZkHelixPropertyStore.get(List, ...), the same
call init() and SegmentZkMetadataFetcher use.
A segment whose record comes back null (missing node or failed read) is not
recorded, so the next change reads it again, as the per-segment read did on a
read failure.

The max end time is kept in the writer-private field _maxEndTimeMs, in sync
with _endTimeMsMap:
- Added segments raise it with Math.max().
- Removed segments trigger a full scan only when one of them holds the
  current max end time.
- refreshSegment() triggers a full scan only when the refreshed segment held
  the max end time and its end time moved back.

The time boundary is still derived on every change through
updateTimeBoundaryInfo(), so explicitly set time boundary changes in the ideal
state keep being picked up. The published TimeBoundaryInfo is unchanged: it
is still replaced with a single volatile write.
SegmentPartitionUtils.extractPartitionInfo() and extractPartitionInfoMap()
created a new PartitionFunction through PartitionFunctionFactory for every
segment, and kept the IntOpenHashSet deserialized from the segment ZK metadata
as the partition set of every segment. All segments of a table usually carry
the same partition function metadata and a single partition, so a large table
held one function instance and one hash set per segment, and every routing
build created them again.

SegmentPartitionUtils now:
- Shares one PartitionFunction per (function name, number of partitions,
  function config) through a static cache. The PartitionFunction contract
  requires implementations to be stateless and safe for concurrent use, and
  the query path already calls one instance from many threads, so sharing it
  across segments adds no new requirement. The cache stops growing at 1024
  entries; past that, functions are created without being cached.
- Replaces a one-element partition set with a shared immutable
  IntSets.singleton() for partition ids below 1024. Callers only read the
  partition sets.

Shared function instances also let the prepared partition pruning in
SinglePartitionColumnSegmentPruner match functions by identity first.

SegmentPartitionInfo is used by SegmentPartitionMetadataManager,
SinglePartitionColumnSegmentPruner and MultiPartitionColumnsSegmentPruner, so
all three benefit.
@gortiz gortiz added the performance Related to performance optimization label Oct 7, 2026
@codecov-commenter

codecov-commenter commented Oct 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.49%. Comparing base (43ecf19) to head (54f9d43).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
...mentpartition/SegmentPartitionMetadataManager.java 80.39% 7 Missing and 3 partials ⚠️
...outing/segmentpartition/SegmentPartitionUtils.java 84.61% 1 Missing and 3 partials ⚠️
...oker/routing/timeboundary/TimeBoundaryManager.java 97.67% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19783      +/-   ##
============================================
+ Coverage     68.47%   68.49%   +0.01%     
  Complexity     1470     1470              
============================================
  Files          3522     3522              
  Lines        229423   229511      +88     
  Branches      36431    36456      +25     
============================================
+ Hits         157101   157206     +105     
+ Misses        60019    60007      -12     
+ Partials      12303    12298       -5     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 68.49% <87.50%> (+0.01%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 68.49% <87.50%> (+0.01%) ⬆️
unittests 68.49% <87.50%> (+0.01%) ⬆️
unittests1 58.25% <ø> (+<0.01%) ⬆️
unittests2 40.11% <87.50%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Related to performance optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants