Skip to content

Commit f029e9a

Browse files
committed
Merge branch 'frontend-websocket' of https://github.com/CS3219-AY2425S1/cs3219-ay2425s1-project-g24 into frontend-websocket
2 parents 1742ea6 + 8782f31 commit f029e9a

File tree

11 files changed

+549
-177
lines changed

11 files changed

+549
-177
lines changed

apps/matching-service/README.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ go mod tidy
2929
- `MATCH_TIMEOUT`: The time in seconds to wait for a match before timing out.
3030
- `REDIS_URL`: The URL for the Redis server. Default is `localhost:6379`.
3131

32-
4. Start a local redis server:
32+
4. Start a local Redis server:
3333

3434
```bash
3535
docker run -d -p 6379:6379 redis
@@ -76,8 +76,7 @@ Client sends matching parameters:
7676
"type": "match_request",
7777
"topics": ["Algorithms", "Arrays"],
7878
"difficulties": ["Easy", "Medium"],
79-
"username": "Jane Doe",
80-
"email": "[email protected]" // possible to change to user ID in mongodb
79+
"username": "Jane Doe"
8180
}
8281
```
8382

@@ -86,9 +85,11 @@ Server response on successful match:
8685
```json
8786
{
8887
"type": "match_found",
89-
"matchID": "aa41c004e589642402215c2c0a3a165a",
90-
"partnerID": 54321, // currently identifying via partner's websocket port --> change to user ID in mongodb
91-
"partnerName": "John Doe" // partner username
88+
"matchId": "1c018916a34c5bee21af0b2670bd6156",
89+
"user": "zkb4px",
90+
"matchedUser": "JohnDoe",
91+
"topic": "Algorithms",
92+
"difficulty": "Medium"
9293
}
9394
```
9495

@@ -101,6 +102,15 @@ If no match is found after a set period of time, the server will send a timeout
101102
}
102103
```
103104

105+
If user has an existing websocket connection and wants to initiate another match, the server will reject the request:
106+
107+
```json
108+
{
109+
"type": "match_rejected",
110+
"message": "You are already in a matchmaking queue. Please disconnect before reconnecting."
111+
}
112+
```
113+
104114
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.
105115

106116
## Testing

apps/matching-service/handlers/websocket.go

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,27 @@ import (
77
"matching-service/processes"
88
"matching-service/utils"
99
"net/http"
10+
"strings"
11+
"sync"
1012

1113
"github.com/gorilla/websocket"
1214
)
1315

14-
var upgrader = websocket.Upgrader{
15-
CheckOrigin: func(r *http.Request) bool {
16-
// Allow all connections by skipping the origin check (set more restrictions in production)
17-
return true
18-
},
19-
}
16+
var (
17+
upgrader = websocket.Upgrader{
18+
CheckOrigin: func(r *http.Request) bool {
19+
// Allow all connections by skipping the origin check (set more restrictions in production)
20+
return true
21+
},
22+
}
23+
// A map to hold active WebSocket connections per username
24+
activeConnections = make(map[string]*websocket.Conn)
25+
// A map to hold user's match ctx cancel function
26+
matchContexts = make(map[string]context.CancelFunc)
27+
// A map to hold user's match channels
28+
matchFoundChannels = make(map[string]chan models.MatchFound)
29+
mu sync.Mutex // Mutex for thread-safe access to activeConnections
30+
)
2031

2132
// handleConnections manages WebSocket connections and matching logic.
2233
func HandleWebSocketConnections(w http.ResponseWriter, r *http.Request) {
@@ -42,25 +53,48 @@ func HandleWebSocketConnections(w http.ResponseWriter, r *http.Request) {
4253
return
4354
}
4455

56+
// Store WebSocket connection in the activeConnections map.
57+
mu.Lock()
58+
// Checks if user is already an existing websocket connection
59+
if _, exists := activeConnections[matchRequest.Username]; exists {
60+
mu.Unlock()
61+
log.Printf("User %s is already connected, rejecting new connection.", matchRequest.Username)
62+
ws.WriteJSON(models.MatchRejected{
63+
Type: "match_rejected",
64+
Message: "You are already in a matchmaking queue. Please disconnect before reconnecting.",
65+
})
66+
ws.Close()
67+
return
68+
}
69+
activeConnections[matchRequest.Username] = ws
70+
matchCtx, matchCancel := context.WithCancel(context.Background())
71+
matchContexts[matchRequest.Username] = matchCancel
72+
73+
matchFoundChan := make(chan models.MatchFound)
74+
matchFoundChannels[matchRequest.Username] = matchFoundChan
75+
mu.Unlock()
76+
4577
// Create a context for cancellation
4678
ctx, cancel := context.WithCancel(context.Background())
4779
defer cancel() // Ensure cancel is called to release resources
4880

81+
processes.EnqueueUser(processes.GetRedisClient(), matchRequest.Username, ctx)
82+
processes.AddUserToTopicSets(processes.GetRedisClient(), matchRequest, ctx)
83+
processes.StoreUserDetails(processes.GetRedisClient(), matchRequest, ctx)
84+
4985
timeoutCtx, timeoutCancel, err := createTimeoutContext()
5086
if err != nil {
5187
log.Printf("Error creating timeout context: %v", err)
5288
return
5389
}
5490
defer timeoutCancel()
5591

56-
matchFoundChan := make(chan models.MatchFound)
57-
5892
// Start goroutines for handling messages and performing matching.
5993
go processes.ReadMessages(ws, ctx, cancel)
60-
go processes.PerformMatching(matchRequest, ctx, matchFoundChan) // Perform matching
94+
go processes.PerformMatching(matchRequest, context.Background(), matchFoundChannels) // Perform matching
6195

6296
// Wait for a match, timeout, or cancellation.
63-
waitForResult(ws, ctx, timeoutCtx, matchFoundChan)
97+
waitForResult(ws, ctx, timeoutCtx, matchCtx, matchFoundChan, matchRequest.Username)
6498
}
6599

66100
// readMatchRequest reads the initial match request from the WebSocket connection.
@@ -69,8 +103,13 @@ func readMatchRequest(ws *websocket.Conn) (models.MatchRequest, error) {
69103
if err := ws.ReadJSON(&matchRequest); err != nil {
70104
return matchRequest, err
71105
}
72-
matchRequest.Port = ws.RemoteAddr().String()
73-
log.Printf("Received match request: %v", matchRequest)
106+
// Get the remote address (client's IP and port)
107+
clientAddr := ws.RemoteAddr().String()
108+
109+
// Extract the port (after the last ':')
110+
clientPort := clientAddr[strings.LastIndex(clientAddr, ":")+1:]
111+
112+
log.Printf("Received match request: %v from client port: %s", matchRequest, clientPort)
74113
return matchRequest, nil
75114
}
76115

@@ -84,26 +123,55 @@ func createTimeoutContext() (context.Context, context.CancelFunc, error) {
84123
return ctx, cancel, nil
85124
}
86125

126+
// Cleans up the data associated with the user before ending the websocket connection.
127+
// If user is already removed, then nothing happens.
128+
func cleanUpUser(username string) {
129+
// Cleanup Redis
130+
processes.CleanUpUser(processes.GetRedisClient(), username, context.Background())
131+
132+
// Removes the match context and active
133+
if cancelFunc, exists := matchContexts[username]; exists {
134+
cancelFunc()
135+
delete(matchContexts, username)
136+
}
137+
if _, exists := activeConnections[username]; exists {
138+
delete(activeConnections, username)
139+
}
140+
if _, exists := matchFoundChannels[username]; exists {
141+
delete(matchFoundChannels, username)
142+
}
143+
}
144+
87145
// waitForResult waits for a match result, timeout, or cancellation.
88-
func waitForResult(ws *websocket.Conn, ctx, timeoutCtx context.Context, matchFoundChan chan models.MatchFound) {
146+
func waitForResult(ws *websocket.Conn, ctx, timeoutCtx, matchCtx context.Context, matchFoundChan chan models.MatchFound, username string) {
89147
select {
90148
case <-ctx.Done():
91149
log.Println("Matching cancelled")
150+
cleanUpUser(username)
92151
return
93152
case <-timeoutCtx.Done():
94153
log.Println("Connection timed out")
95154
sendTimeoutResponse(ws)
155+
cleanUpUser(username)
156+
return
157+
case <-matchCtx.Done():
158+
log.Println("Match found for user: " + username)
159+
160+
// NOTE: user is already cleaned-up in the other process,
161+
// so there is no need to clean up again.
96162
return
97163
case result, ok := <-matchFoundChan:
98164
if !ok {
99165
// Channel closed without a match, possibly due to context cancellation
100166
log.Println("Match channel closed without finding a match")
101167
return
102168
}
103-
log.Println("Match found")
104-
if err := ws.WriteJSON(result); err != nil {
105-
log.Printf("write error: %v", err)
106-
}
169+
log.Println("Match found for user: " + username)
170+
// Notify the user about the match
171+
notifyMatches(result.User, result)
172+
173+
// NOTE: user and other user are already cleaned up in a separate matching algorithm process
174+
// so no clean up is required here.
107175
return
108176
}
109177
}
@@ -118,3 +186,16 @@ func sendTimeoutResponse(ws *websocket.Conn) {
118186
log.Printf("write error: %v", err)
119187
}
120188
}
189+
190+
// Notify matches
191+
func notifyMatches(username string, result models.MatchFound) {
192+
mu.Lock()
193+
defer mu.Unlock()
194+
195+
// Send message to matched user
196+
if userConn, userExists := activeConnections[username]; userExists {
197+
if err := userConn.WriteJSON(result); err != nil {
198+
log.Printf("Error sending message to user %s: %v\n", username, err)
199+
}
200+
}
201+
}

apps/matching-service/main.go

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,17 @@ func main() {
1919
if err != nil {
2020
log.Fatalf("err loading: %v", err)
2121
}
22-
port := os.Getenv("PORT")
23-
24-
// Set up link with redis server
25-
redisAddr := os.Getenv("REDIS_URL")
26-
client := redis.NewClient(&redis.Options{
27-
Addr: redisAddr,
28-
Password: "", // no password set
29-
DB: 0, // use default DB
30-
})
31-
32-
// Ping the redis server
33-
_, err = client.Ping(context.Background()).Result()
34-
if err != nil {
35-
log.Fatalf("Could not connect to Redis: %v", err)
36-
} else {
37-
log.Println("Connected to Redis at the following address: " + redisAddr)
38-
}
39-
40-
// Pass the connect redis client to processes in match
41-
processes.SetRedisClient(client)
22+
23+
// Setup redis client
24+
processes.SetupRedisClient()
4225

26+
// Run a goroutine that matches users
27+
4328
// Routes
4429
http.HandleFunc("/match", handlers.HandleWebSocketConnections)
45-
30+
4631
// Start the server
32+
port := os.Getenv("PORT")
4733
log.Println(fmt.Sprintf("Server starting on :%s", port))
4834
err = http.ListenAndServe(fmt.Sprintf(":%s", port), nil)
4935
if err != nil {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package models
2+
3+
import (
4+
"log"
5+
"strings"
6+
)
7+
8+
// Get the highest common difficulty (aka complexity) between two users,
9+
// If no common difficulty found, choose the min of the 2 arrays.
10+
func GetCommonDifficulty(userArr []string, matchedUserArr []string) string {
11+
commonDifficulties := make([]int, 3)
12+
for i := range commonDifficulties {
13+
commonDifficulties[i] = 0
14+
}
15+
16+
for _, difficulty := range userArr {
17+
formattedDifficulty := strings.ToLower(difficulty)
18+
switch formattedDifficulty {
19+
case "easy":
20+
commonDifficulties[0]++
21+
case "medium":
22+
commonDifficulties[1]++
23+
case "hard":
24+
commonDifficulties[2]++
25+
default:
26+
log.Println("Unknown difficulty specified: " + difficulty)
27+
}
28+
}
29+
30+
for _, difficulty := range matchedUserArr {
31+
formattedDifficulty := strings.ToLower(difficulty)
32+
switch formattedDifficulty {
33+
case "easy":
34+
commonDifficulties[0]++
35+
case "medium":
36+
commonDifficulties[1]++
37+
case "hard":
38+
commonDifficulties[2]++
39+
default:
40+
log.Println("Unknown difficulty specified: " + difficulty)
41+
}
42+
}
43+
44+
lowest := "Hard"
45+
for i := 2; i >= 0; i-- {
46+
if commonDifficulties[i] == 2 {
47+
switch i {
48+
case 0:
49+
return "Easy"
50+
case 1:
51+
return "Medium"
52+
case 2:
53+
return "Hard"
54+
}
55+
} else if commonDifficulties[i] > 0 {
56+
switch i {
57+
case 0:
58+
lowest = "Easy"
59+
case 1:
60+
lowest = "Medium"
61+
case 2:
62+
lowest = "Hard"
63+
}
64+
}
65+
}
66+
return lowest
67+
}

apps/matching-service/models/match.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,23 @@ type MatchRequest struct {
55
Topics []string `json:"topics"`
66
Difficulties []string `json:"difficulties"`
77
Username string `json:"username"`
8-
Email string `json:"email"`
9-
Port string `json:"port"`
108
}
119

1210
type MatchFound struct {
1311
Type string `json:"type"`
1412
MatchID string `json:"matchId"`
15-
PartnerID int64 `json:"partnerId"`
16-
PartnerName string `json:"partnerName"`
13+
User string `json:"user"` // username
14+
MatchedUser string `json:"matchedUser"` // matched username
15+
Topic string `json:"topic"` // matched topic
16+
Difficulty string `json:"difficulty"` // matched difficulty
1717
}
1818

1919
type Timeout struct {
2020
Type string `json:"type"`
2121
Message string `json:"message"`
2222
}
23+
24+
type MatchRejected struct {
25+
Type string `json:"type"`
26+
Message string `json:"message"`
27+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package processes
2+
3+
import (
4+
"context"
5+
"sync"
6+
7+
"github.com/redis/go-redis/v9"
8+
)
9+
10+
const matchmakingQueueRedisKey = "matchmaking_queue"
11+
12+
var (
13+
redisClient *redis.Client
14+
matchingRoutineMutex sync.Mutex // Mutex to ensure only one matchmaking goroutine is running
15+
redisAccessMutex sync.Mutex // Mutex for Redis access for concurrency safety
16+
ctx = context.Background()
17+
)

0 commit comments

Comments
 (0)