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
4 changes: 2 additions & 2 deletions docs/oliveyoung-free-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며
| `stock-goods-info-v3` | `goodsNo` |
| `stock-stores` | `productId`, `lat`, `lon`, `pageIdx`, `searchWords`, `mapLat`, `mapLon` |

숫자·문자열·불리언 타입과 필드 목록을 검증합니다. 요청 본문은 최대 16 KiB입니다. 인증 실패는 브라우저 작업 전에 401, 잘못된 경로는 404, 잘못된 본문은 400/413입니다. 한 브라우저 작업만 동시에 실행하며, 실행·대기를 합쳐 8개를 넘으면 503입니다. 취소되었거나 15초 이상 대기한 요청은 실행하지 않습니다. 브라우저 fetch는 본문을 읽을 때까지 15초 제한을 적용합니다. 정상 HTTP 200과 `SUCCESS` JSON만 반환하며, HTML·상태 오류·네트워크 실패는 원문이나 비밀을 노출하지 않는 502가 됩니다.
숫자·문자열·불리언 타입과 필드 목록을 검증합니다. 요청 본문은 최대 16 KiB입니다. 인증 실패는 브라우저 작업 전에 401, 잘못된 경로는 404, 잘못된 본문은 400/413입니다. 한 브라우저 작업만 동시에 실행하며, 실행·대기를 합쳐 8개를 넘으면 503입니다. 취소되었거나 40초 이상 대기한 요청은 실행하지 않습니다. 같은 조회에 합류한 요청도 각자의 도착 시각으로 만료를 판단하므로 오래된 대기자는 503, 유효한 대기자는 조회 결과를 받습니다. 대기 상한 40초는 관찰된 브라우저 준비 35초를 수용하기 위한 값입니다. 준비된 세션에서는 Worker 기본 예산 60초에서 브라우저 작업 watchdog 18초와 여유 2초를 뺀 대기 목표에 해당합니다. 수명 관리의 추가 준비는 작업 watchdog 전에, 회수는 작업 뒤에 진행될 수 있으므로 모든 요청이 60초 안에 끝난다는 보장은 아닙니다. 브라우저 fetch는 본문을 읽을 때까지 15초 제한을 적용합니다. 정상 HTTP 200과 `SUCCESS` JSON만 반환하며, HTML·상태 오류·네트워크 실패는 원문이나 비밀을 노출하지 않는 502가 됩니다. 준비 실패도 성공으로 반환하거나 성공 캐시에 저장하지 않습니다.

기존 상품·매장·재고의 실패 시 30분 캐시 재사용 동작은 이 전송 변경에서 확대하지 않았습니다. 캐시 데이터가 실시간 재고를 보장하지는 않습니다.

Expand All @@ -90,7 +90,7 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며

브라우저는 별도 guard 프로세스가 소유합니다. 중계 부모가 SIGKILL로 종료되어도 IPC 단절을 감지해 회수합니다. PID·생성 시각·프로세스 그룹을 확인한 소유 프로세스만 종료합니다. macOS에서 별도 그룹으로 분리되는 crashpad 보조 프로세스도 실행별 고유 경로로 식별해 메모리 집계와 회수에 포함합니다. 브라우저에는 GUI 실행에 필요한 환경만 전달하고 운영 토큰은 제외합니다. 정상 종료가 지연되면 확인된 그룹을 강제 종료하고 실제 소멸을 확인합니다. 개인 Chrome을 프로세스 이름으로 일괄 종료하지 않습니다. guard까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다.

소유 브라우저 프로세스 그룹의 RSS를 30초마다 점검하며 1GiB를 넘으면 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다.
소유 브라우저 프로세스 그룹의 RSS를 30초마다 점검하며 1GiB를 넘으면 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다. `state`는 현재 수명 상태이며 `reason`은 마지막 회수 사유입니다. 준비가 성공해 `state: ready`로 돌아와도 이전 `request-failed` 등이 남을 수 있으므로 `reason`만으로 현재 준비 실패를 판단하지 않습니다.

### 전체 호출량과 인증

Expand Down
27 changes: 27 additions & 0 deletions docs/superpowers/plans/2026-10-05-oliveyoung-cold-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 올리브영 준비 대기 보강 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task.

**Goal:** 브라우저 준비35초가 뒤따른 올리브영 조회를 만료시키는 회귀를 수정합니다.

**Architecture:** 준비된 세션에서 Worker60초/브라우저API watchdog18초를 목표 예산으로40초 bounded queue를 사용합니다. 준비·회수를 더한 전체60초 보장은 아닙니다. 인증·슬롯·상한·취소·캐시 계약을 보존합니다.

**Tech Stack:** TypeScript, Vitest fake timers, Playwright lifecycle.

## Task 1: cold-start queue

Files: Modify scripts/relay/oliveyoung.ts; tests/relay/oliveyoung.test.ts 또는 별도 tests/relay/cold-start.test.ts. Modify docs/oliveyoung* 적절한 기존 운영문서 또는 위 spec.

- [x] 현재코드 전체 테스트로 baseline 확인
- [x] 첫 operation이35초 걸리고 두번째 operation도200이 되는 실패 테스트를 먼저 실행
- [x] 두 만료 검사 위치에 명명된40초 상수 사용.60초-18초 예산 이유 한국어주석
- [x] 정확히40초 대기 요청은 실행하지 않는 경계 및 합류자 freshness/취소 테스트. 기존30초 만료 테스트를40초 경계로 수정
- [x] 준비 실패는200/빈재고로 반환하거나 성공캐시하지 않는 기존/새 테스트 확인
- [x] focused 테스트, check·coverage100%·build, 독립 명세 및 품질 리뷰
- [ ] 한국어중립커밋 후 draft PR 생성. 검증 스크립트 하나만 기존Mac중계에 원자적반영·정상재기동·실제재고검증

## 독립 조사

- [x] Daiso 현재판매3종×매장키워드2 운영 재고와 공식원본 대조
- [x] CU/SevenEleven Mac 성공과 Worker차단의 범위 설명
- [x] GS25 공식 공개배포의 인증지원 여부와 완전복구 조건 조사. 키/로그 노출 및 임의유료호출 없음
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# 올리브영 브라우저 준비 중 요청 만료 방지

사용자가 재고 점검 후 올리브영 오동작 방지를 요청해 앞서 제안한 준비·대기 처리 보강을 진행합니다.

## 재현 근거

운영 inventory는 find-store와 product-search를 동시에 중계합니다. 2026-10-04 23:59 UTC 원장에서는 첫 find-store가 브라우저 준비와 함께 35.1초 후 성공했지만 뒤따른 product-search는 30초 대기 상한을 넘겨 실행하지 않고 503을 반환했습니다. 같은 요청은 준비 후 2.4초에 성공했습니다. 분당/일일 쿼터 소진이 아닙니다.

## 선택한 변경

현재 Worker의 중계 조회 기본 예산은 60초, 브라우저 API 작업 watchdog은 18초입니다. 대기 상한은 40초로 조정해 관찰된 준비35초를 수용합니다. 준비된 세션의 후속 작업을 기준으로 대기40초와 API watchdog18초를 기본 예산60초 안에 배치합니다. 추가 세션 준비와 회수까지 포함한 전체 응답시간 보장은 아닙니다. 대기 만료를 검사하는 두 위치가 같은 상수를 사용합니다. 취소 요청은 그대로 실행하지 않고, 슬롯8·소비자·분당/일일 상한·캐시·직렬 브라우저 실행을 유지합니다. 대기시간을 무한히 늘리거나 일반 오류를 성공/재고0으로 바꾸지 않습니다.

35초 준비 후 다른 operation이 성공하는 테스트, 정확히40초 대기 만료 및 미실행, 만료된 합류자와 유효한 합류자가 결과를 나누어 받는 테스트, 취소 요청 및 준비 실패가 성공 캐시에 들어가지 않는 기존 테스트를 확인합니다. 수명 상태의 reason은 마지막 실패 기록이라는 점을 문서화하며 ready 판정 변경은 별도 근거 없이는 하지 않습니다.

전체 check·coverage100%·build와 명세/품질 리뷰 후 draft PR로 제시합니다. 검증한 스크립트 하나를 기존 Mac 중계에 원자적으로 반영합니다. 이전 내용의 해시 일치와 유휴 상태를 확인하고, 백업을 보존한 뒤 정상 종료·재기동과 실제 재고 조회를 검증합니다. Worker 배포나 전체 runtime 교체는 필요하지 않습니다. 다이소는 현재 판매상품과 원본 대조로 진단하고 GS25 인증키와 Cloudflare 출발지 차단은 조사 결과를 별도로 기록합니다.
7 changes: 5 additions & 2 deletions scripts/relay/oliveyoung.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export type BrowserRunner = (
path: string,
body: Record<string, unknown>,
) => Promise<OliveyoungApiResponse>;
// 준비된 세션의 작업 watchdog 18초와 여유 2초를 Worker 기본 예산 60초에서 뺀 대기 목표입니다.
// 수명 관리의 추가 준비·회수 시간까지 포함한 전체 응답 시간을 보장하지는 않습니다.
const MAX_QUEUE_WAIT_MS = 40000;
const paths: Record<string, string> = {
'find-store': OLIVEYOUNG_API.STORE_FINDER_PATH,
'product-search-v3': OLIVEYOUNG_API.PRODUCT_SEARCH_PATH,
Expand Down Expand Up @@ -189,7 +192,7 @@ export function createOliveyoungRelay(
entry!.startedAt = Date.now();
if (
[...signals].every(
([signal, arrived]) => signal.aborted || entry!.startedAt - arrived >= 30000,
([signal, arrived]) => signal.aborted || entry!.startedAt - arrived >= MAX_QUEUE_WAIT_MS,
)
)
return null;
Expand Down Expand Up @@ -224,7 +227,7 @@ export function createOliveyoungRelay(
}
try {
const result = await entry.task;
if (request.signal.aborted || result === null || entry.startedAt - queuedAt >= 30000) {
if (request.signal.aborted || result === null || entry.startedAt - queuedAt >= MAX_QUEUE_WAIT_MS) {
emit('queue', request.signal.aborted ? 'canceled' : 'expired');
return error(503, 'Request expired');
}
Expand Down
119 changes: 119 additions & 0 deletions tests/relay/cold-start.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { EventEmitter } from 'node:events';
import { afterEach, expect, it, vi } from 'vitest';
import { createBrowserLifecycle } from '../../scripts/relay/lifecycle.js';
import { createOliveyoungRelay } from '../../scripts/relay/oliveyoung.js';

const storeBody = {
lat: 1,
lon: 1,
pageIdx: 1,
searchWords: '',
pogKeys: '',
serviceKeys: '',
mapLat: 1,
mapLon: 1,
};
const productBody = { includeSoldOut: true, keyword: 'x', page: 1, sort: 'x', size: 1 };
const request = (operation: string, body: Record<string, unknown>, consumer: string) =>
new Request(`http://localhost/v1/oliveyoung/${operation}`, {
method: 'POST',
headers: {
authorization: 'Bearer test',
'x-relay-consumer': consumer.padStart(64, '0'),
},
body: JSON.stringify(body),
});

afterEach(() => vi.useRealTimers());

it('페이지 접속 10초와 URL 준비 25초 뒤 두 operation이 한 페이지로 성공한다', async () => {
vi.useFakeTimers();
const page = Object.assign(new EventEmitter(), {
goto: vi.fn(() => new Promise<void>(resolve => setTimeout(resolve, 10000))),
waitForURL: vi.fn(() => new Promise<void>(resolve => setTimeout(resolve, 25000))),
evaluate: vi.fn().mockResolvedValue({ status: 200, body: { status: 'SUCCESS' } }),
});
const context = Object.assign(new EventEmitter(), {
newPage: vi.fn().mockResolvedValue(page),
pages: () => [page],
});
const owner = {
browser: { newContext: vi.fn().mockResolvedValue(context) },
close: vi.fn().mockResolvedValue(undefined),
rss: vi.fn().mockResolvedValue(100),
pid: 123,
};
const launch = vi.fn().mockResolvedValue(owner);
const life = createBrowserLifecycle(launch);
const takeQuota = vi.fn().mockResolvedValue(true);
const relay = createOliveyoungRelay('test', life.run, { takeQuota });
try {
const store = relay(request('find-store', storeBody, '1'));
await vi.advanceTimersByTimeAsync(0);
const product = relay(request('product-search-v3', productBody, '2'));
await vi.advanceTimersByTimeAsync(0);
expect(page.evaluate).not.toHaveBeenCalled();
expect(life.status().state).toBe('starting');

await vi.advanceTimersByTimeAsync(10000);
expect(page.waitForURL).toHaveBeenCalledTimes(1);
expect(page.evaluate).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(25000);

expect((await store).status).toBe(200);
expect((await product).status).toBe(200);
expect(page.evaluate).toHaveBeenCalledTimes(2);
expect(context.newPage).toHaveBeenCalledTimes(1);
expect(launch).toHaveBeenCalledTimes(1);
expect(takeQuota).toHaveBeenCalledTimes(2);
expect(life.status()).toMatchObject({ state: 'ready', pages: 1, totalCalls: 2 });
} finally {
await life.close();
expect(owner.close).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
}
});

it.each([35000, 39999])('%i밀리초 브라우저 준비 후 대기한 상품 검색도 성공한다', async (delay) => {
vi.useFakeTimers();
const result = { status: 'SUCCESS', data: {} };
const runner = vi.fn()
.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => resolve(result), delay)))
.mockResolvedValue(result);
const takeQuota = vi.fn().mockResolvedValue(true);
const relay = createOliveyoungRelay('test', runner, { takeQuota });
const store = relay(request('find-store', storeBody, '1'));
await vi.advanceTimersByTimeAsync(0);
expect(runner).toHaveBeenCalledTimes(1);
const product = relay(request('product-search-v3', productBody, '2'));
await vi.advanceTimersByTimeAsync(0);
expect(runner).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(delay);

expect((await store).status).toBe(200);
const response = await product;
expect(response.status).toBe(200);
expect(await response.json()).toEqual(result);
expect(runner).toHaveBeenNthCalledWith(2, '/oystore/api/stock/product-search-v3', productBody);
expect(takeQuota).toHaveBeenCalledTimes(2);
});

it('브라우저 준비 실패를 성공으로 반환하거나 캐시하지 않고 다음 요청에서 재시도한다', async () => {
const runner = vi.fn()
.mockRejectedValueOnce(new Error('startup failed'))
.mockResolvedValue({ status: 'SUCCESS', data: {} });
const relay = createOliveyoungRelay('test', runner);
const failed = await relay(request('find-store', storeBody, '1'));
expect(failed.status).toBe(502);
expect(await failed.json()).toEqual({ error: 'Upstream unavailable' });

const retry = await relay(request('find-store', storeBody, '2'));
expect(retry.status).toBe(200);
expect(retry.headers.get('x-relay-cache')).toBe('miss');
expect(runner).toHaveBeenCalledTimes(2);
const cached = await relay(request('find-store', storeBody, '3'));
expect(cached.status).toBe(200);
expect(cached.headers.get('x-relay-cache')).toBe('hit');
expect(runner).toHaveBeenCalledTimes(2);
});
15 changes: 8 additions & 7 deletions tests/relay/oliveyoung.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,12 @@ it('만료된 대기는 실행하지 않고 실패한 응답을 캐시하지 않
await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1));
const queued = relay(request(undefined, JSON.stringify({ goodsNo: 'other' })));
await new Promise((resolve) => setTimeout(resolve, 0));
clock.mockReturnValue(31000);
clock.mockReturnValue(41000);
release({ status: 'SUCCESS' });
await first;
expect((await queued).status).toBe(503);
expect(runner).toHaveBeenCalledTimes(1);
clock.mockReturnValue(631000);
clock.mockReturnValue(641000);
expect((await relay(request())).status).toBe(502);
expect((await relay(request())).status).toBe(502);
expect(runner).toHaveBeenCalledTimes(3);
Expand Down Expand Up @@ -297,7 +297,7 @@ it('비동기 관측 실패도 요청 처리에 영향을 주지 않는다', asy
});
expect((await relay(request())).status).toBe(200);
});
it('취소된 오래된 요청에 합류한 새 요청은 자기 대기 시간으로 실행한다', async () => {
it('40초 만료된 요청에 합류한 새 요청은 자기 대기 시간으로 실행한다', async () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(1000);
let release!: (value: { status: string }) => void;
const runner = vi
Expand All @@ -315,13 +315,14 @@ it('취소된 오래된 요청에 합류한 새 요청은 자기 대기 시간
await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1));
const old = relay(request());
await new Promise((resolve) => setTimeout(resolve, 0));
clock.mockReturnValue(31000);
clock.mockReturnValue(41000);
const fresh = relay(request());
await new Promise((resolve) => setTimeout(resolve, 0));
release({ status: 'SUCCESS' });
expect((await first).status).toBe(200);
expect((await old).status).toBe(503);
expect((await fresh).status).toBe(200);
expect(runner).toHaveBeenCalledTimes(2);
expect(takeQuota).toHaveBeenCalledTimes(2);
clock.mockRestore();
});
Expand Down Expand Up @@ -407,7 +408,7 @@ it('공유 요청의 부모 ID와 실제 예산 소비를 기록한다', async (
expect((await child).status).toBe(502);
});

it('브라우저 교체 중 23초 기다린 작업은 실행하고 30초 대기는 만료로 기록한다', async () => {
it('브라우저 교체 중 39999밀리초 기다린 작업은 실행하고 40초 대기는 만료로 기록한다', async () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(0);
const events: unknown[] = [];
let release!: (value: { status: string }) => void;
Expand All @@ -418,10 +419,10 @@ it('브라우저 교체 중 23초 기다린 작업은 실행하고 30초 대기
await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1));
const expired = relay(request(undefined, JSON.stringify({ goodsNo: 'expired' })));
await new Promise(resolve => setTimeout(resolve, 0));
clock.mockReturnValue(7000);
clock.mockReturnValue(1);
const valid = relay(request(undefined, JSON.stringify({ goodsNo: 'valid' })));
await new Promise(resolve => setTimeout(resolve, 0));
clock.mockReturnValue(30000);
clock.mockReturnValue(40000);
release({ status: 'SUCCESS' });
expect((await first).status).toBe(200);
expect((await expired).status).toBe(503);
Expand Down
Loading