o2o-negosium-original/lps/tests/test_port_registry.py
민헌 f80cece9c1 feat(lps): 프록시 포트(IP 세션) 공용 중재자 — 소스·워커 간 IP 충돌·재사용 방지
한 DECODO 계정으로 성격이 다른 두 풀을 쓰게 됐다:
  쿠팡   gate.decodo.com  국가 무지정(실측 VN·MY·BD·ID·KZ·IN·PH)
  네이버 kr.decodo.com    한국 전용(LG U+·KT·SK브로드밴드)
네이버는 해외 IP 를 즉시 하드차단한다 — 같은 포트 10091 에서 gate=차단(2,641B) /
kr=정상 14건. 같은 포트 번호라도 게이트웨이가 다르면 IP 가 다르다
(port 10061 → gate=103.99.27.55(ID) / kr=121.180.128.2(KR)) → 자원 키는 (host, port).

PortRegistry 가 프로세스 전체의 포트를 중재한다:
- 배타 임대: 한 (host,port) 는 동시에 한 소유자만. 워커 N개가 회전하다 같은 IP 로
  수렴해 한 IP 에 요청이 몰리던 문제를 없앤다(seed_offset 은 시작점만 벌렸다)
- 전역 쿨다운: 누가 태웠든 만료 전까지 아무도 못 집는다(예전엔 쿨다운이 프록시
  인스턴스별이라 다른 소스가 곧바로 재사용했다)
- 리스 만료 = sticky 수명(session_minutes) → 자동 반납 후 새 IP
- 전 포트 소진 시 가장 빨리 풀릴 포트를 회수(멈추는 것보다 낫다)
- snapshot() 을 ops 알림 페이로드에 실어 게이트웨이별 보유/쿨다운/소유자를 남긴다

DecodoProxy 는 registry 주입 시에만 임대 경로를 타고, 미주입이면 기존 동작 그대로다
(단독 사용·기존 테스트 경로 보존).

검증: 워커 2개 동시 실행 e2e — 쿠팡·네이버 병렬 수집, 삼다수에서 네이버가 최저가
획득(naver 8,960 < coupang 13,200). 테스트 14건 추가, 전체 196 passed.
2026-08-05 10:59:49 +09:00

161 lines
6.7 KiB
Python

"""포트(=IP 세션) 중재자 테스트.
여기서 지키는 계약은 운영 사고와 직결된다:
- 한 IP 를 두 소유자가 동시에 쓰면 그 IP 가 빨리 탄다
- 태운 IP 를 다른 소유자가 곧바로 집으면 차단이 전파된다
- 게이트웨이가 다르면 같은 포트 번호라도 다른 IP 다(실측) — 서로 간섭하면 안 된다
"""
import time
import pytest
from services.search.port_registry import PortRegistry
from services.search.proxy import DecodoProxy
from config.config_models import DecodoConfig
GATE, KR = "gate.decodo.com", "kr.decodo.com"
def _reg(n: int = 5) -> PortRegistry:
return PortRegistry(10001, 10000 + n)
# ── 배타 할당 ────────────────────────────────────────────────────────────
def test_same_port_is_never_handed_to_two_owners():
reg = _reg()
taken = {reg.acquire(GATE, f"w{i}") for i in range(5)}
assert len(taken) == 5, "5개 포트를 5명이 나눠 가져야 한다(중복 배정 없음)"
def test_released_port_returns_to_pool():
reg = _reg(2)
a = reg.acquire(GATE, "coupang")
b = reg.acquire(GATE, "naver")
reg.release(GATE, a, "coupang")
assert reg.acquire(GATE, "naver2") == a # 반납된 포트가 다시 나온다
assert b != a
def test_release_by_wrong_owner_is_ignored():
"""뒤늦은 반납이 남의 리스를 깨면 그 순간 두 소유자가 같은 IP 를 쓰게 된다."""
reg = _reg(1)
p = reg.acquire(GATE, "coupang")
reg.release(GATE, p, "naver") # 남이 반납 시도 → 무시
assert reg.available(GATE) == 0
# ── 전역 쿨다운 ──────────────────────────────────────────────────────────
def test_burned_port_is_avoided_by_every_owner():
reg = _reg(2)
p = reg.acquire(GATE, "coupang")
reg.burn(GATE, p, 600, owner="coupang", reason="block")
other = reg.acquire(GATE, "naver")
assert other != p, "다른 소유자도 태운 포트를 피해야 한다"
def test_burn_releases_the_lease():
reg = _reg(3)
p = reg.acquire(GATE, "coupang")
reg.burn(GATE, p, 600)
assert p not in [v for v in reg._held] # 보유 해제
assert reg.available(GATE) == 2 # 3 - 쿨다운 1
def test_cooldown_expiry_returns_port():
reg = _reg(1)
p = reg.acquire(GATE, "coupang")
reg.burn(GATE, p, -1) # 이미 만료된 쿨다운
assert reg.available(GATE) == 1
assert reg.acquire(GATE, "naver") == p
# ── 게이트웨이 격리 ──────────────────────────────────────────────────────
def test_gateways_do_not_interfere():
"""같은 포트 번호라도 gate/kr 은 다른 IP(실측) — 한쪽 점유·차단이 다른 쪽을 막으면 안 된다."""
reg = _reg(1)
g = reg.acquire(GATE, "coupang")
k = reg.acquire(KR, "naver")
assert g == k == 10001 # 번호는 같아도 서로 다른 자원
reg.burn(GATE, g, 600)
assert reg.available(GATE) == 0 and reg.available(KR) == 0 # kr 은 여전히 naver 가 보유
reg.release(KR, k, "naver")
assert reg.available(KR) == 1
# ── 소진 시 동작 ─────────────────────────────────────────────────────────
def test_exhausted_pool_reclaims_soonest_expiring():
"""전부 쿨다운이면 멈추는 대신 가장 빨리 풀릴 포트를 회수한다(가용성 우선)."""
reg = _reg(2)
reg.burn(GATE, 10001, 600)
reg.burn(GATE, 10002, 60) # 얘가 더 빨리 풀린다
assert reg.acquire(GATE, "naver") == 10002
def test_lease_expiry_is_reported():
reg = _reg(1)
p = reg.acquire(GATE, "coupang")
assert reg.lease_expired(GATE, p, "coupang", lease_sec=600) is False
assert reg.lease_expired(GATE, p, "naver", lease_sec=600) is True # 소유자 아님
reg._held[(GATE, p)] = ("coupang", time.monotonic() - 700)
assert reg.lease_expired(GATE, p, "coupang", lease_sec=600) is True
def test_snapshot_reports_per_gateway_owners():
reg = _reg(4)
reg.acquire(GATE, "coupang-w0")
reg.acquire(GATE, "coupang-w1")
reg.acquire(KR, "naver-w0")
reg.burn(KR, 10004, 600)
snap = reg.snapshot()
assert snap[GATE]["held"] == 2 and snap[GATE]["owners"] == {"coupang-w0": 1, "coupang-w1": 1}
assert snap[KR]["held"] == 1 and snap[KR]["cooling"] == 1
assert snap[KR]["available"] == 2
# ── DecodoProxy 통합 ─────────────────────────────────────────────────────
def _cfg() -> DecodoConfig:
return DecodoConfig(host=GATE, kr_host=KR, username="u", password="p",
port_start=10001, port_end=10003, session_minutes=10)
def test_two_sources_never_share_a_port_through_registry():
reg = _reg(3)
cou = DecodoProxy(_cfg(), registry=reg, owner="coupang-w0")
nav = DecodoProxy(_cfg(), host=KR, registry=reg, owner="naver-w0")
# 게이트웨이가 다르면 번호가 같아도 무방하지만, 같은 게이트웨이를 공유하면 갈려야 한다
nav_gate = DecodoProxy(_cfg(), registry=reg, owner="naver-w0")
assert cou._port() != nav_gate._port()
assert nav._port() is not None
def test_rotate_releases_and_takes_new_port():
reg = _reg(3)
p = DecodoProxy(_cfg(), registry=reg, owner="coupang-w0")
first = p._port()
p.rotate()
second = p._port()
assert first != second
assert reg.available(GATE) == 2 # 이전 포트는 반납돼 풀에 남는다(쿨다운 아님)
def test_mark_burned_goes_global_through_registry():
reg = _reg(3)
cou = DecodoProxy(_cfg(), registry=reg, owner="coupang-w0")
nav = DecodoProxy(_cfg(), registry=reg, owner="naver-w0")
port = cou._port()
cou.mark_burned(port)
assert cou.available_ports() == 2 # 3 - 쿨다운 1 (보유 없음)
assert nav._port() != port, "쿠팡이 태운 포트를 네이버가 집으면 안 된다"
assert cou.available_ports() == 1 # 3 - 쿨다운 1 - 네이버 보유 1
def test_without_registry_behaviour_is_unchanged():
"""registry 미주입 경로(단독 사용·기존 테스트)는 그대로 동작해야 한다."""
p = DecodoProxy(_cfg())
port = p._port()
assert 10001 <= port <= 10003
p.mark_burned(port)
assert p.available_ports() == 2
assert p._port() != port