diff --git a/apps/matching-service/.env.example b/apps/matching-service/.env.example index 961e78fd21..fb40560876 100644 --- a/apps/matching-service/.env.example +++ b/apps/matching-service/.env.example @@ -1,3 +1,4 @@ PORT=8081 -MATCH_TIMEOUT=3 -JWT_SECRET=you-can-replace-this-with-your-own-secret \ No newline at end of file +MATCH_TIMEOUT=10 +JWT_SECRET=you-can-replace-this-with-your-own-secret +REDIS_URL=localhost:6379 \ No newline at end of file diff --git a/apps/matching-service/README.md b/apps/matching-service/README.md index 232e65497c..702d3dcdf3 100644 --- a/apps/matching-service/README.md +++ b/apps/matching-service/README.md @@ -27,8 +27,15 @@ go mod tidy - `PORT`: Specifies the port for the WebSocket server. Default is `8081`. - `JWT_SECRET`: The secret key used to verify JWT tokens. - `MATCH_TIMEOUT`: The time in seconds to wait for a match before timing out. +- `REDIS_URL`: The URL for the Redis server. Default is `localhost:6379`. -4. Start the WebSocket server: +4. Start a local redis server: + +```bash +docker run -d -p 6379:6379 redis +``` + +5. Start the WebSocket server: ```bash go run main.go @@ -68,7 +75,8 @@ Client sends matching parameters: { "type": "match_request", "topics": ["Algorithms", "Arrays"], - "difficulties": ["Easy", "Medium"] + "difficulties": ["Easy", "Medium"], + "username": "Jane Doe" } ``` @@ -77,9 +85,11 @@ Server response on successful match: ```json { "type": "match_found", - "matchID": 67890, - "partnerID": 54321, - "partnerName": "John Doe" + "matchId": "1c018916a34c5bee21af0b2670bd6156", + "user": "zkb4px", + "matchedUser": "JohnDoe", + "topic": "Algorithms", + "difficulty": "Medium" } ``` @@ -92,6 +102,15 @@ If no match is found after a set period of time, the server will send a timeout } ``` +If user has an existing websocket connection and wants to initiate another match, the server will reject the request: + +```json +{ + "type": "match_rejected", + "message": "You are already in a matchmaking queue. Please disconnect before reconnecting." +} +``` + If the server encounters an issue during the WebSocket connection or processing, the connection will be closed without any error message. The client should treat the unexpected closing as an error. ## Testing @@ -100,6 +119,8 @@ Utilize `./tests/websocket-test.html` for a basic debugging interface of the mat Make sure to open the HTML file in a web browser while the WebSocket server is running to perform your tests. +You can open one instance of the HTML file in multiple tabs to simulate multiple clients connecting to the server. (In the future: ensure that only one connection is allowed per user) + ## Docker Support TODO: Add section for Docker setup and usage instructions. diff --git a/apps/matching-service/go.mod b/apps/matching-service/go.mod index 4bb3e79d9b..89ad38d6b5 100644 --- a/apps/matching-service/go.mod +++ b/apps/matching-service/go.mod @@ -5,4 +5,10 @@ go 1.23.1 require ( github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.6.2 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect ) diff --git a/apps/matching-service/go.sum b/apps/matching-service/go.sum index 09f4ebfc33..722db5bc36 100644 --- a/apps/matching-service/go.sum +++ b/apps/matching-service/go.sum @@ -1,4 +1,14 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/redis/go-redis/v9 v9.6.2 h1:w0uvkRbc9KpgD98zcvo5IrVUsn0lXpRMuhNgiHDJzdk= +github.com/redis/go-redis/v9 v9.6.2/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= diff --git a/apps/matching-service/handlers/websocket.go b/apps/matching-service/handlers/websocket.go index 6c34aade4b..066466e9ee 100644 --- a/apps/matching-service/handlers/websocket.go +++ b/apps/matching-service/handlers/websocket.go @@ -7,16 +7,27 @@ import ( "matching-service/processes" "matching-service/utils" "net/http" + "strings" + "sync" "github.com/gorilla/websocket" ) -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - // Allow all connections by skipping the origin check (set more restrictions in production) - return true - }, -} +var ( + upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + // Allow all connections by skipping the origin check (set more restrictions in production) + return true + }, + } + // A map to hold active WebSocket connections per username + activeConnections = make(map[string]*websocket.Conn) + // A map to hold user's match ctx cancel function + matchContexts = make(map[string]context.CancelFunc) + // A map to hold user's match channels + matchFoundChannels = make(map[string]chan models.MatchFound) + mu sync.Mutex // Mutex for thread-safe access to activeConnections +) // handleConnections manages WebSocket connections and matching logic. func HandleWebSocketConnections(w http.ResponseWriter, r *http.Request) { @@ -42,10 +53,35 @@ func HandleWebSocketConnections(w http.ResponseWriter, r *http.Request) { return } + // Store WebSocket connection in the activeConnections map. + mu.Lock() + // Checks if user is already an existing websocket connection + if _, exists := activeConnections[matchRequest.Username]; exists { + mu.Unlock() + log.Printf("User %s is already connected, rejecting new connection.", matchRequest.Username) + ws.WriteJSON(models.MatchRejected{ + Type: "match_rejected", + Message: "You are already in a matchmaking queue. Please disconnect before reconnecting.", + }) + ws.Close() + return + } + activeConnections[matchRequest.Username] = ws + matchCtx, matchCancel := context.WithCancel(context.Background()) + matchContexts[matchRequest.Username] = matchCancel + + matchFoundChan := make(chan models.MatchFound) + matchFoundChannels[matchRequest.Username] = matchFoundChan + mu.Unlock() + // Create a context for cancellation ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Ensure cancel is called to release resources + processes.EnqueueUser(processes.GetRedisClient(), matchRequest.Username, ctx) + processes.AddUserToTopicSets(processes.GetRedisClient(), matchRequest, ctx) + processes.StoreUserDetails(processes.GetRedisClient(), matchRequest, ctx) + timeoutCtx, timeoutCancel, err := createTimeoutContext() if err != nil { log.Printf("Error creating timeout context: %v", err) @@ -53,14 +89,12 @@ func HandleWebSocketConnections(w http.ResponseWriter, r *http.Request) { } defer timeoutCancel() - matchFoundChan := make(chan models.MatchFound) - // Start goroutines for handling messages and performing matching. go processes.ReadMessages(ws, ctx, cancel) - go processes.PerformMatching(matchRequest, ctx, matchFoundChan) // Perform matching + go processes.PerformMatching(matchRequest, context.Background(), matchFoundChannels) // Perform matching // Wait for a match, timeout, or cancellation. - waitForResult(ws, ctx, timeoutCtx, matchFoundChan) + waitForResult(ws, ctx, timeoutCtx, matchCtx, matchFoundChan, matchRequest.Username) } // readMatchRequest reads the initial match request from the WebSocket connection. @@ -69,7 +103,13 @@ func readMatchRequest(ws *websocket.Conn) (models.MatchRequest, error) { if err := ws.ReadJSON(&matchRequest); err != nil { return matchRequest, err } - log.Printf("Received match request: %v", matchRequest) + // Get the remote address (client's IP and port) + clientAddr := ws.RemoteAddr().String() + + // Extract the port (after the last ':') + clientPort := clientAddr[strings.LastIndex(clientAddr, ":")+1:] + + log.Printf("Received match request: %v from client port: %s", matchRequest, clientPort) return matchRequest, nil } @@ -84,25 +124,53 @@ func createTimeoutContext() (context.Context, context.CancelFunc, error) { } // waitForResult waits for a match result, timeout, or cancellation. -func waitForResult(ws *websocket.Conn, ctx, timeoutCtx context.Context, matchFoundChan chan models.MatchFound) { +func waitForResult(ws *websocket.Conn, ctx, timeoutCtx, matchCtx context.Context, matchFoundChan chan models.MatchFound, username string) { select { case <-ctx.Done(): log.Println("Matching cancelled") + // Cleanup Redis + processes.CleanUpUser(processes.GetRedisClient(), username, context.Background()) + // Remove the match context and active + if _, exists := matchContexts[username]; exists { + delete(matchContexts, username) + } + if _, exists := activeConnections[username]; exists { + delete(activeConnections, username) + } + if _, exists := matchFoundChannels[username]; exists { + delete(matchFoundChannels, username) + } + return case <-timeoutCtx.Done(): log.Println("Connection timed out") + // Cleanup Redis + processes.CleanUpUser(processes.GetRedisClient(), username, context.Background()) + // Remove the match context and active + if _, exists := matchContexts[username]; exists { + delete(matchContexts, username) + } + if _, exists := activeConnections[username]; exists { + delete(activeConnections, username) + } + if _, exists := matchFoundChannels[username]; exists { + delete(matchFoundChannels, username) + } + sendTimeoutResponse(ws) return + case <-matchCtx.Done(): + log.Println("Match found for user: " + username) + return case result, ok := <-matchFoundChan: if !ok { // Channel closed without a match, possibly due to context cancellation log.Println("Match channel closed without finding a match") return } - log.Println("Match found") - if err := ws.WriteJSON(result); err != nil { - log.Printf("write error: %v", err) - } + log.Println("Match found for user: " + username) + // Notify the users about the match + notifyMatch(result.User, result.MatchedUser, result) return } } @@ -117,3 +185,46 @@ func sendTimeoutResponse(ws *websocket.Conn) { log.Printf("write error: %v", err) } } + +func notifyMatch(username, matchedUsername string, result models.MatchFound) { + mu.Lock() + defer mu.Unlock() + + // Send message to the first user + if userConn, userExists := activeConnections[username]; userExists { + if err := userConn.WriteJSON(result); err != nil { + log.Printf("Error sending message to user %s: %v\n", username, err) + } + } + + // Send message to the matched user + if matchedUserConn, matchedUserExists := activeConnections[matchedUsername]; matchedUserExists { + result.User, result.MatchedUser = result.MatchedUser, result.User // Swap User and MatchedUser values + if err := matchedUserConn.WriteJSON(result); err != nil { + log.Printf("Error sending message to user %s: %v\n", username, err) + } + } + + // Remove the match context for both users and cancel for matched user + if cancelFunc, exists := matchContexts[username]; exists { + cancelFunc() + delete(matchContexts, username) + } + + if cancelFunc2, exists := matchContexts[matchedUsername]; exists { + cancelFunc2() + delete(matchContexts, matchedUsername) + } + + // Remove the match channels + if _, exists := matchFoundChannels[username]; exists { + delete(matchFoundChannels, username) + } + if _, exists := matchFoundChannels[matchedUsername]; exists { + delete(matchFoundChannels, matchedUsername) + } + + // Remove users from the activeConnections map + delete(activeConnections, username) + delete(activeConnections, matchedUsername) +} diff --git a/apps/matching-service/main.go b/apps/matching-service/main.go index 6a8af7f96b..6688de6547 100644 --- a/apps/matching-service/main.go +++ b/apps/matching-service/main.go @@ -1,13 +1,16 @@ package main import ( + "context" "fmt" "log" "matching-service/handlers" + "matching-service/processes" "net/http" "os" "github.com/joho/godotenv" + "github.com/redis/go-redis/v9" ) func main() { @@ -18,6 +21,27 @@ func main() { } port := os.Getenv("PORT") + // Retrieve redis url env variable and setup the redis client + redisAddr := os.Getenv("REDIS_URL") + client := redis.NewClient(&redis.Options{ + Addr: redisAddr, + Password: "", // no password set + DB: 0, // use default DB + }) + + // Ping the redis server + _, err = client.Ping(context.Background()).Result() + if err != nil { + log.Fatalf("Could not connect to Redis: %v", err) + } else { + log.Println("Connected to Redis at the following address: " + redisAddr) + } + + // Set redis client + processes.SetRedisClient(client) + + // Run a goroutine that matches users + // Routes http.HandleFunc("/match", handlers.HandleWebSocketConnections) diff --git a/apps/matching-service/models/match.go b/apps/matching-service/models/match.go index e7cd2a6ff6..ad555b555b 100644 --- a/apps/matching-service/models/match.go +++ b/apps/matching-service/models/match.go @@ -4,16 +4,24 @@ type MatchRequest struct { Type string `json:"type"` Topics []string `json:"topics"` Difficulties []string `json:"difficulties"` + Username string `json:"username"` } type MatchFound struct { Type string `json:"type"` - MatchID int64 `json:"matchId"` - PartnerID int64 `json:"partnerId"` - PartnerName string `json:"partnerName"` + MatchID string `json:"matchId"` + User string `json:"user"` // username + MatchedUser string `json:"matchedUser"` // matched username + Topic string `json:"topic"` // matched topic + Difficulty string `json:"difficulty"` // matched difficulty } type Timeout struct { - Type string `json:"timeout"` + Type string `json:"type"` + Message string `json:"message"` +} + +type MatchRejected struct { + Type string `json:"type"` Message string `json:"message"` } diff --git a/apps/matching-service/processes/match.go b/apps/matching-service/processes/match.go index 0565e3555e..22a264eb30 100644 --- a/apps/matching-service/processes/match.go +++ b/apps/matching-service/processes/match.go @@ -2,34 +2,129 @@ package processes import ( "context" + "fmt" + "log" "matching-service/models" + "strconv" + "strings" + "sync" "time" + + "github.com/redis/go-redis/v9" +) + +var ( + redisClient *redis.Client + mu sync.Mutex // Mutex to ensure only one matchmaking goroutine is running + ctx = context.Background() ) -// PerformMatching reads the match request and simulates a matching process. -func PerformMatching(matchRequest models.MatchRequest, ctx context.Context, matchFoundChan chan models.MatchFound) { - defer close(matchFoundChan) // Safely close the channel after matching completes. - - // TODO: matching algorithm - // for { - // select { - // case <-ctx.Done(): - // // The context has been cancelled, so stop the matching process. - // return - // default: - // // Continue matching logic... - // } - // } - // Simulate the matching process with a sleep (replace with actual logic). - time.Sleep(2 * time.Second) - - // Create a mock result and send it to the channel. - result := models.MatchFound{ - Type: "match_found", - MatchID: 67890, - PartnerID: 54321, - PartnerName: "John Doe", +// SetRedisClient sets the Redis client to a global variable +func SetRedisClient(client *redis.Client) { + redisClient = client +} + +// Get redisclient +func GetRedisClient() *redis.Client { + return redisClient +} + +func getPortNumber(addr string) (int64, error) { + // Split the string by the colon + parts := strings.Split(addr, ":") + if len(parts) < 2 { + return 0, fmt.Errorf("no port number found") } - matchFoundChan <- result + // Convert the port string to an integer + port, err := strconv.ParseInt(parts[len(parts)-1], 10, 64) + if err != nil { + return 0, err // Return an error if conversion fails + } + + return port, nil +} + +func PerformMatching(matchRequest models.MatchRequest, ctx context.Context, matchFoundChannels map[string]chan models.MatchFound) { + // Acquire mutex + mu.Lock() + // Defer unlocking the mutex + defer mu.Unlock() + + for { + + // Log queue before matchmaking + // PrintMatchingQueue(redisClient, "Before Matchmaking", context.Background()) + + // Check if the queue is empty + queueLength, err := redisClient.LLen(context.Background(), "matchmaking_queue").Result() + if err != nil { + log.Println("Error checking queue length:", err) + time.Sleep(1 * time.Second) + continue + } + + if queueLength == 0 { + // log.Println("No users in the queue") + time.Sleep(1 * time.Second) + continue + } + + // Peek at the user queue + username, err := redisClient.LIndex(context.Background(), "matchmaking_queue", 0).Result() + if err != nil { + log.Println("Error peeking user from queue:", err) + time.Sleep(1 * time.Second) + continue + } + + // log.Printf("Performing matching for user: %s", username) + matchedUsername, matchedTopic, matchedDifficulty, err := FindMatchingUser(redisClient, username, ctx) + if err != nil { + log.Println("Error finding matching user:", err) + time.Sleep(1 * time.Second) + continue + } + + if matchedUsername != "" { + // Log down the state of queue before matchmaking + PrintMatchingQueue(redisClient, "Before Matchmaking", context.Background()) + + // Log down which users got matched + log.Printf("Users %s and %s matched on the topic: %s with difficulty: %s", username, matchedUsername, matchedTopic, matchedDifficulty) + + // Clean up queue, sets and hashset in Redis + DequeueUser(redisClient, username, ctx) + DequeueUser(redisClient, matchedUsername, ctx) + RemoveUserFromTopicSets(redisClient, username, ctx) + RemoveUserFromTopicSets(redisClient, matchedUsername, ctx) + RemoveUserDetails(redisClient, username, ctx) + RemoveUserDetails(redisClient, matchedUsername, ctx) + + // Log queue after matchmaking + PrintMatchingQueue(redisClient, "After Matchmaking", context.Background()) + + // Generate a random match ID + matchId, err := GenerateMatchID() + if err != nil { + log.Println("Unable to randomly generate matchID") + } + + // Signal that a match has been found + matchFoundChannels[username] <- models.MatchFound{ + Type: "match_found", + MatchID: matchId, + User: username, + MatchedUser: matchedUsername, + Topic: matchedTopic, + Difficulty: matchedDifficulty, + } + + } else { + // log.Printf("No match found for user: %s", username) + + // Pop user and add user back into queue + PopAndInsert(redisClient, username, ctx) + } + } } diff --git a/apps/matching-service/processes/queue.go b/apps/matching-service/processes/queue.go new file mode 100644 index 0000000000..254675f9e7 --- /dev/null +++ b/apps/matching-service/processes/queue.go @@ -0,0 +1,329 @@ +package processes + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "matching-service/models" + "strings" + "sync" + + "github.com/redis/go-redis/v9" +) + +var mutex sync.Mutex // Mutex for concurrency safety + +// To simulate generating a random matchID for collaboration service (TODO: Future) +func GenerateMatchID() (string, error) { + b := make([]byte, 16) // 16 bytes = 128 bits + _, err := rand.Read(b) + if err != nil { + return "", err + } + matchID := hex.EncodeToString(b) + return matchID, nil +} + +// Print existing users in the matching queue +func PrintMatchingQueue(client *redis.Client, status string, ctx context.Context) { + mutex.Lock() + defer mutex.Unlock() + + users, err := client.LRange(ctx, "matchmaking_queue", 0, -1).Result() + if err != nil { + log.Println("Error retrieving users from queue:", err) + return + } + + var concatenatedUsers strings.Builder + for i, user := range users { + concatenatedUsers.WriteString(user) + if i != len(users)-1 { + concatenatedUsers.WriteString(", ") + } + } + + log.Println("Redis Queue (" + status + "): " + concatenatedUsers.String()) +} + +// + +// Enqueue a user into the matchmaking queue +func EnqueueUser(client *redis.Client, username string, ctx context.Context) { + mutex.Lock() + defer mutex.Unlock() + + key := "matchmaking_queue" + err := client.LPush(ctx, key, username).Err() + if err != nil { + log.Println("Error enqueuing user:", err) + } +} + +// Remove user from the matchmaking queue +func DequeueUser(client *redis.Client, username string, ctx context.Context) { + mutex.Lock() + defer mutex.Unlock() + + key := "matchmaking_queue" + err := client.LRem(ctx, key, 1, username).Err() + if err != nil { + log.Println("Error dequeuing user:", err) + } +} + +// Add user into each specified topic set based on the topics selected by users +func AddUserToTopicSets(client *redis.Client, request models.MatchRequest, ctx context.Context) { + mutex.Lock() + defer mutex.Unlock() + + for _, topic := range request.Topics { + err := client.SAdd(ctx, strings.ToLower(topic), request.Username).Err() + if err != nil { + log.Println("Error adding user to topic set:", err) + } + } +} + +// Remove user from each specified topic set based on the topics selected by users +func RemoveUserFromTopicSets(client *redis.Client, username string, ctx context.Context) { + mutex.Lock() + defer mutex.Unlock() + + request, err := GetUserDetails(client, username, ctx) + if err != nil { + log.Println("Error retrieving user from hashset:", err) + return + } + + for _, topic := range request.Topics { + err := client.SRem(ctx, strings.ToLower(topic), request.Username).Err() + if err != nil { + log.Println("Error removing user from topic set:", err) + } + } +} + +// Add user details into hashset in Redis +func StoreUserDetails(client *redis.Client, request models.MatchRequest, ctx context.Context) { + mutex.Lock() + defer mutex.Unlock() + + topicsJSON, err := json.Marshal(request.Topics) + if err != nil { + log.Println("Error marshalling topics:", err) + return + } + + difficultiesJSON, err := json.Marshal(request.Difficulties) + if err != nil { + log.Println("Error marshalling difficulties:", err) + return + } + + err = client.HSet(ctx, request.Username, map[string]interface{}{ + "topics": topicsJSON, + "difficulty": difficultiesJSON, + "username": request.Username, + }).Err() + if err != nil { + log.Println("Error storing user details:", err) + } +} + +// Retrieve user details from hashset in Redis +func GetUserDetails(client *redis.Client, username string, ctx context.Context) (models.MatchRequest, error) { + userDetails, err := client.HGetAll(ctx, username).Result() + if err != nil { + return models.MatchRequest{}, err + } + + if len(userDetails) == 0 { + return models.MatchRequest{}, fmt.Errorf("user not found in hashset: %s", username) + } + + topicsJSON, topicsExist := userDetails["topics"] + difficultiesJSON, difficultiesExist := userDetails["difficulty"] + + if !topicsExist || !difficultiesExist { + return models.MatchRequest{}, fmt.Errorf("incomplete user details for: %s", username) + } + + var topics []string + err = json.Unmarshal([]byte(topicsJSON), &topics) + if err != nil { + return models.MatchRequest{}, fmt.Errorf("error unmarshalling topics: %v", err) + } + + var difficulties []string + err = json.Unmarshal([]byte(difficultiesJSON), &difficulties) + if err != nil { + return models.MatchRequest{}, fmt.Errorf("error unmarshalling difficulties: %v", err) + } + + matchRequest := models.MatchRequest{ + Topics: topics, + Difficulties: difficulties, + Username: username, + } + + return matchRequest, nil +} + +// Remove user details from HashSet +func RemoveUserDetails(client *redis.Client, username string, ctx context.Context) { + mutex.Lock() + defer mutex.Unlock() + + err := client.Del(ctx, username).Err() + if err != nil { + log.Println("Error removing user details:", err) + } +} + +// Find the first matching user based on topics +func FindMatchingUser(client *redis.Client, username string, ctx context.Context) (string, string, string, error) { + mutex.Lock() + defer mutex.Unlock() + + user, err := GetUserDetails(client, username, ctx) + if err != nil { + return "", "", "", err + } + + for _, topic := range user.Topics { + users, err := client.SMembers(ctx, strings.ToLower(topic)).Result() + if err != nil { + return "", "", "", err + } + + for _, potentialMatch := range users { + if potentialMatch != username { + matchedUser, err := GetUserDetails(client, potentialMatch, ctx) + if err != nil { + return "", "", "", err + } + + commonDifficulty := GetCommonDifficulty(user.Difficulties, matchedUser.Difficulties) + return potentialMatch, topic, commonDifficulty, nil + } + } + } + + return "", "", "", nil +} + +// Get the highest common difficulty between two users, if no common difficulty found, choose the min of the 2 arrays +func GetCommonDifficulty(userArr []string, matchedUserArr []string) string { + commonDifficulties := make([]int, 3) + for i := range commonDifficulties { + commonDifficulties[i] = 0 + } + + for _, difficulty := range userArr { + formattedDifficulty := strings.ToLower(difficulty) + switch formattedDifficulty { + case "easy": + commonDifficulties[0]++ + case "medium": + commonDifficulties[1]++ + case "hard": + commonDifficulties[2]++ + default: + log.Println("Unknown difficulty specified: " + difficulty) + } + } + + for _, difficulty := range matchedUserArr { + formattedDifficulty := strings.ToLower(difficulty) + switch formattedDifficulty { + case "easy": + commonDifficulties[0]++ + case "medium": + commonDifficulties[1]++ + case "hard": + commonDifficulties[2]++ + default: + log.Println("Unknown difficulty specified: " + difficulty) + } + } + + lowest := "Hard" + for i := 2; i >= 0; i-- { + if commonDifficulties[i] == 2 { + switch i { + case 0: + return "Easy" + case 1: + return "Medium" + case 2: + return "Hard" + } + } else if commonDifficulties[i] > 0 { + switch i { + case 0: + lowest = "Easy" + case 1: + lowest = "Medium" + case 2: + lowest = "Hard" + } + } + } + return lowest +} + +func CleanUpUser(client *redis.Client, username string, ctx context.Context) { + mutex.Lock() + defer mutex.Unlock() + // Dequeue user + key := "matchmaking_queue" + err := client.LRem(ctx, key, 1, username).Err() + if err != nil { + log.Println("Error dequeuing user:", err) + } + + // Remove user from topic sets + request, err := GetUserDetails(client, username, ctx) + if err != nil { + log.Println("Error retrieving user from hashset:", err) + return + } + + for _, topic := range request.Topics { + err := client.SRem(ctx, strings.ToLower(topic), request.Username).Err() + if err != nil { + log.Println("Error removing user from topic set:", err) + } + } + + // Remove user details + err = client.Del(ctx, username).Err() + if err != nil { + log.Println("Error removing user details:", err) + } + return +} + +func PopAndInsert(client *redis.Client, username string, ctx context.Context) { + // Acquire Lock + mutex.Lock() + defer mutex.Unlock() + + // Pop user + username, err := client.LPop(ctx, "matchmaking_queue").Result() + if err != nil { + log.Println("Error popping user from queue:", err) + } + + // Insert back in queue + key := "matchmaking_queue" + err = client.LPush(ctx, key, username).Err() + if err != nil { + log.Println("Error enqueuing user:", err) + } + return +} diff --git a/apps/matching-service/tests/websocket-test.html b/apps/matching-service/tests/websocket-test.html index cdbe3446db..86d2be302c 100644 --- a/apps/matching-service/tests/websocket-test.html +++ b/apps/matching-service/tests/websocket-test.html @@ -1,66 +1,83 @@ - - - + + + Matching service: websocket test - - + +

Status: no matching yet

- +