Cache WAGED partition-movement state maps#205
Conversation
| } | ||
|
|
||
| /** | ||
| * Returns the (instanceName -> state) map for the given partition from the baseline assignment. |
There was a problem hiding this comment.
Nit: -> should be -> As in above code I see -> has been used. Or for javadoc, let us make it uniform.
| // <ResourceName, ResourceAssignment contains the best possible assignment> | ||
| private final Map<String, ResourceAssignment> _bestPossibleAssignment; | ||
| // Memoized per-(resource, partition) instance->state views derived from the immutable baseline | ||
| // and best possible assignments above. These lookups are invariant for the whole rebalance run, |
There was a problem hiding this comment.
Nit: "and best possible assignments above." If someone adds/removes/moves the L64 in future, "above" statement will be confusing and wrong.
| } | ||
|
|
||
| /** | ||
| * Returns the (instanceName -> state) map for the given partition from the best possible |
There was a problem hiding this comment.
Same as above comment.
| if (resourceAssignment == null) { | ||
| return Collections.emptyMap(); | ||
| } | ||
| return cache.computeIfAbsent(resourceName, key -> new ConcurrentHashMap<>()) |
There was a problem hiding this comment.
Suggestion: Right now every lookup goes through computeIfAbsent lazily. Can we build the maps once and use getOrDefault instead?
Like:
// built once, never mutated after construction, so no concurrent map needed
private final Map<String, Map<String, Map<String, String>>> _baselineAssignmentStateMap;
private final Map<String, Map<String, Map<String, String>>> _bestPossibleAssignmentStateMap;
In the constructor, after _baselineAssignment / _bestPossibleAssignment are set:
_baselineAssignmentStateMap = buildAssignmentStateMap(getBaselineAssignment());
_bestPossibleAssignmentStateMap = buildAssignmentStateMap(getBestPossibleAssignment());
Helpers:
private static Map<String, Map<String, Map<String, String>>> buildAssignmentStateMap(
Map<String, ResourceAssignment> assignment) {
Map<String, Map<String, Map<String, String>>> result = new HashMap<>();
for (Map.Entry<String, ResourceAssignment> entry : assignment.entrySet()) {
ResourceAssignment resourceAssignment = entry.getValue();
Map<String, Map<String, String>> byPartition = new HashMap<>();
for (String partitionName : resourceAssignment.getMappedPartitions().stream()
.map(Partition::getPartitionName).collect(Collectors.toList())) {
byPartition.put(partitionName,
resourceAssignment.getReplicaMap(new Partition(partitionName)));
}
result.put(entry.getKey(), byPartition);
}
return result;
}
And rewrite like:
public Map<String, String> getBaselineAssignmentStateMap(String resourceName, String partitionName) {
return _baselineAssignmentStateMap.getOrDefault(resourceName, Collections.emptyMap())
.getOrDefault(partitionName, Collections.emptyMap());
}
There was a problem hiding this comment.
I looked at building the maps eagerly in the constructor. The catch is that the (resource, partition) keys actually scored are only those in toBeAssignedReplicas , which is computed later in ConstraintBasedAlgorithm and isn't available at ClusterContext construction. So an eager build would materialize every partition of the full baseline + best-possible assignment — including the majority never scored in a partial rebalance, which is the hot path this PR targets. Lazy computeIfAbsent gets that selectivity for free by populating on demand. I've kept lazy but hardened it: wrapped the returned view in unmodifiableMap and added a 16-thread concurrent test to lock in the thread-safety invariant
Let me know if you want to discuss this!
There was a problem hiding this comment.
Got it. Thanks for clarification!
| new HashMap<>(), ImmutableMap.of(resourceName, bestPossibleAssignment)); | ||
|
|
||
| // Repeated lookups for the same partition must resolve the underlying map only once. | ||
| for (int i = 0; i < 5; i++) { |
There was a problem hiding this comment.
The test loops 5 times on a single thread and asserts getReplicaMap was called once. That proves the cache works. Test should also cover many threads hitting the same key at once inside parallelStream. So a future refactor (say a plain HashMap ) would pass.
Something like:
@Test
public void testAssignmentStateMapMemoizationIsThreadSafe() throws Exception {
ResourceControllerDataProvider testCache = setupClusterDataCache();
Set<AssignableReplica> assignmentSet = generateReplicas(testCache);
String resourceName = _resourceNames.get(0);
String partitionName = _partitionNames.get(0);
Map<String, String> stateMap = ImmutableMap.of("instance0", "MASTER");
ResourceAssignment bestPossibleAssignment = mock(ResourceAssignment.class);
when(bestPossibleAssignment.getReplicaMap(new Partition(partitionName))).thenReturn(stateMap);
ClusterContext context = new ClusterContext(assignmentSet, generateNodes(testCache),
new HashMap<>(), ImmutableMap.of(resourceName, bestPossibleAssignment));
int threads = 16;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
List<Future<Map<String, String>>> results = new ArrayList<>();
for (int i = 0; i < threads; i++) {
results.add(pool.submit(() -> {
start.await();
return context.getBestPossibleAssignmentStateMap(resourceName, partitionName);
}));
}
start.countDown();
for (Future<Map<String, String>> f : results) {
Assert.assertEquals(f.get(), stateMap);
}
pool.shutdown();
// still resolved exactly once despite concurrent first-access
verify(bestPossibleAssignment, times(1)).getReplicaMap(new Partition(partitionName));
}
There was a problem hiding this comment.
made the tests cover concurrent scenarios
kabragaurav
left a comment
There was a problem hiding this comment.
LGTM! Thanks for addressing comments.
|
CI is failing, but that is due to other failure unrelated to this PR, as Copilot tells. |
The partition-movement soft constraints looked up the baseline and best possible instance-to-state map for a replica once per candidate node during scoring. That result is node-independent, so the same map was recomputed for every node, each time allocating a throwaway Partition key. Memoize these per-(resource, partition) lookups on ClusterContext, which already owns the immutable baseline and best possible assignments for the rebalance run, so each map is resolved once and reused across all nodes. BaselineInfluenceConstraint and PartitionMovementConstraint now read the cached accessors, and the now-unused getStateMap helper is removed from AbstractPartitionMovementConstraint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Wrap cached assignment state maps in Collections.unmodifiableMap. getReplicaMap returns the ResourceAssignment's live internal ZNRecord map (not a copy), so an unwrapped return let a caller mutate both the cache and the underlying baseline/ best-possible assignment. Consumers are read-only, so the view is safe and O(1). - Add a 16-thread concurrent memoization test that races on the same (resource, partition) key to guard the ConcurrentHashMap thread-safety invariant; the existing single-threaded loop would pass even without thread safety. - Doc nits: name the _baselineAssignment/_bestPossibleAssignment fields instead of a positional "above" reference, and use -> (not ->) in the getter javadocs for consistency with the field comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
6dfe864 to
410f8f9
Compare
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Issues
No dedicated GitHub issue was filed; this is a self-contained performance optimization in the WAGED rebalancer's scoring hot path. Happy to open a tracking issue if maintainers prefer.
Description
What: Memoize the per-(resource, partition) instance-to-state map lookups used by the WAGED partition-movement soft constraints, caching them on
ClusterContext.Why: While scoring a replica,
ConstraintBasedAlgorithmevaluates every candidate node in aparallelStream. For each node,PartitionMovementConstraintandBaselineInfluenceConstraintcalledgetStateMap(replica, ...), which looked up the baseline and best-possibleResourceAssignmentfor the replica's (resource, partition). That result is independent of the candidate node, so the identical map was recomputed once per node — and each call allocated a throwawaynew Partition(partitionName)key. For a cluster with N assignable nodes this repeated the same work N times per replica per constraint.How:
ClusterContextalready owns the immutable baseline and best-possible assignments for the whole rebalance run. It now exposesgetBaselineAssignmentStateMap(resource, partition)andgetBestPossibleAssignmentStateMap(resource, partition), backed byConcurrentHashMapcaches (safe under the parallel scoring stream), so each map is resolved exactly once and reused across all nodes. The two movement constraints read these accessors, and the now-unusedgetStateMaphelper is removed fromAbstractPartitionMovementConstraint. Behavior is unchanged: absent resources/partitions still returnCollections.emptyMap().Tests
The following tests are written for this issue:
TestClusterContext#testAssignmentStateMapLookup— verifies the new accessors return the correct instance-to-state map, and an empty map for a missing resource / missing partition / empty assignment.TestClusterContext#testAssignmentStateMapMemoization— verifies a partition's underlyinggetReplicaMapis resolved only once across repeated lookups (proving memoization).TestPartitionMovementConstraint— updated to stub the new cached accessors; existing assertions and expected scores are unchanged.Local code review completed
The following is the result of the "mvn test" command on the appropriate module:
Maven is not available in my local environment, so
mvn testwas not run locally; relying on the project CI to execute thehelix-coretest suite. The change is confined tohelix-coreand is covered by the unit tests listed above.Changes that Break Backward Compatibility (Optional)
None.
getStateMapwas a package-private helper on the package-private abstract classAbstractPartitionMovementConstraintwith no external callers; the newClusterContextaccessors are purely additive. No public API or behavior changes.Documentation (Optional)
Not applicable — internal performance optimization with no new user-facing functionality.
Commits
Code Quality
🤖 Generated with GitHub Copilot CLI