한 DECODO 계정을 여러 워커 프로세스가 나눠 쓰는 전제로 전환한다. 인메모리 장부는 프로세스마다 따로라 (1) 같은 IP 를 동시에 잡고 (2) 한쪽이 태운 IP 를 다른 쪽이 곧바로 집으며 (3) 재시작하면 쿨다운이 통째로 사라졌다. proxy_port 테이블 = 단일 진실. 상태는 세 시각으로만 표현한다(leased/rest/cooldown_until). - acquire: 한 UPDATE 안에서 FOR UPDATE SKIP LOCKED 로 후보를 잠그고 임대까지 끝낸다 (잡 큐와 같은 방식 — SELECT 후 UPDATE 로 나누면 그 틈에 다른 프로세스가 같은 행을 집는다) - 회전은 LRU(last_used_at). 프로세스가 몇 개든 '가장 오래 안 쓴 IP'를 집으므로 전체가 자연히 한 바퀴씩 돈다 → 프로세스별 seed_offset 계산 제거 - 죽은 프로세스 회수: leased_until 만료로 자동 복귀(별도 reaper 불필요) - 차단·휴식은 전역이라 재시작해도 유지된다 DB 왕복은 비동기라 검색 루프(동기)에서 곧바로 못 한다 → 회전·차단을 pending 에 적어두고 ensure_port(브라우저 재기동 직전, async)에서 한 번에 flush. _close_ctx 에서도 flush 해 종료 시 유실(=태운 IP 를 남이 그대로 집는 상황)을 막는다. **프로필 슬롯**(services/search/profile_slot): Chrome 은 user_data_dir 당 1 인스턴스다. 예전엔 워커 인덱스로만 갈라서 프로세스 2개면 같은 경로를 잡아 두 번째가 통째로 죽었다 (실측: 잡 3건 중 2건 DEAD, TargetClosedError). 파일 락으로 슬롯을 선점한다 — PID 경로가 아니라 슬롯이라 재시작 시 재사용돼 웜 쿠키(cf_clearance·Akamai)를 버리지 않는다. 검증: 프로세스 2개 동시 acquire 20회 → 중복 배정 0건. 워커 2프로세스 e2e → 잡 3건 모두 DONE(네이버가 삼다수 최저가 획득 8,960 < 13,200). 테스트 14건 추가, 전체 217 passed.
139 lines
7.2 KiB
Python
139 lines
7.2 KiB
Python
"""프로세스 간 공유 포트 장부(proxy_port) 테스트 — 실제 DB 필요.
|
|
|
|
여기서 지키는 계약은 멀티 프로세스 운영과 직결된다:
|
|
- 두 프로세스가 같은 IP 를 동시에 잡으면 그 IP 가 두 배 속도로 탄다
|
|
- 한 프로세스가 태운 IP 를 다른 프로세스가 곧바로 집으면 차단이 전파된다
|
|
- 워커가 죽은 채로 임대를 쥐고 있으면 그 IP 는 영영 안 돌아온다(임대 만료로 자동 회수돼야 함)
|
|
"""
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.enums import DBType, DBWRType
|
|
from crud.port_lease import PortLeaseStore
|
|
|
|
HOST_A, HOST_B = "test-gate.example", "test-kr.example"
|
|
P_START, P_END = 20001, 20005
|
|
|
|
|
|
@pytest.fixture
|
|
async def store(db_engine):
|
|
st = PortLeaseStore()
|
|
await _exec("DELETE FROM proxy_port WHERE host IN (:a, :b)", {"a": HOST_A, "b": HOST_B})
|
|
await st.ensure_ports(HOST_A, P_START, P_END)
|
|
await st.ensure_ports(HOST_B, P_START, P_END)
|
|
yield st
|
|
await _exec("DELETE FROM proxy_port WHERE host IN (:a, :b)", {"a": HOST_A, "b": HOST_B})
|
|
|
|
|
|
async def _exec(sql: str, params: dict | None = None):
|
|
s = await DB_SESSION_MNG.start_session(DBType.MAIN.value, DBWRType.DB_WRITE.value)
|
|
try:
|
|
await s.execute(text(sql), params or {})
|
|
await s.commit()
|
|
finally:
|
|
await DB_SESSION_MNG.end_session(DBType.MAIN.value, DBWRType.DB_WRITE.value)
|
|
|
|
|
|
# ── 배타 임대(프로세스 간) ───────────────────────────────────────────────
|
|
async def test_no_two_owners_get_the_same_port(store):
|
|
"""서로 다른 프로세스를 흉내낸 owner 5개가 5포트를 하나씩 나눠 가져야 한다."""
|
|
got = [await store.acquire(HOST_A, f"p{i}-w0", 600) for i in range(5)]
|
|
assert None not in got
|
|
assert len(set(got)) == 5
|
|
|
|
|
|
async def test_pool_exhaustion_returns_none(store):
|
|
for i in range(5):
|
|
assert await store.acquire(HOST_A, f"p{i}", 600) is not None
|
|
assert await store.acquire(HOST_A, "p-late", 600) is None # 남는 포트 없음 → 호출부가 대기/재시도
|
|
|
|
|
|
async def test_gateways_are_independent(store):
|
|
"""같은 포트 번호라도 게이트웨이가 다르면 다른 IP(실측) — 한쪽 임대가 다른 쪽을 막으면 안 된다."""
|
|
a = await store.acquire(HOST_A, "coupang", 600)
|
|
b = await store.acquire(HOST_B, "naver", 600)
|
|
assert a == b == P_START # 번호는 같아도 서로 다른 자원
|
|
assert (await store.available(HOST_A)) == 4 and (await store.available(HOST_B)) == 4
|
|
|
|
|
|
# ── 차단·휴식(전역) ──────────────────────────────────────────────────────
|
|
async def test_burned_port_is_invisible_to_every_process(store):
|
|
p = await store.acquire(HOST_A, "coupang-p1", 600)
|
|
await store.burn(HOST_A, p, 1800, owner="coupang-p1")
|
|
others = {await store.acquire(HOST_A, f"naver-p{i}", 600) for i in range(4)}
|
|
assert p not in others # 다른 프로세스도 태운 IP 를 못 집는다
|
|
assert await store.acquire(HOST_A, "naver-p9", 600) is None
|
|
|
|
|
|
async def test_rested_port_is_held_back_then_returns(store):
|
|
p = await store.acquire(HOST_A, "naver-p1", 600)
|
|
await store.release(HOST_A, p, "naver-p1", rest_sec=600)
|
|
others = {await store.acquire(HOST_A, f"x{i}", 600) for i in range(4)}
|
|
assert p not in others # 쉬는 동안은 아무도 못 집는다
|
|
await store.release(HOST_A, p, "nobody", rest_sec=0) # 소유자 불일치 → 무시돼야 함
|
|
assert await store.acquire(HOST_A, "y", 600) is None
|
|
await _exec("UPDATE proxy_port SET rest_until = now() - interval '1 s' WHERE host=:h AND port=:p",
|
|
{"h": HOST_A, "p": p})
|
|
assert await store.acquire(HOST_A, "z", 600) == p # 휴식 만료 → 복귀
|
|
|
|
|
|
async def test_release_without_rest_returns_port_to_pool(store):
|
|
"""반납된 포트는 곧바로 풀에 돌아온다. 단 **다음 차례가 되는 건 아니다** —
|
|
LRU 라 한 번도 안 쓴 포트가 먼저 나가고, 반납분은 한 바퀴 뒤에 다시 온다(회전의 정의)."""
|
|
p = await store.acquire(HOST_A, "naver-p1", 600)
|
|
await store.release(HOST_A, p, "naver-p1")
|
|
assert await store.available(HOST_A) == 5 # 5개 전부 가용
|
|
|
|
picked = [await store.acquire(HOST_A, f"w{i}", 600) for i in range(5)]
|
|
assert p in picked and picked[-1] == p # 가장 최근 사용분이 맨 마지막
|
|
|
|
|
|
# ── 죽은 프로세스 회수 ───────────────────────────────────────────────────
|
|
async def test_expired_lease_is_reclaimed_without_a_reaper(store):
|
|
"""워커가 임대를 쥔 채 죽어도 sticky 수명이 지나면 자동으로 풀려야 한다."""
|
|
p = await store.acquire(HOST_A, "dead-process", 600)
|
|
assert await store.available(HOST_A) == 4 # 죽은 프로세스가 쥔 동안은 빠져 있고
|
|
await _exec("UPDATE proxy_port SET leased_until = now() - interval '1 s' WHERE host=:h AND port=:p",
|
|
{"h": HOST_A, "p": p})
|
|
assert await store.available(HOST_A) == 5 # 임대 만료 → 별도 정리 없이 자동 복귀
|
|
picked = [await store.acquire(HOST_A, f"alive{i}", 600) for i in range(5)]
|
|
assert p in picked
|
|
|
|
|
|
async def test_renew_keeps_the_lease_and_fails_after_takeover(store):
|
|
p = await store.acquire(HOST_A, "p1", 600)
|
|
assert await store.renew(HOST_A, p, "p1", 600) is True
|
|
await _exec("UPDATE proxy_port SET owner='p2' WHERE host=:h AND port=:p", {"h": HOST_A, "p": p})
|
|
assert await store.renew(HOST_A, p, "p1", 600) is False # 남이 가져감 → 새로 잡아야 함
|
|
|
|
|
|
# ── 회전(LRU) ────────────────────────────────────────────────────────────
|
|
async def test_rotation_is_least_recently_used(store):
|
|
"""프로세스가 몇 개든 '가장 오래 안 쓴 IP'를 집으므로 전체가 한 바퀴씩 돈다."""
|
|
first = []
|
|
for i in range(5): # 5포트를 한 바퀴 소진
|
|
p = await store.acquire(HOST_A, f"w{i}", 600)
|
|
first.append(p)
|
|
await store.release(HOST_A, p, f"w{i}")
|
|
second = []
|
|
for i in range(5):
|
|
p = await store.acquire(HOST_A, f"w{i}", 600)
|
|
second.append(p)
|
|
await store.release(HOST_A, p, f"w{i}")
|
|
assert sorted(first) == sorted(second) # 같은 5개를
|
|
assert first == second # 같은 순서로(LRU) — 특정 IP 편중 없음
|
|
|
|
|
|
async def test_snapshot_counts_each_state(store):
|
|
held = await store.acquire(HOST_A, "w0", 600)
|
|
rested = await store.acquire(HOST_A, "w1", 600)
|
|
await store.release(HOST_A, rested, "w1", rest_sec=600)
|
|
burned = await store.acquire(HOST_A, "w2", 600)
|
|
await store.burn(HOST_A, burned, 1800)
|
|
snap = (await store.snapshot(HOST_A))[HOST_A]
|
|
assert snap["held"] == 1 and snap["resting"] == 1 and snap["cooling"] == 1
|
|
assert snap["available"] == 2 and snap["total"] == 5
|
|
assert held != rested != burned
|