Der Webhook ist keine Datenquelle
Workbench bringt Änderungen aus GitLab in weniger als fünf Sekunden in einen offenen Browser. Der Webhook beschleunigt den Weg, doch die Wahrheit bleibt bei GitLab und die lokale Projektion bleibt wegwerfbar.
Workbench ist ein Delivery-Index über mehrere GitLab-Instanzen. Der erste Artikel beschrieb seine wichtigste Eigenschaft: GitLab ist massgebend, jede lokale Zeile ist abgeleitet, und die PostgreSQL-Projektion darf falsch liegen. Eine Reconciliation, also ein erneuter Abgleich mit GitLab, repariert sie wieder.
Diese Eigenschaft blieb. Ihr Zeitfenster wurde zum Problem.
Wer in GitLab ein Work Item schloss und danach zum bereits offenen Workbench-Board wechselte, sah dort noch den alten Zustand. Die Seite wusste nichts von der Änderung, bis der nächste Abgleich lief oder jemand sie neu lud. Korrekt wurde sie später. Für ein Werkzeug, in dem Teams ihre laufende Arbeit beobachten, war später zu spät.
Das neue Ziel lautete deshalb: Eine Änderung an einem Work Item oder Merge Request soll in einem bereits offenen Browser in weniger als fünf Sekunden am p95 sichtbar werden. Gemessen wird vom Empfang des Webhooks bis zum abgeschlossenen Refetch im Browser. Der Index darf weiterhin falsch liegen. Er darf es nur nicht mehr so lange.
Warum Polling mit jedem Workspace teurer wird
Die einfache Lösung wäre Polling gewesen. Jeder offene Browser könnte regelmässig nachfragen, ob sich etwas geändert hat. Oder der Server könnte jedes GitLab-Projekt in einem festen Intervall lesen.
Beide Varianten bezahlen für Zeit statt für Änderungen. Ein Workspace mit vielen Projekten erzeugt auch dann Reads, wenn niemand ein Work Item angefasst hat. Mit jedem weiteren Workspace wächst diese Grundlast. Je nützlicher Workbench wird, desto mehr Scope muss es ohne Anlass abfragen.
Polling hat trotzdem einen Platz: als Reparaturweg. Die bestehende inkrementelle Reconciliation liest alle fünf Minuten bei GitLab und repariert fehlende oder verspätete Events. Nachts folgt ein vollständiger Lauf. Diese Läufe kaufen Korrektheit. Sie müssen keine Interaktion schnell wirken lassen.
Webhooks kaufen Frische. GitLab sendet sie nur bei einer Änderung. Damit verschiebt sich die laufende Arbeit von «alles immer wieder lesen» zu «den betroffenen Datensatz jetzt lesen».
So wenige Hooks wie möglich
Ein Webhook ist eine von GitLab ausgelöste HTTP-Anfrage an Workbench. Die erste naive Topologie installierte einen Project Hook pro Projekt. Das funktioniert, bis ein Workspace eine ganze Gruppe mit vielen Projekten umfasst. Dann beschreiben Dutzende Hooks denselben Scope einzeln und müssen einzeln installiert, geprüft und entfernt werden.
Workbench berechnet deshalb die kleinste vollständige Hook-Menge pro GitLab-Connection. Ein Group Hook deckt die Gruppe und ihre Subgroups ab. Er verdrängt redundante Project Hooks, auch wenn ein Projekt zusätzlich als eigener Scope konfiguriert ist. Überlappende Gruppen behalten nur den Hook des Vorfahren.
GitLab Free kennt Group Hooks in diesem Modell nicht. Dort fällt ein Group Scope kontrolliert auf Project Hooks für die gefundenen Projekte zurück. Das Ergebnis ist in beiden Fällen vollständig, nur die Zahl der Remote Hooks unterscheidet sich.
Die Reconciliation dieser Topologie ist idempotent: Wiederholte Läufe erzeugen keine Duplikate. Ein Drift-Sweep prüft alle 15 Minuten, ob Hooks ausserhalb von Workbench gelöscht oder automatisch deaktiviert wurden. Nachts läuft die vollständige Hook-Reconciliation, die auch zuvor abgelehnte Group Hooks erneut prüft. Beide installieren fehlende Hooks und löschen überflüssige. Fällt GitLab beim Teardown aus, bleibt die Registrierung im Zustand error erhalten und kann später erneut entfernt werden. Ein fehlgeschlagener Remote-Call darf die lokale Erinnerung an die noch offene Arbeit nicht vernichten.
Der Receiver prüft den Raw Body
Ein öffentlich erreichbarer Receiver ist eine Trust Boundary. Er darf erst verstehen, was eine Anfrage behauptet, nachdem er geprüft hat, wer sie geschickt hat.
GitLab Standard Webhooks signieren ab GitLab 19.1 den exakten Raw Body, also die unveränderten Bytes der Anfrage. Workbench berechnet darüber einen HMAC-SHA256. Ein HMAC ist ein kryptografischer Prüfwert aus Nachricht und gemeinsamem Secret. Nur wenn Signatur und Timestamp gültig sind, wird JSON geparst.
Die Reihenfolge ist wichtig. Würde Rails den Body vorher parsen und wieder serialisieren, könnten bereits Whitespace oder die Reihenfolge von JSON-Keys die Bytes verändern. Die Signatur gehört zur empfangenen Nachricht, nicht zu einer semantisch ähnlichen Rekonstruktion.
Unbekannte Registrierung, falsche Signatur, abgelaufener Timestamp und suspendierte Registrierung erhalten dieselbe Antwort: 404. Der Endpoint verrät damit nicht, ob eine bestimmte öffentliche ID existiert. Von aussen gibt es kein Orakel für gültige Registrierungen.
Nach erfolgreicher Prüfung schreibt der Receiver eine Delivery in PostgreSQL und reiht den Job ein. Erst dann antwortet er mit 202. Deduplication liegt ebenfalls in PostgreSQL: Die Delivery-ID ist pro Registrierung eindeutig, und die GitLab Event UUID unterdrückt dasselbe Event aus versehentlich überlappenden Hooks. Ein Neustart des Rails-Prozesses leert diese Erinnerung nicht.
Das Event ist nur ein Routing Envelope
Der verführerische nächste Schritt wäre, Titel, State oder Labels direkt aus dem Webhook-Payload in die Projektion zu schreiben. Dann würde der schnelle Pfad plötzlich eine zweite Datenquelle besitzen. Unterschiedliche Event-Typen liefern unterschiedliche Felder, Payload-Versionen ändern sich, und ein verspätetes Event könnte einen neueren Zustand zurückdrehen.
Workbench behält vom Payload deshalb nur einen Routing Envelope: Event-Typ, Projekt, Resource Kind und IID. Der Envelope beantwortet, wo gelesen werden muss. Er beantwortet nie, was dort aktuell steht.
Für ein Work Item folgt ein targeted GitLab read über GLQL, GitLabs Abfragesprache für Work Items. Für einen Merge Request folgt ein einzelner REST-Read. Erst die Antwort dieses Reads wird projiziert. Fehlen sichere Koordinaten, fällt der Job auf den bestehenden vollständigen Projekt-Sync zurück.
Webhook-Payloads liefern nur Routing-Koordinaten. Workbench liest den aktuellen Zustand danach bei GitLab.
Der schnelle Pfad sieht damit so aus:
- WebhookRouting Envelope empfangen
- HMACRaw Body prüfen und deduplizieren
- GitLab readWork Item via GLQL oder MR via REST lesen
- CommitProjektion transaktional aktualisieren
- BroadcastWorkspace-Koordinaten über Action Cable senden
- InvalidationTanStack Query Prefix invalidieren und refetchen
Alte Reads dürfen neue Daten nicht überschreiben
Webhooks können doppelt, verspätet oder in anderer Reihenfolge eintreffen. Selbst zwei korrekt geroutete Jobs können ihre GitLab-Reads in einer unerwarteten Reihenfolge beenden. Deshalb entscheidet source_updated_at, ob ein targeted upsert schreiben darf. Ein älterer Read überschreibt keine Projektion mit einem neueren GitLab-Timestamp.
Ein targeted upsert darf ausserdem nie seine Geschwister deaktivieren. Wenn der Read genau ein Work Item zurückliefert, weiss er nichts über alle anderen Work Items des Projekts. Nur ein vollständiger autoritativer Snapshot darf aus Abwesenheit schliessen, dass ein Datensatz nicht mehr im Scope liegt.
Dabei kam ein älterer Fehler ans Licht. Der Projector erhöhte projection_version bei jedem Sync, selbst wenn die gespeicherten Bytes identisch blieben. Ein solcher No-op sah nach einer Änderung aus und hätte unnötige Broadcasts ausgelöst. Jetzt erhöhen nur materielle Änderungen die Version.
Das ist der Stale-Write-Guard: Timestamp, begrenzter Scope und No-op-Erkennung schützen gemeinsam davor, dass mehr Geschwindigkeit neue Formen von Drift erzeugt.
Nach dem Commit kennt der Browser nur Koordinaten
Der Broadcast erfolgt erst nach dem Datenbank-Commit. Vorher könnte ein Browser sofort refetchen und noch den alten Stand lesen. Danach sendet Workbench über Action Cable lediglich Workspace, Projekt, Resource Kind und ID beziehungsweise IID. Titel, Labels und andere GitLab-Inhalte gehen nicht über den Channel.
Action Cable verwendet den PostgreSQL Subscription Adapter. Damit erreicht ein Commit den Web-Prozess auch dann, wenn der Job in einem anderen Prozess lief. Der Workspace Channel prüft beim Subscribe die aktuelle Mitgliedschaft. Ein Client kann keinen fremden Workspace abonnieren, indem er dessen ID errät.
Im React-Frontend invalidiert die Nachricht alle passenden TanStack-Query-Keys über ihren gemeinsamen Prefix. Die aktive Query liest anschliessend aus der lokalen API neu. Eine einzelne Koordinate aktualisiert dadurch Liste, Detailansicht und Board, ohne deren konkrete Cache-Keys in Rails nachzubauen.
Verbindungen brechen ab. Das ist normal. Beim Reconnect oder wenn das Browserfenster wieder aktiv wird, invalidiert der Client die Projektionen des aktuellen Workspace vollständig. Verpasste Broadcasts brauchen deshalb keinen serverseitigen Replay Buffer. Der bestehende Read-Pfad heilt den Client.
Der E2E-Beleg prüft die Grenzen
Die Ruby-Suite von MR !480 führte mit mise run applications:workbench:test 2’748 Beispiele ohne Fehler aus. Dieselbe Suite mass 8’670 von 8’670 Zeilen und 1’479 von 1’479 Branches als abgedeckt. Die Frontend-Coverage-Suite applications:workbench:test:frontend:coverage führte 2’369 Tests aus. mise run verify --changed führte für die betroffenen Pfade 1’246 CI-Tests aus.
Diese Zahlen belegen Breite. Für die eigentliche Behauptung war der E2E-Prozess wichtiger. applications:workbench:e2e:process führte 45 Szenarien aus und mass den Weg über echte Prozessgrenzen.
Der geprüfte Titel existiert ausschliesslich im Fake GitLab. Er steht nie im Webhook-Payload. Wenn er im Browser erscheint, muss Workbench den targeted read tatsächlich ausgeführt haben. Der Projektions-Commit geschieht in einem anderen Prozess als die Browser-Verbindung. Wenn die Invalidierung ankommt, muss sie den PostgreSQL Adapter von Action Cable passiert haben. Der Spec prüft zusätzlich, dass die Seite nie neu geladen wurde.
In diesem Aufbau dauerte es 52 ms vom Projektions-Commit bis zur sichtbaren Änderung im Browser. Vom Empfang der Delivery bis zur sichtbaren Änderung vergingen 2’196 ms. Beide Werte stammen aus applications:workbench:e2e:process; der zweite misst das vereinbarte SLO von höchstens 5’000 ms.
| Messung | Ergebnis | Beleg |
|---|---|---|
| Ruby-Suite | 2'748 Beispiele, 0 Fehler | mise run applications:workbench:test |
| Zeilenabdeckung | 8'670 / 8'670 | mise run applications:workbench:test |
| Branch-Abdeckung | 1'479 / 1'479 | mise run applications:workbench:test |
| Frontend | 2'369 Tests | applications:workbench:test:frontend:coverage |
| E2E-Prozess | 45 Szenarien | applications:workbench:e2e:process |
| Commit bis sichtbar | 52 ms | applications:workbench:e2e:process |
| Delivery bis sichtbar | 2'196 ms | applications:workbench:e2e:process |
| Changed-Path-Verifikation | 1'246 CI-Tests | mise run verify --changed |
Das ist kein Produktions-p95. Es ist ein reproduzierbarer E2E-Beleg für den vollständigen Mechanismus und sein 5’000-ms-Budget. Betriebsmessungen müssen das SLO über viele echte Deliveries bestätigen.
Wie ein Workspace Realtime einschaltet
Alles bisher Beschriebene passiert, nachdem ein Hook installiert ist. Installiert wird er nicht von Hand. Workbench legt jeden Hook selbst über die GitLab-API an, aktualisiert ihn und löscht ihn wieder. Der Maintainer verantwortet nur die Bedingungen, unter denen das gelingt.
Sie stehen in den Settings, pro GitLab-Connection auf einer eigenen Karte, unter «Before you enable». Die Liste ist geordnet, weil die Punkte aufeinander aufbauen. Jeder Punkt hat einen Zustand: Ready, Action needed, Not checked oder Check yourself. Der Zustand steht als Wort da, nicht nur als Farbe. Wer die Seite auf einem Beamer liest oder eine Farbsehschwäche hat, muss «bereit» trotzdem von «hier fehlt etwas» unterscheiden können.
- Eine Basis-URL, die GitLab erreichen kann. Ohne
APP_URLbricht die Installation ab. Eine falsche URL ist schlimmer als eine fehlende: GitLab nimmt den Hook an und liefert ins Leere. Das sieht aus wie funktionierende Synchronisation, nur wird nie etwas frisch. Deshalb zeigt die Karte die Receiver-URL an, statt sie als bekannt vorauszusetzen. - Ein Workspace Service Token auf der Connection. Connections, die sich per delegiertem OAuth als Person anmelden, sind von jeder unbeaufsichtigten Arbeit ausgeschlossen (ADR 0003). Ihr Zugriff endet, sobald sich die Person abmeldet. Ein installierter Hook feuert weiter. Auf solchen Connections lässt sich Realtime deshalb gar nicht erst einschalten.
- GitLab 19.1 oder neuer. Darunter fehlen die Standard-Webhooks-Header, und einen Legacy-Pfad über
X-Gitlab-Tokengibt es bewusst nicht. Die Karte zeigt die Version aus dem letzten Verbindungstest, davorNot checked. - Scope und Rollen des Tokens.
apials Scope, dazu Maintainer auf jedem Projekt im Scope. Für einen einzelnen Group Hook zusätzlich Owner auf der Gruppe und eine Instanz mit Premium oder Ultimate. - Ausgehende Requests ins lokale Netz. Das gilt, sobald die Receiver-URL auf eine private Adresse zeigt. Dann braucht es die Admin-Einstellung «Allow requests to the local network from webhooks and integrations». Fehlt sie, weist GitLab den Hook schon beim Anlegen ab, und die Karte übersetzt den Fehler.
Der sechste Punkt bleibt Handarbeit, und die Karte sagt das auch: Check yourself. Ein curl vom GitLab-Host auf die Receiver-URL beweist die Erreichbarkeit, und die erwartete Antwort ist 404. Der Receiver weist ja jede Delivery ab, deren Signatur er nicht prüfen kann. Ein Timeout, ein TLS-Fehler oder das 502 eines Proxys beweisen das Gegenteil.
Erst danach erscheint der Button. Die Installation läuft in einer Reconciliation im Hintergrund, die Coverage füllt sich also über die nächsten Sekunden und nicht mit dem Klick. Die Karte zeigt danach zwei Blöcke: wie viele der vorgesehenen Hooks installiert sind, inklusive der Hooks, die noch ausstehen, gescheitert sind oder von GitLab suspendiert wurden, und wie frisch die Deliveries der letzten 24 Stunden ankamen, mit Median und 95. Perzentil gegen das 5’000-ms-Budget.
Die beiden Zeitpunkte darunter sind bewusst getrennt: letzte empfangene Delivery und letzter Broadcast. Wenn Empfänge weiterlaufen und Broadcasts stehen bleiben, liegt der Fehler nicht bei GitLab. Dann ist die Action-Cable-Verbindung des Browsers weg.
Vier Aktionen liegen unter dem Status, nicht darüber, weil jede auf etwas antwortet, das der Status gerade gemeldet hat: eine Test-Delivery anfordern, die Reconciliation erneut anstossen, Realtime abschalten und Hooks bei GitLab hart entfernen. Der letzte Button ist der einzige destruktive: Er ändert GitLab, nicht die Einstellung des Workspace, und ist für Hooks gedacht, die nach einem gescheiterten Teardown übrig geblieben sind.
Die Fehlermeldung ist immer dieselbe, die Kategorie nicht
GitLab antwortet auf jede abgelehnte Hook-Operation mit demselben Satz: «GitLab request failed». Als Diagnose ist das wertlos. Die Karte zeigt ihn trotzdem im Original und stellt die Kategorie daneben, denn erst die Kategorie sagt, was zu tun ist.
| Kategorie | Was passiert ist | Was hilft |
|---|---|---|
| unauthorized | Das Token wurde abgelehnt (401). | Service Token mit Scope api neu erzeugen und auf der Connection speichern. |
| forbidden | Das Token hat die Rolle nicht (403). | Maintainer auf dem Projekt vergeben, für einen Group Hook Owner auf der Gruppe. |
| not_found | Projekt oder Gruppe ist für das Token unsichtbar (404). | Existenz und Sichtbarkeit prüfen, oder den Scope entfernen. |
| validation, blockierte URL | GitLab hält die Receiver-URL für eine lokale Adresse. | Outbound Requests ins lokale Netz in den Admin Settings erlauben. |
| rate_limited, unavailable, timeout | Vorübergehend. | Nichts ist falsch konfiguriert. Reconciliation erneut anstossen oder den nächsten Lauf abwarten. |
Ein Fall verdient eine eigene Erklärung, weil er nach einem Fehler aussieht und keiner ist. Ob eine Instanz Group Hooks überhaupt anbietet, kündigt GitLab nirgends an. Workbench erfährt es als 403 im Moment der Installation und fällt danach still auf Project Hooks zurück. Das Ergebnis ist eine korrekte Installation, nur eine grössere.
«Retry reconciliation» darf man beliebig oft drücken. Der Lauf vergleicht die Hook-Liste bei GitLab mit der stabilen URL jeder Registrierung, konvergiert also ohne Duplikate, und er vergisst eine frühere Group-Hook-Ablehnung. Eine nachgereichte Owner-Rolle wirkt dadurch beim nächsten Versuch.
Bleibt der schwierigere Fall: Die Coverage ist vollständig aktiv, und trotzdem kommt nichts an. Dann ist GitLabs eigenes Protokoll massgebend, nicht das von Workbench. Unter Settings → Webhooks → Edit → Recent Deliveries steht jeder Versuch mit seinem Antwortstatus.
| Antwort bei GitLab | Bedeutung |
|---|---|
| kein Versuch | Der Hook hört nicht auf die geänderten Events, oder im Scope hat sich nichts geändert. |
| Verbindungsfehler | GitLab erreicht den Receiver nicht. Erreichbarkeit vom GitLab-Host aus erneut prüfen. |
| 404 | Die Signatur wurde nicht verifiziert, oder die Registrierung ist unbekannt oder suspendiert. |
| 503 | Outbound Safety ist auf diesem Deployment abgeschaltet. Der Receiver nimmt bewusst nichts an. |
| 202 | Die Delivery wurde angenommen. Der Fehler liegt danach, in GoodJob oder im Status der Delivery-Zeile. |
Das 404 in dieser Tabelle ist das Interessante. Auf ein handgemachtes curl ist es die richtige Antwort, auf GitLabs eigene signierte Delivery ist es ein Defekt. Dieselbe Zahl bedeutet an zwei Stellen etwas Entgegengesetztes. Was uns das gekostet hat, steht im nächsten Abschnitt.
Was der Plan falsch hatte
Die erste Fassung von ADR 0013 sah Cross-Workspace-Streaming vor. Ein Channel sollte mehrere Workspaces bedienen können. Im Authorization-Spec liess sich zeigen, dass dieses Modell die gewünschte Trennung untergräbt. Die Idee flog aus dem ADR. Heute gehört jede Subscription genau zu einem Workspace.
Der No-op-Fehler bei projection_version entstand ebenfalls nicht in diesem Projekt. Er war schon da. Der Boards-Artikel klang an einer Stelle sauberer, als das System tatsächlich war: Wiederholte Syncs änderten zwar keine fachlichen Daten, aber weiterhin die Version. Realtime machte diese Ungenauigkeit sichtbar, weil jede falsche Version einen falschen Broadcast bedeutet hätte.
Der teuerste Fehler zeigte sich erst im Betrieb, nach der Veröffentlichung dieses Artikels. Der Reconciler schickte das Signing Secret unter GitLabs altem Attribut token. Das ist ein anderes Feld als signing_token: Bei token schickt GitLab den Wert im Klartext-Header X-Gitlab-Token mit, und genau dieses Schema prüft der Receiver nie. Jeder installierte Hook war damit unsigniert, und jede echte Delivery scheiterte an der Signaturprüfung.
Sichtbar war davon nichts. Die Hooks standen bei GitLab, die Coverage meldete sie aktiv, und der Receiver antwortete mit 404. Dieselbe Antwort gibt einem Angreifer kein Orakel über gültige Registrierungen. Sie verbirgt damit aber auch vor dem Betreiber, ob gerade eine Signatur scheiterte oder eine Registrierung fehlte. Aufgefallen ist es an einem Hook in GitLab, dessen signing_token_present auf false stand und token_present auf true. Reproduziert wurde es über GitLabs synthetische Test-Delivery, also über denselben Button, der jetzt auf der Karte sitzt.
Ein Rack-Attack-Spec bleibt flaky. Er kann an der Minutengrenze seines Throttle Buckets auf die andere Seite kippen. Wir haben ihn bewusst nicht in diese Änderung gezogen. Das ist offene Testarbeit, kein Beleg gegen den beschriebenen Datenpfad.
Fazit
Der volle Projekt-Sync bleibt. Er ist Fallback für unklare Events, Reparaturweg für verpasste Deliveries und letzte Instanz bei Drift. Webhooks ersetzen ihn nicht. Sie verkürzen nur die Zeit bis zum nächsten autoritativen Read.
Bewusst draussen bleiben GitLab-Versionen vor 19.1, System Hooks, Event Sourcing und gespeicherte vollständige Webhook-Payloads. Keines davon ist nötig, um eine Änderung schnell und sicher in einen offenen Browser zu bringen.
Die Projektion darf weiterhin falsch liegen. Jetzt merkt sie es schneller.

