Spukhafte Fernwirkung: Wenn dein Proxy-Fix die Anwendung zerstört

Oder: Wie ich lernte, dass „funktioniert" und „korrekt" nicht dasselbe sind

„Spukhafte Fernwirkung" — so nannte Einstein 1947 die Quantenverschränkung, das Phänomen, bei dem die Messung eines Teilchens sofort ein anderes beeinflusst, egal wie weit sie voneinander entfernt sind. Er meinte es abwertend; die Idee schien absurd.

Den Begriff in Bezug auf Software hörte ich zum ersten Mal vor fast zehn Jahren, in Marco Pivettas (@ocramius) Vortrag Extremely Defensive PHP. Das Konzept blieb mir im Kopf: Code an einer Stelle beeinflusst mysteriöserweise Code woanders, ohne sichtbare Verbindung.

Das ist eine Geschichte über genau so einen Bug.


„Die Ranking-Berechnungen laufen nicht."

Das System: Eine Callback-basierte Pipeline

Unser Ranking-Tool fragt eine Drittanbieter-API nach Suchmaschinendaten ab. Die Pipeline sieht simpel genug aus:

Jeder Task durchläuft eine State Machine, die durch drei Flags getrackt wird:

class AnalysisTask
{
    private bool $submitted = false;  // An externe API gesendet?
    private bool $completed = false;  // API hat Verarbeitung abgeschlossen?
    private bool $processed = false;  // Wir haben Ergebnisse abgeholt?
}

Die kritische Übergabe passiert über einen Webhook-Callback: Wenn die externe API die Verarbeitung abschließt, sendet sie einen HTTP-Request an unseren Callback-Endpoint, der completed = true setzt.

Einfach, oder?

Der Bug: Tasks für immer blockiert

Tasks wurden erfolgreich erstellt und übermittelt. Die externe API verarbeitete sie (wir haben es über deren Dashboard verifiziert). Aber unser Process-Command fand nichts zu tun. Mitte November 2025 hörte die Verarbeitung auf.

Ich schaute mir die Verarbeitungslogik an:

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $tasksToProcess = $this->repository->findBy([
        'submitted' => true,
        'completed' => false,  // <-- Hmm...
        'processed' => false,
    ]);

    foreach ($tasksToProcess as $task) {
        $this->processTask($task);
    }

    return Command::SUCCESS;
}

Moment mal. Wir suchen nach Tasks, bei denen completed = false ist?

Wenn der Callback completed = true setzt, wenn die API fertig ist… warum suchen wir dann nach Tasks, die nicht abgeschlossen sind?

Die Annahmen-Falle

Ich prüfte den Callback-Controller:

#[Route('/api/callback', methods: ['POST'])]
public function handleCallback(Request $request): Response
{
    $taskId = $request->get('task_id');

    $task = $this->repository->find($taskId);
    $task->setCompleted(true);  // <-- Setzt completed = TRUE
    $this->entityManager->flush();

    return new Response('OK');
}

Der Callback setzt eindeutig completed = true. Das Process-Command fragt nach completed = false. Das schließt sich gegenseitig aus.

Dieser Code konnte nie funktioniert haben… oder? Wir fanden den Bug, fixten ihn und waren fertig. Aber ich wollte wissen warum es passiert ist, was sich geändert hatte…

Das Mysterium: Code, der sich nicht geändert hatte

Hier wird es seltsam. Ich prüfte die Git-History des Processing-Commands:

git log --oneline -- src/Command/ProcessRankingTasksCommand.php

Letzte Änderung: September 2025. Über zwei Monate her.

Ich prüfte jede Datei, die an der Ranking-Pipeline beteiligt ist:

git log --oneline --since="2025-08-01" -- \
    src/Command/ProcessRankingTasksCommand.php \
    src/Controller/RankingCallbackController.php \
    src/Entity/RankingTask.php \
    src/Repository/RankingTaskRepository.php \
    src/Service/RankingTaskProcessor.php

Nichts. Kein einziger Commit. Der Ranking-Code war seit September 2025 nicht angefasst worden.

Aber die Rankings hörten im November 2025 auf zu funktionieren. Wenn sich am Code nichts geändert hat… was dann?

Die zeitliche Untersuchung

Wir wussten wann es kaputt ging. November 2025. Also stellte ich, statt Code-Pfade zu verfolgen, eine andere Frage: Was hat sich sonst in diesem Monat geändert?

git log --oneline --after="2025-11-01" --before="2025-11-30"

Da fand ich etwas Interessantes, nachdem ich einige Commits durchgegangen war, die hauptsächlich unverwandtes Zeug änderten:

16. November 2025 — „Consolidate shortlink into main app with host-based routing"

Das sah interessant aus…

Ich öffnete den Diff:

# VORHER: Host-spezifisches Routing
http://app.example.com {
    import common_config
}

http://staging.example.com, http://localhost {
    import common_config
}

http://php {  # Internes Docker-Netzwerk
    import common_config
}
# NACHHER: Alle eingehenden Requests akzeptieren
:80 {
    root /app/public
    php_server
}

Der Commit handelte davon, einen Shortlink-Service zu konsolidieren. Der Entwickler hatte keine Ahnung, dass er irgendetwas an unserer Ranking-Pipeline änderte. Aber durch den Wechsel von host-spezifischem Routing zu :80 begann er versehentlich, Requests von jedem Hostnamen zu akzeptieren — einschließlich Callbacks von unserer externen API.

Die Timeline rekonstruieren

Die Puzzleteile fügten sich chronologisch zusammen:

Das versteckte Problem (von Anfang an)

Unsere Caddy-Konfiguration akzeptierte nur Requests von bestimmten Hostnamen. Aber Callbacks von der externen API kamen von deren Servern — mit einem anderen Host-Header. Caddy lehnte sie auf Proxy-Ebene ab. Der Callback-Controller wurde nie aufgerufen. completed blieb für immer false.

Niemand wusste es. Das System funktionierte wegen falscher Annahmen.

September 2025: Das Refactoring

  • Entwickler refactored die Ranking-Pipeline
  • Schaut sich die tatsächlichen Daten in der Datenbank an
  • Sieht Tasks mit submitted = true, completed = false
  • Schreibt Query passend zur beobachteten Realität
  • Tests bestehen (mocken die Datenbank)
  • Code wird deployed

Der Code war nicht falsch, basierend auf dem, was der Entwickler beobachtete. Die Infrastruktur verwarf still Callbacks, und der Code spiegelte diesen kaputten Zustand wider.

  • Entwickler konsolidiert Shortlink-Service in die Haupt-App
  • Ändert Caddy von host-spezifischem Routing zu :80 (akzeptiert alles)
  • Unbeabsichtigter Nebeneffekt: Callbacks von externer API erreichen jetzt die Anwendung
  • completed wird jetzt korrekt auf true gesetzt
  • Niemand bemerkt, dass das Process-Command die invertierte Query hat
  • System scheint zu funktionieren (alte Tasks altern aus oder werden manuell behandelt)

Januar 2026: Der Bug taucht auf

  • Neuer Batch von Tasks wird übermittelt
  • Callbacks werden korrekt empfangen, completed = true
  • Process-Command fragt nach completed = false
  • Null Ergebnisse
  • „Die Ranking-Berechnungen laufen nicht"

Der Fix

Eine Property:

// VORHER
'completed' => false,

// NACHHER
'completed' => true,

Die korrekte State Machine:

// 1. Erstellt:     submitted=false, completed=false, processed=false
// 2. Übermittelt:  submitted=true,  completed=false, processed=false
// 3. Callback:     submitted=true,  completed=true,  processed=false  ← HIER VERARBEITEN
// 4. Fertig:       submitted=true,  completed=true,  processed=true

Wir wollen Tasks in State 3: Callback erhalten, noch nicht verarbeitet.

Warum das so schwer zu finden war

Der Code hatte sich nicht geändert

Der erste Instinkt beim Debuggen ist, aktuelle Änderungen zu prüfen. Aber der Ranking-Code war seit sechs Monaten nicht angefasst worden. Der übliche git blame-Ansatz führte ins Nichts.

Der Bug war nicht im kaputten Code

Das Process-Command war „korrekt" für die beobachteten Daten. Der eigentliche Bug war in Caddy — einer völlig anderen Schicht. Man würde ihn nie finden, indem man Code-Pfade verfolgt.

Kein Test hätte das fangen können

Unit-Tests mocken die Datenbank. Integrationstests involvieren keine externen Callbacks. Man bräuchte einen vollständigen End-to-End-Test mit echten Drittanbieter-Webhooks.

Die Schlüsselerkenntnis: Zeitliche Analyse

Wenn Code-Archäologie versagt, probier zeitliche Analyse. Wir wussten wann es kaputt ging. Dieser Zeitstempel war unser Anker. Statt zu fragen „was hat sich in dieser Datei geändert?" fragten wir „was hat sich überhaupt zu der Zeit geändert?"

Lessons Learned

1. Dokumentiere deine Annahmen

/**
 * Finde Tasks, die zur Ergebnis-Verarbeitung bereit sind.
 *
 * Wir fragen nach completed=true weil:
 * - Externer API-Callback setzt completed=true wenn fertig
 * - Wir wollen Tasks, die von API fertig sind, aber noch nicht von uns verarbeitet
 *
 * Wenn das leer zurückkommt aber Tasks existieren, prüfe:
 * - Erreichen Callbacks den Server? (Proxy-Logs prüfen)
 * - Ist der Callback-Endpoint extern erreichbar?
 */
$tasks = $this->repository->findTasksReadyForProcessing();

2. Infrastruktur-Änderungen brauchen Application-Review

Wenn du Proxy-, Routing- oder Netzwerk-Konfiguration änderst, frag:

  • Welche externen Services rufen unsere Endpoints auf?
  • Welche Hostnamen oder IPs nutzen sie?
  • Wird unsere neue Konfiguration diese Requests akzeptieren?

3. Füge Observability für State-Übergänge hinzu

if (count($tasks) === 0) {
    // Prüfe auf Tasks, die in unerwarteten States feststecken
    $stuckCount = $this->repository->count([
        'submitted' => true,
        'completed' => false,
        'processed' => false,
    ]);

    if ($stuckCount > 0) {
        $this->logger->warning(
            'Found {count} tasks with completed=false. Callback may not be working.',
            ['count' => $stuckCount]
        );
    }
}

4. Frag die tatsächlichen Daten ab

Beim Debuggen von State-Machine-Problemen, schau dir die Verteilung an:

SELECT
    submitted, completed, processed, COUNT(*) as count
FROM analysis_tasks
GROUP BY submitted, completed, processed;

Das zeigt sofort Tasks, die in unerwarteten States feststecken.

Die Taxonomie spukhafter Bugs

Diese Bugs teilen gemeinsame Merkmale:

Dimension Spukhaftes Bug-Merkmal
Raum Ursache und Symptom in verschiedenen Systemen
Zeit Änderungen liegen Wochen oder Monate auseinander
Kausalität Die auslösende Änderung ist völlig unverwandt
Absicht Eine „Shortlink-Konsolidierung" zerstört eine „Ranking-Pipeline"
Sichtbarkeit Funktioniert in Dev, versagt in Prod (oder umgekehrt)

Das Lösungsmuster:

  1. Hinterfrage Annahmen darüber, wie das System funktionieren sollte
  2. Verifiziere mit Daten, wie es tatsächlich funktioniert
  3. Nutze zeitliche Analyse — wenn du weißt wann es kaputt ging, prüfe alles, was sich dann geändert hat
  4. Schau über den Code hinaus auf Infrastruktur, Konfiguration, externe Abhängigkeiten

Fazit

Die heimtückischsten Bugs sind nicht im Code, den du dir anschaust. Sie stecken in den Annahmen, von denen du nicht weißt, dass du sie gemacht hast.

Unser Ein-Zeilen-Fix versteckte sich hinter einer Caddy-Konfigurationsänderung aus demselben Monat. Das Process-Command war nicht falsch — es war korrekt für ein kaputtes System geschrieben. Als wir das System fixten, wurde der „korrekte" Code inkorrekt.

Der Durchbruch kam, als wir eine andere Frage stellten. Statt „was hat sich an diesem Code geändert?" fragten wir „was hat sich geändert, als es kaputt ging?" Sechs Monate unveränderter Code bedeuteten, dass die Antwort woanders liegen musste. Zeitliche Analyse — alle Commits vom Fehlerdatum anzuschauen — führte uns direkt zur Infrastruktur-Änderung.

In der Quantenphysik verändert das Beobachten eines Teilchens seinen Zustand. In verteilten Systemen kann das Fixen einer Komponente die Annahmen ändern, von denen andere Komponenten abhängen.

Spukhafte Fernwirkung, in der Tat.


„Der aufregendste Satz in der Wissenschaft, der neue Entdeckungen ankündigt, ist nicht ‚Heureka!’ sondern ‚Das ist komisch…'" — Isaac Asimov

In der Softwareentwicklung ist es oft: „Moment, warum haben wir das so geschrieben?"