Skip to content

fix: serialise summary-cache fingerprint read-compare-write under a module lock - #158

Merged
wpak-ai merged 4 commits into
masterfrom
fix/summary-cache-fingerprint-lock
Jul 30, 2026
Merged

fix: serialise summary-cache fingerprint read-compare-write under a module lock#158
wpak-ai merged 4 commits into
masterfrom
fix/summary-cache-fingerprint-lock

Conversation

@clean6378-max-it

@clean6378-max-it clean6378-max-it commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #154
Under threaded WSGI, two requests could both miss the summary cache, build in parallel, and the slower one would overwrite the faster one's write. That's the bug.

This PR adds a module lock in summary_cache.py and get_or_build_cached_* helpers for all four cache types. Fingerprint and cache lookup happen under the lock; the build runs outside it; then we re-fingerprint and recheck before writing. If another thread filled the cache while we were building, we return that result and skip the write.

The four call sites (workspace_listing, workspace_tabs, workspace_db, workspace_context) now go through those helpers. tests/test_summary_cache_concurrency.py has a deterministic lost-update test plus a warm-cache stress test. Full suite: 614 passed.

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability of workspace summary caching under concurrent access by serializing cache fingerprinting and cache read/update operations.
    • Prevented lost-update races with double-checked “recheck before write” cache rebuild flow.
    • Ensured workspace tab summaries are cached only when the underlying request succeeds (HTTP 200).
    • Extended consistent caching behavior across workspace project listings, composer/workspace mappings, and invalid workspace alias resolution.
  • Tests
    • Updated concurrency regression coverage to use the new public cache fingerprint helper and validate consistent results during blocked rebuilds.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 87628543-e307-4462-a2d0-4b2d72cc3783

📥 Commits

Reviewing files that changed from the base of the PR and between e89f46b and 28be9fe.

📒 Files selected for processing (5)
  • services/export_engine.py
  • services/search_index.py
  • services/summary_cache.py
  • tests/test_export_engine.py
  • tests/test_summary_cache_concurrency.py
💤 Files with no reviewable changes (1)
  • tests/test_export_engine.py

📝 Walkthrough

Walkthrough

The summary cache now serializes fingerprinting and cache I/O with a module-level lock, adds double-checked builders for four cached artifacts, updates workspace services and fingerprint consumers, and adds concurrent-access regression tests.

Changes

Summary cache concurrency

Layer / File(s) Summary
Locking and project cache orchestration
services/summary_cache.py
Adds locked cache primitives, a public workspace fingerprint helper, and double-checked project cache construction.
Cached artifact builder helpers
services/summary_cache.py
Adds equivalent builders for composer mappings, invalid aliases, and tab summaries; tab results are persisted only for status 200.
Workspace service integration
services/workspace_context.py, services/workspace_db.py, services/workspace_listing.py, services/workspace_tabs.py
Routes cached computations through the new helpers and preserves direct nocache execution paths.
Orchestration and search fingerprint updates
services/export_engine.py, services/search_index.py, tests/test_export_engine.py
Removes orchestration fingerprint state and updates search indexing and tests to use the revised fingerprint API.
Concurrent cache regression tests
tests/test_summary_cache_concurrency.py
Tests blocked-build rechecks, warm-cache reads, cache persistence, and thread-safe project cache access.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Request
  participant list_workspace_projects
  participant get_or_build_cached_projects
  participant workspace_storage_fingerprint
  participant CacheFile
  Request->>list_workspace_projects: request workspace projects
  list_workspace_projects->>get_or_build_cached_projects: provide workspace data and build_fn
  get_or_build_cached_projects->>workspace_storage_fingerprint: compute storage fingerprint
  get_or_build_cached_projects->>CacheFile: read cached projects under lock
  get_or_build_cached_projects->>list_workspace_projects: invoke build_fn on cache miss
  list_workspace_projects-->>get_or_build_cached_projects: return built projects
  get_or_build_cached_projects->>CacheFile: recheck and write cache when fingerprints match
  get_or_build_cached_projects-->>list_workspace_projects: return cached or built projects
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also changes services/search_index.py and services/export_engine.py, which are outside the cache-locking scope and alter fingerprint propagation/API shape. Keep this PR focused on summary_cache and its four cache call sites; move search-index and orchestration cleanup to a separate follow-up.
Docstring Coverage ⚠️ Warning Docstring coverage is 41.51% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed It succinctly describes the main change: serializing summary-cache fingerprint and cache operations under a module lock.
Linked Issues check ✅ Passed Core changes add a module lock, double-checked cached builders, and concurrency coverage for all four cache types.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/summary-cache-fingerprint-lock

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
services/summary_cache.py (1)

263-277: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Global lock also serializes fingerprint stat I/O and JSON disk I/O across unrelated workspaces.

Each critical section runs _workspace_storage_fingerprint (stats every workspace entry, global DB, CLI chats dir) plus the cache-file read/write while holding the single module-wide lock, and the fingerprint is recomputed twice per miss. Under threaded WSGI this makes concurrent requests for different workspaces block on each other's filesystem I/O. Correct, but consider a per-cache-key lock (dict of locks keyed by cache file / workspace id) as a follow-up if listing latency regresses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/summary_cache.py` around lines 263 - 277, The summary-cache path
around _workspace_storage_fingerprint and _get_cached_projects_unlocked holds
the module-wide lock during expensive fingerprint and cache-file I/O, and
recomputes the fingerprint twice on misses. Refactor the synchronization so
unrelated workspace/cache keys do not serialize these operations, using
per-cache-key locking or an equivalent keyed coordination mechanism while
preserving double-checking and preventing duplicate cache writes for the same
key.
services/workspace_tabs.py (1)

529-545: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nocache branch re-inlines the builder body instead of reusing build(). In both call sites the early-return body is character-for-character the build() closure, so future argument changes must be made twice.

  • services/workspace_tabs.py#L529-L545: define build() first, then if nocache_enabled(request_nocache=nocache): return build().
  • services/workspace_db.py#L443-L454: likewise move the build() definition above the nocache_enabled check and return build().
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/workspace_tabs.py` around lines 529 - 545, The nocache paths
duplicate the cached builder logic instead of reusing the local builder closure.
In services/workspace_tabs.py lines 529-545, move build() before the
nocache_enabled check and return build() from that branch; apply the same
restructuring to the build() closure and nocache_enabled branch in
services/workspace_db.py lines 443-454, preserving the existing cache call for
non-nocache requests.
tests/test_summary_cache_concurrency.py (1)

48-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Composer-map / invalid-workspace-alias cache paths are patched but never exercised.

COMPOSER_MAP_CACHE_FILE and INVALID_WORKSPACE_ALIASES_CACHE_FILE are overridden in setUp, but no test in this file drives get_or_build_cached_composer_map or the invalid-workspace-alias equivalent under concurrency. Issue #154 calls for lock coverage across "projects, composer maps, invalid-workspace aliases, and tab summaries" - consider adding blocked-build/warm-cache tests for these two helpers analogous to the project ones, or drop the unused setup if intentionally out of scope for this layer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_summary_cache_concurrency.py` around lines 48 - 53, Either add
concurrency tests that exercise get_or_build_cached_composer_map and the
invalid-workspace-alias cache helper, covering blocked builds and warm-cache
reuse with the temporary paths configured in setUp, or remove the unused
COMPOSER_MAP_CACHE_FILE and INVALID_WORKSPACE_ALIASES_CACHE_FILE overrides if
this test layer intentionally excludes those caches.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_summary_cache_concurrency.py`:
- Around line 43-57: Update setUp in the test class to patch
PROJECTS_CACHE_FILE, COMPOSER_MAP_CACHE_FILE, and
INVALID_WORKSPACE_ALIASES_CACHE_FILE through the mocking framework, alongside
CACHE_DIR, so their original module values are automatically restored during
tearDown. Ensure the temporary paths remain available to this test while
preventing summary_cache state from leaking into subsequent tests.

---

Nitpick comments:
In `@services/summary_cache.py`:
- Around line 263-277: The summary-cache path around
_workspace_storage_fingerprint and _get_cached_projects_unlocked holds the
module-wide lock during expensive fingerprint and cache-file I/O, and recomputes
the fingerprint twice on misses. Refactor the synchronization so unrelated
workspace/cache keys do not serialize these operations, using per-cache-key
locking or an equivalent keyed coordination mechanism while preserving
double-checking and preventing duplicate cache writes for the same key.

In `@services/workspace_tabs.py`:
- Around line 529-545: The nocache paths duplicate the cached builder logic
instead of reusing the local builder closure. In services/workspace_tabs.py
lines 529-545, move build() before the nocache_enabled check and return build()
from that branch; apply the same restructuring to the build() closure and
nocache_enabled branch in services/workspace_db.py lines 443-454, preserving the
existing cache call for non-nocache requests.

In `@tests/test_summary_cache_concurrency.py`:
- Around line 48-53: Either add concurrency tests that exercise
get_or_build_cached_composer_map and the invalid-workspace-alias cache helper,
covering blocked builds and warm-cache reuse with the temporary paths configured
in setUp, or remove the unused COMPOSER_MAP_CACHE_FILE and
INVALID_WORKSPACE_ALIASES_CACHE_FILE overrides if this test layer intentionally
excludes those caches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0784a7aa-2962-4628-a775-027efc3b6465

📥 Commits

Reviewing files that changed from the base of the PR and between b2a4d0d and 901c06a.

📒 Files selected for processing (6)
  • services/summary_cache.py
  • services/workspace_context.py
  • services/workspace_db.py
  • services/workspace_listing.py
  • services/workspace_tabs.py
  • tests/test_summary_cache_concurrency.py

Comment on lines +43 to +57
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.cache_patch = patch.object(summary_cache, "CACHE_DIR", Path(self.tmp.name))
self.cache_patch.start()
summary_cache.PROJECTS_CACHE_FILE = Path(self.tmp.name) / "projects.json"
summary_cache.COMPOSER_MAP_CACHE_FILE = (
Path(self.tmp.name) / "composer-id-to-ws.json"
)
summary_cache.INVALID_WORKSPACE_ALIASES_CACHE_FILE = (
Path(self.tmp.name) / "invalid-workspace-aliases.json"
)

def tearDown(self):
self.cache_patch.stop()
self.tmp.cleanup()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unrestored cache-file patches leak across tests.

self.cache_patch (for CACHE_DIR) is properly stopped in tearDown, but PROJECTS_CACHE_FILE, COMPOSER_MAP_CACHE_FILE, and INVALID_WORKSPACE_ALIASES_CACHE_FILE are reassigned as plain module attributes (lines 47-53) and never restored. After this test class runs, summary_cache permanently points at paths inside a temp directory that tearDown has already deleted via self.tmp.cleanup(). Any other test in the same process that relies on the module's default cache paths can silently break or fail depending on execution order.

🔧 Proposed fix using patch.multiple for auto-restoration
     def setUp(self):
         self.tmp = tempfile.TemporaryDirectory()
-        self.cache_patch = patch.object(summary_cache, "CACHE_DIR", Path(self.tmp.name))
-        self.cache_patch.start()
-        summary_cache.PROJECTS_CACHE_FILE = Path(self.tmp.name) / "projects.json"
-        summary_cache.COMPOSER_MAP_CACHE_FILE = (
-            Path(self.tmp.name) / "composer-id-to-ws.json"
-        )
-        summary_cache.INVALID_WORKSPACE_ALIASES_CACHE_FILE = (
-            Path(self.tmp.name) / "invalid-workspace-aliases.json"
-        )
+        self.addCleanup(self.tmp.cleanup)
+        self.cache_patch = patch.multiple(
+            summary_cache,
+            CACHE_DIR=Path(self.tmp.name),
+            PROJECTS_CACHE_FILE=Path(self.tmp.name) / "projects.json",
+            COMPOSER_MAP_CACHE_FILE=Path(self.tmp.name) / "composer-id-to-ws.json",
+            INVALID_WORKSPACE_ALIASES_CACHE_FILE=(
+                Path(self.tmp.name) / "invalid-workspace-aliases.json"
+            ),
+        )
+        self.cache_patch.start()
+        self.addCleanup(self.cache_patch.stop)
-
-    def tearDown(self):
-        self.cache_patch.stop()
-        self.tmp.cleanup()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.cache_patch = patch.object(summary_cache, "CACHE_DIR", Path(self.tmp.name))
self.cache_patch.start()
summary_cache.PROJECTS_CACHE_FILE = Path(self.tmp.name) / "projects.json"
summary_cache.COMPOSER_MAP_CACHE_FILE = (
Path(self.tmp.name) / "composer-id-to-ws.json"
)
summary_cache.INVALID_WORKSPACE_ALIASES_CACHE_FILE = (
Path(self.tmp.name) / "invalid-workspace-aliases.json"
)
def tearDown(self):
self.cache_patch.stop()
self.tmp.cleanup()
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.cache_patch = patch.multiple(
summary_cache,
CACHE_DIR=Path(self.tmp.name),
PROJECTS_CACHE_FILE=Path(self.tmp.name) / "projects.json",
COMPOSER_MAP_CACHE_FILE=Path(self.tmp.name) / "composer-id-to-ws.json",
INVALID_WORKSPACE_ALIASES_CACHE_FILE=(
Path(self.tmp.name) / "invalid-workspace-aliases.json"
),
)
self.cache_patch.start()
self.addCleanup(self.cache_patch.stop)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_summary_cache_concurrency.py` around lines 43 - 57, Update setUp
in the test class to patch PROJECTS_CACHE_FILE, COMPOSER_MAP_CACHE_FILE, and
INVALID_WORKSPACE_ALIASES_CACHE_FILE through the mocking framework, alongside
CACHE_DIR, so their original module values are automatically restored during
tearDown. Ensure the temporary paths remain available to this test while
preventing summary_cache state from leaking into subsequent tests.

@bradjin8 bradjin8 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional follow-ups

services/summary_cache.py

Global lock serializes fingerprint I/O across unrelated workspaces; per-cache-key locks if threaded latency regresses

tests/test_summary_cache_concurrency.py

Only projects cache tested under concurrency; other three types share _get_or_build_cached so risk is low

@clean6378-max-it

Copy link
Copy Markdown
Collaborator Author

Optional follow-ups

services/summary_cache.py

Global lock serializes fingerprint I/O across unrelated workspaces; per-cache-key locks if threaded latency regresses

tests/test_summary_cache_concurrency.py

Only projects cache tested under concurrency; other three types share _get_or_build_cached so risk is low

Single module lock was intentional per the design guide (same pattern as _workspace_path_lock), and the projects concurrency test is enough because all four cache types share _get_or_build_cached

@clean6378-max-it
clean6378-max-it requested a review from wpak-ai July 29, 2026 16:04

@wpak-ai wpak-ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. services/export_engine.py:87, services/export_engine.py:146, services/workspace_listing.py:82 - Delete the fingerprint field from WorkspaceOrchestration and stop computing it in prepare_workspace_orchestration, now that the cache key is derived entirely inside _get_or_build_cached

  2. services/summary_cache.py:118, services/export_engine.py:146, services/search_index.py:131 - Make the new resolver public by dropping the underscore from _workspace_storage_fingerprint, then call it from prepare_workspace_orchestration and search_index._storage_fingerprint instead of their own copies of the resolve-then-fingerprint block

Comment thread services/summary_cache.py Outdated
@clean6378-max-it

Copy link
Copy Markdown
Collaborator Author
  1. services/export_engine.py:87, services/export_engine.py:146, services/workspace_listing.py:82 - Delete the fingerprint field from WorkspaceOrchestration and stop computing it in prepare_workspace_orchestration, now that the cache key is derived entirely inside _get_or_build_cached
  2. services/summary_cache.py:118, services/export_engine.py:146, services/search_index.py:131 - Make the new resolver public by dropping the underscore from _workspace_storage_fingerprint, then call it from prepare_workspace_orchestration and search_index._storage_fingerprint instead of their own copies of the resolve-then-fingerprint block

Dropped fingerprint from the dataclass and removed the compute in prepare_workspace_orchestration. Cache keys stay inside _get_or_build_cached now.

Renamed to workspace_storage_fingerprint and removed the duplicate block from export orchestration when we dropped the orch field.

@clean6378-max-it
clean6378-max-it requested a review from wpak-ai July 30, 2026 11:06
@wpak-ai
wpak-ai merged commit 2ded4bc into master Jul 30, 2026
18 checks passed
@wpak-ai
wpak-ai deleted the fix/summary-cache-fingerprint-lock branch July 30, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cppa-cursor-browser: lock the summary-cache fingerprint read-compare-write so threaded workers cannot serve a stale summary

3 participants