Caluga - Java blog

Morphium 6.3.0 — Was ist neu?

Morphium 6.3.0 — Was ist neu?

Morphium 6.3.0 ist das größte Release der 6.x-Reihe. Der Schwerpunkt liegt diesmal nicht beim Client, sondern bei PoppyDB und dem darunterliegenden In-Memory-Driver. Beide sind von „gut genug, um Tests dagegen laufen zu lassen" zu etwas geworden, das man tatsächlich betreiben kann — mit Authentifizierung, Konfigurationsdateien, Speicher-Leitplanken, echten Größenlimits und einer Replikation, die nichts mehr verliert oder umsortiert. Dazu kommen zwei neue optionale Module, die Morphium in Jakarta-Data- und Quarkus-Anwendungen bringen, und eine dritte Messaging-Implementierung.

Wer Morphium ausschließlich gegen echtes MongoDB einsetzt und weder den eingebetteten Driver noch PoppyDB anfasst, kann das meiste hier überspringen — für den sind Messaging und Upgrade relevant.

Zwei neue optionale Module

morphium-jakarta-data

Ein Jakarta-Data-1.0-Provider auf Basis von Morphiums bestehender Query-Engine. Man deklariert ein Repository-Interface, Morphium leitet die Queries ab:

@Repository
public interface OrderRepository extends CrudRepository<Order, MorphiumId> {
    List<Order> findByCustomerAndStatusOrderByCreatedDesc(String customer, Status status);
    long countByStatus(Status status);

    @Query("SELECT category, SUM(amount) FROM Order WHERE status = :s GROUP BY category")
    List<Object[]> revenueByCategory(@By("s") Status status);
}

Ableitung aus Methodennamen mit dem kompletten Standard-Keyword-Set, JDQL über @Query (inklusive GROUP BY/HAVING, übersetzt in eine Morphium-Aggregation-Pipeline), @Find/@Delete mit explizitem @By-Binding, Offset-Pagination (Page<T>) und Cursor-/Keyset-Pagination (CursoredPage<T>), statische und dynamische Sortierung.

Das Modul ist bewusst framework-agnostisch — reines Java, kein Quarkus, kein Spring, kein DI-Container — weil es von Framework-Integrationen konsumiert werden soll und nicht direkt eingebunden. Die Abhängigkeitsrichtung ist streng einseitig: Der Core weiß nichts von Jakarta Data, wer nur de.caluga:morphium deklariert, bekommt jakarta.data-api also nicht in den Classpath.

quarkus-morphium

Die Quarkus-Integration, aufbauend auf dem Modul darüber: ein CDI-Producer für Morphium, typsichere Konfiguration über @ConfigMapping (quarkus.morphium.*), deklarative @MorphiumTransactional-Transaktionen mit CDI-Events, MicroProfile-Health-Checks, Dev Services (ein automatisch gestarteter MongoDB-Container, wahlweise als Single-Node-ReplicaSet), eine Dev-UI-Karte und GraalVM-Native-Image-Support mit automatischer Reflection-Registrierung für jede @Entity. Die Repository-Implementierungen entstehen zur Build-Zeit per Gizmo-Bytecode — keine Reflection zur Laufzeit, keine dynamischen Proxies.

Wer die Extension bereits einsetzt, muss die Maven-Koordinaten ändern. Sie wurde bisher als io.quarkiverse.morphium:quarkus-morphium:1.2.0 veröffentlicht, liegt aber gar nicht in der Quarkiverse-Organisation. Sie folgt jetzt Morphiums eigener groupId und wird im Gleichschritt mit dem Reactor versioniert:

<dependency>
    <groupId>de.caluga</groupId>          <!-- vorher: io.quarkiverse.morphium -->
    <artifactId>quarkus-morphium</artifactId>
    <version>6.3.0</version>              <!-- vorher: 1.2.0 -->
</dependency>

Keine Paketumbenennungen, keine API-Änderungen — nur die Koordinaten wandern.

Beide Module stammen aus Heiko Bardiocs Repositories morphium-jakarta-data und quarkus-morphium, die nun archiviert werden, da ihr Inhalt ins Hauptrepository umgezogen ist. Ein Build des Reactors mit -DskipExtensions erzeugt weiterhin einen reinen Core-Build.

Messaging

DualChannelMessaging — eine dritte Implementierung, in Beta

Lastmessungen haben gezeigt, dass der Request/Reply-Durchsatz auf MongoDB nicht schreib-, sondern zustellungsgebunden ist: Ein einzelner Change-Stream-Cursor gibt majority-committete Events in festem Takt heraus, was den Dauerdurchsatz deckelt — egal, wie schnell man Nachrichten anbietet. MultiCollectionMessaging schnitt in diesen Läufen besser ab, aber nicht wegen seiner Aufteilung in Collections pro Topic (auf mongod tailt ohnehin jeder Cursor das ganze Oplog). Der wirksame Mechanismus war sein zweiter Cursor für Antworten und Direktnachrichten.

DualChannelMessaging überträgt genau diesen einen Mechanismus auf das Standard-Layout: dieselbe einzelne Collection und derselbe Cursor für Broadcast- und Topic-Verkehr, dazu eine eigene Collection pro Empfänger mit eigenem Cursor und Dispatcher-Thread für gerichtete Nachrichten und Antworten.

cfg.messagingSettings().setMessagingImplementation("DualChannelMessaging");

Eines muss vor der Umstellung klar sein: Alle Teilnehmer einer Queue müssen dieselbe Messaging-Implementierung fahren. Das galt schon zwischen SingleCollectionMessaging und MultiCollectionMessaging — deren Collection-Layouts haben nichts gemeinsam — und Dual Channel ist keine Ausnahme. Eine Brücke zwischen den Layouts gibt es nicht, und eine Abweichung scheitert nicht laut: Broadcast- und Topic-Verkehr läuft weiter (Dual Channels Hauptspur ist Byte für Byte die von Standard), aber ein Standard-Knoten, der auf die Antwort eines Dual-Channel-Responders wartet, wartet für immer — diese Antwort wurde in die DM-Collection des Anfragenden geschrieben, die Standard nie liest. Es ist also eine Big-Bang-Umstellung: Konsumenten stoppen, Konfiguration auf jedem Knoten ändern, neu starten — und Request/Reply-Verkehr währenddessen leeren oder pausieren. Jede DualChannelMessaging-Instanz loggt beim Start eine WARN-Meldung, die genau daran erinnert.

Als Beta ist sie bewusst markiert: Der gemessene Nutzen fiel kleiner und differenzierter aus, als die ursprüngliche Motivation nahelegte. Jenseits der Sättigung tauscht sie etwas Durchsatz gegen deutlich bessere Tail-Latenz — p99 von 519 ms gegenüber 723 ms bei Standard und 2044 ms bei MultiCollection im Steady-State-Fenster. Opt-in, solange sie Praxiskilometer sammelt; der Vergleich der Messaging-Implementierungen hat die Details.

Und in allen Implementierungen

  • Ein Datenbank-Roundtrip weniger pro Nachricht — nicht-exklusive Nachrichten werden direkt aus dem fullDocument des Change Streams verarbeitet, statt neu gelesen zu werden.
  • Requeue-Nachrichten werden event-getrieben zugestellt statt auf den nächsten Poll zu warten.
  • Konfigurierbare Default-TTL und Fallback-Poll-Taktung, und der Fallback-Poll richtet sich jetzt nach der Lebendigkeit des Change Streams statt nach einem festen Timer.
  • Ein Trace der Verarbeitungsentscheidung zur Diagnose von Antwort-Timeouts — er sagt, warum eine Nachricht verarbeitet wurde oder eben nicht.
  • Übersprungene Nachrichten wurden fälschlich als „kürzlich abgeschlossen" markiert, was Requeues 10 Sekunden lang blockierte. Behoben.

PoppyDB wird betreibbar

Der Server hatte bisher genau einen Betriebsmodus: sperrangelweit offen. Für Tests reicht das, für sonst nichts. 6.3.0 schließt die Lücke.

Authentifizierung. Serverseitiges SCRAM-SHA-1 und SCRAM-SHA-256 (RFC 5802/7677, gegen die RFC-Testvektoren validiert), inklusive MongoDBs Eigenheiten — MD5-gehashtes Passwort für SHA-1, SASLprep für SHA-256, der dreistufige Austausch, den Clients wie mongosh verwenden. createUser legt jetzt wirklich Benutzer an, mongod-förmig in admin.system.users abgelegt. Die Verifikation ist immer aktiv, die Durchsetzung ist Opt-in:

poppydb  --auth  --rootUser admin --rootPassword secret  --ssl  --sslKeystore keystore.jks

Mit --auth darf eine Verbindung nur Handshake, SASL, logout, ping und buildInfo ausführen, bis sie einen SCRAM-Austausch abgeschlossen hat; alles andere wird mit Code 13 abgelehnt. Eine Localhost-Ausnahme gibt es nicht. Ohne --auth ändert sich für bestehende Setups nichts. Falsche Passwörter und unbekannte Benutzer werden ununterscheidbar abgelehnt, User-Enumeration ist also nicht möglich. Die Autorisierung ist vorerst reine Authentifizierung — Rollen werden gespeichert, aber nicht ausgewertet.

Benutzer, die man versionieren kann. --rootUser hat immer nur einen einzigen Admin angelegt, jeder reale Benutzerbestand musste weiterhin von Hand erzeugt werden. --users-file provisioniert deklarativ, mit Upsert-Semantik und einem Versions-Gate, damit ein Config-Management-Lauf die Datei wiederholt anwenden kann, ohne Überraschungen zu produzieren. Und Benutzer replizieren jetzt über das ReplicaSet, überleben also einen Failover, statt nur auf dem Knoten zu existieren, der sie zufällig angelegt hat.

Konfigurationsdateien. --cfg <datei> (oder -f) hält Secrets von der Kommandozeile und aus der Prozesstabelle fern, --no-config ignoriert sie komplett. --print-config zeigt die effektive Konfiguration, --check-config validiert sie ohne Start. Und --log-level gibt es auch, der Server loggt also nicht mehr alles auf DEBUG.

Eine DevOps-Kommandofläche. Live-currentOp/killOp, rs.conf(), listCommands, hostInfo und echte Verbindungszähler. dbHash berechnet einen Hash pro Collection in kanonischer Dokumentreihenfolge — zwei ReplicaSet-Mitglieder mit denselben Daten liefern damit denselben Hash, obwohl die Synchronisation ihre Collections in unterschiedlicher Reihenfolge materialisiert hat. Ein Konsistenzcheck mit einem einzigen Kommando, bewusst auch auf Secondaries beantwortet. validate ist eine echte Prüfung statt eines Stubs: Es läuft jeden Index ab und meldet Einträge, die auf nicht mehr existierende Dokumente zeigen, sowie Dokumente, die im Index fehlen. top scheitert jetzt mit einem expliziten CommandNotSupported statt mit einem generischen „Kommando unbekannt" — echtes mongod hat das Kommando, also soll die Fehlermeldung sagen, warum PoppyDB es nicht hat.

Leitplanken

Ein In-Memory-Store stirbt an OOM, sobald Produzenten schneller sind als Konsumenten — und ein ReplicaSet stirbt vollständig, weil die Replikation das Datenvolumen auf jeden Knoten kopiert. Zwei Wasserstandsmarken sichern jetzt den Schreibpfad ab:

poppydb  --memory-warn 75  --memory-reject 90   # Prozent des max. Heaps; 100 = aus

Das Überschreiten der Warnschwelle wird einmal geloggt. Oberhalb der Ablehnschwelle werden dokumenterzeugende Schreibvorgänge mit einem mongod-förmigen ExceededMemoryLimit (Code 146) abgewiesen, das Clients als wiederholbaren Backpressure behandeln sollten. Updates, Deletes und TTL-Ablauf bleiben erlaubt — die Abflusspfade müssen weiterlaufen, sonst käme das System nie wieder unter die Marke. Replikation und Initial Sync umgehen die Sperre, denn ein Secondary, das ablehnt, was der Primary angenommen hat, würde stillschweigend divergieren.

Beide Stufen entscheiden anhand des Live-Sets nach GC, nicht anhand der rohen Heap-Belegung. Genau darin liegt das Feature: Mit -Xms gleich -Xmx steht die rohe used/max-Anzeige unter allokationslastiger Last routinemäßig über 90 %, auch wenn die nächste GC das meiste davon freiräumen würde. Der erste nächtliche ReplicaSet-CI-Lauf hat es bewiesen — die Variante mit der rohen Anzeige lehnte die Schreibvorgänge von acht ansonsten grünen Messaging-Testklassen ab, auf einem Heap, den die GC prompt auf 46 % senkte.

Zwei verwandte Limits sind jetzt ebenfalls ehrlich. Das 16-MB-BSON-Dokumentlimit wurde bisher nur angekündigt (vom eingebetteten Driver sogar als fantasievolle 128 MB), aber nie durchgesetzt — Updates konnten Dokumente unbegrenzt wachsen lassen, was kein echtes MongoDB akzeptieren würde. Es wird jetzt exakt wie bei mongod durchgesetzt, bis hin zur internen 16-KB-Marge bei Update-Ergebnissen, und bei Verletzung atomar zurückgerollt. Und maxMessageSizeBytes wird durchgängig respektiert: Die Batch-Bildung war bisher rein anzahlbasiert, 1000 × 1 MB Dokumente gingen also als eine ~1-GB-Nachricht hinaus, die jedes echte MongoDB mit dem Schließen der Verbindung beantwortet. Schreibkommandos zerlegen übergroße Payloads jetzt wie die offiziellen Treiber und falten die Ergebnisse zu einer mongod-förmigen Antwort zusammen — mit Write-Error-Indizes, die auf die ursprünglichen Statement-Positionen zurückgerechnet sind.

Der In-Memory-Driver schließt zu mongod auf

Der unglamouröseste Teil des Releases und vermutlich der wertvollste: eine lange Korrektheitsoffensive, Issue für Issue, damit grüne Tests gegen den In-Memory-Driver auch etwas bedeuten.

Neu in der Aggregation: $merge, $documents, $densify, $fill, $setWindowFields (mit dem vollständigen Satz an Fensterfunktionen), $collStats, $listSessions — und ein echtes $out statt eines vorgetäuschten. Rund 40 Expression-Operatoren kamen dazu, drei still falsch rechnende wurden korrigiert. Der Aggregator hat typisierte Builder-Methoden für die neuen Stages bekommen.

Bei Updates: die Positions-Operatoren $, $[] und $[<identifier>] mit arrayFilters (auch über die Query-API verfügbar), dazu $bit.

Und eine lange Liste stiller Fehler, von denen der alarmierendste war, dass $geoWithin mit $center, $centerSphere oder $polygon schlicht jedes Dokument der Collection traf. Datumsoperatoren rechnen jetzt in UTC mit 1-basiertem $month und echten ISO-Wochenfeldern; $dateFromParts liefert ein Datum statt seiner eigenen JSON-Struktur; der Inclusion-Modus von $project beschränkt die Ausgabe tatsächlich auf die gewählten Felder; $sample größer als die Collection liefert alles zurück, statt zu werfen; $indexStats führt nicht mehr klammheimlich $geoNear aus; Aggregation-Stages, die still $bucket ausführten, melden jetzt einen Fehler. Unbekannte Kommandos werden beantwortet, wie mongod sie beantwortet, statt eine Exception zu werfen.

Replikation und Failover

Die hier behobenen Fehlerbilder sind die Sorte, die man nur im Betrieb findet:

  • Ein neu synchronisierendes Secondary sendete seinen eigenen Initial-Sync-Wipe als Change-Stream-Drop-Events — womit veraltete Watcher während eines Stepdowns admin.system.users clusterweit zerstören konnten.
  • Die Replikation ist jetzt verlustfrei und reihenfolgetreu und umfasst auch Indexdefinitionen, nicht nur Dokumente.
  • Ein degradierter Leader konnte nach einem schnellen Führungswechsel dauerhaft primary == true behalten.
  • rs.status() meldete einen mit dem Failover gestorbenen Peer für immer als SECONDARY — und sprach dabei Raft-Terminologie, während es wildcard-gebundene Knoten falsch zuordnete.
  • --auth/--ssl funktionieren jetzt auch im ReplicaSet — der interne Wahl- und Replikationskanal war bisher immer unverschlüsselt und unauthentifiziert.
  • Auf Client-Seite konnte der Failover-Lesepfad eine nackte NPE an jedem Retry vorbei werfen, und getLastConnectFailure() blieb nach der Erholung veraltet stehen.
  • Das „Port belegt, Knoten wird übersprungen" von startPoppyDB.sh hat tatsächlich nicht übersprungen.

Die manuelle Failover-Reproduktion ist durch einen automatisierten Test ersetzt, der das Wire-Protokoll über einen Proxy umschreibt — damit bleiben diese Fehler behoben.

Performance

  • Die Duplikatsprüfung auf _id beim Insert ist ein O(1)-Indexzugriff statt eines vollständigen Collection-Scans unter dem Schreiblock. Dieser Scan war beim Messaging-Workload der dominierende Kostenfaktor pro Insert.
  • Das Before-Image des Change Streams wird nicht mehr doppelt tief kopiert.
  • Ein Rebuild-Pingpong zwischen offener Transaktion und parallelen Lesern auf derselben Collection ist beseitigt: Der Index-Store wird jetzt atomar an den neuen Eigentümer übergeben statt verworfen — im gemessenen Szenario 2 statt 20 Rebuilds.
  • Der Replay-Puffer des Change Streams ist in O(1) begrenzt, und dbStats/collStats melden echte Größen statt Nullen.

Der Bug, den man zweimal lesen sollte

Wer Morphium-Messaging auf PoppyDB betreibt, sollte genau deswegen auf 6.3.0 gehen:

invalidateTtlQueue() verwirft die Ablaufwarteschlange einer Collection bei jeder strukturellen Änderung und verlässt sich auf einen faulen Rebuild beim nächsten Fehlzugriff. Nur der Sweep hat sich an diesen Vertrag gehalten — ttlEnqueue() installierte eine frische Warteschlange, die nichts enthielt außer dem Dokument, für das sie gerufen wurde. Damit war die Warteschlange nicht mehr abwesend, der Rebuild feuerte nie wieder, und jedes Dokument, das vor der Invalidierung existierte, verlor seine Ablaufverfolgung dauerhaft.

Msg.deleteAt ist TTL-indiziert — genau so räumt Morphium-Messaging hinter sich auf. Ein Messaging-Knoten, der gegen eine PoppyDB mit bereits vorhandenen Nachrichten startete, riss das Fenster auf, und von da an liefen die vorhandenen Nachrichten nie wieder ab: unbegrenztes Wachstum der Message-Collection.

Transaktionen hatten ein Problem derselben Klasse — ein vor Transaktionsbeginn aufgebauter CollectionIndexStore blieb für die gesamte Laufzeit veraltet und konnte beim Commit still ein Update verlieren, und abgebrochene oder committete Transaktionen konnten veraltete Einträge hinterlassen, die auf einer nachweislich leeren Collection falsche Duplicate-Key-Fehler produzierten. Alles behoben.

Upgrade

<dependency>
    <groupId>de.caluga</groupId>
    <artifactId>morphium</artifactId>
    <version>6.3.0</version>
</dependency>

Und wer PoppyDB zum Testen einsetzt:

<dependency>
    <groupId>de.caluga</groupId>
    <artifactId>poppydb</artifactId>
    <version>6.3.0</version>
    <scope>test</scope>
</dependency>

Keine Abhängigkeits-Updates in diesem Release — Netty, BSON, SLF4J und Logback sind unverändert gegenüber 6.2.10.

Die Breaking Changes, die Deprecations und den groupId-Umzug von quarkus-morphium geht der Migrationsleitfaden der Reihe nach durch. Die vollständigen Release Notes stehen im CHANGELOG auf GitHub.

Besonderer Dank geht an Heiko Bardioc, dessen Downstream-Anwendungen in diesem Zyklus mehrere der Transaktions- und Index-Store-Regressionen gefunden haben, bevor sie in ein Release gelangen konnten.