diff --git a/docker-compose.yml b/docker-compose.yml index 1360613..1d92fb5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -206,6 +206,8 @@ services: - DATA_GO_KR_API_KEY=${DATA_GO_KR_API_KEY:-} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} - AGENT_OFFICE_URL=${AGENT_OFFICE_URL:-http://agent-office:8000} + - INTERNAL_API_KEY=${INTERNAL_API_KEY:-} + - NAVER_PAGE_LIMIT=${NAVER_PAGE_LIMIT:-2} - PYTHONPATH=/app:/shared volumes: - ${RUNTIME_PATH}/data/realestate:/app/data diff --git a/realestate-lab/app/internal_router.py b/realestate-lab/app/internal_router.py index 95c68e6..8d9dd12 100644 --- a/realestate-lab/app/internal_router.py +++ b/realestate-lab/app/internal_router.py @@ -11,6 +11,7 @@ from .lawd_codes import naver_cortar from .listing_collector import _parse_naver_article from .listing_matcher import run_listing_matching from .notifier import notify_new_listings +from .pipeline_lock import match_notify_lock logger = logging.getLogger("realestate-lab") router = APIRouter() @@ -61,7 +62,8 @@ def listings_ingest(body: ListingsIngest): logger.warning("네이버 ingest 파싱 실패: %s", e) matched = 0 if received: - run_listing_matching() - noti = notify_new_listings() - matched = int(noti.get("sent", 0)) if isinstance(noti, dict) else 0 + with match_notify_lock: + run_listing_matching() + noti = notify_new_listings() + matched = int(noti.get("sent", 0)) if isinstance(noti, dict) else 0 return {"received": received, "new": new, "matched": matched} diff --git a/realestate-lab/app/main.py b/realestate-lab/app/main.py index 122e1b5..6490804 100644 --- a/realestate-lab/app/main.py +++ b/realestate-lab/app/main.py @@ -23,6 +23,7 @@ from .matcher import run_matching from .notifier import notify_new_matches, notify_new_listings from .listing_collector import collect_listings from .listing_matcher import run_listing_matching, compute_safety, compute_valuation +from .pipeline_lock import match_notify_lock from . import finance_rules from .finance_rules_config import DISCLAIMER from .models import ( @@ -73,7 +74,10 @@ def _run_listing_pipeline(): if not _listing_collect_lock.acquire(blocking=False): logger.info("매물 수집 이미 진행 중 — 건너뜀"); return try: - collect_listings(); run_listing_matching(); notify_new_listings() + collect_listings() + with match_notify_lock: + run_listing_matching() + notify_new_listings() finally: _listing_collect_lock.release() diff --git a/realestate-lab/app/pipeline_lock.py b/realestate-lab/app/pipeline_lock.py new file mode 100644 index 0000000..ac9349d --- /dev/null +++ b/realestate-lab/app/pipeline_lock.py @@ -0,0 +1,5 @@ +"""매물 매칭+알림 임계구역 직렬화 락 — cron 파이프라인과 워커 ingest가 공유해 +run_listing_matching()+notify_new_listings() 동시 실행(중복 텔레그램)을 방지한다.""" +import threading + +match_notify_lock = threading.Lock() diff --git a/realestate-lab/tests/test_internal_ingest.py b/realestate-lab/tests/test_internal_ingest.py index 31aa9ea..4dfb16e 100644 --- a/realestate-lab/tests/test_internal_ingest.py +++ b/realestate-lab/tests/test_internal_ingest.py @@ -44,3 +44,16 @@ def test_ingest_idempotent(monkeypatch): c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload) r2 = c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, json=payload) assert r2.json()["new"] == 0 # 재push → 신규 0 + +def test_ingest_holds_match_notify_lock(monkeypatch): + from app import internal_router as ir + from app.pipeline_lock import match_notify_lock + c = _client(monkeypatch) + holds = {} + def fake_notify(): + holds["locked"] = match_notify_lock.locked() + return {"sent": 0} + monkeypatch.setattr(ir, "notify_new_listings", fake_notify) + c.post("/api/internal/realestate/listings-ingest", headers={"X-Internal-Key": "SECRET"}, + json={"batches": [{"dong": "신대방동", "articles": [_ARTICLE]}]}) + assert holds["locked"] is True # 알림 시점에 락 보유(임계구역 직렬화 증명)