Skip to content

Commit 3c3a0aa

Browse files
authored
Merge pull request #39 from CS3219-AY2526Sem1/feature/matching-service-init
Feature/matching service init
2 parents 2a261d2 + 67d81b9 commit 3c3a0aa

File tree

18 files changed

+603
-0
lines changed

18 files changed

+603
-0
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
__pycache__/
2+
*.pyc
3+
*.pyo
4+
*.pyd
5+
.venv/
6+
venv/
7+
.git
8+
.gitignore
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__pycache__/
2+
*.pyc
3+
*.pyo
4+
*.pyd
5+
.venv/
6+
venv/
7+
.env
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt .
6+
RUN pip install --no-cache-dir -r requirements.txt
7+
8+
COPY ./app ./app
9+
10+
EXPOSE 8000
11+
12+
# Dev mode (reload)
13+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

services/matching-service/Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
run:
2+
uvicorn app.main:app --reload --port 8000
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Matching Service
2+
3+
## Overview
4+
5+
The **Matching Service** is responsible for pairing users in PeerPrep based on selected topic, difficulty, and programming language. It manages user queues, handles match creation, and communicates with other services such as:
6+
7+
- **User Service**
8+
- **Question Service**
9+
- **Collaboration Service**
10+
11+
This service is built using **FastAPI** and is designed to be **containerised using Docker**.
12+
13+
---
14+
15+
## Features
16+
17+
- Join a match queue for a specific topic, difficulty, and programming language
18+
- Automatic peer matching from the queue
19+
- Cancel a queue request
20+
- Integration points for collaboration sessions
21+
- Health check endpoint for service monitoring
22+
23+
---
24+
25+
## API Testing with Postman
26+
27+
A **Postman collection** is provided to test the Matching Service:
28+
29+
1. Open Postman -> Import -> File -> `postman/PeerPrep.postman_collection.json`
30+
2. The collection includes:
31+
- Join queue (`/match/request`)
32+
- Cancel queue (`/match/cancel`)
33+
3. Update environment variables if needed (e.g., `url`)
34+
35+
---
36+
37+
## WebSocket Testing
38+
39+
To test real-time events such as match found or timeout:
40+
41+
1. Open Postman -> New -> WebSocket Request
42+
2. URL: `ws://localhost:8000/match/ws/{{user_id}}`
43+
3. Replace `{{user_id}}` with your test user
44+
4. Click **Connect**
45+
5. To simulate events:
46+
- POST `/match/request` from **user 1** and **user 2** to trigger `match.found`
47+
- POST `/match/request` to trigger `match.timeout` if no peer is found within 60 seconds
48+
49+
---
50+
51+
## Running the service
52+
53+
### Using Make
54+
55+
From the `matching-service` folder, run:
56+
57+
```bash
58+
make run
59+
```

services/matching-service/app/__init__.py

Whitespace-only changes.

services/matching-service/app/core/__init__.py

Whitespace-only changes.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from app.models.match import MatchRequest, MatchResponse
2+
import uuid
3+
from app.core import queue
4+
import time
5+
import asyncio
6+
from app.core.websocket_manager import manager
7+
8+
TIMEOUT_SECONDS = 60
9+
10+
class MatchingService:
11+
12+
@staticmethod
13+
async def join_queue(request: MatchRequest) -> MatchResponse:
14+
difficulty = request.difficulty
15+
topic = request.topic
16+
language = request.language
17+
user_id = request.user_id
18+
19+
peer_id = queue.dequeue_user(difficulty, topic, language)
20+
21+
# suitable match
22+
if peer_id:
23+
await manager.send_event(user_id, "match.found", {"peer_id": peer_id})
24+
await manager.send_event(peer_id, "match.found", {"peer_id": user_id})
25+
26+
# TODO: Initialise collaboration session and assign question
27+
28+
return MatchResponse(success=True, peer_id=peer_id, message="Peer found")
29+
else:
30+
# no suitable match
31+
queue.enqueue_user(difficulty, topic, language, user_id)
32+
position = queue.get_queue_position(difficulty, topic, language, user_id)
33+
34+
return MatchResponse(
35+
success=True,
36+
message="Added to queue",
37+
# queue_position=position
38+
)
39+
40+
@staticmethod
41+
def cancel_queue(request: MatchRequest) -> MatchResponse:
42+
removed = queue.remove_user(request.difficulty, request.topic, request.language, request.user_id)
43+
if removed:
44+
return MatchResponse(success=True, message="Removed from queue")
45+
else:
46+
return MatchResponse(success=False, message="User not in queue")
47+
48+
@staticmethod
49+
async def check_timeouts(request: MatchRequest):
50+
await asyncio.sleep(TIMEOUT_SECONDS)
51+
position = queue.get_queue_position(request.difficulty, request.topic, request.language, request.user_id)
52+
53+
if position is not None:
54+
queue.remove_user(request.difficulty, request.topic, request.language, request.user_id)
55+
print(f"Timeout: Removed user {request.user_id} from queue after {TIMEOUT_SECONDS}")
56+
57+
# send WebSocket event timeout
58+
await manager.send_event(
59+
request.user_id,
60+
"match.timeout",
61+
{"message": f"Timeout after {TIMEOUT_SECONDS} seconds, removed from queue"}
62+
)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import redis
2+
import json
3+
import time
4+
5+
r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
6+
7+
def _queue_key(difficulty: str, topic: str, language: str) -> str:
8+
return f"match_queue:{difficulty}:{topic}:{language}"
9+
10+
def enqueue_user(difficulty: str, topic: str, language: str, user_id: str):
11+
key = _queue_key(difficulty, topic, language)
12+
# add to the end of the queue
13+
r.rpush(key, user_id)
14+
r.hset("match_timestamps", user_id, int(time.time()))
15+
16+
def dequeue_user(difficulty: str, topic: str, language: str):
17+
key = _queue_key(difficulty, topic, language)
18+
# pop from the front (FIFO)
19+
user_id = r.lpop(key)
20+
if user_id:
21+
r.hdel("match_timestamps", user_id)
22+
return user_id
23+
24+
def get_queue_position(difficulty: str, topic: str, language: str, user_id: str):
25+
key = _queue_key(difficulty, topic, language)
26+
queue = r.lrange(key, 0, -1)
27+
try:
28+
return queue.index(user_id) + 1
29+
except ValueError:
30+
return None
31+
32+
def remove_user(difficulty: str, topic: str, language: str, user_id: str):
33+
key = _queue_key(difficulty, topic, language)
34+
removed = r.lrem(key, 0, user_id)
35+
r.hdel("match_timestamps", user_id)
36+
return removed > 0
37+
38+
def get_user_join_time(user_id: str):
39+
ts = r.hget("match_timestamps", user_id)
40+
return int(ts) if ts else None
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Dict
2+
from fastapi import WebSocket
3+
4+
class ConnectionManager:
5+
def __init__(self):
6+
self.active_connections: Dict[str, WebSocket] = {}
7+
8+
async def connect(self, user_id: str, websocket: WebSocket):
9+
await websocket.accept()
10+
self.active_connections[user_id] = websocket
11+
12+
def disconnect(self, user_id: str):
13+
self.active_connections.pop(user_id, None)
14+
15+
async def send_event(self, user_id: str, event: str, data: dict):
16+
websocket = self.active_connections.get(user_id)
17+
if websocket:
18+
await websocket.send_json({
19+
"event": event,
20+
"data": data
21+
})
22+
23+
manager = ConnectionManager()

0 commit comments

Comments
 (0)