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))
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user