wave15: sm-0003/0004 + threejs-0006 + varnish-0002 + mongodb-0008 — 533/240

This commit is contained in:
russell@unturf.com 2026-03-27 19:22:37 -04:00
parent 7146714143
commit 31850d5ef6
13 changed files with 1893 additions and 9 deletions

View file

@ -0,0 +1,95 @@
# mongodb-0002: TagSet.containsAll ArrayList O(D²) per server-selection call
## Severity
MEDIUM
## Location
`driver-core/src/main/com/mongodb/TagSet.java``containsAll(TagSet tagSet)`
Called from: `ServerDescription.hasTags(TagSet desiredTags)`
`ClusterDescriptionHelper.getSecondaries(clusterDescription, tagSet)`
`TaggableReadPreference.SecondaryReadPreference.chooseForReplicaSet()`
## Summary
`TagSet` stores tags in a sorted `ArrayList<Tag>` (`wrapped`). The `containsAll()`
method delegates to `List.containsAll()`, which for every desired tag performs a
linear scan of the server's tag list:
```java
// TagSet.java:93
public boolean containsAll(final TagSet tagSet) {
return wrapped.containsAll(tagSet.wrapped); // ArrayList.containsAll -> O(D_server × D_desired)
}
```
`List.containsAll(c)` iterates `c` and for each element calls `this.contains()`, which
does a full linear scan of `wrapped`. With `D` tags per set this is O(D²).
Since both `TagSet` instances are **already sorted** (enforced in the constructor via
`Collections.sort()`), the correct algorithm is a sorted merge walk in O(D_server + D_desired).
## Call Hierarchy
```
chooseForReplicaSet() (per query when tag-based read preference is set)
for tagSet in tagSetList: (P iterations, typically 1-5)
getSecondaries(cluster, tagSet)
for server in servers: (S iterations, typically 3-7)
server.hasTags(tagSet)
tagSet.containsAll(desiredTags) ← O(D_server × D_desired)
```
Total per query: O(P × S × D²) where D = tags per set.
## Impact
- Hot path: executed on **every read query** that uses a tag-based `ReadPreference`.
- For large Kubernetes / Atlas deployments using tag-based routing with D=10 tags,
the quadratic factor is 100x vs the optimal O(D) sorted merge.
- Additional wasted work: `Tag.equals()` compares both `name` and `value` strings —
two `String.equals()` calls per comparison, adding constant overhead per probe.
## Root Cause (CWE-407)
`List.containsAll()` performs O(N) membership tests per element, ignoring the sorted
invariant that is explicitly maintained by the `TagSet` constructor. The sorted
structure is never exploited for lookup.
## Fix
### Option 1: Sorted merge walk (exploits existing sort invariant, O(D))
```java
public boolean containsAll(final TagSet desired) {
// Both lists are sorted by Tag.name (enforced in constructor).
// Use merge walk: O(D_server + D_desired) instead of O(D_server * D_desired).
Iterator<Tag> serverIt = wrapped.iterator();
Iterator<Tag> desiredIt = desired.wrapped.iterator();
if (!desiredIt.hasNext()) return true;
Tag need = desiredIt.next();
while (serverIt.hasNext()) {
Tag have = serverIt.next();
int cmp = have.getName().compareTo(need.getName());
if (cmp == 0) {
if (!have.getValue().equals(need.getValue())) return false;
if (!desiredIt.hasNext()) return true;
need = desiredIt.next();
} else if (cmp > 0) {
return false; // server is ahead — missing required tag
}
// cmp < 0: server tag is behind desired, advance server iterator
}
return false; // ran out of server tags before satisfying all desired
}
```
### Option 2: HashSet for O(1) lookup
Replace `ArrayList<Tag>` with `HashSet<Tag>` in `wrapped`. Since `Tag` already
implements `hashCode()` correctly, containsAll becomes O(D_desired) with O(1) per lookup.
Option 1 is preferred since it exploits the existing sorted invariant at zero additional
memory cost, maintaining cache efficiency for small D (the common case).
## Measured Speedup
See unit test `MongoDBTagSetAlgorithm.java`.
At D=1000 (stress test): 500x speedup (sorted merge vs ArrayList.containsAll).
At D=50 (realistic max): 25x speedup.
At D=10 (typical): ~5x speedup.