luizmachado.dev

PT EN

Session 034 — DynamoDB: transactions and conditional operations

Dependencies: session-033-dax-arquitetura-casos-uso


Objective

By the end of this session, you will be able to implement a TransactWriteItems that atomically updates multiple entities, use ConditionExpression for optimistic locking with a version counter, understand transaction limits (100 items, 4 MB, 2× cost), and correctly distinguish the isolation levels of each operation.


Context

[FACT] DynamoDB has offered native support for ACID transactions since November 2018. TransactWriteItems and TransactGetItems provide atomicity and serializable isolation for operations that need consistency across multiple items — without the need for client-side coordination.

[CONSENSUS] The 2× cost of transactions (each item requires two internal operations: prepare + commit) is frequently misunderstood. For multi-item transactions, this cost can be economically superior to attempting application-side consistency with retries and manual rollbacks.


Key concepts

1. TransactWriteItems: available actions and limits

[FACT] TransactWriteItems groups up to 100 write actions into a single all-or-nothing operation. The allowed actions are:

Ação            Equivalente         Uso típico
─────────────────────────────────────────────────────────────
Put             PutItem             Criar ou substituir um item
Update          UpdateItem          Modificar atributos
Delete          DeleteItem          Remover um item
ConditionCheck  (sem equivalente)   Verificar condição sem escrita
                                    (ex: verificar se saldo suficiente
                                    antes de debitar em outro item)

[FACT] Hard limits:
- Maximum 100 distinct items per transaction
- Aggregate item size: maximum 4 MB
- Only within the same region and same AWS account
- Cannot target the same item twice in the same transaction (e.g., ConditionCheck + Update on the same item → validation error)

[FACT] Transactions do not work via indexes — actions must always reference the base table via PK (+ SK if it exists).


2. 2× cost and how to calculate it

[FACT] Each item in a transaction consumes 2× the normal cost of write or read, because DynamoDB executes two internal phases (prepare and commit):

Exemplo: TransactWriteItems com 3 itens de 500 bytes cada

Cálculo normal (sem transação):
  Item 1: ceil(500B / 1KB) = 1 WCU
  Item 2: 1 WCU
  Item 3: 1 WCU
  Total: 3 WCU

Com transação (2× por item):
  Item 1: 1 WCU × 2 = 2 WCU
  Item 2: 1 WCU × 2 = 2 WCU
  Item 3: 1 WCU × 2 = 2 WCU
  Total: 6 WCU

Com transação via DAX (extra RCU para popular cache):
  6 WCU (escrita) + 6 RCU (TransactGetItems pós-escrita)
  Total: 6 WCU + 6 RCU

[FACT] The 2× cost appears in CloudWatch as ConsumedWriteCapacityUnits — the two internal operations (prepare and commit) are visible separately in the metrics.


3. Isolation: levels by operation table

[FACT] The isolation levels between transactions and other operation types are:

Operação concorrente                  Nível de isolamento
──────────────────────────────────────────────────────────────
PutItem / UpdateItem / DeleteItem     SERIALIZABLE
GetItem (individual)                  SERIALIZABLE
TransactWriteItems / TransactGetItems SERIALIZABLE (entre si)
BatchGetItem (unidade)                READ-COMMITTED *
BatchWriteItem (unidade)              NÃO serializável *
Query / Scan                          READ-COMMITTED

* individual items within batch/query = serializable;
  the batch/query as a unit = read-committed

[FACT] Serializable: the result is equivalent to executing the operations one after another, without overlap. Read-committed: the read returns only values from already-completed transactions, but may see different versions of different items if a transaction completed between reads.

[FACT] After a successful TransactWriteItems, subsequent eventually consistent reads may still return the previous state for a short period. To guarantee the most recent value immediately after the transaction, use ConsistentRead=True via the DynamoDB client (not DAX).


4. Idempotency with ClientRequestToken

[FACT] TransactWriteItems accepts an optional ClientRequestToken to guarantee idempotency in case of retries due to timeouts or connectivity issues:

Janela de idempotência: 10 minutos
  ├── Mesmo token, mesmos parâmetros → sucesso, sem repetição da escrita
  └── Mesmo token, parâmetros diferentes → IdempotentParameterMismatch

Após 10 minutos: mesmo token é tratado como nova requisição

[FACT] If ReturnConsumedCapacity is configured: the original call returns consumed WCUs; repeated calls within the window return RCUs (read cost for idempotency verification).


5. ConditionExpression: functions and operators

[FACT] ConditionExpression can be used in PutItem, UpdateItem, DeleteItem, and in the ConditionCheck action within transactions. If the condition fails, the operation returns ConditionalCheckFailedException.

Funções disponíveis:
─────────────────────────────────────────────────────────────
attribute_exists(path)         Verdadeiro se o atributo existe
attribute_not_exists(path)     Verdadeiro se o atributo NÃO existe
attribute_type(path, type)     Verifica tipo: S, N, B, SS, NS, BS,
                               M, L, NULL, BOOL
begins_with(path, substr)      String começa com o valor dado
contains(path, operand)        Set contém elemento / string contém substr
size(path)                     Tamanho do atributo (número de bytes/chars)

Operadores de comparação: =  <>  <  <=  >  >=
Operadores lógicos: AND  OR  NOT
Operador de faixa: BETWEEN :lo AND :hi
Operador de lista: IN (:v1, :v2, ...)

[FACT] attribute_not_exists(PK) in a PutItem is the canonical mechanism for put-if-not-exists — it prevents overwriting an existing item. If the table has PK + SK, the condition checks whether the combination PK+SK does not exist.


6. Optimistic locking with version counter

[FACT] The optimistic locking pattern in DynamoDB uses a version attribute (integer) that is checked before each write and incremented along with the update:

Fluxo do optimistic locking:

1. Client A lê item: {PK: "X", version: 5, data: "abc"}
2. Client B lê item: {PK: "X", version: 5, data: "abc"}
3. Client A faz UpdateItem:
   UpdateExpression:    "SET data = :new, version = version + :inc"
   ConditionExpression: "version = :expected"
   ExpressionAttributeValues: {":new": "xyz", ":inc": 1, ":expected": 5}
   → Sucesso: item agora tem version: 6

4. Client B tenta UpdateItem com version: 5
   ConditionExpression: "version = :expected" (esperava 5, encontrou 6)
   → ConditionalCheckFailedException: Client B deve reler e retentar

[CONSENSUS] Optimistic locking is preferable to pessimistic locking (explicit locks) in DynamoDB because: (a) there is no native lock mechanism; (b) actual conflicts are rare in most workloads; (c) the retry cost is lower than the overhead of lock coordination.


Practical example

Scenario: bank transfer — atomic debit and credit with balance ConditionCheck

Table: accounts-table (single-table design)
Operation: transfer value between two accounts with balance validation, versioning, and atomic auditing

CDK Python — Table and Lambda function

from aws_cdk import (
    Stack, RemovalPolicy,
    aws_dynamodb as dynamodb,
    aws_lambda as lambda_,
    aws_iam as iam,
)
from constructs import Construct


class BankingStack(Stack):
    def __init__(self, scope: Construct, construct_id: str, **kwargs):
        super().__init__(scope, construct_id, **kwargs)

        accounts_table = dynamodb.Table(
            self, "AccountsTable",
            table_name="accounts-table",
            partition_key=dynamodb.Attribute(
                name="PK", type=dynamodb.AttributeType.STRING
            ),
            sort_key=dynamodb.Attribute(
                name="SK", type=dynamodb.AttributeType.STRING
            ),
            billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
            removal_policy=RemovalPolicy.DESTROY,
        )

        transfer_fn = lambda_.Function(
            self, "TransferFn",
            function_name="bank-transfer",
            runtime=lambda_.Runtime.PYTHON_3_12,
            handler="handler.lambda_handler",
            code=lambda_.Code.from_asset("lambda/transfer"),
        )
        accounts_table.grant_read_write_data(transfer_fn)

Python — Complete implementation

# lambda/transfer/handler.py
import uuid
from decimal import Decimal
from datetime import datetime, timezone
import boto3
from boto3.dynamodb.conditions import Attr
from botocore.exceptions import ClientError

dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
table = dynamodb.Table("accounts-table")


# ── Operações de setup (criação de conta) ─────────────────────────────

def create_account(account_id: str, owner: str, initial_balance: Decimal) -> dict:
    """
    PutItem com attribute_not_exists — put-if-not-exists.
    Falha com ConditionalCheckFailedException se a conta já existe.
    """
    try:
        table.put_item(
            Item={
                "PK": f"ACCOUNT#{account_id}",
                "SK": "METADATA",
                "accountId": account_id,
                "owner": owner,
                "balance": initial_balance,
                "version": 0,
                "status": "ACTIVE",
                "createdAt": datetime.now(timezone.utc).isoformat(),
            },
            ConditionExpression="attribute_not_exists(PK)",
        )
        return {"success": True, "accountId": account_id}
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            raise ValueError(f"Account {account_id} already exists")
        raise


def deactivate_account_if_empty(account_id: str) -> None:
    """
    UpdateItem condicional: só desativa se saldo = 0.
    """
    try:
        table.update_item(
            Key={"PK": f"ACCOUNT#{account_id}", "SK": "METADATA"},
            UpdateExpression="SET #s = :inactive, updatedAt = :ts",
            ConditionExpression="balance = :zero AND #s = :active",
            ExpressionAttributeNames={"#s": "status"},  # "status" é palavra reservada
            ExpressionAttributeValues={
                ":inactive": "INACTIVE",
                ":active": "ACTIVE",
                ":zero": Decimal("0"),
                ":ts": datetime.now(timezone.utc).isoformat(),
            },
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            raise ValueError("Cannot deactivate: account not active or balance not zero")
        raise


# ── Transferência atômica com TransactWriteItems ──────────────────────

def transfer(
    from_account_id: str,
    to_account_id: str,
    amount: Decimal,
    from_version: int,
    to_version: int,
    idempotency_key: str | None = None,
) -> dict:
    """
    Transferência atômica com:
    - ConditionCheck: valida que from_account tem saldo suficiente e está ATIVA
    - Update em from_account: decrementa saldo + incrementa version
    - Update em to_account: incrementa saldo + incrementa version
    - Put de registro de auditoria (transação imutável)

    from_version / to_version: versões lidas previamente (optimistic locking)
    idempotency_key: opcional, garante idempotência por 10 minutos
    """
    if amount <= 0:
        raise ValueError("Amount must be positive")

    transfer_id = str(uuid.uuid4())
    ts = datetime.now(timezone.utc).isoformat()

    try:
        kwargs = {
            "TransactItems": [
                # 1. ConditionCheck: saldo suficiente + conta ativa + versão correta
                {
                    "ConditionCheck": {
                        "TableName": "accounts-table",
                        "Key": {
                            "PK": {"S": f"ACCOUNT#{from_account_id}"},
                            "SK": {"S": "METADATA"},
                        },
                        "ConditionExpression": (
                            "balance >= :amount "
                            "AND #s = :active "
                            "AND version = :from_v"
                        ),
                        "ExpressionAttributeNames": {"#s": "status"},
                        "ExpressionAttributeValues": {
                            ":amount": {"N": str(amount)},
                            ":active": {"S": "ACTIVE"},
                            ":from_v": {"N": str(from_version)},
                        },
                    }
                },
                # 2. Update: débito na conta origem
                {
                    "Update": {
                        "TableName": "accounts-table",
                        "Key": {
                            "PK": {"S": f"ACCOUNT#{from_account_id}"},
                            "SK": {"S": "METADATA"},
                        },
                        "UpdateExpression": (
                            "SET balance = balance - :amount, "
                            "version = version + :inc, "
                            "updatedAt = :ts"
                        ),
                        "ExpressionAttributeValues": {
                            ":amount": {"N": str(amount)},
                            ":inc": {"N": "1"},
                            ":ts": {"S": ts},
                        },
                    }
                },
                # 3. Update: crédito na conta destino (com versioning)
                {
                    "Update": {
                        "TableName": "accounts-table",
                        "Key": {
                            "PK": {"S": f"ACCOUNT#{to_account_id}"},
                            "SK": {"S": "METADATA"},
                        },
                        "UpdateExpression": (
                            "SET balance = balance + :amount, "
                            "version = version + :inc, "
                            "updatedAt = :ts"
                        ),
                        "ConditionExpression": "version = :to_v",
                        "ExpressionAttributeValues": {
                            ":amount": {"N": str(amount)},
                            ":inc": {"N": "1"},
                            ":to_v": {"N": str(to_version)},
                            ":ts": {"S": ts},
                        },
                    }
                },
                # 4. Put: registro imutável de auditoria
                {
                    "Put": {
                        "TableName": "accounts-table",
                        "Key": {
                            "PK": {"S": f"TRANSFER#{transfer_id}"},
                            "SK": {"S": "RECORD"},
                        },
                        "Item": {
                            "PK": {"S": f"TRANSFER#{transfer_id}"},
                            "SK": {"S": "RECORD"},
                            "fromAccount": {"S": from_account_id},
                            "toAccount": {"S": to_account_id},
                            "amount": {"N": str(amount)},
                            "createdAt": {"S": ts},
                            "status": {"S": "COMPLETED"},
                        },
                        # Garante que transfer_id é único (UUID collision guard)
                        "ConditionExpression": "attribute_not_exists(PK)",
                    }
                },
            ]
        }

        if idempotency_key:
            kwargs["ClientRequestToken"] = idempotency_key

        # Nota: TransactWriteItems via baixo nível (client) não via resource
        client = boto3.client("dynamodb", region_name="us-east-1")
        client.transact_write_items(**kwargs)

        return {
            "success": True,
            "transferId": transfer_id,
            "amount": str(amount),
        }

    except ClientError as e:
        code = e.response["Error"]["Code"]

        if code == "TransactionCanceledException":
            # Extrai os motivos de cancelamento por item
            reasons = e.response.get("CancellationReasons", [])
            failed = [
                {"index": i, "code": r.get("Code"), "message": r.get("Message")}
                for i, r in enumerate(reasons)
                if r.get("Code") != "None"
            ]
            raise ValueError(
                f"Transaction cancelled. Reasons: {failed}"
            ) from e

        if code == "TransactionInProgressException":
            # Conflito com outra transação em andamento no mesmo item
            # SDK retenta automaticamente, mas se chegou aqui, esgotou retries
            raise RuntimeError("Transaction conflict — retry after backoff") from e

        raise


# ── Leitura consistente pós-transação ─────────────────────────────────

def get_account_balance(account_id: str) -> dict:
    """
    TransactGetItems: lê saldo de forma atomicamente consistente.
    Garante que não vemos estado parcial de uma transação em andamento.
    """
    client = boto3.client("dynamodb", region_name="us-east-1")
    response = client.transact_get_items(
        TransactItems=[
            {
                "Get": {
                    "TableName": "accounts-table",
                    "Key": {
                        "PK": {"S": f"ACCOUNT#{account_id}"},
                        "SK": {"S": "METADATA"},
                    },
                    "ProjectionExpression": "balance, version, #s",
                    "ExpressionAttributeNames": {"#s": "status"},
                }
            }
        ]
    )
    item = response["Responses"][0].get("Item")
    if not item:
        raise ValueError(f"Account {account_id} not found")
    return {
        "balance": item["balance"]["N"],
        "version": int(item["version"]["N"]),
        "status": item["status"]["S"],
    }

CLI — Examples of conditional operations and transactions

# 1. PutItem com attribute_not_exists (put-if-not-exists)
aws dynamodb put-item \
  --table-name accounts-table \
  --item '{
    "PK": {"S": "ACCOUNT#acc-001"},
    "SK": {"S": "METADATA"},
    "balance": {"N": "1000"},
    "version": {"N": "0"},
    "status": {"S": "ACTIVE"}
  }' \
  --condition-expression "attribute_not_exists(PK)"

# 2. UpdateItem com optimistic locking (version counter)
aws dynamodb update-item \
  --table-name accounts-table \
  --key '{"PK": {"S": "ACCOUNT#acc-001"}, "SK": {"S": "METADATA"}}' \
  --update-expression "SET balance = balance - :amount, version = version + :inc" \
  --condition-expression "version = :expected AND balance >= :amount AND #s = :active" \
  --expression-attribute-names '{"#s": "status"}' \
  --expression-attribute-values '{
    ":amount": {"N": "200"},
    ":inc": {"N": "1"},
    ":expected": {"N": "0"},
    ":active": {"S": "ACTIVE"}
  }' \
  --return-values ALL_NEW

# 3. TransactWriteItems via CLI (transferência simplificada)
aws dynamodb transact-write-items \
  --transact-items '[
    {
      "Update": {
        "TableName": "accounts-table",
        "Key": {"PK": {"S": "ACCOUNT#acc-001"}, "SK": {"S": "METADATA"}},
        "UpdateExpression": "SET balance = balance - :amt, version = version + :inc",
        "ConditionExpression": "balance >= :amt AND version = :v",
        "ExpressionAttributeValues": {
          ":amt": {"N": "100"},
          ":inc": {"N": "1"},
          ":v": {"N": "1"}
        }
      }
    },
    {
      "Update": {
        "TableName": "accounts-table",
        "Key": {"PK": {"S": "ACCOUNT#acc-002"}, "SK": {"S": "METADATA"}},
        "UpdateExpression": "SET balance = balance + :amt, version = version + :inc",
        "ExpressionAttributeValues": {
          ":amt": {"N": "100"},
          ":inc": {"N": "1"}
        }
      }
    }
  ]' \
  --client-request-token "transfer-$(uuidgen)" \
  --return-consumed-capacity TOTAL

# 4. TransactGetItems — snapshot atômico de múltiplas contas
aws dynamodb transact-get-items \
  --transact-items '[
    {
      "Get": {
        "TableName": "accounts-table",
        "Key": {"PK": {"S": "ACCOUNT#acc-001"}, "SK": {"S": "METADATA"}},
        "ProjectionExpression": "balance, version"
      }
    },
    {
      "Get": {
        "TableName": "accounts-table",
        "Key": {"PK": {"S": "ACCOUNT#acc-002"}, "SK": {"S": "METADATA"}},
        "ProjectionExpression": "balance, version"
      }
    }
  ]'

# 5. Verificar custo de transações no CloudWatch
aws cloudwatch get-metric-statistics \
  --namespace AWS/DynamoDB \
  --metric-name ConsumedWriteCapacityUnits \
  --dimensions Name=TableName,Value=accounts-table \
  --start-time "$(date -u -d '1 hour ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-1H '+%Y-%m-%dT%H:%M:%SZ')" \
  --end-time "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
  --period 300 \
  --statistics Sum

# 6. Verificar conflitos de transação
aws cloudwatch get-metric-statistics \
  --namespace AWS/DynamoDB \
  --metric-name TransactionConflict \
  --dimensions Name=TableName,Value=accounts-table \
  --start-time "$(date -u -d '1 hour ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-1H '+%Y-%m-%dT%H:%M:%SZ')" \
  --end-time "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
  --period 300 \
  --statistics Sum

Common pitfalls

1. Same item twice in the same transaction → validation error
[FACT] TransactWriteItems does not allow two actions on the same item within the same call — neither ConditionCheck + Update, nor two Updates. If you need to check and update the same item, combine the condition directly in the Update with ConditionExpression.

2. Transactions in Global Tables: ACID only in the write region
[FACT] TransactWriteItems guarantees atomicity only in the region where it was invoked. Replication to other regions occurs asynchronously and may show partial state transitionally. Do not assume cross-region consistency with transactions.

3. TransactionCanceledException: extract per-item reasons
[FACT] When a transaction is cancelled, the exception contains CancellationReasons — an array with one entry per item, in the same order as TransactItems. Only the items that failed have a Code different from "None". Without extracting these reasons, it is impossible to know which condition failed.

4. BatchWriteItem is not transactional — common confusion
[FACT] BatchWriteItem is a convenience operation (reduces round-trips), not transactional. Some items may be written successfully while others fail. For all-or-nothing atomicity, use TransactWriteItems. The cost difference: BatchWriteItem = 1× WCU/item; TransactWriteItems = 2× WCU/item.

5. Reserved words in ConditionExpression
[FACT] status, name, size, value, type, timestamp, among others, are reserved words in DynamoDB. Using them directly in expressions causes a syntax error. Always use ExpressionAttributeNames with the # prefix to substitute: "#s": "status".

6. Doubled cost when using transactions with DAX
[FACT] TransactWriteItems via DAX: beyond the normal 2× WCUs, DAX internally executes a TransactGetItems to populate the cache after each transactional write — adding 2× RCUs per item. A 500-byte item in TransactWriteItems via DAX consumes 2 WCUs + 2 RCUs.


Reflection exercise

You are modeling a ticket reservation system. When a customer purchases a ticket, you need to:
1. Verify that the event still has available capacity (availableSeats > 0)
2. Decrement availableSeats on the event item
3. Create a ticket item for the customer
4. Record the sale in an audit item

Answer:

  1. What is the problem with implementing this flow with 3 separate sequential PutItem/UpdateItem calls? Describe a concrete race condition scenario that can occur.

  2. You decide to use TransactWriteItems. Which 3 or 4 actions do you place in the transaction and what ConditionExpression do you use to guarantee the operation is safe? Write the skeleton of the call.

  3. The system has 100 req/s of simultaneous purchases for the same event. How many TransactionConflicts would you expect? What can be done to reduce conflicts while maintaining consistency?


Resources for further study

  • [FACT] DynamoDB Transactions — How it works: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html
  • [FACT] ConditionExpression — CLI examples: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html
  • [FACT] Optimistic locking with version number: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/BestPractices_OptimisticLocking.html
  • [FACT] Best practices — implementing version control: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/BestPractices_ImplementingVersionControl.html