diff --git a/.env.example b/.env.example index 31cdb13..69ee48a 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,10 @@ APP_ENV=local SERVER_PORT=8000 -# Backend API (deprecated — AI 서버가 PostgreSQL 직접 저장으로 전환) +# Backend API (트렌드 캐시 무효화 호출에 사용) BACKEND_URL=http://localhost:8080 + # PostgreSQL (AI 서버 직접 저장) DATABASE_URL=postgresql://user:password@localhost:5432/devpick diff --git a/CLAUDE.md b/CLAUDE.md index 1ee5a52..74261ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ - **수집 동향 LLM 서사 요약 CollectionSummaryGenerator + TrendSignals 구현 (DP-384)** - **트렌드 배치 오케스트레이터 TrendOrchestrator + run_trend_batch.py + run_trend_scheduler.py 구현 (DP-386)** - **트렌드 내부 API POST /internal/trends + GET /internal/trends/latest + GET /internal/trends/{period_start} 구현 (DP-385)** + - **트렌드 배치 완료 후 BE Redis 캐시 무효화 CacheEvictionClient 구현 (DP-387)** --- diff --git a/app/services/CLAUDE.md b/app/services/CLAUDE.md index a6b1d1a..a0b045f 100644 --- a/app/services/CLAUDE.md +++ b/app/services/CLAUDE.md @@ -29,6 +29,7 @@ | `trend/top_posts_summary.py` | `TopPostsSummaryGenerator` | Top 5 콘텐츠 주제 흐름 LLM 서사 요약 (DP-404) | | `trend/collection_summary.py` | `CollectionSummaryGenerator` | 수집 동향 LLM 서사 요약 + `TrendSignals` 데이터클래스 (DP-384) | | `trend/orchestrator.py` | `TrendOrchestrator` | 일/주/월 트렌드 배치 오케스트레이터 + `compute_period()` (DP-386) | +| `trend/cache_eviction.py` | `CacheEvictionClient` | 배치 완료 후 BE Redis 캐시 무효화 — best-effort (DP-387) | --- diff --git a/app/services/trend/CLAUDE.md b/app/services/trend/CLAUDE.md index 3e79e76..bd62b23 100644 --- a/app/services/trend/CLAUDE.md +++ b/app/services/trend/CLAUDE.md @@ -17,6 +17,7 @@ | `top_posts_summary.py` | `TopPostsSummaryGenerator` | Top 5 콘텐츠 주제 흐름 LLM 서사 요약 (DP-404) | | `collection_summary.py` | `CollectionSummaryGenerator`, `TrendSignals` | 수집 동향 LLM 서사 요약 (DP-384) | | `orchestrator.py` | `TrendOrchestrator`, `compute_period` | 배치 오케스트레이터 — 위 컴포넌트를 순서대로 조합 (DP-386) | +| `cache_eviction.py` | `CacheEvictionClient` | 배치 완료 후 BE Redis 캐시 무효화 요청 — best-effort (DP-387) | --- @@ -43,6 +44,8 @@ TopPostsSummaryGenerator.generate(top_contents_raw, unit, ..., prev_summary) → CollectionSummaryGenerator.generate(TrendSignals(...)) → collection_summary ↓ TrendResponse 조립 → TrendSnapshotRepository.upsert() + ↓ +CacheEvictionClient.evict(unit, period_start) # best-effort (DP-387) ``` --- diff --git a/app/services/trend/cache_eviction.py b/app/services/trend/cache_eviction.py new file mode 100644 index 0000000..88d1215 --- /dev/null +++ b/app/services/trend/cache_eviction.py @@ -0,0 +1,52 @@ +"""BE Redis 캐시 무효화 클라이언트 (DP-387).""" + +from __future__ import annotations + +import logging +from datetime import date + +import requests + +logger = logging.getLogger(__name__) + + +class CacheEvictionClient: + """BE DELETE /internal/trends/cache 를 호출해 Redis 캐시를 무효화한다. + + best-effort — 실패해도 예외를 전파하지 않는다. + 배치 완료 직후 호출되며, 실패 시 BE Redis TTL 만료 후 자동 갱신된다. + """ + + def __init__(self, base_url: str, internal_key: str, timeout: float = 5.0) -> None: + self._url = base_url.rstrip("/") + "/internal/trends/cache" + self._headers = {"X-Internal-Key": internal_key} + self._timeout = timeout + + def evict(self, unit: str, period_start: date, scope: str = "global") -> None: + """캐시 무효화를 요청한다. 실패해도 예외를 전파하지 않는다.""" + params = { + "unit": unit, + "scope": scope, + "periodStart": str(period_start), + } + try: + resp = requests.delete( + self._url, + headers=self._headers, + params=params, + timeout=self._timeout, + ) + if resp.status_code == 204: + logger.info( + "캐시 무효화 완료: unit=%s period_start=%s", unit, period_start + ) + else: + logger.warning( + "캐시 무효화 실패: unit=%s period_start=%s status=%d body=%s", + unit, + period_start, + resp.status_code, + resp.text[:200], + ) + except Exception as exc: + logger.warning("캐시 무효화 요청 오류 (best-effort): %s", exc) diff --git a/app/services/trend/orchestrator.py b/app/services/trend/orchestrator.py index 1881181..c4ec716 100644 --- a/app/services/trend/orchestrator.py +++ b/app/services/trend/orchestrator.py @@ -11,6 +11,7 @@ from app.repositories.summary_repository import SummaryRepository from app.repositories.trend_repository import TrendSnapshotRepository from app.schemas.trend import TopContent, TrendResponse +from app.services.trend.cache_eviction import CacheEvictionClient from app.services.trend.collection_summary import ( CollectionSummaryGenerator, TrendSignals, @@ -124,6 +125,8 @@ def __init__( database_url: str, aws_region: str = "ap-northeast-2", model: str = "global.anthropic.claude-sonnet-4-6", + backend_url: str | None = None, + internal_key: str | None = None, ) -> None: content_repo = ContentRepository(database_url) summary_repo = SummaryRepository(aws_region=aws_region) @@ -143,6 +146,11 @@ def __init__( model=model, ) self._snapshot_repo = TrendSnapshotRepository(database_url) + self._cache_client: CacheEvictionClient | None = ( + CacheEvictionClient(backend_url, internal_key) + if backend_url and internal_key + else None + ) def run( self, @@ -250,6 +258,9 @@ def run( generated_at=datetime.now(tz=timezone.utc), ) + if self._cache_client: + self._cache_client.evict(unit, period_start) + logger.info( "트렌드 배치 완료: unit=%s period=%s~%s contents=%d top_posts=%d", unit, diff --git a/scripts/run_trend_batch.py b/scripts/run_trend_batch.py index b1b3037..18a9270 100644 --- a/scripts/run_trend_batch.py +++ b/scripts/run_trend_batch.py @@ -83,15 +83,23 @@ def main() -> None: period_start, period_end = compute_period(args.unit) aws_region = os.environ.get("AWS_REGION", "ap-northeast-2") + backend_url = os.environ.get("BACKEND_URL") + internal_key = os.environ.get("INTERNAL_API_KEY") logger.info( - "트렌드 배치 시작: unit=%s period=%s~%s force=%s", + "트렌드 배치 시작: unit=%s period=%s~%s force=%s cache_evict=%s", args.unit, period_start, period_end, args.force, + bool(backend_url and internal_key), ) - orchestrator = TrendOrchestrator(database_url=database_url, aws_region=aws_region) + orchestrator = TrendOrchestrator( + database_url=database_url, + aws_region=aws_region, + backend_url=backend_url, + internal_key=internal_key, + ) result = orchestrator.run( unit=args.unit, period_start=period_start, diff --git a/scripts/run_trend_scheduler.py b/scripts/run_trend_scheduler.py index 0645230..bcd6670 100644 --- a/scripts/run_trend_scheduler.py +++ b/scripts/run_trend_scheduler.py @@ -32,6 +32,8 @@ _DATABASE_URL = os.environ.get("DATABASE_URL", "") _AWS_REGION = os.environ.get("AWS_REGION", "ap-northeast-2") +_BACKEND_URL = os.environ.get("BACKEND_URL") +_INTERNAL_KEY = os.environ.get("INTERNAL_API_KEY") def _run(unit: str) -> None: @@ -40,7 +42,10 @@ def _run(unit: str) -> None: return try: orchestrator = TrendOrchestrator( - database_url=_DATABASE_URL, aws_region=_AWS_REGION + database_url=_DATABASE_URL, + aws_region=_AWS_REGION, + backend_url=_BACKEND_URL, + internal_key=_INTERNAL_KEY, ) period_start, period_end = compute_period(unit) orchestrator.run(unit, period_start, period_end) diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 0304aa9..137171a 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -48,7 +48,8 @@ | `test_trend_data_loader.py` | `TrendDataLoader` | cur/prev 기간 병렬 로드, prev_contents 포함 확인 (DP-379, DP-386) | | `test_trend_collection_summary.py` | `CollectionSummaryGenerator` | 수집 동향 LLM 서사 요약 mock 테스트 (DP-384) | | `test_trend_top_posts_summary.py` | `TopPostsSummaryGenerator` | Top 5 콘텐츠 LLM 서사 요약 mock 테스트 (DP-404) | -| `test_trend_orchestrator.py` | `TrendOrchestrator` | 배치 오케스트레이터 통합 흐름, skip/force/LLM 실패 격리 (DP-386) | +| `test_trend_orchestrator.py` | `TrendOrchestrator` | 배치 오케스트레이터 통합 흐름, skip/force/LLM 실패 격리, 캐시 무효화 통합 (DP-386, DP-387) | +| `test_trend_cache_eviction.py` | `CacheEvictionClient` | URL 조합, 성공/비204/네트워크 오류/타임아웃 best-effort 처리 (DP-387) | | `test_trend_endpoint.py` | `POST/GET /internal/trends` | 트렌드 엔드포인트 통합 테스트 — 수동 생성, 0건/5건미만 에러, 조회 404 (DP-385) | --- diff --git a/tests/test_trend_cache_eviction.py b/tests/test_trend_cache_eviction.py new file mode 100644 index 0000000..ce708bb --- /dev/null +++ b/tests/test_trend_cache_eviction.py @@ -0,0 +1,93 @@ +"""CacheEvictionClient 단위 테스트 (DP-387).""" + +from __future__ import annotations + +from datetime import date +from unittest.mock import MagicMock, patch + +from app.services.trend.cache_eviction import CacheEvictionClient + + +def _client( + base_url: str = "http://backend:8080", key: str = "test-key" +) -> CacheEvictionClient: + return CacheEvictionClient(base_url, key) + + +# ── URL 조합 ─────────────────────────────────────────────────────────────────── + + +def test_url_built_from_base_url() -> None: + assert ( + _client("http://backend:8080")._url + == "http://backend:8080/internal/trends/cache" + ) + + +def test_trailing_slash_stripped() -> None: + assert ( + _client("http://backend:8080/")._url + == "http://backend:8080/internal/trends/cache" + ) + + +# ── 성공 케이스 ──────────────────────────────────────────────────────────────── + + +def test_evict_calls_delete_with_correct_params() -> None: + mock_resp = MagicMock() + mock_resp.status_code = 204 + + with patch("requests.delete", return_value=mock_resp) as mock_delete: + _client().evict("daily", date(2026, 4, 25)) + + mock_delete.assert_called_once_with( + "http://backend:8080/internal/trends/cache", + headers={"X-Internal-Key": "test-key"}, + params={"unit": "daily", "scope": "global", "periodStart": "2026-04-25"}, + timeout=5.0, + ) + + +def test_evict_scope_default_global() -> None: + mock_resp = MagicMock() + mock_resp.status_code = 204 + + with patch("requests.delete", return_value=mock_resp) as mock_delete: + _client().evict("weekly", date(2026, 4, 21)) + + call_params = mock_delete.call_args.kwargs["params"] + assert call_params["scope"] == "global" + + +# ── best-effort: 실패해도 예외 미전파 ───────────────────────────────────────── + + +def test_non_204_response_does_not_raise() -> None: + mock_resp = MagicMock() + mock_resp.status_code = 403 + mock_resp.text = "Forbidden" + + with patch("requests.delete", return_value=mock_resp): + _client().evict("daily", date(2026, 4, 25)) # 예외 없어야 함 + + +def test_404_response_does_not_raise() -> None: + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_resp.text = "Not Found" + + with patch("requests.delete", return_value=mock_resp): + _client().evict("monthly", date(2026, 4, 1)) + + +def test_network_error_does_not_raise() -> None: + with patch("requests.delete", side_effect=ConnectionError("refused")): + _client().evict("weekly", date(2026, 4, 21)) + + +def test_timeout_error_does_not_raise() -> None: + import requests as req + + with patch("requests.delete", side_effect=req.Timeout("timeout")): + _client().evict("daily", date(2026, 4, 25)) diff --git a/tests/test_trend_orchestrator.py b/tests/test_trend_orchestrator.py index 07bc004..5d98355 100644 --- a/tests/test_trend_orchestrator.py +++ b/tests/test_trend_orchestrator.py @@ -239,3 +239,70 @@ def test_compute_period_monthly() -> None: def test_compute_period_invalid_unit() -> None: with pytest.raises(ValueError): compute_period("quarterly") + + +# ── 캐시 무효화 통합 (DP-387) ────────────────────────────────────────────────── + + +def _make_orchestrator_with_cache( + backend_url: str | None, key: str | None +) -> TrendOrchestrator: + with ( + patch("app.services.trend.orchestrator.ContentRepository"), + patch("app.services.trend.orchestrator.SummaryRepository"), + patch("app.services.trend.orchestrator.TrendSnapshotRepository"), + patch("app.services.trend.orchestrator.TrendDataLoader"), + patch("app.services.trend.orchestrator.KoreanTokenizer"), + patch("app.services.trend.orchestrator.TopPostsSummaryGenerator"), + patch("app.services.trend.orchestrator.CollectionSummaryGenerator"), + ): + orch = TrendOrchestrator( + "postgresql://test", + aws_region="us-east-1", + backend_url=backend_url, + internal_key=key, + ) + orch._loader = MagicMock() + orch._normalizer = MagicMock() + orch._freq = MagicMock() + orch._tokenizer = MagicMock() + orch._tfidf = MagicMock() + orch._ranker = MagicMock() + orch._top_posts_gen = MagicMock() + orch._collection_gen = MagicMock() + orch._snapshot_repo = MagicMock() + return orch + + +def test_cache_client_created_when_both_params_set() -> None: + orch = _make_orchestrator_with_cache("http://be:8080", "secret") + assert orch._cache_client is not None + assert orch._cache_client._url == "http://be:8080/internal/trends/cache" + + +def test_cache_client_none_when_key_missing() -> None: + assert _make_orchestrator_with_cache("http://be:8080", None)._cache_client is None + + +def test_cache_client_none_when_url_missing() -> None: + assert _make_orchestrator_with_cache(None, "secret")._cache_client is None + + +def test_run_calls_cache_eviction_after_upsert() -> None: + orch = _make_orchestrator_with_cache("http://be:8080", "secret") + _setup_defaults(orch) + + with patch.object(orch._cache_client, "evict") as mock_evict: + orch.run("weekly", _PERIOD_START, _PERIOD_END) + + orch._snapshot_repo.upsert.assert_called_once() + mock_evict.assert_called_once_with("weekly", _PERIOD_START) + + +def test_run_skips_cache_eviction_when_no_client() -> None: + orch = _make_orchestrator_with_cache(None, None) + _setup_defaults(orch) + + orch.run("weekly", _PERIOD_START, _PERIOD_END) + + orch._snapshot_repo.upsert.assert_called_once()