Wie funktioniert das Pulsar-Protokoll auf Leitungsebene?
In Fachgesprächen kam in letzter Zeit immer wieder mal eine eher grundsätzliche Frage auf. Teils beim Troubleshooting, teils aus Neugier – oder aus dem Gefühl heraus, dass man sich im Alltag sehr stark auf Abstraktionen verlässt:
Was passiert bei Pulsar eigentlich wirklich auf der Leitung?
Die Client-Libraries machen vieles angenehm einfach. Sie kapseln das Protokoll, verstecken Zustandsautomaten, übernehmen Retry-Logik, Flow-Control und Fehlerbehandlung. Für den produktiven Einsatz ist das genau richtig – und meistens auch völlig ausreichend.
Gleichzeitig bleibt dadurch ein großer Teil des Systems unsichtbar. Wer sich jenseits von APIs und Konfigurationsoptionen ein echtes Systemverständnis aufbauen will, stößt schnell an eine Grenze: Die entscheidenden Annahmen liegen unterhalb der Libraries, im Protokoll selbst.
Zugegeben: Auch für mich war ein Teil davon bis vor Kurzem eher gefühltes Wissen als echtes Verständnis. Das wollte ich ändern.
Die Idee: Ein minimaler Broker als Analysewerkzeug
Beim Blick in die Dokumentation fällt auf, dass zentrale Teile des Protokolls offen über Protobuf definiert sind. Strukturen wie BaseCommand, CommandSend, CommandSubscribe oder MessageMetadata beschreiben sehr präzise, welche Informationen zwischen Client und Broker ausgetauscht werden.
Daraus entstand die Idee, auf Basis dieser Spezifikation einen minimalistischen Broker zu bauen, der grundlegende Publish-/Subscribe-Patterns so korrekt implementiert, dass Standard-Pulsar-Clients damit sprechen können – idealerweise, ohne zu merken, dass sie gerade nicht mit einem vollständigen Apache-Pulsar-Broker verbunden sind.
Nicht als Ersatz für Pulsar und nicht mit dem Anspruch auf Vollständigkeit, sondern als Experiment. Ein Broker, der gerade so viel vom Protokoll versteht, dass reale Clients zufrieden sind, eignet sich hervorragend, um die tatsächlichen Invarianten des Protokolls sichtbar zu machen.
Protocol Buffers (Protobuf) ist ein von Google entwickeltes, binäres Serialisierungsformat.
Es beschreibt Datenstrukturen in einer sprachneutralen .proto-Datei und erlaubt daraus automatisch Code für verschiedene Programmiersprachen zu generieren. Im Gegensatz zu textbasierten Formaten wie JSON oder XML ist Protobuf:
- kompakt (binär, platzsparend)
- strikt typisiert
- versionsfähig (Felder können hinzugefügt oder entfernt werden)
Für verteilte Systeme ist besonders wichtig, dass Protobuf explizit zwischen Schema und Transport trennt. Das Schema definiert, was übertragen wird – das Protokoll legt fest, wie diese Bytes auf der Leitung aussehen.
Die Protobuf-Spezifikation von Pulsar: grobe Orientierung
Die Pulsar-Protokollbeschreibung liegt als Protobuf-Definition im proto2-Format vor.
Diese Datei ist keine Implementierung, sondern ein formaler Vertrag, der exakt festlegt, welche Datenstrukturen zwischen Client und Broker ausgetauscht werden dürfen.
Bereits der Kopf der Datei macht einige zentrale Designentscheidungen sichtbar:
syntax = "proto2"Pulsar nutzt bewusst Protobuf 2 mitrequired-Feldern. Fehlende Pflichtfelder sind harte Protokollfehler und führen in der Praxis zu sofortigen Verbindungsabbrüchen.package pulsar.protoEin klarer, stabiler Namensraum für alle Nachrichten.- Sprachspezifische Optionen (
java_package,LITE_RUNTIME) zeigen, dass diese Spezifikation direkt für Codegenerierung gedacht ist – nicht nur als Dokumentation.
Messages als Datenverträge
Jede message beschreibt eine klar abgegrenzte Datenstruktur:
MessageMetadatabündelt alles, was eine Nachricht beschreibt: Producer-Identität, Sequenznummern, Properties, Kompression, Schema-Informationen, Transaktionen.MessageIdDatadefiniert die logische Identität einer Nachricht (Ledger, Entry, Partition, Batch).Schemabeschreibt nicht den Transport, sondern die Bedeutung des Payloads.
Wichtig ist: Diese Messages sagen nichts über Transport, Reihenfolge oder Zustandsautomaten aus. Sie definieren ausschließlich die Form der Daten.
Enums als harte Zustandsräume
Enums wie CompressionType, ProducerAccessMode, ServerError oder ProtocolVersion definieren explizit erlaubte Zustände.
Für einen minimalistischen Broker ist das lehrreich:
- neue Enum-Werte tauchen mit neuen Versionen auf
- Clients erwarten oft Toleranz gegenüber unbekannten Werten
- echte Inkompatibilitäten zeigen sich meist nicht im Schema, sondern im Verhalten
Gerade ProtocolVersion ist zentral: Clients erwarten, dass der Broker ihre Version akzeptiert – oder explizit und nachvollziehbar ablehnt.
Commands und der zentrale Knoten: BaseCommand
Der eigentliche Kern der Spezifikation ist BaseCommand.
Er fungiert als Umschlag für alle Protokolloperationen:
typelegt fest, welches Kommando vorliegt (CONNECT, SEND, ACK, FLOW, …)- genau ein zugehöriges Sub-Message-Feld ist gesetzt
Für den Broker ergibt sich daraus ein klares Grundmuster:
BaseCommandlesentypeauswerten- passendes Sub-Message verarbeiten
Alles Weitere – Framing, Checksummen, Payload-Parsing – liegt außerhalb von Protobuf und wird vom Pulsar-Binary-Protokoll separat geregelt.
Implizite Komplexität
Was die Spezifikation nüchtern erscheinen lässt, ist in Wirklichkeit hoch verdichtet:
- optionale Felder, Default-Werte und Feature-Flags kodieren jahrelange Evolution
- viele Felder sind nur in bestimmten Kombinationen sinnvoll
- Clients verlassen sich auf bestimmte Antworten in bestimmter Reihenfolge, obwohl das nicht explizit im Schema steht
Genau hier entfaltet ein minimalistischer Broker seinen Wert: Er zwingt dazu, zwischen formal spezifiziert und implizit erwartet zu unterscheiden.
Umsetzung: KI-generierter Code, menschliches Feedback
Der gesamte Code dieses Prototyps wurde von einer KI erzeugt. Die menschliche Rolle bestand darin,
- die Pulsar-Protobuf-Spezifikation bereitzustellen
- klar zu formulieren, welche Protokollpfade unterstützt werden müssen
- reale Pulsar-Clients gegen den Broker zu testen
- Abweichungen systematisch zurück in präzisere Anforderungen zu übersetzen
Der Code entstand nicht aus einem vorab entworfenen Architekturmodell, sondern aus iterativem Gegenprüfen realen Client-Verhaltens.
Die Wahl von Go ist bewusst pragmatisch und folgt drei konkreten Motiven:
-
Vertrautheit und Alltagstauglichkeit
Go ist meine primäre Arbeitssprache für DevOps-Tools und Integrationskomponenten. Protokoll- und Infrastrukturcode lässt sich damit schnell schreiben, testen und gezielt verändern. -
Abgrenzung vom Pulsar-Referenzstack
Apache Pulsar ist historisch und technisch stark im Apache-/Java-Ökosystem verankert (ZooKeeper, BookKeeper). Diese Abhängigkeiten bringen eine nicht triviale betriebliche und konzeptionelle Komplexität mit sich, die für ein reines Protokoll-Experiment nicht notwendig ist. -
Portabilität jenseits des offiziellen Stacks
Der Prototyp soll auch auf Systemen lauffähig sein, auf denen Pulsar nativ nicht oder nur eingeschränkt betrieben werden kann – etwa aufgrund nativer Abhängigkeiten wie RocksDB in BookKeeper. Ein einzelnes statisches Go-Binary senkt diese Hürde deutlich.
Netzwerk- und Frame-Handling
Der Einstiegspunkt des Brokers ist bewusst unspektakulär: ein TCP-Listener auf Port 6650. Jede eingehende Verbindung wird in einer eigenen Goroutine behandelt. Der eigentliche Kern liegt im Frame-Handling.
Pulsar verwendet ein klares Längen-Framing. Jeder Frame beginnt mit einem uint32 totalSize, gefolgt von einem inneren Frame, der wiederum mit der Länge des Protobuf-Commands beginnt. Erst nach dem Lesen des vollständigen Frames wird der eigentliche Protobuf-Command extrahiert und deserialisiert. Der innere Frame beginnt erneut mit einem Längenfeld, das die Größe des Protobuf-Commands angibt:
// Gesamten Frame lesen
frame := make([]byte, totalSize)
if _, err := io.ReadFull(conn, frame); err != nil {
return err
}
r := bytes.NewReader(frame)
// Länge des Protobuf-Commands
var cmdSizeBuf [4]byte
if _, err := io.ReadFull(r, cmdSizeBuf[:]); err != nil {
return fmt.Errorf("read command size: %w", err)
}
cmdSize := binary.BigEndian.Uint32(cmdSizeBuf[:])
if cmdSize == 0 || int(cmdSize) > r.Len() {
return fmt.Errorf("invalid command size %d", cmdSize)
}
// Protobuf-Command lesen
cmdBytes := make([]byte, cmdSize)
if _, err := io.ReadFull(r, cmdBytes); err != nil {
return fmt.Errorf("read command: %w", err)
}
// BaseCommand deserialisieren
var base pulsar.BaseCommand
if err := proto.Unmarshal(cmdBytes, &base); err != nil {
return fmt.Errorf("unmarshal BaseCommand: %w", err)
}
Erst nach erfolgreichem Unmarshalling steht fest, um welchen Kommando-Typ es sich handelt. Ab diesem Punkt verlässt der Code die Transport-Ebene und wechselt in die eigentliche Protokoll-Logik, die über den BaseCommand.Type gesteuert wird.
Command-Dispatch über BaseCommand
Nach dem erfolgreichen Unmarshalling wird der Command-Typ ausgewertet und an den jeweils zuständigen Handler weitergereicht. Der Dispatch erfolgt ausschließlich über das type-Feld des BaseCommand:
switch base.GetType() {
case pulsar.BaseCommand_CONNECT:
return b.handleConnect(conn, &base)
case pulsar.BaseCommand_PRODUCER:
return b.handleProducer(conn, &base)
case pulsar.BaseCommand_SUBSCRIBE:
return b.handleSubscribe(conn, &base)
case pulsar.BaseCommand_SEND:
// SEND ist ein Sonderfall: auf den Command folgt ein Message-Frame
payloadSection, _ := io.ReadAll(r)
return b.handleSend(conn, &base, payloadSection)
case pulsar.BaseCommand_FLOW:
return b.handleFlow(conn, &base)
case pulsar.BaseCommand_ACK:
return b.handleAck(conn, &base)
case pulsar.BaseCommand_PING:
return b.handlePing(conn, &base)
default:
// Unbekannte Commands werden toleriert, aber nicht verarbeitet
log.Printf("unhandled command type: %v", base.GetType())
return nil
}
Ein wichtiger Sonderfall ist CONNECT. Der Broker beantwortet ihn unmittelbar mit einem CONNECTED, das die vom Client genutzte Protokollversion explizit widerspiegelt:
func (b *broker) handleConnect(conn net.Conn, base *pulsar.BaseCommand) error {
cmd := base.GetConnect()
if cmd == nil {
return fmt.Errorf("CONNECT without payload")
}
resp := &pulsar.BaseCommand{
Type: pulsar.BaseCommand_CONNECTED.Enum(),
Connected: &pulsar.CommandConnected{
ServerVersion: proto.String("minipulsar-0.1"),
ProtocolVersion: proto.Int32(cmd.GetProtocolVersion()),
MaxMessageSize: proto.Int32(5 * 1024 * 1024),
},
}
return b.writeCommand(conn, resp)
}
PRODUCER und SUBSCRIBE: Zustand pro Verbindung
Producer und Consumer werden im Prototyp bewusst pro Verbindung verwaltet. Schlüssel setzen sich aus der TCP-Verbindung und der vom Client vergebenen ID zusammen.
// Verbindungsspezifische Schlüssel verhindern ID-Kollisionen
type producerKey struct {
conn net.Conn
id uint64
}
type consumerKey struct {
conn net.Conn
id uint64
}
Producer und Consumer werden anschließend immer über diese Schlüssel adressiert:
type broker struct {
producers map[producerKey]*producer
consumers map[consumerKey]*consumer
}
Beim Anlegen eines Producers wird der Schlüssel explizit aus der aktuellen Verbindung und der vom Client vergebenen ID gebildet:
func (b *broker) handleProducer(conn net.Conn, base *pulsar.BaseCommand) error {
cmd := base.GetProducer()
producerID := cmd.GetProducerId()
key := producerKey{
conn: conn,
id: producerID,
}
b.mu.Lock()
b.producers[key] = &producer{
id: producerID,
topic: cmd.GetTopic(),
conn: conn,
}
b.mu.Unlock()
// PRODUCER_SUCCESS antworten …
return nil
}
Dasselbe Prinzip gilt für Consumer beim SUBSCRIBE:
func (b *broker) handleSubscribe(conn net.Conn, base *pulsar.BaseCommand) error {
cmd := base.GetSubscribe()
consumerID := cmd.GetConsumerId()
key := consumerKey{
conn: conn,
id: consumerID,
}
c := &consumer{
id: consumerID,
topic: cmd.GetTopic(),
subscription: cmd.GetSubscription(),
conn: conn,
}
b.mu.Lock()
b.consumers[key] = c
b.mu.Unlock()
// SUCCESS antworten …
return nil
}
Der Hintergrund ist unscheinbar, aber kritisch: Pulsar-IDs sind nur innerhalb einer Verbindung eindeutig. Zwei Clients dürfen beide einen Producer mit ID 1 anlegen, ohne sich gegenseitig zu beeinflussen. Ohne diese Trennung entstehen subtile Fehler, die Clients sehr schnell offenlegen.
SEND: Message-Frames, Magic und CRC
Nach dem SEND-Command folgt im Pulsar-Protokoll kein weiteres Protobuf-Objekt, sondern ein binärer Message-Frame mit fester Struktur. Der Broker muss diesen Frame bytegenau interpretieren. Zunächst werden die Magic Bytes geprüft, die den Beginn eines Message-Frames markieren:
var magicBuf [2]byte
if _, err := io.ReadFull(r, magicBuf[:]); err != nil {
return fmt.Errorf("read magic: %w", err)
}
magic := binary.BigEndian.Uint16(magicBuf[:])
if magic != magicMessageFormat {
return fmt.Errorf("unexpected magic 0x%x", magic)
}
Unmittelbar danach folgt die CRC32C-Checksum. Sie wird nicht über das gesamte Frame berechnet, sondern ausschließlich über genau definierte Bytes:
// CRC32C (Castagnoli) – vom Protokoll vorgegeben
var checksumBuf [4]byte
if _, err := io.ReadFull(r, checksumBuf[:]); err != nil {
return fmt.Errorf("read checksum: %w", err)
}
Anschließend wird die Länge der Message-Metadata gelesen und deren Protobuf-Payload extrahiert:
var metaSizeBuf [4]byte
if _, err := io.ReadFull(r, metaSizeBuf[:]); err != nil {
return fmt.Errorf("read metadata size: %w", err)
}
metaSize := binary.BigEndian.Uint32(metaSizeBuf[:])
if metaSize == 0 || int(metaSize) > r.Len() {
return fmt.Errorf("invalid metadata size %d", metaSize)
}
metaBytes := make([]byte, metaSize)
if _, err := io.ReadFull(r, metaBytes); err != nil {
return fmt.Errorf("read metadata: %w", err)
}
var meta pulsar.MessageMetadata
if err := proto.Unmarshal(metaBytes, &meta); err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
Der verbleibende Rest des Frames ist der Payload, dessen Bytes ebenfalls in die CRC-Berechnung einfließen:
payload, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("read payload: %w", err)
}
Die Validierung der Checksum erfolgt exakt gemäß Pulsar-Spezifikation: CRC32C über Metadata-Längenfeld + Metadata + Payload – nicht über Magic oder das CRC-Feld selbst.
crc := crc32.New(crc32.MakeTable(crc32.Castagnoli))
crc.Write(metaSizeBuf[:])
crc.Write(metaBytes)
crc.Write(payload)
if crc.Sum32() != binary.BigEndian.Uint32(checksumBuf[:]) {
return fmt.Errorf("checksum mismatch")
}
Persistenz: Zustand sichtbar machen
Sobald Nachrichten verarbeitet werden, braucht der Broker Zustand.
Der Prototyp verwendet SQLite – nicht aus Performance-Gründen, sondern um Zustand
transparent und nachvollziehbar zu halten.
Nachrichten werden append-only gespeichert:
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL,
payload BLOB NOT NULL,
publish_time INTEGER NOT NULL,
sequence_id INTEGER NOT NULL
);
Für jede Subscription existiert ein Dispatch-Cursor, der angibt, ab welcher Message-ID Nachrichten überhaupt für die Auslieferung in Frage kommen:
CREATE TABLE subscription_cursor (
topic TEXT NOT NULL,
name TEXT NOT NULL,
next_message_id INTEGER NOT NULL,
PRIMARY KEY (topic, name)
);
Bereits ausgelieferte, aber noch nicht bestätigte Nachrichten werden separat als Pending geführt:
CREATE TABLE subscription_pending (
topic TEXT NOT NULL,
name TEXT NOT NULL,
message_id INTEGER NOT NULL,
consumer_id INTEGER NOT NULL,
delivered_at INTEGER NOT NULL,
PRIMARY KEY (topic, name, message_id)
);
Claiming bewegt den Cursor
Der zentrale Punkt liegt im Claim-Vorgang. In einer einzelnen Transaktion werden:
- der aktuelle Cursor gelesen
- auslieferbare Nachrichten selektiert
- Pending-Einträge angelegt
- der Cursor monoton nach vorne bewegt
// Cursor lesen
var cur int64
err := tx.QueryRow(
"SELECT next_message_id FROM subscription_cursor WHERE topic=? AND name=?",
topic, sub,
).Scan(&cur)
// Pending-Einträge anlegen (Claim)
_, err = tx.Exec(
"INSERT INTO subscription_pending(topic, name, message_id, consumer_id, delivered_at) VALUES(?,?,?,?,?)",
topic, sub, m.id, consumerUID, now,
)
// Cursor NUR hier bewegen – nicht beim ACK
_, err = tx.Exec(
"UPDATE subscription_cursor SET next_message_id=? WHERE topic=? AND name=?",
lastID+1, topic, sub,
)
Erst nach erfolgreichem Commit ist eine Nachricht dauerhaft als „beansprucht“ markiert.
ACKs löschen nur Pending
ACKs greifen nicht auf den Cursor zu.
Sie entfernen ausschließlich Pending-Einträge für den jeweiligen Consumer:
_, err := tx.Exec(
"DELETE FROM subscription_pending WHERE topic=? AND name=? AND message_id=? AND consumer_id=?",
topic, sub, msgID, c.uid,
)
Damit ist klar getrennt:
- Claim entscheidet, wer eine Nachricht bekommt
- ACK bestätigt lediglich die Verarbeitung
Technische Bewertung
- Cursor-Bewegung beim Claim verhindert Duplikate
- ACKs sind idempotent und lokal zum Consumer
- Abgestürzte Consumer hinterlassen Pending-Einträge, blockieren aber den Cursor nicht dauerhaft
- Das Modell ist einfach, aber korrekt für Shared-Subscriptions
Dieses Design ist keine Optimierung, sondern eine konsequente Umsetzung der
Protokollsemantik:
Auslieferung und Bestätigung sind getrennte Zustände – und werden auch getrennt persistiert.
FLOW und Delivery-Loop
Die Auslieferung orientiert sich strikt an den FLOW-Kommandos der Clients.
Consumer signalisieren darüber, wie viele Nachrichten sie aktuell annehmen können.
Der Prototyp verwaltet diese Permits explizit und liefert nur dann Nachrichten aus,
wenn Permits vorhanden sind.
Ein FLOW-Kommando erhöht die verfügbaren Permits eines Consumers
und triggert gegebenenfalls die Auslieferung:
func (b *broker) handleFlow(conn net.Conn, base *pulsar.BaseCommand) error {
cmd := base.GetFlow()
key := consumerKey{conn: conn, id: cmd.GetConsumerId()}
b.mu.RLock()
c := b.consumers[key]
b.mu.RUnlock()
if c == nil {
return nil
}
c.mu.Lock()
c.permits += int(cmd.GetMessagePermits())
c.mu.Unlock()
s := b.getOrCreateSubState(c.topic, c.subscription)
b.maybeStartSubDelivery(s)
return nil
}
Die Auslieferung wird nur gestartet, wenn noch kein Loop läuft und mindestens ein Consumer freie Permits besitzt:
func (b *broker) maybeStartSubDelivery(s *subState) {
s.mu.Lock()
if s.delivering {
s.mu.Unlock()
return
}
s.delivering = true
s.mu.Unlock()
go b.deliveryLoopShared(s)
}
Die eigentliche Delivery-Logik besteht aus einer einfachen Schleife pro Subscription. Es gibt keine Redelivery-Logik, keine Timeouts und keine Priorisierung:
func (b *broker) deliveryLoopShared(s *subState) {
defer func() {
s.mu.Lock()
s.delivering = false
s.mu.Unlock()
}()
for {
c := s.nextConsumerWithPermits()
if c == nil {
return
}
msgs, _ := b.dbClaimBatch(s.key.topic, s.key.name, c.uid, c.permits)
if len(msgs) == 0 {
return
}
for _, m := range msgs {
_ = b.writeMsgFrame(c.conn, c.id, &m)
c.mu.Lock()
c.permits--
c.mu.Unlock()
}
}
}
Technische Einordnung
FLOWist der einzige Trigger für Auslieferung- Permits bilden den Backpressure-Mechanismus vollständig ab
- Der Broker trifft keine eigenen Scheduling-Entscheidungen
Die bewusste Reduktion der Delivery-Logik macht das reine Pulsar-Protokollverhalten sichtbar: Auslieferung erfolgt ausschließlich auf explizite Nachfrage des Clients.
Umfang und Grenzen des Prototyps
Das Ergebnis ist ein Prozess, der auf Port 6650 das Pulsar-Binary-Protokoll spricht und von Standard-Clients akzeptiert wird. Producer können Nachrichten veröffentlichen, Consumer sie empfangen und bestätigen, grundlegende Shared-Subscription-Szenarien funktionieren erwartungsgemäß.
Gleichzeitig fehlt bewusst nahezu alles, was Apache Pulsar als verteiltes System auszeichnet: keine Partitionierung, kein Ledger- oder Segmentmodell, keine Authentifizierung oder Autorisierung, keine Retention, keine Compaction. Der Code ist nicht produktionsreif und erhebt diesen Anspruch ausdrücklich nicht.
Ausblick: Wozu der Prototyp noch taugen kann
Auch wenn dieser Prototyp ausdrücklich kein Produkt ist, öffnet er einen interessanten Denkraum.
Ein Broker, der das Pulsar-Binary-Protokoll korrekt spricht, intern aber bewusst minimal bleibt, ist mehr als eine Spielerei – er kann als Werkzeug für ganz unterschiedliche Fragestellungen dienen.
1. Pulsar-Protokoll als Frontend für andere Broker
Eine naheliegende Anwendung ist die Nutzung des in Go implementierten Pulsar-Protokolls als Protokoll-Frontend für andere Message-Broker, die ebenfalls in Go implementiert sind – etwa NATS oder verwandte Systeme.
Das Pulsar-Protokoll würde dabei ausschließlich als Kompatibilitätsschicht dienen:
Pulsar-Clients sprechen unverändert mit dem Broker, während die eigentliche Persistenz, Fan-out-Logik oder Replikation von einem anderen Backend übernommen wird.
Das eröffnet interessante Potenziale:
- bestehende Pulsar-Clients können auf alternative Backends zugreifen, ohne angepasst zu werden
- Betriebsmodelle lassen sich entkoppeln: Pulsar nach außen, schlanker Broker nach innen
- Vergleich und Evaluation unterschiedlicher Messaging-Backends wird einfacher, weil das Client-Verhalten konstant bleibt
2. Ein bewusst reduzierter, aber „echter“ Pulsar-Broker
Ein zweiter Pfad wäre die Weiterentwicklung hin zu einem vollwertigen Pulsar-Broker mit stark reduziertem Feature-Set. Nicht als Konkurrenz zum offiziellen Projekt, sondern als spezialisierte Variante.
Der Fokus läge dabei nicht auf Vollständigkeit, sondern auf:
- klar definiertem Protokoll-Subset
- strikter Korrektheit gegenüber dem Protokoll
- hoher Performance durch gezielte Vereinfachung
Ein solcher Broker könnte insbesondere für Edge-Szenarien interessant sein: leistungsschwache Hardware, wenig Speicher, begrenzte Abhängigkeiten – aber trotzdem native Pulsar-Kommunikation.
Als Bindeglied zwischen Edge und Cloud-basierten Pulsar-Clustern ließe sich so eine Kopplung ohne Protokollbrüche realisieren.
3. Protokollbasierte Test- und Fuzzing-Umgebung
Ein minimalistischer Broker eignet sich hervorragend als Testinstrument. Statt Clients ausschließlich gegen einen komplexen Referenz-Stack zu testen, kann gezielt überprüft werden:
- welche Protokollvarianten Clients tatsächlich senden
- wie sie auf ungewöhnliche, aber formal korrekte Antworten reagieren
- wo implizite Annahmen über Reihenfolge, Timing oder Zustände bestehen
Damit wird der Broker zu einem Werkzeug für systematische Protokolltests, Fuzzing und Regressionsanalysen – insbesondere bei Client-Upgrades.
4. Lehr- und Analysewerkzeug für verteilte Systeme
Der Prototyp ist klein genug, um vollständig verstanden zu werden, aber real genug, um mit echten Clients zu sprechen. Genau diese Kombination macht ihn zu einem geeigneten didaktischen Werkzeug.
Er eignet sich, um:
- das Pulsar-Protokoll greifbar zu erklären
- Zustandsautomaten und Flow-Control sichtbar zu machen
- Unterschiede zwischen formaler Spezifikation und gelebter Praxis zu diskutieren
Nicht als Tutorial-Code, sondern als nachvollziehbares Studienobjekt.
Fazit
Der Code dieses Prototyps ist sichtbar roh. Er ist weder modular aufgebaut noch auf langfristige Wartbarkeit ausgelegt, sondern das Ergebnis einer schnellen, zielgerichteten Exploration.
Das ist kein Versehen, sondern Ausdruck der Arbeitsweise. Der Prototyp ist keine Ausarbeitung, sondern eine funktionierende Skizze. Er zeigt, wie weit man in kurzer Zeit kommen kann, wenn logisches Denken, Protokollverständnis und klare Zielvorgaben die Leitplanken bilden – und große Teile der Implementierungsarbeit an eine KI delegiert werden.
Der eigentliche Engineering-Prozess würde erst danach beginnen: Struktur einziehen, Verantwortlichkeiten trennen, Tests ergänzen, Wartbarkeit herstellen. Für den Zweck dieses Experiments – das Verständnis des Pulsar-Binary-Protokolls – ist dieser Zustand jedoch ausreichend und sogar hilfreich, weil er die Mechanik unverstellt sichtbar macht.
-
Prototyp-Code (minipulsar): https://forge.ext.d2ux.net/OpenLab/minipulsar
-
Apache Pulsar – Sourcecode: https://github.com/apache/pulsar
-
Dokumentation des Pulsar-Binary-Protokolls: https://pulsar.apache.org/docs/next/developing-binary-protocol/
-
Pulsar Protobuf-Spezifikation (
PulsarApi.proto): https://github.com/apache/pulsar/blob/master/pulsar-common/src/main/proto/PulsarApi.proto
Bildnachweis: Das Pulsar-Logo ist eine eingetragene Marke der Apache Software Foundation. Verwendung im Rahmen redaktioneller Berichterstattung gemäß den offiziellen Richtlinien. Thumbnail-Illustration inspiriert vom Go Gopher, entworfen von Renée French. Verwendet unter der CC BY 3.0-Lizenz.