Add k6 load test scripts for Voiceprint verification endpoints (FRE-4930)
- k6 script with P99 latency thresholds (enrollment <500ms, verification <250ms, model retrieval <100ms) - Configurable 500 req/s sustained throughput for 5 minutes - Mixed workload scenario + individual endpoint scenarios - GitHub Actions workflow for automated load testing - Runner script with environment configuration - JSON result export for CI artifact collection - .gitignore entry for load test results
This commit is contained in:
54
.github/workflows/load-test.yml
vendored
Normal file
54
.github/workflows/load-test.yml
vendored
Normal file
@@ -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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist
|
|||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
load-tests/voiceprint/results/
|
||||||
|
|||||||
19
load-tests/voiceprint/.env.example
Normal file
19
load-tests/voiceprint/.env.example
Normal file
@@ -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
|
||||||
69
load-tests/voiceprint/run.sh
Executable file
69
load-tests/voiceprint/run.sh
Executable file
@@ -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" \
|
||||||
|
<<EOF
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
enrollment)
|
||||||
|
k6 run --scenario enrollment_only voiceprint.js \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
;;
|
||||||
|
verification)
|
||||||
|
k6 run --scenario verification_only voiceprint.js \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
;;
|
||||||
|
model-retrieval)
|
||||||
|
k6 run --scenario model_retrieval_only voiceprint.js \
|
||||||
|
--out json="$OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown scenario: $SCENARIO"
|
||||||
|
echo "Available: mixed, enrollment, verification, model-retrieval"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
EXIT_CODE=$?
|
||||||
|
|
||||||
|
if [[ $EXIT_CODE -eq 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ All thresholds passed!"
|
||||||
|
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "❌ Thresholds failed. Check output above."
|
||||||
|
echo "Results saved to: $OUTPUT_DIR/results-${TIMESTAMP}.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
259
load-tests/voiceprint/voiceprint.js
Normal file
259
load-tests/voiceprint/voiceprint.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep } from 'k6';
|
||||||
|
import { Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
// ── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
const BASE_URL = __ENV.VOICEPRINT_BASE_URL || 'http://localhost:3000';
|
||||||
|
const API_TOKEN = __ENV.API_TOKEN || 'test-token';
|
||||||
|
const DURATION = __ENV.DURATION || '300s'; // 5 minutes
|
||||||
|
const TARGET_RPS = parseInt(__ENV.TARGET_RPS || '500', 10);
|
||||||
|
|
||||||
|
// P99 latency thresholds (ms)
|
||||||
|
const THRESHOLDS = {
|
||||||
|
enrollment: parseInt(__ENV.ENROLLMENT_P99_MS || '500', 10),
|
||||||
|
verification: parseInt(__ENV.VERIFICATION_P99_MS || '250', 10),
|
||||||
|
modelRetrieval: parseInt(__ENV.MODEL_RETRIEVAL_P99_MS || '100', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Custom Metrics ───────────────────────────────────────────────────────────
|
||||||
|
const enrollmentLatency = new Trend('enrollment_p99');
|
||||||
|
const verificationLatency = new Trend('verification_p99');
|
||||||
|
const modelRetrievalLatency = new Trend('model_retrieval_p99');
|
||||||
|
|
||||||
|
const enrollmentSuccess = new Rate('enrollment_success');
|
||||||
|
const verificationSuccess = new Rate('verification_success');
|
||||||
|
const modelRetrievalSuccess = new Rate('model_retrieval_success');
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function uuidv4() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user