Skip to content

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=true startet den dedizierten ingest-Worker beim naechsten Staging-Deploy.
  • STAGING_INGEST_ENABLED=false stoppt den Worker wieder und entfernt alte Container aus frueheren Deploys.
  • Aendere den Wert in den GitHub Actions Variablen und starte den Workflow Deploy Staging erneut auf dem development-Branch.

Replicas hoch- und runterfahren

  • STAGING_INGEST_REPLICAS steuert 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 Staging fuehrt vor dem Restart automatisch npm run prisma:migrate:deploy im 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-group startet einen frischen Replay-Lauf ab Offset 0

Empfohlener Ablauf fuer groessere Reprocessing-Faelle:

  1. Live-Worker bei Bedarf kurz deaktivieren oder auf 1 Replicas lassen.
  2. Backfill mit einer klar benannten Gruppe starten.
  3. Ergebnis in Mirror-DB, Kafka-Topic und Admin-Diagnose pruefen.
  4. 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 Beispiel 413067
  • group_id: Standard targetshot-shooter-backfill
  • fresh_group=true: erzwingt einen frischen Replay-Lauf ab Offset 0

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-Betrieb
  • admissionState=rejecting: frische, wiederholte Contract-Rejects; einzelne fehlerhafte Events werden verworfen, aber der Worker verarbeitet weiter
  • sourceDriftState=suspected: es gibt ein frisches Drift-Signal, bevor bereits ein voller Incident offen ist
  • admissionState=quarantined: derselbe fehlerhafte Pfad wurde mehrfach in kurzer Zeit abgelehnt; der Vorfall ist jetzt operativ ein Incident
  • incidentState=recovered: der vormals fehlerhafte Pfad wurde spaeter wieder mit passender Herkunft akzeptiert

Diese Signale erscheinen an drei Stellen:

  • GET /healthz unter components.ingest
  • admin-portal in Connectors beim Shared-Ingest-Block des Host-Dialogs
  • admin-portal in Diagnostics und in den Dashboard-Warnungen

Wichtige Felder fuer die Einordnung:

  • lastContractRejectReason
  • lastContractRejectTopic
  • lastContractRejectEntity
  • sourceDriftDetail
  • rejectBurstCount
  • quarantineSince
  • quarantineTopic
  • quarantineClubId
  • quarantineEntity
  • quarantineDetail

Vorgehen bei rejecting oder sourceDriftState=suspected

Wenn der Worker noch laeuft, aber frische Rejects oder Drift-Hinweise auftauchen:

  1. Im admin-portal den betroffenen Club-Host oeffnen und sourceDriftDetail, lastContractRejectReason, Topic und Entity notieren.
  2. In /healthz oder den Worker-Logs bestaetigen, ob der Fehler von einem einzelnen Event oder von einer durchgehenden Serie kommt.
  3. Am Producer-/Connector-Pfad pruefen:
  4. passt source_topic zur Entitaet?
  5. passt source_club_id zum Topic-Scope?
  6. gibt es widerspruechliche Header-/Payload-Herkunft?
  7. Solange noch keine Quarantaene vorliegt, den Worker weiterlaufen lassen und nur den fehlerhaften Producer-Pfad korrigieren.
  8. Nach der Korrektur aktiv beobachten, ob sourceDriftState wieder auf recovered oder none faellt.

Faustregel:

  • suspected oder rejecting ist 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:

  1. Den betroffenen Producer-/Connector-Pfad zuerst stoppen oder korrigieren.
  2. Keine grossflaechige Replay-Aktion starten, solange der Scope-Fehler noch offen ist.
  3. quarantineTopic, quarantineClubId, quarantineEntity und quarantineReason im Incident festhalten.
  4. Danach entscheiden:
  5. nur der betroffene Pfad war falsch: Worker kann weiterlaufen, sobald wieder gueltige Events kommen
  6. mehrere Pfade oder hoher Druck auf dem Cluster: STAGING_INGEST_REPLICAS reduzieren oder STAGING_INGEST_ENABLED=false setzen und neu deployen
  7. 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:

  1. Ursache am Producer-/Connector beheben
  2. mit /healthz bestaetigen, dass keine neue Reject-Serie mehr entsteht
  3. Reprocessing gezielt fuer den betroffenen Datenschnitt starten
  4. Ergebnis im Zielmodell validieren
  5. 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=running
  • catchUpState geht wieder auf live oder faellt kontrolliert aus catching_up zurueck
  • sourceDriftState bleibt auf none oder recovered
  • keine neuen Contract-Rejects fuer denselben Pfad
  • das fachliche Zielmodell wurde wirklich aktualisiert

Rollback

Wenn eine neue Ingest-Version Probleme macht:

  1. STAGING_INGEST_ENABLED=false setzen und den Staging-Deploy erneut ausfuehren.
  2. Wenn nur zu viel Druck auf dem Cluster entsteht, STAGING_INGEST_REPLICAS auf 1 reduzieren.
  3. Danach Logs, Health und Mirror-DB erneut validieren.
  4. Vor einem erneuten Einschalten pruefen, ob noch ein offener Source-Drift-/Quarantaene-Kontext besteht.
  5. 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 /healthz den Worker als up und running?
  • Wieviele Replicas laufen?
  • Stehen lastProcessedAt und heartbeatAt noch?
  • Ist lagSampledAt frisch und bleibt lagErrorMessage leer?
  • Wie gross sind totalLag und die auffaelligsten lagTopics?
  • Gibt es sourceDriftState, frische Rejects oder eine laufende Quarantaene?
  • Ist ein neuer Fehler in lastErrorMessage aufgetaucht?
  • 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.