Files
2026-07-13 12:24:33 +08:00

248 lines
8.4 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Unit tests for :class:`QuotaManager`.
"""
# Standard
import threading
# Third Party
import pytest
# First Party
from lmcache.v1.distributed.quota_manager import QuotaEntry, QuotaManager
class TestQuotaManagerBasics:
def test_unregistered_salt_limit_is_zero(self):
"""Legacy allowlist semantics — anything unregistered has limit 0."""
qm = QuotaManager()
assert qm.get_limit_bytes("alice") == 0
assert not qm.has_quota("alice")
def test_set_and_get(self):
qm = QuotaManager()
qm.set_quota("alice", 1024)
assert qm.get_limit_bytes("alice") == 1024
assert qm.has_quota("alice")
def test_set_overwrites(self):
qm = QuotaManager()
qm.set_quota("alice", 1024)
qm.set_quota("alice", 2048)
assert qm.get_limit_bytes("alice") == 2048
def test_delete_returns_true_when_present(self):
qm = QuotaManager()
qm.set_quota("alice", 1024)
assert qm.delete_quota("alice") is True
assert not qm.has_quota("alice")
assert qm.get_limit_bytes("alice") == 0
def test_delete_returns_false_when_absent(self):
qm = QuotaManager()
assert qm.delete_quota("alice") is False
def test_zero_limit_is_registered_and_distinguishable(self):
"""A zero limit IS a registration — ``has_quota`` returns True,
so the entry shows up in ``list_quotas``. Evaluating the quota
still yields 0, so the salt behaves like an unregistered one
for eviction purposes."""
qm = QuotaManager()
qm.set_quota("alice", 0)
assert qm.has_quota("alice")
assert qm.get_limit_bytes("alice") == 0
entries = qm.list_quotas()
assert QuotaEntry(cache_salt="alice", limit_bytes=0) in entries
def test_reject_negative_limit(self):
qm = QuotaManager()
with pytest.raises(ValueError, match="non-negative"):
qm.set_quota("alice", -1)
def test_empty_salt_is_a_valid_key(self):
"""``cache_salt=""`` is a real bucket (anonymous traffic) — the
registry treats it like any other key."""
qm = QuotaManager()
qm.set_quota("", 512)
assert qm.has_quota("")
assert qm.get_limit_bytes("") == 512
class TestQuotaManagerList:
def test_list_returns_snapshot(self):
qm = QuotaManager()
qm.set_quota("alice", 1)
entries = qm.list_quotas()
# Mutating the snapshot must not affect the registry.
entries.clear()
assert qm.has_quota("alice")
def test_list_is_complete(self):
qm = QuotaManager()
qm.set_quota("alice", 100)
qm.set_quota("bob", 200)
qm.set_quota("", 50)
got = {(e.cache_salt, e.limit_bytes) for e in qm.list_quotas()}
assert got == {("alice", 100), ("bob", 200), ("", 50)}
class TestQuotaManagerThreadSafety:
def test_concurrent_writes_do_not_corrupt(self):
"""Every write must land intact under contention."""
qm = QuotaManager()
n_writers = 8
per_writer = 50
barrier = threading.Barrier(n_writers)
def writer(worker_id: int) -> None:
barrier.wait()
for i in range(per_writer):
qm.set_quota(f"user-{worker_id}-{i}", worker_id * 1000 + i)
threads = [threading.Thread(target=writer, args=(w,)) for w in range(n_writers)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(qm.list_quotas()) == n_writers * per_writer
# Spot-check a few values.
assert qm.get_limit_bytes("user-3-7") == 3007
assert qm.get_limit_bytes("user-0-0") == 0
def test_concurrent_read_write(self):
"""Readers must never see a corrupted value."""
qm = QuotaManager()
qm.set_quota("alice", 1024)
stop = threading.Event()
errors: list[str] = []
def reader():
while not stop.is_set():
v = qm.get_limit_bytes("alice")
if v not in (1024, 2048):
errors.append(f"unexpected {v}")
return
def writer():
for _ in range(1000):
qm.set_quota("alice", 2048)
qm.set_quota("alice", 1024)
t_r = threading.Thread(target=reader)
t_w = threading.Thread(target=writer)
t_r.start()
t_w.start()
t_w.join()
stop.set()
t_r.join()
assert not errors, errors
def test_concurrent_set_and_delete_on_same_key(self):
"""A set racing with a delete must leave the registry in a
self-consistent state — either set wins (entry present) or
delete wins (entry absent). Neither path should crash or leak
a half-applied write."""
qm = QuotaManager()
iters = 500
def setter():
for _ in range(iters):
qm.set_quota("alice", 1024)
def deleter():
for _ in range(iters):
qm.delete_quota("alice")
t1 = threading.Thread(target=setter)
t2 = threading.Thread(target=deleter)
t1.start()
t2.start()
t1.join()
t2.join()
# Final state is either present-with-1024 or absent. Anything
# else (KeyError mid-read, partial write, etc.) would have
# surfaced during the run.
final = qm.get_limit_bytes("alice")
assert final in (0, 1024)
class TestQuotaManagerEdgeCases:
def test_very_large_limit(self):
"""Well inside a 64-bit int — shouldn't overflow or round."""
qm = QuotaManager()
big = 2**60
qm.set_quota("alice", big)
assert qm.get_limit_bytes("alice") == big
def test_delete_then_read_returns_zero(self):
"""After deletion the salt is indistinguishable from an
unregistered one — limit is the allowlist default of 0."""
qm = QuotaManager()
qm.set_quota("alice", 1024)
qm.delete_quota("alice")
assert qm.get_limit_bytes("alice") == 0
assert not qm.has_quota("alice")
def test_many_salts(self):
"""Sanity: large registries don't trip a size limit."""
qm = QuotaManager()
for i in range(10_000):
qm.set_quota(f"user-{i}", i)
assert len(qm.list_quotas()) == 10_000
assert qm.get_limit_bytes("user-9999") == 9999
class TestQuotaManagerDefaultLimit:
"""Default-quota semantics used by the coordinator's eviction manager
(``effective_limit_bytes``); ``get_limit_bytes`` keeps the legacy
allowlist behavior for the MP server's local eviction controller."""
def test_default_starts_unset(self):
qm = QuotaManager()
assert qm.get_default_limit_bytes() is None
def test_effective_limit_none_for_unregistered_until_default_set(self):
"""Boot state: unregistered salts are exempt (None), even though
the legacy ``get_limit_bytes`` still reports 0."""
qm = QuotaManager()
assert qm.effective_limit_bytes("alice") is None
assert qm.get_limit_bytes("alice") == 0
def test_default_zero_applies_to_unregistered(self):
qm = QuotaManager()
qm.set_default_limit_bytes(0)
assert qm.get_default_limit_bytes() == 0
assert qm.effective_limit_bytes("alice") == 0
def test_positive_default_applies_to_unregistered(self):
qm = QuotaManager()
qm.set_default_limit_bytes(4096)
assert qm.effective_limit_bytes("alice") == 4096
def test_explicit_quota_wins_over_default(self):
qm = QuotaManager()
qm.set_default_limit_bytes(4096)
qm.set_quota("alice", 1024)
assert qm.effective_limit_bytes("alice") == 1024
def test_explicit_zero_quota_wins_over_unset_default(self):
"""An explicit 0 is enforceable even while the default is None."""
qm = QuotaManager()
qm.set_quota("alice", 0)
assert qm.effective_limit_bytes("alice") == 0
assert qm.effective_limit_bytes("bob") is None
def test_default_resettable_to_none(self):
qm = QuotaManager()
qm.set_default_limit_bytes(0)
qm.set_default_limit_bytes(None)
assert qm.get_default_limit_bytes() is None
assert qm.effective_limit_bytes("alice") is None
def test_negative_default_rejected(self):
qm = QuotaManager()
with pytest.raises(ValueError):
qm.set_default_limit_bytes(-1)