8000 perf: CGV 중계 응답 재사용과 제한 진단 개선 by hmmhmmhm · Pull Request #251 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

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
2 changes: 2 additions & 0 deletions docs/cgv-relay-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

CGV는 `/v1/cgv/{theaters,movies,timetable,timetable-movie}` 경로로 기존 Dtryx 4320 프로세스를 사용합니다. 별도 예산을 만들지 않고 `DTRYX_RELAY_TOKEN`, `DTRYX_RELAY_STATE_DIR/quota.json`, Dtryx 로그를 공유합니다. 기본 상한은 `scripts/relay/quota.ts`의 일 3,000회·분 30회이며 CGV와 Dtryx의 실제 중계 원본 요청을 합산합니다. CU·세븐일레븐·GS25의 별도 편의점 원장은 공유하지 않습니다.

중계는 검증된 성공 응답만 재사용합니다. 극장·영화 목록은 5분, 시간표·영화별 시간표는 30초 뒤 만료됩니다. 동일 작업과 조회 조건으로 겹친 진행 요청은 원본 조회와 예산을 한 번만 소비합니다. 원본 HTTP 오류·업무 오류는 캐시하지 않으며, 시간표 캐시가 만료되면 좌석 수량을 다시 조회합니다. 이 중계 캐시는 REST 캐시와 별도이며 MCP 요청에도 적용됩니다. 분·일 상한은 변경하지 않습니다.

5분 주기 경로 점검은 CGV 직접 요청과 중계를 확인해 사용 가능한 경로를 선택합니다. 직접 경로가 차단되면 중계를 사용하고, 직접 경로가 회복되면 점검 결과에 따라 복귀합니다. 일반 요청 중 직접 실패도 경로 선택에 반영됩니다. `/health`의 `config.cgvRelay`는 유효 설정 여부만 나타내며 연결 성공을 뜻하지 않습니다.

## 설정
Expand Down
2 changes: 2 additions & 0 deletions scripts/relay/cgv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export function createCgvRelay(
) {
return createHttpRelay(token, {
...options,
cacheTtl: (operation) =>
['timetable', 'timetable-movie'].includes(operation) ? 30000 : 300000,
prefix: '/v1/cgv/',
operations: Object.keys(paths),
validate,
Expand Down
2 changes: 2 additions & 0 deletions src/services/dtryx/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { requestDirectRoute } from '../../utils/directRoutes.js';
import { routeOperation } from '../../utils/routeHealth.js';
import { diagnosticHeaders } from '../../utils/diagnostics.js';
/** 디트릭스 전용 중계 설정과 인증 전송입니다. */
import { parseRelayQuota } from '../../utils/relayQuota.js';
import { ServiceError } from '../../core/errors.js';
import { createTimeoutController } from '../../utils/http.js';

Expand Down Expand Up @@ -117,6 +118,7 @@ async function requestDtryxRelayRaw<T>(
mapped,
status === 429 || status >= 500,
status,
parseRelayQuota(status, response.headers),
);
}
const result: unknown = await response.json();
Expand Down
80 changes: 80 additions & 0 deletions tests/services/cgv/relay-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/** CGV 중계의 짧은 성공 캐시와 진행 조회 병합을 검증합니다. */
import { afterEach, expect, it, vi } from 'vitest';
import { createCgvRelay } from '../../../scripts/relay/cgv.js';
function request(operation: string, body: unknown) {
return new Request(`http://127.0.0.1/v1/cgv/${operation}`, {
method: 'POST',
headers: { Authorization: 'Bearer test-token' },
body: JSON.stringify(body),
});
}
afterEach(() => vi.restoreAllMocks());
const params = { theaterCode: '0056', playDate: '20261008' };
it.each([
['theaters', {}, 300000],
['movies', params, 300000],
['timetable', params, 30000],
['timetable-movie', { ...params, movieCode: '20000000' }, 30000],
])(
'%s 입력 %j 성공은 %i 밀리초까지 원본 조회와 예산을 재사용한다',
async (operation, body, ttl) => {
let now = 1000000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const takeQuota = vi.fn(async () => true);
let remainingSeats = 10;
const fetcher = vi.fn(async () =>
Response.json({ statusCode: 0, data: [{ frSeatCnt: remainingSeats }] }),
);
const relay = createCgvRelay('test-token', { takeQuota, fetcher });
expect((await relay(request(operation as string, body))).status).toBe(200);
now += Number(ttl) - 1;
remainingSeats = 9;
const cached = await relay(request(operation as string, body));
expect(await cached.json()).toEqual({ statusCode: 0, data: [{ frSeatCnt: 10 }] });
expect(fetcher).toHaveBeenCalledTimes(1);
expect(takeQuota).toHaveBeenCalledTimes(1);
now += 1;
const fresh = await relay(request(operation as string, body));
expect(await fresh.json()).toEqual({ statusCode: 0, data: [{ frSeatCnt: 9 }] });
expect(fetcher).toHaveBeenCalledTimes(2);
expect(takeQuota).toHaveBeenCalledTimes(2);
},
);
it('동시에 겹친 같은 시간표 요청은 원본과 예산을 한 번만 소비한다', async () => {
let finish!: () => void;
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const fetcher = vi.fn(async () => {
await gate;
return Response.json({ statusCode: 0, data: [] });
});
const takeQuota = vi.fn(async () => true);
const relay = createCgvRelay('test-token', { takeQuota, fetcher });
const responses = [
relay(request('timetable', params)),
relay(request('timetable', { playDate: params.playDate, theaterCode: params.theaterCode })),
];
await vi.waitFor(() => expect(fetcher).toHaveBeenCalled());
finish();
expect((await Promise.all(responses)).map((response) => response.status)).toEqual([200, 200]);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(takeQuota).toHaveBeenCalledTimes(1);
});
it.each([500, 200])('HTTP %i 원본 오류 또는 업무 오류는 캐시하지 않는다', async (status) => {
const fetcher = vi
.fn()
.mockImplementationOnce(async () =>
status === 500
? new Response('failure', { status })
: Response.json({ statusCode: 1, data: [] }),
)
.mockImplementation(async () => Response.json({ statusCode: 0, data: [] }));
const takeQuota = vi.fn(async () => true);
const relay = createCgvRelay('test-token', { takeQuota, fetcher });
expect((await relay(request('theaters', {}))).status).toBe(502);
expect((await relay(request('theaters', {}))).status).toBe(200);
expect((await relay(request('theaters', {}))).status).toBe(200);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(takeQuota).toHaveBeenCalledTimes(2);
});
19 changes: 19 additions & 0 deletions tests/services/dtryx/relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,22 @@ it('실패 응답 이후 연결도 중단한다', async () => {
await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ status: 502 });
expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true);
});

it('중계의 자체 429 원인과 대기 시간을 공개 진단에 보존한다', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
new Response('', {
status: 429,
headers: { 'x-relay-quota-reason': 'minute', 'Retry-After': '30' },
}),
),
);
await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({
code: 'DTRYX_RELAY_FAILED',
quotaReason: 'minute',
retryAfter: 30,
});
});
34 changes: 34 additions & 0 deletions tests/utils/routeProbes-no-relay.test.ts
BD0
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** 정기 경로 검사는 중계 원본 조회 예산을 사용하지 않습니다. */
import { afterEach, expect, it, vi } from 'vitest';
import { refreshRouteChecks } from '../../src/utils/routeProbes.js';
afterEach(() => vi.unstubAllGlobals());
it('실제 검사 함수는 직접 원본 요청과 중계 건강 GET만 호출한다', async () => {
const fetcher = vi
.fn()
.mockImplementation(async () => Response.json({ status: 'ok', state: 'ready' }));
vi.stubGlobal('fetch', fetcher);
const storageFetch = vi.fn(async () => Response.json({}));
const namespace = {
idFromName: () => ({}),
get: () => ({ fetch: storageFetch }),
} as unknown as DurableObjectNamespace;
await refreshRouteChecks({
UPSTREAM_ROUTE_HEALTH: namespace,
DTRYX_RELAY_URL: 'https://relay.example',
DTRYX_RELAY_TOKEN: 'test',
CONVENIENCE_RELAY_URL: 'https://relay.example',
CONVENIENCE_RELAY_TOKEN: 'test',
OY_RELAY_URL: 'https://relay.example',
OY_RELAY_TOKEN: 'test',
});
const calls = fetcher.mock.calls as unknown as [URL, RequestInit][];
const relayCalls = calls.filter(([url]) => url.hostname === 'relay.example');
expect(relayCalls).toHaveLength(4);
expect(
relayCalls.every(([url, init]) => url.pathname.endsWith('/health') && init.method === 'GET'),
).toBe(true);
const cgvCalls = calls.filter(([url]) => url.hostname === 'api.cgv.co.kr');
expect(cgvCalls).toHaveLength(4);
expect(cgvCalls.every(([, init]) => init.method === 'GET')).toBe(true);
expect(storageFetch).toHaveBeenCalledTimes(1);
});
Loading
0