test(profiles): pin projects/tree single-flight as a behaviour contract
Replace the attribute-presence check (asserts a cache_clear attribute the decorator does not expose) with one invariant: eight concurrent projects/tree requests run the profile scan once and each caller gets its own copy of the payload. The scope-suite fixture now disables the TTL instead of calling a non-existent clear hook, which silently no-op'd.
This commit is contained in:
@@ -172,20 +172,37 @@ class SidebarCacheTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(inspect.signature(wrapped), inspect.signature(scan))
|
self.assertEqual(inspect.signature(wrapped), inspect.signature(scan))
|
||||||
|
|
||||||
def test_heavy_sidebar_endpoints_are_all_single_flight_wrapped(self):
|
def test_projects_tree_coalesces_concurrent_scans_and_returns_copies(self):
|
||||||
# HostHighCPU on a 2-vCore box: /api/profiles/projects/tree was the
|
# /api/profiles/projects/tree fans out over every profile's state.db; desktop
|
||||||
# one heavy sidebar endpoint left uncoalesced, so every Desktop poll
|
# background sync + sidebar refreshes overlap identical requests. One scan must
|
||||||
# re-ran the full 50-profile fan-out. Any new heavy sidebar endpoint
|
# serve the whole burst, and no two callers may share the same payload object.
|
||||||
# must carry the wrapper too — this pins the existing two.
|
workers = 8
|
||||||
for endpoint in (
|
entered = threading.Event()
|
||||||
profiles.get_profiles_sessions_sidebar,
|
release = threading.Event()
|
||||||
profiles.get_profiles_projects_tree,
|
scans = 0
|
||||||
):
|
scans_lock = threading.Lock()
|
||||||
self.assertTrue(
|
|
||||||
callable(getattr(endpoint, "cache_clear", None)),
|
def fake_read(name, home, errors, fn):
|
||||||
f"{endpoint.__name__} lost its @_sidebar_singleflight_cache "
|
nonlocal scans
|
||||||
"wrapper — concurrent Desktop polls will re-scan every profile",
|
with scans_lock:
|
||||||
)
|
scans += 1
|
||||||
|
entered.set()
|
||||||
|
self.assertTrue(release.wait(timeout=2))
|
||||||
|
return None
|
||||||
|
|
||||||
|
with mock.patch.object(profiles, "_profile_targets", return_value=[("default", Path("/nonexistent"))]), \
|
||||||
|
mock.patch.object(profiles, "_read_profile_db", side_effect=fake_read), \
|
||||||
|
ThreadPoolExecutor(max_workers=workers) as pool:
|
||||||
|
futures = [pool.submit(profiles.get_profiles_projects_tree) for _ in range(workers)]
|
||||||
|
self.assertTrue(entered.wait(timeout=1))
|
||||||
|
time.sleep(0.05)
|
||||||
|
release.set()
|
||||||
|
results = [future.result(timeout=2) for future in futures]
|
||||||
|
|
||||||
|
self.assertEqual(scans, 1)
|
||||||
|
self.assertEqual(len({id(r) for r in results}), workers)
|
||||||
|
self.assertEqual(results, [results[0]] * workers)
|
||||||
|
self.assertEqual(results[0]["projects"], [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -15,30 +15,13 @@ import pytest
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _cold_sidebar_singleflight_caches():
|
def _uncached_sidebar_endpoints(monkeypatch):
|
||||||
"""Force a cold single-flight cache around every test in this file.
|
"""Both sidebar endpoints sit behind ``_sidebar_singleflight_cache`` (5s TTL). Every
|
||||||
|
test here builds a fresh tmp profile set under the same default query params, so a warm
|
||||||
Both sidebar endpoints are wrapped in ``_sidebar_singleflight_cache``
|
entry would answer with another test's payload. TTL 0 makes each request cold."""
|
||||||
(5s TTL). Each test builds a fresh tmp profile set, so a warm entry from
|
|
||||||
a previous test — same default query params, different disk state — would
|
|
||||||
answer with another test's payload. Clearing before and after keeps every
|
|
||||||
request cold without disabling the caching the endpoints rely on.
|
|
||||||
"""
|
|
||||||
from hermes_cli.web_routers import profiles as profiles_routes
|
from hermes_cli.web_routers import profiles as profiles_routes
|
||||||
|
|
||||||
endpoints = (
|
monkeypatch.setattr(profiles_routes, "_SIDEBAR_CACHE_TTL_SECONDS", 0.0)
|
||||||
profiles_routes.get_profiles_sessions_sidebar,
|
|
||||||
profiles_routes.get_profiles_projects_tree,
|
|
||||||
)
|
|
||||||
for endpoint in endpoints:
|
|
||||||
clear = getattr(endpoint, "cache_clear", None)
|
|
||||||
if clear is not None:
|
|
||||||
clear()
|
|
||||||
yield
|
|
||||||
for endpoint in endpoints:
|
|
||||||
clear = getattr(endpoint, "cache_clear", None)
|
|
||||||
if clear is not None:
|
|
||||||
clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
Reference in New Issue
Block a user