Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**

---

Expand Down
1 change: 1 addition & 0 deletions app/services/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

---

Expand Down
3 changes: 3 additions & 0 deletions app/services/trend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

---

Expand All @@ -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)
```

---
Expand Down
52 changes: 52 additions & 0 deletions app/services/trend/cache_eviction.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions app/services/trend/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions scripts/run_trend_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion scripts/run_trend_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion tests/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

---
Expand Down
93 changes: 93 additions & 0 deletions tests/test_trend_cache_eviction.py
Original file line number Diff line number Diff line change
@@ -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))
67 changes: 67 additions & 0 deletions tests/test_trend_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading