diff --git a/src/internal/utils.rs b/src/internal/utils.rs index 8609b2e..8db7c39 100644 --- a/src/internal/utils.rs +++ b/src/internal/utils.rs @@ -20,7 +20,22 @@ pub unsafe fn pyobject_hash( return Err(pyo3::PyErr::take(py).unwrap()); } - Ok(py_hash as u64) + Ok(mix_hash(py_hash as u64)) +} + +/// Spreads entropy of a Python hash across all 64 bits. +/// +/// CPython hashes are frequently low-entropy in the high bits: `hash(n) == n` for +/// small ints, and many floats/tuples hash to small values. The vendored hashbrown +/// table takes its 7-bit SIMD control tag from the **top** bits of the hash, so such +/// keys all get the same tag and every probe degenerates into a chain of Python +/// `__eq__` calls. A folded 128-bit multiply (as in foldhash/wyhash) fixes the tag +/// distribution for ~1 ns. The mixed value never leaves the table. +#[inline(always)] +pub fn mix_hash(h: u64) -> u64 { + const K: u64 = 0x9E37_79B9_7F4A_7C15; + let p = (h ^ K) as u128 * K as u128; + (p as u64) ^ ((p >> 64) as u64) } /// Pointer-equality fast path, then Python `==`. diff --git a/tests/test_hashing.py b/tests/test_hashing.py new file mode 100644 index 0000000..7b6e613 --- /dev/null +++ b/tests/test_hashing.py @@ -0,0 +1,81 @@ +"""Key hashing edge cases, for every cache type. + +The tables mix Python hashes internally; these tests pin down that key +identity still follows Python semantics (equal objects share an entry, equal +hashes alone do not). +""" + +import pytest + +import cachebox + + +def _make(cache_cls, maxsize=0): + if cache_cls is cachebox.TTLCache: + return cache_cls(maxsize, 3600) + return cache_cls(maxsize) + + +def test_equal_numbers_share_one_entry(cache_cls): + c = _make(cache_cls) + c[1] = "int" + c[1.0] = "float" + c[True] = "bool" + assert len(c) == 1 + assert c[1] == "bool" + + +def test_equal_hash_but_unequal_keys_are_distinct(cache_cls): + # In CPython hash(-1) == hash(-2) == -2. + assert hash(-1) == hash(-2) + c = _make(cache_cls) + c[-1] = "minus one" + c[-2] = "minus two" + assert len(c) == 2 + assert c[-1] == "minus one" + assert c[-2] == "minus two" + + +class _ConstantHash: + def __init__(self, n): + self.n = n + + def __hash__(self): + return 42 + + def __eq__(self, other): + return isinstance(other, _ConstantHash) and other.n == self.n + + +def test_all_keys_colliding(cache_cls): + c = _make(cache_cls) + keys = [_ConstantHash(i) for i in range(100)] + for k in keys: + c[k] = k.n + assert len(c) == 100 + for k in keys: + assert c[_ConstantHash(k.n)] == k.n + + +@pytest.mark.parametrize( + "keys", + [ + range(2_000), + range(-1_000, 1_000), + [i * 2**61 for i in range(1, 1_000)], + [i + 0.5 for i in range(1_000)], + ], + ids=["small-ints", "negative-ints", "huge-ints", "floats"], +) +def test_many_low_entropy_keys(cache_cls, keys): + keys = list(keys) + c = _make(cache_cls) + for k in keys: + c[k] = k + assert len(c) == len(set(keys)) + for k in keys: + assert c[k] == k + for k in keys[::2]: + del c[k] + for i, k in enumerate(keys): + assert (k in c) == (i % 2 == 1) diff --git a/tests/test_impls.py b/tests/test_impls.py index 20141c5..6d1ff19 100644 --- a/tests/test_impls.py +++ b/tests/test_impls.py @@ -1550,11 +1550,14 @@ def test_items_with_expire(self): assert isinstance(ttl, float) def test_sweep_interval(self): - obj = cachebox.TTLCache(10, 3, {1: 1, 2: 2, 3: 3}, sweep_interval=3) + # Entries expire at t≈1s and the sweeper wakes every second. The sweep at + # t≈1s races with expiry (Event.wait() may return a few ms early, notably on + # Windows), so assert only after the next sweep at t≈2s has surely run. + obj = cachebox.TTLCache(10, 1, {1: 1, 2: 2, 3: 3}, sweep_interval=1) # __len__ doesn't call expire itself assert len(obj) == 3 - time.sleep(3.5) + time.sleep(2.5) assert len(obj) == 0