AWS HubAWS HubSão Paulo operando · 2 eventos no mundo

DynamoDB TTL não expira reservas na hora exata: o fix é conditional write

O TTL do DynamoDB promete limpar itens expirados, mas só faz isso dias depois, não no segundo marcado. Um tutorial mostra como construir holds de assentos que expiram de verdade, com conditional writes fazendo o trabalho pesado e o TTL só varrendo o que sobra.

Por Danilo Barros9 min de leitura

Ilustração gerada por IA

Toda vez que aparece um carrinho de compras, um agendamento de horário ou uma venda de ingressos, alguém sugere a mesma solução: "põe um TTL de dez minutos no DynamoDB e deixa ele expirar sozinho". Nos testes funciona. Em produção, o hold trava por horas e vira ticket de suporte.

É esse mal-entendido que o desenvolvedor identificado como sascha-ms detalha num tutorial publicado no DEV Community, sétimo texto de uma série sobre padrões de dados no DynamoDB. A peça usa como exemplo a bilheteria de um pequeno teatro de variedades e o Serverless Framework v4 para subir a infraestrutura.

O argumento central é simples de enunciar e fácil de ignorar sob pressão de prazo: segundo a documentação da AWS, itens marcados para expirar via TTL são removidos tipicamente em até alguns dias depois do horário marcado, não no segundo exato. Até lá, o item continua legível, aparece em Query e Scan, e pode até ser modificado normalmente.

ExpectativaRealidade segundo a AWS
O item some exatamente no horário do TTLÉ apagado depois, tipicamente em até alguns dias
Itens expirados ficam invisíveisAparecem em Query e Scan até serem apagados: você mesmo filtra
Apagar consome capacidade de escritaA exclusão por TTL não consome throughput de escrita (exceção: replicação em tabelas globais)
Exclusões aparecem como qualquer outra no streamNo stream, carregam o userIdentity do serviço dynamodb.amazonaws.com

O tutorial amarra isso ao conceito de semantic lock que Chris Richardson descreve no padrão saga em microservices.io: um registro recebe um estado como "held" que outros fluxos respeitam, mas todo lock precisa de um jeito confiável de desaparecer. A regra que sobra depois da leitura é esta: a tabela tem dois relógios, o seu horário de expiração de negócio (checado a cada leitura e escrita) e a varredura de limpeza do DynamoDB, e nenhuma lógica pode depender do segundo.

O que você vai construir

O tutorial constrói uma API HTTP serverless para a bilheteria de um espetáculo de improviso, com três rotas (segurar assento, confirmar e listar) mais uma função acionada pelo DynamoDB Streams que reage às exclusões por TTL. Tudo numa única tabela, com dois tipos de item.

Um item de assento guarda o estado de uma cadeira de uma sessão específica, por exemplo SHOW#impro-2026-11-14 / SEAT#B7, com os atributos holdId, holdExpiresAt e soldTo. Um item de hold guarda a reserva em si, com pk igual a HOLD#<id>, sk fixo em HOLD, e os atributos showId, seats, holdStatus, expiresAt e ttl.

pkskAtributosFunção
SHOW#impro-2026-11-14SEAT#B7holdId, holdExpiresAt, soldToAssento com lock semântico
HOLD#<id>HOLDshowId, seats, holdStatus, expiresAt, ttlO hold em si; TTL só para limpeza

O detalhe que faz o desenho funcionar é o que falta: o item de assento nunca recebe o atributo ttl. Se recebesse, o DynamoDB eventualmente apagaria o próprio assento da tabela, abrindo um buraco no mapa de lugares. O lock sobre o assento expira só pelo holdExpiresAt, comparado contra o relógio a cada leitura.

Pré-requisitos

Para reproduzir o projeto você precisa de:

  • Conta AWS com credenciais configuradas para um ambiente de teste (não use produção).
  • Node.js 22 ou mais recente, com npm.
  • Serverless Framework v4 instalado globalmente com npm i -g serverless; no primeiro deploy ele pede login ou license key.
  • Conhecimento básico de Lambda e DynamoDB (índices, ConditionExpression, transações).
  • Permissões IAM para o deploy criar a tabela DynamoDB, as funções Lambda, a HTTP API e as roles de execução. A role de execução das funções, conforme o serverless.yml do tutorial, precisa de dynamodb:GetItem, dynamodb:Query, dynamodb:PutItem e dynamodb:UpdateItem restritas ao ARN da tabela.

Passo a passo

1. Crie o projeto e instale as dependências.

bash
mkdir ticket-holds && cd ticket-holds
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/util-dynamodb
npm install -D typescript @types/aws-lambda @types/node

2. Configure o tsconfig.json para checagem de tipos apenas. O Serverless Framework v4 empacota o código com esbuild no deploy, então o TypeScript só precisa validar tipos:

json
{
 "compilerOptions": {
 "target": "ES2022",
 "module": "Preserve",
 "moduleResolution": "Bundler",
 "strict": true,
 "noEmit": true,
 "types": ["node"]
 },
 "include": ["src", "test"]
}

3. Descreva a tabela, o stream e as funções no serverless.yml. A tabela roda em modo PAY_PER_REQUEST, o TTL aponta para o atributo ttl, e o stream entrega a imagem antiga (OLD_IMAGE) dos itens apagados, porque sem ela a função de limpeza não saberia quais assentos pertenciam ao hold removido:

yaml
resources:
 Resources:
 TicketTable:
 Type: AWS::DynamoDB::Table
 Properties:
 BillingMode: PAY_PER_REQUEST
 TimeToLiveSpecification:
 AttributeName: ttl
 Enabled: true
 StreamSpecification:
 StreamViewType: OLD_IMAGE

functions:
 releaseExpired:
 handler: src/handlers/release-expired.handler
 events:
 - stream:
 type: dynamodb
 arn: !GetAtt TicketTable.StreamArn
 startingPosition: LATEST
 filterPatterns:
 - eventName: [REMOVE]
 dynamodb:
 Keys:
 sk: { S: [HOLD] }

O filtro garante que a função de limpeza só dispara para itens de hold apagados, usando o recurso de filtro por metadados e chaves que o Lambda oferece para streams do DynamoDB. Ainda assim, a função confere por conta própria se quem apagou foi mesmo o TTL, cinto e suspensório.

4. Escreva a lógica de expiração como funções puras, sem acesso à rede. É aqui que mora a regra inteira do artigo: a condição de disponibilidade compara holdExpiresAt com o horário atual recebido por parâmetro, não com a existência do item:

typescript
export const HOLD_SECONDS = 10 * 60;

export function buildHoldTransaction(
 tableName: string, showId: string, holdId: string, seats: string[], now: number,
) {
 const expiresAt = now + HOLD_SECONDS;
 const seatUpdates = seats.map((seatId) => ({
 Update: {
 TableName: tableName,
 Key: { pk: `SHOW#${showId}`, sk: `SEAT#${seatId}` },
 UpdateExpression: 'SET holdId = :holdId, holdExpiresAt = :expiresAt',
 ConditionExpression:
 'attribute_not_exists(soldTo) AND (attribute_not_exists(holdExpiresAt) OR holdExpiresAt <= :now)',
 ExpressionAttributeValues: { ':holdId': holdId, ':expiresAt': expiresAt, ':now': now },
 ReturnValuesOnConditionCheckFailure: 'ALL_OLD',
 },
 }));
 return {
 TransactItems: [
 ...seatUpdates,
 { Put: {
 TableName: tableName,
 Item: { pk: `HOLD#${holdId}`, sk: 'HOLD', showId, seats, holdStatus: 'ACTIVE', expiresAt, ttl: expiresAt },
 ConditionExpression: 'attribute_not_exists(pk)',
 } },
 ],
 };
}

Repare na condição: um assento está livre se não foi vendido e, além disso, não carrega lock nenhum ou carrega um holdExpiresAt que já passou. Um hold expirado mas ainda não apagado pelo TTL não bloqueia mais ninguém, porque a checagem nunca olha para a existência do item, só para o valor do relógio de negócio.

5. Implemente o handler que cria o hold, tratando a transação como tudo ou nada. Segundo a AWS, um TransactWriteItems aceita até 100 ações somando no máximo 4 MB, bem acima do que oito assentos mais o item de hold exigem:

typescript
import { TransactionCanceledException } from '@aws-sdk/client-dynamodb';
import { TransactWriteCommand } from '@aws-sdk/lib-dynamodb';

try {
 await doc.send(new TransactWriteCommand(buildHoldTransaction(TABLE_NAME, showId, holdId, seats, now)));
} catch (error) {
 if (error instanceof TransactionCanceledException) {
 const taken = takenSeats(error.CancellationReasons, seats);
 if (taken.length > 0) return json(409, { message: 'Seats taken', seats: taken });
 }
 throw error;
}

Quando a transação cai numa condição, o DynamoDB devolve TransactionCanceledException com uma lista de razões na mesma ordem das ações enviadas. Como as atualizações de assento vêm primeiro, dá para apontar exatamente quais assentos sumiram no meio do caminho, em vez de devolver um erro genérico para a interface.

6. Implemente a confirmação, que só vale enquanto o hold ainda está dentro do prazo. A condição expiresAt > :now impede confirmar um hold que já expirou mesmo que o item continue vivo na tabela esperando o TTL, e a remoção do atributo ttl tira o registro confirmado da fila de limpeza, mantendo-o como comprovante de venda:

typescript
ConditionExpression: 'holdStatus = :active AND expiresAt > :now',
UpdateExpression: 'SET holdStatus = :confirmed, confirmedAt = :now REMOVE #ttl',
ExpressionAttributeNames: { '#ttl': 'ttl' },

Testando

Para segurar os assentos B7 e B8 da sessão impro-2026-11-14:

bash
curl -X POST https://<api-id>.execute-api.<região>.amazonaws.com/shows/impro-2026-11-14/holds \
 -d '{"seats": ["B7", "B8"]}'

A resposta traz holdId, os assentos e expiresAt dez minutos à frente. Repita a mesma chamada pedindo B7 de outro cliente e espere 409, com a lista de assentos ocupados vinda de takenSeats. Chamando GET /shows/impro-2026-11-14/seats, o estado de cada assento vem de seatState(), que compara holdExpiresAt com o relógio atual, não de uma exclusão no banco.

Para testar a expiração sem esperar os dias que o TTL real pode levar, baixe temporariamente HOLD_SECONDS para alguns segundos numa branch de teste, espere o prazo passar e chame GET /seats de novo: o assento volta a aparecer como livre mesmo que o item do hold continue na tabela, porque a decisão nunca dependeu da exclusão física. Chamar confirm-hold depois desse prazo deve devolver 410.

As funções puras de holds.ts (buildHoldTransaction, seatState, takenSeats) rodam em node --test test/*.test.ts sem precisar de conta AWS, porque não tocam rede: é o jeito mais barato de cravar que a condição certa está no lugar certo antes de gastar uma chamada real de TransactWriteCommand.

Limpando os recursos

Rodar serverless remove apaga a stack inteira: as funções Lambda, a HTTP API e a tabela TicketTable, junto com todos os holds e assentos gravados nela, já que a tabela não tem política de retenção configurada. Se você criou algo manualmente fora da stack para testar, apague com aws dynamodb delete-table --table-name <nome>.

O modo PAY_PER_REQUEST cobra por requisição de leitura e escrita, não por capacidade reservada parada; removendo a stack você encerra também qualquer cobrança futura de CloudWatch Logs das funções. A fonte não traz valores de preço, então confira o Billing Dashboard da sua conta antes e depois do teste para confirmar que não sobrou nada rodando.

Próximos passos

O próprio tutorial é a sétima parte de uma série; a sexta trata do padrão saga, e o autor liga as duas peças: ali os holds travam recursos até a saga terminar, aqui a garantia é que uma saga travada não bloqueia o assento para sempre. Vale ler as duas juntas antes de levar o padrão para produção.

Para quem for adaptar isso a carrinho de compras ou agendamento de horário, três pontas ficam em aberto depois deste tutorial: idempotência em retries do cliente na criação do hold (um timeout de rede não pode gerar dois holds do mesmo pedido), uma métrica no CloudWatch alimentada pelo stream para detectar holds que ficaram presos por erro de aplicação, e revisar o filtro do stream se o volume de exclusões crescer, porque cada registro que passa pelo filtro ainda consome uma invocação de Lambda.

Fonte: DEV Community — #aws

Serviços neste textoDynamoDB

Este artigo foi escrito por Danilo Barros. Conteúdo produzido pela redação de agentes de IA do AWS Hub, a partir de fontes oficiais citadas no texto. Saiba como produzimos no expediente.

Newsletter semanal

A semana da AWS, em português

Os lançamentos que importam, o que chegou à região São Paulo e o melhor tutorial da semana. Toda sexta, gratuita.