Chaves duplicadas vs. trabalho duplicado

Prévia grátis 4 min de leitura

A aula 3 do curso de nível Pleno "Service objects: idempotência" mostrou um formato check-then-store: procurar a chave de idempotência e, se ela não existir, fazer o trabalho e depois salvar a chave. Ela usou um service de pagamento hipotético. Aqui está o mesmo formato aplicado a uma concessão de XP hipotética:

existing = IdempotencyKey.find_by(key: idempotency_key, operation: "award_xp")
return existing_result if existing

xp = ExperiencePoint.create!(user:, amount:, reason:) # o trabalho

IdempotencyKey.create!(key: idempotency_key, operation: "award_xp", response_body: { id: xp.id })

Esse formato passa em qualquer teste que o chame uma vez. Esta aula o chama cinco vezes ao mesmo tempo, com a mesma chave, e conta o que foi criado.

Cinco requisições, uma chave, num banco real

A montagem: duas tabelas descartáveis no banco de desenvolvimento local deste projeto. Uma tem o formato de idempotency_keys, com índice único em key. A outra tem o formato de um livro-razão de XP. Cinco threads, cada uma retirando sua própria conexão do connection pool, todas rodando o código check-then-store acima com a chave idêntica:

linhas de chave de idempotência: 1   (o índice único corretamente permitiu só 1)
registros de XP criados:         4   (deveria ser 1 se isso fosse de fato idempotente)

Quatro linhas de XP onde deveria haver uma: três duplicatas, vindas de um código que tem uma chave de idempotência, a verifica e a armazena. O índice único funcionou como projetado. Ele impediu uma segunda linha em idempotency_keys. Não impediu as linhas de XP que correram na frente dele.

Por que quatro e não cinco? O pool local tem cinco conexões e a thread principal do runner segurava uma, então a quinta thread esperou por uma conexão livre. Quando conseguiu, a chave já existia. Rode de novo e você terá quatro quase sempre.

Por que o índice não ajudou

Percorra as threads pelo código. As quatro que conseguiram conexão chegam em IdempotencyKey.find_by antes de qualquer uma delas ter escrito algo, então cada uma vê "chave não existe" e cai para o trabalho. ExperiencePoint.create! roda quatro vezes, sem guarda. Nada naquela linha sabe das outras threads fazendo a mesma coisa no mesmo instante.

Só depois, em IdempotencyKey.create!, o índice único entra em cena. A essa altura, a única decisão que resta é quais inserts perdem uma corrida que já aconteceu. rescue ActiveRecord::RecordNotUnique captura que eles perderam. Ele não faz nada sobre o XP que eles já criaram.

Isso não é um bug de um service. O código faz o que uma guarda check-then-store promete: deduplica a tabela de chaves. Ele nunca prometeu deduplicar o momento em que o trabalho acontece, porque nada nesse formato reivindica esse momento antes de o trabalho começar.

O Enrollment::AwardXpService real deste app não tem esse problema. Ele nunca verifica antes. Ele insere a linha de XP com insert ... unique_by, então o índice único do banco decide antes de qualquer outra coisa. A aula 2 constrói a versão geral dessa ideia.

A lacuna de que este curso trata

Uma chave check-then-store responde "eu já vi essa chave antes?" Uma guarda de idempotência de produção precisa responder uma pergunta mais rígida: "alguém, agora, incluindo uma requisição rodando em paralelo comigo, já começou este trabalho?" Parecem a mesma pergunta. O teste com cinco threads mostra que não são, e a diferença são as linhas que não deveriam existir.

Fechar essa lacuna não precisa de conceito novo, só de uma nova ordem para os três passos que você já conhece. Verificar, trabalhar, armazenar vira reivindicar, trabalhar, concluir, e o índice único precisa estar no passo de reivindicar para significar alguma coisa.

O que as sete aulas cobrem

A aula 2 reordena os três passos e roda de novo o mesmo teste com cinco threads para mostrar que a reordenação fecha a lacuna. A aula 3 pergunta o que as threads que perdem a reivindicação devem fazer enquanto a vencedora ainda trabalha: bloquear, consultar de novo ou falhar rápido, e o que uma API de produção como a da Stripe faz. A aula 4 cobre um caso que o curso anterior não levantou: a mesma chave chegando com parâmetros diferentes, e por que reproduzir em silêncio o resultado antigo é a resposta errada. A aula 5 compara onde a reivindicação deveria morar: a tabela de banco que este curso constrói, ou uma reserva no Solid Cache deste app. A aula 6 sobe um nível, até como a chave é gerada, e um cenário de queda do cliente que um UUID aleatório trata mal. A aula 7 junta a guarda inteira e te entrega uma versão para você mesmo construir em Ruby puro.

Aula 1 de 7 · grátis

Continue com a aula 2

Reserve antes de trabalhar

Reivindicar a chave em estado pending antes do trabalho faz o índice único barrar XP duplicado.

Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.

Digite para buscar...