diff --git a/.changeset/jolly-canyons-glow.md b/.changeset/jolly-canyons-glow.md new file mode 100644 index 00000000..ee7b3036 --- /dev/null +++ b/.changeset/jolly-canyons-glow.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +perf: added k6 testing for redis on connection and message service rate limiting diff --git a/package-lock.json b/package-lock.json index cb999924..1f827cc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2711,6 +2711,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/k6": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@types/k6/-/k6-1.7.0.tgz", + "integrity": "sha512-oL4mckVcOPIA2HUrCVj3aQXCJgCqsQe35Uc4fRTffmrQuR24v92GJImnagqUaRnC1TQVJFx85o3aHQPP+0bxpg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", diff --git a/package.json b/package.json index b25f5241..b2657500 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "test:load": "node -r ts-node/register ./scripts/security-load-test.ts", "smoke:nip03": "node -r ts-node/register scripts/smoke-nip03.ts", "test:integration": "cucumber-js", + "test:connection": "docker ps | grep nostream > /dev/null && k6 run test/integration/performance/connection-limiting-k6.ts || echo 'Error: nostream container not running'", + "test:message": "docker ps | grep nostream > /dev/null && k6 run test/integration/performance/message-limiting-k6.ts || echo 'Error: nostream container not running'", "cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover", "export": "node --env-file-if-exists=.env -r ts-node/register src/scripts/export-events.ts", "docker:compose:start": "./scripts/start", @@ -100,6 +102,7 @@ "@types/chai-as-promised": "^7.1.5", "@types/express": "4.17.21", "@types/js-yaml": "4.0.5", + "@types/k6": "^1.7.0", "@types/mocha": "^9.1.1", "@types/node": "^24.12.2", "@types/pg": "^8.6.5", diff --git a/test/integration/performance/connection-limiting-k6.ts b/test/integration/performance/connection-limiting-k6.ts new file mode 100644 index 00000000..0eb8d61a --- /dev/null +++ b/test/integration/performance/connection-limiting-k6.ts @@ -0,0 +1,83 @@ +import { check, sleep } from 'k6'; +import { Counter } from 'k6/metrics'; +import ws from 'k6/ws'; + +const relayUrl = 'ws://127.0.0.1:8008'; +const connectionSuccess = new Counter('connection_success'); +const connectionRateLimited = new Counter('connection_rate_limited'); + +export const options = { + stages: [ + { duration: '10s', target: 3 }, + { duration: '10s', target: 6 }, + { duration: '10s', target: 12 }, + { duration: '10s', target: 18 }, + { duration: '5s', target: 0 }, + ], + thresholds: { + 'ws_connecting': ['p(95)<2000'], + }, +}; + +export default function () { + let socketClosed = false; + + const res = ws.connect(relayUrl, {}, function (socket) { + socket.on('close', () => { + socketClosed = true; + connectionRateLimited.add(1); + }); + + socket.on('open', () => { + connectionSuccess.add(1); + }); + + socket.setTimeout(() => { + if (!socketClosed) { + socket.close(); + } + }, 3000); + }); + + check(res, { + 'status is 101': (r) => r && r.status === 101, + }); + + sleep(0.5); +} + +export function handleSummary(data: any) { + const connSuccess = data.metrics?.connection_success?.values?.count || 0; + const connRateLimited = data.metrics?.connection_rate_limited?.values?.count || 0; + const iterations = data.metrics?.iterations?.values?.count || 0; + const checks = data.metrics?.checks?.values?.passes || 0; + const wsSessions = data.metrics?.ws_sessions?.values?.count || 0; + + const totalConnections = connSuccess + connRateLimited; + const successRate = totalConnections > 0 ? ((connSuccess / totalConnections) * 100).toFixed(2) : 0; + const rate = parseFloat(successRate as string); + const successStatus = rate >= 80 ? '✓ GOOD' : rate >= 50 ? '⚠ MODERATE' : '✗ POOR'; + + console.log(` + ╔════════════════════════════════════════════════════════════════╗ + ║ CONNECTION RATE LIMITER TEST RESULTS ║ + ╚════════════════════════════════════════════════════════════════╝ + + EXECUTION: + Iterations: ${iterations} + WebSocket Sessions: ${wsSessions} + Checks Passed: ${checks} + + CONNECTIONS: + ✓ Success (stayed open): ${connSuccess} + ✗ Rate Limited (closed): ${connRateLimited} + ───────────────────── + Total: ${totalConnections} + + PERFORMANCE: + Success Rate: ${successStatus} ${successRate}% + + ═══════════════════════════════════════════════════════════════════ + `); + return {}; +} \ No newline at end of file diff --git a/test/integration/performance/message-limiting-k6.ts b/test/integration/performance/message-limiting-k6.ts new file mode 100644 index 00000000..02fc46fb --- /dev/null +++ b/test/integration/performance/message-limiting-k6.ts @@ -0,0 +1,106 @@ +import { check } from 'k6'; +import { Counter } from 'k6/metrics'; +import ws from 'k6/ws'; + +const relayUrl = 'ws://127.0.0.1:8008'; +const noticeCounter = new Counter('notice_messages'); +const eoseCounter = new Counter('eose_messages'); +const eventCounter = new Counter('event_messages'); +const errorCounter = new Counter('error_messages'); + +export const options = { + stages: [ + { duration: '10s', target: 1 }, + { duration: '10s', target: 2 }, + { duration: '10s', target: 4 }, + { duration: '5s', target: 0 }, + ], +}; + +export default function () { + const res = ws.connect(relayUrl, null, function (socket) { + socket.on('open', function () { + let msgCount = 0; + socket.setInterval(function () { + msgCount++; + const text = JSON.stringify(['REQ', `sub-${Date.now()}-${msgCount}`, {limit: 10}]); + socket.send(text); + }, 1000); + }); + + socket.on('message', function (data) { + try { + const parsed = JSON.parse(data); + const msgType = parsed[0]; + + if (msgType === 'NOTICE') { + noticeCounter.add(1); + } else if (msgType === 'EOSE') { + eoseCounter.add(1); + } else if (msgType === 'EVENT') { + eventCounter.add(1); + } + } catch (e: any) { + errorCounter.add(1); + console.error('Failed to parse message:', e.message); + } + }); + + socket.setTimeout(function () { + socket.close(); + }, 9000); + }); + + check(res, { + 'status 101': (r) => r && r.status === 101, + }); +} + +export function handleSummary(data: any) { + const notices = data.metrics?.notice_messages?.values?.count || 0; + const eoses = data.metrics?.eose_messages?.values?.count || 0; + const events = data.metrics?.event_messages?.values?.count || 0; + const iterations = data.metrics?.iterations?.values?.count || 0; + const wsSessions = data.metrics?.ws_sessions?.values?.count || 0; + const msgsSent = data.metrics?.ws_msgs_sent?.values?.count || 0; + const msgsReceived = data.metrics?.ws_msgs_received?.values?.count || 0; + const dataReceived = data.metrics?.data_received?.values?.count || 0; + const checks = data.metrics?.checks?.values?.passes || 0; + + const totalMessages = notices + eoses + events; + const successRate = totalMessages > 0 ? ((eoses + events) / totalMessages * 100).toFixed(2) : 0; + + const rate = parseFloat(successRate as string); + const successStatus = rate >= 80 ? '✓ GOOD' : rate >= 50 ? '⚠ MODERATE' : '✗ POOR'; + const rateLimitStatus = notices > 0 ? '⚠ ACTIVE' : '✓ INACTIVE'; + + console.log(` +╔════════════════════════════════════════════════════════════════╗ +║ MESSAGE RATE LIMITER TEST RESULTS ║ +╚════════════════════════════════════════════════════════════════╝ + +EXECUTION: + Iterations: ${iterations} + WebSocket Sessions: ${wsSessions} + Checks Passed: ${checks} + +MESSAGES: + Sent: ${msgsSent} + Received: ${msgsReceived} + +MESSAGE TYPES: + ✗ NOTICE (rate limited): ${notices} + ✓ EOSE (query complete): ${eoses} + ◆ EVENT (results): ${events} + ───────────────────── + Total: ${totalMessages} + +PERFORMANCE: + Success Rate: ${successStatus} ${successRate}% + Data Received: ${dataReceived} bytes + Rate Limiter: ${rateLimitStatus} + +═══════════════════════════════════════════════════════════════════ + `); + return {}; +} \ No newline at end of file