Silêncio de execuções Ruby: Rails transacionais / PostgreSQL Thriller

Esta é uma história sobre por que você nunca deve ignorar erros quando está dentro de uma transação em um banco de dados. Descobrir como usar transações corretamente e o que fazer ao usá-las não é uma opção. Spoiler: será sobre bloqueios consultivos no PostgreSQL!


Trabalhei em um projeto no qual os usuários podem importar um grande número de entidades pesadas (vamos chamá-las de produtos) de um serviço externo para o nosso aplicativo. Para cada produto, dados ainda mais diversos associados a ele são carregados de APIs externas. Não é incomum que um usuário carregue centenas de produtos, juntamente com todas as dependências, como resultado, a importação de um produto leva um tempo tangível (30 a 60 segundos) e todo o processo pode demorar. O usuário pode estar cansado de esperar pelo resultado e tem o direito de clicar no botão "Cancelar" a qualquer momento, e o aplicativo deve ser útil com o número de produtos que puderam ser baixados nesse momento.


A “importação interrompida” é implementada da seguinte forma: no início de cada produto, um registro temporário de tarefa é criado na placa de identificação no banco de dados. Para cada produto, uma tarefa de importação em segundo plano é iniciada, que faz o download do produto, o salva no banco de dados, juntamente com todas as dependências (faz tudo em geral) e, no final, exclui seu registro de tarefa. Se no momento em que a tarefa em segundo plano for iniciada, não haverá registro no banco de dados - a tarefa simplesmente termina silenciosamente. Portanto, para cancelar a importação, basta excluir todas as tarefas e pronto.


Não importa se a importação foi cancelada pelo usuário ou completamente concluída por ele mesmo - em qualquer caso, a ausência de tarefas significa que tudo acabou e o usuário pode começar a usar o aplicativo.


O design é simples e confiável, mas havia um pequeno bug nele. Um relatório de erro típico sobre ele era: “Depois que a importação é cancelada, o usuário recebe uma lista de seus produtos. No entanto, se você atualizar a página, a lista de produtos será complementada por várias entradas ". O motivo desse comportamento é simples - quando o usuário clicou no botão "Cancelar", ele foi imediatamente transferido para a lista de todos os produtos. Mas, neste momento, as importações já iniciadas de certos bens ainda estão "em execução".


Isso, é claro, é um pouco, mas os usuários ficaram intrigados com o pedido, por isso seria bom corrigi-lo. Eu tinha duas maneiras: de alguma forma, identificar e "matar" as tarefas que já estão em execução ou, quando clico no botão Cancelar, aguarde até que elas sejam concluídas e "morra a própria morte" antes de transferir o usuário. Eu escolhi o segundo caminho - esperar.


Fechaduras transacionais correm para o resgate


Para todos que trabalham com bancos de dados (relacionais), a resposta é óbvia: use transações !


É importante lembrar que, na maioria dos RDBMSs, os registros atualizados em uma transação serão bloqueados e inacessíveis para alterações por outros processos até que essa transação seja concluída. Os registros selecionados usando SELECT FOR UPDATE também serão bloqueados.


Exatamente o nosso caso! Envolvi a tarefa de importar mercadorias individuais em uma transação e bloqueei o registro da tarefa no início:


 ActiveRecord::Base.transaction do task = Import::Task.lock.find_by(id: id) # SELECT … FOR UPDATE  «    » return unless task #  - ? ,    ! #     task.destroy end 

Agora, quando o usuário desejar cancelar a importação, a operação de parada de importação excluirá as tarefas das importações que ainda não foram iniciadas e será forçada a aguardar a conclusão das já existentes:


 user.import_tasks.delete_all #        

Simples e elegante! Fiz os testes, verifiquei a importação localmente e na preparação e implantei "na batalha".


Não é tão rápido ...


Satisfeito com o meu trabalho, fiquei muito surpreso ao encontrar em breve relatórios de erros e toneladas de erros nos logs. Muitos produtos não foram importados. Em alguns casos, apenas um único produto poderia permanecer após a conclusão de todas as importações.


Os erros nos logs também não eram encorajadores: PG::InFailedSqlTransaction com um PG::InFailedSqlTransaction conduz ao código que executava os SELECT inocentes. O que está acontecendo?


Após um dia exaustivo de depuração, identifiquei três causas principais dos problemas:


  1. Inserção competitiva de registros conflitantes no banco de dados.
  2. Cancelamento automático de transação no PostgreSQL após erros.
  3. Silêncio de problemas (exceções Ruby) no código do aplicativo.

Problema 1: inserção competitiva de entradas conflitantes


Como cada operação de importação leva até um minuto e existem muitas dessas tarefas, as executamos em paralelo para economizar tempo. Registros dependentes de mercadorias podem se cruzar, na medida em que todos os produtos do usuário possam se referir a um único registro, criado uma vez e depois reutilizado.


Existem verificações para encontrar e reutilizar as mesmas dependências no código do aplicativo, mas agora, quando usamos transações, essas verificações se tornam inúteis : se a transação A criou um registro dependente, mas ainda não foi concluído, a transação B não poderá descobrir sua existência e tentará criar uma duplicata. registro.


Problema 2: cancelamento automático de transação do PostgreSQL após erros


Obviamente, impedimos a criação de tarefas duplicadas no nível do banco de dados usando a seguinte DDL:


 ALTER TABLE product_deps ADD UNIQUE (user_id, characteristics); 

Se uma transação em andamento A inseriu um novo registro e a transação B tenta inserir um registro com os mesmos valores dos campos user_id e characteristics , a transação B receberá um erro:


 BEGIN; INSERT INTO product_deps (user_id, characteristics) VALUES (1, '{"same": "value"}'); -- Now it will block until first transaction will be finished ERROR: duplicate key value violates unique constraint "product_deps_user_id_characteristics_key" DETAIL: Key (user_id, characteristics)=(1, {"same": "value"}) already exists. -- And will throw an error when first transaction have commited and it is become clear that we have a conflict 

Mas há um recurso que não deve ser esquecido - a transação B, após a detecção de um erro, será automaticamente cancelada e todo o trabalho realizado nela será drenado. No entanto, essa transação ainda está aberta em um estado "incorreto", mas com qualquer tentativa de executar qualquer solicitação, mesmo a mais inofensiva, apenas erros serão retornados em resposta:


 SELECT * FROM products; ERROR: current transaction is aborted, commands ignored until end of transaction block 

Bem, é completamente desnecessário dizer que tudo o que foi inserido no banco de dados nesta transação não será salvo:


 COMMIT; --      ,   ROLLBACK --           

Problema Três: Silêncio


A essa altura, já estava claro que a simples adição de transações ao aplicativo o quebrou. Não havia escolha: eu tive que mergulhar no código de importação. No código, muitas vezes os seguintes padrões começaram a chamar minha atenção:


 def process_stuff(data) # ,   rescue StandardError nil #  ,  end 

O autor do código aqui nos diz: "Tentamos, não tivemos sucesso, mas tudo bem, continuamos sem ele". E embora os motivos dessa escolha possam ser bastante explicáveis ​​(nem tudo pode ser processado no nível do aplicativo), é isso que torna impossível qualquer lógica baseada em transações: uma execução descartada não poderá flutuar até o bloco da transaction e não causará uma reversão correta transações (o ActiveRecord captura todos os erros nesse bloco, reverte a transação e os lança novamente).


Tempestade perfeita


E foi assim que todos esses três fatores se uniram para criar a perfeita a tempestade bug:


  • Um aplicativo em uma transação tenta inserir um registro conflitante no banco de dados e causa um erro de "chave duplicada" do PostgreSQL. No entanto, esse erro não faz com que a transação seja revertida no aplicativo, pois é "ocultada" dentro de uma das partes do aplicativo.
  • A transação se torna inválida, mas o aplicativo não a conhece e continua a funcionar. Em qualquer tentativa de acessar o banco de dados, o aplicativo recebe novamente um erro, desta vez "a transação atual é abortada", mas esse erro também pode ser descartado ...
  • Você provavelmente já entendeu que algo no aplicativo continua a quebrar, mas ninguém saberá até que a execução chegue ao primeiro lugar, onde não há rescue excessivamente ganancioso e onde o erro pode eventualmente aparecer, ser registrado, registrado no rastreador de erros - qualquer coisa. Mas esse local já estará muito longe do local que se tornou a causa raiz do erro, e isso sozinho transformará a depuração em um pesadelo.

Alternativa aos bloqueios transacionais no PostgreSQL


Buscar o rescue no código do aplicativo e reescrever toda a lógica de importação não é uma opção. Muito tempo. Eu precisava de uma solução rápida e a encontrei no postgres! Possui uma solução interna para bloqueios, uma alternativa ao bloqueio de registros em transações, bloqueios de reunião com aviso de sessão. Eu os usei da seguinte maneira:


Primeiro, removi a transação de empacotamento primeiro. De qualquer forma, interagir com APIs externas (ou quaisquer outros "efeitos colaterais") do código do aplicativo com uma transação aberta é uma má idéia, porque mesmo se você reverter a transação junto com todas as alterações em nosso banco de dados, as alterações nos sistemas externos permanecerão , e o aplicativo como um todo pode estar em um estado estranho e indesejável. A gema isoladora pode ajudar a garantir que os efeitos colaterais sejam isolados adequadamente das transações.


Então, em cada operação de importação, tomo um bloqueio compartilhado em alguma chave exclusiva para toda a importação (por exemplo, criada a partir do ID do usuário e hash do nome da classe de operação):


 SELECT pg_advisory_lock_shared(42, user.id); 

Bloqueios compartilhados na mesma chave podem ser executados simultaneamente por qualquer número de sessões.


O cancelamento da operação de importação exclui ao mesmo tempo todas as entradas de tarefas do banco de dados e tenta obter um bloqueio exclusivo na mesma chave. Nesse caso, ela terá que esperar até que todos os bloqueios compartilhados sejam liberados:


 SELECT pg_advisory_lock(42, user.id) 

E isso é tudo! Agora, o "cancelamento" aguardará até que todas as importações "em andamento" de mercadorias individuais sejam concluídas.


Além disso, agora que não estamos conectados por uma transação, podemos usar um pequeno hack para limitar o tempo de espera para o cancelamento da importação (no caso de algumas importações "travarem"), porque não é bom bloquear o fluxo do servidor da web por um longo tempo (e forçar aguarde usuário):


 transaction do execute("SET LOCAL lock_timeout = '30s'") execute("SELECT pg_advisory_lock(42, user.id)") rescue ActiveRecord::LockWaitTimeout nil #    (     ) end 

É seguro detectar um erro fora do bloco da transaction , porque o ActiveRecord já reverterá a transação .


Mas o que fazer com a inserção competitiva de registros idênticos?


Infelizmente, não conheço uma solução que funcione bem com insertos competitivos . Existem as seguintes abordagens, mas todas elas bloquearão inserções simultâneas até a conclusão da primeira transação:


  • INSERT … ON CONFLICT UPDATE (disponível desde o PostgreSQL 9.5) na segunda transação será bloqueada até a primeira transação ser concluída e, em seguida, retornará o registro que foi inserido pela primeira transação.
  • Bloqueie algum registro geral em uma transação antes de executar validações para inserir um novo registro. Aqui, esperaremos até que o registro inserido em outra transação seja visível e as validações não funcionem completamente.
  • Faça algum tipo de bloqueio de recomendação geral - o efeito é o mesmo que para bloquear um registro geral.

Bem, se você não tem medo de trabalhar com erros no nível básico, pode capturar o erro de exclusividade:


 def import_all_the_things #   ,   Dep.create(user_id, chars) rescue ActiveRecord::RecordNotUnique retry end 

Apenas verifique se esse código não está mais encapsulado em uma transação.


Por que eles estão bloqueados?

As restrições UNIQUE e EXCLUDE bloqueiam conflitos em potencial, impedindo que eles sejam registrados ao mesmo tempo. Por exemplo, se você tiver uma restrição exclusiva em uma coluna inteira e uma transação inserir uma linha com o valor 5, outras transações que também tentarem inserir 5 serão bloqueadas, mas as transações que tentarem inserir 6 ou 4 terão êxito imediatamente, sem bloquear. Como o nível mínimo de isolamento de transações reais do PostgreSQL é READ COMMITED , uma transação não tem o direito de ver alterações não confirmadas de outras transações. Portanto, um INSERT com um valor conflitante não pode ser aceito ou rejeitado até a primeira transação confirmar suas alterações (a segunda recebe um erro de exclusividade) ou reverter (a inserção na segunda transação será bem-sucedida). Leia mais sobre isso em um artigo do autor de EXCLUDE restrições .

Prevenir desastres futuros


Agora você sabe que nem todo o código pode ser agrupado em uma transação. Seria bom garantir que ninguém mais embrulhe esse código em uma transação no futuro, repetindo meu erro.


Para fazer isso, você pode agrupar todas as suas operações em um pequeno módulo auxiliar que verificará se a transação está aberta antes de executar o código da operação agrupada (aqui presume-se que todas as suas operações tenham a mesma interface - o método de call ).


 #     module NoTransactionAllowed class InTransactionError < RuntimeError; end def call(*) return super unless in_transaction? raise InTransactionError, "#{self.class.name} doesn't work reliably within a DB transaction" end def in_transaction? connection = ApplicationRecord.connection # service transactions (tests and database_cleaner) are not joinable connection.transaction_open? && connection.current_transaction.joinable? end end #    class Deps::Import < BaseService prepend NoTransactionAllowed def call do_import rescue ActiveRecord::RecordNotUnique retry end end 

Agora, se alguém tentar embrulhar um serviço perigoso em uma transação, ele receberá imediatamente um erro (a menos que, é claro, ele o mantenha em silêncio).


Sumário


A principal lição a ser aprendida: tenha cuidado com exceções. Não lide com tudo em uma linha, capture apenas as exceções que você sabe como lidar e deixe o resto chegar aos logs. Nunca ignore exceções (apenas se você não tiver 100% de certeza do motivo pelo qual está fazendo isso). Quanto mais cedo um erro for percebido, mais fácil será a depuração.


E não exagere nas transações no banco de dados. Isso não é uma panacéia. Use nosso isolador de gemas e after_commit_everywhere - eles ajudarão suas transações a se tornarem completamente à prova de falhas.


O que ler


Ruby excepcional por Avdi Grimm . Este pequeno livro ensinará como lidar com exceções existentes no Ruby e como projetar adequadamente um sistema de exceções para o seu aplicativo.


Usando transações atômicas para alimentar uma API idempotente da @Brandur. Seu blog possui muitos artigos úteis sobre confiabilidade de aplicativos, Ruby e PostgreSQL.

Source: https://habr.com/ru/post/pt418147/


All Articles