web-dev-qa-db-fra.com

Transaction non valide persistante entre les demandes

Résumé

Un de nos threads en production a rencontré une erreur et génère maintenant des erreurs InvalidRequestError: This session is in 'prepared' state; no further SQL can be emitted within this transaction., À chaque demande avec une requête qu'il dessert, pour le reste de sa vie! Ça fait ça depuis jours, maintenant! Comment est-ce possible et comment pouvons-nous l'empêcher d'aller de l'avant?

Contexte

Nous utilisons une application Flask sur uWSGI (4 processus, 2 threads), Flask-SQLAlchemy nous fournissant des connexions DB à SQL Server.

Le problème semblait commencer lorsque l'un de nos threads en production supprimait sa demande, à l'intérieur de cette méthode 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

... et a réussi à appeler self.session.commit() lorsque la transaction n'était pas valide. Cela a abouti à sqlalchemy.exc.InvalidRequestError: Can't reconnect until invalid transaction is rolled back Pour obtenir la sortie vers stdout, au mépris de notre configuration de journalisation, ce qui est logique car cela s'est produit pendant la destruction du contexte de l'application, qui n'est jamais censé déclencher d'exceptions. Je ne suis pas sûr comment la transaction doit être invalide sans que response_or_exec Soit défini, mais c'est en fait le moindre problème AFAIK.

Le plus gros problème est que c'est à ce moment que les erreurs "d'état" préparé "ont commencé et ne se sont pas arrêtées depuis. Chaque fois que ce thread sert une requête qui atteint la base de données, il fait 500s. Tous les autres threads semblent bien: pour autant que je sache, même le thread qui est dans le même processus se porte bien.

Devinette sauvage

La liste de diffusion SQLAlchemy contient une entrée sur l'erreur "état" préparé "indiquant qu'elle se produit si une session a commencé à être validée et n'est pas encore terminée, et que quelque chose d'autre essaie de l'utiliser. Je suppose que la session de ce fil n'est jamais arrivée à l'étape self.session.remove(), et maintenant elle ne le sera plus.

J'ai toujours l'impression que cela n'explique pas comment cette session persiste à travers les demandes cependant. Nous n'avons pas modifié l'utilisation de sessions de portée de requête par Flask-SQLAlchemy, donc la session devrait être retournée au pool de SQLAlchemy et annulée à la fin de la demande, même celles qui sont erronées (bien qu'il soit vrai, probablement pas le premier, depuis celle soulevée lors de la destruction du contexte de l'application). Pourquoi les annulations ne se produisent-elles pas? Je pourrais le comprendre si nous voyions les erreurs de "transaction invalide" sur stdout (dans le journal d'uwsgi) à chaque fois, mais nous ne le sommes pas: je ne l'ai vu qu'une fois, la première fois. Mais je vois l'erreur "état" préparé "(dans le journal de notre application) chaque fois que les 500 se produisent.

Détails de configuration

Nous avons désactivé expire_on_commit Dans le session_options Et nous avons activé SQLALCHEMY_COMMIT_ON_TEARDOWN. Nous lisons uniquement à partir de la base de données, nous n'écrivons pas encore. Nous utilisons également Dogpile-Cache pour toutes nos requêtes (en utilisant le verrou memcached car nous avons plusieurs processus, et en fait, 2 serveurs à charge équilibrée). Le cache expire toutes les minutes pour notre requête principale.

Mise à jour le 2014-04-28: Étapes de résolution

Le redémarrage du serveur semble avoir résolu le problème, ce qui n'est pas entièrement surprenant. Cela dit, je m'attends à le revoir jusqu'à ce que nous trouvions comment l'arrêter. benselme (ci-dessous) a suggéré d'écrire notre propre rappel de démontage avec une gestion des exceptions autour de la validation, mais je pense que le plus gros problème est que le thread a été foiré pour le reste de sa vie. Le fait que cela ne soit pas disparaître après une demande ou deux me rend vraiment nerveux!

37
Vanessa Phipps

Modifier 2016-06-05:

Un RP qui résout ce problème a été fusionné le 26 mai 2016.

Flacon PR 1822

Modifier le 13/04/2015:

Mystère résolu!

TL; DR: soyez absolument sûr vos fonctions de démontage réussissent, en utilisant la recette d'emballage de démontage dans l'édition 2014-12-11!

J'ai commencé un nouveau travail en utilisant également Flask, et ce problème est apparu à nouveau, avant que je ne mette en place la recette de démontage. J'ai donc revu ce problème et finalement compris ce qui s'est passé.

Comme je le pensais, Flask pousse un nouveau contexte de demande sur la pile de contexte de demande chaque fois qu'une nouvelle demande arrive sur la ligne. Ceci est utilisé pour prendre en charge les globaux locaux de demande, comme la session.

Flask a également une notion de contexte "d'application" qui est distincte du contexte de demande. Il est destiné à prendre en charge des éléments tels que les tests et l'accès CLI, où HTTP ne se produit pas. Je le savais, et je savais aussi que c'est là que Flask-SQLA met ses sessions DB.

Pendant le fonctionnement normal, une demande et un contexte d'application sont poussés au début d'une demande et sautés à la fin.

Cependant, il s'avère qu'en poussant un contexte de demande, le contexte de demande vérifie s'il existe un contexte d'application existant, et s'il est présent, il ne le fait pas Poussez un nouveau!

Donc, si le contexte de l'application ne l'est pas est apparu à la fin d'une demande en raison d'une augmentation de la fonction de démontage, non seulement il restera éternellement, il n'aura même pas de nouveau contexte d'application poussé en plus.

Cela explique également une magie que je n'avais pas comprise dans nos tests d'intégration. Vous pouvez INSÉRER certaines données de test, puis exécuter certaines demandes et ces demandes pourront accéder à ces données même si vous ne vous engagez pas. Cela n'est possible que si la demande a un nouveau contexte de demande, mais réutilise le contexte de l'application de test, donc il réutilise la connexion DB existante. C'est vraiment une fonctionnalité, pas un bug.

Cela dit, cela signifie que vous devez être absolument sûr que vos fonctions de démontage réussissent, en utilisant quelque chose comme l'encapsuleur de fonction de démontage ci-dessous. C'est une bonne idée même sans cette fonctionnalité pour éviter les fuites de mémoire et de connexions DB, mais c'est particulièrement important à la lumière de ces résultats. Je vais soumettre un PR aux documents de Flask pour cette raison. ( le voici )

Modifier le 2014-12-11:

Une chose que nous avons fini par mettre en place était le code suivant (dans notre fabrique d'applications), qui encapsule chaque fonction de démontage pour s'assurer qu'il enregistre l'exception et ne se lève plus. Cela garantit que le contexte de l'application s'affiche toujours avec succès. Évidemment, cela doit aller après vous êtes sûr que toutes les fonctions de démontage ont été enregistrées.

# 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)

Modifier le 2014-09-19:

Ok, il s'avère que --reload-on-exception N'est pas une bonne idée si 1.) vous utilisez plusieurs threads et 2.) la fin d'une mi-requête de thread pourrait causer des problèmes. Je pensais que uWSGI attendrait que toutes les demandes pour que ce travailleur se termine, comme le fait la fonctionnalité de "rechargement gracieux" d'uWSGI, mais il semble que ce ne soit pas le cas. Nous avons commencé à avoir des problèmes où un thread pouvait acquérir un verrou dogpile dans Memcached, puis nous nous terminions lorsque uWSGI rechargeait le travailleur en raison d'une exception dans un autre thread, ce qui signifie que le verrou n'est jamais libéré.

La suppression de SQLALCHEMY_COMMIT_ON_TEARDOWN A résolu une partie de notre problème, bien que nous recevions toujours des erreurs occasionnelles lors du démontage de l'application pendantsession.remove(). Il semble que cela soit dû à problème SQLAlchemy 304 , qui a été corrigé dans la version 0.9.5, donc j'espère que la mise à niveau vers 0.9.5 nous permettra de compter sur le démontage du contexte de l'application qui fonctionne toujours.

Original:

Comment cela s'est-il produit en premier lieu est toujours une question ouverte, mais j'ai trouvé un moyen de l'empêcher: l'option --reload-on-exception De uWSGI.

Notre gestion des erreurs de l'application Flask devrait être à peu près n'importe quoi, donc elle peut servir une réponse d'erreur personnalisée, ce qui signifie que seules les exceptions les plus inattendues devraient se rendre jusqu'à uWSGI. sens de recharger l'application entière chaque fois que cela se produit.

Nous désactiverons également SQLALCHEMY_COMMIT_ON_TEARDOWN, Mais nous nous engagerons probablement explicitement plutôt que d'écrire notre propre rappel pour le démontage de l'application, car nous écrivons si rarement dans la base de données.

33
Vanessa Phipps

Une chose surprenante est qu'il n'y a aucune exception à cette règle self.session.commit. Et une validation peut échouer, par exemple si la connexion à la base de données est perdue. Ainsi, la validation échoue, session n'est pas supprimé et la prochaine fois que ce thread particulier gère une demande, il essaie toujours d'utiliser cette session maintenant non valide.

Malheureusement, Flask-SQLAlchemy n'offre aucune possibilité propre d'avoir votre propre fonction de démontage. Une façon serait d'avoir le SQLALCHEMY_COMMIT_ON_TEARDOWN réglé sur False puis en écrivant votre propre fonction de démontage.

Ça devrait ressembler à ça:

@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

Maintenant, vous aurez toujours vos validations échouées, et vous devrez enquêter séparément ... Mais au moins votre thread devrait récupérer.

6
benselme