Oder: Wie ich lernte, dass „funktioniert" und „korrekt" nicht dasselbe sind
„Spukhafte Fernwirkung" (Spooky action at a distance) — 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 im Kopf: Code an einer Stelle beeinflusst Code woanders, ohne sichtbare Verbindung.
„Die Ranking-Berechnungen laufen nicht."
Das System: Eine Callback-basierte Pipeline
Permalink to "Das System: Eine Callback-basierte Pipeline"Unser Ranking-Tool fragt eine Externe-API nach Suchmaschinendaten ab. Die Pipeline ist simpel:
Command] --> B[Externe
API] B --> C[Verarbeiten
Command] C --> D[Ergebnisse
berechnen]
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 Übergabe passiert über einen Webhook-Callback: Wenn die externe API uns
die Daten gesendet hat, wird danach einen HTTP-Request an unseren
Callback-Endpoint geschickt, der completed = true setzt.
Einfach, oder?
Der Bug: Tasks für immer blockiert
Permalink to "Der Bug: Tasks für immer blockiert"Tasks wurden erfolgreich erstellt und übermittelt. Die externe API verarbeitete sie (im Anbieter Dashboard verifiziert). Aber unser Process-Command fand nichts zu tun. Mitte November 2025 hörte die Verarbeitung auf.
Ich schaute mir die Pipeline 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
Permalink to "Die Annahmen-Falle"Ich schaute mir den Callback-Controller an:
#[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. Der Command fragt nach
completed = false. Das schließt sich gegenseitig aus.
Dieser Code konnte so eigenttlich nie funktioniert haben… oder? Wir fixten den `completed`` Bug und waren fertig. Aber ich wollte auch wissen warum es passiert ist, was sich geändert hatte…
Code, der sich nicht geändert hatte
Permalink to "Code, der sich nicht geändert hatte"Hier wird es seltsam. Ich schaute mir die History des Commands an:
git log --oneline -- src/Command/ProcessRankingTasksCommand.php
Letzte Änderung: September 2025. Über zwei Monate her.
Ich prüfte jede Datei, die von der Ranking-Pipeline benutzt wird:
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
Permalink to "Die zeitliche Untersuchung"Wir wussten wann es aufhörte Rankings upzudaten. 16. November 2025. Also stellte ich mir, wenn der Code sich nicht geändert hat, eine andere Frage: Was hat sich sonst noch an diesem Tag geändert?
git log --oneline --since="2025-11-16 00:00" --until="2025-11-16 23:59"
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 schaute mir den Diff an:
# 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
Permalink to "Die Timeline"Die Puzzleteile fügten sich chronologisch zusammen:
Das versteckte Problem
Permalink to "Das versteckte Problem"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.
Das System funktionierte wegen falscher Annahmen.
September 2025: Das Refactoring
Permalink to "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 zum derzeit richtigen (angenommenen) Ablauf
- Tests bestehen (mocken die Datenbank)
Der Code war nicht falsch, basierend auf dem, was der Entwickler analysierte. Die Infrastruktur verwarf still Callbacks, und der Code spiegelte diesen kaputten Zustand wider.
November 2025: Die Shortlink-Konsolidierung
Permalink to "November 2025: Die Shortlink-Konsolidierung"- 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
completedwird jetzt korrekt auftruegesetzt- Niemand bemerkt, dass die Pipeline die invertierte Query hat
Januar 2026: Der Bug taucht auf
Permalink to "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
Permalink to "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
Permalink to "Warum das so schwer zu finden war"Der Code hatte sich nicht geändert
Permalink to "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 Pipeline Code
Permalink to "Der Bug war nicht im Pipeline Code"Die Pipeline war „korrekt" für die beobachteten Daten. Der eigentliche Bug war in Caddy — einer völlig anderen Schicht. Man hätte den Bug nie gefunden, indem man Code-Pfade verfolgt.
Tests?
Permalink to "Tests?"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 Lösung: Temporale Analyse
Permalink to "Die Lösung: Temporale Analyse"Wenn am relevantent Code nichts verändert wurde, probier temporale (zeitliche) Analyse. Wir wussten wann es kaputt ging. Dieser Zeitstempel war unsere Rettung. Statt zu fragen „was hat sich im relevanten Code geändert?" fragten wir „was hat sich generell zu der Zeit geändert?"
Lessons Learned
Permalink to "Lessons Learned"1. Dokumentiere deine Annahmen
Permalink to "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
Permalink to "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
Permalink to "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
Permalink to "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 von spooky Bugs
Permalink to "Die Taxonomie von spooky Bugs"Diese Bugs teilen gemeinsame Merkmale:
| Dimension | Spooky 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:
- Hinterfrage Annahmen darüber, wie das System funktionieren sollte
- Verifiziere mit Daten, wie es tatsächlich funktioniert
- Nutze temporale Analyse — wenn du weißt wann es kaputt ging, prüfe alles, was sich um diese Zeit geändert hat
- Schau über den Tellerand (Code) hinaus auf Infrastruktur, Konfiguration, externe Abhängigkeiten
Fazit
Permalink to "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. Die Pipeline war nicht falsch — es war korrekt für ein kaputtes System geschrieben. Als wir das System fixten, wurde der „korrekte" Code inkorrekt.
Die Lösung 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, echt jetzt.
„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?"
