Friday, May 3, 2024
No menu items!
HomeDatabase ManagementFriend microservices using Amazon DynamoDB and event filtering

Friend microservices using Amazon DynamoDB and event filtering

The gaming industry has evolved significantly over the past few years. A feature that has become essential to that evolution is to be friends with other players and play together in the same game.

From the players’ point of view, the process to become friends is straightforward. A player sends a friend request to another, who accepts the request, and they become friends within the game. After establishing a friend relationship, players can stay together through player matchmaking, play in a closed session that others aren’t allowed to join, send chat messages to each other, and more. Game developers use Amazon DynamoDB, a serverless NoSQL database service, for its durability, scalability, and performance to support thousands of players.

In this post, I showcase an example in-game friend service that uses a combination of Amazon DynamoDB and AWS Lambda event filtering. Event filtering is a Lambda feature that enables you specify up to five filter criteria when creating or updating the event source mappings for your Lambda functions. The functions can be initiated by Amazon Simple Queue Service (Amazon SQS), Amazon DynamoDB Streams, or Amazon Kinesis.

Friend service overview

The friend service is part of a friend relationship management system. Friend relationships identify the state of the relationships a player is currently in. For example, friend relationships can have a state of requested, pending, or friends (accepted).

When developers build a friend service in games, they often expose player-driven APIs to the game client and other backend services. The APIs used for friend services are responsible for sending friend requests, accepting requests, and retrieving lists of friends. Because these API calls are completely independent from other game features, such as battles, leaderboards, and player matchmaking, the friend service is suitable for a microservices architecture that scales independently from other game features.

Data design

There are two common ways to structure data for a friend service:

Relationship-based – A single entry describes the status of a friend relationship between two players. With this approach, there is no data duplication and fewer transactions are required to write the data.
Player-based – Shows the relationship status of each player. This structure makes it easier to process requests, such as obtaining a friends list. Also, this structure works well with a player-based user experience (UX).

Relationship-based data structure:

Relationship ID (PK)
Relationship
PlayerA-PlayerB
Friends

Player-based data structure:

Player ID (PK)
Friend ID (SK)
Relationship
PlayerA
PlayerB
Friend
PlayerB
PlayerA
Friend

Relationship-based data can also be described using DynamoDB secondary indexes, for example, using the requesting player’s ID as the primary key and the receiving player’s ID as a secondary index. Many game backend functions are initiated by player actions and the UX of most games is player-oriented, so the example in this post follows a player-based data design, while makes it easier to give backend services access to player data.

Friend states and actions

Friend states are based on players, and indicate the state of a player in relation to the another. There are three friend states.

Requested – The player has sent a friend request to another player and is waiting for a reply.
Pending – The player has received a friend request from another player.
Friends – The request has been accepted and the two players are friends.

At first glance, three states seem insufficient to explain all possible situations. However, it’s possible to support the following four player-based actions by using the three states (four if you include the empty state):

Request – A player sends a friend request to another player, which sets the sending player’s state to requested and the receiving player’s state to pending.
Accept – A player accepts a friend request from another player, which sets the state for both players to friends.
Reject – A player rejects a friend request from another player, which clears the request for both players.
Unfriend – A player ends a friend relationship with a specific player, which removes the friend relationship.

The player state changes according to the four actions. Figure 1 that follows illustrates the state flow for all possible situations, including the empty state when a friend request is rejected or a friend is unfriended.

Figure 1: Friend and actions and states

Based on those actions and states, you can define a DynamoDB table structure similar to the following:

Primary key
Attributes
Partition key: player_id
Sort key: friend_id
playerA
playerB
state
last_updated
Requested
2022-12-01T17:00:00Z

When managing data on a player basis, it’s common to use transactions to make simultaneous changes and keep states in sync for both players. For example, if Player A sends a friend request to Player B, with an atomic operation, you have to simultaneously write both Player A’s and Player B’s data to Requested and Pending, as shown in the following table. You can query the state from requester to receiver or receiver to requester to verify they have the correct state.

Primary key
Attributes
Partition key: player_id
Sort key: friend_id
playerA
playerB
state
last_updated
Requested
2022-12-01T17:00:00Z
playerB
playerA
state
last_updated
Pending
2022-12-01T17:00:00Z

There are no architectural issues with using transactions to make changes simultaneously, but an asynchronous design provides greater flexibility to scale your solution up or down as needed.

When player A sends a friend request to player B, the friend states for both players don’t have to be updated synchronously from player B’s point of view. The next section describes how you can achieve an asynchronous flow by using event-driven AWS services while keeping all state management atomic. By using an asynchronous design, you can decrease the number of transactions and provide a scalable friend service even with a player-based data design.

Friend service architecture

The serverless architecture in this example is for an asynchronous friend service that uses Lambda, Amazon SQS, and a DynamoDB table. There are several event source mapping filters, so the state handlers are built with multiple Lambda functions. The backend game services interact with the solution through the frontend queue. The frontend queue uses Amazon SQS, which is a fully managed message queuing service for microservices, distributed systems, and serverless applications. The example that follows uses a standard SQS queue, which acts as a load balancer for the microservices. See Using AWS Lambda with Amazon SQS to learn more. Figure 2 that follows illustrates the architecture for the friend microservice.

Figure 2: Friend microservice architecture

In the remainder of this post, the player who performs each action is Player A, and the other player is Player B. For instance, the player who sends a friend request is Player A, and the player who receives the request is Player B.

DynamoDB Streams and event filtering

DynamoDB Streams captures a time-ordered sequence of item-level modifications in a DynamoDB table and durably stores the information for up to 24 hours. DynamoDB Streams sends a series of records from a DynamoDB table in near-real time, each containing an item change.

The following example uses the AWS Cloud Development Kit (AWS CDK) for Typescript to set up the infrastructure. The AWS CDK lets you define cloud infrastructure as code and provision it through AWS CloudFormation. All code is available in the GitHub repository.

To prepare your data, define a friend table and enable DynamoDB Streams with StreamViewType set to NEW_AND_OLD_IMAGES.

const friendTable = new Table(this, “Friend”, {
tableName: friendTableName,

// once StreamViewType is defined you have DynamoDB Streams active
stream: StreamViewType.NEW_AND_OLD_IMAGES,
});

Consider the following request. Because Player A initiates the request, you first update Player A’s state. Here, you’re creating a new item with Player A’s ID in player_id, Player B’s ID in friend_id, and Requested in the state attribute. From this action, you receive an event from DynamoDB Streams that looks like the following:

{
“eventID”: “380271641178688a170beb2fabd6c401”,
“eventName”: “INSERT”,
“eventVersion”: “1.1”,
“eventSource”: “aws:dynamodb”,
“awsRegion”: “us-east-1”,
“dynamodb”: {
“ApproximateCreationDateTime”: 1659342437,
“Keys”: {
“friend_id”: {
“S”: “playerB”
},
“player_id”: {
“S”: “playerA”
}
},
“NewImage”: {
“friend_id”: {
“S”: “playerB”
},
“last_updated”: {
“N”: “1659342437340”
},
“player_id”: {
“S”: “playerA”
},
“state”: {
“S”: “Requested”
}
},
“SequenceNumber”: “23469600000000008634076814”,
“SizeBytes”: 98,
“StreamViewType”: “NEW_AND_OLD_IMAGES”
},
“eventSourceARN”: “arn:aws:dynamodb:us-east-1:123456789012:table/Friend/stream/2022-07-27T07:25:17.774”
}

To complete the request, you receive the preceding event from DynamoDB Streams through a Lambda handler, then create a new item for Player B with the necessary attributes. A simple way to filter the events is to run the filtering logic inside the Lambda function. However, that incurs extra cost because you’re charged for each Lambda invocation from DynamoDB Streams. A better approach is to use event filtering to narrow the event down to only the needed parameters before invoking the Lambda function. In the following code, you define the filter for the request inside your AWS CDK project and then use event filtering to reduce the number of times you invoke DynamoDB streams, as shown in the following code.

// define props for event source used by multiple handlers
const streamEventSourceProps: StreamEventSourceProps = {
startingPosition: StartingPosition.LATEST,
batchSize: 5,
retryAttempts: 1,
onFailure: stateHandlerDLQ,
reportBatchItemFailures: true,
};

// attach event source to handler that updates player B for Request action
requestStateHandler.addEventSource(
new DynamoEventSource(friendTable, {
// define filters here
filters: [
FilterCriteria.filter({
eventName: FilterRule.isEqual(“INSERT”),
dynamodb: {
NewImage: {
state: { S: FilterRule.isEqual(State.Requested) },
},
},
}),
],
…streamEventSourceProps,
})
);

This filter is used to initiate the requestStateHandler Lambda function only when eventName is INSERT and the attribute state is Requested. No matter how many times Player A sends the friend request to the same Player B, the service updates Player B’s data only the first time the request is sent. This provides you idempotency within the flow, so you can use a standard SQS queue at the front of the flow to support a nearly unlimited number of API calls per second, per API action to make this microservices event more scalable.

Note: You can use a dead letter queue (DLQ) with DynamoDB Streams to support retries of functions that aren’t successful. The sample project in GitHub includes a detailed implementation of a DLQ. If a function initiated by DynamoDB Streams fails, a DLQ can be used to preserve all events as metadata with a SequenceNumber.

Understanding asynchronous flows

Up to this point, you’ve seen how to use event filtering with DynamoDB Streams to reduce the number of transactions while keeping idempotence. The next step is to examine some characteristics of asynchronous flows as demonstrated by edge cases that you might need to address. For this example, consider an asynchronous process to update the receiving player, Player B.

Unfriend

Unfriend is when both players, Player A and Player B, are friends and Player A sends an unfriend request to remove the relationship, as shown in Figure 3 that follows.

Figure 3: Player A unfriends Player B and the relationship is removed

When Player A sends an unfriend request, the front handler Lambda function first deletes Player A’s relationship data relative to Player B from DynamoDB, which initiates a stream. From the stream, the state handler is invoked to delete Player B’s relationship data relative to Player A, as shown in Figure 4 that follows.

Figure 4: The sequence to unfriend Player A and Player B

When deleting Player B’s relationship data relative to Player A, you can use ConditionExpression to make sure that the current state is Friends before you delete the connection data. The code to check and then delete the relationship looks like the following:

const rejectParam: DocumentClient.DeleteItemInput = {
TableName: friendTableName,
// PK is player B、SK is player A
Key: {
player_id: playerBId,
friend_id: playerAId,
},
// if state=Friends, then Delete
ConditionExpression: “#state = :friends”,
ExpressionAttributeNames: {
“#state”: “state”,
},
ExpressionAttributeValues: {
“:friends”: Friends,
},
};

There are a couple edge cases that can cause the condition operation to fail. One is when Player B has already sent a similar request to Player A, as shown in Figure 5 that follows.

Figure 5: Player A and Player B both send an unfriend request

Another is when the state handler runs more than twice due to unexpected retries caused by failures in the Lambda function, as shown in Figure 6 that follows.

Figure 6: Lambda function runs multiple times, causing a failure.

In both scenarios, the conditional delete failed because of a race condition. Usually, you have to resolve race conditions, however, you can ignore ConditionalCheckFailedException for this edge case. The exception handling code for both scenarios is as follows:

const rejectParam: DocumentClient.DeleteItemInput = {
TableName: friendTableName,

// parameters from above example
};
try {
await db.delete(rejectParam).promise();
} catch (e: any) {
if (e.name == “ConditionalCheckFailedException”) {
// gracefully ignore the exception
return;
}
throw e;
}

The reject and accept actions also have conditional exceptions due to race conditions that can be ignored. This leaves you with one last action to consider.

Request

Before Player A sends a friend request, there should be no relationship data between the players. Once Player A sends the request action, Player A’s friend state relative to Player B is created with a value of requested and Player B’s state is created with a value of pending, as shown in Figure 7 that follows.

Figure 7: Players’ states are created as requested and pending when a friend request is sent

After Player A sends a friend request to Player B, the front handler creates Player A’s relationship data relative to Player B in DynamoDB and a stream is initiated. From the stream, the state handler is invoked and creates Player B’s relationship data, as shown in Figure 8 that follows.

Figure 8: Player A sends a friend request, initiating the creation of relationship data for Player A and Player B

Check to see if Player B has relationship data relative to Player A. If not, the function creates a new entry of Pending for Player B. See the following code:

const friendParam: DocumentClient.PutItemInput = {
TableName: friendTableName,
Item: {
player_id: playerBId,
friend_id: playerAId,
state: Pending,
last_updated: timeStamp,
},
// the condition here is no item under player B
ConditionExpression: `attribute_not_exists(player_id)`,
};

If Player B has already sent a friend request to Player A, the condition expression fails. If this happens, you cannot ignore the conditional exception like you can other race condition cases, because both Player A and Player B could have the state Requested and be waiting for a reply, as shown in Figure 9 that follows.

Figure 9: Player A and Player B have sent a friend request to each other

To solve this edge case, you must align the players. One method is to use a transaction write to update both players’ states after detecting the ConditionalCheckFailedException. Transactions are powerful as they can be used to reduce complexity, especially when more than two items that rely on each other can be modified at the same time. By using transactions, when players send friend requests to each other, the requests can be processed as accepted without any additional action by the players, as shown in Figure 10 that follows.

Figure 10: Overlapping friend requests are automatically accepted

The following is an example of the code using transactions:

const updateReceiverParams: DocumentClient.TransactWriteItem = {
Update: {
TableName: friendTableName,
Key: {
player_id: playerAId,
friend_id: playerBId,
},
// player A has also sent request, so state=Requested
ConditionExpression: “#state = :requested”,
// since both players are requesting, change both state to Friends
UpdateExpression: “SET #state = :friends, #last_updated = :last_updated”,
ExpressionAttributeNames: {
“#state”: “state”,
“#last_updated”: “last_updated”,
},
ExpressionAttributeValues: {
“:requested”: Requested,
“:friends”: Friends,
“:last_updated”: timeStamp,
},
},
};

const updateRequesterParam: DocumentClient.TransactWriteItem = {
Update: {
TableName: friendTableName,
Key: {
player_id: playerBId,
friend_id: playerAId,
},
// player B has also sent request, so state=Requested
ConditionExpression: “#state = :requested”,
// since both players are requesting, change both state to Friends
UpdateExpression: “SET #state = :friends, #last_updated = :last_updated”,
ExpressionAttributeNames: {
“#state”: “state”,
“#last_updated”: “last_updated”,
},
ExpressionAttributeValues: {
“:requested”: Requested,
“:friends”: Friends,
“:last_updated”: timeStamp,
},
},
};

There are two additional scenarios that can cause this transaction to fail. One is when there’s a transaction conflict, meaning there are multiple transactions pending for the same item. The other is if state = Requested is false. The former case can be solved by retries and the latter case can be ignored for the same reasons as similar race conditions mentioned previously.

When moving into the asynchronous world, there will be some edge cases that you must plan for and manage. However, DynamoDB features such as ConditionExpression can reduce these cases and enable you to solve complex backend problems in your game apps.

Conclusion

In this post, you learned some ways that you can use DynamoDB Streams and event filtering to construct scalable, asynchronous microservices to support players’ friend relationships in games. The example on GitHub includes a read handler for retrieving basic data from the friend management service. Deploy the solution and test it by feeding messages into the SQS queue to see how scalable the backend service is and how serverless AWS services can expand your games’ possibilities. To learn about DynamoDB design patterns to build gaming applications, see Gaming use case and design patterns. To learn more about event filtering, see to Filtering event sources for AWS Lambda functions.

About the Author

Takahiro Ishii is a Senior Game Developer Relations at Amazon Web Services.

Read MoreAWS Database Blog

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments