Silencio de las ejecuciones de Ruby: Transactional Rails / PostgreSQL Thriller

Esta es una historia sobre por qué nunca debe ignorar los errores cuando está dentro de una transacción en una base de datos. Averiguar cómo usar las transacciones correctamente y qué hacer al usarlas no es una opción. Spoiler: ¡se tratará de bloqueos de asesoramiento en PostgreSQL!


Trabajé en un proyecto en el que los usuarios pueden importar una gran cantidad de entidades pesadas (llamémoslas productos) desde un servicio externo a nuestra aplicación. Para cada producto, se cargan datos aún más diversos asociados desde API externas. No es raro que un usuario cargue cientos de productos junto con todas las dependencias, como resultado, importar un producto lleva un tiempo tangible (30-60 segundos), y todo el proceso puede llevar mucho tiempo. El usuario puede estar cansado de esperar el resultado y tiene derecho a hacer clic en el botón "Cancelar" en cualquier momento y la aplicación debería ser útil con la cantidad de productos que se pudieron descargar en este momento.


La “importación interrumpida” se implementa de la siguiente manera: al principio de cada producto se crea un registro de tarea temporal en la placa de identificación de la base de datos. Para cada producto, se inicia una tarea de importación en segundo plano, que descarga el producto, lo guarda en la base de datos junto con todas las dependencias (hace todo en general) y, al final, elimina su registro de tareas. Si para el momento en que comienza la tarea en segundo plano, no habrá ningún registro en la base de datos, la tarea simplemente finaliza en silencio. Por lo tanto, para cancelar la importación, es suficiente simplemente eliminar todas las tareas y listo.


No importa si la importación fue cancelada por el usuario o completada por él mismo; en cualquier caso, la ausencia de tareas significa que todo ha terminado y el usuario puede comenzar a usar la aplicación.


El diseño es simple y confiable, pero había un pequeño error. Un informe de error típico sobre él fue: “Después de que se cancela la importación, se muestra al usuario una lista de sus productos. Sin embargo, si actualiza la página, la lista de productos se complementa con varias entradas ". La razón de este comportamiento es simple: cuando el usuario hizo clic en el botón "Cancelar", fue transferido inmediatamente a la lista de todos los productos. Pero en este momento, las importaciones ya iniciadas de ciertos bienes todavía están "funcionando".


Esto, por supuesto, es un poco, pero los usuarios estaban desconcertados por la orden, por lo que sería bueno solucionarlo. Tenía dos formas: identificar y "matar" de alguna manera las tareas que ya se estaban ejecutando, o cuando hago clic en el botón cancelar, esperar hasta que se completen y "morir por su propia muerte" antes de transferir al usuario aún más. Elegí la segunda forma: esperar.


Las cerraduras transaccionales corren al rescate


Para todos los que trabajan con bases de datos (relacionales), la respuesta es obvia: ¡use transacciones !


Es importante recordar que en la mayoría de los RDBMS, los registros actualizados dentro de una transacción serán bloqueados e inaccesibles para cambios por otros procesos hasta que se complete esta transacción. Los registros seleccionados con SELECT FOR UPDATE también se bloquearán.


¡Exactamente nuestro caso! Terminé la tarea de importar bienes individuales en una transacción y bloqueé el registro de la tarea desde el principio:


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

Ahora, cuando el usuario desea cancelar la importación, la operación de detención de importación eliminará las tareas para las importaciones que aún no se han iniciado y se verá obligado a esperar la finalización de las ya existentes:


 user.import_tasks.delete_all #        

Simple y elegante! Ejecuté las pruebas, verifiqué la importación localmente y en la puesta en escena, y me desplegué "en batalla".


No tan rápido ...


Satisfecho con mi trabajo, me sorprendió encontrar pronto informes de errores y toneladas de errores en los registros. Muchos productos no fueron importados en absoluto . En algunos casos, solo puede quedar un solo producto después de la finalización de todas las importaciones.


Los errores en los registros tampoco fueron alentadores: PG::InFailedSqlTransaction con un PG::InFailedSqlTransaction conduce al código que ejecutó los inocentes SELECT . ¿Qué está pasando en absoluto?


Después de un día de depuración agotadora, identifiqué tres causas principales de los problemas:


  1. Inserción competitiva de registros en conflicto en la base de datos.
  2. Cancelación automática de transacciones en PostgreSQL después de errores.
  3. Silencio de problemas (excepciones de Ruby) en el código de la aplicación.

Problema uno: inserción competitiva de entradas en conflicto


Como cada operación de importación demora hasta un minuto y hay muchas de estas tareas, las realizamos en paralelo para ahorrar tiempo. Los registros dependientes de bienes pueden cruzarse, en la medida en que todos los productos del usuario puedan referirse a un solo registro, creado una vez y luego reutilizado.


Hay comprobaciones para encontrar y reutilizar las mismas dependencias en el código de la aplicación, pero ahora, cuando usamos transacciones, estas comprobaciones se vuelven inútiles : si la transacción A creó un registro dependiente pero aún no se ha completado, la transacción B no podrá descubrir su existencia e intentará crear un duplicado registro.


Problema dos: cancelación automática de transacciones de PostgreSQL después de errores


Por supuesto, evitamos la creación de tareas duplicadas a nivel de base de datos utilizando el siguiente DDL:


 ALTER TABLE product_deps ADD UNIQUE (user_id, characteristics); 

Si una transacción en curso A inserta un nuevo registro y la transacción B intenta insertar un registro con los mismos valores de los campos user_id y characteristics , la transacción B recibirá un error:


 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 

Pero hay una característica que no debe olvidarse: la transacción B, después de detectar un error, se cancelará automáticamente y todo el trabajo realizado se irá por el desagüe. Sin embargo, esta transacción aún está abierta en un estado "erróneo", pero con cualquier intento de ejecutar cualquier solicitud, incluso la más inofensiva, solo se devolverán los errores en respuesta:


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

Bueno, es completamente innecesario decir que todo lo que se ingresó en la base de datos en esta transacción no se guardará:


 COMMIT; --      ,   ROLLBACK --           

Problema tres: silencio


En este punto, ya estaba claro que simplemente agregando transacciones a la aplicación la rompió. No había otra opción: tuve que sumergirme en el código de importación. En el código, a menudo los siguientes patrones comenzaron a llamar mi atención:


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

El autor del código aquí nos dice: "Lo intentamos, no lo logramos, pero está bien, continuamos sin él". Y aunque las razones de esta elección pueden ser bastante explicables (no todo se puede procesar a nivel de aplicación), esto es lo que hace que cualquier lógica basada en transacciones sea imposible: una ejecución rechazada no podrá flotar hasta el bloque de transaction y no causará una reversión correcta transacciones (ActiveRecord detecta todos los errores en este bloque, revierte la transacción y los arroja nuevamente).


Tormenta perfecta


Y así es como estos tres factores se unieron para crear el perfecto la tormenta error:


  • Una aplicación en una transacción intenta insertar un registro conflictivo en la base de datos y causa un error de "clave duplicada" de PostgreSQL. Sin embargo, este error no hace que la transacción se revierta en la aplicación, ya que se "silencia" dentro de una de las partes de la aplicación.
  • La transacción deja de ser válida, pero la aplicación no lo sabe y continúa funcionando. En cualquier intento de acceder a la base de datos, la aplicación nuevamente recibe un error, esta vez "la transacción actual se cancela", pero este error también se puede descartar ...
  • Probablemente ya haya entendido que algo en la aplicación continúa rompiéndose, pero nadie lo sabrá hasta que la ejecución llegue al primer lugar, donde no haya un rescue demasiado codicioso y donde el error pueda aparecer, registrarse. registrado en el rastreador de errores - cualquier cosa. Pero este lugar ya estará muy lejos del lugar que se convirtió en la causa raíz del error, y esto solo convertirá la depuración en una pesadilla.

Alternativa a los bloqueos transaccionales en PostgreSQL


Buscar el rescue en el código de la aplicación y reescribir toda la lógica de importación no es una opción. Mucho tiempo ¡Necesitaba una solución rápida y la encontré en el postgres! Tiene una solución integrada para bloqueos, una alternativa al bloqueo de registros en transacciones, bloqueos de aviso de reunión. Los usé de la siguiente manera:


Primero, eliminé la transacción de envoltura primero. En cualquier caso, es una mala idea interactuar con API externas (o cualquier otro "efecto secundario") del código de la aplicación con una transacción abierta, porque incluso si revierte la transacción junto con todos los cambios en nuestra base de datos, los cambios en los sistemas externos permanecerán , y la aplicación en su conjunto puede estar en un estado extraño e indeseable. La gema aislante puede ayudarlo a asegurarse de que los efectos secundarios estén adecuadamente aislados de las transacciones.


Luego, en cada operación de importación, tomo un bloqueo compartido en alguna clave única para toda la importación (por ejemplo, creada a partir del ID de usuario y hash del nombre de la clase de operación):


 SELECT pg_advisory_lock_shared(42, user.id); 

Las cerraduras compartidas en la misma clave se pueden tomar simultáneamente por cualquier número de sesiones.


La cancelación de la operación de importación al mismo tiempo elimina todas las entradas de tareas de la base de datos e intenta tomar un bloqueo exclusivo en la misma clave. En este caso, tendrá que esperar hasta que se liberen todos los bloqueos compartidos:


 SELECT pg_advisory_lock(42, user.id) 

¡Y eso es todo! Ahora la "cancelación" esperará hasta que se completen todas las importaciones "en ejecución" de bienes individuales.


Además, ahora que no estamos conectados por una transacción, podemos usar un pequeño truco para limitar el tiempo de espera para la cancelación de la importación (en caso de que algunos "bloqueos" de la importación), porque no es bueno bloquear el flujo del servidor web durante mucho tiempo (y forzar esperar al usuario):


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

Es seguro detectar un error fuera del bloque de transaction , porque ActiveRecord ya revertirá la transacción .


Pero, ¿qué hacer con la inserción competitiva de registros idénticos?


Desafortunadamente, no conozco una solución que funcione bien con insertos competitivos . Existen los siguientes enfoques, pero todos bloquearán las inserciones concurrentes hasta que se complete la primera de las transacciones:


  • INSERT … ON CONFLICT UPDATE (disponible desde PostgreSQL 9.5) en la segunda transacción se bloqueará hasta que se complete la primera transacción y luego devolverá el registro insertado por la primera transacción.
  • Bloquee algún registro general en una transacción antes de ejecutar validaciones para insertar un nuevo registro. Aquí esperaremos hasta que el registro insertado en otra transacción sea visible y las validaciones no puedan funcionar por completo.
  • Tome algún tipo de bloqueo de recomendación general: el efecto es el mismo que para bloquear un registro general.

Bueno, si no tiene miedo de trabajar con errores de nivel base, puede detectar el error de unicidad:


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

Solo asegúrese de que este código ya no esté envuelto en una transacción.


¿Por qué están bloqueados?

Las restricciones ÚNICAS y EXCLUIDAS bloquean conflictos potenciales al evitar que se graben al mismo tiempo. Por ejemplo, si tiene una restricción única en una columna de enteros y una transacción inserta una fila con un valor de 5, se bloquearán otras transacciones que también intenten insertar 5, pero las transacciones que intenten insertar 6 o 4 tendrán éxito inmediatamente, sin bloqueo. Dado que el nivel mínimo de aislamiento de transacción real de PostgreSQL es READ COMMITED , una transacción no tiene derecho a ver cambios no confirmados de otras transacciones. Por lo tanto, un INSERT con un valor en conflicto no puede ser aceptado o rechazado hasta que la primera transacción confirme sus cambios (luego la segunda reciba un error de unicidad) o retroceda (entonces la inserción en la segunda transacción tendrá éxito). Lea más sobre esto en un artículo del autor de restricciones EXCLUDE .

Prevenir futuros desastres


Ahora sabe que no todo el código se puede incluir en una transacción. Sería bueno asegurarse de que nadie más envuelva dicho código en una transacción en el futuro, repitiendo mi error.


Para hacer esto, puede envolver todas sus operaciones en un pequeño módulo auxiliar que verificará si la transacción está abierta antes de ejecutar el código de operación envuelto (aquí se supone que todas sus operaciones tienen la misma interfaz: el 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 

Ahora, si alguien intenta envolver un servicio peligroso en una transacción, recibirá inmediatamente un error (a menos que, por supuesto, lo mantenga en silencio).


Resumen


La principal lección que se debe aprender: tenga cuidado con las excepciones. No maneje todo en una fila, capture solo aquellas excepciones que sepa cómo manejar y deje que el resto llegue a los registros. Nunca ignore las excepciones (solo si no está 100% seguro de por qué está haciendo esto). Cuanto antes se note un error, más fácil será depurarlo.


Y no exagere con las transacciones en la base de datos. Esto no es una panacea. Use nuestro aislador de gemas y after_commit_everywhere : ayudarán a que sus transacciones sean completamente infalibles.


Que leer


Ruby excepcional de Avdi Grimm . Este breve libro le enseñará cómo manejar las excepciones existentes en Ruby y cómo diseñar adecuadamente un sistema de excepciones para su aplicación.


Uso de transacciones atómicas para impulsar una API idempotente por @Brandur. Su blog tiene muchos artículos útiles sobre la confiabilidad de la aplicación, Ruby y PostgreSQL.

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


All Articles