2

I am using C# and DynamoDb in my application. My design suppose Write-Read only. Strongly forbidden to update items. Only insert new item or read existing.
Lets say I have status of payment item

{
  "PaymentInvoice":"001", //PK
  "Status":"2019-07-10T00:00:00#Approved" //SK
}

Now 2 concurrent request came from different client: 1st one tries to Cancel payment, 2nd tries to Settle(confirm) payment.

2 inserts are :

{
  "PaymentInvoice":"001", //PK
  "Status":"2019-07-10T00:01:00#Cancel" //SK
}

and

{
  "PaymentInvoice":"001", //PK
  "Status":"2019-07-10T00:01:00#Settle" //SK
}

So here is race condition.
- You cannot settle payment if it was canceled
- You cannot cancel payment if in already settled

Obvious solution is:
1) create transaction
2) make Query first and then check if is it possible to insert due to business rules or not
3) insert new item

So the questions are: 1) is it possible to lock entire partition to prevent insert new items from other client? 2) are there any build-in options like conditional update, but for inserting items

StNickolas
  • 576
  • 7
  • 20

1 Answers1

4

Quick point of clarification before I begin. I am assuming a distributed, service-oriented architecture, and I assume that all reads and writes to this DynamoDB table occurs only through one service. I will use "application" to refer to the software that you are building that accesses the table and "client" to refer to anything at all that is a client of your application.


Are there any built-in options like conditional update, but for inserting items?

The short answer is "yes", using optimistic locking based on version numbers.

You need to start by changing your sort key to an sequential event number. This will be the attribute for versioning your items, which uses conditional updates, and incurs the least amount of additional overhead of any solution to your problem.

Let's start by looking at some sample data for the proposed schema. I've take the liberty to add some more status types.

invoiceId | eventNo | eventStatus |      datetime
----------|---------|-------------|---------------------
      111 |       0 | created     | 2019-07-11T00:01:00
      111 |       1 | approved    | 2019-07-11T00:02:00
      111 |       2 | modified    | 2019-07-12T00:03:00
      111 |       3 | approved    | 2019-07-12T00:04:00
      111 |       4 | settled     | 2019-07-13T00:05:00

The general idea of optimistic locking is that you read the current state, and then update the state by inserting a new record with an incremented eventNo (equivalent to version in the AWS docs) with the condition that the eventNo is not already present for that invoiceId. The reason this works is because when you read the existing state, you always know what the next eventNo should be (unlike using timestamps for the sort key).

To make this more concrete, on 2019-07-13, when the client sends a request to settle the invoice, your application reads the latest state, sees that the eventNo is 3 and that status is "approved", and so it submits an UpdateItem request to DynamoDB that (translated to plain english) says

insert a status update with invoiceId=111 and eventNo=4 only if no other status update already exists with invoiceId=111 and eventNo=4

If two clients were to try to update the state at the same time, only one of the UpdateItem requests would succeed, and the other one would return a ConditionalCheckFailedException.

Okay, so how do I code this?

I have not used C# in more than a decade, so please forgive any syntax or formatting errors that may be present.

AmazonDynamoDBClient client = new AmazonDynamoDBClient();

// These should be method parameters/args, but I'm directly assigning them to 
// keep this code sample simple.
var invoiceToUpdate = 123;
var invoiceNewState = "settled";

// Here's the useful part of the sample code

// First we make a query to get the current state
var queryRequest = new QueryRequest
{
    TableName = "Invoices",
    KeyConditionExpression = "invoiceId = :inv",
    ExpressionAttributeValues = new Dictionary<string, AttributeValue> {
        {":inv", new AttributeValue {N = invoiceIdToUpdate.toString() }}
    },

    // This assumes we only need to check the current state and not any of the historical
    // state, so we'll limit the query to return only one result.
    Limit = 1,

    // If we're limiting it to only one result, change the sort order to make sure we get
    // the result with the largest eventNo (and therefore the most recent state).
    ScanIndexForward = false,

    // This is not strictly necessary for correctness because of the condition expression
    // in the PutItem request, but including it will help reduce the likelihood of getting
    // a ConditionalCheckFailedException later on.
    ConsistentRead = true
};

var queryResponse = client.Query(queryRequest);

// Check to see if there is any previous record for this invoice
// Setup the default values if the query returned no results
int newEventNo = 0;
string invoiceCurrentState = null;
if (queryResponse.Items.Count > 0) {{
    // If there is any existing record, then increment the eventNo for the new record
    var latestRecord = queryResponse.QueryResult().Items[0];
    newEventNo = Convert.ToInt32(latestRecord["eventNo"]) + 1;
    invoiceCurrentState = latestRecord["eventStatus"];
}

var isValidChange = MyBusinessLogic.isValidChange(invoiceCurrentState, invoiceNewState);

if (isValidChange) {
    var putItemRequest = new PutItemRequest
    {
        TableName = "Invoices",
        Item = new Dictionary<string,AttributeValue>() { 
            { "invoiceId", new AttributeValue {N = invoiceIdToUpdate.toString() }},
            { "eventNo", new AttributeValue {N = newEventNo.toString()}},
            { "eventStatus", new AttributeValue {S = invoiceNewState}},
            { "datetime", new AttributeValue {S = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") }}
        },

        // Every item must have the key attributes, so using 'attribute_not_exists'
        // on a key attribute is functionally equivalent to an "item_not_exists" 
        // condition, causing the PUT to fail if it would overwrite anything at all.
        ConditionExpression = "attribute_not_exists('invoiceId')"
    };

    try {
        var putItemResponse = client.PutItem(putItemRequest);

    } catch (ConditionalCheckFailedException ex) {
        // How you handle this is up to you. I recommend choosing one of these options: 
        // (1) Throw an exception with a more useful message explaining that the state changed while the
        //     request was being processed
        // (2) Automatically try again, starting with the query and including the business validations,
        //     and if the state change is still valid, submit a new PutItem request with the new eventNo.
    }

    // Return an acknowledgement to the client

} else {
    throw new System.InvalidOperationException("Update is not valid for the current status of the invoice.");
}

Here's some of the relevant documentation for the code sample I've given.


Is it possible to lock entire partition to prevent insert new items from other client?

Yes, but it's not a lock that's implemented in the database. The locking has to occur in your application using a separate locking library that incurs additional overhead, so you should not use this approach unless you have no other alternative. For anyone reading the question who cannot change the schema of their table, you can set up your application(s) to use DynamoDB lock client to lock on the partition key, read the current state, perform the write (if allowed), and then release the lock.

Matthew Pope
  • 7,212
  • 1
  • 28
  • 49
  • Very good explanation. The only one question for such solution - this Sort Key is also a partition key for one of GSIs, so if I put 1,2,3 or something, dynamo will put all statuses from all transactions for that partitions. So it will be huge partition. Is it okay to have them like this? unless I query by this partition. Is that okay if I then combine this sort key like InvoiceNumber+Sequence and in application level I will basically extract that sequence and increase its number? – StNickolas Jul 22 '19 at 06:09
  • You can use whatever keys you want for a GSI. Don’t change the key attributes for the main table, though. – Matthew Pope Jul 22 '19 at 09:13
  • Also, having a large partition isn't really something you need to be worried about anymore. – Matthew Pope Jul 22 '19 at 21:49