Skip to content

Commit 0f896e4

Browse files
Cache invalid-workspace alias inference per storage fingerprint (#116)
Compute invalid_workspace_aliases once per mtime-keyed fingerprint and reuse it in assemble_single_tab, tab summaries, and workspace listing instead of re-scanning all composerData rows on every request.
1 parent fb97d03 commit 0f896e4

7 files changed

Lines changed: 333 additions & 55 deletions

services/summary_cache.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
CACHE_DIR = Path.home() / ".cache" / "cursor-chat-browser"
2424
PROJECTS_CACHE_FILE = CACHE_DIR / "projects.json"
2525
COMPOSER_MAP_CACHE_FILE = CACHE_DIR / "composer-id-to-ws.json"
26+
INVALID_WORKSPACE_ALIASES_CACHE_FILE = CACHE_DIR / "invalid-workspace-aliases.json"
2627
TAB_SUMMARIES_PREFIX = "tab-summaries-"
2728

2829

@@ -238,6 +239,47 @@ def set_cached_composer_id_to_ws(
238239
)
239240

240241

242+
def get_cached_invalid_workspace_aliases(
243+
fingerprint: dict[str, Any],
244+
) -> dict[str, str] | None:
245+
"""Load cached invalid-workspace alias map when the fingerprint matches.
246+
247+
Args:
248+
fingerprint: Storage mtime/rules digest.
249+
250+
Returns:
251+
``{invalid_id: replacement_id}`` on hit, else ``None``.
252+
"""
253+
data = _read_cache_file(INVALID_WORKSPACE_ALIASES_CACHE_FILE)
254+
if not data:
255+
return None
256+
if not _fingerprint_equal(data.get("fingerprint"), fingerprint):
257+
return None
258+
aliases = data.get("invalid_workspace_aliases")
259+
if not isinstance(aliases, dict):
260+
return None
261+
return {str(k): str(v) for k, v in aliases.items()}
262+
263+
264+
def set_cached_invalid_workspace_aliases(
265+
fingerprint: dict[str, Any],
266+
aliases: dict[str, str],
267+
) -> None:
268+
"""Persist invalid-workspace alias map under *fingerprint*.
269+
270+
Args:
271+
fingerprint: Invalidation fingerprint paired with *aliases*.
272+
aliases: ``{invalid_id: replacement_id}`` from alias inference.
273+
"""
274+
_write_cache_file(
275+
INVALID_WORKSPACE_ALIASES_CACHE_FILE,
276+
{
277+
"fingerprint": fingerprint,
278+
"invalid_workspace_aliases": aliases,
279+
},
280+
)
281+
282+
241283
def _tab_summaries_path(workspace_id: str) -> Path:
242284
safe = hashlib.sha256(workspace_id.encode("utf-8")).hexdigest()[:16]
243285
return CACHE_DIR / f"{TAB_SUMMARIES_PREFIX}{safe}.json"

services/workspace_context.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,27 @@
22

33
from __future__ import annotations
44

5+
import os
56
import sqlite3
6-
from dataclasses import dataclass, replace
7+
from dataclasses import dataclass, field, replace
78
from typing import Any
89

910
from models import Bubble
1011
from services.workspace_db import (
12+
COMPOSER_ROWS_WITH_HEADERS_SQL,
1113
build_composer_id_to_workspace_id,
1214
build_composer_id_to_workspace_id_cached,
1315
collect_invalid_workspace_ids,
1416
collect_workspace_entries,
17+
global_storage_db_path,
1518
load_bubble_map,
1619
load_project_layouts_map,
20+
safe_fetchall,
1721
)
1822
from services.workspace_resolver import (
1923
create_project_name_to_workspace_id_map,
2024
create_workspace_path_to_id_map,
25+
infer_invalid_workspace_aliases,
2126
)
2227

2328

@@ -32,6 +37,7 @@ class WorkspaceContext:
3237
workspace_path_to_id: dict[str, str]
3338
project_layouts_map: dict[str, list[str]]
3439
bubble_map: dict[str, Bubble]
40+
invalid_workspace_aliases: dict[str, str] = field(default_factory=dict)
3541

3642

3743
def _entries(
@@ -135,3 +141,100 @@ def enrich_workspace_context_from_global_db(
135141
if not updates:
136142
return ctx
137143
return replace(ctx, **updates)
144+
145+
146+
def resolve_invalid_workspace_aliases_cached(
147+
ctx: WorkspaceContext,
148+
global_db: sqlite3.Connection,
149+
workspace_path: str,
150+
rules: list[Any],
151+
*,
152+
nocache: bool = False,
153+
project_layouts_map: dict[str, list[str]] | None = None,
154+
) -> dict[str, str]:
155+
"""Return invalid-workspace alias map, using the summary-cache fingerprint.
156+
157+
Computes ``infer_invalid_workspace_aliases`` at most once per storage
158+
fingerprint (same mtime key as composer-map / tab-summary caches). When
159+
*ctx* already carries a populated ``invalid_workspace_aliases`` field,
160+
that value is returned without touching disk or the global DB roster.
161+
162+
Args:
163+
ctx: Workspace maps from :func:`resolve_workspace_context_cached`.
164+
global_db: Open global ``state.vscdb`` connection.
165+
workspace_path: Cursor ``workspaceStorage`` root.
166+
rules: Exclusion rule token lists (fingerprint input).
167+
nocache: When ``True``, bypass disk cache reads and writes.
168+
project_layouts_map: Pre-loaded layouts; loaded from *global_db* when
169+
``None``.
170+
171+
Returns:
172+
``{invalid_id: replacement_id}``, or ``{}`` when every workspace is valid.
173+
"""
174+
if not ctx.invalid_workspace_ids:
175+
return {}
176+
177+
from services.summary_cache import (
178+
fingerprint_workspace_storage,
179+
get_cached_invalid_workspace_aliases,
180+
nocache_enabled,
181+
set_cached_invalid_workspace_aliases,
182+
)
183+
from utils.workspace_path import get_cli_chats_path
184+
185+
gdb = global_storage_db_path(workspace_path)
186+
cli_path = get_cli_chats_path()
187+
fingerprint = fingerprint_workspace_storage(
188+
workspace_path,
189+
ctx.workspace_entries,
190+
global_db_path=gdb if os.path.isfile(gdb) else None,
191+
rules=rules,
192+
cli_chats_path=cli_path if os.path.isdir(cli_path) else None,
193+
)
194+
if not nocache_enabled(request_nocache=nocache):
195+
cached = get_cached_invalid_workspace_aliases(fingerprint)
196+
if cached is not None:
197+
return cached
198+
199+
layouts = (
200+
project_layouts_map
201+
if project_layouts_map is not None
202+
else load_project_layouts_map(global_db)
203+
)
204+
composer_rows = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
205+
aliases = infer_invalid_workspace_aliases(
206+
composer_rows=composer_rows,
207+
project_layouts_map=layouts,
208+
project_name_map=ctx.project_name_to_workspace_id,
209+
workspace_path_map=ctx.workspace_path_to_id,
210+
workspace_entries=ctx.workspace_entries,
211+
bubble_map={},
212+
composer_id_to_ws=ctx.composer_id_to_workspace_id,
213+
invalid_workspace_ids=ctx.invalid_workspace_ids,
214+
)
215+
if not nocache_enabled(request_nocache=nocache):
216+
set_cached_invalid_workspace_aliases(fingerprint, aliases)
217+
return aliases
218+
219+
220+
def with_invalid_workspace_aliases(
221+
ctx: WorkspaceContext,
222+
global_db: sqlite3.Connection,
223+
workspace_path: str,
224+
rules: list[Any],
225+
*,
226+
nocache: bool = False,
227+
project_layouts_map: dict[str, list[str]] | None = None,
228+
) -> WorkspaceContext:
229+
"""Return *ctx* with ``invalid_workspace_aliases`` populated from cache."""
230+
aliases = resolve_invalid_workspace_aliases_cached(
231+
ctx,
232+
global_db,
233+
workspace_path,
234+
rules,
235+
nocache=nocache,
236+
project_layouts_map=project_layouts_map,
237+
)
238+
if aliases is ctx.invalid_workspace_aliases:
239+
return ctx
240+
return replace(ctx, invalid_workspace_aliases=aliases)

services/workspace_listing.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
nocache_enabled,
3131
set_cached_projects,
3232
)
33+
from services.workspace_context import resolve_invalid_workspace_aliases_cached
3334
from services.workspace_db import (
3435
COMPOSER_ROWS_WITH_HEADERS_SQL,
3536
collect_workspace_entries,
@@ -41,7 +42,6 @@
4142
from utils.workspace_path import get_cli_chats_path
4243
from services.workspace_resolver import (
4344
build_composer_ids_by_workspace,
44-
infer_invalid_workspace_aliases,
4545
infer_workspace_name_from_layouts,
4646
lookup_workspace_display_name,
4747
)
@@ -124,18 +124,13 @@ def _build_workspace_projects_uncached(
124124
project_layouts_map = load_project_layouts_map(global_db)
125125

126126
bubble_map: dict[str, Bubble] = {}
127-
invalid_workspace_aliases: dict[str, str] = {}
128-
if invalid_workspace_ids:
129-
invalid_workspace_aliases = infer_invalid_workspace_aliases(
130-
composer_rows=composer_rows,
131-
project_layouts_map=project_layouts_map,
132-
project_name_map=project_name_map,
133-
workspace_path_map=workspace_path_map,
134-
workspace_entries=workspace_entries,
135-
bubble_map=bubble_map,
136-
composer_id_to_ws=composer_id_to_ws,
137-
invalid_workspace_ids=invalid_workspace_ids,
138-
)
127+
invalid_workspace_aliases = resolve_invalid_workspace_aliases_cached(
128+
ctx,
129+
global_db,
130+
workspace_path,
131+
rules,
132+
project_layouts_map=project_layouts_map,
133+
)
139134

140135
for row in composer_rows:
141136
composer = parse_composer_data_row(

services/workspace_tabs.py

Lines changed: 25 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@
5050
nocache_enabled,
5151
set_cached_tab_summaries,
5252
)
53-
from services.workspace_context import resolve_workspace_context_cached
53+
from services.workspace_context import (
54+
resolve_invalid_workspace_aliases_cached,
55+
resolve_workspace_context_cached,
56+
)
5457
from services.workspace_db import (
5558
COMPOSER_ROWS_WITH_HEADERS_SQL,
5659
collect_workspace_entries,
@@ -67,7 +70,6 @@
6770
)
6871
from utils.workspace_path import get_cli_chats_path
6972
from services.workspace_resolver import (
70-
infer_invalid_workspace_aliases,
7173
lookup_workspace_display_name,
7274
matching_workspace_ids_for_folder,
7375
)
@@ -453,18 +455,14 @@ def _build_workspace_tab_summaries_uncached(
453455

454456
composer_rows = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
455457

456-
invalid_workspace_aliases: dict[str, str] = {}
457-
if invalid_workspace_ids:
458-
invalid_workspace_aliases = infer_invalid_workspace_aliases(
459-
composer_rows=composer_rows,
460-
project_layouts_map=project_layouts_map,
461-
project_name_map=project_name_map,
462-
workspace_path_map=workspace_path_map,
463-
workspace_entries=workspace_entries,
464-
bubble_map={},
465-
composer_id_to_ws=composer_id_to_ws,
466-
invalid_workspace_ids=invalid_workspace_ids,
467-
)
458+
invalid_workspace_aliases = resolve_invalid_workspace_aliases_cached(
459+
ctx,
460+
global_db,
461+
workspace_path,
462+
rules,
463+
nocache=nocache,
464+
project_layouts_map=project_layouts_map,
465+
)
468466

469467
for row in composer_rows:
470468
composer = parse_composer_data_row(
@@ -582,22 +580,15 @@ def assemble_single_tab(
582580
return {"error": "Conversation not found"}, 404
583581

584582
project_layouts_map: dict[str, list[str]] = {}
585-
invalid_workspace_aliases: dict[str, str] = {}
586583
project_layouts_map[composer_id] = load_project_layouts_for_composer(
587584
global_db, composer_id,
588585
)
589-
if invalid_workspace_ids:
590-
composer_rows_for_aliases = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
591-
invalid_workspace_aliases = infer_invalid_workspace_aliases(
592-
composer_rows=composer_rows_for_aliases,
593-
project_layouts_map=project_layouts_map,
594-
project_name_map=project_name_map,
595-
workspace_path_map=workspace_path_map,
596-
workspace_entries=workspace_entries,
597-
bubble_map={},
598-
composer_id_to_ws=composer_id_to_ws,
599-
invalid_workspace_ids=invalid_workspace_ids,
600-
)
586+
invalid_workspace_aliases = resolve_invalid_workspace_aliases_cached(
587+
ctx,
588+
global_db,
589+
workspace_path,
590+
rules,
591+
)
601592

602593
bubble_map = load_bubbles_for_composer(
603594
global_db, composer_id, parse_warnings=parse_warnings,
@@ -723,18 +714,13 @@ def assemble_workspace_tabs(
723714
# Get composer data entries with conversations
724715
composer_rows = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
725716

726-
invalid_workspace_aliases: dict[str, str] = {}
727-
if invalid_workspace_ids:
728-
invalid_workspace_aliases = infer_invalid_workspace_aliases(
729-
composer_rows=composer_rows,
730-
project_layouts_map=project_layouts_map,
731-
project_name_map=project_name_map,
732-
workspace_path_map=workspace_path_map,
733-
workspace_entries=workspace_entries,
734-
bubble_map=bubble_map,
735-
composer_id_to_ws=composer_id_to_ws,
736-
invalid_workspace_ids=invalid_workspace_ids,
737-
)
717+
invalid_workspace_aliases = resolve_invalid_workspace_aliases_cached(
718+
ctx,
719+
global_db,
720+
workspace_path,
721+
rules,
722+
project_layouts_map=project_layouts_map,
723+
)
738724

739725
for row in composer_rows:
740726
composer = parse_composer_data_row(

tests/test_summary_cache.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818

1919
from services.summary_cache import (
2020
fingerprint_workspace_storage,
21+
get_cached_invalid_workspace_aliases,
2122
get_cached_projects,
23+
set_cached_invalid_workspace_aliases,
2224
set_cached_projects,
2325
)
2426

@@ -29,6 +31,9 @@ def setUp(self):
2931
self.cache_patch = patch.object(summary_cache, "CACHE_DIR", self.tmp.name)
3032
self.cache_patch.start()
3133
summary_cache.PROJECTS_CACHE_FILE = Path(self.tmp.name) / "projects.json"
34+
summary_cache.INVALID_WORKSPACE_ALIASES_CACHE_FILE = (
35+
Path(self.tmp.name) / "invalid-workspace-aliases.json"
36+
)
3237

3338
def tearDown(self):
3439
self.cache_patch.stop()
@@ -84,6 +89,19 @@ def test_workspace_files_fingerprint_round_trip(self):
8489
assert hit is not None
8590
self.assertEqual(hit[0], projects)
8691

92+
def test_invalid_workspace_aliases_cache_hit(self):
93+
fp = {"version": 1, "workspace_path": "/ws", "global_db_mtime_ns": 100}
94+
aliases = {"broken-ws": "good-ws"}
95+
set_cached_invalid_workspace_aliases(fp, aliases)
96+
hit = get_cached_invalid_workspace_aliases(fp)
97+
self.assertEqual(hit, aliases)
98+
99+
def test_invalid_workspace_aliases_cache_miss_on_fingerprint_change(self):
100+
fp1 = {"version": 1, "workspace_path": "/ws", "global_db_mtime_ns": 100}
101+
fp2 = {**fp1, "global_db_mtime_ns": 101}
102+
set_cached_invalid_workspace_aliases(fp1, {"broken-ws": "good-ws"})
103+
self.assertIsNone(get_cached_invalid_workspace_aliases(fp2))
104+
87105

88106
if __name__ == "__main__":
89107
unittest.main()

0 commit comments

Comments
 (0)