From b403ca21c98774bf93d59c4f607d6795947151d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Thu, 6 Aug 2026 13:25:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(postgres-init):=20LPS=202026-08=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=B6=94=EA=B0=80=EB=B6=84=20dbe?= =?UTF-8?q?aver=20=ED=8C=8C=EC=9D=BC=20=E2=80=94=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EC=9B=8C=EC=BB=A4=20=EA=B8=B0=EB=8F=99=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 워커가 기동 중 크래시 루프에 빠졌다: UndefinedTableError: relation "proxy_port" does not exist (worker_main.py:336 store.ensure_ports → 기동 시 반드시 만지는 테이블) 3_lps_dbeaver.sql 이후 늘어난 스키마가 운영 DB(172.30.1.36/lps_db)에 반영되지 않은 것이다. 모델은 바뀌었지만 이미 만들어진 DB 는 자동으로 따라오지 않는다. 6_lps_2026-08_dbeaver.sql 신규: - proxy_port 테이블 + LRU 인덱스(ix_proxy_port_pick) - price_history 신뢰 신호 2컬럼(final_rating, final_review_count) - price_history 배송 3컬럼(final_shipping_fee/type/label) - 끝에 검증 SELECT — 워커가 요구하는 테이블 6종의 존재 여부를 한눈에 확인 전부 IF NOT EXISTS 라 재실행 안전하고, 신규 설치(3 → 6)와 기존 DB 양쪽에 같은 파일을 쓴다. 검증: 기존 lps_db 재실행(NOTICE 만, 오류 없음) + 빈 DB 에 3 → 6 순서 적용 후 컬럼이 모델과 일치. Co-Authored-By: Claude Opus 5 --- .../dbeaver/6_lps_2026-08_dbeaver.sql | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql diff --git a/postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql b/postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql new file mode 100644 index 0000000..20ead13 --- /dev/null +++ b/postgres-init/dbeaver/6_lps_2026-08_dbeaver.sql @@ -0,0 +1,52 @@ +-- LPS 2026-08 스키마 추가분 — **lps_db 에 연결해서 실행**. 재실행 안전(IF NOT EXISTS). +-- +-- 3_lps_dbeaver.sql 이후에 늘어난 것들이다. 이미 만들어진 DB(dev·운영)는 모델이 바뀌어도 +-- 자동으로 따라오지 않으므로 이 파일로 맞춘다. 신규 설치는 3 → 6 순서로 실행하면 된다. +-- +-- ⚠️ 안 돌리면 워커가 기동 중 죽는다 — proxy_port 를 기동 시 반드시 만지기 때문이다 +-- (실측 2026-08-06 운영: UndefinedTableError: relation "proxy_port" does not exist → 크래시 루프). + +-- ── 1) 프록시 포트(=IP 세션) 임대 장부 ──────────────────────────────────────── +-- 한 DECODO 계정을 여러 워커 **프로세스**가 나눠 쓴다. 인메모리로 관리하면 서로의 임대·차단을 +-- 몰라 같은 IP 를 동시에 잡거나(요청이 몰려 그 IP 가 빨리 탄다) 방금 태운 IP 를 곧바로 재사용한다. +-- 그래서 잡 큐와 같은 방식(FOR UPDATE SKIP LOCKED)으로 DB 에서 배타 임대한다. +CREATE TABLE IF NOT EXISTS proxy_port ( + host VARCHAR(80) NOT NULL, -- 게이트웨이(gate/kr — 같은 번호라도 IP 가 다름) + port INTEGER NOT NULL, + owner VARCHAR(80) NULL, -- 현재 임대자(소스-PID-워커) + leased_until TIMESTAMPTZ NULL, -- 임대 만료(=sticky 수명). 프로세스가 죽어도 자동 회수 + rest_until TIMESTAMPTZ NULL, -- 휴식 만료(예산 선제 회전 — 탄 게 아님) + cooldown_until TIMESTAMPTZ NULL, -- 쿨다운 만료(차단 — 전역 격리) + last_used_at TIMESTAMPTZ NULL, -- 마지막 임대 시각(LRU 회전 기준) + last_reason VARCHAR(40) NULL, -- acquire/release/rest/block + use_count INTEGER NOT NULL DEFAULT 0, + burn_count INTEGER NOT NULL DEFAULT 0, -- 누적 차단(불량 IP 슬롯 식별) + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (host, port) +); +-- LRU 배정용 — '가장 오래 안 쓴 IP' 를 게이트웨이별로 뽑는다 +CREATE INDEX IF NOT EXISTS ix_proxy_port_pick ON proxy_port (host, last_used_at); + +-- ── 2) price_history — 최저가 오퍼의 신뢰 신호 ──────────────────────────────── +-- 최저가는 '가장 싼 값'이 아니라 '실제로 살 수 있는 가장 싼 값'이어야 한다. 리뷰·평점이 전혀 없는 +-- 오퍼는 재고 없는 미끼가격일 수 있고, 그걸 최저가로 보고하면 사용자는 그 가격에 살 수 없다. +-- NULL(정보 없음)과 0(리뷰 0개)은 뜻이 다르므로 기본값을 두지 않는다. +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_rating NUMERIC(3,2); +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_review_count INTEGER; + +-- ── 3) price_history — 최저가 오퍼의 배송 정보 ──────────────────────────────── +-- 순위는 상품가로 매긴다(배송 주체가 로켓/판매자로켓/네이버 판매자로 갈리면 배송비 숫자만으로는 +-- 비교가 무의미하다). 그래도 기록은 남겨야 나중에 '배송비를 더하면 순위가 뒤집히는 비율'을 +-- 데이터로 판단할 수 있다. fee 는 0=무료 / NULL=미확인(로켓처럼 조건부 무료라 금액 표기가 없는 경우). +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_fee INTEGER; +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_type VARCHAR(20); +ALTER TABLE price_history ADD COLUMN IF NOT EXISTS final_shipping_label VARCHAR(120); + +-- ── 검증 ────────────────────────────────────────────────────────────────────── +-- 워커가 기동 시 요구하는 테이블이 다 있는지 확인한다(전부 OK 여야 정상 기동). +SELECT 'proxy_port' AS relation, CASE WHEN to_regclass('public.proxy_port') IS NULL THEN 'MISSING' ELSE 'OK' END AS status +UNION ALL SELECT 'ip_session', CASE WHEN to_regclass('public.ip_session') IS NULL THEN 'MISSING' ELSE 'OK' END +UNION ALL SELECT 'price_history', CASE WHEN to_regclass('public.price_history') IS NULL THEN 'MISSING' ELSE 'OK' END +UNION ALL SELECT 'bot_detection', CASE WHEN to_regclass('public.bot_detection') IS NULL THEN 'MISSING' ELSE 'OK' END +UNION ALL SELECT 'job', CASE WHEN to_regclass('public.job') IS NULL THEN 'MISSING' ELSE 'OK' END +UNION ALL SELECT 'search_negative', CASE WHEN to_regclass('public.search_negative') IS NULL THEN 'MISSING' ELSE 'OK' END;