Das Apache-Pulsar-Protokoll verstehen – ein minimalistischer Broker in Go als Analysewerkzeug

Das Apache-Pulsar-Protokoll verstehen – ein minimalistischer Broker in Go als Analysewerkzeug
By Matthias Petermann / on 21.12.2025

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.

ℹ️ Was ist Protobuf?

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.

🤔 Einordnung
Dieser Artikel beschreibt kein Produkt und keine Referenzimplementierung. Er dokumentiert ein Werkstatt-Experiment, das aus dem Wunsch entstand, das Pulsar-Binary-Protokoll jenseits der Client-Libraries besser zu verstehen – insbesondere im Kontext von Versions- und Kompatibilitätsfragen.

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 mit required-Feldern. Fehlende Pflichtfelder sind harte Protokollfehler und führen in der Praxis zu sofortigen Verbindungsabbrüchen.
  • package pulsar.proto Ein 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:

  • MessageMetadata bündelt alles, was eine Nachricht beschreibt: Producer-Identität, Sequenznummern, Properties, Kompression, Schema-Informationen, Transaktionen.
  • MessageIdData definiert die logische Identität einer Nachricht (Ledger, Entry, Partition, Batch).
  • Schema beschreibt 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:

  • type legt 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:

  1. BaseCommand lesen
  2. type auswerten
  3. 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.

🧪 Arbeitsweise
Die Implementierung erfolgte durch KI-gestützte Codegenerierung. Funktionale Anforderungen wurden durch manuelle Tests mit realen Pulsar-Clients validiert und iterativ präzisiert.
🚀 Warum in Go implementiert?

Die Wahl von Go ist bewusst pragmatisch und folgt drei konkreten Motiven:

  1. 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.

  2. 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.

  3. 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:

  1. der aktuelle Cursor gelesen
  2. auslieferbare Nachrichten selektiert
  3. Pending-Einträge angelegt
  4. 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

  • FLOW ist 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.

ℹ️ Weiterführende Links

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.