Wrapping my head around Azure Cosmos DB
Azure Cosmos DB is Microsoft Azure’s go-to document database and a foundational Azure service that offers some interesting and unique benefits. While studying for the AZ-204 (developing software Solution for the Azure cloud) a while ago, I ran into this diagram on Microsoft Learn that inspired me. I have an interest in Azure cloud technology in combination with a long-term interest in video games and have lately been working on a private video-game project in my free time for fun.
While being busy with my game, I figured it would be a fun side-project to implement the ability to store and retrieve game-state data in the cloud (i.e. make cloud-saves). This served both as a thought experiment and to get more familiar with a set of cloud technologies relevant for my job as a software developer.
This idea of ‘cloud-saves’ served mostly as a concept and excuse for the sake of exploring Cosmos DB. It was a fun experiment and one that I took some insights from that I would like to share in this write-up. The topics I will discuss here are just as relevant for storing and- or retrieving any other kind of data from a central Cosmos Database, for any kind of desktop or mobile app.
In a small series of blogs, I will share with you what I have encountered, what I have learned from this in relation to Cosmos DB, and what questions and interesting design decisions are still up in the air. So let’s get going!
My game has bunch of data that I want to store each time a player saves a game:
Think of information like the current level, the position of characters on the map, the current health of each character, etcetera.
Cosmos DB serves this scenario well for a couple of reasons.
- Because a user will normally be getting and retrieving data related to their own game session, by choosing an identifier that represents which documents a user is allowed to access as ‘partition key,’ we can achieve great horizontal scaling and keep cross-partition querying to a minimum. I will explain what exactly a partition key in detail during this article.
- As the game changes and new versions are released, the structure of save games may change with new kinds of data and state that needs to be stored for objects on the game map. The schema-less nature of Cosmos DB is great for this.
- Cosmos DB allows for fine-grained control over which user can access what data on a database level.
- Users may one day play my game anywhere in the world. The replication features of Cosmos across regions can ensure optimal latency.
In this series of two blogs, I demonstrate and explain these four points. I have found there is a lack of resources that explain these topics very clearly, and that they can be tricky to understand for inexperienced users. I therefore hope this series will help the reader to get a good grasp on these topics. This first blog will cover points 1 and 2.
Point 1: Choose a good partition key
Suppose each saved game is stored inside a document in our Cosmos DB in a structure that looks like this in the Azure Explorer for Cosmos DB:
The selected document shows the top part of large, saved game that, once deserialized, my application can use to reconstruct a stored state. On the left side, notice the path: “/UserPermission”. This path is the ‘Partition Key path’ and means that the saved documents are partitioned by the ‘Partition Key’ user-{id}. The logical concept of documents that share the same Partition Key path is what we call a Container in Cosmos DB (Souls…Items in the picture above). Note that one other document contains the same value for its partition key as the save-game:
This document contains some personalia and represents a registered user. Documents that share the partition key, resides in the same logical partition. More on that soon.
In my case, user-{id} represents a permission scope for a single Cosmos DB user; To get or store save games from- and to the database, users will be accessing documents that have their corresponding user-{id} as partition key. In a hypothetical scenario where the database contains documents that need shared, or read-only access, additional Cosmos DB Users would get created to represent these scopes, in addition to the read/write Cosmos DB User. I will dive into the topic of Users and permissions in Cosmos DB in a follow-up post. For now, it is enough to assume each user-{id} represents a unique end-user of the game.
Choosing a partition key that has a different value for each user, is a very intentional choice. In order to understand why it is a good one, we must understand something about horizontal scaling and partitioning in Cosmos DB. This is a deceptively tricky subject to explain. I want to explicitly thank colleague and UX-designer Ivo Domburg for the visuals I will use to do so.
Assume that our document database has only one logical container and that this container contains an arbitrary number of documents of varying sizes:
By definition, all documents in a logical container share the same partition key path. Let us hypothetically say our sample database has 4 distinct users that each have a set of permissions that determine what documents they can access. I would then give the documents those users may store a /UserPermission partition key with a value in the format user-{id}:
Cosmos DB will put documents that share a partition key in the same logical partition. As a developer it is important to realize that a logical partition can be at most 20 GB in size. That means that the sum of documents with the same value for the partition key cannot exceed this 20GB combined.
When the number of documents in our database that have /UserPermission as their partition key Path grows, Cosmos DB will start horizontally scaling the logical partitions over physical partition of 50GB each. It will put documents with the same PartitionKey paths in the same physical partitions, until they no longer fit and then a new physical partition gets created:
All this kind of begs the question why partitioning works like this in Cosmos DB. While I am no expert on the deeper technological considerations of the creators, I can paint a general picture: In its essence, this model strives to balance (horizontal) scalability, (global) distribution and efficient resource utilization. For example, a 50 GB partition allows for the efficient management of the automatic indexes that Cosmos DB creates, ensuring that queries can be processed quickly without overloading any single partition. The 50 GB limit also helps in managing throughput (RU/s). When a container grows beyond this size, Cosmos DB dynamically splits the data into multiple partitions, and the associated throughput (RUs) is also scaled across those partitions. This allows for better resource allocation and more granular control over performance. Other factors that may have been a consideration for choosing these physical constraints, are the balance between over- and under allocation of storage resources and efficient geo-replication of data.
In any case, now that we understand how documents scale in a Cosmos Database and what the limitations are, we can understand what we should– and should not- do when designing the database. In general, you should strive for the following:
Prevent ‘hot’ partitions
When designing a database with Cosmos DB, we should try to prevent creating partitions whose data needs to be stored- or retrieved very often compared to that of other partitions, because this defeats the purpose of horizontal scaling. Moreover, physical partitions have set throughput limits. Suppose we would choose the save–game Date instead of user permission as partition key path. This would typically be a bad choice, as most users may be accessing save–games they made that day. An overly large number of users would be querying in the same physical partition in that scenario. In my game, by choosing user permission as partition key, I ensure all documents that a user can access fall in the same partition. Therefore, traffic is spread amongst many different partitions and can scale when more users are added to the system. It also means that my users will usually be accessing data from within a single partition during their sessions, which brings to a following point:
Prevent cross partition queries
Unlike relational databases, cross-partition querying, i.e. retrieving and combining data located in different partitions in a single query, is expensive and slow in a document database like Cosmos DB. Therefore, you should strive to reduce the need of cross-partition querying as much as possible when designing your document database (I will discuss exceptions to this rule later on). When a user is in a game session and is storing and retrieving save-game data to Cosmos DB for his session, he should ideally only need to access data from within the partition that has his user permission partitionkey (e.g. user-xyz) and not mix that data with info from other logical partitions. In our simple setup, with each user having access rights to only the documents that have his user id as partition key, this is obviously the case. Within his session, he is therefore accessing data from within a single physical location, optimizing costs and performance. Keeping all data of a user in a single partition also has an obvious potential disadvantage:
Keep the physical limitations of cosmos DB in mind
Since a logical partition, e.g. a set of documents with the same value for partition key, has a maximum of 20GB, we should keep this in mind when developing the database. In the case of our save-games example, it means when can store a maximum of 20 GB of data for a dataset of documents with the same userpermission. This is fine for my own game, as I do not want individual users to store more than 20 GB of data anyway. You should notably keep this limitation in mind when trying to store rapidly growing datasets, like for example logs or chat history in a single logical partition.
Further considerations when nesting data
Nesting data is often a good idea in Cosmos DB, even if it leads to data duplication, because it allows users to stay in the same partition when querying their data and prevents cross partition queries.
If our game map has tiles, objects, playable characters for example each with their own attributes and state that we wish to store in a save-game, we typically want to nest these inside the same document.
However, when designing a database, there are some cases where nesting is explicitly a bad idea. One is as mentioned above: If a sub-dataset gets very large, not only do you have the 20 GB cap, you also must consider that you will be transferring the data each time you update a document with a given partition key. If these objects are unnecessarily large, it may mean unneeded, costly traffic. Let us consider, for example, what to do if we would want to store the player’s chat history or game log in our save-game. In such a case it would not be a good idea to nest this data under the save-game. Instead, you should make an item with its own partition key value for each item (comment or log entry) and reference a parent container. However, since this may increase the need for cross-partition querying and roundtrips, you may want to consider a hybrid approach in such cases. For example, you could choose to nest the most recent logs and comments under the save-game, while storing older information that needs to be accessed infrequently in separate containers.
Another example of nesting being a bad idea is when a sub-dataset of the save-game would change very frequently, while the rest of the document does not. This one is a little harder to find an analogy for with our save-game example. But let us say for example, that we have some reason to want to immediately persist the coordinate of each mouse button click by the user to our save- game. In this case we would again run into the problem that we need to update a very large document frequently, with only a small amount of actual data changing. Here we could consider the save-game holding a reference id to another small document that contains data for the mouse-click event.
In short, a developer should consider the traffic costs in terms of request units and the physical limitations of the database, when designing the database and choosing suitable partition keys.
Point 2: Cosmos DB is schema-less.
Let us imagine that the game’s playing field consists of chessboard-like tiles in a 12 x 6 grid and that objects like statues and characters can occupy such a tile. The dragon from the picture above (Billy) initially had only a bite attack. But in the newest version of my game it gets a power-up and can spit fireballs that leave behind some scorched playing field tile. Additionally, let us hypothetically say that we changed all tiles on the playing field to be grassland and we no longer need to track what type of tile it is in the latest version of the game. These 2 changes would mean the tile-related data we need for our save-game (for each tile, in a simplified example), would change.
from
{ "x_postion": 5, "y_position": 5, "type": "grass", }
to
{ "x_postion": 5, "y_position": 5, "fire_turns_remaining": 3 }
With a schema-less database like Cosmos DB it is no problem to maintain save–games like this next to each other. As long as the application logic can handle both, the save–game will even keep working perfectly for the new version. Imagine what a pain it would be to do this in a SQL database, where you would have to deal with schema migrations, data-loss and stuff like that!
One frequently used practice is to include a version field on the save–game object in such a case, such as ‘schema_version”. By including a schema version, developers can support even drastic changes to the schema when deserializing the data by checking the included version on the JSON object and then deciding what to do with it based on that information.
Until next time
Alright, that is what I have for you today. In an upcoming blog, I will be covering the remaining points from the introduction: (3) Fine-grained control of which user can access what partitions and (4) geographical considerations when using Cosmos DB. I hope to see you there!
Want to know more about what we do?
We are your dedicated partner. Reach out to us.