Fallen beim Statuscheck lang laufender PHP-Jobs mit Ajax – I

Wir entwickeln gerade ein User-Interface für eine PHP-basierte "Supply Chain Netzwerk-Simulation". Dabei kommen Javascript, jQuery und Ajax zum Einsatz. Vor kurzem mussten wir uns mit dem Problem befassen, wie man mit periodischen Ajax-Jobs den zwischenzeitlichen Status von lang laufenden PHP-Berechnungs- und Simulationsjobs abfragt. Dabei sind wir in zwei Fallen getappt, die uns eigentlich hätten bekannt sein müssen. Aber inaktives Wissen schützt manchmal nicht vor naiven Herangehensweisen :-).

Ich stelle die relevanten Punkte in mehreren Blog-Beiträgen mal für interessierte Leser zusammen. Die erste potentielle Falle liegt auf der PHP-Seite und hat mit Sessions zu tun; die zweite Falle liegt im JS/jQuery-Bereich des Clients und hat damit zu tun, dass sich die Bedeutung der "this"-Referenz kontext-abhängig und damit trotz konsequenten Einsatzes von Objekt-Patterns ggf. unerwartet ändern kann.

Aufgabenstellung

Wir haben es mit folgender Aufgabe zu tun:
Ein lang laufender PHP Job [wir nennen ihn nachfolgend "RUN"] wird von einem User-Interface (Web-Seite im Browser - nachfolgend "CLIENT" genannt) gestartet. In unserem Fall ist RUN z.B. eine 60 Sekunden lang laufende PHP-Simulationsberechnung. Dieser PHP-Job schreibt seinen sehr langen fachlich-technischen Ergebnis-Output einerseits in Datenbanktabellen, aber Teile davon zur direkten User-Information sukzessive auch in ein separat geöffnetes zweites Fenster des Browsers. Gleichzeitig - d.h. während der Laufzeit - wollen wir aber zu verschiedenen Zwischenschritten des RUNs knappe Statusmeldungen abfragen und diese in speziellen DIVs des ursprünglichen CLIENT-Windows anzeigen. Um diese Zielsetzung zu erreichen, sehen wir 3 Schritte vor:

  1. Unmittelbar vor dem Start von RUN über eine Submit-Ereignis öffnen wir per Javascript ein zweites Browserfenster.
  2. RUN wird vom CLIENT-Window per Javascript und eine gezielte "submit"-Methode mit geeignetem Target so gestartet, dass sein primärer fachlich/technischer Output an das zuvor geöffnetes zweite Browserfenster übermittelt wird.
  3. RUN schreibt seine Statusmeldungen auf dem PHP-Server kontinuierlich in einen selbst verwalteten Message-Buffer.
  4. Vom CLIENT-Window aus starten wir kurz nach dem Submit von RUN mittels Javascript und JQuery periodisch Ajax-PHP-Jobs, die die Änderungen im Message-Buffer des Servers abfragen und zum CLIENT transferieren. Diese kurz laufenden Ajax-Jobs nennen wir nachfolgend "CHECKER".
     
    Die durch die CHECKER ermittelten Statusdaten werden z.B. als JSON-Daten über den jeweils geöffneten Ajax-Kanal zum CLIENT transferiert. Dort werden sie über Callback-Methoden definierter Javascript-Objekte ausgewertet, aufbereitet und mit jQuery in die dafür vorgesehenen, speziellen DIVs des CLIENTs geschrieben.
    [Für den periodischen Start kann man Javascripts "setInterval" einsetzen und über die ermittelten Status-Daten den Timer am CLIENT nach dem Ende des RUN-Jobs auf dem Server auch wieder abbrechen.]

Unser erster naiver Ansatz für den Message-Buffer war der, den PHP-Sessionspeicher als Ort eines Message-Arrays zu nutzen. Ein Motiv dafür war der technisch einfache Zugriff auf die Daten des Sessionspeichers. Mit diesem Ansatz sind wir (natürlich) kläglich gescheitert.

Falle 1: Locked $_Session-Array

Normalerweise nutzen wir Ajax im Rahmen unseres CMS-Frameworks "ixCMF". Damit werden Formular- und Webseiten des CMS selbst, wie später auch die Ergebnisseiten dynamisch auf Basis von Datenbank-Inhalten und PEAR-ITX-Templates erstellt. Das funktioniert zuverlässig und schnell. Die zum Browser übermittelten HTML-Seiten nutzen dann wiederum Ajax-Funktionalitäten, um bestimmte Transaktionen wie ein Zwischenspeichern oder Abfragen von Daten im Hintergrund asynchron zu bewältigen.

Für Transaktionen des CMS wird auch der PHP Session-Speicher genutzt. Strukturell und vom Bedienungsablauf sind die Verhältnisse allerdings so, dass über die ixCMF-PHP-Programme und Ajax-Jobs ein Zugriff auf den Sessionspeicher während einer Websitzung des Users sequentiell und damit synchron abläuft. Über parallele, asynchrone Abfragen des Sessionspeichers durch mehrere gleichzeitig arbeitende PHP-Ajax-Jobs mussten wir uns bislang nie Gedanken machen. Da die Ajax-Jobs den Sessionspeicher lesend nutzen, gab es auch keinen Anlass, sich über potentielle Inkonsistenzen aufgrund parallel schreibender Jobs Sorgen zu machen.

Denkt man über das oben als Aufgabe beschriebene Szenario aber erst einmal in allgemeingültiger Form nach, dann fallen einem jedoch zwei Dinge auf:

  • Es kann zu Race-Conditions kommen. Parallel laufende Jobs können sich gegenseitig Session-Daten zerstören, wenn sie gleichzeitig schreibend auf ein und denselben Sessionspeicher zugreifen dürfen.
  • Sequentielle Zugriffe auf den Sessionspeicher lassen sich vom Webserver und nicht zuletzt auch von den PHP-Programmen selbst besser steuern und überwachen. Dies dient u.a. der Sicherheit. Ein parallel erlaubter Zugriff würde viele mögliche Schutz-Mechanismen wie die sequentiell Vergabe und Überwachung von kryptierten zufälligen Transaktionsnummern oder zusätzliche zeit- und ID-bezogene Schutzmechanismen über sequentiell vergebene kryptierte Cookies, die über eine Session hinweg verfolgt werden sollen, von vornherein aushebeln.

Der erste Punkt ist aufgrund der speziellen Session-Behandlung von PHP im Detail ggf. noch komplexer, als man meinen möchte. Siehe hierzu die gründliche Diskussion unter folgendem Link.
https://00f.net/2011/01/19/thoughts-on-php-sessions/

Der zweite Punkt hätte uns eigentlich selbst sofort zu denken geben müssen, da wir entsprechende Session-Schutz-Mechanismen im Rahmen unseres ixCMF-Frameworks selbst programmiert und immer wieder genutzt haben.

Jedenfalls gilt: Aus den genannten Gründen wird der Standardzugriff auf Sessions von PHP sequentialisiert:

PHP -Programme können nur sequentiell auf einen Standard-Session-Speicher zugreifen. Der Job, der sich gerade über session_start() den Zugriff verschafft hat, sperrt den Sessionspeicher gegen Zugriffe von anderen Programmen vollständig, bis er die Session über "session_commit" oder "session_write_close" freigegeben hat. Oft geschieht dies erst implizit am Ende der Laufzeit eines PHP-Programms.

Zu welchem Verhalten führt das in unserem Szenario ? Ganz einfach:

RUN blockt den Sessionspeicher bis zum Ende seiner Laufzeit und alle zwischenzeitlich gestarteten Ajax-Jobs müssen bis dahin warten. Damit aber wird eine zwischenzeitliches Verfolgen der Statusmeldungen von RUN unmöglich. Sprich: der ganze geplante parallele und periodische Abgriff von Statusinformationen, die RUN in S_SESSION hinterlegen sollte, funktioniert nicht. Es kommt vielmehr zu einem sinnlosen Stau der CHECKER-Jobs und jeder dieser Jobs ermittelt nach Abschluss von RUN Statusdaten raus, die bereits veraltet sind.

Das gleiche Sperr- und Warteverhalten erleben viele User und Administratoren in schlecht durchdachten Systemen, wenn schnell hintereinander gestartete PHP-Jobs die gleiche Session nutzen sollen oder müssen.

session_write_close() ist keine Lösung!

Nun empfehlen viele durch ähnliche Probleme Betroffene in Internet-Beiträgen die frühest mögliche Anwendung von "session_write_close()" als Lösung. Siehe hierzu die am Ende des Beitrags als Beispiele angegebenen Links. Die Idee ist eine frühzeitige Freigabe des Sessionspeichers durch das jeweilige Programm, das aktuell auf den Sessionspeicher zugreift. Das mag ja in vielen Fällen gehen, nicht aber in unserem:

Hat ein Programm den Sessionspeicher über "session_commit()" oder "session_write_close()" erst einmal freigegeben, können zwar andere Programme auf den Sessionspeicher zugreifen, es selbst aber während seiner Laufzeit nicht mehr !

Die kurz laufenden Ajax-CHECKER-Jobs könnten session_write_close() nutzen - nicht aber RUN. Denn RUN muss fortwährend Statusinformationen in den Sessionspeicher schreiben - und die erste Session-Freigabe über die genannten Kommandos würde nachfolgende Schreibaktionen in die Session unmöglich machen.

Lösung für Falle 1: Nutze eine Datenbank zum Schreiben von Statusinformationen lang laufender Jobs

Wir haben uns dann sinnvollerweise entschlossen, als Ort für den Status-Message-Buffer von RUN eine spezielle Tabelle in einer MariaDB/MySQL-Datenbank zu nutzen, die für die Simulationsrechnungen sowieso erforderlich ist. Eine Alternative wäre ein File als Message-Buffer gewesen. Wir haben Files aber verworfen, weil sie aus unserer Sicht wieder andere Nachteile haben und uns der Zugriff auf einen Datenbank-Tabelle letztlich einfacher, flexibler und ausbaufähiger erschien. Zumal in unserem auf Datenbank-Nutzung ausgelegten Framework.

Die Nutzung einer Datenbank hat über die Lösung des oben besprochenen Session-Locks hinaus noch andere Vorteile:

  • Es zwingt zur sauberen Strukturierung der Status-Information. Das vereinfacht zudem auch den XML- oder JSON-Transfer der Daten im Zuge der periodischen Ajax-Prozess.
  • Erweiterungen sind zügig möglich. Die Feldstruktur ist über Schema-Verfahren insgesamt schnell geändert.
  • Es ist einfach, nur die seit dem letzten CHECKER-Zugriff neu eingetragenen Daten zu selektieren.
  • Liegt die Datenbank auf ein- und demselben LAMP-Server , ist sowohl der schreibende Zugriff durch RUN als auch der lesende Zugriff durch die CHECKER wegen der Kürze der Tabelle extrem performant. Man kann das Intervall für die zu startenden Ajax-CHECKER auf diese Performance hin anpassen.
  • Man kann die Timestamps der Datenbank nutzen, um genaue Zeitinformationen über das Ende der Zwischenschritte zu erhalten.
  • Die Statusinformation kann über die Laufzeit und Garbage Collection Time hinaus aufbewahrt und in weiteren Tabellen historisiert werden.
  • Für eine Historisierung von RUNs kann man in der Bank noch andere nützliche Daten hinterlegen.

Je nach Netz-Anbindung an den Server und dessen Leistungsfähigkeit können wir durch Nutzung der Datenbank als Message-Buffer von RUNs nun mit CHECKER-Jobs im Abstand von 250 bis 500 msec arbeiten, ohne Server und Client übermäßig zu belasten. Damit eröffnen sich während der Laufzeit der Simulationen alle Möglichkeiten einer kontinuierlichen, sehr fein-granularen Statusinformation im gleichen CLIENT-Window, von dem aus der RUN-Job ursprünglich gestartet wurde. Eine Darstellung von fachlich-technischem Output in einem zweiten Browserfenster während der Laufzeit bleibt davon unberührt und ist unbenommen möglich.

In einem kommenden zweiten
Fallen beim Statuscheck lang laufender PHP-Jobs mit Ajax – II
befassen wir uns prophylaktisch mit potentiellen "this"-Fallen auf der Javascript/jQuery-Seite der Ajax-Prozesse. In späteren Beiträgen werden wir dann die gewonnen Erkenntnisse zu einem Verfahren zusammensetzen, das es erlaubt den "RUN" wie die "CHECKER"-Jobs systematisch mit Ajax zu behandeln. .

Links

Nutzung von commit_session oder write_close-session
http://konrness.com/php5/how-to-prevent-blocking-php-requests/
http://blog.preinheimer.com/index.php?/archives/416-PHP-and-Async-requests-with-file-based-sessions.html
http://www.held.org.il/blog/2008/02/php-session-locks/

Sicherheit
http://php.net/manual/de/session.security.php
http://phpsec.org/projects/guide/4.html