Staging Ingest Operations Runbook¶
Dieser Block beschreibt nur den operativen Ablauf fuer den separaten Kafka/Ingest-Worker im Staging. Die API bleibt dabei weiter online, weil der Worker als eigener Compose-Service laeuft.
Wann wir den Worker einschalten¶
STAGING_INGEST_ENABLED=truestartet den dedizierteningest-Worker beim naechsten Staging-Deploy.STAGING_INGEST_ENABLED=falsestoppt den Worker wieder und entfernt alte Container aus frueheren Deploys.- Aendere den Wert in den GitHub Actions Variablen und starte den Workflow
Deploy Stagingerneut auf demdevelopment-Branch.
Replicas hoch- und runterfahren¶
STAGING_INGEST_REPLICASsteuert die Anzahl der Worker-Instanzen im Staging-Deploy.- Starte normal mit
1. - Erhoehe den Wert, wenn sich CDC-Backlog oder Catch-up sichtbar aufbauen.
- Reduziere den Wert wieder, wenn die Last sinkt oder wir nur eine einzelne Instanz brauchen.
- Mehrere Replicas sind unkritisch, weil sie sich den gleichen Kafka Consumer Group State teilen.
Verifikation nach dem Deploy¶
Pruefe nach jedem Deploy diese Punkte:
docker compose ps backend ingest
docker compose logs --tail 200 ingest
curl -sS http://127.0.0.1:8080/healthz | jq '.components.ingest'
curl -sS http://127.0.0.1:8080/api/admin/diagnostics/summary | jq '.ingest'
Erwartung:
- status unter components.ingest ist up
- workerState ist running
- lastErrorMessage ist leer
- heartbeatAt und lastProcessedAt aktualisieren sich
- catchUpState ist je nach Lage live oder kontrolliert catching_up
- lagSampledAt wird frisch geschrieben und lagErrorMessage bleibt leer
- totalLag, maxTopicLag und lagTopics zeigen, ob echter Rueckstau vorliegt oder nur normaler Live-Betrieb
- der Admin-Bereich zeigt den Ingest-Status nicht mehr als pauschalen Fehler, sondern als echte Worker-Info
Wenn der Worker laeuft, aber die Daten nicht weiter vorankommen, dann zuerst Logs und Health pruefen, danach die Kafka-/Debezium-Strecke getrennt betrachten.
Wichtig fuer Releases mit Datenbankaenderungen:
Deploy Stagingfuehrt vor dem Restart automatischnpm run prisma:migrate:deployim Backend-Image aus.- Dadurch werden neue Prisma-Migrationen zusammen mit dem Release auf das Staging-Schema angewendet.
- Wenn dieser Schritt scheitert, bleibt das Deploy bewusst stehen, bevor die neuen Container live gehen.
Backfill und Reprocessing¶
Fuer gezielte Rueckverarbeitung nutzen wir den Backfill-Command im Backend:
cd targetshot/backend
npm run ingest:backfill -- 1234:5678 1234:6789
Nutzliche Optionen:
--group=<id>verwendet eine stabile Consumer Group fuer Lag-Beobachtung--new-groupstartet einen frischen Replay-Lauf ab Offset 0
Empfohlener Ablauf fuer groessere Reprocessing-Faelle:
- Live-Worker bei Bedarf kurz deaktivieren oder auf
1Replicas lassen. - Backfill mit einer klar benannten Gruppe starten.
- Ergebnis in Mirror-DB, Kafka-Topic und Admin-Diagnose pruefen.
- Den normalen Worker wieder aktivieren bzw. die Replicas hochziehen.
Fuer die historische Rueckverarbeitung der Schuetzen-Stammdaten steht zusaetzlich der manuelle GitHub-Workflow Replay Shooter Master Data zur Verfuegung.
Empfohlene Eingaben:
club_ids: zum Beispiel413067group_id: Standardtargetshot-shooter-backfillfresh_group=true: erzwingt einen frischen Replay-Lauf ab Offset0
Der Workflow startet einen einmaligen Backend-Container mit node dist/ingest/backfillShooters.js ... und nutzt dabei die bestehende Staging-.env.
Incident-Matrix fuer Source-Drift und Rejects¶
Die Ingest-Health unterscheidet jetzt bewusst zwischen normalem Betrieb, frischen Vertragsabweichungen und echter Quarantaene:
workerState=running+catchUpState=live: normaler Live-BetriebadmissionState=rejecting: frische, wiederholte Contract-Rejects; einzelne fehlerhafte Events werden verworfen, aber der Worker verarbeitet weitersourceDriftState=suspected: es gibt ein frisches Drift-Signal, bevor bereits ein voller Incident offen istadmissionState=quarantined: derselbe fehlerhafte Pfad wurde mehrfach in kurzer Zeit abgelehnt; der Vorfall ist jetzt operativ ein IncidentincidentState=recovered: der vormals fehlerhafte Pfad wurde spaeter wieder mit passender Herkunft akzeptiert
Diese Signale erscheinen an drei Stellen:
GET /healthzuntercomponents.ingestadmin-portalinConnectorsbeim Shared-Ingest-Block des Host-Dialogsadmin-portalinDiagnosticsund in den Dashboard-Warnungen
Wichtige Felder fuer die Einordnung:
lastContractRejectReasonlastContractRejectTopiclastContractRejectEntitysourceDriftDetailrejectBurstCountquarantineSincequarantineTopicquarantineClubIdquarantineEntityquarantineDetail
Vorgehen bei rejecting oder sourceDriftState=suspected¶
Wenn der Worker noch laeuft, aber frische Rejects oder Drift-Hinweise auftauchen:
- Im
admin-portalden betroffenen Club-Host oeffnen undsourceDriftDetail,lastContractRejectReason, Topic und Entity notieren. - In
/healthzoder den Worker-Logs bestaetigen, ob der Fehler von einem einzelnen Event oder von einer durchgehenden Serie kommt. - Am Producer-/Connector-Pfad pruefen:
- passt
source_topiczur Entitaet? - passt
source_club_idzum Topic-Scope? - gibt es widerspruechliche Header-/Payload-Herkunft?
- Solange noch keine Quarantaene vorliegt, den Worker weiterlaufen lassen und nur den fehlerhaften Producer-Pfad korrigieren.
- Nach der Korrektur aktiv beobachten, ob
sourceDriftStatewieder aufrecoveredodernonefaellt.
Faustregel:
suspectedoderrejectingist noch kein automatischer Rollback-Fall.- Erst wenn die gleiche Abweichung wiederholt auftritt oder produktiv mehrere Clubs betreffen koennte, wird der Incident wie eine Quarantaene behandelt.
Vorgehen bei admissionState=quarantined¶
Bei Quarantaene ist der Herkunftsvertrag mehrfach verletzt worden. Dann gilt:
- Den betroffenen Producer-/Connector-Pfad zuerst stoppen oder korrigieren.
- Keine grossflaechige Replay-Aktion starten, solange der Scope-Fehler noch offen ist.
quarantineTopic,quarantineClubId,quarantineEntityundquarantineReasonim Incident festhalten.- Danach entscheiden:
- nur der betroffene Pfad war falsch: Worker kann weiterlaufen, sobald wieder gueltige Events kommen
- mehrere Pfade oder hoher Druck auf dem Cluster:
STAGING_INGEST_REPLICASreduzieren oderSTAGING_INGEST_ENABLED=falsesetzen und neu deployen - Erst nach erfolgreicher Scope-Korrektur gezielt reprocessen.
Ein recovered-Zustand bedeutet:
- der Worker hat fuer denselben Pfad spaeter wieder gueltige Herkunft gesehen
- das ist ein gutes Signal, ersetzt aber nicht die fachliche Kontrolle, ob ein Replay noetig ist
Reprocessing-Vertrag nach einer Korrektur¶
Nach einem Source- oder Mapping-Fehler wird immer in dieser Reihenfolge gearbeitet:
- Ursache am Producer-/Connector beheben
- mit
/healthzbestaetigen, dass keine neue Reject-Serie mehr entsteht - Reprocessing gezielt fuer den betroffenen Datenschnitt starten
- Ergebnis im Zielmodell validieren
- erst danach eventuell Replicas wieder hochziehen
Fuer Treffer-/Scheiben-bezogene Rueckverarbeitung:
cd targetshot/backend
npm run ingest:backfill -- --group=targetshot-reprocess-incident 413067:899822614
Fuer einen frischen Replay-Lauf ohne alten Consumer-Offset:
cd targetshot/backend
npm run ingest:backfill -- --group=targetshot-reprocess-incident --new-group 413067:899822614
Fuer historische Schuetzen-Stammdaten:
cd targetshot/backend
npm run ingest:backfill-shooters -- --group=targetshot-shooter-reprocess --new-group 413067
Nach jedem Reprocessing muessen mindestens diese Punkte gruen sein:
workerState=runningcatchUpStategeht wieder aufliveoder faellt kontrolliert auscatching_upzuruecksourceDriftStatebleibt aufnoneoderrecovered- keine neuen Contract-Rejects fuer denselben Pfad
- das fachliche Zielmodell wurde wirklich aktualisiert
Rollback¶
Wenn eine neue Ingest-Version Probleme macht:
STAGING_INGEST_ENABLED=falsesetzen und den Staging-Deploy erneut ausfuehren.- Wenn nur zu viel Druck auf dem Cluster entsteht,
STAGING_INGEST_REPLICASauf1reduzieren. - Danach Logs, Health und Mirror-DB erneut validieren.
- Vor einem erneuten Einschalten pruefen, ob noch ein offener Source-Drift-/Quarantaene-Kontext besteht.
- Erst wenn der Ingest-Pfad wieder stabil ist, den Worker wieder hochfahren.
Wichtig: - Der Worker ist absichtlich vom API-Container getrennt. - Ein Rollback kann deshalb den Ingest sauber stoppen, ohne dass die API neu gebaut werden muss. - Ein Rollback ersetzt keine spaetere gezielte Rueckverarbeitung; fehlende Daten muessen danach bewusst nachgezogen werden.
Kurzcheck fuer Operatoren¶
- Ist der Worker aktiv?
- Meldet
/healthzden Worker alsupundrunning? - Wieviele Replicas laufen?
- Stehen
lastProcessedAtundheartbeatAtnoch? - Ist
lagSampledAtfrisch und bleibtlagErrorMessageleer? - Wie gross sind
totalLagund die auffaelligstenlagTopics? - Gibt es
sourceDriftState, frische Rejects oder eine laufende Quarantaene? - Ist ein neuer Fehler in
lastErrorMessageaufgetaucht? - Wurde eine gegebene Rueckverarbeitung wirklich im Mirror-DB-Stand sichtbar?
- Wurde nach einem Incident auch das fachliche Zielmodell wieder geprueft?
Wenn diese Punkte sauber sind, ist der Staging-Ingest aus Ops-Sicht gesund.
Datenfrische auf PROD: „geschossen UND angekommen?"¶
Alles oben misst die MECHANIK des Staging-Workers. Auf prod ingestiert
stattdessen die Azure Function targetshot-ingest-prd01.
Seit 09/2026 schreibt auch sie einen Statuszettel — aber nur, wenn der Schalter gesetzt ist:
az functionapp config appsettings set -g targetshot-kc \
-n targetshot-ingest-prd01 --settings TS_INGEST_STATUS_ENABLED=true
Der Schalter ist per Vorgabe AUS und darf erst nach einem Backend-Release
umgelegt werden, das die Betriebsart runnerKind: 'event' kennt
(backend/src/services/ingestStatus.ts). Eine aeltere Fassung liest den Zettel
sonst als „Dauerlaeufer, der nichts tut" und faerbt die Plattform-Station der
Vereinskarte an jedem schiessfreien Abend gelb.
Was der Zettel auf prod beweist und was nicht:
| Feld | Herkunft | Aussage |
|---|---|---|
heartbeatAt |
Zeitgeber IngestStatusHeartbeat, jede Minute |
Die Ingest-App laeuft und erreicht die Datenbank. |
lastProcessedAt / lastSourceEventAt |
Nachrichtenverarbeitung, gedrosselt auf 30 s | Wann zuletzt wirklich etwas verarbeitet wurde. |
catchUpState |
abgeleitet | Bei runnerKind: 'event' niemals stalled — „seit Stunden nichts" heisst dort „gerade schiesst niemand". |
lag |
— | Gibt es nicht: der EH-Trigger checkpointet ausserhalb des Kafka-Group-Modells, ein Consumer-Rueckstand ist nicht ablesbar. |
Die Frage „fehlen Daten, obwohl welche haetten kommen muessen?" beantwortet dieser Zettel ausdruecklich NICHT. Dafuer ist der Frische-Waechter da.
Solange der Schalter aus ist, gilt der alte Stand: GET /healthz/ingest
antwortet unknown, und weil unknown nicht als down zaehlt, bleibt
/healthz trotzdem ok.
Der Frische-Wächter misst deshalb das Ergebnis statt der Mechanik:
GET https://api.targetshot.app/healthz/data-freshness
-> { "status": "ok|stale|unknown|error", "checked", "fresh", "stale", "unknown" }
Regel: nach einem abgeschlossenen Trainingsabend muss ein Treffer in der
Cloud angekommen sein, der juenger ist als der Beginn dieses Abends. Die
Trainingsabende kommen aus dem Standaufsichtsplan des Vereins
(club:range-supervision:schedule:<clubId>), nicht aus einer festen
Altersgrenze — ein Verein, der nur mittwochs schiesst, waere sonst von
Donnerstag bis Dienstag dauerhaft rot. Karenz nach Abendende: 6 Stunden.
Gemessen wird ausschliesslich max(ClubDataHit.createdAt): zeitstempel ist
die Anlagenuhr (Boxversatz), updatedAt hebt jeder Re-Sync ohne neuen Schuss
an. Logik und Begruendung: backend/src/services/dataFreshness.ts.
Zustaende. stale = es wurde geschossen und nichts kam an. error = die
Pruefung selbst ist gestoert (Abfrage gescheitert, oder die juengste Ankunft
liegt in der Zukunft — dann stimmt der Zeitbezug der DB-Sitzung nicht und das
Signal ist unbrauchbar, NICHT gut). unknown = dieser Verein hat noch nie Daten
geliefert; ein Einrichtungsstand, kein Ausfall.
Ein Verein OHNE gespeicherten Trainingsplan wird trotzdem beurteilt — nach der
Vorgabe, die ihm die App anzeigt (Mi 18-20 Uhr). Grund: loadRangeSupervisionConfig
in routes/club.ts liefert diese Vorgabe aus, ohne sie je zu speichern; ein
Waechter, der auf die Setting-Zeile besteht, waere bei genau dem Verein stumm,
der ihn braucht. Im Admin-Detail steht dann planSource: "assumed" — bei einem
Fehlalarm ist das die erste Stelle zum Nachsehen.
Alarm. .github/workflows/data-freshness.yml, taeglich 06:00 UTC, auf
einem GitHub-hosted Runner — bewusst unabhaengig von Box, Connector-Host und
self-hosted Runnern. Rot bei stale und error; die native
GitHub-Fehlschlag-Mail ist der Zustellweg. Ferienpause: Repo-Variable
TS_FRESHNESS_SNOOZE_UNTIL auf ein ISO-Datum.
Nach einem roten Lauf. /api/admin/diagnostics/summary (Plattform-Admin)
fuehrt unter dataFreshness je Verein die letzte Ankunft und zwei
consent-freie Gegenproben:
lastStarterlistSeenAt— der Starterlisten-Kanal laeuft OHNE Einwilligungs-Filter. Frisch, aber keine Treffer? Dann lebt die Box und der Schiessdaten-Weg (Event Hubs) ist abgerissen — genau der Fall vom 26.08.2026.edgeTokenLastUsedAt— die Box spricht ueberhaupt HTTP. Alt? Dann ist die Box aus oder offline, und es gibt nichts zu reparieren, was in der Cloud liegt.
Abgrenzung. ts-connect-freshness.yml (admin-portal, Do 05:00 UTC) prueft
die Spiegel-DB AUF DER BOX. Die beiden ergaenzen sich: Box gruen + Cloud rot =
der Transportweg steht. Box rot = es wurde gar nicht geschossen oder die
Meyton-Kiste lief nicht.