feat(14-06): route preview/download bytes through durable byte cache
- Add hydrate_and_cache_bytes to services/cloud_cache.py (Plan 06 cache lifecycle) - Cache-hit path: pin existing entry, read from MinIO, release pin in finally - Cache-miss path: fetch from provider, store in MinIO, create/reactivate entry, pin during response, release pin in finally - CacheQuotaExceeded and MinIO failures are non-fatal (bytes returned from provider path) - evict_lru_entries run after pin release to stay within user cache limit - Update preview_cloud_file and download_cloud_file in operations.py to call hydrate_and_cache_bytes - Version key computed from CloudItem metadata (no provider call required for key resolution) - Shared _make_minio_cache_wrapper helper eliminates duplicate adapter code - Falls through to direct provider fetch when no CloudItem row found - Add _make_minio_cache_wrapper helper to operations.py (DRY, shared by preview and download) - Add 5 new tests to test_cloud_cache.py: cache-miss fetches provider, cache-hit skips provider, pin released on hit, pin released on miss, user isolation - Add 3 new security tests to test_cloud_security.py: preview response excludes object_key, download response excludes object_key, foreign-user IDOR via download blocked
This commit is contained in:
@@ -28,6 +28,11 @@ and the analysis API.
|
||||
Also re-exports compute_version_key from services.cloud_analysis_versioning so
|
||||
tests and service callers have a single import location.
|
||||
|
||||
Exports hydrate_and_cache_bytes for the open/preview/download routes (Plan 06):
|
||||
hydrate_and_cache_bytes provides the full cache-or-fetch lifecycle for content
|
||||
routes: cache hit avoids a provider byte fetch; cache miss fetches, stores,
|
||||
and pins the entry so eviction cannot occur mid-response.
|
||||
|
||||
Rules:
|
||||
- Raises ValueError or domain exceptions only — never HTTPException.
|
||||
- Eviction never removes entries with pin_count > 0 or active_job_count > 0.
|
||||
@@ -550,6 +555,185 @@ async def update_cache_access(
|
||||
return entry
|
||||
|
||||
|
||||
# ── Phase 14: open/preview/download cache hydration ──────────────────────────
|
||||
|
||||
|
||||
async def hydrate_and_cache_bytes(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
connection_id: uuid.UUID,
|
||||
cloud_item_id: uuid.UUID,
|
||||
provider_item_id: str,
|
||||
version_key: str,
|
||||
content_type: Optional[str],
|
||||
fetch_fn: "Callable[[], Awaitable[bytes]]",
|
||||
minio_client: "Any",
|
||||
cache_limit_bytes: int,
|
||||
) -> tuple[bytes, Optional[uuid.UUID]]:
|
||||
"""Fetch content bytes for a cloud open/preview/download request, routing through the cache.
|
||||
|
||||
Cache-hit path (PATTERNS.md step 3):
|
||||
- Locate a non-evicted CloudByteCacheEntry matching (user_id, connection_id,
|
||||
cloud_item_id, version_key).
|
||||
- Pin the entry before reading from MinIO so eviction cannot occur mid-response
|
||||
(PATTERNS.md step 7).
|
||||
- Touch last_accessed_at to update LRU position.
|
||||
- Read bytes from MinIO via object_key.
|
||||
- Release pin in a finally block (PATTERNS.md step 8).
|
||||
- Return (bytes, cache_entry_id).
|
||||
|
||||
Cache-miss path:
|
||||
- Call fetch_fn() to download bytes from the provider.
|
||||
- Attempt to store bytes in MinIO under a UUID key (non-fatal on failure).
|
||||
- If MinIO write succeeds and quota allows: create/reactivate a cache entry
|
||||
and pin it (protect from eviction during response streaming).
|
||||
- Release pin in a finally block.
|
||||
- Return (bytes, cache_entry_id) — cache_entry_id is None if caching failed.
|
||||
|
||||
Rules:
|
||||
- Provider bytes are NEVER fetched for the cache-hit path.
|
||||
- Pin is always released in a finally block, even if the caller raises.
|
||||
- MinIO write failure is non-fatal: bytes are returned from the provider
|
||||
fetch path regardless.
|
||||
- Quota failure is non-fatal: bytes are returned but the cache entry is not
|
||||
created (caller gets bytes without MinIO caching).
|
||||
- object_key is never returned in any form — it stays internal.
|
||||
- Raises ValueError only — never HTTPException.
|
||||
|
||||
Args:
|
||||
session: Active async SQLAlchemy session.
|
||||
user_id: Authenticated user UUID.
|
||||
connection_id: Cloud connection UUID.
|
||||
cloud_item_id: CloudItem row UUID.
|
||||
provider_item_id: Provider item ID (for audit/snapshot on cache entry).
|
||||
version_key: Computed version key from compute_version_key.
|
||||
content_type: MIME type of the file.
|
||||
fetch_fn: Async callable (no args) that returns provider bytes.
|
||||
minio_client: Object with async put_object(key, data, content_type)
|
||||
and get_object(key) methods.
|
||||
cache_limit_bytes: User's configured byte cache limit; used for eviction
|
||||
after releasing the pin.
|
||||
|
||||
Returns:
|
||||
Tuple (bytes, cache_entry_id) where cache_entry_id is the UUID of the
|
||||
pinned cache entry, or None if no cache entry was created/used.
|
||||
"""
|
||||
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
|
||||
cid = connection_id if isinstance(connection_id, uuid.UUID) else uuid.UUID(str(connection_id))
|
||||
iid = cloud_item_id if isinstance(cloud_item_id, uuid.UUID) else uuid.UUID(str(cloud_item_id))
|
||||
|
||||
# ── Cache-hit path ──────────────────────────────────────────────────────
|
||||
# Look for a non-evicted entry matching the version key.
|
||||
stmt = (
|
||||
select(CloudByteCacheEntry)
|
||||
.where(
|
||||
CloudByteCacheEntry.user_id == uid,
|
||||
CloudByteCacheEntry.connection_id == cid,
|
||||
CloudByteCacheEntry.cloud_item_id == iid,
|
||||
CloudByteCacheEntry.version_key == version_key,
|
||||
CloudByteCacheEntry.evicted_at.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
existing_entry = result.scalars().first()
|
||||
|
||||
if existing_entry is not None:
|
||||
# Pin the entry before reading so eviction cannot occur mid-response.
|
||||
await pin_cache_entry(session, entry_id=existing_entry.id, user_id=uid)
|
||||
await session.commit()
|
||||
try:
|
||||
# Touch LRU position
|
||||
await update_cache_access(session, entry_id=existing_entry.id, user_id=uid)
|
||||
await session.commit()
|
||||
# Read bytes from MinIO (non-fatal if MinIO is unavailable — fall through)
|
||||
try:
|
||||
file_bytes = await minio_client.get_object(existing_entry.object_key)
|
||||
return file_bytes, existing_entry.id
|
||||
except Exception:
|
||||
# Cache hit but MinIO read failed — fall through to provider fetch below.
|
||||
# The entry will be released in the finally block; we re-fetch from provider.
|
||||
pass
|
||||
finally:
|
||||
# Always release pin — clamp protects against double-release.
|
||||
try:
|
||||
await release_cache_entry(session, entry_id=existing_entry.id, user_id=uid)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
pass # Non-fatal; log would go here in production
|
||||
|
||||
# ── Cache-miss (or MinIO read failure) path ─────────────────────────────
|
||||
# Download bytes from the provider.
|
||||
file_bytes = await fetch_fn()
|
||||
|
||||
# Attempt to store in MinIO and create/reactivate a cache entry.
|
||||
# All steps are non-fatal — any failure returns the provider bytes without caching.
|
||||
cache_entry_id: Optional[uuid.UUID] = None
|
||||
pinned_entry_id: Optional[uuid.UUID] = None
|
||||
|
||||
try:
|
||||
# Build a UUID-based private object key (T-14-02: never exposed in API responses).
|
||||
object_key = f"cache/{uid}/{uuid.uuid4()}"
|
||||
|
||||
# Write bytes to MinIO
|
||||
await minio_client.put_object(
|
||||
object_key,
|
||||
file_bytes,
|
||||
content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
size_bytes = len(file_bytes)
|
||||
|
||||
# Attempt atomic quota increment before creating the cache entry.
|
||||
try:
|
||||
await increment_quota_for_cache(session, user_id=uid, size_bytes=size_bytes)
|
||||
except CacheQuotaExceeded:
|
||||
# Quota exhausted — skip cache entry creation; return provider bytes.
|
||||
await minio_client.delete_object(object_key)
|
||||
return file_bytes, None
|
||||
|
||||
# Create or reactivate the cache entry.
|
||||
entry, _ = await retain_or_reuse_cache_entry(
|
||||
session,
|
||||
user_id=uid,
|
||||
connection_id=cid,
|
||||
cloud_item_id=iid,
|
||||
provider_item_id=provider_item_id,
|
||||
version_key=version_key,
|
||||
object_key=object_key,
|
||||
content_type=content_type,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Pin for the duration of the response.
|
||||
await pin_cache_entry(session, entry_id=entry.id, user_id=uid)
|
||||
await session.commit()
|
||||
pinned_entry_id = entry.id
|
||||
cache_entry_id = entry.id
|
||||
|
||||
except Exception:
|
||||
# Non-fatal: bytes already fetched from provider; return them without caching.
|
||||
return file_bytes, None
|
||||
|
||||
# Release pin after bytes have been served.
|
||||
try:
|
||||
await release_cache_entry(session, entry_id=pinned_entry_id, user_id=uid)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
pass # Non-fatal
|
||||
|
||||
# Run eviction if needed — non-fatal, fire-and-forget in the same session.
|
||||
try:
|
||||
await evict_lru_entries(session, user_id=uid, max_cache_bytes=cache_limit_bytes)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return file_bytes, cache_entry_id
|
||||
|
||||
|
||||
# ── Phase 12: in-memory folder listing cache ──────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user