feat(14-03): add cache orchestration functions and tests

- retain_or_reuse_cache_entry: reuse non-evicted entry or reactivate evicted row
- pin_cache_entry / release_cache_entry: pin_count lifecycle management
- update_cache_access: touch last_accessed_at for LRU ordering
- Ownership assertions in all new service functions (T-14-06)
- 9 new integration tests covering retain/reuse, pin/release, and isolation
This commit is contained in:
curo1305
2026-06-23 15:24:13 +02:00
parent 54cc78eb96
commit 5bdd23f3bc
2 changed files with 512 additions and 1 deletions
+228 -1
View File
@@ -21,7 +21,8 @@ References:
── Phase 14: durable byte cache (cloud_byte_cache_entries) ─────────────────────
Exports create_cache_entry, evict_lru_entries, list_cache_entries,
increment_quota_for_cache, decrement_quota_for_cache for use by Celery tasks
increment_quota_for_cache, decrement_quota_for_cache, retain_or_reuse_cache_entry,
pin_cache_entry, release_cache_entry, update_cache_access for use by Celery tasks
and the analysis API.
Also re-exports compute_version_key from services.cloud_analysis_versioning so
@@ -323,6 +324,232 @@ async def decrement_quota_for_cache(
)
async def retain_or_reuse_cache_entry(
session: AsyncSession,
*,
user_id: uuid.UUID,
connection_id: uuid.UUID,
cloud_item_id: uuid.UUID,
provider_item_id: str,
version_key: str,
object_key: str,
content_type: Optional[str] = None,
size_bytes: int,
content_hash: Optional[str] = None,
) -> tuple[CloudByteCacheEntry, bool]:
"""Look up a non-evicted cache entry matching the version_key; create one if absent.
Implements the Phase 14 cache lifecycle step 3 (PATTERNS.md): reuse matching
non-evicted entry before hydrating from provider.
Ownership is strictly enforced — queries filter by (user_id, connection_id,
cloud_item_id, version_key) so a foreign user can never reuse another user's entry.
When creating a new entry this function does NOT call increment_quota_for_cache.
The caller is responsible for reserving quota atomically before retaining bytes
and calling increment_quota_for_cache before this function.
Args:
session: Active async SQLAlchemy session.
user_id: Owner UUID.
connection_id: Cloud connection UUID.
cloud_item_id: CloudItem row UUID.
provider_item_id: Provider item id snapshot for audit.
version_key: Idempotency key from compute_version_key.
object_key: Private MinIO object key — UUID-based, never exposed.
content_type: Optional MIME type.
size_bytes: Byte count stored in MinIO.
content_hash: Optional post-hydration content hash.
Returns:
Tuple (entry, created_new) where created_new=True if a new row was inserted,
False if an existing non-evicted entry was reused.
"""
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))
now = datetime.now(timezone.utc)
# Step 3: check for existing entry (evicted or not) for this version key.
# The unique constraint on (user_id, connection_id, cloud_item_id, version_key) means
# at most one row exists per version key. We handle both states:
# - non-evicted → reuse (touch last_accessed_at)
# - evicted → reactivate (clear evicted_at, update object_key + size)
stmt = (
select(CloudByteCacheEntry)
.where(
CloudByteCacheEntry.user_id == uid,
CloudByteCacheEntry.connection_id == cid,
CloudByteCacheEntry.cloud_item_id == iid,
CloudByteCacheEntry.version_key == version_key,
)
.limit(1)
)
result = await session.execute(stmt)
existing = result.scalars().first()
if existing is not None:
if existing.evicted_at is None:
# Active entry — reuse it
existing.last_accessed_at = now
existing.updated_at = now
await session.flush()
return existing, False
else:
# Evicted entry — reactivate with fresh bytes and updated object key
existing.evicted_at = None
existing.object_key = object_key
existing.size_bytes = size_bytes
existing.content_type = content_type
existing.content_hash = content_hash
existing.last_accessed_at = now
existing.updated_at = now
await session.flush()
return existing, True
# No existing entry — create a new one
# Quota must be reserved by caller before bytes are stored in MinIO
new_entry = await create_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,
content_hash=content_hash,
)
return new_entry, True
async def pin_cache_entry(
session: AsyncSession,
*,
entry_id: uuid.UUID,
user_id: uuid.UUID,
) -> CloudByteCacheEntry:
"""Increment pin_count to mark an active preview or download lease.
Pinned entries are protected from LRU eviction (PATTERNS.md step 7).
Ownership is asserted — user_id must match the entry's user_id.
Args:
session: Active async SQLAlchemy session.
entry_id: CloudByteCacheEntry UUID.
user_id: Authenticated user UUID — must own the entry.
Returns:
Updated CloudByteCacheEntry.
Raises:
ValueError: Entry not found or does not belong to user_id.
"""
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
eid = entry_id if isinstance(entry_id, uuid.UUID) else uuid.UUID(str(entry_id))
result = await session.execute(
select(CloudByteCacheEntry).where(
CloudByteCacheEntry.id == eid,
CloudByteCacheEntry.user_id == uid,
)
)
entry = result.scalars().first()
if entry is None:
raise ValueError(f"Cache entry {eid} not found for user {uid}")
now = datetime.now(timezone.utc)
entry.pin_count += 1
entry.last_accessed_at = now
entry.updated_at = now
await session.flush()
return entry
async def release_cache_entry(
session: AsyncSession,
*,
entry_id: uuid.UUID,
user_id: uuid.UUID,
) -> CloudByteCacheEntry:
"""Decrement pin_count when a preview or download lease ends.
Clamped to 0 — defensive against double-release (PATTERNS.md step 8).
Args:
session: Active async SQLAlchemy session.
entry_id: CloudByteCacheEntry UUID.
user_id: Authenticated user UUID — must own the entry.
Returns:
Updated CloudByteCacheEntry.
Raises:
ValueError: Entry not found or does not belong to user_id.
"""
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
eid = entry_id if isinstance(entry_id, uuid.UUID) else uuid.UUID(str(entry_id))
result = await session.execute(
select(CloudByteCacheEntry).where(
CloudByteCacheEntry.id == eid,
CloudByteCacheEntry.user_id == uid,
)
)
entry = result.scalars().first()
if entry is None:
raise ValueError(f"Cache entry {eid} not found for user {uid}")
now = datetime.now(timezone.utc)
entry.pin_count = max(0, entry.pin_count - 1)
entry.updated_at = now
await session.flush()
return entry
async def update_cache_access(
session: AsyncSession,
*,
entry_id: uuid.UUID,
user_id: uuid.UUID,
) -> CloudByteCacheEntry:
"""Touch last_accessed_at to update LRU position.
Called after a successful cache hit to maintain accurate LRU ordering.
Args:
session: Active async SQLAlchemy session.
entry_id: CloudByteCacheEntry UUID.
user_id: Authenticated user UUID — must own the entry.
Returns:
Updated CloudByteCacheEntry.
Raises:
ValueError: Entry not found or does not belong to user_id.
"""
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
eid = entry_id if isinstance(entry_id, uuid.UUID) else uuid.UUID(str(entry_id))
result = await session.execute(
select(CloudByteCacheEntry).where(
CloudByteCacheEntry.id == eid,
CloudByteCacheEntry.user_id == uid,
)
)
entry = result.scalars().first()
if entry is None:
raise ValueError(f"Cache entry {eid} not found for user {uid}")
now = datetime.now(timezone.utc)
entry.last_accessed_at = now
entry.updated_at = now
await session.flush()
return entry
# ── Phase 12: in-memory folder listing cache ──────────────────────────────────