Angefangen hat es damit, dass mir ein LLM-Agent die Grenzen dokumentiert hat, statt sie zu bauen. Es ist um Nebenläufigkeit gegangen, und herausgekommen ist ein Kommentar: welcher Aufruf aus welcher Goroutine kommen darf, was vorher gesperrt sein muss, worauf man aufpassen soll. Alles richtig. Alles freundlich. Alles nur Text.

Eine Grenze, die in einem Kommentar steht, ist keine Grenze. Sie ist eine Bitte. Sie hält so lange, wie der Kommentar gelesen und behalten wird. Ich will ihn gar nicht behalten müssen, das sollen statische Prüfer tun. Sicher zu jeder Zeit, für jeden Leser.

Sie ist auch nicht prüfbar. Eine klare Grenze ist testbar: dieselbe Eingabe, dasselbe Ergebnis, immer wieder. Eine unklare gibt unklare Ergebnisse. Das mag ich nicht. Und sicherer Code ist für mich Code mit klaren Grenzen, statt Kommentaren, die sie erklären.

„not safe for concurrent use", „caller must hold the lock" — solche Zeilen habe ich selber geschrieben, mit den Jahren weniger, und Agenten ohne saubere Harness produzieren sie in Mengen. Es hat mich zum ersten Mal richtig gestört, und danach habe ich lange darüber nachgedacht, wie ein Core ausschaut, in dem man diese Kommentare gar nicht braucht.

Mit mehr Disziplin geht sich das nicht aus. Was sich ausgeht, ist eine Form, in der es den Weg zu fremden Daten schlicht nicht gibt. Dann braucht auch keiner einen Kommentar, der ihn verbietet.

In dem, was dabei herausgekommen ist, steht kein einziges sync.Mutex. Das war mir wichtig, und darum geht es hier.

Was da überhaupt läuft

incus-compose kriegt bald einen DNS-Server dazu. Der holt sich seine Records aus Incus und hält sie über den Event-Stream aktuell. Darunter liegt ievent: die Kette, durch die die Events von incusd laufen. DNS war dabei der Anlass, nicht das Ziel — ein Checker und ein Operator hängen an derselben Kette, jeder als eigenes Binary.

incusd → source → debounce → log → enricher → dns → http → log

source liest den Stream. debounce fasst Bursts zusammen. Der enricher hält ein Modell der Flotte und patcht es aus den Events; was er nicht weiß, liest er bei incusd nach — so eine Rückfrage nenne ich ab jetzt einen Read. dns baut daraus Antworten. http macht /ready und Metriken. log schreibt mit.

Ein Event klingt vielleicht nach einem Arbeitsauftrag; es ist ein Leseauftrag. Die Kette hält ein Bild der Flotte, und ein Event ist ein Delta darauf. Daraus folgt der Rest: debounce fasst zusammen, weil vier Deltas auf einem Key ein Bild beschreiben, das es nicht mehr gibt. Ob etwas weg ist, kann man nur gegen ein ganzes Bild lesen. Und ChainState sagt, ob das Bild vollständig ist.

Die Reihenfolge der Kette ist einkompiliert. Es gibt ein --exclude, aber das darf nur Positionen wegnehmen, die das Binary als optional markiert hat, und welche das sind, entscheidet kein Deployment.

Bis hierher ist das eine gewöhnliche Middleware-Kette mit anderen Namen.

Ein Plugin

type Plugin interface {
	// Name benennt das Plugin, im Log, in den Metriken und in der Kette.
	Name() string

	// Wants sagt, welche Actions dieses Plugin interessieren.
	Wants() []Want

	// Setup verdrahtet das Plugin, einmal, bevor irgendetwas läuft.
	Setup(args SetupArgs) error

	// Handle bekommt ein Event.
	Handle(ev *Event)
}

Vier Methoden. Die letzte ist die interessante, und was in SetupArgs steckt, ist noch interessanter:

// Next ist das Handle des Nachfolgers — das einzige Feld, das sich pro
// Position unterscheidet.
Next Next

next ist das Handle vom nächsten Plugin. Eine Funktion, die ich aufrufe, wenn ich fertig bin — oder wenn ich neue Events an das nächste Plugin schicken will.

Jedes Glied hält seinen Nachfolger selber und läuft selber weiter. Deshalb darf ein Plugin vor und nach next arbeiten — dieselbe Form wie eine Kette von HTTP-Handlern.

Der Briefkasten

Jetzt der Teil, der anders ist.

// Handle läuft in der Goroutine des Vorgängers und darf nicht blockieren:
// es legt ab und kehrt zurück. Next wird aus der eigenen Goroutine gerufen,
// wenn die Arbeit getan ist.

Handle ist die Briefkastenklappe. Sie läuft in der Goroutine von dem, der sie aufgerufen hat. Also darf sie nichts machen, was dauert. Sie wirft das Event ein und geht.

Das Event selber ist unveränderlich. Jedes With… gibt eine Kopie her, statt dasselbe Stück Speicher anzugreifen. Deshalb ist es harmlos, wenn zwei Goroutinen denselben Zeiger in der Hand haben, und die Frage, wem ein Event grad gehört, stellt sich erst gar nicht.

Bei debounce schaut das so aus:

func (p *Plugin) Handle(ev *iutil.Event) {
	select {
	case p.inbox <- ev:
	default:
		p.next(ev.WithDropped(name))
	}
}

Zwei Zeilen Logik. Geht es sich aus, ist es drin. Geht es sich nicht aus, läuft es weiter — mit einem Vermerk, wer es fallen gelassen hat.

Die eigentliche Arbeit passiert woanders:

// Run hält Events, bis es fertig werden soll. Run blockiert, also gehört die
// Goroutine main, und Run kehrt zurück, wenn alles weitergereicht ist.
func (p *Plugin) Run(ctx context.Context) error {
	open := map[string]*burst{}

	for {
		var due <-chan time.Time

		at, ok := earliest(open)
		if ok {
			due = time.After(time.Until(at))
		}

		select {
		case <-ctx.Done():
			return nil

		case cmd := <-p.in:
			p.drain(open)
			p.closeAll(open)
			p.answer(ctx, cmd)

			return nil

		case <-due:
			p.closeExpired(open)

		case ev := <-p.inbox:
			p.accept(open, ev)
		}
	}
}

Ein for mit einem select. Der Briefkasten, ein Timer, ein Kommandokanal, ein Abbruch. Mehr ist es nicht.

Und irgendwann ruft dieses Plugin dann next. Aus seiner eigenen Goroutine. Heißt: es legt das Event in den Briefkasten vom nächsten Plugin und geht zurück an seine Arbeit.

Die Kette ist als Aufrufstapel geschrieben und läuft als Fließband.

Später weitergeben

Das ist es, was der Briefkasten kauft.

debounce fasst einen Burst auf einem Key zu zwei Events zusammen: das erste geht sofort, das letzte geht, wenn der Key eine Weile ruhig war. Alles dazwischen wird überholt. Ein Burst kostet damit zwei Reads statt einen pro Event.

„Eine Weile" sind 250 Millisekunden. Da ist Handle längst zurück. Der Vorgänger hat weitergemacht, source liest den Stream weiter, und irgendwann läuft der Timer ab und debounce gibt das letzte Event her — eine Viertelsekunde, nachdem es angekommen ist.

Eine synchrone Kette kann das nicht. Die kann warten, aber sie wartet dann mit dem Aufrufer mit. Ein Glied, das 250 Millisekunden schläft, hält alles auf, was hinter ihm steht.

Die Runde

Der enricher geht die ganze Flotte durch, Name für Name, für die Lebenszeit des Prozesses. GetProjectNames, GetNetworkNames, GetInstanceNames, danach ein Read pro Name. Jeder Name wird ein nacktes instance-updated und nimmt genau den Weg, den ein Live-Event auch nimmt.

Das ist der Weg zurück. Ein Verbindungsabriss ist der offensichtliche Fall, die Runde läuft für den weniger offensichtlichen: ein Event, das nie angekommen ist, lässt nichts zurück, woran man es merken könnte. Nur ein voller Durchgang erwischt einen Übergang, von dem keiner erzählt hat.

Ein Read, der nichts Neues gefunden hat, erzeugt dabei gar kein Event — es wird verworfen. Verglichen wird gegen das letzte, das über dieses Subjekt hinausgegangen ist — eine unveränderte Instanz kostet damit einen Read und sonst nichts.

Und die Runde funktioniert über Abwesenheit:

Was das Modell in einem Bereich hält und die Liste nicht nennt, ist weg.

Der Bereich zählt, und der Zeitpunkt auch. Welche Schlüssel das Modell hält, wird festgehalten, wenn die Anfrage hinausgeht, und gelöscht wird dieser Stand minus die Antwort. Ein Name, der während des Flugs entsteht, steht nicht drin und überlebt — er musste es auch, denn zurückholen würde ihn keiner: die nächste Runde liest ihn unverändert und sendet nichts.

Eine Liste, die fehlgeschlagen ist, löscht nichts. Eine leere Antwort und ein Daemon, der nicht antwortet, kommen gleich an, und eines davon heißt „jeder Name in diesem Projekt".

Aufgehalten wird dabei niemand. Die Runde ist getaktet, zwischen den Projekten und zwischen den Reads darin, und wie fest entscheidet der, der die Plugins zusammenfügt. Genau deshalb darf sie lange dauern: es gibt keinen Zustand, in dem die Kette die Luft anhält.

Was es gibt, ist kalt und warm. Kalt heißt, dass die Flotte noch nie ganz gelesen wurde, und dann fasst debounce nichts zusammen:

collapse := ev.State() == iutil.StateOk &&
	ev.ChainState() == iutil.ChainWarm &&
	p.wanted[ev.Action()].Debounce

Geprüft wird auf warm, nie gegen cold. Der Nullwert ist keines von beiden, ein Event, das keiner gestempelt hat, verhält sich also wie eines, für das noch nichts gelesen wurde. Vor der ersten vollen Runde läuft alles einzeln durch, in der Reihenfolge, in der es gekommen ist — was diese Runde dann liest, ist alles, was Incus geschickt hat, statt dem, was ein Fenster übrig gelassen hat.

Und wer warm setzt, ist dns. Der enricher sagt nur, dass eine Runde einmal herum war, mit enricher/sweep-end. Ob das etwas bedeutet, entscheidet der Letzte in der Kette: am enricher gesetzt wäre warm schon wahr, während die Events dieser Runde noch unterwegs sind.

Wenn der Briefkasten voll ist

Der default-Zweig von vorhin ist die ehrliche Stelle. Irgendwann ist jeder Puffer voll, und dann muss wer entscheiden, was passiert.

Hier entscheidet das Plugin, das voll ist. Mit seinem eigenen Namen dran. Das Event verschwindet nicht, es läuft weiter durch die Kette und trägt den Vermerk mit — StateDropped heißt „damit fertig, läuft weiter, damit die Beobachter dahinter es sehen". Für log also, und für die Metriken. Wer dort dropped mit einem Namen daneben sieht, weiß, welches Plugin zu klein dimensioniert ist. Mehr leistet der Vermerk heute nicht.

Für DNS reicht das: ein verlorenes Event ist dort ein Record, der bis zur nächsten Runde alt ist. Ein Operator darf keines verlieren. Was debounce zusammenfasst, ist dabei ungefährlich, gehalten wird ja nur, was sich wiederholt. Ein voller Briefkasten lässt dagegen fallen, was gerade kommt, und das kann ein Delete sein.

Am Ende der Kette wird deshalb ein Plugin stehen, das den Vermerk liest und für das Fallengelassene die Reparatur anstößt. Umgesetzt ist es noch nicht.

WithDropped schreibt dazu, wer es war, und ist danach ein No-Op. Wer ein Event zuerst fallen lässt, bleibt der, der drinsteht.

Plugins, die etwas tun, überspringen so ein Event:

func (p *Plugin) Handle(ev *iutil.Event) {
	if ev.State() != iutil.StateOk {
		p.next(ev)

		return
	}
	// ...
}

Kommuniziert wird nach außen, und zwar unmissverständlich: der Zustand steht auf dem Event, jeder kann ihn lesen. Was ein Plugin innen damit macht, gehört ihm. Ein Typ, der die Prüfung erzwingt, wäre ein Vorschlag, der nur gegen ein Plugin Sinn ergibt, das sich schlecht benimmt — und die werden hier nicht gebaut.

Außen klar, innen frei.

Eine Stelle bleibt trotzdem übrig, und die steht bei Value, mit Ansage — „kein Typ kann diese Regel tragen, deshalb steht sie hier".

Ein voller Briefkasten meldet sich also mit Namen. Und ein debounce, das zweihundert Events zu zweien zusammenfasst, macht genau dasselbe:

// Innerhalb eines offenen Fensters. Das erste Event hier hat nichts zu
// überholen.
if b.ev != nil {
	p.next(b.ev.WithDropped(name))
}

Es verschwindet also gar nichts. Jedes überholte Event läuft weiter durch die Kette, mit demselben Vermerk wie ein fallengelassenes, und die Beobachter dahinter sehen es. Zusammengefasst werden nicht die Events, sondern die Reads — der enricher schlägt für ein Event, mit dem schon wer fertig ist, gar nicht mehr nach. Und zweihundert Reads auf dasselbe Objekt geben ohnehin das her, was der letzte auch hergibt.

Gehalten werden instance-updated und network-updated, also das, was sich wiederholt. Start, Stop, Delete und Rename laufen sofort durch, und bei Umbenennungen steht der Grund daneben: zwei zusammengefasst behalten den letzten OldName und verlieren den mittleren, der Record darunter würde nie verschwinden. Ein gehaltenes Event kann deshalb nie eine Nachricht über Existenz sein.

Der Briefkasten ist konfigurierbar, von dem, der die Plugins zusammenfügt — der weiß am besten, welche Zwischenspeicher sie brauchen.

Handle ist das eigentliche Powerfeature dieser Kette. Jedes Plugin bekommt das Handle seines Nachfolgers als next, und mehr braucht sie nicht, um zu leben. Weil Handle in der Goroutine des Vorgängers läuft, darf der Empfänger sie nicht blockieren — und genau deshalb muss ein voller Briefkasten fallen lassen, statt zu warten.

Manche haben gar keinen

log hat keinen Briefkasten, keine Goroutine, kein Run. Sein Setup merkt sich den Nachfolger und startet nichts, Handle schreibt die Zeile und ruft next direkt auf, in der Goroutine des Aufrufers.

Von außen schaut das gleich aus. Wer next aufruft, weiß nicht, ob er grad eine Goroutine-Grenze überschritten oder eine Funktion aufgerufen hat, die sofort zurückkommt. Beides ist next(ev). Dieselbe Signatur trägt das Plugin, das ein Event eine Viertelsekunde festhält, und das, was es durchreicht, ohne stehenzubleiben.

Und deshalb kein Mutex

Jetzt der Teil von ganz oben.

Alles außer dem Briefkasten gehört der Goroutine, die Run besitzt.

Ein Besitzer pro Plugin. Die open-Map in debounce greift sonst keiner an. Der Briefkasten ist das einzige, was geteilt wird, und der ist ein Kanal — der synchronisiert selber.

Der harte Fall ist dabei nicht debounce, sondern der enricher. Der hat einen Worker-Pool, mehrere Goroutinen lesen gleichzeitig bei incusd — und sein Modell greift trotzdem keine davon an. Ein Worker liest und gibt die Antwort zurück, gepatcht wird dort, wo das Modell lebt. Nebenläufig gelesen, an einer Stelle geschrieben, kein Lock.

Genau darauf war ich aus. Auf ein Fundament, auf dem kein sync.Mutex mehr nötig ist, weil es nichts mehr gibt, um das sich zwei Goroutinen streiten könnten. Jede besitzt seine Daten, und was zwischen ihnen hin und her geht, geht durch einen Briefkasten.

Kommunikation über Kanäle zwischen Arbeitern ist besser, als deren Daten zu sperren. Wenn ein Plugin seine Daten allein besitzen soll, dann darf keiner sonst hineingreifen — also muss ihm wer etwas schicken, statt es ihm hinzulegen. Genau das macht Handle.

In einer Kette, die in einem Worker-Pool läuft, geht sich das nicht aus. Da hat kein Glied eine eigene Goroutine, jedes borgt sich eine, und mit ihr teilt es alles, was ein anderes auch angreift. Dann hilft nur mehr ein Lock.

Dieselbe Form, ein anderes Fundament, und das Mutex ist weg. „Wem gehören diese Daten?" ist die unangenehmere Frage als „wo kommt das Lock hin?", und sie ist genau einmal unangenehm, beim Entwurf. Danach steht die Antwort in der Form, und es bleibt nichts zu merken.

Und sie ist prüfbar, wie oben verlangt. Ein Plugin ist eine Funktion von Event und eigenem Zustand auf Events: ein Test ist ein Aufruf mit einem Wert und eine Behauptung über das, was hinten rauskommt. Die einzige Naht, an der etwas vorgetäuscht werden muss, liegt im enricher, dort wo Incus drankommt.

Was jeder will, vorher

Eine Sache habe ich dazubauen müssen, die eine gewöhnliche Kette nicht braucht.

Wants() ist von Setup() getrennt, und das hat einen Grund. Der enricher bedient die ganze Kette aus einem einzigen Read. Er muss also wissen, was alle wollen, bevor er das erste Mal liest — und zwar vollständig.

Würde die Tabelle beim Verdrahten entstehen, kriegte jedes Plugin den Teil der Vereinigung, der zufällig schon da war, als sein eigenes Setup gelaufen ist.

Also fragt die Kette erst alle, dann verdrahtet sie. Jedes Plugin kriegt dieselbe fertige Tabelle.

Die beiden Felder falten in entgegengesetzte Richtungen und meinen dasselbe. Bei Enrich gewinnt mehr: Wenn irgendwer eine Action angereichert haben will, wird sie angereichert. Bei Debounce gewinnt false — „der Nullwert legt für alle ein Veto ein". Beide Male ist der Nullwert die sichere Seite: mehr Arbeit, weniger Risiko.

Das ist der Preis einer einkompilierten Kette. Sie muss ihre eigenen Glieder fragen, bevor sie sich bauen kann.

Drei Wege hinein

Neben dem Briefkasten hat jedes Plugin noch zwei Kanäle. Einen rein, einen raus, beide für Kommandos. Der eingehende ist ungepuffert, mit Begründung: „ein Slot würde die source ein Plugin fragen lassen, das nicht zuhört, und sie würde glauben, sie sei gehört worden."

Vorher war das ein Funktionsaufruf. Zwei Kanäle pro Plugin sind daraus geworden, und die zwei Richtungen ziehen mit Absicht auseinander.

Rein: an der Schlange vorbei. Wenn der Prozess fertig werden soll, geht source/drain an jedes Plugin — auf dem eigenen Kanal, „egal wie der Event-Briefkasten grad ausschaut". Ein Kommando, das im vollen Briefkasten hinter zehntausend Events warten müsste, wäre nutzlos.

Raus: zurück in die Schlange. Wenn ein Plugin selber etwas zu sagen hat — dns meldet, dass es jetzt etwas Vernünftiges zum Antworten hat — geht das nicht als Broadcast an alle.

Die source macht ein Event daraus und legt es vorne in die Kette. Es läuft von Position eins durch, wie jedes andere Event auch, „damit es jede Position erreicht und in der Reihenfolge gegenüber den Events, die es ausgelöst haben".

Das ist der Punkt. Ein Seitenkanal, der an der Schlange vorbeigeht, geht auch an der Reihenfolge vorbei. Ein ready, das die Events überholt, die es ausgelöst haben, sagt die Unwahrheit.

Also darf genau das an der Schlange vorbei, dem die Reihenfolge wurscht ist: das Aufhören. Alles andere stellt sich hinten an.

Ein Command trägt dabei zwei unabhängige Felder, und beide dürfen leer bleiben. Eine Action mintet das Event, ein ChainState setzt, was die source ab da stempelt.

Die zwei sagen Verschiedenes. Eine Action sagt, was zu tun ist. Ein ChainState sagt, was die Kette gerade ist — das eine ist eine Nachricht, das andere ein Pegel.

Die source hält diesen Pegel und stempelt ihn auf alles, was sie mintet. Was ein Plugin damit anfängt, ist seine eigene Sache: debounce fasst nur zusammen, wenn es warm ist, dns entscheidet, wann es warm wird, http macht sein /ready daraus.

http weiß damit immer, wie es der Kette geht. Sein /ready rastet auf warm ein und fällt auf kalt zurück, und angefangen wird kalt — was vor dem ersten Read auch stimmt. Kein Zeiger auf dns, keine Registry, keiner, den es fragen müsste. Dass dns etwas Vernünftiges zum Antworten hat, kommt auf demselben Weg: als Event, dns/ready, gefaltet in der Reihenfolge gegenüber dem, was es ausgelöst hat.

Und jedes Plugin darf ihn setzen — den Übergang prüft keiner, auf demselben Vertrauen wie alles andere hier. Am Ende einer Runde nutzen zwei Plugins je ein Feld: der enricher schickt eine Action, enricher/sweep-end, und dns schickt daraufhin einen ChainState, warm.

Vorwärts: von hier aus. Es gibt einen dritten Weg hinein, und der ist der leiseste. Ändert sich ein Profil oder ein Netzwerk, müssen alle Instanzen darunter neu gelesen werden — das Event, das die Änderung gemeldet hat, nennt aber keine einzige davon. Der enricher erfindet deshalb pro betroffener Instanz ein instance-updated, das es so nie gegeben hat, und stellt es in seine eigene Reihe:

// Ein synthetisches instance-updated pro Stück, hier in die Reihe gestellt
// statt an den Kopf der Kette: es ist die eigene Arbeit dieses Plugins, und
// einmal herumgeschickt würde debounce den Satz auf das letzte zusammenfassen.

Von hinten schaut das aus wie jedes andere Update. Entscheidend ist, wo es hineingeht. Am Kopf der Kette liefe es noch einmal durch debounce, das vor dem enricher sitzt — und aus dem Satz würde eines.

Drei Wege also, und jeder ist danach ausgesucht, was die Reihenfolge bedeuten soll. An der Schlange vorbei, wenn sie wurscht ist. An den Kopf, wenn es auf die Reihenfolge gegenüber den Auslösern ankommt. Von hier aus weiter, wenn das, was davor sitzt, es nicht noch einmal angreifen darf.

Man sieht es an den Namen. enricher/sweep-end, source/connected, dns/ready, source/drain.

Das gilt für Steuerung. Datenevents bleiben ohne Präfix, auch die erfundenen: das synthetische instance-updated von vorhin heißt genauso wie eines von incusd. Für alles dahinter ist ja auch genau das passiert.

Kein init()

Das Übliche wäre eine Registry. Jedes Plugin bringt ein func init() mit, das sich beim Import irgendwo anmeldet, und ein Import mit _ sorgt dafür, dass es auch läuft.

Ich weiß ziemlich genau, wie das ausgeht. In den Plugins von go-orb stehen 42 davon — zweiundvierzig Mal meldet sich etwas selber an, während das Programm noch startet. An einer Stelle so:

func init() {
	if err := source.Plugins.Add(New()); err != nil {
		panic(err)
	}
}

Ein Panic, bevor main überhaupt läuft. Wenn das feuert, gibt es keine Stelle, an der man es abfangen könnte, und keinen Aufrufer, dem man es melden könnte.

Wenn man sich Freiheit durch Verstecken erkauft, erkauft man sich ein neues Gefängnis. Was drin ist, steht in einer Import-Liste. Verdrahtet wird als Nebenwirkung. Um herauszufinden, warum sich etwas so verhält, muss man rückwärts nach dem Paket suchen, das es mitgebracht hat.

In ievent gibt es kein einziges init() und keinen Import mit Unterstrich. Ich brauche keins.

Die Kette ist ein Slice. chain() gibt her, was drinsteht, in der Reihenfolge, in der es drinsteht. Man liest eine Funktion und weiß, was der Prozess macht. Und weil es gewöhnlicher Go-Code ist statt einer generierten Tabelle, darf dasselbe Plugin auch zweimal drinstehen — log einmal vorne, einmal hinten, und dazwischen liest man ab, was eine Position gekostet hat.

Das zahlt sich beim zweiten Produkt aus. Ein Checker ist ein zweites chain.go mit denselben Importen und einem anderen letzten Glied. Mit einer Registry wäre die Frage stattdessen, welche Plugins sich beim Importieren mit einschleppen.

Es ist wieder dieselbe Grenze wie ganz am Anfang. Was der Prozess macht, soll man lesen können, statt es sich zusammensuchen zu müssen.

Plugins vertrauen einander

Ein Satz steht im Interface, und der ist mir wichtig:

Plugins vertrauen einander: hier verteidigt sich keines gegen ein anderes, und ein Panic nimmt den Prozess mit. Wenn ein Vorschlag nur gegen ein Plugin Sinn ergibt, das sich schlecht benimmt, wird er nicht gebaut.

Das ist eine Grenze. Innerhalb eines Binaries habe ich alles selber geschrieben. Leichtsinn wäre blindes Vertrauen zu unbekannten Libraries und Plugins. Ein Plugin, das sich schlecht benimmt, ist ein Bug, und Bugs repariert man, statt Wachtürme drumherum zu bauen.

Vertrauen heißt dabei nicht, die eigene Lage von wem anderem zu beziehen. Jedes Plugin nimmt an, was ankommt, ohne es zu prüfen — und weiß trotzdem selber, wo es steht: debounce hält seinen letzten Kettenzustand, http seinen eingerasteten. Wer seine Position aus einem gemeinsamen Wert liest, hat kein Vertrauen gebaut, sondern eine Abhängigkeit.

Über eine Prozessgrenze hinweg ist es genau umgekehrt. Der Caddy-Sidecar, den ich grad plane, hält kein Zertifikat. Der Prozess, der ins Internet schaut, hat nichts, was man ihm wegnehmen könnte, und der, der die Incus-Verbindung hält, hört auf keinem öffentlichen Port.

Innen volles Vertrauen, außen keins. Beides mit Absicht.

Was am Ende nicht dasteht

Die Kette habe ich mir nicht ausgedacht. Ein Glied hält seinen Nachfolger und reicht weiter — das steckt hinter jeder Middleware und hinter defer, und es hat auf mich gewartet.

Gesucht habe ich das, was darunter liegt. Ein next, das etwas ablegt und geht, statt zu arbeiten. Ein Besitzer pro Datensatz. Ein Briefkasten als einzige Tür.

Was dabei herauskommt, schaut man sich am besten daran an, was im Code fehlt.

sync.Mutex, init(), Importe mit Unterstrich — nichts davon steht im Code von ievent.

Ein neues Plugin ist eine Struct mit vier Methoden und einer Zeile in der Liste. Wo es sitzt, geht es nichts an. Wer voll läuft, entscheidet selber.

Und wenn eines fett wird, ist es innen wieder eine Kette. dns ist eine, in Richtung Server und Cold Store. Dieselbe Form, andere Himmelsrichtung.

Boring. Schon wieder. Genau so soll das sein.


Von René Jochum. Lizenz: CC-BY-4.0.