11 KiB
phase: "14" plan: "06" subsystem: cloud-byte-cache tags: [cache, preview, download, security, pin, hydration, minio] status: complete
requires:
- phases/14-selective-analysis-and-byte-cache/14-03
- phases/14-selective-analysis-and-byte-cache/14-05
provides:
- services/cloud_cache.py: hydrate_and_cache_bytes — full cache lifecycle for open/preview/download: cache-hit avoids provider fetch, cache-miss stores bytes in MinIO and pins entry, pin always released in finally block (PATTERNS.md steps 3, 7, 8)
- api/cloud/operations.py: _make_minio_cache_wrapper — shared MinIO adapter for cache operations (DRY, used by both preview and download endpoints)
- api/cloud/operations.py: preview_cloud_file and download_cloud_file now route through hydrate_and_cache_bytes; version_key computed from CloudItem metadata, no provider call required for cache key resolution
affects:
- backend/services/cloud_cache.py
- backend/api/cloud/operations.py
- backend/tests/test_cloud_cache.py
- backend/tests/test_cloud_security.py
- frontend/src/views/tests/CloudFolderOpenPreview.test.js
tech-stack: added: [] patterns: - cache-hit-pin-read-release: hydrate_and_cache_bytes pins before MinIO read, releases in finally — eviction cannot occur mid-response (PATTERNS.md steps 7, 8) - cache-miss-fetch-store-pin-release: provider fetch → MinIO put → retain_or_reuse → pin → release; all steps non-fatal (bytes returned even on MinIO/quota failure) - quota-bypass-on-failure: CacheQuotaExceeded causes MinIO object deletion and immediate return of provider bytes without caching — quota is never left inconsistent - version-key-from-metadata: compute_version_key called with CloudItem.version/etag/ provider_size/modified_at — no provider round-trip needed for key resolution - shared-minio-wrapper: _make_minio_cache_wrapper in operations.py eliminates duplicate adapter code at two call sites (DRY) - cache-transparent-frontend: response shapes from preview and download routes unchanged — frontend never sees object_key, MinIO paths, or cache metadata
key-files: created: [] modified: - backend/services/cloud_cache.py - backend/api/cloud/operations.py - backend/tests/test_cloud_cache.py - backend/tests/test_cloud_security.py - frontend/src/views/tests/CloudFolderOpenPreview.test.js
decisions:
- hydrate_and_cache_bytes lives in services/cloud_cache.py (service layer), not in the router — CLAUDE.md service-layer rule; router only calls the helper
- MinIO write failure and quota failure are non-fatal — provider bytes returned without caching; caller experience is identical (just no cache benefit on miss)
- Cache-hit MinIO read failure falls through to provider fetch — robust degradation so a corrupted MinIO entry does not break user access to the file
- Pin is acquired before MinIO read on cache hit so eviction cannot occur mid-response
- evict_lru_entries called after pin release on cache-miss path — post-hydration LRU maintenance stays within user's configured cache limit
- Frontend tests required no response-shape changes — all 8 pre-existing tests pass unchanged; 1 new test added to document T-14-02 cache transparency invariant
- _make_minio_cache_wrapper helper avoids inline class duplication at preview/download
metrics: duration: "~10 minutes" completed: "2026-06-23" tasks: 2 files: 5
Phase 14 Plan 06: Cache-Backed Open/Preview/Download Summary
One-liner: Preview and download routes now hydrate provider bytes through the Phase 14 durable byte cache — cache hits avoid provider round-trips, active entries are pinned during response streaming, and pins are always released in finally blocks.
What Was Built
Task 1: Backend open/preview/download routes use cache leases
services/cloud_cache.py — hydrate_and_cache_bytes (new, ~140 lines):
The canonical cache lifecycle helper for content routes (PATTERNS.md steps 3, 7, 8).
Cache-hit path:
- Query
CloudByteCacheEntryfor a non-evicted matching row by(user_id, connection_id, cloud_item_id, version_key). - Pin the entry before reading (
pin_count += 1) so LRU eviction cannot occur mid-response (T-14-12 equivalent for preview/download). - Touch
last_accessed_atto update LRU position. - Read bytes from MinIO via the private
object_key. - Release pin in a
finallyblock on all exit paths. - Return
(bytes, cache_entry_id).
Cache-miss path:
- Call
fetch_fn()to download bytes from the provider. - Write bytes to MinIO under a private UUID key.
- Attempt atomic quota increment via
increment_quota_for_cache. - If quota exceeded: delete the MinIO object, return provider bytes without caching.
- Create or reactivate the cache entry via
retain_or_reuse_cache_entry. - Pin the entry for the response duration.
- Release pin in a
finallyblock. - Run
evict_lru_entriesto stay within the user's configured byte limit. - Return
(bytes, cache_entry_id).
Non-fatal failure policy: MinIO write failure, quota exhaustion, and any other non-provider error return the bytes from the provider fetch path without bubbling up an exception. The caller always gets bytes.
api/cloud/operations.py — three changes:
-
_make_minio_cache_wrapper(backend)helper (new): Returns a_MinIOCacheWrapperinstance adaptingMinIOBackendto thehydrate_and_cache_bytesinterface (get_object,put_object,delete_object). Shared by bothpreview_cloud_fileanddownload_cloud_file(DRY). -
preview_cloud_fileupdated (Steps 4-5 refactored):- Step 4 now resolves the adapter only (no
get_objectcall inline). - Step 5 computes
version_keyfromCloudItem.version/etag/provider_size/modified_at— no provider call needed. - Reads user's
cloud_cache_limit_bytesfromUserAnalysisSettings(default 512 MB). - Calls
hydrate_and_cache_bytes; falls through to directadapter.get_object()if noCloudItemrow found. - Binary-only preview enforcement (D-18) unchanged —
PreviewSupport.is_supported()still gates before the adapter is resolved.
- Step 4 now resolves the adapter only (no
-
download_cloud_fileupdated:- Same pattern: version_key from metadata, hydrate through cache, fallback to direct provider fetch for items without metadata rows.
- Error handling preserved —
_normalize_erroron adapter still surfacesreauth_requiredas 401.
New tests (Task 1):
tests/test_cloud_cache.py — 5 new tests (prefixed test_hydrate_*):
test_hydrate_cache_miss_fetches_from_provider: cache miss callsfetch_fnonce, stores in MinIO, creates cache entrytest_hydrate_cache_hit_skips_provider: cache hit reads from MinIO,fetch_fnnot called (T-14-03 equivalent)test_hydrate_pin_released_after_cache_hit:pin_count == 0after successful hit (PATTERNS.md step 8)test_hydrate_cache_miss_pin_released: no pin leak after miss path completestest_hydrate_foreign_user_gets_separate_cache_entry: foreign user cannot reuse owner's cache entry (T-14-06)
tests/test_cloud_security.py — 3 new tests:
test_preview_cache_hit_response_excludes_object_key: response body/headers contain noobject_keyorcredentials_enc(T-14-02)test_download_response_excludes_object_key: download headers contain no cache internals (T-14-02)test_foreign_user_cannot_download_via_cache: IDOR — ownership check runs before cache lookup (T-13-01)
Task 2: Frontend tests preserve open/preview behavior
No response schema changes were required — the byte cache is entirely backend-transparent. The existing 8 frontend tests in CloudFolderOpenPreview.test.js pass unchanged.
New test added (1):
test_hydrate_cache_hit_skips_providerincache_backed_response_has_no_object_keydescribe block: verifies that theopenCloudFileresponse processed byCloudFolderViewnever exposesobject_key, MinIO cache paths, orcredentials_encin rendered HTML or API call arguments (T-14-02 frontend invariant).
Test Results
| File | Tests | Pass | Notes |
|---|---|---|---|
| test_cloud_cache.py | 30 → 35 | 35 | 5 new hydrate tests |
| test_cloud_security.py -k cache/preview/download | 10 → 13 | 13 | 3 new Plan 06 security tests |
| CloudFolderOpenPreview.test.js | 8 → 9 | 9 | 1 new cache transparency test |
| Overall backend (excl. test_extractor.py docx env) | 693 | 693 | No regressions |
Pre-existing failures (not Plan 06 scope):
test_extractor.py::test_extract_docx: pre-existing docx env issue (ModuleNotFoundError)
Deviations from Plan
Auto-fixed Issues
1. [Rule 1 - Bug] CacheQuotaExceeded with no Quota row in test SQLite DB
- Found during: Task 1 test authoring
- Issue:
hydrate_and_cache_bytescallsincrement_quota_for_cachewhich uses a raw SQLUPDATE quotas SET...— but test fixtures use random UUIDs without creating User+Quota rows. SQLite returns 0 rows →CacheQuotaExceededraised → cache entry never created → assertionlen(entries) == 1fails. - Fix: Patched
services.cloud_cache.increment_quota_for_cachewithAsyncMock()in the three tests that need cache entry creation (test_hydrate_cache_miss_fetches_from_provider,test_hydrate_cache_miss_pin_released,test_hydrate_foreign_user_gets_separate_cache_entry). The quota contract is already tested bytest_cache_retain_increments_quotaandtest_cache_eviction_decrements_quotain the existing RED suite. - Files modified:
backend/tests/test_cloud_cache.py - Commit:
dfc3350
2. [Rule 2 - Missing] Shared MinIO wrapper to avoid code duplication
- Found during: Task 1 implementation (both preview and download needed identical wrapper)
- Issue: Both
preview_cloud_fileanddownload_cloud_filewould require identical inline_MinIOWrapperclass definitions. - Fix: Extracted
_make_minio_cache_wrapper(backend)helper function at the top ofoperations.py, used at both call sites. - Files modified:
backend/api/cloud/operations.py - Commit:
dfc3350
Known Stubs
None. The byte cache integration is complete for the preview and download routes. Caching is non-fatal on MinIO failure or quota exhaustion — the routes always return provider bytes as a fallback.
Threat Flags
None new. The plan's mitigations are fully implemented:
| Threat | Mitigation | Status |
|---|---|---|
| T-14-02 object_key secrecy | object_key never in any response body/header | Verified by test |
| T-13-01 IDOR | ownership check (resolve_owned_connection) runs before cache lookup | Verified by test |
| T-14-12 cache pin leak | finally block releases pin on all exit paths (success, failure, MinIO error) | Implemented + tested |
| Provider URL exposure | No provider URL in open/preview/download response — DocuVault URLs only | Verified by rg check |