Intermediário6 min de leitura

Condition Expressions no DynamoDB

Uma condition expression é um predicado que o DynamoDB avalia no item existente antes de commitar sua escrita. Se o predicado for falso, a escrita é rejeitada e nada muda. É a coisa mais próxima que o DynamoDB tem de uma cláusula WHERE em uma escrita — e a única forma segura de impor uma invariante.

Como funcionam as condition expressions do DynamoDB?

Uma condition expression é um predicado que o DynamoDB avalia no servidor sobre o item atual antes de commitar uma escrita. Se for verdadeiro, a escrita prossegue; se for falso, a escrita é rejeitada com ConditionalCheckFailedException e nada muda. Ela funde a verificação e a mutação em uma única operação atômica, então chamadores concorrentes não podem competir sobre uma leitura obsoleta.

  • É uma guarda, não um filtro. A ConditionExpression roda no servidor sobre o item atual; um resultado falso faz a escrita falhar com ConditionalCheckFailedException.
  • Substitui o ler-depois-escrever. Sem uma ida e volta de SELECT depois UPDATE — a verificação e a mutação são uma operação atômica, então dois chamadores não podem competir.
  • É de graça rejeitar, não de graça rodar. Uma escrita condicional que falha ainda consome capacidade de escrita. A garantia custa o mesmo que a escrita que ela bloqueia.

Vindo do SQL, você leria a linha, a checaria no código do app, depois atualizaria. No DynamoDB essa lacuna entre leitura e escrita é um bug de corrupção de dados esperando um chamador concorrente. A condition expression fecha a lacuna.

Onde elas se aplicam

Você anexa uma ConditionExpression a PutItem, UpdateItem, DeleteItem, e a cada ação dentro de TransactWriteItems. Ela não faz parte de Query ou Scan — esses usam FilterExpression, que é uma coisa diferente no caminho de leitura.

Essa distinção confunde as pessoas, então seja preciso:

ConditionExpressionFilterExpression
CaminhoEscritas (Put/Update/Delete)Leituras (Query/Scan)
Efeito na falhaRejeita a escrita inteiraRemove o item dos resultados
O item atual, pré-escritaCada item candidato, pós-leitura
CustoEscrita que falha ainda é cobradaItens filtrados ainda são cobrados na leitura

As duas rodam no servidor. A diferença é o que "falso" faz: uma condição aborta uma mutação; um filtro só esconde uma linha que você já pagou para ler. (AWS: Condition Expressions)

As funções que você de fato vai usar

A linguagem de condição é pequena. Os cavalos de batalha:

  • attribute_exists(path) / attribute_not_exists(path) — este atributo existe no item? O idioma clássico para "criar só se ausente" / "atualizar só se presente".
  • Comparadores — =, <>, <, <=, >, >= — contra um valor ou outro atributo.
  • attribute_type, begins_with, contains, size — verificações de tipo e de string/set.
  • BETWEEN … AND …, IN (…) — intervalo e pertencimento.
  • AND, OR, NOT, parênteses — para combinar os acima.

attribute_not_exists na chave de partição é a forma canônica de fazer PutItem se comportar como um insert que não vai sobrescrever um item existente — o DynamoDB não tem uma operação "insert" separada, então a condição é a semântica de insert. (AWS: Comparison Operator and Function Reference)

Um exemplo prático: protegendo um livro-razão contra saldo negativo

Pegue um livro-razão bancário. Cada conta é um item:

PK = "ACCT#a7f3"
SK = "BALANCE"
clearedCents = 50000
holdCents    = 0

A invariante: um débito nunca deve empurrar o saldo disponível abaixo de zero, e você nunca deve debitar uma conta que não existe. Duas regras, ambas impostas na própria escrita.

O jeito errado (a cilada)

GetItem ACCT#a7f3 / BALANCE     → clearedCents = 50000
if (50000 >= 30000) ...         ← app-side check
UpdateItem  SET clearedCents = 20000

Entre o GetItem e o UpdateItem, um segundo débito pode ler os mesmos 50000, passar na sua própria verificação, e escrever também. Os dois sucedem; a conta fica negativa. Essa é uma corrida de read-modify-write, e nenhuma quantidade de validação no lado do app a resolve — a verificação e a escrita são operações separadas.

O jeito certo

Dobre a verificação para dentro da escrita. Debite 30000 centavos, condicional a a conta existir e ter o suficiente:

UpdateItem  ACCT#a7f3 / BALANCE
  SET clearedCents = clearedCents - :amt
  ConditionExpression:
    attribute_exists(PK) AND clearedCents >= :amt

com :amt = 30000. Se o saldo for baixo demais, ou o item nunca tiver sido criado, o DynamoDB rejeita a escrita com ConditionalCheckFailedException e o saldo fica intacto. O débito concorrente ou vê o saldo original e é checado contra ele, ou vê o atualizado — nunca uma leitura obsoleta sobre a qual ele agiu.

Você pode construir e copiar a expressão exata — nomes, valores e tudo — com o DynamoDB expression builder em vez de montar à mão o mapa ExpressionAttributeValues.

Inspecionando a guarda no DynoTable

Quando uma escrita condicional falha, você quer ver o estado real do item, não adivinhá-lo. Puxe o item da conta e leia clearedCents diretamente.

A coleção do livro-razão no DynoTable — o item BALANCE mostra clearedCents acima dos itens de transação da conta.
A coleção do livro-razão no DynoTable — o item BALANCE mostra clearedCents acima dos itens de transação da conta.

Leia a rejeição, não tente de novo às cegas

ConditionalCheckFailedException não é um erro transitório — tentar a mesma escrita de novo não muda nada. Significa que uma regra de negócio disparou: fundos insuficientes, criação duplicada, versão obsoleta. Trate-a como um resultado de domínio, não um soluço de infraestrutura.

Duas coisas tornam as falhas depuráveis:

  • ReturnValuesOnConditionCheckFailure: ALL_OLD — o DynamoDB retorna o item atual junto com a falha, então você pode mostrar "o saldo era 20000, você pediu 30000" sem uma segunda leitura. (AWS: Working with Items)
  • Distinguir as duas razões de falha. attribute_exists(PK) AND clearedCents >= :amt colapsa "sem conta" e "sem fundos" em uma só exceção. Se os chamadores precisam diferenciá-las, separe em duas escritas ou inspecione o item retornado.

Optimistic locking é o mesmo truque

O padrão de número de versão é só uma condition expression vestindo outro chapéu. Armazene um atributo version; toda escrita afirma a versão que você leu e a incrementa:

UpdateItem  ACCT#a7f3 / BALANCE
  SET clearedCents = :new, version = :next
  ConditionExpression: version = :seen

Se outro escritor se moveu primeiro, version = :seen é falso, a escrita é rejeitada, e você relê e tenta de novo. É assim que o DynamoDB faz controle de concorrência sem locks — afirme o que você viu, falhe se mudou. (AWS: Optimistic Locking with Version Number)

Armadilhas e próximos passos

  • Nomes que colidem com palavras reservadas. status, size, name, e ~570 outros são reservados. Aliase-os com ExpressionAttributeNames (#s = status) ou sua expressão silenciosamente falha ao parsear.
  • Uma condição não pode referenciar outro item. Ela só vê o item sendo escrito. Invariantes entre itens precisam de TransactWriteItems com uma ConditionExpression por ação, ou um ConditionCheck contra um item sentinela.
  • Escritas que falham ainda custam WCUs. Uma guarda que rejeita 90% das vezes ainda cobra por essas rejeições. Seguro barato, mas não de graça.

Para modelar as chaves contra as quais essas guardas rodam, veja single-table design e Query vs Scan. Quando você estiver pronto para emitir escritas condicionais contra dados reais, baixe o DynoTable e rode-as contra suas próprias tabelas.

Atualizado