Repository: pantsbuild/pants

Issue:
pants.util.frozendict.FrozenDict has error prone order-sensitive comparisons
**Describe the bug**

The `FrozenDict` type is order sensitive, which differs to a normal `dict`. It also gives `False` when comparing to a normal dict. This makes it easy to get unexpected behaviour, and likely leads to unnecessary cache misses.

https://github.com/pantsbuild/pants/blob/57d1801c80f8273ca386fba198495a05a45f60ae/src/python/pants/util/frozendict.py#L15-L21

```python
d1 = {'a': 0, 'b': 1}
d2 = {'b': 1, 'a': 0}
print(d1 == d2) # True
print(FrozenDict(d1) == FrozenDict(d2)) # False

print(FrozenDict(d1) == d1) # False
```

NB. this seems to apply to all methods that call `tuple(self.items())` or similar: `__eq__`, `__lt__`, `_calculate_hash`/`__hash__`.

**Pants version**

`main`

**OS**

N/A

**Additional info**

This caused half of #20210, see #20220.


Discussion:

Resolving patch:
diff --git a/src/python/pants/util/frozendict.py b/src/python/pants/util/frozendict.py
--- a/src/python/pants/util/frozendict.py
+++ b/src/python/pants/util/frozendict.py
@@ -85,19 +85,27 @@ def __iter__(self) -> Iterator[K]:
     def __reversed__(self) -> Iterator[K]:
         return reversed(tuple(self._data))
 
-    def __eq__(self, other: Any) -> bool:
-        if not isinstance(other, FrozenDict):
-            return NotImplemented
-        return tuple(self.items()) == tuple(other.items())
+    def __eq__(self, other: Any) -> Any:
+        # defer to dict's __eq__
+        return self._data == other
 
     def __lt__(self, other: Any) -> bool:
         if not isinstance(other, FrozenDict):
             return NotImplemented
-        return tuple(self._data.items()) < tuple(other._data.items())
+        # If sorting each of these on every __lt__ call ends up being a problem we could consider
+        # optimising this, by, for instance, sorting on construction.
+        return sorted(self._data.items()) < sorted(other._data.items())
 
     def _calculate_hash(self) -> int:
         try:
-            return hash(tuple(self._data.items()))
+            h = 0
+            for pair in self._data.items():
+                # xor is commutative, i.e. we get the same hash no matter the order of items. This
+                # "relies" on "hash" of the individual elements being unpredictable enough that such
+                # a naive aggregation is okay. In addition, the Python hash isn't / shouldn't be
+                # used for cryptographically sensitive purposes.
+                h ^= hash(pair)
+            return h
         except TypeError as e:
             raise TypeError(
                 softwrap(


Test patch:
diff --git a/src/python/pants/util/frozendict_test.py b/src/python/pants/util/frozendict_test.py
--- a/src/python/pants/util/frozendict_test.py
+++ b/src/python/pants/util/frozendict_test.py
@@ -92,24 +92,41 @@ def test_eq() -> None:
     fd1 = FrozenDict(d1)
     assert fd1 == fd1
     assert fd1 == FrozenDict(d1)
-    # Order matters.
-    assert fd1 != FrozenDict({"b": 1, "a": 0})
-    # Must be an instance of FrozenDict.
-    assert fd1 != d1
-
+    # Order doesn't matter.
+    assert fd1 == FrozenDict({"b": 1, "a": 0})
+    # A FrozenDict is equal to plain `dict`s with equivalent contents.
+    assert fd1 == d1
+    assert d1 == fd1
 
-@pytest.mark.xfail(reason="FrozenDict equality broken for different insertion orders.")
-def test_eq_different_orders() -> None:
-    fd1 = FrozenDict({"a": 0, "b": 1})
-    fd2 = FrozenDict({"b": 1, "a": 0})
-    assert fd1 == fd2
+    # A different dict is not equal.
+    d2 = {"a": 1, "b": 0}
+    fd2 = FrozenDict(d2)
+    assert fd2 != fd1
+    assert fd1 != d2
+    assert d2 != fd1
 
 
 def test_lt() -> None:
     d = {"a": 0, "b": 1}
-    assert FrozenDict(d) < FrozenDict({"a": 1, "b": 2})
-    # Order matters.
-    assert FrozenDict(d) < FrozenDict({"b": 1, "a": 0})
+
+    ordered = [
+        {"a": 0},
+        d,
+        {"a": 0, "b": 2},
+        {"a": 1},
+        {"a": 1, "b": 0},
+        {"b": -1},
+        {"c": -2},
+    ]
+    # Do all comparisions: the list is in order, so the comparisons of the dicts should match the
+    # comparison of indices.
+    for i, di in enumerate(ordered):
+        for j, dj in enumerate(ordered):
+            assert (FrozenDict(di) < FrozenDict(dj)) == (i < j)
+
+    # Order doesn't matter.
+    assert FrozenDict(d) < FrozenDict({"b": 2, "a": 0})
+
     # Must be an instance of FrozenDict.
     with pytest.raises(TypeError):
         FrozenDict(d) < d
@@ -118,8 +135,22 @@ def test_lt() -> None:
 def test_hash() -> None:
     d1 = {"a": 0, "b": 1}
     assert hash(FrozenDict(d1)) == hash(FrozenDict(d1))
-    # Order matters.
-    assert hash(FrozenDict(d1)) != hash(FrozenDict({"b": 1, "a": 0}))
+    # Order doesn't matters.
+    assert hash(FrozenDict(d1)) == hash(FrozenDict({"b": 1, "a": 0}))
+
+    # Confirm that `hash` is likely doing something "correct", and not just implemented as `return
+    # 0` or similar.
+    unique_hashes = set()
+    for i in range(1000):
+        unique_hashes.add(hash(FrozenDict({"a": i, "b": i})))
+
+    # It would be incredibly unlikely for 1000 different examples to have so many collisions that we
+    # saw fewer than 500 unique hash values. (It would be unlikely to see even one collision
+    # assuming `hash` acts like a uniform random variable: by
+    # https://en.wikipedia.org/wiki/Birthday_problem, the probability of seeing a single collision
+    # with m = 2**64 (the size of the output of hash) and n = 1000 is approximately n**2/(2*m) =
+    # 2.7e-14).
+    assert len(unique_hashes) >= 500
 
 
 def test_works_with_dataclasses() -> None: