Skip to content

Commit fc0fdac

Browse files
authored
Merge pull request #39 from CS3219-AY2425S1/ben/matching-service
feat: Revamp matching service
2 parents dae6adb + ad3397d commit fc0fdac

File tree

10 files changed

+726
-104
lines changed

10 files changed

+726
-104
lines changed

apps/matching-service/.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
PORT=8081
2-
MATCH_TIMEOUT=3
3-
JWT_SECRET=you-can-replace-this-with-your-own-secret
2+
MATCH_TIMEOUT=10
3+
JWT_SECRET=you-can-replace-this-with-your-own-secret
4+
REDIS_URL=localhost:6379

apps/matching-service/README.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,15 @@ go mod tidy
2727
- `PORT`: Specifies the port for the WebSocket server. Default is `8081`.
2828
- `JWT_SECRET`: The secret key used to verify JWT tokens.
2929
- `MATCH_TIMEOUT`: The time in seconds to wait for a match before timing out.
30+
- `REDIS_URL`: The URL for the Redis server. Default is `localhost:6379`.
3031

31-
4. Start the WebSocket server:
32+
4. Start a local redis server:
33+
34+
```bash
35+
docker run -d -p 6379:6379 redis
36+
```
37+
38+
5. Start the WebSocket server:
3239

3340
```bash
3441
go run main.go
@@ -68,7 +75,8 @@ Client sends matching parameters:
6875
{
6976
"type": "match_request",
7077
"topics": ["Algorithms", "Arrays"],
71-
"difficulties": ["Easy", "Medium"]
78+
"difficulties": ["Easy", "Medium"],
79+
"username": "Jane Doe"
7280
}
7381
```
7482

@@ -77,9 +85,11 @@ Server response on successful match:
7785
```json
7886
{
7987
"type": "match_found",
80-
"matchID": 67890,
81-
"partnerID": 54321,
82-
"partnerName": "John Doe"
88+
"matchId": "1c018916a34c5bee21af0b2670bd6156",
89+
"user": "zkb4px",
90+
"matchedUser": "JohnDoe",
91+
"topic": "Algorithms",
92+
"difficulty": "Medium"
8393
}
8494
```
8595

@@ -92,6 +102,15 @@ If no match is found after a set period of time, the server will send a timeout
92102
}
93103
```
94104

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+
95114
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.
96115

97116
## Testing
@@ -100,6 +119,8 @@ Utilize `./tests/websocket-test.html` for a basic debugging interface of the mat
100119

101120
Make sure to open the HTML file in a web browser while the WebSocket server is running to perform your tests.
102121

122+
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)
123+
103124
## Docker Support
104125

105126
TODO: Add section for Docker setup and usage instructions.

apps/matching-service/go.mod

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,10 @@ go 1.23.1
55
require (
66
github.com/gorilla/websocket v1.5.3
77
github.com/joho/godotenv v1.5.1
8+
github.com/redis/go-redis/v9 v9.6.2
9+
)
10+
11+
require (
12+
github.com/cespare/xxhash/v2 v2.2.0 // indirect
13+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
814
)

apps/matching-service/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1+
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
2+
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
3+
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
4+
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
5+
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
6+
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
7+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
8+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
19
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
210
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
311
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
412
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
13+
github.com/redis/go-redis/v9 v9.6.2 h1:w0uvkRbc9KpgD98zcvo5IrVUsn0lXpRMuhNgiHDJzdk=
14+
github.com/redis/go-redis/v9 v9.6.2/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=

apps/matching-service/handlers/websocket.go

Lines changed: 127 additions & 16 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,7 +103,13 @@ func readMatchRequest(ws *websocket.Conn) (models.MatchRequest, error) {
69103
if err := ws.ReadJSON(&matchRequest); err != nil {
70104
return matchRequest, err
71105
}
72-
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)
73113
return matchRequest, nil
74114
}
75115

@@ -84,25 +124,53 @@ func createTimeoutContext() (context.Context, context.CancelFunc, error) {
84124
}
85125

86126
// waitForResult waits for a match result, timeout, or cancellation.
87-
func waitForResult(ws *websocket.Conn, ctx, timeoutCtx context.Context, matchFoundChan chan models.MatchFound) {
127+
func waitForResult(ws *websocket.Conn, ctx, timeoutCtx, matchCtx context.Context, matchFoundChan chan models.MatchFound, username string) {
88128
select {
89129
case <-ctx.Done():
90130
log.Println("Matching cancelled")
131+
// Cleanup Redis
132+
processes.CleanUpUser(processes.GetRedisClient(), username, context.Background())
133+
// Remove the match context and active
134+
if _, exists := matchContexts[username]; exists {
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+
91144
return
92145
case <-timeoutCtx.Done():
93146
log.Println("Connection timed out")
147+
// Cleanup Redis
148+
processes.CleanUpUser(processes.GetRedisClient(), username, context.Background())
149+
// Remove the match context and active
150+
if _, exists := matchContexts[username]; exists {
151+
delete(matchContexts, username)
152+
}
153+
if _, exists := activeConnections[username]; exists {
154+
delete(activeConnections, username)
155+
}
156+
if _, exists := matchFoundChannels[username]; exists {
157+
delete(matchFoundChannels, username)
158+
}
159+
94160
sendTimeoutResponse(ws)
95161
return
162+
case <-matchCtx.Done():
163+
log.Println("Match found for user: " + username)
164+
return
96165
case result, ok := <-matchFoundChan:
97166
if !ok {
98167
// Channel closed without a match, possibly due to context cancellation
99168
log.Println("Match channel closed without finding a match")
100169
return
101170
}
102-
log.Println("Match found")
103-
if err := ws.WriteJSON(result); err != nil {
104-
log.Printf("write error: %v", err)
105-
}
171+
log.Println("Match found for user: " + username)
172+
// Notify the users about the match
173+
notifyMatch(result.User, result.MatchedUser, result)
106174
return
107175
}
108176
}
@@ -117,3 +185,46 @@ func sendTimeoutResponse(ws *websocket.Conn) {
117185
log.Printf("write error: %v", err)
118186
}
119187
}
188+
189+
func notifyMatch(username, matchedUsername string, result models.MatchFound) {
190+
mu.Lock()
191+
defer mu.Unlock()
192+
193+
// Send message to the first user
194+
if userConn, userExists := activeConnections[username]; userExists {
195+
if err := userConn.WriteJSON(result); err != nil {
196+
log.Printf("Error sending message to user %s: %v\n", username, err)
197+
}
198+
}
199+
200+
// Send message to the matched user
201+
if matchedUserConn, matchedUserExists := activeConnections[matchedUsername]; matchedUserExists {
202+
result.User, result.MatchedUser = result.MatchedUser, result.User // Swap User and MatchedUser values
203+
if err := matchedUserConn.WriteJSON(result); err != nil {
204+
log.Printf("Error sending message to user %s: %v\n", username, err)
205+
}
206+
}
207+
208+
// Remove the match context for both users and cancel for matched user
209+
if cancelFunc, exists := matchContexts[username]; exists {
210+
cancelFunc()
211+
delete(matchContexts, username)
212+
}
213+
214+
if cancelFunc2, exists := matchContexts[matchedUsername]; exists {
215+
cancelFunc2()
216+
delete(matchContexts, matchedUsername)
217+
}
218+
219+
// Remove the match channels
220+
if _, exists := matchFoundChannels[username]; exists {
221+
delete(matchFoundChannels, username)
222+
}
223+
if _, exists := matchFoundChannels[matchedUsername]; exists {
224+
delete(matchFoundChannels, matchedUsername)
225+
}
226+
227+
// Remove users from the activeConnections map
228+
delete(activeConnections, username)
229+
delete(activeConnections, matchedUsername)
230+
}

apps/matching-service/main.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package main
22

33
import (
4+
"context"
45
"fmt"
56
"log"
67
"matching-service/handlers"
8+
"matching-service/processes"
79
"net/http"
810
"os"
911

1012
"github.com/joho/godotenv"
13+
"github.com/redis/go-redis/v9"
1114
)
1215

1316
func main() {
@@ -18,6 +21,27 @@ func main() {
1821
}
1922
port := os.Getenv("PORT")
2023

24+
// Retrieve redis url env variable and setup the redis client
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+
// Set redis client
41+
processes.SetRedisClient(client)
42+
43+
// Run a goroutine that matches users
44+
2145
// Routes
2246
http.HandleFunc("/match", handlers.HandleWebSocketConnections)
2347

apps/matching-service/models/match.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@ type MatchRequest struct {
44
Type string `json:"type"`
55
Topics []string `json:"topics"`
66
Difficulties []string `json:"difficulties"`
7+
Username string `json:"username"`
78
}
89

910
type MatchFound struct {
1011
Type string `json:"type"`
11-
MatchID int64 `json:"matchId"`
12-
PartnerID int64 `json:"partnerId"`
13-
PartnerName string `json:"partnerName"`
12+
MatchID string `json:"matchId"`
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
1417
}
1518

1619
type Timeout struct {
17-
Type string `json:"timeout"`
20+
Type string `json:"type"`
21+
Message string `json:"message"`
22+
}
23+
24+
type MatchRejected struct {
25+
Type string `json:"type"`
1826
Message string `json:"message"`
1927
}

0 commit comments

Comments
 (0)