java-topology/defects/pulsar/patch/pulsar-0005-partialroundrobin-cowalist-contains-hashset.md

3 KiB
Raw Blame History

UNDF: UNDF-2026-000000509

pulsar-0005 — PartialRoundRobinMessageRouterImpl CopyOnWriteArrayList.contains O(N×L) → HashSet O(N)

Classification

Field Value
CWE CWE-407 Inefficient Algorithmic Complexity
Severity MEDIUM
Component pulsar-client/src/main/java/org/apache/pulsar/client/impl/customroute/PartialRoundRobinMessageRouterImpl.java
Introduced PartialRoundRobin routing support
Status PATCHED (unit test PASS)

Defect

In getOrCreatePartialList(), when the partial list needs to grow, a stream over all N partition indices filters out those already present in partialList, a CopyOnWriteArrayList<Integer>:

// DEFECTIVE — PartialRoundRobinMessageRouterImpl.java:73-74
} else if (partialList.size() < numPartitionsLimit && partialList.size() < metadata.numPartitions()) {
    partialList.addAll(IntStream.range(0, metadata.numPartitions()).boxed()
            .filter(e -> !partialList.contains(e))   // O(L) per element
            .collect(Collectors.collectingAndThen(Collectors.toList(), list -> {

CopyOnWriteArrayList.contains() is O(L) where L = partialList.size() (up to numPartitionsLimit). The stream visits N elements (N = metadata.numPartitions()). Total: O(N × L).

For a topic with N=1 000 partitions and L=500 already selected, this performs 500 000 comparisons instead of 1 000.

Additionally, partialList is a CopyOnWriteArrayList, which copies the backing array on every add() during the addAll() batch, adding O(L²) write overhead if addAll() is not atomic. In practice addAll() is a single copy, but contains() remains O(L).

Fix

Build a HashSet<Integer> snapshot of partialList before the filter, then use the set for O(1) membership tests:

// FIXED
Set<Integer> existing = new HashSet<>(partialList);
partialList.addAll(IntStream.range(0, metadata.numPartitions()).boxed()
        .filter(e -> !existing.contains(e))   // O(1) per element
        .collect(Collectors.collectingAndThen(Collectors.toList(), list -> {
            Collections.shuffle(list);
            return list.stream();
        })).limit(numPartitionsLimit - partialList.size()).collect(Collectors.toList()));

The snapshot is built once in O(L) and all N filter calls are O(1), giving O(N + L) total.

Complexity

Scenario Before After Ratio
N=100, L=50 O(5 000) O(150) 33×
N=500, L=250 O(125 000) O(750) 167×
N=1 000, L=500 O(500 000) O(1 500) 333×

Speedup (measured)

See unit test PulsarPartialRoundRobinRouterTest.java. Measured ratio ≥ 5× at N=500 (SLOW CopyOnWriteArrayList vs FAST HashSet snapshot).

Affected File

pulsar-client/src/main/java/org/apache/pulsar/client/impl/customroute/PartialRoundRobinMessageRouterImpl.java
  Line 35:  partialList = new CopyOnWriteArrayList<>()  — fine for reads, O(L) contains
  Line 74:  .filter(e -> !partialList.contains(e))      — O(N × L), should snapshot to HashSet first