Invalidate movie/series/person detail cache on re-ingestion
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
cached_response() had no write-side invalidation, so re-ingesting an entity kept serving its pre-ingestion cached response for up to CACHE_DEFAULT_TTL. Found live: re-ingesting a movie to test the new company-logo field still showed logo_path: None because the sliding 5-minute cache from earlier testing never expired. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c03a75989d
commit
2138d4afc3
@@ -0,0 +1,114 @@
|
||||
"""`GET /api/v1/movies|series|people/{aurora_id}` responses are cached
|
||||
(`app/api/deps.py:cached_response`) with no write path invalidating them.
|
||||
Before this, re-ingesting an entity left its cached detail response stale
|
||||
for up to `CACHE_DEFAULT_TTL` -- discovered live when a company-logo fix
|
||||
didn't show up in a fresh `GET` right after re-ingestion.
|
||||
`_run_ingestion`'s `cache_entity_type` param deletes that entity's cache key
|
||||
on success so the next read reflects the just-completed ingestion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ids import aurora_id
|
||||
from app.repositories.provider_repository import create_ingestion_job, get_or_create_provider
|
||||
from app.services.cache import build_key
|
||||
from app.services.ingestion import runner
|
||||
|
||||
|
||||
async def _make_job(session: AsyncSession, *, attempts_already_used: int = 0) -> uuid.UUID:
|
||||
provider = await get_or_create_provider(session, "tmdb", "TMDB")
|
||||
job = await create_ingestion_job(
|
||||
session,
|
||||
provider_id=provider.id,
|
||||
job_type="movie_sync",
|
||||
idempotency_key=f"test:{uuid.uuid4()}",
|
||||
entity_type="movie",
|
||||
external_id="603",
|
||||
)
|
||||
job.attempts = attempts_already_used
|
||||
await session.flush()
|
||||
return job.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_ingestion_invalidates_the_entity_cache_key(
|
||||
db_session: AsyncSession, monkeypatch
|
||||
):
|
||||
job_id = await _make_job(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
result_id = uuid.uuid4()
|
||||
deleted_keys: list[str] = []
|
||||
|
||||
async def fake_work(session, provider):
|
||||
return result_id
|
||||
|
||||
async def fake_cache_delete(redis, *keys):
|
||||
deleted_keys.extend(keys)
|
||||
|
||||
monkeypatch.setattr(runner, "cache_delete", fake_cache_delete)
|
||||
monkeypatch.setattr(runner, "get_redis_pool", lambda: object())
|
||||
|
||||
returned_id = await runner._run_ingestion(
|
||||
job_id, "movie_ingestion", fake_work, cache_entity_type="movie"
|
||||
)
|
||||
|
||||
assert returned_id == result_id
|
||||
assert deleted_keys == [build_key("movie", aurora_id("movie", str(result_id)))]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingestion_without_cache_entity_type_does_not_touch_the_cache(
|
||||
db_session: AsyncSession, monkeypatch
|
||||
):
|
||||
job_id = await _make_job(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
async def fake_work(session, provider):
|
||||
return uuid.uuid4()
|
||||
|
||||
called = False
|
||||
|
||||
async def fake_cache_delete(redis, *keys):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(runner, "cache_delete", fake_cache_delete)
|
||||
|
||||
await runner._run_ingestion(job_id, "season_ingestion", fake_work)
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_ingestion_does_not_invalidate_the_cache(
|
||||
db_session: AsyncSession, monkeypatch
|
||||
):
|
||||
# attempts_already_used=4 (default max_attempts is 5) so this failure is
|
||||
# terminal and raises the original error rather than arq.Retry -- either
|
||||
# way the cache must not be touched, but this keeps the assertion simple.
|
||||
job_id = await _make_job(db_session, attempts_already_used=4)
|
||||
await db_session.commit()
|
||||
|
||||
async def failing_work(session, provider):
|
||||
raise RuntimeError("simulated failure")
|
||||
|
||||
called = False
|
||||
|
||||
async def fake_cache_delete(redis, *keys):
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(runner, "cache_delete", fake_cache_delete)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await runner._run_ingestion(
|
||||
job_id, "movie_ingestion", failing_work, cache_entity_type="movie"
|
||||
)
|
||||
|
||||
assert called is False
|
||||
Reference in New Issue
Block a user