Typescript microservice to act as a key component of our gaming platform. This service is responsible for tracking and displaying the all-time top scores achieved by players in our games.
As players complete games, the game service publishes their scores to a designated topic. The Leaderboard Service provides an API to retrieve the top n scores and the names of the players who attained those scores.
Leaderboard-Service
consumes events from Kafka
and stores the data in MySQL
. It also maintains a cache(Memcached
)
to serve the traffic. We have a cache-aside
arrangement here, which means that we will first check the cache for the data
and if it is not present in the cache, then we will fetch it from the DB and store it in the cache for future use.
Dimension | Salient Points |
---|---|
Cost | + Cheap at scale |
Complexity | ~ Low/Medium complexity setting up a cache-aside arrangement |
Scalability | + Highly scalable and can serve huge amounts of reads at low latency |
User Experience | ~ User might get stale data, but lastUpdatedAt can be used to provide better UX |
Here we are using Sorted Sets
in Redis to store the scores. A Redis sorted set is a collection of unique strings
(members) ordered by an associated score. We can use this property to maintain the top scores for a game, and it will be
automatically ordered. On every write we will also update the entry for the user in MySQL DB as well which will act as a backup
and serve traffic when redis is down. MySQL here also works as a persistent store for the scores data. We require this as by
default Redis does not support persistence, though we enable persistence and HA in Redis and remove MySQL as a backup.
Dimension | Salient Points |
---|---|
Cost | ~ Costs higher than Memcache, local-pod-cache ~ Costs ~= using DB read replicas |
Complexity | ~ While low dev complexity, using a complex data-structure like Sorted Sets leads to a more complex solution- Vendor/Technology lock-in as using redis proprietary Data Structures |
Scalability | + Redis is highly scalable and can serve huge amounts of reads at low latency |
User Experience | + User is always served the latest data |
This option stores data just in MySQL and uses a read replica to help scale up reads.
Dimension | Salient Points |
---|---|
Cost | ~ While not the cheapest, costs should not be as high as using distributed SQL DBs ~ Costs higher than using a DB+Cache |
Complexity | + Low Complexity as DB replication is handled at the DB layer |
Scalability | ~ Quite scalable but might need to add a lot of replicas at higher scale ~ Latency will be higher than using a DB+Cache solution |
User Experience | ~ User can be served stale date depending on replication lag |
This approach is quite similar to the primary approach, but here we are using a local cache in each pod instead of memcached.
Dimension | Salient Points |
---|---|
Cost | ++ Costs are minimal as we are using local pod memory as cache |
Complexity | + Low complexity implementing an in-memory cache + No additional system required for implementing caching |
Scalability | + Quite scalable as we can scale pod memory horizontally/vertically |
User Experience | -- Bad user experience as each pod can store a different value for top-scores, leading to an overall jittery experience |
curl --location 'localhost:3000/leaderboard-service/v1/public/top-scores?gameId=<GAME_ID_HERE>&limit=<LIMIT_HERE>' \
--header 'CONSISTENT-READ: false'
Sample Response
Status Code :- 200 (OK)
{
"gameId":"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"gameName":"car-racing",
"topScorers":
[
{
"score":200,
"userId":"14192",
"userName":"user14192"
},
{
"score":200,
"userId":"14195",
"userName":"user14195"
},
{
"score":120,
"userId":"14191",
"userName":"user14191"
},
{
"score":100,
"userId":"14193",
"userName":"user14193"
},
{
"score":100,
"userId":"14194",
"userName":"user14194"
}
],
"lastUpdatedAt":1698390071726
}
Status Code - 4xx/5xx
{
"errorMsg": "errString"
}
-- Create Database for service
CREATE DATABASE leaderboard_service;
USE leaderboard_service;
-- Create Tables
CREATE TABLE game (
id VARCHAR(50) NOT NULL,
name VARCHAR(255) DEFAULT 'game',
PRIMARY KEY (id)
);
CREATE TABLE user (
id VARCHAR(50) NOT NULL,
name VARCHAR(255) DEFAULT 'gameUser',
PRIMARY KEY (id)
);
CREATE TABLE leaderboard (
user_id VARCHAR(50) NOT NULL,
game_id VARCHAR(50) NOT NULL,
score INT DEFAULT NULL,
updated_at BIGINT DEFAULT NULL,
PRIMARY KEY (game_id, user_id),
-- Add a composite secondary index for querying top n scores within a game
INDEX idx_game_score_updated_at (game_id, score DESC, updated_at ASC),
FOREIGN KEY (user_id) REFERENCES user(id),
FOREIGN KEY (game_id) REFERENCES game(id)
);
Get Top n(limit) scores for given gameId
-- Note:- SQL is autogenerated by ORM
-- Get Game Data
SELECT `game`.`id`, `game`.`name` FROM `game`
WHERE `game`.`id` = :gameId
LIMIT 1;
-- Get Top Scorers Data
SELECT `leaderboard`.`score` AS score,
`user`.`id` AS userId,
`user`.`name` AS userName
FROM `leaderboard` `leaderboard`
INNER JOIN `user` `user`
ON `user`.`id` = `leaderboard`.`user_id`
WHERE `leaderboard`.`game_id` = :gameId
ORDER BY `leaderboard`.`score` DESC, `leaderboard`.`updated_at` ASC LIMIT :limit;
- Clone the repo and change directory to leaderboard-service
- Run
nvm use
in terminal to use node version specified in.nvmrc
- Run
npm ci
in terminal to install dependencies - Run
npx tsc
in terminal to compile typescript to javascript. This will create abuild
folder in root directory. - Start Kafka, MySQL and Memcached from docker, by running
docker-compose up -d
insidedevEnv
folder - javascript file is
build/src/index.js
- Run
node build/src/index.js
in terminal to start the server - This will start the Node.js server at
localhost:3000
Swagger : http://localhost:3000/api-docs
Clone the repo and change directory to leaderboard-service.
Run npm test
to run tests and check coverage. There should be no failing tests and coverage should be above 90%.
Coverage report is generated in coverage
folder.
- Create feature branch from main and write all code in feature branch
- Run
npm prettier
to format code - Run
npm run lint
to check for linting errors - Run
npm test
to run tests and check coverage. There should be no failing tests and coverage should be above 90% - Create PR for main using template
- In case of conflicts do not resolve on GitHub, but do following
a.git branch -D main
b.git pull origin main
c.git checkout main
d.git merge feature_branch_name
e. resolve conflicts
f.git push origin main
- Merge branch into main.