How to ensure cross-region data integrity with Amazon DynamoDB global tables
At Stedi, we are fans of everything related to AWS and serverless. By standing on the shoulders of giants, the engineering teams can quickly iterate on their ideas by not having to reinvent the wheel. Serverless-first development allows product backends to scale elastically with low maintenance effort and costs.
One of the serverless products in the AWS catalog is Amazon DynamoDB – an excellent NoSQL storage solution that works wonderfully for most of our needs. Designed with scale, availability, and speed in mind, it stands at the center of our application architectures.
Background
I'm part of a team that keeps all its application data in Amazon DynamoDB. One of the items in the database is a JSON blob, which holds the data for our core entity. It has three large payload fields for customer input and one much smaller field containing various metadata. A simplified control flow diagram of creating/updating the entity follows.

The user sends the JSON blob to the application API. The API is fronted with Amazon API Gateway and backed by the AWS Lambda function. The logic within the AWS Lambda function validates and applies business logic to the data and then saves or updates the JSON blob as a single Amazon DynamoDB item.
The product matures
As the product matured, more and more customers began to rely on our application. We have observed that the user payload started to grow during the application lifecycle.
Aware of the 400 KB limitation of a single Amazon DynamoDB item, we started thinking about alternatives in how we could store the entity differently. One of such was splitting the single entity into multiple sub-items, following the logical separation of data inside it.
We have opted for an active-active architecture backed by DynamoDB global tables to improve the service's availability. The following depicts the new API architecture in a simplified form.

The tipping point

Considering prior exploratory work, we identified two viable solutions that would enable us to expand the amount of data the entity consists of, allowing the service to accept bigger payloads.
The first approach would be to switch from Amazon DynamoDB to Amazon S3 as the storage layer solution. Changing to Amazon S3 would allow the entity, in theory, to grow to the size of 5 TB (which we will never reach).
After splitting up the core entity into four sub-items, each of them had the following structure.
{
pk: "CustomerID",
sk: "EntityID#SubItemA",
data: ...
}
The challenges with global replication and eventual consistency

Below the orange box, you can see four replications of the entity's sub-items. Below the red box, you can see a query to retrieve the entity. The query operation took place before all sub-items had the chance to replicate. Consequently, the fetched data consists of two sub-items from the new version and two ones from the old version. Merging those four sub-items leads to an inconsistent state and is not what the customer expects. Here's a code example that shows how this query would work:
import { DocumentClient } from "aws-sdk/clients/dynamodb";
const ddb = new DocumentClient();
const NUMBER_OF_SUB_ITEMS = 4;
const subItems = (
await ddb
.query({
TableName: "MyTableName",
KeyConditionExpression: "pk = :pk and begins_with(sk, :sk_prefix)",
ExpressionAttributeValues: {
":pk": "CustomerID",
":sk": "EntityID#",
},
Limit: NUMBER_OF_SUB_ITEMS,
})
.promise()
).Items;
const entity = merge(subItems);
The solution
Below, you can see how we added the version to the sort key.
{
pk: "CustomerID",
sk: "EntityID#Version1#SubItemA",
data: ...
}


import { DocumentClient } from "aws-sdk/clients/dynamodb";
const ddb = new DocumentClient();
const NUMBER_OF_SUB_ITEMS = 4;
const subItems = (
await ddb
.query({
TableName: "MyTableName",
KeyConditionExpression: "pk = :pk and begins_with(sk, :sk_prefix)",
ExpressionAttributeValues: {
":pk": "CustomerID",
":sk": "EntityID#",
},
// This line changed: We now load two versions if possible.
Limit: 2 * NUMBER_OF_SUB_ITEMS,
})
.promise()
).Items;
const latestSubItems = filterForHighestReplicatedVersion(subItems);
const entity = merge(latestSubItems);
Engineering tradeoffs
Conclusion
DynamoDB Global Tables are eventually consistent, with a replication latency measured in seconds. With increased data requirements, splitting entities into multiple sub-items becomes an option but must be implemented carefully.
This article shows how my team used custom logic to version entities and merge sub-items into the entity that our customers expect. It would be nice if DynamoDB Global Tables provided global consistency. Still, until then, I hope this article helps you understand the problems and a possible solution to implement yourself.
Get blog posts delivered to your inbox.