Skip to content

Commit 7f245ff

Browse files
Cache invalid-workspace alias inference per storage fingerprint (#116) (#125)
* 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. * fix(#125): harden alias cache validation and honor nocache fast path * fix(#125): wire export alias cache and log corrupt cache rejects * fix(#125): address bradjin8 tab nocache, ctx enrichment, and tests
1 parent fb97d03 commit 7f245ff

11 files changed

Lines changed: 461 additions & 70 deletions

api/workspaces.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ def get_workspace_tabs(workspace_id: str) -> tuple[Response, int] | Response:
167167
workspace_id: Storage folder name, ``global`` for unassigned chats, or
168168
``cli:<project_id>``.
169169
summary: When ``1`` or ``true``, return lightweight tab headers only.
170-
nocache: When ``1`` or ``true``, bypass cache on summary requests.
170+
nocache: When ``1`` or ``true``, bypass cache on summary and full-tab
171+
requests (alias disk cache on per-tab lazy load).
171172
172173
Returns:
173174
Tabs payload from :func:`services.workspace_tabs` helpers (typically
@@ -190,7 +191,9 @@ def get_workspace_tabs(workspace_id: str) -> tuple[Response, int] | Response:
190191
workspace_id, workspace_path, rules, nocache=_request_nocache(),
191192
)
192193
else:
193-
payload, status = assemble_workspace_tabs(workspace_id, workspace_path, rules)
194+
payload, status = assemble_workspace_tabs(
195+
workspace_id, workspace_path, rules, nocache=_request_nocache(),
196+
)
194197
return json_response(payload, status)
195198
except Exception:
196199
_logger.exception("Failed to get workspace tabs")
@@ -209,6 +212,7 @@ def get_workspace_tab(workspace_id: str, composer_id: str) -> tuple[Response, in
209212
workspace_id: Storage folder name, ``global`` for unassigned chats, or
210213
``cli:<project_id>`` (CLI workspaces return 400).
211214
composer_id: Composer UUID to load.
215+
nocache: When ``1`` or ``true``, bypass alias disk cache.
212216
213217
Returns:
214218
Single-tab JSON from :func:`services.workspace_tabs.assemble_single_tab`
@@ -221,7 +225,13 @@ def get_workspace_tab(workspace_id: str, composer_id: str) -> tuple[Response, in
221225
try:
222226
workspace_path = resolve_workspace_path()
223227
rules = exclusion_rules()
224-
payload, status = assemble_single_tab(workspace_id, composer_id, workspace_path, rules)
228+
payload, status = assemble_single_tab(
229+
workspace_id,
230+
composer_id,
231+
workspace_path,
232+
rules,
233+
nocache=_request_nocache(),
234+
)
225235
return json_response(payload, status)
226236
except Exception:
227237
_logger.exception("Failed to get workspace tab")

services/export_engine.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from services.workspace_context import (
1717
WorkspaceContext,
1818
enrich_workspace_context_from_global_db,
19+
resolve_invalid_workspace_aliases_cached,
1920
resolve_workspace_context_cached,
2021
)
2122
from services.workspace_db import (
@@ -28,7 +29,6 @@
2829
)
2930
from services.workspace_resolver import (
3031
determine_project_for_conversation,
31-
infer_invalid_workspace_aliases,
3232
lookup_workspace_display_name,
3333
)
3434
from utils.cli_chat_reader import (
@@ -169,6 +169,9 @@ def prepare_workspace_orchestration(
169169

170170
def load_global_db_export_data(
171171
orch: WorkspaceOrchestration,
172+
rules: list[Any],
173+
*,
174+
nocache: bool = False,
172175
) -> GlobalDbExportData | None:
173176
"""Load global DB maps needed for IDE composer export."""
174177
ctx = orch.ctx
@@ -197,15 +200,13 @@ def load_global_db_export_data(
197200
code_block_diff_map = load_code_block_diff_map(global_db)
198201
ide_composer_rows = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
199202

200-
invalid_workspace_aliases = infer_invalid_workspace_aliases(
201-
composer_rows=ide_composer_rows,
203+
invalid_workspace_aliases = resolve_invalid_workspace_aliases_cached(
204+
ctx,
205+
global_db,
206+
orch.workspace_path,
207+
rules,
208+
nocache=nocache,
202209
project_layouts_map=project_layouts_map,
203-
project_name_map=ctx.project_name_to_workspace_id,
204-
workspace_path_map=ctx.workspace_path_to_id,
205-
workspace_entries=orch.workspace_entries,
206-
bubble_map=bubble_map,
207-
composer_id_to_ws=ctx.composer_id_to_workspace_id,
208-
invalid_workspace_ids=ctx.invalid_workspace_ids,
209210
)
210211

211212
return GlobalDbExportData(
@@ -503,7 +504,9 @@ def collect_export_entries(
503504
exported: list[CollectedExportEntry] = []
504505

505506
if include_composer:
506-
db_data = load_global_db_export_data(orch)
507+
db_data = load_global_db_export_data(
508+
orch, exclusion_rules, nocache=effective_nocache,
509+
)
507510
if db_data is not None:
508511
exported.extend(
509512
_collect_ide_export_entries(

services/search.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,9 @@ def _load_search_workspace_assigner(
382382
)
383383
invalid_workspace_aliases: dict[str, str] = {}
384384
if ctx.invalid_workspace_ids:
385+
# Issue #116 follow-up: search assigner still cold-scans composerData:*
386+
# rows here; sharing resolve_invalid_workspace_aliases_cached is
387+
# intentionally deferred (operator scope — see issue Out of scope).
385388
composer_rows = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
386389
invalid_workspace_aliases = infer_invalid_workspace_aliases(
387390
composer_rows=composer_rows,

services/summary_cache.py

Lines changed: 55 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,60 @@ 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+
_logger.debug(
261+
"Invalid workspace aliases cache rejected: invalid_workspace_aliases is not a dict",
262+
)
263+
return None
264+
validated: dict[str, str] = {}
265+
for key, value in aliases.items():
266+
if not isinstance(key, str) or not isinstance(value, str):
267+
_logger.debug(
268+
"Invalid workspace aliases cache rejected: non-string entry (%r -> %r)",
269+
key,
270+
value,
271+
)
272+
return None
273+
validated[key] = value
274+
return validated
275+
276+
277+
def set_cached_invalid_workspace_aliases(
278+
fingerprint: dict[str, Any],
279+
aliases: dict[str, str],
280+
) -> None:
281+
"""Persist invalid-workspace alias map under *fingerprint*.
282+
283+
Args:
284+
fingerprint: Invalidation fingerprint paired with *aliases*.
285+
aliases: ``{invalid_id: replacement_id}`` from alias inference.
286+
"""
287+
_write_cache_file(
288+
INVALID_WORKSPACE_ALIASES_CACHE_FILE,
289+
{
290+
"fingerprint": fingerprint,
291+
"invalid_workspace_aliases": aliases,
292+
},
293+
)
294+
295+
241296
def _tab_summaries_path(workspace_id: str) -> Path:
242297
safe = hashlib.sha256(workspace_id.encode("utf-8")).hexdigest()[:16]
243298
return CACHE_DIR / f"{TAB_SUMMARIES_PREFIX}{safe}.json"

services/workspace_context.py

Lines changed: 105 additions & 0 deletions
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
67
from dataclasses import dataclass, 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] | None = None
3541

3642

3743
def _entries(
@@ -135,3 +141,102 @@ 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 ctx.invalid_workspace_aliases is not None:
175+
return ctx.invalid_workspace_aliases
176+
if not ctx.invalid_workspace_ids:
177+
return {}
178+
179+
from services.summary_cache import (
180+
fingerprint_workspace_storage,
181+
get_cached_invalid_workspace_aliases,
182+
nocache_enabled,
183+
set_cached_invalid_workspace_aliases,
184+
)
185+
from utils.workspace_path import get_cli_chats_path
186+
187+
gdb = global_storage_db_path(workspace_path)
188+
cli_path = get_cli_chats_path()
189+
fingerprint = fingerprint_workspace_storage(
190+
workspace_path,
191+
ctx.workspace_entries,
192+
global_db_path=gdb if os.path.isfile(gdb) else None,
193+
rules=rules,
194+
cli_chats_path=cli_path if os.path.isdir(cli_path) else None,
195+
)
196+
if not nocache_enabled(request_nocache=nocache):
197+
cached = get_cached_invalid_workspace_aliases(fingerprint)
198+
if cached is not None:
199+
return cached
200+
201+
layouts = (
202+
project_layouts_map
203+
if project_layouts_map is not None
204+
else load_project_layouts_map(global_db)
205+
)
206+
composer_rows = safe_fetchall(global_db, COMPOSER_ROWS_WITH_HEADERS_SQL)
207+
aliases = infer_invalid_workspace_aliases(
208+
composer_rows=composer_rows,
209+
project_layouts_map=layouts,
210+
project_name_map=ctx.project_name_to_workspace_id,
211+
workspace_path_map=ctx.workspace_path_to_id,
212+
workspace_entries=ctx.workspace_entries,
213+
bubble_map={},
214+
composer_id_to_ws=ctx.composer_id_to_workspace_id,
215+
invalid_workspace_ids=ctx.invalid_workspace_ids,
216+
)
217+
if not nocache_enabled(request_nocache=nocache):
218+
set_cached_invalid_workspace_aliases(fingerprint, aliases)
219+
return aliases
220+
221+
222+
def with_invalid_workspace_aliases(
223+
ctx: WorkspaceContext,
224+
global_db: sqlite3.Connection,
225+
workspace_path: str,
226+
rules: list[Any],
227+
*,
228+
nocache: bool = False,
229+
project_layouts_map: dict[str, list[str]] | None = None,
230+
) -> WorkspaceContext:
231+
"""Return *ctx* with ``invalid_workspace_aliases`` populated from cache."""
232+
if ctx.invalid_workspace_aliases is not None:
233+
return ctx
234+
aliases = resolve_invalid_workspace_aliases_cached(
235+
ctx,
236+
global_db,
237+
workspace_path,
238+
rules,
239+
nocache=nocache,
240+
project_layouts_map=project_layouts_map,
241+
)
242+
return replace(ctx, invalid_workspace_aliases=aliases)

services/workspace_listing.py

Lines changed: 12 additions & 14 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
)
@@ -93,7 +93,7 @@ def list_workspace_projects(
9393
)
9494

9595
projects, warnings = _build_workspace_projects_uncached(
96-
workspace_path, rules, orch,
96+
workspace_path, rules, orch, nocache=effective_nocache,
9797
)
9898
if not effective_nocache:
9999
set_cached_projects(orch.fingerprint, projects, warnings)
@@ -104,6 +104,8 @@ def _build_workspace_projects_uncached(
104104
workspace_path: str,
105105
rules: list[Any],
106106
orch: WorkspaceOrchestration,
107+
*,
108+
nocache: bool = False,
107109
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
108110
parse_warnings = ParseWarningCollector()
109111
ctx = orch.ctx
@@ -124,18 +126,14 @@ def _build_workspace_projects_uncached(
124126
project_layouts_map = load_project_layouts_map(global_db)
125127

126128
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-
)
129+
invalid_workspace_aliases = resolve_invalid_workspace_aliases_cached(
130+
ctx,
131+
global_db,
132+
workspace_path,
133+
rules,
134+
nocache=nocache,
135+
project_layouts_map=project_layouts_map,
136+
)
139137

140138
for row in composer_rows:
141139
composer = parse_composer_data_row(

0 commit comments

Comments
 (0)