Der Supabase-Pooler behält deine Session-Einstellungen, für jemand anderen

Christian Drexler

Ich wollte mir ein paar Zeilen in einer Produktionsdatenbank ansehen und dabei vorsichtig sein. Vor der ersten Abfrage habe ich meine Session deshalb auf nur lesend gestellt:

set session characteristics as transaction read only;

Auf einer direkten Verbindung ist das eine vernünftige Gewohnheit. Was immer ich danach tippe, die Datenbank weigert sich, etwas zu ändern. Ich habe meine Selects laufen lassen, meine Antwort bekommen und die Verbindung getrennt.

Ein paar Minuten später scheiterte eine Registrierung in der Anwendung mit einem Serverfehler. Im Log stand der Grund:

ERROR: cannot execute INSERT in a read-only transaction (SQLSTATE 25006)

An der Anwendung war nichts deployt oder sonst geändert worden. Sie versuchte, eine Profilzeile anzulegen, genau wie tausendmal zuvor, und die Datenbank sagte ihr, sie dürfe nicht schreiben.

Was ich getan hatte

Ich hatte mich nicht direkt mit der Datenbank verbunden. Ich hatte die Verbindungszeichenkette mit Port 6543 benutzt, und das ist der Pooler von Supabase im Transaktionsmodus.

Ein Transaktions-Pooler gibt einem keine eigene Verbindung. Er hält eine kleine Zahl echter Datenbankverbindungen offen und leiht jedem Client eine für die Dauer einer Transaktion. Endet die Transaktion, geht dieselbe echte Verbindung an den Nächsten, der fragt. So können ein paar Dutzend Datenbankverbindungen Tausende kurzlebiger Clients bedienen, etwa Serverless-Funktionen.

Der Haken steckt im Wort Session. Ein SET auf Session-Ebene ändert eine Eigenschaft der echten Verbindung, und die gehört mir als Client nicht. Im Transaktionsmodus gibt es so etwas wie meine Session nicht. Es gibt die gepoolte Verbindung, die ich zufällig gerade in der Hand hatte, und meine Einstellung blieb darauf, als ich losließ.

Eine der gepoolten Verbindungen war also jetzt für alle nur lesend. Jede Anfrage, die zufällig diese Verbindung bekam, konnte lesen und nicht schreiben. Anfragen, die eine der anderen bekamen, funktionierten. Aus Sicht der Anwendung sieht das aus wie ein Insert, der manchmal scheitert, ohne Grund, ohne Gemeinsamkeit zwischen den Fehlern.

Der sichere Weg, vorsichtig zu sein

Die Absicht war richtig. Sie braucht die Form, die mit der Transaktion endet.

begin transaction read only;

select id, email, created_at from profiles order by created_at desc limit 20;
-- more selects

rollback;

begin transaction read only gilt für diese eine Transaktion. Das rollback am Ende schließt sie, und die Verbindung geht in genau dem Zustand in den Pool zurück, in dem sie war. Nichts, was ich darin tue, kann Daten ändern, und nichts, was ich setze, überlebt mich.

Die Regel, die ich daraus mitgenommen habe: Über einen Pooler muss alles, was vorübergehend sein soll, in einer Transaktion leben. Also set local statt set, begin ... read only statt Session-Eigenschaften und überhaupt keine Anweisung auf Session-Ebene auf Port 6543. Brauche ich wirklich Session-Verhalten, etwa für eine lange Auswertung mit temporären Tabellen, nehme ich die direkte Verbindung auf Port 5432, wo die Session tatsächlich meine ist.

Die vergiftete Verbindung finden

Die Einstellung verschwindet nicht von selbst. Sie bleibt, bis diese echte Verbindung geschlossen wird, und das kann bei einem beschäftigten Pool lange dauern. Ich musste sie also finden.

Jede Abfrage über den Pooler landet auf irgendeinem Backend. Fragt man oft genug parallel, trifft man die meisten:

for i in $(seq 1 30); do
  psql "$POOLER_URL" -Atc \
    "select pg_backend_pid(), current_setting('default_transaction_read_only')" &
done | sort -u
wait

Die Ausgabe ist eine kurze Liste von Prozess-IDs, jede mit off, und in meinem Fall eine mit on. Das war die Verbindung, die ich zurückgelassen hatte.

Sie zu schließen ist eine Anweisung, mit der Prozess-ID aus der Liste:

select pg_terminate_backend(1234567);

Der Pooler merkt, dass die Verbindung weg ist, und öffnet eine frische mit Standardeinstellungen. Ich habe die Schleife noch einmal laufen lassen, jede Zeile sagte off, und die Registrierung funktionierte beim nächsten Versuch.

Wie groß der Schaden war

In meinem Fall eine gescheiterte Registrierung, und es war mein eigenes Testkonto. Die Zeit zwischen meinem SET und der Korrektur war kurz, und es war wenig Verkehr. Ich hatte Glück, dass ich die Registrierung gleich danach aus einem ganz anderen Grund getestet habe.

Unangenehm ist die Vorstellung, wie das ohne dieses Glück ausgesehen hätte. Ein Bruchteil der Schreibanfragen scheitert, und der Bruchteil hängt von der Größe des Pools ab. Kein Deployment, dem man die Schuld geben könnte, und kein Fehler im Anwendungscode. Es hätte angehalten, bis der Pooler diese Verbindung zufällig erneuert, und wäre dann ohne Erklärung von selbst verschwunden. Das ist die Art Vorfall, die mit „nicht reproduzierbar“ endet.

Die Kurzfassung

  • Port 6543 ist ein geteilter Pool. SET auf Session-Ebene ändert dort eine Verbindung, die andere Clients benutzen werden.
  • Für vorsichtiges Lesen auf Produktion: begin transaction read only; ... rollback;.
  • Für vorübergehende Einstellungen: set local, innerhalb einer Transaktion.
  • Scheitern Schreibzugriffe zufällig mit SQLSTATE 25006, such nach einem Backend, bei dem default_transaction_read_only auf on steht, und beende es.

Ich wollte meine Session sicherer machen. Der Pooler kennt keine Sessions, also hatte ich eine geteilte Verbindung für jeden Client nach mir auf nur lesend gestellt.