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:
kshitijk4poor
2026-09-09 18:29:04 +05:30
committed by kshitij
parent 35af06c009
commit b36f654271
2 changed files with 36 additions and 36 deletions
+31 -14
View File
@@ -172,20 +172,37 @@ class SidebarCacheTests(unittest.TestCase):
self.assertEqual(inspect.signature(wrapped), inspect.signature(scan))
def test_heavy_sidebar_endpoints_are_all_single_flight_wrapped(self):
# HostHighCPU on a 2-vCore box: /api/profiles/projects/tree was the
# one heavy sidebar endpoint left uncoalesced, so every Desktop poll
# re-ran the full 50-profile fan-out. Any new heavy sidebar endpoint
# must carry the wrapper too — this pins the existing two.
for endpoint in (
profiles.get_profiles_sessions_sidebar,
profiles.get_profiles_projects_tree,
):
self.assertTrue(
callable(getattr(endpoint, "cache_clear", None)),
f"{endpoint.__name__} lost its @_sidebar_singleflight_cache "
"wrapper — concurrent Desktop polls will re-scan every profile",
)
def test_projects_tree_coalesces_concurrent_scans_and_returns_copies(self):
# /api/profiles/projects/tree fans out over every profile's state.db; desktop
# background sync + sidebar refreshes overlap identical requests. One scan must
# serve the whole burst, and no two callers may share the same payload object.
workers = 8
entered = threading.Event()
release = threading.Event()
scans = 0
scans_lock = threading.Lock()
def fake_read(name, home, errors, fn):
nonlocal scans
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__":
@@ -15,30 +15,13 @@ import pytest
@pytest.fixture(autouse=True)
def _cold_sidebar_singleflight_caches():
"""Force a cold single-flight cache around every test in this file.
Both sidebar endpoints are wrapped in ``_sidebar_singleflight_cache``
(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.
"""
def _uncached_sidebar_endpoints(monkeypatch):
"""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
entry would answer with another test's payload. TTL 0 makes each request cold."""
from hermes_cli.web_routers import profiles as profiles_routes
endpoints = (
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()
monkeypatch.setattr(profiles_routes, "_SIDEBAR_CACHE_TTL_SECONDS", 0.0)
@pytest.fixture