How to build a leaderboard service with Momento: a step-by-step guide
In this guide, we'll walk through building a leaderboard service using Momento's Sorted Set collection.
Leaderboards are a common sight in many applications. From games to fitness apps that display step counts among friends. It’s a common way to gamify applications!
However, building a scalable leaderboard service can be challenging. A common approach is to model a leaderboard as a Redis sorted set. Sorted sets are a great fit for this problem, but now we’re ladened with running Redis and all that it entails:
- managing infrastructure
- capacity planning
- maintenance windows
- updating the Redis engine version
- paying for uptime even when no one is using our app
I love the simplicity of serverless and the efficiency of pay-per-use pricing. And that’s where Momento comes in!
Momento is a serverless cache and real-time data platform. It’s built by the team behind DynamoDB and is built for scale and robustness.
In this guide, we’ll walk through building a leaderboard service using Momento’s Sorted Set collection.
All the code for this demo is available in this repo [1].
Pre-requisites
Here are the pre-requisites for this guide:
- A Momento account: Sign up for a free account at console.gomomento.com [2].
- An AWS account.
- Node.js.
- CDK v2.
Understanding Momento’s Sorted Set
A sorted set is a data structure that stores unique elements, each associated with a score. The elements are sorted based on their scores, which makes sorted sets ideal for leaderboards.
With Momento’s Sorted Set collections, we can:
- Fetch an element’s score or rank.
- Fetch a list of elements by rank.
- Fetch a list of elements by score.
- and more (see the API reference here [3])
These map nicely to the common operations on a leaderboard!
Architecture overview
For our demo, we will create three API routes:
- POST /{leaderboard}/{name} to submit scores for a user. For example,
POST /mario-kart/theburningmonk
.
Example payload:
{
"score": 42
}
Example payload:
- GET /{eaderboard}/{name} to get the current rank (0-index) and score of a user. For example,
GET /mario-kart/theburningmonk
.
Example response:
{
"score": 42,
"rank": 1
}
- GET /{leaderboard}?startRank={startRank}&endRank={endRank} to list a page of users by rank.
Example response:
{
"leaderboard": [{
"name": "v",
"score": 2077
}, {
"name": "silverhand",
"score": 34
}]
}
We will use API Gateway and Lambda functions to implement the API. There is one Lambda function per route, and they will use the Momento SDK [4] to communicate with Momento.
This is how the architecture looks:
Step 1: Creating a new cache
Log into the Momento Console [2] and go to “Caches”.
Click “Create cache”.
We will call this new cache “leaderboard” and create it in the “us-east-1” region.
This can also be done through the Momento CLI [5].
Step 2: Generate API key
Momento has two types of API keys:
- Super User Key: This is used to administer caches and topics and generate disposable tokens.
- Fine-Grained Access Key: This is for interacting with Moment caches/topics.
Our Lambda functions need to read and write to the leaderboard
cache, but it doesn’t need to create/delete caches. So, we need a fine-grained access key.
Go to “API keys” in the Momento console and generate a new “Fine-Grained Access Key” in the “us-east-1” region. Add a “readwrite” role for the newly created “leaderboard” cache.
Click “Add Permission” to add this permission to our API key. Review and click “Generate Api Key”.
This generates a new API key for us to use.
Step 3: Secure the API key
To keep our API key safe, we will store it in the SSM Parameter Store.
Go to the “AWS Systems Manager” console in AWS, click “Parameter Store”.
Create a new parameter, and call the new parameter “/leaderboard-api/dev/momento-api-key”. This is the naming convention I typically use for SSM parameters: /{service-name}/{environment}/{parameter-name}
.
Make sure the parameter type is “SecureString”. This ensures the API key is encrypted at rest.
Step 4: Create a CDK app
For this demo, we will use CDK.
For the CDK app, I want to:
- Support ephemeral environments [6], one of the most impactful practices for serverless development.
- Allow ephemeral environments to reuse SSM parameters [7] from one of the main environments (e.g. dev).
So, we will take in two context variables: stageName
and ssmStageName
.
The stageName
is included in the name of every AWS resource we create to avoid name clashes.
The ssmStageName
is used in place of the stageName
in every SSM parameter we reference.
With these in mind, here is the CDK app.
#!/usr/bin/env node
const cdk = require('aws-cdk-lib');
const { LeaderboardApiStack } = require('./constructs/leaderboard-api-stack');
const app = new cdk.App();
let stageName = app.node.tryGetContext('stageName');
let ssmStageName = app.node.tryGetContext('ssmStageName');
if (!stageName) {
console.log('Defaulting stage name to dev');
stageName = 'dev';
}
if (!ssmStageName) {
console.log(`Defaulting SSM stage name to "stageName": ${stageName}`);
ssmStageName = stageName;
}
const serviceName = 'leaderboard-api';
new LeaderboardApiStack(app, `LeaderboardApiStack-${stageName}`, {
serviceName,
stageName,
ssmStageName,
});
And here is the LeaderboardApiStack
:
const { Stack } = require('aws-cdk-lib');
const { Runtime } = require('aws-cdk-lib/aws-lambda');
const { NodejsFunction } = require('aws-cdk-lib/aws-lambda-nodejs');
const { RestApi, LambdaIntegration } = require('aws-cdk-lib/aws-apigateway');
const iam = require('aws-cdk-lib/aws-iam');
const MOMENTO_CACHE_NAME = 'leaderboard';
class LeaderboardApiStack extends Stack {
constructor(scope, id, props) {
super(scope, id, props);
const api = new RestApi(this, `${props.stageName}-LeaderboardApi`, {
deployOptions: {
stageName: props.stageName,
tracingEnabled: true
}
});
this.momentoApiKeyParamName = `/${props.serviceName}/${props.ssmStageName}/momento-api-key`;
this.momentoApiKeyParamArn = `arn:aws:ssm:${this.region}:${this.account}:parameter${this.momentoApiKeyParamName}`;
const submitScoreFunction = this.createSubmitScoreFunction(props);
const getStandingFunction = this.createGetStandingFunction(props);
const getLeaderboardFunction = this.createGetLeaderboardFunction(props);
this.createApiEndpoints(api, {
submitScore: submitScoreFunction,
getStanding: getStandingFunction,
getLeaderboard: getLeaderboardFunction
})
}
createSubmitScoreFunction(props) {
return this.createFunction(props, 'submit-score.js', 'SubmitScoreFunction');
}
createGetStandingFunction(props) {
return this.createFunction(props, 'get-standing.js', 'GetStandingFunction');
}
createGetLeaderboardFunction(props) {
return this.createFunction(props, 'get-leaderboard.js', 'GetLeaderboardFunction');
}
createFunction(props, filename, logicalId) {
const func = new NodejsFunction(this, logicalId, {
runtime: Runtime.NODEJS_20_X,
handler: 'handler',
entry: `functions/${filename}`,
memorySize: 1024,
environment: {
SERVICE_NAME: props.serviceName,
STAGE_NAME: props.stageName,
MOMENTO_API_KEY_PARAM_NAME: this.momentoApiKeyParamName,
MOMENTO_CACHE_NAME,
POWERTOOLS_LOG_LEVEL: props.stageName === 'prod' ? 'INFO' : 'DEBUG'
}
});
func.role.attachInlinePolicy(new iam.Policy(this, `${logicalId}SsmPolicy`, {
statements: [
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [ 'ssm:GetParameter*' ],
resources: [ this.momentoApiKeyParamArn ]
})
]
}));
return func;
}
/**
*
* @param {RestApi} api
*/
createApiEndpoints(api, functions) {
const leaderboardResource = api.root.addResource('{leaderboard}')
const nameResource = leaderboardResource.addResource('{name}')
// POST /{leaderboard}/{name}
nameResource.addMethod('POST', new LambdaIntegration(functions.submitScore));
// GET /{eaderboard}/{name}
nameResource.addMethod('GET', new LambdaIntegration(functions.getStanding));
// GET /{leaderboard}?startRank=1&endRank=10
leaderboardResource.addMethod('GET', new LambdaIntegration(functions.getLeaderboard));
}
}
module.exports = { LeaderboardApiStack }
Here, we created an API in API Gateway and three Lambda functions to implement the aforementioned routes:
Loading SSM parameters securely
Notice that our functions did not include the Momento API key as an environment variable. Instead, we pass along the name of the parameter:
environment: {
SERVICE_NAME: props.serviceName,
STAGE_NAME: props.stageName,
MOMENTO_API_KEY_PARAM_NAME: this.momentoApiKeyParamName,
MOMENTO_CACHE_NAME,
POWERTOOLS_LOG_LEVEL: props.stageName === 'prod' ? 'INFO' : 'DEBUG'
}
and we give the function the IAM permission to fetch the parameter at runtime.
func.role.attachInlinePolicy(new iam.Policy(this, `${logicalId}SsmPolicy`, {
statements: [
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [ 'ssm:GetParameter*' ],
resources: [ this.momentoApiKeyParamArn ]
})
]
}));
This is so that:
- We protect ourselves against compromised dependencies from stealing information from environment variables.
- We can use short expirations for our API keys and rotate them without redeploying the application.
During cold start, a function will fetch the SSM parameter, decrypt it and cache its value for a few minutes. After the cache expiry, the next invocation will attempt to fetch an updated value from the SSM Parameter store.
This way, we don’t need to call SSM on every invocation. When we rotate the API key in the background (with a cron job), our functions automatically pick up the new key after its cache expires.
Luckily, Middy’s ssm middleware [8] supports this flow out-of-the-box. We will let it handle the heavy lifting, but more on this later!
Example workflow
When I start working on a JIRA ticket “ABP-1734”, I will:
- Create a feature branch
ABP-1734
. - Create an ephemeral environment by running
cdk deploy --context stageName=FEAT-ABP-1734 --context ssmStageName=dev
. This creates a new instance of our leaderboard service so I can work on my changes in isolation. This new environment will use thedev
SSM parameters, but all its resources will have theFEAT-ABP-1734
suffix. - I make my changes, test them and create a PR.
- I delete the ephemeral environment by running
cdk destroy --context stageName=FEAT-ABP-1734 --context ssmStageName=dev
.
These short-lived environments are useful for feature development as well as running tests in CI/CD pipelines. Thanks to pay-per-use pricing, we can have as many environments as we need without incurring additional costs.
Ideally, we’d have one Momento cache per environment, too. In that case, the name of the cache should be prefixed or suffixed with stageName
.
Step 5: Implement submitScore function
Here’s the code for the submitScore
function behind the POST /{leaderboard}/{name}
route:
const { initClient, submitScore } = require('../lib/momento');
const middy = require('@middy/core');
const ssm = require('@middy/ssm');
const handler = async (event, context) => {
const body = JSON.parse(event.body);
const leaderboardName = event.pathParameters['leaderboard'];
const name = event.pathParameters['name'];
await initClient(context.MOMENTO_API_KEY);
await submitScore(leaderboardName, name, body.score);
return {
statusCode: 202,
};
};
module.exports.handler = middy(handler)
.use(ssm({
cache: true,
cacheExpiry: 5 * 60 * 1000,
setToContext: true,
fetchData: {
MOMENTO_API_KEY: process.env.MOMENTO_API_KEY_PARAM_NAME
}
}));
Here, we use Middy’s ssm middleware [8] to fetch and cache the Momento API key from SSM Parameter Store.
.use(ssm({
cache: true,
cacheExpiry: 5 * 60 * 1000,
setToContext: true,
fetchData: {
MOMENTO_API_KEY: process.env.MOMENTO_API_KEY_PARAM_NAME
}
}));
By default, the middleware injects the fetched data into environment variables. However, as stated before, we should avoid putting unencrypted API keys in the Lambda functions’ environment variables. Attackers would often scan environment variables for sensitive information.
So, we ask the middleware to set the fetched data to Lambda’s invocation context
object instead. Therefore, when we initialize the Momento client, we have to get the Momento API key from context.MOMENTO_API_KEY
.
await initClient(context.MOMENTO_API_KEY);
await submitScore(leaderboardName, name, body.score);
Encapsulating shared logic
As per the earlier snippet, I have encapsulated all the Momento-related operations into a shared momento.js
module.
This includes shared logic, such as initializing a Momento cache client.
const { CacheClient, Configurations, CredentialProvider } = require('@gomomento/sdk');
const {
CacheSortedSetPutElementResponse,
CacheSortedSetGetScoreResponse,
CacheSortedSetGetRankResponse,
CacheSortedSetFetchResponse,
SortedSetOrder
} = require('@gomomento/sdk');
const { Logger } = require('@aws-lambda-powertools/logger');
const logger = new Logger({ serviceName: 'leaderboard-api' });
const { MOMENTO_CACHE_NAME } = global.process.env;
let cacheClient;
async function initClient(apiKey) {
if (!cacheClient) {
logger.info('Initializing Momento cache client');
cacheClient = await CacheClient.create({
configuration: Configurations.Lambda.latest(),
credentialProvider: CredentialProvider.fromString(apiKey),
defaultTtlSeconds: 7 * 24 * 60 * 60, // 7 days
});
logger.info('Initialized Momento cache client');
}
};
async function submitScore(leaderboardName, name, score) {
const result = await cacheClient.sortedSetPutElement(
MOMENTO_CACHE_NAME, leaderboardName, name, score);
if (result.type === CacheSortedSetPutElementResponse.Error) {
logger.error('Failed to submit score', {
error: result.innerException(),
errorMessage: result.message()
});
throw result.innerException();
}
}
Here, we are taking advantage of the fact that Lambda execution environments are reused.
When a new execution environment is created (during a cold start), the cacheClient
variable is set. On subsequent invocations on the same execution environment, the initClient
function will short-circuit and return right away.
Step 6: Implement getStanding function
Here’s the code for the getStanding
function behind the GET /{leaderboard}/{name}
route:
const { initClient, getScoreAndRank } = require('../lib/momento');
const middy = require('@middy/core');
const ssm = require('@middy/ssm');
const handler = async (event, context) => {
const leaderboardName = event.pathParameters['leaderboard'];
const name = event.pathParameters['name'];
await initClient(context.MOMENTO_API_KEY);
const result = await getScoreAndRank(leaderboardName, name);
return {
statusCode: 200,
body: JSON.stringify({
score: result.score,
rank: result.rank
})
};
};
module.exports.handler = middy(handler)
.use(ssm({
cache: true,
cacheExpiry: 5 * 60 * 1000,
setToContext: true,
fetchData: {
MOMENTO_API_KEY: process.env.MOMENTO_API_KEY_PARAM_NAME
}
}));
And here is the getScoreAndRank
function in the shared momento.js
module:
async function getScore(leaderboardName, name) {
const result = await cacheClient.sortedSetGetScore(
MOMENTO_CACHE_NAME, leaderboardName, name);
if (result.type === CacheSortedSetGetScoreResponse.Hit) {
return result.score();
} else if (result.type === CacheSortedSetGetScoreResponse.Miss) {
return 0;
} else {
logger.error('Failed to get score', {
error: result.innerException(),
errorMessage: result.message()
});
throw result.innerException();
}
}
async function getRank(leaderboardName, name) {
const result = await cacheClient.sortedSetGetRank(
MOMENTO_CACHE_NAME, leaderboardName, name, {
order: SortedSetOrder.Descending
});
if (result.type === CacheSortedSetGetRankResponse.Hit) {
return result.rank();
} else if (result.type === CacheSortedSetGetRankResponse.Miss) {
return null;
} else {
logger.error('Failed to get rank', {
error: result.innerException(),
errorMessage: result.message()
});
throw result.innerException();
}
}
async function getScoreAndRank(leaderboardName, name) {
const [score, rank] = await Promise.all([
getScore(leaderboardName, name),
getRank(leaderboardName, name),
]);
return { score, rank };
}
Notice that we made a small optimization here to fetch the user’s score and rank concurrently.
const [score, rank] = await Promise.all([
getScore(leaderboardName, name),
getRank(leaderboardName, name),
]);
This is a simple yet effective optimization.
Also, when fetching the rank, we can specify in which order the scores should be sorted. In this case, we want a leaderboard where a higher score is better. It’s also possible to have a leaderboard where a lower score is better (e.g. the lap time in a racing game).
const result = await cacheClient.sortedSetGetRank(
MOMENTO_CACHE_NAME, leaderboardName, name, {
order: SortedSetOrder.Descending
});
Step 7: Implement getLeaderboard function
Here’s the code for the getLeaderboard
function behind the GET /{leaderboard}
route:
const { initClient, getLeaderboard } = require('../lib/momento');
const middy = require('@middy/core');
const ssm = require('@middy/ssm');
const handler = async (event, context) => {
const leaderboardName = event.pathParameters['leaderboard'];
const startRank = parseInt(event.queryStringParameters['startRank']) || undefined;
const endRank = parseInt(event.queryStringParameters['endRank']) || undefined;
await initClient(context.MOMENTO_API_KEY);
const leaderboard = await getLeaderboard(leaderboardName, startRank, endRank);
return {
statusCode: 200,
body: JSON.stringify({
leaderboard: leaderboard
})
};
};
module.exports.handler = middy(handler)
.use(ssm({
cache: true,
cacheExpiry: 5 * 60 * 1000,
setToContext: true,
fetchData: {
MOMENTO_API_KEY: process.env.MOMENTO_API_KEY_PARAM_NAME
}
}));
And the corresponding getLeaderboard
function in the shared momento.js
module:
async function getLeaderboard(leaderboardName, startRank, endRank) {
const result = await cacheClient.sortedSetFetchByRank(
MOMENTO_CACHE_NAME, leaderboardName, {
startRank: startRank || 0,
endRank,
order: SortedSetOrder.Descending
});
if (result.type === CacheSortedSetFetchResponse.Hit) {
return result.value().map((item) => ({
name: item.value,
score: item.score
}));
} else if (result.type === CacheSortedSetFetchResponse.Miss) {
return [];
} else {
logger.error('Failed to get leaderboard', {
error: result.innerException(),
errorMessage: result.message()
});
throw result.innerException();
}
}
Notice in the above that if endRank
is undefined
then Momento will return all the users on the leaderboard. This is likely not what we want!
So in practice, we should enforce a page size limit in case the caller does not specify an endRank
. For example, if endRank
is undefined
then it should equal (startRank || 0) + 20
.
Step 8: Deploy and Test
Finally, run cdk deploy
and test it out!
If you want to see how it works in action, then check out the full course here.
Wrap up
I hope I have given you a flavor of how easy it is to use Momento cache. It’s fully serverless and only charges for what we use. There’s no need to manage infrastructure, and it’s designed for scale!
Momento can easily scale to tens of thousands of requests per second (RPS), as illustrated in this article [9] by Daniele Frasca.
But it’s more than just a cache—it also has Topics, which supports WebSockets out-of-the-box. Topics offer some really interesting possibilities for building real-time applications.
We will dive into Momento Topics in the next article.
Links
[1] Demo repo
[2] Momento Console
[3] Momento Sorted Set API reference
[4] Momento SDKs
[5] Momento CLI
[6] Serverless Ephemeral Environments Explained
[7] Ephemeral Environments: how to reuse SSM parameters
[9] Serverless Challenge: Is scaling achievable using Momento as a cache-aside pattern?