- cloud_analysis_versioning.py: compute_version_key with version>etag>fingerprint precedence (D-19); compute_fingerprint helper; content_hash is optional post- hydration only (D-20); raises ValueError only, no HTTPException - cloud_analysis_settings.py: get_or_create_user_analysis_settings (upsert on first access), update_user_analysis_settings with enum validation and tier-capped cache limit, build_default_settings for API responses; raises ValueError only - cloud_cache.py: Phase 14 durable cache functions added alongside existing Phase 12 in-memory TTL cache — create_cache_entry, list_cache_entries (owner-scoped), evict_lru_entries (LRU, skips pin_count>0 and active_job_count>0), increment/ decrement_quota_for_cache (atomic UPDATE pattern); re-exports compute_version_key from cloud_analysis_versioning so tests have one import site
164 lines
6.2 KiB
Python
164 lines
6.2 KiB
Python
"""
|
|
Cloud analysis user settings helpers — Phase 14.
|
|
|
|
Provides get_or_create_user_analysis_settings and update_user_analysis_settings:
|
|
the single authority for reading and mutating per-user analysis preferences.
|
|
|
|
Rules:
|
|
- Service raises ValueError only — never HTTPException (CLAUDE.md).
|
|
- Default values are defined here; the router layer never hard-codes them.
|
|
- Tier/admin cache limit maximums are enforced as a seam: pass the admin
|
|
maximum via max_cache_limit_bytes to update_user_analysis_settings.
|
|
- Invalid enum values are rejected before touching the database.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from db.models import UserAnalysisSettings
|
|
|
|
|
|
# ── Valid enum values ─────────────────────────────────────────────────────────
|
|
|
|
VALID_PROGRESS_DETAIL = {"simple", "detailed"}
|
|
VALID_FAILURE_BEHAVIOR = {"pause_batch", "continue_item"}
|
|
|
|
# Absolute floor for cache limit — prevents pathologically small values
|
|
MIN_CACHE_LIMIT_BYTES = 1 * 1024 * 1024 # 1 MB
|
|
|
|
# Default tier maximum — overridable by caller (admin/tier seam)
|
|
DEFAULT_MAX_CACHE_LIMIT_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB
|
|
|
|
# Default per-user cache limit when row is created
|
|
DEFAULT_CACHE_LIMIT_BYTES = 512 * 1024 * 1024 # 512 MB
|
|
|
|
# Default enum values (mirrors migration server_defaults)
|
|
DEFAULT_PROGRESS_DETAIL = "simple"
|
|
DEFAULT_FAILURE_BEHAVIOR = "pause_batch"
|
|
|
|
|
|
# ── Public API ─────────────────────────────────────────────────────────────────
|
|
|
|
async def get_or_create_user_analysis_settings(
|
|
session: AsyncSession,
|
|
*,
|
|
user_id: uuid.UUID,
|
|
) -> UserAnalysisSettings:
|
|
"""Return the user's analysis settings row, creating defaults if absent.
|
|
|
|
Never raises — creates the row on first access so callers always receive
|
|
a valid settings object.
|
|
|
|
Args:
|
|
session: Active async SQLAlchemy session.
|
|
user_id: Authenticated user UUID.
|
|
|
|
Returns:
|
|
UserAnalysisSettings with current preferences or newly created defaults.
|
|
"""
|
|
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
|
|
|
|
result = await session.execute(
|
|
select(UserAnalysisSettings).where(UserAnalysisSettings.user_id == uid)
|
|
)
|
|
existing = result.scalars().first()
|
|
if existing is not None:
|
|
return existing
|
|
|
|
# Create defaults on first access
|
|
settings_row = UserAnalysisSettings(
|
|
user_id=uid,
|
|
analysis_progress_detail=DEFAULT_PROGRESS_DETAIL,
|
|
analysis_failure_behavior=DEFAULT_FAILURE_BEHAVIOR,
|
|
cloud_cache_limit_bytes=DEFAULT_CACHE_LIMIT_BYTES,
|
|
)
|
|
session.add(settings_row)
|
|
await session.flush()
|
|
return settings_row
|
|
|
|
|
|
async def update_user_analysis_settings(
|
|
session: AsyncSession,
|
|
*,
|
|
user_id: uuid.UUID,
|
|
analysis_progress_detail: Optional[str] = None,
|
|
analysis_failure_behavior: Optional[str] = None,
|
|
cloud_cache_limit_bytes: Optional[int] = None,
|
|
max_cache_limit_bytes: int = DEFAULT_MAX_CACHE_LIMIT_BYTES,
|
|
) -> UserAnalysisSettings:
|
|
"""Update analysis preferences for the user.
|
|
|
|
Only provided (non-None) fields are updated. Enum values and cache limit
|
|
bounds are validated before the database is touched.
|
|
|
|
Args:
|
|
session: Active async SQLAlchemy session.
|
|
user_id: Authenticated user UUID.
|
|
analysis_progress_detail: "simple" | "detailed" (or None to leave unchanged).
|
|
analysis_failure_behavior: "pause_batch" | "continue_item" (or None).
|
|
cloud_cache_limit_bytes: User-preferred cache byte limit (or None).
|
|
max_cache_limit_bytes: Tier/admin maximum (default 5 GB). Caller
|
|
supplies this so the seam is explicit.
|
|
|
|
Returns:
|
|
Updated UserAnalysisSettings row.
|
|
|
|
Raises:
|
|
ValueError: Invalid enum value or cache limit out of bounds.
|
|
"""
|
|
if analysis_progress_detail is not None:
|
|
if analysis_progress_detail not in VALID_PROGRESS_DETAIL:
|
|
raise ValueError(
|
|
f"Invalid analysis_progress_detail {analysis_progress_detail!r}. "
|
|
f"Valid values: {VALID_PROGRESS_DETAIL}"
|
|
)
|
|
|
|
if analysis_failure_behavior is not None:
|
|
if analysis_failure_behavior not in VALID_FAILURE_BEHAVIOR:
|
|
raise ValueError(
|
|
f"Invalid analysis_failure_behavior {analysis_failure_behavior!r}. "
|
|
f"Valid values: {VALID_FAILURE_BEHAVIOR}"
|
|
)
|
|
|
|
if cloud_cache_limit_bytes is not None:
|
|
if cloud_cache_limit_bytes < MIN_CACHE_LIMIT_BYTES:
|
|
raise ValueError(
|
|
f"cloud_cache_limit_bytes must be at least {MIN_CACHE_LIMIT_BYTES} bytes, "
|
|
f"got {cloud_cache_limit_bytes}"
|
|
)
|
|
if cloud_cache_limit_bytes > max_cache_limit_bytes:
|
|
raise ValueError(
|
|
f"cloud_cache_limit_bytes {cloud_cache_limit_bytes} exceeds tier maximum "
|
|
f"{max_cache_limit_bytes}"
|
|
)
|
|
|
|
# Fetch or create the row (never fails)
|
|
row = await get_or_create_user_analysis_settings(session, user_id=user_id)
|
|
|
|
if analysis_progress_detail is not None:
|
|
row.analysis_progress_detail = analysis_progress_detail
|
|
if analysis_failure_behavior is not None:
|
|
row.analysis_failure_behavior = analysis_failure_behavior
|
|
if cloud_cache_limit_bytes is not None:
|
|
row.cloud_cache_limit_bytes = cloud_cache_limit_bytes
|
|
|
|
await session.flush()
|
|
return row
|
|
|
|
|
|
def build_default_settings() -> dict:
|
|
"""Return a dict of default settings values for use in API responses.
|
|
|
|
Used when no row exists yet and the caller needs a response without
|
|
committing a new row (e.g. GET /cache before any settings are saved).
|
|
"""
|
|
return {
|
|
"analysis_progress_detail": DEFAULT_PROGRESS_DETAIL,
|
|
"analysis_failure_behavior": DEFAULT_FAILURE_BEHAVIOR,
|
|
"cloud_cache_limit_bytes": DEFAULT_CACHE_LIMIT_BYTES,
|
|
}
|