Skip to content

Commit e89f46b

Browse files
refactor: extract shared get_or_build_cached and tidy concurrency tests
1 parent c801f6f commit e89f46b

5 files changed

Lines changed: 72 additions & 82 deletions

File tree

services/summary_cache.py

Lines changed: 58 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from collections.abc import Callable
1818
from concurrent.futures import ThreadPoolExecutor, as_completed
1919
from pathlib import Path
20-
from typing import Any
20+
from typing import Any, TypeVar
2121

2222
from utils.exclusion_rules import RuleTokens
2323

@@ -34,6 +34,8 @@
3434
INVALID_WORKSPACE_ALIASES_CACHE_FILE = CACHE_DIR / "invalid-workspace-aliases.json"
3535
TAB_SUMMARIES_PREFIX = "tab-summaries-"
3636

37+
T = TypeVar("T")
38+
3739

3840
def nocache_enabled(*, request_nocache: bool = False) -> bool:
3941
"""Return whether summary-cache reads should be bypassed.
@@ -166,11 +168,6 @@ def _read_cache_file_unlocked(path: Path | str) -> dict[str, Any] | None:
166168
return None
167169

168170

169-
def _read_cache_file(path: Path | str) -> dict[str, Any] | None:
170-
with _summary_cache_lock:
171-
return _read_cache_file_unlocked(path)
172-
173-
174171
def _write_cache_file_unlocked(path: Path | str, payload: dict[str, Any]) -> None:
175172
p = Path(path)
176173
try:
@@ -252,31 +249,52 @@ def set_cached_projects(
252249
_set_cached_projects_unlocked(fingerprint, projects, warnings)
253250

254251

255-
def get_or_build_cached_projects(
252+
def _get_or_build_cached(
256253
workspace_path: str,
257254
workspace_entries: list[dict[str, Any]],
258255
rules: list[RuleTokens],
259256
*,
260-
build_fn: Callable[[], tuple[list[dict[str, Any]], list[dict[str, Any]]]],
261-
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
262-
"""Return cached projects or build once under double-checked locking."""
257+
build_fn: Callable[[], T],
258+
get_unlocked: Callable[[dict[str, Any]], T | None],
259+
set_unlocked: Callable[[dict[str, Any], T], None],
260+
should_cache: Callable[[T], bool] | None = None,
261+
) -> T:
263262
with _summary_cache_lock:
264263
fingerprint = _workspace_storage_fingerprint(workspace_path, workspace_entries, rules)
265-
hit = _get_cached_projects_unlocked(fingerprint)
264+
hit = get_unlocked(fingerprint)
266265
if hit is not None:
267266
return hit
268267

269268
built = build_fn()
270269

271270
with _summary_cache_lock:
272271
fingerprint = _workspace_storage_fingerprint(workspace_path, workspace_entries, rules)
273-
hit = _get_cached_projects_unlocked(fingerprint)
272+
hit = get_unlocked(fingerprint)
274273
if hit is not None:
275274
return hit
276-
_set_cached_projects_unlocked(fingerprint, built[0], built[1])
275+
if should_cache is None or should_cache(built):
276+
set_unlocked(fingerprint, built)
277277
return built
278278

279279

280+
def get_or_build_cached_projects(
281+
workspace_path: str,
282+
workspace_entries: list[dict[str, Any]],
283+
rules: list[RuleTokens],
284+
*,
285+
build_fn: Callable[[], tuple[list[dict[str, Any]], list[dict[str, Any]]]],
286+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
287+
"""Return cached projects or build once under double-checked locking."""
288+
return _get_or_build_cached(
289+
workspace_path,
290+
workspace_entries,
291+
rules,
292+
build_fn=build_fn,
293+
get_unlocked=_get_cached_projects_unlocked,
294+
set_unlocked=lambda fp, built: _set_cached_projects_unlocked(fp, built[0], built[1]),
295+
)
296+
297+
280298
def _get_cached_composer_id_to_ws_unlocked(
281299
fingerprint: dict[str, Any],
282300
) -> dict[str, str] | None:
@@ -341,21 +359,14 @@ def get_or_build_cached_composer_id_to_ws(
341359
build_fn: Callable[[], dict[str, str]],
342360
) -> dict[str, str]:
343361
"""Return cached composer map or build once under double-checked locking."""
344-
with _summary_cache_lock:
345-
fingerprint = _workspace_storage_fingerprint(workspace_path, workspace_entries, rules)
346-
hit = _get_cached_composer_id_to_ws_unlocked(fingerprint)
347-
if hit is not None:
348-
return hit
349-
350-
built = build_fn()
351-
352-
with _summary_cache_lock:
353-
fingerprint = _workspace_storage_fingerprint(workspace_path, workspace_entries, rules)
354-
hit = _get_cached_composer_id_to_ws_unlocked(fingerprint)
355-
if hit is not None:
356-
return hit
357-
_set_cached_composer_id_to_ws_unlocked(fingerprint, built)
358-
return built
362+
return _get_or_build_cached(
363+
workspace_path,
364+
workspace_entries,
365+
rules,
366+
build_fn=build_fn,
367+
get_unlocked=_get_cached_composer_id_to_ws_unlocked,
368+
set_unlocked=_set_cached_composer_id_to_ws_unlocked,
369+
)
359370

360371

361372
def _get_cached_invalid_workspace_aliases_unlocked(
@@ -435,21 +446,14 @@ def get_or_build_cached_invalid_workspace_aliases(
435446
build_fn: Callable[[], dict[str, str]],
436447
) -> dict[str, str]:
437448
"""Return cached alias map or build once under double-checked locking."""
438-
with _summary_cache_lock:
439-
fingerprint = _workspace_storage_fingerprint(workspace_path, workspace_entries, rules)
440-
hit = _get_cached_invalid_workspace_aliases_unlocked(fingerprint)
441-
if hit is not None:
442-
return hit
443-
444-
built = build_fn()
445-
446-
with _summary_cache_lock:
447-
fingerprint = _workspace_storage_fingerprint(workspace_path, workspace_entries, rules)
448-
hit = _get_cached_invalid_workspace_aliases_unlocked(fingerprint)
449-
if hit is not None:
450-
return hit
451-
_set_cached_invalid_workspace_aliases_unlocked(fingerprint, built)
452-
return built
449+
return _get_or_build_cached(
450+
workspace_path,
451+
workspace_entries,
452+
rules,
453+
build_fn=build_fn,
454+
get_unlocked=_get_cached_invalid_workspace_aliases_unlocked,
455+
set_unlocked=_set_cached_invalid_workspace_aliases_unlocked,
456+
)
453457

454458

455459
def _tab_summaries_path(workspace_id: str) -> Path:
@@ -536,19 +540,14 @@ def get_or_build_cached_tab_summaries(
536540
build_fn: Callable[[], tuple[dict[str, Any], int]],
537541
) -> tuple[dict[str, Any], int]:
538542
"""Return cached tab summaries or build once under double-checked locking."""
539-
with _summary_cache_lock:
540-
fingerprint = _workspace_storage_fingerprint(workspace_path, workspace_entries, rules)
541-
hit = _get_cached_tab_summaries_unlocked(fingerprint, workspace_id)
542-
if hit is not None:
543-
return hit
544-
545-
payload, status = build_fn()
546-
547-
with _summary_cache_lock:
548-
fingerprint = _workspace_storage_fingerprint(workspace_path, workspace_entries, rules)
549-
hit = _get_cached_tab_summaries_unlocked(fingerprint, workspace_id)
550-
if hit is not None:
551-
return hit
552-
if status == 200:
553-
_set_cached_tab_summaries_unlocked(fingerprint, workspace_id, payload, status)
554-
return payload, status
543+
return _get_or_build_cached(
544+
workspace_path,
545+
workspace_entries,
546+
rules,
547+
build_fn=build_fn,
548+
get_unlocked=lambda fp: _get_cached_tab_summaries_unlocked(fp, workspace_id),
549+
set_unlocked=lambda fp, built: _set_cached_tab_summaries_unlocked(
550+
fp, workspace_id, built[0], built[1],
551+
),
552+
should_cache=lambda built: built[1] == 200,
553+
)

services/workspace_context.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import os
65
import sqlite3
76
from dataclasses import dataclass, replace
87
from typing import Any
@@ -16,7 +15,6 @@
1615
build_composer_id_to_workspace_id_cached,
1716
collect_invalid_workspace_ids,
1817
collect_workspace_entries,
19-
global_storage_db_path,
2018
load_bubble_map,
2119
load_project_layouts_map,
2220
safe_fetchall,

services/workspace_listing.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
from services.workspace_db import (
3333
COMPOSER_ROWS_WITH_HEADERS_SQL,
3434
collect_workspace_entries,
35-
global_storage_db_path,
3635
load_project_layouts_map,
3736
open_global_db,
3837
safe_fetchall,

services/workspace_tabs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import hashlib
44
import json
55
import logging
6-
import os
76
import sqlite3
87
from collections.abc import Iterator, Mapping
98
from datetime import datetime

tests/test_summary_cache_concurrency.py

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
get_cached_projects,
2323
)
2424

25+
_WAIT_TIMEOUT_S = 5.0
26+
2527

2628
def _make_workspace_fixture(root: str) -> tuple[str, list[dict[str, object]]]:
2729
entry_dir = os.path.join(root, "entry1")
@@ -59,11 +61,14 @@ def tearDown(self):
5961
patcher.stop()
6062
self.tmp.cleanup()
6163

64+
def _setup_workspace(self, name: str) -> tuple[str, list[dict[str, object]]]:
65+
ws_root = os.path.join(self.tmp.name, name)
66+
os.makedirs(ws_root)
67+
return _make_workspace_fixture(ws_root)
68+
6269
def test_blocked_build_recheck_returns_peer_cache(self):
6370
"""Lost-update: peer write while build is blocked must win on recheck."""
64-
ws_root = os.path.join(self.tmp.name, "ws")
65-
os.makedirs(ws_root)
66-
workspace_path, workspace_entries = _make_workspace_fixture(ws_root)
71+
workspace_path, workspace_entries = self._setup_workspace("ws")
6772

6873
peer_projects = [
6974
{"id": "peer", "name": "Peer", "conversationCount": 1, "lastModified": "x"},
@@ -73,22 +78,14 @@ def test_blocked_build_recheck_returns_peer_cache(self):
7378
]
7479
build_started = threading.Event()
7580
allow_build_finish = threading.Event()
76-
build_count = 0
77-
build_count_lock = threading.Lock()
7881

7982
def blocked_build() -> tuple[list[dict[str, object]], list[dict[str, object]]]:
80-
nonlocal build_count
81-
with build_count_lock:
82-
build_count += 1
83-
count = build_count
8483
build_started.set()
8584
self.assertTrue(
86-
allow_build_finish.wait(timeout=5.0),
85+
allow_build_finish.wait(timeout=_WAIT_TIMEOUT_S),
8786
msg="timed out waiting to finish blocked build",
8887
)
89-
if count == 1:
90-
return stale_projects, []
91-
return peer_projects, []
88+
return stale_projects, []
9289

9390
errors: list[str] = []
9491
results: list[tuple[list[dict[str, object]], list[dict[str, object]]]] = []
@@ -109,7 +106,7 @@ def thread_a() -> None:
109106
def thread_b() -> None:
110107
try:
111108
self.assertTrue(
112-
build_started.wait(timeout=5.0),
109+
build_started.wait(timeout=_WAIT_TIMEOUT_S),
113110
msg="thread B started before thread A entered build_fn",
114111
)
115112
results.append(
@@ -141,9 +138,7 @@ def thread_b() -> None:
141138
self.assertEqual(on_disk.get("projects"), peer_projects)
142139

143140
def test_concurrent_get_or_build_returns_consistent_results(self):
144-
ws_root = os.path.join(self.tmp.name, "ws-warm")
145-
os.makedirs(ws_root)
146-
workspace_path, workspace_entries = _make_workspace_fixture(ws_root)
141+
workspace_path, workspace_entries = self._setup_workspace("ws-warm")
147142
warm_projects = [
148143
{"id": "warm", "name": "Warm", "conversationCount": 2, "lastModified": "z"},
149144
]
@@ -160,7 +155,7 @@ def test_concurrent_get_or_build_returns_consistent_results(self):
160155

161156
def reader() -> None:
162157
try:
163-
barrier.wait(timeout=5.0)
158+
barrier.wait(timeout=_WAIT_TIMEOUT_S)
164159
hit = get_or_build_cached_projects(
165160
workspace_path,
166161
workspace_entries, # type: ignore[arg-type]
@@ -195,7 +190,7 @@ def test_get_cached_projects_remains_thread_safe(self):
195190

196191
def reader() -> None:
197192
try:
198-
barrier.wait(timeout=5.0)
193+
barrier.wait(timeout=_WAIT_TIMEOUT_S)
199194
hits.append(get_cached_projects(fp))
200195
except Exception as exc:
201196
errors.append(str(exc))

0 commit comments

Comments
 (0)