From 5f88c3148488614b74acc10db33b5fa3ed0c4723 Mon Sep 17 00:00:00 2001 From: yawning5 Date: Thu, 8 Jan 2026 01:43:17 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=AC=B8=EC=84=9C=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k6-test/README.md | 89 +++++++-- k6-test/test_command.txt | 28 ++- k6-test/ws_test.js | 418 ++++++++++++++++++++++++++------------- 3 files changed, 375 insertions(+), 160 deletions(-) diff --git a/k6-test/README.md b/k6-test/README.md index 879ba053..13ed263a 100644 --- a/k6-test/README.md +++ b/k6-test/README.md @@ -7,8 +7,9 @@ - `outbox_load.sql`: outbox 더미 데이터 부하용 SQL ## 동작 방식 -- SockJS info 엔드포인트 호출: `GET /ws-auction/info` -- WebSocket 연결: `/ws-auction/{server}/{session}/websocket` +- SockJS info 엔드포인트 호출: `GET /ws-auction/info` (`WS_MODE=sockjs`) +- WebSocket 연결: `/ws-auction/{server}/{session}/websocket` (`WS_MODE=sockjs`) +- 직접 WebSocket 연결: `/ws-auction` (`WS_MODE=ws`) - STOMP `CONNECT` 후 `SUBSCRIBE /topic/auction/{auctionId}` - 세션 동안 `SEND /pub/auction/{auctionId}/bid` 반복 전송 - 서버의 "최소 입찰가" 메시지에 맞춰 입찰가 자동 증가 (`updateMinBidFromFrame` 참고) @@ -21,6 +22,8 @@ ## 환경 변수 - `WS_BASE`: WebSocket base URL (기본값: `ws://host.docker.internal:6600`) - `HTTP_BASE`: HTTP base URL (기본값: `WS_BASE`에서 `ws` -> `http` 변환) +- `WS_MODE`: `sockjs`(기본) 또는 `ws` (직접 WebSocket, `/info` 미사용) +- `SKIP_INFO=1`: SockJS `/info` 호출 생략 (게이트웨이에서 `/info` 미지원 시 사용) - `AUCTION_ID`: auction UUID - `TOKENS_FILE`: 컨테이너 내 토큰 파일 경로 (1줄 1 JWT) - `TOKEN`: 단일 JWT (`TOKENS_FILE` 미지정 시 사용) @@ -37,45 +40,82 @@ - `LOG_WS_ERRORS=1`: WebSocket 핸드셰이크 실패 로그 출력 - `LOG_INFO_SLOW_MS`: `/ws-auction/info` 응답이 지정 ms 이상이면 로그 출력 - `SCENARIO=step-bid`: 단계 시나리오 사용 (아래 참고) +- `SCENARIO=rate-step`: 고정 VU + 전송률 단계 시나리오 (아래 참고) - `STEP_FIRST_RPS`: 단계 시나리오 1차 전송률 (VU당 초당 메시지 수) - `STEP_SECOND_RPS`: 단계 시나리오 2차 전송률 (VU당 초당 메시지 수) - `STEP_RATE_SWITCH_MS`: 1차 -> 2차 전송률 전환 시점 (밀리초) +- `RATE_STEP_RPS_LIST`: rate-step 전송률 목록 (기본값: `5,10,15,20,25`) +- `RATE_STEP_MINUTES`: rate-step 단계당 분 (기본값: `1`) +- `RATE_STEP_VUS`: rate-step 고정 VU (기본값: `VUS`) ## 단계 시나리오 (step-bid) -- VU 단계: 50 → 100 → 150 → 200 → 250 → 300 → 350 → 400 (각 단계 2분) -- 각 단계는 1분 동안 `STEP_FIRST_RPS`, 이후 1분 동안 `STEP_SECOND_RPS`로 전송 +- VU 단계: 550 → 600 → 650 → 700 → 750 (각 단계 1분) +- 각 단계는 30초 동안 `STEP_FIRST_RPS`, 이후 30초 동안 `STEP_SECOND_RPS`로 전송 - `SCENARIO=step-bid` 사용 시 `VUS`, `DURATION`은 무시됩니다 요청한 패턴 예시: -- 50 VU 2분 (1분: VU당 5 msg/s, 다음 1분: VU당 10 msg/s) -- 100 VU 2분 동일 패턴 -- 150 VU 2분 동일 패턴 -- 200 VU 2분 동일 패턴 -- 250 VU 2분 동일 패턴 -- 300 VU 2분 동일 패턴 -- 350 VU 2분 동일 패턴 -- 400 VU 2분 동일 패턴 +- 550 VU 1분 (30초: VU당 5 msg/s, 다음 30초: VU당 10 msg/s) +- 600 VU 1분 동일 패턴 +- 650 VU 1분 동일 패턴 +- 700 VU 1분 동일 패턴 +- 750 VU 1분 동일 패턴 + +## 고정 VU + 전송률 단계 (rate-step) +- 고정 VU로 여러 전송률을 순차 테스트할 때 사용합니다. +- `SCENARIO=rate-step` 사용 시 `VUS`, `DURATION`은 무시됩니다. +- 예: VU 300, 5/10/15/20/25 msg/s (각 1분) + +요청한 패턴 예시: +- `RATE_STEP_VUS=300`, `RATE_STEP_RPS_LIST=5,10,15,20,25`, `RATE_STEP_MINUTES=1` + +## Metrics +- `bid_sent`: k6 Counter (전송 시도 수). `rate` 값이 VU 기준 실제 전송 msg/s 입니다. +- `ws_connect_ok`: k6 Rate (WebSocket 연결 성공률). summary에 `ws_connect_ok rate`로 출력됩니다. ## 최근 사용 시나리오 요약 - GW 경유 (`host.docker.internal:17700`) - 토큰 파일 사용 (`TOKENS_FILE=/scripts/tokens.txt`) - 입찰가: `BID_PRICE_START=1001`, `BID_PRICE_STEP=1` -- 단계 시나리오: 50→400 VU, 각 2분 (1분 5 msg/s → 1분 10 msg/s) +- 단계 시나리오: 550→750 VU, 각 1분 (30초 5 msg/s → 30초 10 msg/s) +- 고정 VU: 300, 5 msg/s, 4분 (`SEND_EVERY_MS=200`, `DURATION=4m`) + +## 테스트 기준/방법 (최근) +- 경매가 테스트 시간 동안 유지되어야 합니다 (중간 종료 시 결과 무효). +- 고정 VU + 현실적인 전송률(예: VU 300, 5 msg/s)을 기준으로 내부/외부 브로커를 동일 조건으로 비교합니다. +- 결과는 `k6_summary.json`, `k6_summary.txt`, `k6_dashboard.html`로 보관합니다. +- 대시보드가 깨질 수 있으니 HTML export를 기준으로 기록합니다. ## Commands (PowerShell) -GW 경유 + 단계 시나리오 (50→400 VU, 각 2분): +GW 경유 + 단계 시나리오 (550→750 VU, 각 1분): ``` docker run --rm -i -p 5665:5665 -v "${PWD}\k6-test:/scripts" ` -e K6_WEB_DASHBOARD=true -e K6_WEB_DASHBOARD_HOST=0.0.0.0 ` + -e K6_WEB_DASHBOARD_PERIOD=10s -e K6_WEB_DASHBOARD_EXPORT=/scripts/k6_dashboard.html ` -e SCENARIO=step-bid ` - -e STEP_FIRST_RPS=5 -e STEP_SECOND_RPS=10 -e STEP_RATE_SWITCH_MS=60000 ` + -e STEP_FIRST_RPS=5 -e STEP_SECOND_RPS=10 -e STEP_RATE_SWITCH_MS=30000 ` -e WS_BASE=ws://host.docker.internal:17700 ` -e HTTP_BASE=http://host.docker.internal:17700 ` + -e SKIP_INFO=1 ` -e TOKENS_FILE=/scripts/tokens.txt ` -e AUCTION_ID=11111111-1111-1111-1111-111111111111 ` -e BID_PRICE_START=1001 -e BID_PRICE_STEP=1 ` - grafana/k6 run --quiet /scripts/ws_test.js + grafana/k6 run /scripts/ws_test.js +``` + +GW 경유, 고정 VU 300 + 5 msg/s, 4분: +``` +docker run --rm -i -p 5665:5665 -v "${PWD}\k6-test:/scripts" ` + -e K6_WEB_DASHBOARD=true -e K6_WEB_DASHBOARD_HOST=0.0.0.0 ` + -e K6_WEB_DASHBOARD_PERIOD=10s -e K6_WEB_DASHBOARD_EXPORT=/scripts/k6_dashboard.html ` + -e VUS=300 -e DURATION=4m -e SESSION_MS=240000 ` + -e SEND_EVERY_MS=200 ` + -e WS_BASE=ws://host.docker.internal:17700 ` + -e HTTP_BASE=http://host.docker.internal:17700 ` + -e SKIP_INFO=1 ` + -e TOKENS_FILE=/scripts/tokens.txt ` + -e AUCTION_ID=11111111-1111-1111-1111-111111111111 ` + grafana/k6 run /scripts/ws_test.js ``` Gateway, 다중 토큰, 지속 입찰 (500 VU, 60s, 대시보드): @@ -123,14 +163,22 @@ Dashboard URL: `http://localhost:5665` ``` docker run --rm -i -p 5665:5665 -v "${PWD}\k6-test:/scripts" ` -e K6_WEB_DASHBOARD=true -e K6_WEB_DASHBOARD_HOST=0.0.0.0 ` + -e K6_WEB_DASHBOARD_PERIOD=10s -e K6_WEB_DASHBOARD_EXPORT=/scripts/k6_dashboard.html ` -e SCENARIO=step-bid ` - -e STEP_FIRST_RPS=5 -e STEP_SECOND_RPS=10 -e STEP_RATE_SWITCH_MS=60000 ` + -e STEP_FIRST_RPS=5 -e STEP_SECOND_RPS=10 -e STEP_RATE_SWITCH_MS=30000 ` -e WS_BASE=ws://host.docker.internal:17700 ` -e HTTP_BASE=http://host.docker.internal:17700 ` + -e SKIP_INFO=1 ` -e TOKENS_FILE=/scripts/tokens.txt ` -e AUCTION_ID=11111111-1111-1111-1111-111111111111 ` -e BID_PRICE_START=1001 -e BID_PRICE_STEP=1 ` - grafana/k6 run --quiet /scripts/ws_test.js 2>&1 | Tee-Object -FilePath k6-test\k6_run.log + grafana/k6 run /scripts/ws_test.js 2>&1 | Tee-Object -FilePath k6-test\k6_run.log +``` + +## Redis 처리량 측정 +PowerShell 예시: +``` +.\k6-test\redis_measure.ps1 -AuctionId 11111111-1111-1111-1111-111111111111 -Seconds 60 -RedisContainer redis-stack ``` ## Redis 검증 @@ -143,9 +191,12 @@ docker exec redis-stack redis-cli ZREVRANGE auction:11111111-1111-1111-1111-1111 ## Codex로 다시 실행하기 다음에 같은 테스트를 Codex에 요청할 때는 이런 식으로 말하면 됩니다: -- "k6-test step-bid 시나리오로 50→400 VU, 각 2분, VU당 1초에 5회 → 10회로 전송. GW는 host.docker.internal:17700, 토큰 파일 사용, BID_PRICE_START=1001, BID_PRICE_STEP=1로 PowerShell 커맨드 만들어줘" +- "k6-test step-bid 시나리오로 550→750 VU, 각 1분, VU당 1초에 5회 → 10회로 전송. GW는 host.docker.internal:17700, 토큰 파일 사용, BID_PRICE_START=1001, BID_PRICE_STEP=1로 PowerShell 커맨드 만들어줘" ## Notes - 입찰 수가 `stock`에 도달하면 컷오프 이하 입찰이 거절됩니다. 스크립트는 최소 입찰가에 맞춰 자동으로 올립니다. - 높은 VU에서 `/ws-auction/info`의 `connection refused`나 WebSocket 프로토콜 오류가 날 수 있습니다. VU 램프업 또는 게이트웨이 용량 조정이 필요할 수 있습니다. - SockJS URL에 랜덤 세션 ID가 포함되어 k6 메트릭 카디널리티가 높아질 수 있습니다. +- k6 대시보드 안정화를 위해 `systemTags`를 최소화했습니다. +- WebSocket 부하 시 대시보드 렌더링이 깨질 수 있으니 `k6_dashboard.html`을 기준으로 확인하세요. +- 실행이 끝나면 `k6_summary.json`, `k6_summary.txt`가 자동 생성됩니다. diff --git a/k6-test/test_command.txt b/k6-test/test_command.txt index 7d84c636..346531f7 100644 --- a/k6-test/test_command.txt +++ b/k6-test/test_command.txt @@ -1,32 +1,50 @@ -GW 경유 + 단계 시나리오 (50→400 VU, 각 2분) +GW 경유 + 단계 시나리오 (550→750 VU, 각 1분) docker run --rm -i -p 5665:5665 -v "${PWD}\k6-test:/scripts" ` -e K6_WEB_DASHBOARD=true -e K6_WEB_DASHBOARD_HOST=0.0.0.0 ` + -e K6_WEB_DASHBOARD_PERIOD=10s -e K6_WEB_DASHBOARD_EXPORT=/scripts/k6_dashboard.html ` -e SCENARIO=step-bid ` - -e STEP_FIRST_RPS=5 -e STEP_SECOND_RPS=10 -e STEP_RATE_SWITCH_MS=60000 ` + -e STEP_FIRST_RPS=5 -e STEP_SECOND_RPS=10 -e STEP_RATE_SWITCH_MS=30000 ` -e WS_BASE=ws://host.docker.internal:17700 ` -e HTTP_BASE=http://host.docker.internal:17700 ` + -e SKIP_INFO=1 ` -e TOKENS_FILE=/scripts/tokens.txt ` -e AUCTION_ID=11111111-1111-1111-1111-111111111111 ` -e BID_PRICE_START=1001 -e BID_PRICE_STEP=1 ` - grafana/k6 run --quiet /scripts/ws_test.js + grafana/k6 run /scripts/ws_test.js 실패/느린 응답 로그 추가 (진단용) docker run --rm -i -p 5665:5665 -v "${PWD}\k6-test:/scripts" ` -e K6_WEB_DASHBOARD=true -e K6_WEB_DASHBOARD_HOST=0.0.0.0 ` + -e K6_WEB_DASHBOARD_PERIOD=10s -e K6_WEB_DASHBOARD_EXPORT=/scripts/k6_dashboard.html ` -e SCENARIO=step-bid ` - -e STEP_FIRST_RPS=5 -e STEP_SECOND_RPS=10 -e STEP_RATE_SWITCH_MS=60000 ` + -e STEP_FIRST_RPS=5 -e STEP_SECOND_RPS=10 -e STEP_RATE_SWITCH_MS=30000 ` -e WS_BASE=ws://host.docker.internal:17700 ` -e HTTP_BASE=http://host.docker.internal:17700 ` + -e SKIP_INFO=1 ` -e TOKENS_FILE=/scripts/tokens.txt ` -e AUCTION_ID=11111111-1111-1111-1111-111111111111 ` -e BID_PRICE_START=1001 -e BID_PRICE_STEP=1 ` -e LOG_WS_ERRORS=1 -e LOG_INFO_SLOW_MS=3000 ` - grafana/k6 run --quiet /scripts/ws_test.js + grafana/k6 run /scripts/ws_test.js 대시보드 http://localhost:5665 +GW 경유, 고정 VU 300 + 5 msg/s, 4분 + +docker run --rm -i -p 5665:5665 -v "${PWD}\k6-test:/scripts" ` + -e K6_WEB_DASHBOARD=true -e K6_WEB_DASHBOARD_HOST=0.0.0.0 ` + -e K6_WEB_DASHBOARD_PERIOD=10s -e K6_WEB_DASHBOARD_EXPORT=/scripts/k6_dashboard.html ` + -e VUS=300 -e DURATION=4m -e SESSION_MS=240000 ` + -e SEND_EVERY_MS=200 ` + -e WS_BASE=ws://host.docker.internal:17700 ` + -e HTTP_BASE=http://host.docker.internal:17700 ` + -e SKIP_INFO=1 ` + -e TOKENS_FILE=/scripts/tokens.txt ` + -e AUCTION_ID=11111111-1111-1111-1111-111111111111 ` + grafana/k6 run /scripts/ws_test.js + GW 경유, 다중 토큰, 지속 입찰 (500 VU, 1분, 대시보드) docker run --rm -i -p 5665:5665 -v "${PWD}\k6-test:/scripts" ` diff --git a/k6-test/ws_test.js b/k6-test/ws_test.js index 5693821a..fa8b120a 100644 --- a/k6-test/ws_test.js +++ b/k6-test/ws_test.js @@ -1,71 +1,123 @@ import http from 'k6/http'; import ws from 'k6/ws'; import { check } from 'k6'; +import { Counter, Rate } from 'k6/metrics'; + +const ENV = __ENV; + +const USE_STEP_SCENARIO = ENV.SCENARIO === 'step-bid'; +const USE_RATE_STEP_SCENARIO = ENV.SCENARIO === 'rate-step'; +const STEP_STAGE_MINUTES = 1; +const STEP_RATE_SWITCH_MS = ENV.STEP_RATE_SWITCH_MS + ? parseInt(ENV.STEP_RATE_SWITCH_MS, 10) + : 30000; +const STEP_FIRST_RPS = ENV.STEP_FIRST_RPS ? parseFloat(ENV.STEP_FIRST_RPS) : 5; +const STEP_SECOND_RPS = ENV.STEP_SECOND_RPS ? parseFloat(ENV.STEP_SECOND_RPS) : 10; +const STEP_FIRST_SEND_EVERY_MS = rateToInterval(STEP_FIRST_RPS); +const STEP_SECOND_SEND_EVERY_MS = rateToInterval(STEP_SECOND_RPS); +const STEP_SCENARIO_VUS = [550, 600, 650, 700, 750]; + +const DEFAULT_VUS = ENV.VUS ? parseInt(ENV.VUS, 10) : 500; +const DEFAULT_DURATION = ENV.DURATION || '30s'; +const RATE_STEP_MINUTES = ENV.RATE_STEP_MINUTES + ? parseInt(ENV.RATE_STEP_MINUTES, 10) + : 1; +const RATE_STEP_RPS_LIST = parseRateList( + ENV.RATE_STEP_RPS_LIST || '5,10,15,20,25' +); +const RATE_STEP_VUS = ENV.RATE_STEP_VUS + ? parseInt(ENV.RATE_STEP_VUS, 10) + : DEFAULT_VUS; -const VUS = __ENV.VUS ? parseInt(__ENV.VUS, 10) : 500; -const DURATION = __ENV.DURATION || '30s'; -const USE_STEP_SCENARIO = __ENV.SCENARIO === 'step-bid'; - -const STEP_STAGE_MINUTES = 2; -const STEP_RATE_SWITCH_MS = __ENV.STEP_RATE_SWITCH_MS - ? parseInt(__ENV.STEP_RATE_SWITCH_MS, 10) - : 60000; -const STEP_FIRST_RPS = __ENV.STEP_FIRST_RPS ? parseFloat(__ENV.STEP_FIRST_RPS) : 2; -const STEP_SECOND_RPS = __ENV.STEP_SECOND_RPS ? parseFloat(__ENV.STEP_SECOND_RPS) : 4; -const STEP_FIRST_SEND_EVERY_MS = Math.round(1000 / STEP_FIRST_RPS); -const STEP_SECOND_SEND_EVERY_MS = Math.round(1000 / STEP_SECOND_RPS); - -const STEP_SCENARIO_STAGES = [ - { vus: 50 }, - { vus: 100 }, - { vus: 150 }, - { vus: 200 }, - { vus: 250 }, - { vus: 300 }, - { vus: 350 }, - { vus: 400 }, -]; +export const options = { + systemTags: ['scenario'], + ...(USE_STEP_SCENARIO + ? { scenarios: buildStepScenarios() } + : USE_RATE_STEP_SCENARIO + ? { scenarios: buildRateScenarios() } + : { vus: DEFAULT_VUS, duration: DEFAULT_DURATION }), +}; + +const WS_BASE = ENV.WS_BASE || 'ws://host.docker.internal:6600'; +const HTTP_BASE = ENV.HTTP_BASE || WS_BASE.replace(/^ws/i, 'http'); +const AUCTION_ID = ENV.AUCTION_ID || '11111111-1111-1111-1111-111111111111'; + +const USE_SOCKJS = (ENV.WS_MODE || 'sockjs').toLowerCase() !== 'ws'; +const SKIP_INFO = ENV.SKIP_INFO === '1'; + +const DEBUG = ENV.DEBUG === '1'; +const LOG_WS_ERRORS = ENV.LOG_WS_ERRORS === '1'; +const LOG_INFO_SLOW_MS = ENV.LOG_INFO_SLOW_MS ? parseInt(ENV.LOG_INFO_SLOW_MS, 10) : 0; + +const SEND_DELAY_MS = ENV.SEND_DELAY_MS ? parseInt(ENV.SEND_DELAY_MS, 10) : 0; +const SEND_EVERY_MS = ENV.SEND_EVERY_MS ? parseInt(ENV.SEND_EVERY_MS, 10) : 1000; +const SESSION_MS = ENV.SESSION_MS ? parseInt(ENV.SESSION_MS, 10) : 10000; + +const BID_PRICE_BASE = ENV.BID_PRICE_BASE ? parseFloat(ENV.BID_PRICE_BASE) : 1000; +const BID_PRICE_STEP = ENV.BID_PRICE_STEP ? parseFloat(ENV.BID_PRICE_STEP) : 0.01; +const BID_PRICE_START = ENV.BID_PRICE_START ? parseFloat(ENV.BID_PRICE_START) : null; +const BID_PRICE_MODE = (ENV.BID_PRICE_MODE || '').toLowerCase(); +const VU_COUNT = ENV.SCENARIO_VUS ? parseInt(ENV.SCENARIO_VUS, 10) : DEFAULT_VUS; + +const bidSent = new Counter('bid_sent'); +const wsConnectOk = new Rate('ws_connect_ok'); + +function rateToInterval(rps) { + if (!rps || rps <= 0) { + return 0; + } + return Math.max(1, Math.round(1000 / rps)); +} + +function parseRateList(raw) { + if (!raw) { + return []; + } + return String(raw) + .split(/[\s,]+/) + .map((value) => parseFloat(value)) + .filter((value) => Number.isFinite(value) && value > 0); +} function buildStepScenarios() { const scenarios = {}; const stageSessionMs = STEP_STAGE_MINUTES * 60 * 1000; - STEP_SCENARIO_STAGES.forEach((stage, index) => { - scenarios[`step_${stage.vus}vus`] = { + STEP_SCENARIO_VUS.forEach((vus, index) => { + scenarios[`step_${vus}vus`] = { executor: 'constant-vus', - vus: stage.vus, + vus, duration: `${STEP_STAGE_MINUTES}m`, startTime: `${index * STEP_STAGE_MINUTES}m`, + gracefulStop: '0s', env: { SESSION_MS: String(stageSessionMs), - SCENARIO_VUS: String(stage.vus), + SCENARIO_VUS: String(vus), }, }; }); return scenarios; } -export const options = { - ...(USE_STEP_SCENARIO - ? { scenarios: buildStepScenarios() } - : { vus: VUS, duration: DURATION }), -}; - -const WS_BASE = __ENV.WS_BASE || 'ws://host.docker.internal:6600'; -const HTTP_BASE = __ENV.HTTP_BASE || WS_BASE.replace(/^ws/i, 'http'); -const AUCTION_ID = __ENV.AUCTION_ID || '11111111-1111-1111-1111-111111111111'; -const DEBUG = __ENV.DEBUG === '1'; -const LOG_WS_ERRORS = __ENV.LOG_WS_ERRORS === '1'; -const LOG_INFO_SLOW_MS = __ENV.LOG_INFO_SLOW_MS - ? parseInt(__ENV.LOG_INFO_SLOW_MS, 10) - : 0; -const SEND_DELAY_MS = __ENV.SEND_DELAY_MS ? parseInt(__ENV.SEND_DELAY_MS, 10) : 0; -const SEND_EVERY_MS = __ENV.SEND_EVERY_MS ? parseInt(__ENV.SEND_EVERY_MS, 10) : 1000; -const SESSION_MS = __ENV.SESSION_MS ? parseInt(__ENV.SESSION_MS, 10) : 10000; -const BID_PRICE_BASE = __ENV.BID_PRICE_BASE ? parseFloat(__ENV.BID_PRICE_BASE) : 1000; -const BID_PRICE_STEP = __ENV.BID_PRICE_STEP ? parseFloat(__ENV.BID_PRICE_STEP) : 0.01; -const BID_PRICE_START = __ENV.BID_PRICE_START ? parseFloat(__ENV.BID_PRICE_START) : null; -const BID_PRICE_MODE = (__ENV.BID_PRICE_MODE || '').toLowerCase(); -const VU_COUNT = __ENV.SCENARIO_VUS ? parseInt(__ENV.SCENARIO_VUS, 10) : VUS; +function buildRateScenarios() { + const scenarios = {}; + const stageSessionMs = RATE_STEP_MINUTES * 60 * 1000; + RATE_STEP_RPS_LIST.forEach((rps, index) => { + const intervalMs = rateToInterval(rps); + scenarios[`rate_${rps}rps`] = { + executor: 'constant-vus', + vus: RATE_STEP_VUS, + duration: `${RATE_STEP_MINUTES}m`, + startTime: `${index * RATE_STEP_MINUTES}m`, + gracefulStop: '0s', + env: { + SEND_EVERY_MS: String(intervalMs), + SCENARIO_VUS: String(RATE_STEP_VUS), + SESSION_MS: String(stageSessionMs), + }, + }; + }); + return scenarios; +} function buildQuery(params) { const parts = []; @@ -80,26 +132,26 @@ function buildQuery(params) { } function loadTokens() { - const rawFromFile = __ENV.TOKENS_FILE ? open(__ENV.TOKENS_FILE) : ''; - const rawFromEnv = __ENV.TOKENS || ''; - const raw = [rawFromFile, rawFromEnv].filter((v) => v).join(','); + const rawFromFile = ENV.TOKENS_FILE ? open(ENV.TOKENS_FILE) : ''; + const rawFromEnv = ENV.TOKENS || ''; + const raw = [rawFromFile, rawFromEnv].filter((value) => value).join(','); if (!raw) { return []; } return raw .split(/[\r\n,]+/) - .map((t) => t.trim()) - .filter((t) => t.length > 0); + .map((token) => token.trim()) + .filter((token) => token.length > 0); } -const TOKENS_LIST = loadTokens(); +const TOKENS = loadTokens(); function pickToken() { - if (TOKENS_LIST.length > 0) { - return TOKENS_LIST[(__VU - 1) % TOKENS_LIST.length]; + if (TOKENS.length > 0) { + return TOKENS[(__VU - 1) % TOKENS.length]; } - if (__ENV.TOKEN) { - return __ENV.TOKEN; + if (ENV.TOKEN) { + return ENV.TOKEN; } return null; } @@ -141,6 +193,26 @@ function nextBidPrice(bidState) { return Number(price.toFixed(2)); } +function stompFrame(command, headers, body) { + let frame = `${command}\n`; + Object.keys(headers || {}).forEach((key) => { + frame += `${key}:${headers[key]}\n`; + }); + frame += '\n'; + if (body) { + frame += body; + } + return `${frame}\x00`; +} + +function sendFrame(socket, frame) { + if (USE_SOCKJS) { + socket.send(JSON.stringify([frame])); + return; + } + socket.send(frame); +} + function sendBid(socket, bidState) { const bidPrice = nextBidPrice(bidState); const payload = JSON.stringify({ @@ -149,7 +221,7 @@ function sendBid(socket, bidState) { idempotencyKey: pseudoUuid(), }); - sockjsSend( + sendFrame( socket, stompFrame( 'SEND', @@ -161,9 +233,10 @@ function sendBid(socket, bidState) { payload ) ); + bidSent.add(1); } -function stepSendEveryMs(startedAt) { +function stepIntervalMs(startedAt) { const elapsed = Date.now() - startedAt; return elapsed < STEP_RATE_SWITCH_MS ? STEP_FIRST_SEND_EVERY_MS @@ -171,7 +244,7 @@ function stepSendEveryMs(startedAt) { } function scheduleStepBids(socket, bidState, startedAt) { - const intervalMs = stepSendEveryMs(startedAt); + const intervalMs = stepIntervalMs(startedAt); sendBid(socket, bidState); if (intervalMs > 0) { socket.setTimeout(() => { @@ -180,24 +253,7 @@ function scheduleStepBids(socket, bidState, startedAt) { } } -function stompFrame(command, headers, body) { - let frame = `${command}\n`; - Object.keys(headers || {}).forEach((key) => { - frame += `${key}:${headers[key]}\n`; - }); - frame += '\n'; - if (body) { - frame += body; - } - return `${frame}\x00`; -} - -function sockjsSend(socket, payload) { - // WebSocket transport expects a raw JSON array of messages. - socket.send(JSON.stringify([payload])); -} - -function sockjsMessages(data) { +function parseSockjsMessages(data) { if (!data) { return []; } @@ -214,6 +270,15 @@ function sockjsMessages(data) { return []; } +function parseStompFrames(data) { + if (!data) { + return []; + } + return String(data) + .split('\x00') + .filter((frame) => frame.length > 0); +} + function stompBody(frame) { const marker = '\n\n'; const idx = frame.indexOf(marker); @@ -238,39 +303,120 @@ function updateMinBidFromFrame(frame, bidState) { } } -function buildSockjsUrls(token) { +function buildWsUrls(token) { + if (!USE_SOCKJS) { + const wsQuery = buildQuery({ token }); + const wsUrl = `${WS_BASE}/ws-auction${wsQuery ? `?${wsQuery}` : ''}`; + return { infoUrl: null, wsUrl }; + } + const serverId = String(Math.floor(Math.random() * 1000)).padStart(3, '0'); const sessionId = randomString(8); const infoQuery = buildQuery({ token, t: Date.now() }); const wsQuery = buildQuery({ token }); - const infoUrl = `${HTTP_BASE}/ws-auction/info${infoQuery ? `?${infoQuery}` : ''}`; + const infoUrl = SKIP_INFO + ? null + : `${HTTP_BASE}/ws-auction/info${infoQuery ? `?${infoQuery}` : ''}`; const wsUrl = `${WS_BASE}/ws-auction/${serverId}/${sessionId}/websocket${ wsQuery ? `?${wsQuery}` : '' }`; return { infoUrl, wsUrl }; } +function startBidding(socket, bidState) { + if (USE_STEP_SCENARIO) { + scheduleStepBids(socket, bidState, Date.now()); + return; + } + + sendBid(socket, bidState); + if (SEND_EVERY_MS > 0) { + socket.setInterval(() => { + sendBid(socket, bidState); + }, SEND_EVERY_MS); + } +} + +function summaryValue(data, metricName, key) { + if (!data.metrics || !data.metrics[metricName]) { + return null; + } + const values = data.metrics[metricName].values || {}; + return Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null; +} + +export function handleSummary(data) { + const checksRate = summaryValue(data, 'checks', 'rate'); + const checksPass = summaryValue(data, 'checks', 'passes'); + const checksFail = summaryValue(data, 'checks', 'fails'); + const bidSentRate = summaryValue(data, 'bid_sent', 'rate'); + const bidSentCount = summaryValue(data, 'bid_sent', 'count'); + const wsSentRate = summaryValue(data, 'ws_msgs_sent', 'rate'); + const wsRecvRate = summaryValue(data, 'ws_msgs_received', 'rate'); + const wsConnectP95 = summaryValue(data, 'ws_connecting', 'p(95)'); + const wsConnectOkRate = summaryValue(data, 'ws_connect_ok', 'rate'); + const vusMax = summaryValue(data, 'vus_max', 'max'); + + const lines = []; + lines.push('k6 summary'); + if (checksRate !== null) { + lines.push(`checks rate: ${checksRate}`); + } + if (checksPass !== null || checksFail !== null) { + lines.push(`checks pass/fail: ${checksPass || 0}/${checksFail || 0}`); + } + if (bidSentRate !== null || bidSentCount !== null) { + lines.push(`bid_sent rate/count: ${bidSentRate || 0}/${bidSentCount || 0}`); + } + if (wsSentRate !== null) { + lines.push(`ws_msgs_sent rate: ${wsSentRate}`); + } + if (wsRecvRate !== null) { + lines.push(`ws_msgs_received rate: ${wsRecvRate}`); + } + if (wsConnectP95 !== null) { + lines.push(`ws_connecting p95: ${wsConnectP95}`); + } + if (wsConnectOkRate !== null) { + lines.push(`ws_connect_ok rate: ${wsConnectOkRate}`); + } + if (vusMax !== null) { + lines.push(`vus_max: ${vusMax}`); + } + const summaryText = `${lines.join('\n')}\n`; + + return { + '/scripts/k6_summary.json': JSON.stringify(data, null, 2), + '/scripts/k6_summary.txt': summaryText, + stdout: summaryText, + }; +} + export default function () { const token = pickToken(); const useToken = token && token.length > 0; - const userId = __ENV.USER_ID_BASE - ? String(parseInt(__ENV.USER_ID_BASE, 10) + __VU - 1) + const userId = ENV.USER_ID_BASE + ? String(parseInt(ENV.USER_ID_BASE, 10) + __VU - 1) : String(__VU); - const { infoUrl, wsUrl } = buildSockjsUrls(token); - const infoRes = http.get(infoUrl); - if (DEBUG && __ITER === 0) { - console.log(`sockjs info status=${infoRes.status}`); - } - if ( - LOG_INFO_SLOW_MS > 0 - && infoRes - && infoRes.timings - && infoRes.timings.duration > LOG_INFO_SLOW_MS - ) { - console.warn( - `slow info: status=${infoRes.status} duration_ms=${infoRes.timings.duration}` - ); + const { infoUrl, wsUrl } = buildWsUrls(token); + if (USE_SOCKJS && !SKIP_INFO) { + const infoRes = http.get(infoUrl); + if (DEBUG && __ITER === 0) { + console.log(`sockjs info status=${infoRes.status}`); + } + if ( + LOG_INFO_SLOW_MS > 0 + && infoRes + && infoRes.timings + && infoRes.timings.duration > LOG_INFO_SLOW_MS + ) { + console.warn( + `slow info: status=${infoRes.status} duration_ms=${infoRes.timings.duration}` + ); + } + } else if (DEBUG && __ITER === 0) { + console.log('sockjs info skipped'); } const res = ws.connect( @@ -291,40 +437,33 @@ export default function () { minBid: null, }; + const connectStomp = () => { + sendFrame( + socket, + stompFrame('CONNECT', { + 'accept-version': '1.2', + 'heart-beat': '10000,10000', + }) + ); + }; + if (SESSION_MS > 0) { socket.setTimeout(() => { socket.close(); }, SESSION_MS); } - socket.on('message', (data) => { - if (data === 'o') { - sockjsSend( - socket, - stompFrame('CONNECT', { - 'accept-version': '1.2', - 'heart-beat': '10000,10000', - }) - ); - return; - } - - if (data === 'h') { - return; - } - - if (data && data[0] === 'c') { - socket.close(); - return; - } + if (!USE_SOCKJS) { + connectStomp(); + } - const frames = sockjsMessages(data); + const handleFrames = (frames) => { frames.forEach((frame) => { updateMinBidFromFrame(frame, bidState); if (frame.startsWith('CONNECTED') && !biddingStarted) { biddingStarted = true; - sockjsSend( + sendFrame( socket, stompFrame('SUBSCRIBE', { id: `sub-${userId}`, @@ -332,30 +471,36 @@ export default function () { }) ); - const startBidding = () => { - if (USE_STEP_SCENARIO) { - scheduleStepBids(socket, bidState, Date.now()); - return; - } - - sendBid(socket, bidState); - if (SEND_EVERY_MS > 0) { - socket.setInterval(() => { - sendBid(socket, bidState); - }, SEND_EVERY_MS); - } - }; - const startDelayMs = USE_STEP_SCENARIO ? STEP_FIRST_SEND_EVERY_MS : SEND_DELAY_MS; if (startDelayMs > 0) { - socket.setTimeout(startBidding, startDelayMs); + socket.setTimeout(() => startBidding(socket, bidState), startDelayMs); } else { - startBidding(); + startBidding(socket, bidState); } } }); + }; + + socket.on('message', (data) => { + if (USE_SOCKJS) { + if (data === 'o') { + connectStomp(); + return; + } + if (data === 'h') { + return; + } + if (data && data[0] === 'c') { + socket.close(); + return; + } + handleFrames(parseSockjsMessages(data)); + return; + } + + handleFrames(parseStompFrames(data)); }); socket.on('error', (e) => { @@ -381,6 +526,7 @@ export default function () { ); } + wsConnectOk.add(res && res.status === 101); check(res, { 'status is 101': (r) => r && r.status === 101, });