Eine Frage des Kontexts

Einleitung

Vor kurzem hatte ich während der Entwicklung einer Flask-Anwendung folgende (gekürzte) Exception:

Traceback (most recent call last):
[…]
sqlalchemy.exc.InvalidRequestError: This session is provisioning a new connection; concurrent operations are not permitted (Background on this error at: https://sqlalche.me/e/20/isce)

SQLAlchemy beklagt sich darin darüber das ich versuche mehrere Verbindungen über die gleiche Session abzuwickeln.

Die relevanten Code-Stellen sehen so aus:

somesubmodule.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from flask import render_template, Blueprint
from sqlalchemy import Session
from somedbmodule import engine

someblueprintp = Blueprint("somesubmodule", __name__)

sqlsession = Session(engine)

@someblueprintp.route("/list/", methods=["POST"])
def list_table():
    stmt = select(Customer)
    customers = sqlsession.scalars(stmt)
    return render_template("somefile.htm", customers=customers)
app.py
1
2
3
4
5
from flask import Flask, render_template, g
from somesubmodule import someblueprintp

app = Flask(__name__)
app.register_blueprint(someblueprintp)

Grob gesagt was hier passiert ist ein Blueprint wird in Flask registiert (Zeile 5 in app.py).
Das Blueprint hat eine Route registriert (Zeile 9 in somesubmole.py)
Die Route wiederum ruft Daten per SQLAlchemy aus einer Datenbank ab (Zeile 12 in somesubmodule.py).

Das Problem ist in Zeile 7 in somesubmodule.py.
An dieser Stelle wird eine SQL-Session instanziiert (die Konfiguration und das Erstellen des Engine-Objektes erfolgt in einem anderen Modul - ist hier nicht von Relevanz), das passiert auf Module-Ebene. Genaugenommen passierte es im Original in einem anderen Modul, was in die View-Module importiert wurde. Die Idee war, dass es eine globale SQL-Session gibt, die für alle SQL-Queries in den Views genutzt werden kann.

Das Problem kommt jetzt daher, wann wird diese sqlsession instanziiert?
Sie wird instanziiert wenn das Modul geladen wird, sprich wenn app.py somesubmodule.py importiert, in dem Moment führt Python den gesamten Code im Modul aus und instanziiert sqlsession. Module werden innerhalb des gleichen Prozesses in Python nur einmal geladen, egal wie oft sie in anderen Modulen importiert werden - auch wenn die sqlsession über ein anderes Modul (wie es im echten Code der Fall war) in verschiedene View-Module import werden würde, würden alle das gleiche sqlsession-Objekt importieren.
Das wäre kein Problem, wenn es sich um ein normales Python-Programm handeln würde. In dem Fall würden einfach mehrere Anfragen nacheinander über die die gleiche SQLSession abgearbeitet, obiger Fehler würde nicht auftreten.

Was Flask eigentlich mit der "App" macht

Flask bzw. der Application-Server muss mehrere HTTP-Anfragen parallel verarbeiten können.
Theoretisch ließe sich das so abarbeiten:

  • Application-Server bekommt Request

  • Application-Server startet einen Prozess mit der Flask-App und übergibt ihr den Request

  • nächster Request kommt rein

  • Application-Server startet einen weiteren Prozess mit der Flask-App und übergibt den neuen Request

  • Prozess 1 hat den Response für den ersten Request fertig und sendet ihn zurück an den Application-Server und der sendet ihn an den Client

  • Prozess 1 beendet sich

Das Problem ist, einen Prozess für jeden Request zu starten dauert Zeit, das heißt die Verarbeitung verzögert sich. Insbesondere da Python nicht bekannt dafür ist besonders schnell zu sein, dauert es zu lange für jede Anfrage einen neuen Prozess zu starten.
Man kann das Problem auf 2 Arten bekämpfen:

  • man startet einen Prozess mit mehreren Threads

  • man startet mehrere Worker-Prozesse

Egal wie man es macht, der Application-Server verteilt die Requests auf die Prozesse oder Threads und jeder verarbeitet einen zu einer Zeit. Der Prozess der die Threads beinhaltet oder die mehreren Prozesse werden mit dem Application-Server gestartet und enthalten jeweils eine Flask-App und sie werden (theoretisch) erst beendet wenn der Application-Server beendet wird.
Ein Thread oder Prozess verarbeitet also nicht einfach einen Request in seinem Leben, sondern beliebig viele nacheinander, nur eben nur einen gleichzeitig.

Das bedeutet die Flask-App wird nicht für jeden Prozess neu erschaffen, sondern die Requests benutzen eine Flask-App die bereits mehrere Requests vor ihnen verarbeitet hat.

Die Herausforderung ist es, es für jeden Request so auszusehen als würde er in einer frischen Umgebung verarbeitet.

Dazu reduziert Flask den Teil der für jeden Request erneuert werden muss.
Zu diesem Zweck stellt es Objekte zur Verfügung:

  • app bzw. current_app → die App-Konfiguration bzw. Objekte und Funktionen die direkt mit der App verknüpft sind

  • g → in diesem Objekt kann der Programmierer im Verlauf der Verarbeitung des Requests Daten speichern auf die alle Views zugreifen können sollen

  • session - enthält die Session-Cookie-Daten

  • request - enthält die Daten des Requests

Am Ende eines Requests werden diese verworfen und bei einem neuen Request neu mit Daten gefüllt.
Alles was nicht Teil dieser Objekte ist/an sie gebunden ist wird nicht neu geladen, sondern existiert einfach über alle Requests hinweg, bis der Prozess beendet wird.

Grundsätzlich muss man 2 Sachen beachten, die sehr eng beieinander liegen:

threads

Obiges Bild zeigt was mit verschiedenem Code in der "App" passiert, wenn man mehrere Threads nutzt um mehrere Requests parallel zu verarbeiten.
Die Grünen "Objekte"/Teile des Codes werden für jeden Thread (und jeden Request) separat erzeugt.
In diesem Fall hier ist das eine Funktion die mit "@app.before_request" dekoriert ist und eine View-Funktion die mit "@someblueprint.route" dekoriert ist. someblueprint ist in app.py mit "app" registiert. Flask stellt sicher das jeder Thread eine eigene Version von "app" hat, die nichts mit den anderen zu tun haben, das heißt alles was an app gebunden ist/mit einem Dekorator des app-Objektes erstellt wird und nur an eines der oben aufgezählten Objekte gebunden wird, existiert jeweils nur in dem jeweiligen Thread, während der parallel laufende Thread seine eigene, unabhängige Version hat.
Daneben haben wir 2 Klassen und daraus abgeleitete Objekte die ohne Dekorator auf Modul-Ebene erstellt wurden. Diese sind im gesamten Prozess identisch! Das heißt greift Thread1 auf someview.something zu und ändert etwas am Objekt, ändert sich dieses Objekt auch in Thread2 - es ist schlicht das gleiche Objekt im gesamten Objekt.
Das gefährliche hier ist, dass Daten zwischen verschiedenen, parallel verarbeiteten Requests, die nichts miteinander zu tun haben ausgetauscht werden können und damit ggf. unbeabsichtigt Daten ausgeleitet werden.

requests

In obigem Bild wird dargestellt was passiert wenn Requests nacheinander durch den gleichen Thread/Prozess verarbeitet werden.
Wie zu sehen holt sich Flask jeweils die Funktionen die mit @app.before_request bzw. @someblueprint.route dekorierten Funktionen aus dem Code. Diese sind an das "app"-Objekt gebunden.
Wie bereits beschrieben werden "app" bzw. "current_app", "g", "session" und "request" jeweils für jeden Request neu erstellt. Der jeweilige Request sieht also "seine" Version dieser Objekte.
Bei Request2 handelt es sich um neue Objekte, mit dem gleichen Namen.
Anders sieht es aus bei den in auf Modulebene instanziierten und nicht an eines der genannten Objekte gebundenen Objekte "something", so wie die Klasse "Something", diese werden direkt, beim Start des Prozesses instanziiert und werden bis zum Endes des Prozesses nicht neu instanziiert, sie laufen an Flask "vorbei" und werden direkt verwendet.
Wie in der Beschreibung was bei Threads mit solchen Objekten passieren kann, gilt auch hier das, da die Objekte über Request-Grenzen hinaus existieren - Daten zwischen voneinander unabhängigen Requests ausgetauscht und ausgeleitet werden könnten.

Proxy-Objekte

Die "Flask-Objekte" app (erstellt durch instanziieren von Flask()) bzw. in Modulen dann current_app, session, request, g werden importiert.

from flask import Flask, session, request, g

An sich hätte man jetzt wieder das Problem welches ich oben mit der Datenbank-Session hatte, die Objekte existieren nur einmal pro Prozess, bei mehreren Threads die parallel arbeiten würden sich alle Threads die gleichen Objekte teilen.

proxy objects

Das Problem tritt bei den oben genannten Objekten aber nicht auf, da es sich dabei um sogenannte Proxy-Objekte handelt.
Das heißt es gibt zwar ein app (Flask-Klasse), session-, request- und g-Objekt die zentral importiert werden, aber beim Zugriff (wenn man ein Attribut ließt oder setzt) evaluiert das Objekt aus welchem Thread die Anfrage erfolgt und greift transparent auf ein Schatten-Objekt zu was nur für diesen Thread existiert und liefert dessen Wert oder setzt dessen Wert.
Praktisch kann man sich (ich habe nicht nachgesehen wie es wirklich implementiert ist) vorstellen das z.B. das g-Objekt die Methoden __getattr__, __setattr__ und __delattr__ (diese Methoden werden aufgerufen wenn man in einem Objekt ein Attribut abruft, setzt oder löscht, das Objekt muss dabei das Attribut gar nicht haben) implementiert und in diesen prüft wer der aufrufende Thread ist und dann einfach die gewünschte Operation gegen das Schattenobjekt das für den Thread existiert ausführt. Der Programmierer spricht immer nur mit einem g-Objekt, im Hintergrund sind es aber mehrere.

Der Vollständigkeit halber, Flask unterscheidet in Contexte, der Application-Context umfasst app/current_app und der Request-Context session, request und g.
Beide werden bei jedem Request neu erstellt und in den meisten Fällen spielt diese Unterscheidung keine Rolle.

Schlussfolgerungen

Alle Objekte (das schließt Klassen und Variablen mit ein) die spezifisch für einen Request erstellt werden oder modifiziert werden und alle Resourcen die "limitiert" sind - aka. nur einzeln zugegriffen werden kann, wie Datenbank-Sessions, Filehandles und Dateien selbst - müssen in einer Funktion erstellt werden die einen Flask-Dekorator nutzen und resultierende Objekte (wenn sie außerhalb der Funktion noch benötigt werden) müssen an ein Flask-Objekt (app, current_app, request, session, g) als Attribut dessen gebunden werden.
Nur so ist sichergestellt das mit jedem Request und jedem Thread ein neues und exklusives Objekt zur Verfügung steht.

Wie ich das Problem mit der Database Session gelöst habe

app.py
@app.before_request
def set_g_db_session():
    g.db_session = set_db_session()


@app.teardown_request
def close_db_session():
    g.db_session.close()

Statt ein globales Objektes welches über Thread- und Request-Grenzen hinaus existiert wird für jeden Request (über den before_request-Decorator) ein neues Session-Objekt erstellt und an g.db_session gebunden. Die Methode die mit teardown_request dekoriert ist sorgt dafür das die session am Ende der Behandlung eines Requests wieder geschlossen wird.
Das binden an g.db_session stellt sicher das jeder Thread ein exklusives db_session-Objekt hat, weil "g" wie oben beschrieben ein Proxy-Objekt ist, welches im Hintergrund für jeden Thread (bzw. eigentlich für jeden neuen Request) ein eigenes g-Schatten-Objekt bereit hält.

"g" kann in anderen Modulen (wie zum Beispiel denen die die Views enthalten) importiert werden und damit existiert auch g.db_session in all diesen Modulen und damit der Zugriff auf die gleiche Datenbank-Session über alle Module hinweg, solange sie den gleichen Request bearbeiten/im gleichen Request-Kontext laufen.