Transacción no válida que persiste en todas las solicitudes

Resumen

Uno de nuestros subprocesos en producción tuvo un error y ahora está produciendo InvalidRequestError: This session is in 'prepared' state; no further SQL can be emitted within this transaction. InvalidRequestError: This session is in 'prepared' state; no further SQL can be emitted within this transaction. ¡Errores, en cada solicitud con una consulta que atiende, por el rest de su vida! ¡Hace días que hace esto, ahora! ¿Cómo es esto posible y cómo podemos evitar que siga avanzando?

Fondo

Estamos utilizando una aplicación Flask en uWSGI (4 procesos, 2 subprocesos), con Flask-SQLAlchemy que nos proporciona conexiones de base de datos a SQL Server.

El problema pareció comenzar cuando uno de nuestros subprocesos en producción estaba destruyendo su solicitud, dentro de este método Flask-SQLAlchemy:

 @teardown def shutdown_session(response_or_exc): if app.config['SQLALCHEMY_COMMIT_ON_TEARDOWN']: if response_or_exc is None: self.session.commit() self.session.remove() return response_or_exc 

… y de alguna manera logró llamar a self.session.commit() cuando la transacción no era válida. Esto dio como resultado sqlalchemy.exc.InvalidRequestError: Can't reconnect until invalid transaction is rolled back hacer que la salida salga de la salida estándar, desafiando nuestra configuración de registro, lo cual tiene sentido, ya que sucedió durante el recorte del contexto de la aplicación, que se supone que nunca se debe boost. excepciones No estoy seguro de cómo la transacción llegó a ser inválida sin que se estableciera response_or_exec , pero ese es realmente el problema menor de AFAIK.

El mayor problema es que es cuando los errores de “estado ” preparado ‘comenzaron y no se han detenido desde entonces. Cada vez que este hilo sirve una solicitud que llega a la base de datos, es 500. Todos los demás hilos parecen estar bien: por lo que puedo decir, incluso el hilo que está en el mismo proceso está funcionando bien.

Conjetura salvaje

La lista de correo SQLAlchemy tiene una entrada sobre el error “estado” “preparado” que dice que ocurre si una sesión comenzó a comprometerse y aún no ha terminado, y algo más intenta usarla. Supongo que la sesión en este hilo nunca llegó al paso self.session.remove() , y ahora nunca lo hará.

Todavía siento que eso no explica cómo esta sesión persiste en todas las solicitudes . No hemos modificado el uso de Flask-SQLAlchemy de las sesiones con ámbito de solicitud, por lo que la sesión debería devolverse al grupo de SQLAlchemy y revertirse al final de la solicitud, incluso las que están cometiendo errores (aunque es cierto, probablemente no sea la primera). desde que se planteó durante el contexto de la aplicación derribando). ¿Por qué no están sucediendo los rollbacks? Podría entenderlo si estuviéramos viendo los errores de “transacción no válida” en la salida estándar (en el registro de uwsgi) todas las veces, pero no lo hacemos: solo la vi una vez, la primera vez. Pero veo el error de “estado ” preparado ‘(en el registro de nuestra aplicación) cada vez que se producen los 500.

Detalles de configuración

Hemos desactivado expire_on_commit en la session_options , y hemos activado SQLALCHEMY_COMMIT_ON_TEARDOWN . Solo estamos leyendo de la base de datos, todavía no estamos escribiendo. También estamos usando Dogpile-Cache para todas nuestras consultas (usando el locking memcached ya que tenemos múltiples procesos y, en realidad, 2 servidores con carga equilibrada). El caché caduca cada minuto para nuestra consulta principal.

Actualizado el 2014-04-28: pasos de resolución

Reiniciar el servidor parece haber solucionado el problema, lo que no es del todo sorprendente. Dicho esto, espero volver a verlo hasta que averigüemos cómo detenerlo. benselme (a continuación) sugirió escribir nuestra propia callback de desassembly con un manejo de excepciones alrededor del compromiso, pero creo que el mayor problema es que el hilo se desordenó por el rest de su vida. ¡El hecho de que esto no desapareciera después de una o dos solicitudes realmente me pone nervioso!

Edición 2016-06-05:

Una RP que resuelve este problema se fusionó el 26 de mayo de 2016.

Matraz PR 1822

Editar 2015-04-13:

¡Misterio resuelto!

TL; DR: ¡Asegúrese absolutamente de que sus funciones de desassembly tengan éxito, utilizando la receta de ajuste de desgarro en la edición 2014-12-11!

Comencé un nuevo trabajo también usando Flask, y este problema surgió de nuevo, antes de que pusiera en marcha la receta de envoltura de desgarro. Así que volví a examinar este problema y finalmente me di cuenta de lo que pasó.

Como pensé, Flask inserta un nuevo contexto de solicitud en la stack de contexto de solicitud cada vez que una nueva solicitud aparece en la línea. Esto se utiliza para admitir globales de solicitud local, como la sesión.

Flask también tiene una noción de contexto de “aplicación” que está separado del contexto de solicitud. Está pensado para admitir cosas como las pruebas y el acceso a la CLI, donde HTTP no está sucediendo. Sabía esto, y también sabía que ahí es donde Flask-SQLA pone sus sesiones de DB.

Durante el funcionamiento normal, tanto la solicitud como el contexto de la aplicación se insertan al principio de una solicitud y se muestran al final.

Sin embargo, resulta que al empujar un contexto de solicitud, el contexto de solicitud verifica si existe un contexto de aplicación existente, y si está presente, ¡ no impulsa uno nuevo!

Por lo tanto, si el contexto de la aplicación no aparece al final de una solicitud debido al aumento de la función de desassembly, no solo se quedará para siempre, sino que tampoco tendrá un nuevo contexto de aplicación sobre ella.

Eso también explica algo de magia que no había entendido en nuestras pruebas de integración. Puede INSERTAR algunos datos de prueba, luego ejecutar algunas solicitudes y esas solicitudes podrán acceder a esos datos a pesar de que no se estén comprometiendo. Esto solo es posible ya que la solicitud tiene un nuevo contexto de solicitud, pero está reutilizando el contexto de la aplicación de prueba, por lo que está reutilizando la conexión de base de datos existente. Así que esto realmente es una característica, no un error.

Dicho esto, significa que tiene que estar absolutamente seguro de que sus funciones de desassembly tienen éxito, utilizando algo como el envoltorio de la función de desassembly a continuación. Esa es una buena idea incluso sin esa función para evitar pérdidas de memoria y conexiones de DB, pero es especialmente importante a la luz de estos hallazgos. Estaré enviando un PR a los documentos de Flask por este motivo. ( Aquí está )

Editar 2014-12-11:

Una cosa que terminamos de implementar fue el siguiente código (en nuestra fábrica de aplicaciones), que envuelve cada función de desassembly para asegurarnos de que registra la excepción y no aumenta más. Esto asegura que el contexto de la aplicación siempre se abra correctamente. Obviamente, esto tiene que suceder después de que esté seguro de que se han registrado todas las funciones de desassembly.

 # Flask specifies that teardown functions should not raise. # However, they might not have their own error handling, # so we wrap them here to log any errors and prevent errors from # propagating. def wrap_teardown_func(teardown_func): @wraps(teardown_func) def log_teardown_error(*args, **kwargs): try: teardown_func(*args, **kwargs) except Exception as exc: app.logger.exception(exc) return log_teardown_error if app.teardown_request_funcs: for bp, func_list in app.teardown_request_funcs.items(): for i, func in enumerate(func_list): app.teardown_request_funcs[bp][i] = wrap_teardown_func(func) if app.teardown_appcontext_funcs: for i, func in enumerate(app.teardown_appcontext_funcs): app.teardown_appcontext_funcs[i] = wrap_teardown_func(func) 

Editar 2014-09-19:

Bien, resulta que --reload-on-exception no es una buena idea si 1.) está utilizando varios subprocesos y 2.) la terminación de un subproceso de solicitud puede causar problemas. Pensé que uWSGI esperaría a que terminen todas las solicitudes para que ese trabajador termine, como lo hace la característica de “recarga elegante” de uWSGI, pero parece que ese no es el caso. Comenzamos a tener problemas cuando un subproceso adquiría un locking dogpile en Memcached, luego terminaba cuando uWSGI recarga al trabajador debido a una excepción en un subproceso diferente, lo que significa que el locking nunca se libera.

La eliminación de SQLALCHEMY_COMMIT_ON_TEARDOWN solucionó parte de nuestro problema, aunque aún estamos recibiendo errores ocasionales durante el desassembly de la aplicación durante session.remove() . Parece que estos son causados ​​por el problema 3043 de SQLAlchemy , que se corrigió en la versión 0.9.5, por lo que esperamos que la actualización a 0.9.5 nos permita confiar en que el desassembly del contexto de la aplicación siempre funcione.

Original:

En primer lugar, cómo sucedió esto sigue siendo una pregunta abierta, pero encontré una manera de evitarlo: la opción de --reload-on-exception de --reload-on-exception .

El control de errores de nuestra aplicación Flask debería estar detectando casi cualquier cosa, por lo que puede ofrecer una respuesta de error personalizada, lo que significa que solo las excepciones más inesperadas deberían llegar hasta uWSGI. Por lo tanto, tiene sentido recargar la aplicación completa cuando esto suceda.

También desactivaremos SQLALCHEMY_COMMIT_ON_TEARDOWN , aunque probablemente nos comprometamos explícitamente en lugar de escribir nuestra propia callback para el desassembly de la aplicación, ya que estamos escribiendo en la base de datos muy rara vez.

Una cosa sorprendente es que no hay excepción en el manejo de ese self.session.commit . Y una confirmación puede fallar, por ejemplo, si se pierde la conexión a la base de datos. Por lo tanto, la confirmación falla, la session no se elimina y la próxima vez que un hilo en particular maneje una solicitud, aún intentará usar esa sesión ahora no válida.

Desafortunadamente, Flask-SQLAlchemy no ofrece ninguna posibilidad limpia de tener su propia función de desassembly. Una forma sería tener el SQLALCHEMY_COMMIT_ON_TEARDOWN configurado en False y luego escribir su propia función de desassembly.

Debe tener un aspecto como este:

 @app.teardown_appcontext def shutdown_session(response_or_exc): try: if response_or_exc is None: sqla.session.commit() finally: sqla.session.remove() return response_or_exc 

Ahora, todavía tendrás tus confirmaciones fallidas, y tendrás que investigar eso por separado … Pero al menos tu hilo debería recuperarse.