Morphium 6.3.1 - und der durchsichtige Message-Bus
Zwei Wochen nach 6.3.0 haben wir gerade Morphium 6.3.1 released. Auf dem Papier ein Bugfix-Release - tatsächlich stecken da die ersten zwei Produktionswochen von 6.3.0 drin, ein neues Messaging-Feature und einige Fixes, die ehrlich gesagt niemand gefunden hätte, ohne dem Message-Bus live bei der Arbeit zuzusehen. Und genau darüber will ich diesmal auch schreiben: warum ein Message-Bus, dessen Nachrichten einfach Dokumente in einer Datenbank sind, eine ganz andere Art von System ist als Kafka und Co. - beim Testen und in Produktion. Aber der Reihe nach...
Was ist neu in 6.3.1?
Messaging: der Change-Stream filtert jetzt serverseitig
Der wichtigste Fix zuerst. Bisher hat der Change-Stream-Cursor jedes Consumers jeden Insert in die Messaging-Collection zu sehen bekommen - auch Nachrichten, die an ganz andere Empfänger adressiert waren, inklusive der kompletten Payloads großer Antworten fremder Producer. Unter hohem Traffic (bei uns z.B. bei Dokument-Export-Bursts) fiel der Cursor dadurch zurück, und Nachrichten kamen nur noch über den Fallback-Poll an - mit entsprechender Latenz.
Der Haupt-Change-Stream wird jetzt mit einem serverseitigen $match aufgebaut, das nur noch durchlässt, was die Instanz auch wirklich verarbeiten kann: an sie adressierte Nachrichten, Broadcasts für Topics mit registriertem Listener, Antworten. Wenn sich die Listener zur Laufzeit ändern, wird der Stream mit dem neuen Filter neu aufgebaut. Kleines Detail am Rande: Nachrichten von V5-Sendern haben nur ein name-Feld statt topic - der Filter berücksichtigt beide, sonst wären Alt-Sender einfach still rausgefiltert worden.
In die gleiche Kategorie fällt #286: der Callback des Lock-Change-Streams hat pro gelöschtem Lock eine countAll-Query ausgeführt - und zwar auf dem Change-Stream-Thread. Bei einem Burst von Lock-Freigaben hat der Stream sich damit quasi selbst blockiert. Die Query ist ersatzlos gestrichen, ein Zähler koalesziert beliebig viele Lock-Events zu genau einem Poll.
Dazu noch zwei Robustheits-Fixes aus dem Code-Review: ein fehlgeschlagener Neuaufbau des Streams wird jetzt beim nächsten Tick erneut versucht (bisher hielt sich die Instanz für aktuell und lief ohne Haupt-Change-Stream einfach weiter), und die Listener-Registry wird nicht mehr in place verändert, während der Poll-Thread sie liest.
Neu: Implementierungs-Mismatch wird erkannt
Morphium bringt inzwischen drei Messaging-Implementierungen mit: StandardMessaging, MultiCollectionMessaging und seit 6.3.0 DualChannelMessaging (Beta). Die Collection-Layouts der drei sind nicht kompatibel - und das Schlimmste daran war bisher: ein gemischter Betrieb scheiterte lautlos. Broadcasts liefen weiter, aber Antworten landeten in einer Collection, die die Gegenseite nie liest. "Das meiste funktioniert, nur Antworten kommen nie an" ist so ziemlich das fieseste Fehlerbild, das man sich vorstellen kann.
Ab 6.3.1 meldet jede Messaging-Instanz beim Start ihre Implementierung in einer layout-unabhängigen <queue>_participants-Collection an (mit Heartbeat) und prüft, was die anderen Teilnehmer so fahren. Der Kanal ist bewusst nicht das Messaging selbst - zwischen zwei Implementierungen ohne gemeinsame Collection käme die Warnung ja nie an 😉. Bei einem Mismatch wird per Default gewarnt, wer es strenger mag:
Damit verweigert eine falsch konfigurierte Instanz den Start, statt still Antworten zu verlieren.
In-Memory-Driver: die Korrektheitsoffensive geht weiter
6.3.0 stand unter dem Motto "grüne Tests gegen den In-Memory-Driver müssen etwas bedeuten". Das geht in 6.3.1 weiter - mit drei Fixes, die es in sich haben:
- Ein Index auf einem Array-Feld lieferte bei Gleichheitsabfragen still eine leere Ergebnismenge (#289). Multikey-Indexe sind im Index-Store nicht implementiert, trotzdem hat der Query-Planner solche Indexe benutzt. Was das in einem realen System bedeutet: ein Job-Scheduler, der mit
processed_by == "X"Arbeit selektiert, findet nichts - und gibt nichts frei. Solche Indexe werden jetzt als multikey erkannt (auch wenn das Array mitten im Pfad liegt, also{"a.b": 1}über{a: [{b: ...}]}) und aus der Planung genommen. Korrektheit vor Geschwindigkeit. - Change-Stream-Events kamen unter Last in falscher Reihenfolge an. Der Dispatcher hat jedes Event als eigenen Task an einen Thread-Pool übergeben - und ein Pool garantiert nun mal keine Reihenfolge. mongod garantiert die Event-Reihenfolge pro Cursor, jetzt tut der In-Memory-Driver das auch (ein Dispatcher-Thread, unbounded Queue, Writer blockieren weiterhin nie). Aufgefallen ist das übrigens nur, weil ein neuer Regressionstest auf dem parallel belasteten Test-Runner sporadisch rot wurde - auf idle Hardware war die Race seit März unsichtbar. Solche Bugs findet man nicht im Debugger.
- Die Operation-Types
updateundreplacestimmen jetzt exakt mit mongod überein (#288). EinreplaceOneerzeugte bisher gar kein Event (d.h. die Änderung war für jeden Watcher unsichtbar - auch für die PoppyDB-Replikation!), und einstore()eines existierenden Dokuments meldetereplace, wo mongod für das dahinter liegende$set-UpdateupdatesamtupdateDescriptionmeldet. Wer seine Change-Stream-Konsumenten gegen den In-Memory-Driver testet und gegen MongoDB betreibt, bekommt jetzt in beiden Welten dieselben Events.
Außerdem: Collection- und Index-Erzeugung sind jetzt atomar (zwei parallele createUser konnten vorher beide gewinnen), und die PoppyDB-Replikation wendet Events direkt beim Eintreffen an, statt auf einen 5ms-Flush-Takt zu warten - spürbar weniger Lag auf den Secondaries.
PoppyDB als Testsystem
Warum eigentlich der ganze Aufwand für einen In-Memory-Driver? Weil er das Fundament von PoppyDB ist - und PoppyDB unser Testsystem.
Das Argument ist einfach: PoppyDB spricht das MongoDB-Wire-Protokoll, startet in ein paar hundert Millisekunden, braucht weder Docker noch Installation und verhält sich - dank genau dieser Korrektheitsoffensive - wie mongod, bis runter zu Event-Reihenfolgen und Fehlercodes. Unsere Testmatrix läuft in fünf Phasen: In-Memory, MongoDB-ReplicaSet, PoppyDB-ReplicaSet, MongoDB-Single, PoppyDB-Single - dieselben ~215 Testklassen gegen alle Backends. Jede Abweichung zwischen In-Memory-Verhalten und mongod ist ein Bug. Und #288/#289 zeigen, dass das ernst gemeint ist: beide wurden gefunden, weil Tests auf dem einen Backend grün und auf dem anderen rot waren - und beide Male war der Fix nicht "Test anpassen", sondern "Driver reparieren".
Für die eigene CI heißt das: ein poppydb-Artefakt im Test-Scope, ein Port, fertig. Kein Container-Startup, kein flaky Netzwerk. Und wer will, testet Failover-Szenarien gegen ein echtes 3-Knoten-ReplicaSet aus drei JVM-Prozessen.
...und als Message-Broker in Produktion
Jetzt wird's interessanter: für Messaging ist PoppyDB nämlich nicht nur ein Testsystem.
Nachrichten sind von Natur aus vergänglich - Morphium-Messages haben eine TTL und werden nach der Verarbeitung gelöscht. Daten, die sowieso in Sekunden ablaufen, auf ein Write-Ahead-Log zu schreiben, ist bezahlte Durabilität, die niemand abholt. Genau da passt ein replizierter In-Memory-Store: ein 3-Knoten-PoppyDB-ReplicaSet deckt Knotenausfälle über Replikation und Raft-Failover ab, die Memory-Watermark macht aus Überlast wiederholbare Backpressure statt einem OOM, und seit 6.3.0 gibt es SCRAM-Auth, TLS und Konfigurationsdateien dazu.
Der Effekt ist messbar. Identischer Morphium-Messaging-Code auf beiden Seiten, beide Systeme als 3-Knoten-ReplicaSet auf derselben Maschine:
| Round-Trip (ms) | PoppyDB RS | MongoDB RS | Faktor |
|---|---|---|---|
| avg | 2,64 | 59,5 | ~22x |
| p50 | 2,43 | 59,1 | ~24x |
| p99 | 6,70 | 79,8 | ~12x |
| Jitter | 0,66 | 6,48 | ~10x |
Der Abstand ist strukturell, kein Tuning: MongoDB-Change-Streams liefern nur majority-committete Events aus - jeder Messaging-Hop bezahlt also Replikation plus Journal-Commit. Das ist ein Latenzboden von ~35ms, und der kauft Durabilität. PoppyDB liefert direkt aus dem Speicher, weil es schlicht nichts zu persistieren gibt. Man bekommt die Latenz, weil man das Verlustmodell akzeptiert hat: kein WAL heißt, ein clusterweiter Ausfall verliert die in-flight Nachrichten. Für Events, Cache-Invalidierung und Job-Trigger mit senderseitigem Retry ist das der richtige Deal. Für garantierte Zustellung ist es das falsche Werkzeug - das steht übrigens genau so auch in der Doku, d.h. hier wird nichts schöngeredet.
Dazu kommt etwas, das ein generischer Store nicht bieten kann: PoppyDB kennt Morphium-Messaging. Instanzen registrieren ihre Messaging-Collection beim Server (registerMessagingCollection), und der Server pusht dann z.B. lock_released-Events über den Haupt-Change-Stream - der Client braucht keinen zweiten Cursor auf die Lock-Collection, und exklusive Nachrichten werden event-getrieben statt poll-getrieben neu verteilt. Der Broker optimiert für das Protokoll, das auf ihm läuft.
Der durchsichtige Bus
Und damit zum eigentlichen Punkt. Der größte Unterschied zwischen diesem Messaging und einem klassischen Broker ist nicht Durchsatz oder Latenz - es ist, dass der Bus durchsichtig ist. Nachrichten sind Dokumente in einer Collection. Man kann sie abfragen, über sie aggregieren und sie per Change Stream beobachten, ohne dass das Messaging davon irgendwas mitbekommt.
Mitlesen ohne zu konsumieren
Im Post über unseren Enterprise-Message-Bus habe ich das Muster schon mal beschrieben, aber es gehört hier in den Vordergrund. Bei uns läuft ein MessageBusPeeker: ein Service, der einen Change Stream auf die Messaging-Collection öffnet und alles mitliest - welcher Request wann kam, ob und wann die Antwort dazu einging, Round-Trip-Zeiten pro Nachrichtentyp, und welche Requests nach zwei Minuten keine Antwort hatten. Daraus entstehen stündliche und tägliche Zeitreihen pro Topic: Volumen, Antwortzeiten, Unbeantwortet-Quoten.
Das Entscheidende: der Peeker konsumiert nichts. Er bestätigt keine Nachrichten, verschiebt keine Offsets, nimmt an keinem Rebalancing teil und beeinflusst die Zustellung in keiner Weise - Sender und Empfänger wissen nicht mal, dass es ihn gibt. Er ist einfach ein weiterer MongoDB-Client mit Lesezugriff auf eine Collection. Bei Kafka bräuchte man dafür eine eigene Consumer-Group samt Offset-Verwaltung - hier sind es ein Change Stream und hundert Zeilen Code. Der neue serverseitige Topic-Filter ändert daran übrigens nichts: der filtert die Cursor der Consumer, ein Beobachter hängt sich mit seiner eigenen Pipeline an dieselbe Collection.
Morpheus: das Cockpit für den laufenden Bus
Was der Peeker als Dienst im Hintergrund macht, macht Morpheus interaktiv: eine Terminal-UI (plus skriptbares CLI), die sich an den laufenden Bus hängt. Produktion oder Test, MongoDB oder PoppyDB - dank Wire-Protokoll ist das derselbe Handgriff.
messages- ein Live-Monitor imtop-Stil: Sender, Topic, Verarbeitungszustand, Exklusivität, Antwort samt Round-Trip-Zeit, Timeout-Hervorhebung.topicsundnodes- Aggregate pro Topic bzw. pro Sender/Antworter-Paar: Volumen, mittlere RTT, Timeouts. Lastschieflagen zwischen Knoten sieht man hier zuerst.status- Gesundheitsmonitor pro Knoten (Heap, Cache-Hit-Ratio, Verbindungen, Fehler, Threads), mit Drill-Down in den kompletten Status-Dump. Basiert auf dem eingebautenmorphium_status-Topic, das jede Messaging-Instanz sowieso beantwortet.graph- der Nachrichtenfluss als animierter Graph: Knoten auf einem Ring, Nachrichten als topic-gefärbte Schüsse, Timeouts in rot. Zugegeben: braucht kein Mensch, sieht aber großartig aus 😉latency- Live-Latenzgraph und Lasttest in einem: Intervall-, Festraten- und Rampen-Modus, Perzentil-Tabelle pro Responder, dazupongals Echo-Responder für die Gegenseite. Die PoppyDB-Zahlen weiter oben kommen genau aus diesem Harness (latency --headlessschreibt JSON/CSV/Graphite).
Der Punkt ist nicht das einzelne Feature. Der Punkt ist, dass es das alles gegen das laufende System gibt - ohne Agent, ohne Instrumentierung, ohne Deployment-Änderung. morpheus messages -c prod, und man sieht, was der Bus gerade tut. Mehrere der Fixes in 6.3.0 und 6.3.1 - der zurückfallende Cursor hinter #283, die Lock-Stalls hinter #286 - wurden genau so gefunden: nicht im Debugger, sondern beim Zuschauen.
Diese Beobachtbarkeit ist keine Zugabe, sie ist eine direkte Folge der Architektur: weil die Queue eine Datenbank ist und kein Log, ist jedes Datenbank-Werkzeug automatisch auch ein Bus-Werkzeug.
Installation
Morphium 6.3.1 ist wie immer über Maven Central verfügbar:
<groupId>de.caluga</groupId>
<artifactId>morphium</artifactId>
<version>6.3.1</version>
</dependency>
Und für Tests (oder den Broker):
<groupId>de.caluga</groupId>
<artifactId>poppydb</artifactId>
<version>6.3.1</version>
<scope>test</scope>
</dependency>
Keine API-Änderungen, keine neuen Dependencies, kein Migrationsaufwand gegenüber 6.3.0. Die Issues #280, #283, #286, #288 und #289 sind mit diesem Release geschlossen, Details wie immer im CHANGELOG auf GitHub.
Wer 6.3.0 mit Messaging auf PoppyDB betreibt, sollte zügig aktualisieren - die Change-Stream-Fixes (#288, Event-Reihenfolge) betreffen dort auch die Replikation. Alle anderen bekommen ein Release, das vor allem eines tut: dafür sorgen, dass das, was der Bus meldet, auch stimmt.