Using DynamoDB Transactions with the AWS SDK for Rust

Learn how to use DynamoDB transactions with the AWS SDK for Rust to ensure strong consistency across multiple table actions with StratusGrid's guide.

Subscribe

Subscribe

Although Amazon DynamoDB is a NoSQL (Not only SQL) database engine, it still supports the concept of transactions. In short, a transaction allows you to either retrieve or write an array of items all at once; if any one of the requested writes in a given transaction fails, then all of the other writes will also fail.

There are two types of transactions: get and write. DynamoDB transactions support up to 4 megabyte payloads, and up to 100 get or write item actions per request.

DynamoDB also supports batch APIs, which allow reading or writing multiple items simultaneously. However, batch APIs are limited to 25 actions per request. More importantly, batch APIs do not guarantee that all specified actions will be committed together aka. “all or nothing.” AWS recommends using batch operations for bulk data uploads, and transactions when you need strong consistency across all actions.

Prerequisites

Before following through on this article, you’ll want to have familiarity with using Rust with the AWS SDK. Check out this article or this video for an introduction on that topic. You can also check out the intro video to DynamoDB with Rust.

Transaction Item Targeting

Something that’s unique about transactions is that they can span multiple DynamoDB tables, in a single request. In fact, each get or write action in a transaction can target a different table. However, a single transaction request can only target tables that exist in the same AWS account and region. Each item in a transaction can only be targeted by a single action; for example, you cannot have a get action that targets the same item as a delete action.

Each DynamoDB table has its own schema. The schema consists of the item’s primary key: either a partition key alone, or partition and sort keys (composite primary key). In order to get or write an item with DynamoDB transactions, the primary key of the item must be specified for each action.

In the diagram below, we look at a contrived example of a DynamoDB write transaction. Imagine that you have a retail website that allows new customers to register an account at the same time that they place their first order. In this scenario, we would only want the orders item to be created if the customers item is also created, and vice versa. Also, we might need to perform a check to validate that the ordered product actually exists in the product table. We’ll explore check conditions in more depth later on.

Using DynamoDB Transactions with the AWS SDK for Rust

When a matching item isn’t found for a given get action, that doesn’t mean that the entire transaction request has failed. Rather, any matching items specified by other actions will be returned from the request.

DynamoDB Get Transactions with Rust

Let’s start by taking a look at how to invoke a get transaction against a DynamoDB table, using the AWS SDK for Rust. There are a couple of data structures that you’ll want to be familiar with, in the aws-sdk-dynamodb crate. I’ll refer to this crate as ddb below; this crate has several modules that you can drill into. For any of the service-specific AWS SDK crates, including DynamoDB, the types module contains the data structures used to build API calls and represent the responses.

For both of these data structures, and others in the types module, we can use the builder pattern to construct an instance of the struct. Typically we use the default() method to construct a new instance, and then call other struct-specific methods to configure the struct fields.

Define Get Action Details

Let’s start by defining the Get structs, and then wrap them in the top-level TransactGetItem struct. For the sake of this example, we will attempt to retrieve two items from two different DynamoDB tables, which have different schemas. You can reference the table below for the schema examples. Bear in mind that these may not be practical schemas for production applications.

Table

Attribute

Attribute Type

Key Schema Type

trevor-products

p_category

String

Partition

trevor-products

p_name

String

Sort

sg-people

last_name

String

Partition

sg-people

first_name

String

Sort

use aws_sdk_dynamodb as ddb;




let get1 = ddb::types::Get::builder()

  .table_name("trevor-products")

  .key("p_category", ddb::types::AttributeValue::S("kitchen".to_string()))

  .key("p_name", ddb::types::AttributeValue::S("fork".to_string()))

  .build().unwrap();




let get2 = ddb::types::Get::builder()

  .table_name("sg-people")

  .key("last_name", ddb::types::AttributeValue::S("Chris".to_string()))

  .key("first_name", ddb::types::AttributeValue::S("Hurst".to_string()))

  .build().unwrap();




let req1 = ddb::types::TransactGetItem::builder().get(get1).build();

let req2 = ddb::types::TransactGetItem::builder().get(get2).build();

As you can see, each of the Get structs defines the DynamoDB table that the item will be retrieved from, using the table_name() function. Then the unique values for the partition key and sort key are specified, using the key() function, along with the DynamoDB AttributeValue enum type.

Finally, we call build() to construct the instance and unwrap() it from the Result<T, E> type, a common pattern in Rust development.

Invoke the REST API Request

If you explore the DynamoDB TransactGetItems REST API documentation, you can see the core structure of the request that we’re building, using the Rust crate. On the DynamoDB client, we use the transact_get_items() method to start building this API call. This will return a TransactGetItemsFluentBuilder struct, which has methods to specify the input parameters to the API call.

Since the only root-level parameter we need to specify is the TransactItems property, we’ll use the transact_items() method to add items to the builder. Each time you call transact_items(), it will append another TransactGetItem struct to the request.

Since all the DynamoDB details have been captured in the previous section, all we have to do is append these get actions to the request, and send it off. Remember, you can add up to 100 actions to the request.

let sdk_config = aws_config::load_from_env();

let ddb_client = ddb::Client::new(&sdk_config);
let transact_result = ddb_client.transact_get_items()

  .transact_items(req1)

  .transact_items(req2)

  .send().await;

Once you’ve retrieved the results from your DynamoDB tables, you can iterate over them, similar to queries and scans. However, the resulting data structure is a bit different because we’re retrieving items across multiple tables via transactions.

After calling unwrap() on the Result<T, E>, you can drill into the responses field, and unwrap() that from its surrounding Option type. That’ll give you access to a Vec<ItemResponse>, which you can iterate over.

In the code below, the ddb_item variable will be assigned to each ItemResponse in the Vec. You’ll need to access the item field in order to access the attributes on the item as a HashMap. We can use the get() function to retrieve a specific attribute by name, or you can iterate over the HashMap keys() to get all of the attributes in the response. 

for ddb_item in get_result.unwrap().responses.unwrap() {

    match ddb_item.item {

        Some(item) => {

            println!("{0}", item.get("p_name").unwrap().as_s().unwrap());

        }

        None => { }

    }

}

Remember that each item may have different attributes, depending on which table it was retrieved from. Hard-coding attribute names in your item processing code is not recommended except for experimentation purposes. If you’re unfamiliar with the match expression in Rust, you can read about it here.

DynamoDB Write Transactions with Rust

In addition to get transactions, you can also write (create, update, delete) items in DynamoDB tables with transactions. These operations are ultimately calling the TransactWriteItems REST API for DynamoDB. Write requests are more complex than get requests, because you have more options available. 

There are 4 different actions you can add to each write transaction in DynamoDB.

  • Put - write a new item to a table
  • Delete - delete an item from a table
  • Update - update or add attribute values on an existing item
  • CheckCondition - reads an item and performs comparisons before committing the transaction

Put Operation

Let’s start by looking at a simple put operation that writes a new item to a table, using a transaction. Similar to the get transaction, we have two main data types to focus on.

  • ddb::types::Put - defines the table and item attributes
  • ddb::types::TransactWriteItem - wraps the put operation details

We use the builder pattern, common across the entire AWS SDK for Rust, to construct these structs. We start with the Put struct, specifying the DynamoDB table name and item attributes as key-value pairs. Then we create a TransactWriteItem struct and add the Put struct into it, using the put() method.

Finally, we use the DynamoDB client to build a write transaction, using the transact_write_items() method. Then we add the TransactWriteItem to the array of (up to 100) transact items, by using the transact_items() method. Finally, we use the common send() function to create a Rust Future, and use the Rust await operator to invoke the Future and return the result.

let put01 = ddb::types::Put::builder()

    .item("p_category", ddb::types::AttributeValue::S("kitchen".to_string()))

    .item("p_name", ddb::types::AttributeValue::S("butter-knife".to_string()))

    .table_name("trevor-products")

    .build().unwrap();

let write01 = TransactWriteItem::builder()

    .put(put01)

    .build();

let write_result = ddb_client.transact_write_items()

    .transact_items(write01)

    .send().await;

The write_result variable will contain the response from the service, wrapped in the Result<T, E> type. For now, after running this code, you should be able to verify that a new item has been written by exploring the table items in the AWS Management Console.

I’ll leave you to explore the delete and update operations on your own. They are more or less similar to the put operation.

Condition Check

Aside from put, update, and delete, the other supported action in write transactions is called a condition check. These allow you to inspect a DynamoDB item and perform comparisons on its attributes. If the check returns a false result, then the entire transaction will fail.

The new data structure that you’ll want to familiarize yourself with here is the ConditionCheck. Similar to get or put actions that we saw before, this ConditionCheck allows you to specify the table, and the partition & sort key attributes for the item you’re targeting.

What’s different here is that we specify a condition expression. This is the expression that must be evaluated to be true for the transaction to be committed. It uses similar syntax to the key condition expression that we looked at in query operations or filter expressions from table scans.

You can use expression attribute names and values to substitute placeholders in the condition expression with actual values. Although in some cases this is not strictly necessary, it’s generally advisable to use this approach as DynamoDB expressions can be quirky.

Let’s look at an example.

In plain English, we intend to apply a condition check that “allows the transaction if the kitchen (partition key), fork (sort key) item exists in the trevor-products table.”

let condition01 = ConditionCheck::builder()

    .table_name("trevor-products")

    .key("p_category", ddb::types::AttributeValue::S("kitchen".to_string()))

    .key("p_name", ddb::types::AttributeValue::S("fork".to_string()))

    .condition_expression("attribute_exists(#attr)")

    .expression_attribute_names("#attr", "p_category")

    .build().unwrap();

In the above code sample, you can see that we instantiate the ConditionCheck struct using the common builder pattern. Then, we call several methods like table_name(), key(), condition_expression(), and expression_attribute_names(), to configure the condition check. In this example, we’re using the attribute_exists() function to check for the existence of the p_category attribute, which coincidentally is the partition key for the table as well.

let t_item = TransactWriteItem::builder()

    .condition_check(condition01)

    .build();

After the ConditionCheck is constructed, we wrap it in the TransactWriteItem struct. Remember that this struct is used to wrap any of the supported actions for write transactions, just like the put action we saw in the previous section.


let write_result = ddb_client.transact_write_items()

    .transact_items(write01)

    .transact_items(t_item)

    .send().await;

Finally, we build and send the request with the DynamoDB client’s transact_write_items() method, just as with the put action.

Potential Errors

These are some error messages you might receive from the Amazon DynamoDB service when you’re working with transaction APIs.

Error #1: Transaction canceled, please refer to cancellation reasons for specific reasons.

This is a generic error message that can reference more specific error messages during a DynamoDB transaction. See the following error messages for more potential issues.

Error #2: The provided key element does not match the schema.

Resolution: This error can occur if you attempt to specify a key in a transaction that doesn’t align to either the partition or sort key attribute names. When you’re configuring your get or write actions, make sure that the configured keys align with the partition and sort keys for your table schema.

Error #3: Transaction requests cannot include multiple operations on one item.

Resolution: In DynamoDB transactions, only a single action can be configured to affect a given DynamoDB item. Make sure that you don’t have multiple actions in your transaction that point to the same DynamoDB item. For example, if a transaction has a delete action for an item, you cannot also have an update action for the same item.

Error #4: Validation error detected: 'transactItems' failed to satisfy constraint - Member must have a length less than or equal to 100.

Resolution: This error can appear if you attempt to specify more than 100 actions in a DynamoDB get or write transaction. Make sure that the number of transaction items does not exceed 100, before sending your request.

Error #5: Requested resource not found.

Resolution: You may receive this error message if you run a DynamoDB get transaction against a table that does not exist. Make sure that the tables referenced by the transaction exist, or remove the invalid request from the transaction.

Using DynamoDB Transactions with the AWS SDK for Rust

DynamoDB transactions are a powerful feature that helps you manage data across many different tables in a strongly consistent manner. Transactions work for both reads and writes, and support larger payloads than GetItem, PutItem, Query, and Scan. You can use transactions from any of the supported AWS SDKs for Rust, C#, Java, and other programming languages.

Check out these resources for more information about DynamoDB transactions.

Get Expert Assistance on DynamoDB Transactions with StratusGrid

Navigating through DynamoDB transactions using the AWS SDK for Rust can be difficult. StratusGrid is here to provide expert guidance and support to help you leverage DynamoDB transactions effectively. Whether you're dealing with get-or-write transactions, facing challenges with batch APIs, or ensuring strong consistency across your database actions, our team is ready to assist you. 

Reach out to StratusGrid for any questions or support you need now!

 

stratusphere by stratusgrid 1

 

Similar posts