Ja, keine Sorge, auch wenn der Titel nach einer philosophischen Mathematikdiskussion aussehen mag, nope, diesmal geht es um die wahre Geschichte, wie es dazu kam 3,14 Milliarden Azure Functions quasi parallel laufen zu lassen. Eine wahre Geschichte, denn mir ist es passiert.
Irgendwann im Oktober 2016, ich hatte kurz vorher einen Arbeitsvertrag unterschrieben, um in dem Unternehmen dafür zu sorgen, die anfallenden Daten von einigen externen Systemen zusammenzusammeln und aufbereitet für diverse Anwendungsfälle aufzubereiten. Eigentlich nichts bedeutendes. Die externen Systeme kannte man und ein paar Datapipelines waren schon eingerichtet - mit Logic Apps. Leider war die Performance nicht wirklich toll, was sicher daran lag, dass die Logic Apps nicht vernünftig eingerichtet waren. Zu diesem Zeipunkt auch nicht besonders schwer, denn diese Logic Apps der 2016er Jahre waren wirklich übel. Doch darum soll es hier nicht gehen.
Wie gesagt, ich war frisch in der Firma, hatte eine gewissen Antipathie den Logic Apps gegenüber entwickelt, da tauchten die Azure Functions in meinem Sichtfeld auf. Lasst es mich so ausdrücken - binnen weniger Minuten war ich Feuer und Flamme. Diese Functions taten genau das, was ich von der Cloud erwartete, sie haben mich von der Infrastruktur, um die ich mich sonst immer kümmern musste, befreit. ENDLICH keinen Infrastrukturcode mehr schreiben. Fast alles lässt sich mit den Bindings erledigen. Aber auch darum soll es hier nicht gehen, jedenfalls nicht vordergründig.
Nach einigen Tests, was man mit den Functions machen kann, haben wir in der Abteilung mehr oder minder festgelegt, die Logic Apps mit Functions zu ersetzen. Denn mit Hilfe der Functions kann man wenigstens sehen, wie die Daten geholt und verarbeitet werden. Und das allerbeste... Die Functions instanziieren, freundlich auch spawnen, sich selbst ins endlose, bei Bedarf. Ihr ahnt schon worauf es hinaus geht? :D
Die Aufgabe der Function App, die wir entwickeln wollten, war denkbar einfach. Kopiere Daten aus einer Datenbank A in die CosmosDB, damals noch DocumentDB. Super einfach.
Der erste Entwurf sah folgendermaßen aus. Eine Function App enthält genau eine Funktion, die alle Datensätze mittels einer Query aus der Quelle zieht und dann Satz für Ssatz in die Zieldatenbank schiebt. Dank der Bindings eine extrem einfache Angelegenheit. Das Laden aus der SQL Datenbank war noch etwas kompliziert, da SQL-Connections noch nicht sauber unterstützt wurden. Doch das ließ sich leicht lösen, Dank der Nähe zu den Azure App Services.
Der erste Entwurf war sofort erfolgreich. Die Daten sind geflossen wie.. wie... wie zäher Honig. Super langsam, wenn man bedenkt, dass man in der Cloud ist und nur 700.00 Datensätze zu bewegen hatte. Es dauerte etwa 30 Minuten, was viel zu lange ist.Das Problem lag auf der Hand. Eine Funktion tut alles.
Hier muss Parallelisiert werden.
Die zweite Version der Function App enthielt zwei Funktionen. Die erste zählt, wie viele Datensätze zu holen sind. Dieser Count ging sehr schnell. Damit konnte nun eine zweite Funktion beauftragt werden immer nur einen Datensatz zu laden. Hiermit sollte das Self-Spawning der Functions ausgenutzt werden. einfach 700.000 Anfragen gleichzeitig losfeuern und die Daten müssten nur so sprudeln.
SELECT * FROM [TABLE] ORDER BY [ID] OFFSET [ROWNUMBER] ROWS FETCH NEXT 1 ROWS ONLY;
So in etwa darf man sich das Statement vorstellen. ROWNUMBER ist der Parameter, der von der ersten Funktion geliefert wird, indem Nachrichten in eine Azure Storage Queue geworfen werden, sozusagen für jede ROWNUMBER eine Nachricht. 700.000 Nachrichten in die Queue werfen. Jede Nachricht ist nur eine Nummer zwischen 1 und der Anzahl der vorhandenen Datensätze. Clever nicht wahr? Bis zu dem Moment, wo man dem DB Admin dieses Modell zeigt. "Seid ihr wahnsinnig gegen meinen Server 700.000 Queries gleichzeitig zu feuern? Der platzt nach wenigen Sekunden." war seine Aussage.
Okay, dann eben nicht.
Wie wäre es dann, wenn wir eine rekursive Funktion schreiben, die Datensatz für Datensatz abarbeitet? Wir wissen, wie viele Datensätze wir haben, das bedeutet, wir können eine Nachricht in die Queue legen in der steht: Anzahl der maximalen Datensätze und Nummer des Datensatzes, der geladen werden soll. Damit werden nicht alle Daten gleichzeitig angefragt. Super Idee. Gesagt getan. die ersten Test waren extrem vielversprechend. Die Rekursion war perfekt und 100 Datensätze waren vergleichsweise schnell geladen. Bei einer Hochrechnung müssten wir mit der gesammten Pipeline bei ca 7 Minuten ankommen. Im Vergleich zur Logic App, die 4 Stunden brauchte, war das der Hammer.
Freitag 16 Uhr. "Lass uns das Ding jetzt starten. Wie lange es wirklich dauert, sehen wir ja am Montag, wenn wir zurück sind, steht ja im Log der Function." Tja, das waren meine Worte.
Der darauf folgende Montag. Ein Blick in die Function App zeigte, dass sie immer noch lief. Nanu, das dürfte doch gar nicht sein. Erstmal abbrechen und in der Queue schauen, wie viele Nachrichten dort liegen. PANIK macht sich breit.
Drei Komma Eins Vier Milliarden Messages liegen in der Queue, um abgearbeitet zu werden. Bei dem Wissen, dass jede Nachricht in der Queue "sofort" verarbeitet wird, wenn sie reingeworfen wird, legte nahe, dass es eben genau diese Anzahl an Funktionen gab, die gespawned wurden. Wie viele es tatsächlich waren, ist mir nicht wirklich bekannt, aber die Rechnung am Ende des Monats ließ darauf schließen, dass wir dicht dran waren.
Was war passiert? Naja, wenn man nicht weiß, wie eine Storage Queue tatsächlich funktioniert, dann kann man schnell übersehen, dass es möglich ist, dass sich Nachrichten überholen. Eine Queue ist halt nicht FIFO. Außerdem kommt erschwerend hinzu, dass in der Standardkonfiguration bis zu 16 Nachrichten gleichzeitig verarbeitet werden. Sprich, im Extremfall liegen 16 Nachrichten in der Queue, die dazu führen, dass 16 Funktionen gespawned werden. Jede der Funktionen wirft wiederum eine Nachricht in die Queue zurück, dass der nächste Datensatz geladen werden kann. Aus Datensatz 1 wird 2, aus 2 wird 3, aus 3 wird 4 und so weiter, nur doof, dass da nichts sequentiell verarbeitet wird. Das passiert parallel. Wo eben Datensatz 2 sagte, dass nun 3 geladen werden kann, hat Datensatz 1 gesagt, es darf (wieder) 2 geladen werden. Dass daraus schnell ein Schneeball wird, liegt auf der Hand. Ein Schneeball aus mehr als 3,14 Milliarden Schneeflocken.
Zum Glück ist die Queue extrem langsam. Ansonsten hätten wir vermutlich noch viel mehr Nachrichten darin gefunden.
Ich bin sehr froh dies erlebt zu haben, denn dieses Fehldenken hat mir einen tiefen Einblick in die Funktionsweise der Functions gezeigt und seither hat sich mein Denken zu den Functions verändert. Zum Einen beachte ich viel mehr, wie Sequenzen funktionieren und vor allem habe ich gelernt, was es bedeutet, stateless Functions zu verwenden. Ich bin ein riesen Fan geworden, auch weil sich meine Art des Schreibens von Software verändert hat.
Hier also mein Tip an euch, wenn ihr Functions verwenden wollt. Vergesst objektorientiertes Programmieren. Denkt in kleinen Funmktionseinheiten. Und vor allem Denkt in Triggern.