diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml new file mode 100644 index 0000000..2d56f5e --- /dev/null +++ b/.github/workflows/load-test.yml @@ -0,0 +1,54 @@ +name: Load Test + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + target_rps: + description: 'Target requests per second' + required: false + default: '500' + duration: + description: 'Test duration' + required: false + default: '300s' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + load-test-voiceprint: + name: Voiceprint Load Test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install k6 + run: | + curl -s https://github.com/grafana/k6/releases/download/v0.50.0/k6-linux-amd64.tar.gz -L | tar xz + sudo mv k6 /usr/local/bin/ + k6 version + + - name: Run Voiceprint load tests + run: | + cd load-tests/voiceprint + ./run.sh mixed + env: + VOICEPRINT_BASE_URL: ${{ secrets.LOAD_TEST_BASE_URL || 'http://localhost:3000' }} + API_TOKEN: ${{ secrets.LOAD_TEST_API_TOKEN || 'test-token' }} + TARGET_RPS: ${{ github.event.inputs.target_rps || '500' }} + DURATION: ${{ github.event.inputs.duration || '300s' }} + ENROLLMENT_P99_MS: '500' + VERIFICATION_P99_MS: '250' + MODEL_RETRIEVAL_P99_MS: '100' + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: load-test-results-${{ github.sha }} + path: load-tests/voiceprint/results/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index af3f68a..0c49ace 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist .env *.log .DS_Store +load-tests/voiceprint/results/ diff --git a/load-tests/voiceprint/.env.example b/load-tests/voiceprint/.env.example new file mode 100644 index 0000000..c9a8c6b --- /dev/null +++ b/load-tests/voiceprint/.env.example @@ -0,0 +1,19 @@ +# Voiceprint Load Test Configuration +# Copy to .env and adjust values + +# Base URL of the Voiceprint API +VOICEPRINT_BASE_URL=http://localhost:3000 + +# API authentication token +API_TOKEN=test-token + +# Test duration (default: 300s = 5 minutes) +DURATION=300s + +# Target requests per second (default: 500) +TARGET_RPS=500 + +# P99 latency thresholds in milliseconds +ENROLLMENT_P99_MS=500 +VERIFICATION_P99_MS=250 +MODEL_RETRIEVAL_P99_MS=100 diff --git a/load-tests/voiceprint/run.sh b/load-tests/voiceprint/run.sh new file mode 100755 index 0000000..e3048cf --- /dev/null +++ b/load-tests/voiceprint/run.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Run k6 load tests for Voiceprint endpoints +# Usage: ./run.sh [scenario] +# scenario: mixed (default), enrollment, verification, model-retrieval + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Load environment variables from .env if present +if [[ -f .env ]]; then + set -a + source .env + set +a +fi + +SCENARIO="${1:-mixed}" +OUTPUT_DIR="${SCRIPT_DIR}/results" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" + +mkdir -p "$OUTPUT_DIR" + +echo "=== Voiceprint Load Test ===" +echo "Scenario: $SCENARIO" +echo "Target RPS: ${TARGET_RPS:-500}" +echo "Duration: ${DURATION:-300s}" +echo "Base URL: ${VOICEPRINT_BASE_URL:-http://localhost:3000}" +echo "" + +case "$SCENARIO" in + mixed) + k6 run voiceprint.js \ + --out json="$OUTPUT_DIR/results-${TIMESTAMP}.json" \ + < { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +// Generate a realistic audio payload (base64-encoded WAV-like buffer) +// ~3 seconds of 16kHz mono 16-bit audio = ~96KB +function generateAudioPayload() { + const size = 96000; + const audio = new Array(size); + for (let i = 0; i < size; i++) { + audio[i] = Math.floor(Math.random() * 256); + } + return btoa(String.fromCharCode(...audio.slice(0, 2048))); +} + +const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${API_TOKEN}`, +}; + +// ── Scenario: Enrollment (POST /voiceprint/enroll) ────────────────────────── +function testEnrollment() { + const payload = JSON.stringify({ + name: `voice_profile_${uuidv4()}`, + audio: generateAudioPayload(), + }); + + const res = http.post(`${BASE_URL}/voiceprint/enroll`, payload, { headers }); + const duration = res.timings.duration; + enrollmentLatency.add(duration); + + const success = res.status === 201; + enrollmentSuccess.add(success); + + check(res, { + 'enrollment: status 201': (r) => r.status === 201, + 'enrollment: has enrollment.id': (r) => { + try { + const json = JSON.parse(r.body); + return !!json.enrollment && !!json.enrollment.id; + } catch { + return false; + } + }, + `enrollment: P99 < ${THRESHOLDS.enrollment}ms`: (r) => duration < THRESHOLDS.enrollment, + }); + + return res.json()?.enrollment?.id || uuidv4(); +} + +// ── Scenario: Verification (POST /voiceprint/analyze) ─────────────────────── +function testVerification() { + const payload = JSON.stringify({ + audio: generateAudioPayload(), + }); + + const res = http.post(`${BASE_URL}/voiceprint/analyze`, payload, { headers }); + const duration = res.timings.duration; + verificationLatency.add(duration); + + const success = res.status === 201; + verificationSuccess.add(success); + + check(res, { + 'verification: status 201': (r) => r.status === 201, + 'verification: has analysis.id': (r) => { + try { + const json = JSON.parse(r.body); + return !!json.analysis && !!json.analysis.id; + } catch { + return false; + } + }, + `verification: P99 < ${THRESHOLDS.verification}ms`: (r) => duration < THRESHOLDS.verification, + }); + + return res.json()?.analysis?.id || uuidv4(); +} + +// ── Scenario: Model Retrieval (GET /voiceprint/results/:id) ───────────────── +function testModelRetrieval(modelId) { + const id = modelId || uuidv4(); + const res = http.get(`${BASE_URL}/voiceprint/results/${id}`, { headers }); + const duration = res.timings.duration; + modelRetrievalLatency.add(duration); + + // 200 = found, 404 = not found (both valid for load testing) + const success = res.status === 200 || res.status === 404; + modelRetrievalSuccess.add(success); + + check(res, { + 'model_retrieval: status 200 or 404': (r) => r.status === 200 || r.status === 404, + `model_retrieval: P99 < ${THRESHOLDS.modelRetrieval}ms`: (r) => duration < THRESHOLDS.modelRetrieval, + }); +} + +// ── Default Scenario: Weighted mixed workload ──────────────────────────────── +export const options = { + scenarios: { + sustained_load: { + executor: 'constant-arrival-rate', + duration: DURATION, + rate: TARGET_RPS, + preAllocatedVUs: 20, + maxVUs: 100, + startTime: '0s', + exec: 'mixedWorkload', + tags: { scenario: 'sustained_load' }, + }, + }, + thresholds: { + `enrollment_p99`: [`p(99)<${THRESHOLDS.enrollment}`], + `verification_p99`: [`p(99)<${THRESHOLDS.verification}`], + `model_retrieval_p99`: [`p(99)<${THRESHOLDS.modelRetrieval}`], + `enrollment_success`: ['rate>0.95'], + `verification_success`: ['rate>0.95'], + `model_retrieval_success`: ['rate>0.95'], + http_req_duration: [`p(95)<400`, `p(99)<500`], + http_req_failed: ['rate<0.05'], + }, +}; + +// Mixed workload: 30% enrollment, 45% verification, 25% model retrieval +export function mixedWorkload() { + const rand = Math.random(); + + if (rand < 0.3) { + const modelId = testEnrollment(); + sleep(0.1); + testModelRetrieval(modelId); + } else if (rand < 0.75) { + const modelId = testVerification(); + sleep(0.05); + testModelRetrieval(modelId); + } else { + testModelRetrieval(); + } + + sleep(0.05); +} + +// ── Individual endpoint scenarios for targeted testing ─────────────────────── +export const endpointScenarios = { + enrollment_only: { + executor: 'constant-arrival-rate', + duration: DURATION, + rate: TARGET_RPS, + preAllocatedVUs: 20, + maxVUs: 100, + exec: 'enrollmentOnly', + startTime: '0s', + tags: { scenario: 'enrollment_only' }, + }, + verification_only: { + executor: 'constant-arrival-rate', + duration: DURATION, + rate: TARGET_RPS, + preAllocatedVUs: 20, + maxVUs: 100, + exec: 'verificationOnly', + startTime: '0s', + tags: { scenario: 'verification_only' }, + }, + model_retrieval_only: { + executor: 'constant-arrival-rate', + duration: DURATION, + rate: TARGET_RPS, + preAllocatedVUs: 20, + maxVUs: 100, + exec: 'modelRetrievalOnly', + startTime: '0s', + tags: { scenario: 'model_retrieval_only' }, + }, +}; + +export function enrollmentOnly() { + testEnrollment(); + sleep(0.1); +} + +export function verificationOnly() { + testVerification(); + sleep(0.05); +} + +export function modelRetrievalOnly() { + testModelRetrieval(); + sleep(0.02); +} + +// ── Summary Hook ───────────────────────────────────────────────────────────── +export function handleSummary(data) { + return { + 'stdout': `\n=== Voiceprint Load Test Results ===\n`, + 'summary.json': JSON.stringify({ + timestamp: new Date().toISOString(), + duration: DURATION, + targetRPS: TARGET_RPS, + thresholds: THRESHOLDS, + metrics: { + enrollment: { + p99: data.metrics.enrollment_p99?.values['p(99)']?.toFixed(2) || 'N/A', + p95: data.metrics.enrollment_p99?.values['p(95)']?.toFixed(2) || 'N/A', + avg: data.metrics.enrollment_p99?.values.avg?.toFixed(2) || 'N/A', + count: data.metrics.enrollment_p99?.values.count || 0, + successRate: (data.metrics.enrollment_success?.values.rate || 0) * 100 + '%', + }, + verification: { + p99: data.metrics.verification_p99?.values['p(99)']?.toFixed(2) || 'N/A', + p95: data.metrics.verification_p99?.values['p(95)']?.toFixed(2) || 'N/A', + avg: data.metrics.verification_p99?.values.avg?.toFixed(2) || 'N/A', + count: data.metrics.verification_p99?.values.count || 0, + successRate: (data.metrics.verification_success?.values.rate || 0) * 100 + '%', + }, + modelRetrieval: { + p99: data.metrics.model_retrieval_p99?.values['p(99)']?.toFixed(2) || 'N/A', + p95: data.metrics.model_retrieval_p99?.values['p(95)']?.toFixed(2) || 'N/A', + avg: data.metrics.model_retrieval_p99?.values.avg?.toFixed(2) || 'N/A', + count: data.metrics.model_retrieval_p99?.values.count || 0, + successRate: (data.metrics.model_retrieval_success?.values.rate || 0) * 100 + '%', + }, + }, + passed: Object.entries(data.metrics).every( + ([_, metric]) => metric?.thresholds?.every?.((t) => t.pass) + ), + }, null, 2), + }; +}