diff --git a/backend/internal/handlers/websocket_loadtest.go b/backend/internal/handlers/websocket_loadtest.go new file mode 100644 index 0000000..bcb285e --- /dev/null +++ b/backend/internal/handlers/websocket_loadtest.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/M1ngdaXie/realtime-collab/internal/hub" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// loadTestUpgrader allows any origin for load testing +var loadTestUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for load testing + }, +} + +// HandleWebSocketLoadTest is a backdoor WebSocket handler that skips all authentication. +// WARNING: Only use this for local load testing! Do not expose in production. +// +// Usage: ws://localhost:8080/ws/loadtest/:roomId +// Optional query param: ?user=customUserName +func (wsh *WebSocketHandler) HandleWebSocketLoadTest(c *gin.Context) { + roomID := c.Param("roomId") + if roomID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "roomId is required"}) + return + } + + // Generate or use provided user identifier + userName := c.Query("user") + if userName == "" { + userName = "LoadTestUser-" + uuid.New().String()[:8] + } + + // Upgrade connection - NO AUTH CHECK + conn, err := loadTestUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("Failed to upgrade connection: %v", err) + return + } + + // Create client with full edit permissions (no userID, anonymous) + clientID := uuid.New().String() + client := hub.NewClient( + clientID, + nil, // userID - nil for anonymous + userName, // userName + nil, // userAvatar + "edit", // permission - full access for load testing + conn, + wsh.hub, + roomID, + ) + + // Register client + wsh.hub.Register <- client + + // Start goroutines + go client.WritePump() + go client.ReadPump() + + log.Printf("[LOADTEST] Client connected: %s (user: %s) to room: %s", clientID, userName, roomID) +} diff --git a/backend/internal/hub/hub.go b/backend/internal/hub/hub.go index 6d9c959..feba3d5 100644 --- a/backend/internal/hub/hub.go +++ b/backend/internal/hub/hub.go @@ -51,9 +51,9 @@ type Hub struct { func NewHub() *Hub { return &Hub{ rooms: make(map[string]*Room), - Register: make(chan *Client), - Unregister: make(chan *Client), - Broadcast: make(chan *Message, 1024), + Register: make(chan *Client, 256), + Unregister: make(chan *Client, 256), + Broadcast: make(chan *Message, 4096), } } diff --git a/loadtest/loadtest.js b/loadtest/loadtest.js new file mode 100644 index 0000000..39270e3 --- /dev/null +++ b/loadtest/loadtest.js @@ -0,0 +1,151 @@ +import ws from 'k6/ws'; +import { check, sleep } from 'k6'; +import { Counter, Trend, Rate } from 'k6/metrics'; + +// ============================================================================= +// CUSTOM METRICS - These will show up in your test results +// ============================================================================= +const connectionTime = new Trend('ws_connection_time_ms'); // Connection latency +const messageRTT = new Trend('ws_message_rtt_ms'); // Round-trip time +const connectionsFailed = new Counter('ws_connections_failed'); // Failed connections +const messagesReceived = new Counter('ws_messages_received'); // Messages received +const messagesSent = new Counter('ws_messages_sent'); // Messages sent +const connectionSuccess = new Rate('ws_connection_success'); // Success rate + +// ============================================================================= +// TEST CONFIGURATION +// ============================================================================= +export const options = { + stages: [ + // Ramp up phase: 0 → 2000 connections over 30 seconds + { duration: '10s', target: 500 }, + { duration: '10s', target: 1000 }, + { duration: '10s', target: 2000 }, + + // Hold at peak: maintain 2000 connections for 1 minute + { duration: '60s', target: 2000 }, + + // Ramp down: graceful disconnect + { duration: '10s', target: 0 }, + ], + + thresholds: { + 'ws_connection_time_ms': ['p(95)<1000'], // 95% connect under 1s + 'ws_message_rtt_ms': ['p(95)<100'], // 95% RTT under 100ms + 'ws_connection_success': ['rate>0.95'], // 95% success rate + }, +}; + +// ============================================================================= +// MAIN TEST FUNCTION - Runs for each virtual user (VU) +// ============================================================================= +export default function () { + // Distribute users across 20 rooms (simulates realistic usage) + const roomId = `loadtest-room-${__VU % 20}`; + const url = `ws://localhost:8080/ws/loadtest/${roomId}`; + + const connectStart = Date.now(); + + const res = ws.connect(url, {}, function (socket) { + // Track connection time + const connectDuration = Date.now() - connectStart; + connectionTime.add(connectDuration); + connectionSuccess.add(1); + + // Variables for RTT measurement + let pendingPing = null; + let pingStart = 0; + + // ======================================================================= + // MESSAGE HANDLER - Receives broadcasts from other users + // ======================================================================= + socket.on('message', (data) => { + messagesReceived.add(1); + + // Check if this is our ping response (echo from server) + if (pendingPing && data.length > 0) { + const rtt = Date.now() - pingStart; + messageRTT.add(rtt); + pendingPing = null; + } + }); + + // ======================================================================= + // ERROR HANDLER + // ======================================================================= + socket.on('error', (e) => { + console.error(`WebSocket error: ${e.error()}`); + connectionsFailed.add(1); + }); + + // ======================================================================= + // PING LOOP - Send a message every second to measure RTT + // ======================================================================= + socket.setInterval(function () { + // Create a simple binary message (mimics Yjs awareness update) + // Format: [1, ...payload] where 1 = Awareness message type + const timestamp = Date.now(); + const payload = new Uint8Array([ + 1, // Message type: Awareness + 0, // Payload length (varint) + ]); + + pingStart = timestamp; + pendingPing = true; + + socket.sendBinary(payload.buffer); + messagesSent.add(1); + + }, 1000); // Every 1 second + + // ======================================================================= + // STAY CONNECTED - Keep connection alive for the test duration + // ======================================================================= + socket.setTimeout(function () { + socket.close(); + }, 90000); // 90 seconds max per connection + }); + + // ======================================================================= + // CONNECTION RESULT CHECK + // ======================================================================= + const connected = check(res, { + 'WebSocket connected': (r) => r && r.status === 101, + }); + + if (!connected) { + connectionsFailed.add(1); + connectionSuccess.add(0); + } + + // Small sleep to prevent tight loop on connection failure + sleep(0.1); +} + +// ============================================================================= +// LIFECYCLE HOOKS +// ============================================================================= +export function setup() { + console.log('========================================'); + console.log(' WebSocket Load Test Starting'); + console.log('========================================'); + console.log('Target: ws://localhost:8080/ws/loadtest/:roomId'); + console.log('Max VUs: 2000'); + console.log('Duration: ~100 seconds'); + console.log(''); + console.log('Key Metrics to Watch:'); + console.log(' - ws_connection_time_ms (p95 < 1s)'); + console.log(' - ws_message_rtt_ms (p95 < 100ms)'); + console.log(' - ws_connection_success (rate > 95%)'); + console.log('========================================'); +} + +export function teardown(data) { + console.log('========================================'); + console.log(' Load Test Complete!'); + console.log('========================================'); + console.log(''); + console.log('📸 Take a screenshot of the results above'); + console.log(' for your "Before vs After" comparison!'); + console.log('========================================'); +} diff --git a/loadtest/loadtest_1000.js b/loadtest/loadtest_1000.js new file mode 100644 index 0000000..2cf3dda --- /dev/null +++ b/loadtest/loadtest_1000.js @@ -0,0 +1,91 @@ +import ws from 'k6/ws'; +import { check, sleep } from 'k6'; +import { Counter, Trend, Rate } from 'k6/metrics'; + +// ============================================================================= +// CUSTOM METRICS +// ============================================================================= +const connectionTime = new Trend('ws_connection_time_ms'); +const messageRTT = new Trend('ws_message_rtt_ms'); +const connectionsFailed = new Counter('ws_connections_failed'); +const messagesReceived = new Counter('ws_messages_received'); +const messagesSent = new Counter('ws_messages_sent'); +const connectionSuccess = new Rate('ws_connection_success'); + +// ============================================================================= +// 1000 USERS TEST +// ============================================================================= +export const options = { + stages: [ + { duration: '10s', target: 200 }, + { duration: '10s', target: 500 }, + { duration: '10s', target: 1000 }, + { duration: '60s', target: 1000 }, // Hold at 1000 for 1 minute + { duration: '10s', target: 0 }, + ], + + thresholds: { + 'ws_connection_time_ms': ['p(95)<500'], + 'ws_message_rtt_ms': ['p(95)<100'], + 'ws_connection_success': ['rate>0.95'], + }, +}; + +export default function () { + const roomId = `loadtest-room-${__VU % 10}`; // 10 rooms, 100 users each + const url = `ws://localhost:8080/ws/loadtest/${roomId}`; + + const connectStart = Date.now(); + + const res = ws.connect(url, {}, function (socket) { + const connectDuration = Date.now() - connectStart; + connectionTime.add(connectDuration); + connectionSuccess.add(1); + + let pingStart = 0; + + socket.on('message', (data) => { + messagesReceived.add(1); + if (pingStart > 0) { + const rtt = Date.now() - pingStart; + messageRTT.add(rtt); + pingStart = 0; + } + }); + + socket.on('error', (e) => { + connectionsFailed.add(1); + }); + + // Send message every 500ms + socket.setInterval(function () { + const payload = new Uint8Array([1, 0]); + pingStart = Date.now(); + socket.sendBinary(payload.buffer); + messagesSent.add(1); + }, 500); + + socket.setTimeout(function () { + socket.close(); + }, 90000); + }); + + const connected = check(res, { + 'WebSocket connected': (r) => r && r.status === 101, + }); + + if (!connected) { + connectionsFailed.add(1); + connectionSuccess.add(0); + } + + sleep(0.1); +} + +export function setup() { + console.log('========================================'); + console.log(' Load Test: 1000 Users'); + console.log('========================================'); + console.log('10 rooms x 100 users each'); + console.log('========================================'); +} diff --git a/loadtest/loadtest_500.js b/loadtest/loadtest_500.js new file mode 100644 index 0000000..cdc945c --- /dev/null +++ b/loadtest/loadtest_500.js @@ -0,0 +1,92 @@ +import ws from 'k6/ws'; +import { check, sleep } from 'k6'; +import { Counter, Trend, Rate } from 'k6/metrics'; + +// ============================================================================= +// CUSTOM METRICS +// ============================================================================= +const connectionTime = new Trend('ws_connection_time_ms'); +const messageRTT = new Trend('ws_message_rtt_ms'); +const connectionsFailed = new Counter('ws_connections_failed'); +const messagesReceived = new Counter('ws_messages_received'); +const messagesSent = new Counter('ws_messages_sent'); +const connectionSuccess = new Rate('ws_connection_success'); + +// ============================================================================= +// CONSERVATIVE TEST - 500 users (reliable on local machine) +// ============================================================================= +export const options = { + stages: [ + { duration: '10s', target: 100 }, + { duration: '10s', target: 300 }, + { duration: '10s', target: 500 }, + { duration: '60s', target: 500 }, // Hold at 500 for 1 minute + { duration: '10s', target: 0 }, + ], + + thresholds: { + 'ws_connection_time_ms': ['p(95)<500'], + 'ws_message_rtt_ms': ['p(95)<100'], + 'ws_connection_success': ['rate>0.99'], // Expect 99%+ success at 500 + }, +}; + +export default function () { + const roomId = `loadtest-room-${__VU % 10}`; // 10 rooms, 50 users each + const url = `ws://localhost:8080/ws/loadtest/${roomId}`; + + const connectStart = Date.now(); + + const res = ws.connect(url, {}, function (socket) { + const connectDuration = Date.now() - connectStart; + connectionTime.add(connectDuration); + connectionSuccess.add(1); + + let pingStart = 0; + + socket.on('message', (data) => { + messagesReceived.add(1); + if (pingStart > 0) { + const rtt = Date.now() - pingStart; + messageRTT.add(rtt); + pingStart = 0; + } + }); + + socket.on('error', (e) => { + connectionsFailed.add(1); + }); + + // Send message every 500ms (more aggressive) + socket.setInterval(function () { + const payload = new Uint8Array([1, 0]); + pingStart = Date.now(); + socket.sendBinary(payload.buffer); + messagesSent.add(1); + }, 500); + + socket.setTimeout(function () { + socket.close(); + }, 90000); + }); + + const connected = check(res, { + 'WebSocket connected': (r) => r && r.status === 101, + }); + + if (!connected) { + connectionsFailed.add(1); + connectionSuccess.add(0); + } + + sleep(0.1); +} + +export function setup() { + console.log('========================================'); + console.log(' Conservative Load Test (500 users)'); + console.log('========================================'); + console.log('This test should show ~99% success rate'); + console.log('Use this for reliable Before/After comparison'); + console.log('========================================'); +}