We’re back with another collection data type: Sorted sets!
Add another powerful tool to your caching strategy with sorted sets.
Remember last week when we got excited about our new collection data types (CDT)? We introduced three new cacheable data types: dictionaries, sets, and lists. Guess what? We did it again.
This time, we’re introducing a fourth CDT, sorted sets! A sorted set is exactly as it sounds – an ordered array of distinct elements!
“Ordered by what” you ask? Whatever you want (as long as what you want is a double)!
A sorted set is ordered by something known as a score.The score must be numeric and may have decimal places. Order is updated automatically as elements are added, updated, and removed.
Pretty cool, right?
What can you do with sorted sets?
If you remember our fictitious game Acorn Hunt from our last blog post on collections, you recall it is a multiplayer game that allows players to collaborate and gather as many acorns as they can before time runs out.
In Acorn Hunt, we used dictionaries to store player metadata, lists to store in-game chat, and sets to store the player list for each game. Now we can use sorted sets to add a leaderboard! As players collect their acorns, their score goes up and the leaderboard is automatically updated.
Not only can we build a leaderboard with sorted sets, but we can also add rate-limiting too! In every round of Acorn Hunt, players can use their super-ability – tree slam – 3 times. With sorted sets, we can decrement the score every time the ability is used and remove the player when their limit is reached.
Build a Leaderboard
Leaderboards list players in order of their score. Some leaderboards will only show the top N number of players, like the top 10. Other leaderboards will display scores inverted, where the lowest score wins – like in golf.
With sorted sets, we can easily handle these scenarios without jeopardizing the integrity of our data. Consider an endpoint for Acorn Hunt that gets us the top 5 scores for a specific game:
// GET /games/{gameId}/scores?top=5&sort=desc
func handler(w http.ResponseWriter, r *http.Request) {
client := getClient()
ctx := r.Context()
setupCache(client, ctx)
gameId := mux.Vars(r)["gameId"]
top, _ := strconv.Atoi(r.URL.Query().Get("top"))
sort := r.URL.Query().Get("sort")
numberOfResults := 10
if top > 0 {
numberOfResults = top
}
order := momento.ASCENDING
if sort == "desc" {
order = momento.DESCENDING
}
fetchResponse, err := client.SortedSetFetch(ctx, &momento.SortedSetFetchRequest{
CacheName: cacheName,
SetName: gameId,
Order: order,
NumberOfResults: &momento.FetchLimitedElements{
Limit: uint32(numberOfResults),
},
})
if err != nil {
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
switch r := fetchResponse.(type) {
case *momento.SortedSetFetchHit:
leaderboard := make([]Player, 0, len(r.Elements))
for _, e := range r.Elements {
leaderboard = append(leaderboard, Player{
Name: string(e.Value),
Score: int(e.Score),
})
}
leaderboardResult, err := json.Marshal(leaderboard)
if err != nil {
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(leaderboardResult)
return
case *momento.SortedSetFetchMiss:
http.Error(w, "Game not found", http.StatusNotFound)
}
}
Our Go SDK accepts values that conditionally limit the number of results and set the order the values are returned. This provides an easy, extensible way to build a leaderboard!
Rate limit the super-ability
The tree slam super-ability is pretty powerful. It allows users to knock acorns directly out of a tree with a single click. We don’t want everyone doing that all the time so we limit players to 3 uses per game to keep it fair and fun.
With sorted sets, we can keep track of the limit. By using the sortedSetGetScore, sortedSetIncrement, and sortedSetRemove commands in the SDK, we can update the amount of times the super-ability can be used and remove the player from the sorted set when they reach 0. Consider the following endpoint for using the super-ability.
// DELETE /games/{gameId}/super-ability
func handler(w http.ResponseWriter, r *http.Request) {
client := getClient()
ctx := r.Context()
setupCache(client, ctx)
gameId := mux.Vars(r)["gameId"]
username := mux.Vars(r)["username"]
// Find user in the rate limit cache for the game
fetchResponse, err := client.SortedSetGetScore(ctx, &momento.SortedSetGetScoreRequest{
CacheName: "super-abilities",
SetName: gameId,
ElementNames: []momento.Value{momento.String(username)},
})
if err != nil {
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
switch set := fetchResponse.(type) {
case *momento.SortedSetGetScoreHit:
switch set.Elements[0].(type) {
case *momento.SortedSetScoreHit:
// Continue processing outside of switch
case *momento.SortedSetScoreMiss:
http.Error(w, "Out of super-ability uses", http.StatusConflict)
return
}
case *momento.SortedSetGetScoreMiss:
http.Error(w, "Game not found", http.StatusNotFound)
return
}
// Decrease the number of remaining super-ability usages
incrementResponse, err := client.SortedSetIncrementScore(ctx, &momento.SortedSetIncrementScoreRequest{
CacheName: "super-abilities",
SetName: gameId,
ElementName: momento.String(username),
Amount: -1,
})
if err != nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
switch r := incrementResponse.(type) {
case *momento.SortedSetIncrementScoreSuccess:
// Remove the user from the sorted set if they have no more usages left
if r.Value <= 0 {
_, err := client.SortedSetRemove(ctx, &momento.SortedSetRemoveRequest{
CacheName: "super-abilities",
SetName: gameId,
ElementsToRemove: &momento.RemoveSomeElements{
Elements: []momento.Value{
momento.String(username),
},
},
})
if err != nil {
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
}
remainingSuperAbilities, err := json.Marshal(&SuperAbility{
Remaining: int(r.Value),
})
if err != nil {
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(remainingSuperAbilities)
return
default:
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return
}
}
Every time the endpoint is called, it decrements the score of the player. When the score reaches 0, the player is removed from the sorted set. If we get a cache miss when looking the player up, we know they are out of super abilities and return an error. We reset the count at the end of each round to effectively limit the allowable rate of tree slam.
Other exciting things you can do
Those were two pretty cool use cases. But we’re not done yet. Need to show a player their current ranking on the leaderboard without fetching the whole thing? Just use sortedSetGetRank–it does it for you! What about resetting the entire leaderboard back to 0 or restoring the super-ability counter every round? Just call the sortedSetPut command to overwrite everything.
You can, of course, remove an entire sorted set at your leisure with the delete command. But keep in mind these are cached items, they will expire on their own! Sorted sets, like all other data types in Momento Cache, require a time to live (TTL). If a TTL is not provided when creating or updating a sorted set, the default value from the Momento Simple Cache Client will be used.
Need more time? You have the option to reset the time to live when a modification is made to your sorted set (or any other cache item). This enables you to automatically expire inactive items and keep alive the ones that are still kicking.
Feel free to use sorted sets to your specific use cases! These new cacheable collections aren’t scoped to just leaderboards and rate limiting, they can be used for anything you imagine!
All of the available commands are documented in our sorted set API reference.
Ready to play?
I don’t know about you, but I’m excited. Sorted sets add some incredibly powerful tools to your caching toolbelt. Starting today, they are available in our Go SDK.
Need a different programming language? Check out our support in Node.js, Python, PHP, .NET, Rust, and Java!