/** * TTFS (Time to First Signal) Load Test Suite * Reference: SPRINT_0341_0001_0001 Task T13 * * Tests the /first-signal endpoint under various load scenarios. * Requirements from Advisory §12.4: * - Cache-hit P95 ≤ 250ms * - Cold-path P95 ≤ 500ms * - Error rate < 0.1% */ import http from 'k6/http'; import { check, sleep } from 'k6'; import { Rate, Trend } from 'k6/metrics'; import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.3/index.js'; // Custom metrics const cacheHitLatency = new Trend('ttfs_cache_hit_latency_ms'); const coldPathLatency = new Trend('ttfs_cold_path_latency_ms'); const errorRate = new Rate('ttfs_error_rate'); const signalKindCounter = new Rate('ttfs_signal_kind_distribution'); // Configuration export const options = { scenarios: { // Scenario 1: Sustained load - simulates normal operation sustained: { executor: 'constant-arrival-rate', rate: 50, timeUnit: '1s', duration: '5m', preAllocatedVUs: 50, maxVUs: 100, tags: { scenario: 'sustained' }, }, // Scenario 2: Spike test - simulates CI pipeline burst spike: { executor: 'ramping-arrival-rate', startRate: 50, timeUnit: '1s', stages: [ { duration: '30s', target: 200 }, // Ramp to 200 RPS { duration: '1m', target: 200 }, // Hold { duration: '30s', target: 50 }, // Ramp down ], preAllocatedVUs: 100, maxVUs: 300, startTime: '5m30s', tags: { scenario: 'spike' }, }, // Scenario 3: Soak test - long running stability soak: { executor: 'constant-arrival-rate', rate: 25, timeUnit: '1s', duration: '15m', preAllocatedVUs: 30, maxVUs: 50, startTime: '8m', tags: { scenario: 'soak' }, }, }, thresholds: { // Advisory requirements: §12.4 'ttfs_cache_hit_latency_ms{scenario:sustained}': ['p(95)<250'], // P95 ≤ 250ms 'ttfs_cache_hit_latency_ms{scenario:spike}': ['p(95)<350'], // Allow slightly higher during spike 'ttfs_cold_path_latency_ms{scenario:sustained}': ['p(95)<500'], // P95 ≤ 500ms 'ttfs_cold_path_latency_ms{scenario:spike}': ['p(95)<750'], // Allow slightly higher during spike 'ttfs_error_rate': ['rate<0.001'], // < 0.1% errors 'http_req_duration{scenario:sustained}': ['p(95)<300'], 'http_req_duration{scenario:spike}': ['p(95)<500'], 'http_req_failed': ['rate<0.01'], // HTTP failures < 1% }, }; // Environment configuration const BASE_URL = __ENV.BASE_URL || 'http://localhost:5000'; const RUN_IDS = JSON.parse(__ENV.RUN_IDS || '["run-load-1","run-load-2","run-load-3","run-load-4","run-load-5"]'); const TENANT_ID = __ENV.TENANT_ID || 'load-test-tenant'; const AUTH_TOKEN = __ENV.AUTH_TOKEN || ''; /** * Main test function - called for each VU iteration */ export default function () { const runId = RUN_IDS[Math.floor(Math.random() * RUN_IDS.length)]; const url = `${BASE_URL}/api/v1/orchestrator/runs/${runId}/first-signal`; const params = { headers: { 'Accept': 'application/json', 'X-Tenant-Id': TENANT_ID, 'X-Correlation-Id': `load-test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, }, tags: { endpoint: 'first-signal' }, }; // Add auth if provided if (AUTH_TOKEN) { params.headers['Authorization'] = `Bearer ${AUTH_TOKEN}`; } const start = Date.now(); const response = http.get(url, params); const duration = Date.now() - start; // Track latency by cache status const cacheStatus = response.headers['Cache-Status'] || response.headers['X-Cache-Status']; if (cacheStatus && cacheStatus.toLowerCase().includes('hit')) { cacheHitLatency.add(duration); } else { coldPathLatency.add(duration); } // Validate response const checks = check(response, { 'status is 200 or 204 or 304': (r) => [200, 204, 304].includes(r.status), 'has ETag header': (r) => r.status === 200 ? !!r.headers['ETag'] : true, 'has Cache-Status header': (r) => !!cacheStatus, 'response time < 500ms': (r) => r.timings.duration < 500, 'valid JSON response': (r) => { if (r.status !== 200) return true; try { const body = JSON.parse(r.body); return body.runId !== undefined; } catch { return false; } }, 'has signal kind': (r) => { if (r.status !== 200) return true; try { const body = JSON.parse(r.body); return !body.firstSignal || ['passed', 'failed', 'degraded', 'partial', 'pending'].includes(body.firstSignal.kind); } catch { return false; } }, }); errorRate.add(!checks); // Extract signal kind for distribution analysis if (response.status === 200) { try { const body = JSON.parse(response.body); if (body.firstSignal?.kind) { signalKindCounter.add(1, { kind: body.firstSignal.kind }); } } catch { // Ignore parse errors } } // Minimal sleep to allow for realistic load patterns sleep(0.05 + Math.random() * 0.1); // 50-150ms between requests per VU } /** * Conditional request test - tests ETag/304 behavior */ export function conditionalRequest() { const runId = RUN_IDS[0]; const url = `${BASE_URL}/api/v1/orchestrator/runs/${runId}/first-signal`; // First request to get ETag const firstResponse = http.get(url, { headers: { 'Accept': 'application/json', 'X-Tenant-Id': TENANT_ID }, }); if (firstResponse.status !== 200) return; const etag = firstResponse.headers['ETag']; if (!etag) return; // Conditional request const conditionalResponse = http.get(url, { headers: { 'Accept': 'application/json', 'X-Tenant-Id': TENANT_ID, 'If-None-Match': etag, }, tags: { request_type: 'conditional' }, }); check(conditionalResponse, { 'conditional request returns 304': (r) => r.status === 304, }); } /** * Setup function - runs once before the test */ export function setup() { console.log(`Starting TTFS load test against ${BASE_URL}`); console.log(`Testing with ${RUN_IDS.length} run IDs`); // Verify endpoint is accessible const healthCheck = http.get(`${BASE_URL}/health`, { timeout: '5s' }); if (healthCheck.status !== 200) { console.warn(`Health check returned ${healthCheck.status} - proceeding anyway`); } return { startTime: Date.now() }; } /** * Teardown function - runs once after the test */ export function teardown(data) { const duration = (Date.now() - data.startTime) / 1000; console.log(`TTFS load test completed in ${duration.toFixed(1)}s`); } /** * Generate test summary */ export function handleSummary(data) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); return { 'stdout': textSummary(data, { indent: ' ', enableColors: true }), [`results/ttfs-load-test-${timestamp}.json`]: JSON.stringify(data, null, 2), 'results/ttfs-load-test-latest.json': JSON.stringify(data, null, 2), }; }