Der versteckte Bug: Wie ein Format-Mismatch zu Duplikaten führte
Der versteckte Bug: Wie ein Format-Mismatch zu Duplikaten führte
Ein klassischer Bug, der zeigt, warum “Single Source of Truth” keine optionale Best Practice ist, sondern eine Notwendigkeit. In diesem Post beschreibe ich, wie ein simpler Format-Unterschied zwischen zwei Dateien zu einem kritischen Bug führte – und was wir daraus lernen können.
Die Ausgangslage
BudgetBuddy importiert Banktransaktionen in YNAB (You Need A Budget). Um Duplikate zu vermeiden, verwendet YNAB ein import_id Feld: wenn zwei Transaktionen dieselbe Import-ID haben, wird die zweite als Duplikat erkannt und abgelehnt.
Das System bestand aus zwei Komponenten:
- YnabClient.fs – Generiert Import-IDs beim Erstellen von Transaktionen
- DuplicateDetection.fs – Prüft vor dem Import, ob Transaktionen bereits existieren
Soweit die Theorie. In der Praxis hatte ich einen Bug, der lange unentdeckt blieb.
Das Problem: “Force Import” erstellt Duplikate
Ich stellte fest: “Wenn ich Force Import verwende, erscheinen meine Transaktionen doppelt in YNAB.”
Force Import ist eine Funktion, die Transaktionen erneut importiert, selbst wenn sie als Duplikate erkannt wurden. Nützlich, wenn man eine Transaktion in YNAB gelöscht hat und sie wieder haben möchte.
Meine erste Reaktion: “Das kann nicht sein, Force Import generiert neue UUIDs als Import-IDs, also sollten sie als neue Transaktionen erkannt werden.”
Aber der Bug war real.
Herausforderung 1: Das Format-Mismatch finden
Die Symptome verstehen
Ich habe mir die Logs angeschaut und etwas Seltsames bemerkt: bei Force Import wurden ALLE Transaktionen als “Duplikate” markiert, nicht nur einzelne. Das war merkwürdig – wie konnten alle Transaktionen Duplikate sein?
Die Spurensuche
Ich habe die beiden relevanten Dateien verglichen:
YnabClient.fs (wie Import-IDs generiert werden):
let importId =
let txIdNoDashes = txId.ToString().Replace("-", "")
$"BB:{txIdNoDashes}"
DuplicateDetection.fs (wie Import-IDs gesucht werden):
let matchesByImportId (bankTx: BankTransaction) (ynabTx: YnabTransaction) : bool =
match ynabTx.ImportId with
| Some importId ->
let (TransactionId txId) = bankTx.Id
importId.StartsWith($"BUDGETBUDDY:{txId}:")
Da war es. Offensichtlich. Peinlich offensichtlich.
- YnabClient generierte:
BB:tx123456 - DuplicateDetection suchte nach:
BUDGETBUDDY:tx-123-456:
Unterschiedliches Prefix (BB: vs BUDGETBUDDY:), unterschiedliche Behandlung der Bindestriche (entfernt vs beibehalten), und ein zusätzlicher Doppelpunkt am Ende.
Warum war das so schlimm?
Die matchesByImportId Funktion fand niemals einen Match. Das bedeutete:
- Der Import-ID-basierte Duplikat-Check funktionierte nicht
- Das System fiel auf andere Heuristiken zurück (Datum, Betrag, Payee)
- Diese Heuristiken waren unzuverlässig
Herausforderung 2: Die gefährliche Fallback-Logik
Der zweite Bug im Bug
Während ich den Code analysierte, fand ich noch etwas Erschreckendes in Api.fs:
// ALTE VERSION - GEFÄHRLICH
if mapped.IsEmpty && not result.DuplicateImportIds.IsEmpty then
toImport |> List.map (fun tx -> tx.Transaction.Id)
else
mapped
Diese Logik sagte: “Wenn wir die Duplikat-IDs nicht den ursprünglichen Transaktionen zuordnen können, markiere ALLE Transaktionen als Duplikate.”
Das war die eigentliche Ursache für den Bug! Weil das Format-Mismatch dafür sorgte, dass mapped immer leer war, wurden bei jedem Import ALLE Transaktionen als Duplikate markiert.
Die “Sicherheitslogik” die alles kaputt machte
Diese Fallback-Logik war vermutlich als Sicherheitsnetz gedacht: “Lieber zu viele Duplikate erkennen als zu wenige.” Aber in Kombination mit dem Format-Bug wurde sie zum Problem.
Lesson Learned: Fallback-Logik, die “sicherheitshalber” den schlimmsten Fall annimmt, kann genau das Gegenteil bewirken. Lieber ehrlich scheitern (mit Logging) als still falsche Annahmen treffen.
Herausforderung 3: Tautologische Tests
Tests die nichts testen
Jetzt kam die unangenehme Frage: Warum haben die Tests das nicht gefangen?
Die Antwort: Die Tests waren tautologisch. Sie testeten das falsche Format gegen das falsche Format:
// ALTE VERSION - TAUTOLOGISCH
testCase "generates consistent import IDs for same transaction" <| fun () ->
let transactionId = TransactionId "tx-123"
let bookingDate = DateTime(2025, 11, 29)
let (TransactionId id) = transactionId
let importId1 = $"BUDGETBUDDY:{id}:{bookingDate.Ticks}"
let importId2 = $"BUDGETBUDDY:{id}:{bookingDate.Ticks}"
Expect.equal importId1 importId2 "Same transaction should generate same import ID"
Dieser Test prüft, ob X == X. Natürlich ist das wahr. Aber er testet nicht, ob der Code das richtige Format verwendet!
Das gleiche Problem in den DuplicateDetection-Tests:
// ALTE VERSION - TAUTOLOGISCH
test "matchesByImportId returns true when import IDs match" {
let txId = "TX123"
let ynabTx = createYnabTransaction ... (Some $"BUDGETBUDDY:{txId}:12345")
let result = matchesByImportId bankTx' ynabTx
Expect.isTrue result "Should match by import ID"
}
Der Test erstellt das Format manuell (BUDGETBUDDY:...) und prüft dann, ob der Code dieses Format findet. Das funktioniert, weil der Test und der Code zufällig das gleiche (falsche) Format verwenden.
Das fundamentale Problem
Tautologische Tests sind gefährlich, weil sie:
- Grün sind – sie geben falsches Vertrauen
- Bugs nicht finden – sie testen nicht das echte Verhalten
- Refactoring verhindern – sie brechen bei Änderungen, auch wenn das echte Verhalten korrekt bleibt
Die Lösung: Single Source of Truth
Schritt 1: Zentralisieren des Formats
Ich habe das Import-ID-Format in Domain.fs zentralisiert:
/// Import ID prefix used for YNAB transactions to prevent duplicates.
/// MUST be used in both YnabClient (generation) and DuplicateDetection (matching).
[<Literal>]
let ImportIdPrefix = "BB"
/// Generates an import ID from a transaction ID.
/// Format: "BB:{transactionId}" (max 36 chars for YNAB)
let generateImportId (TransactionId txId) : string =
let txIdNoDashes = txId.Replace("-", "")
$"{ImportIdPrefix}:{txIdNoDashes}"
/// Checks if an import ID matches a transaction ID.
/// Used in duplicate detection to identify transactions we previously imported.
let matchesImportId (TransactionId txId) (importId: string) : bool =
let txIdNoDashes = txId.Replace("-", "")
importId.StartsWith($"{ImportIdPrefix}:{txIdNoDashes}")
Warum diese Entscheidungen:
[<Literal>]für den Prefix – Compile-time Konstante, kann nicht versehentlich geändert werden- Beide Funktionen nebeneinander – Macht die Beziehung zwischen Generierung und Matching offensichtlich
- In
Domain.fs– Das Shared-Modul, das von Server und Tests importiert wird
Schritt 2: YnabClient anpassen
// NEUE VERSION
let importId =
if forceNewImportId then
let newGuid = Guid.NewGuid().ToString("N")
$"{Shared.Domain.ImportIdPrefix}:{newGuid}"
else
Shared.Domain.generateImportId tx.Transaction.Id
Jetzt verwendet YnabClient die zentrale Funktion. Kein Risiko mehr, dass das Format an einer Stelle geändert wird und an der anderen nicht.
Schritt 3: DuplicateDetection anpassen
// NEUE VERSION
let matchesByImportId (bankTx: BankTransaction) (ynabTx: YnabTransaction) : bool =
match ynabTx.ImportId with
| None -> false
| Some importId ->
Shared.Domain.matchesImportId bankTx.Id importId
Keine duplizierte Logik mehr. Die Matching-Logik ist jetzt exakt das Gegenstück zur Generierungs-Logik.
Schritt 4: Gefährliche Fallback-Logik entfernen
// NEUE VERSION
if mapped.IsEmpty && not result.DuplicateImportIds.IsEmpty then
printfn "[WARNING] Could not map %d duplicate import IDs to transaction IDs: %A"
result.DuplicateImportIds.Length result.DuplicateImportIds
// Only return actually mapped duplicates, never all transactions
mapped
Statt “alle als Duplikate markieren” loggen wir jetzt eine Warnung. Das System verhält sich ehrlich: wenn etwas nicht funktioniert, tut es so, als hätte es keine Duplikate gefunden – was sicherer ist als das Gegenteil.
Schritt 5: Echte Tests schreiben
// NEUE VERSION - ECHTER TEST
testCase "generateImportId produces correct format with BB prefix" <| fun () ->
let txId = TransactionId "TX-123-456"
let importId = generateImportId txId
Expect.stringStarts importId $"{ImportIdPrefix}:" "Import ID should start with BB:"
Expect.stringContains importId "TX123456" "Should contain transaction ID without dashes"
testCase "matchesImportId works with generateImportId output" <| fun () ->
let txId = TransactionId "abc-def-ghi"
let importId = generateImportId txId
Expect.isTrue (matchesImportId txId importId) "Generated ID should match its source"
test "matchesByImportId returns true when import IDs match" {
let txId = TransactionId "TX123"
let bankTx' = { bankTx with Id = txId }
// Use Domain.generateImportId to ensure test uses same format as production code
let importId = generateImportId txId
let ynabTx = createYnabTransaction ... (Some importId)
let result = matchesByImportId bankTx' ynabTx
Expect.isTrue result "Should match by import ID"
}
Der Unterschied: Die Tests verwenden jetzt generateImportId statt ein hardcodiertes Format. Wenn das Format geändert wird, ändern sich Tests und Produktionscode gemeinsam.
Lessons Learned
1. “Single Source of Truth” ist nicht optional
Wenn zwei Code-Teile dasselbe Format oder dieselbe Logik verwenden, müssen sie eine gemeinsame Quelle haben. Alles andere ist ein Bug, der nur darauf wartet zu passieren.
In diesem Fall:
- Vorher: Format in YnabClient.fs UND DuplicateDetection.fs (unabhängig)
- Nachher: Format in Domain.fs, verwendet von beiden
2. Tautologische Tests sind gefährlicher als keine Tests
Ein Test, der X == X prüft, gibt falsches Vertrauen. Er ist grün, also denkt man, alles funktioniert. Aber er testet nichts Nützliches.
Erkennungsmerkmale tautologischer Tests:
- Der Test erstellt Testdaten mit demselben Code/Format, den er dann prüft
- Der Test verwendet hardcodierte Werte, die zufällig mit dem aktuellen Code übereinstimmen
- Wenn man den Produktionscode ändert, muss man auch die Test-Assertions ändern
Lösung: Tests sollten eine andere “Quelle der Wahrheit” haben als der Code selbst. In diesem Fall: Domain.generateImportId.
3. Defensive Fallbacks können mehr schaden als nutzen
Die Logik “wenn unser Check fehlschlägt, nimm den schlimmsten Fall an” klingt sicher. Aber sie kann genau das Gegenteil bewirken:
- Sie versteckt den eigentlichen Bug (der Check schlägt fehl!)
- Sie verursacht oft mehr Schaden als ein ehrliches Scheitern
- Sie macht Debugging schwieriger
Besser: Loggen und ehrlich scheitern. Lieber eine fehlende Funktion als eine kaputte.
4. Bugs in “glue code” sind am schwierigsten zu finden
Der Bug war nicht in der Generierungs-Logik. Der Bug war nicht in der Matching-Logik. Beide Teile für sich waren korrekt. Der Bug war im Zusammenspiel – in der Annahme, dass beide dasselbe Format verwenden.
Solche Bugs sind schwer zu testen, weil Unit-Tests typischerweise einzelne Komponenten isoliert testen.
Lösung: Integration-Tests, die den gesamten Flow testen. Und: weniger “glue code” durch bessere Abstraktion (Single Source of Truth).
Fazit
Am Ende war es ein simpler Fix: ~50 Zeilen neuer Code in Domain.fs, einige Zeilen gelöscht in YnabClient.fs und DuplicateDetection.fs, und überarbeitete Tests.
Änderungen im Überblick:
src/Shared/Domain.fs– Neue FunktionengenerateImportId,matchesImportId, KonstanteImportIdPrefixsrc/Server/YnabClient.fs– Verwendet jetztDomain.generateImportIdsrc/Server/DuplicateDetection.fs– Verwendet jetztDomain.matchesImportIdsrc/Server/Api.fs– Gefährliche Fallback-Logik durch Logging ersetztsrc/Tests/*.fs– Tests verwenden jetzt die Domain-Funktionen statt hardcodierte Formate
Build: Erfolgreich Tests: 375/375 bestanden
Aber die Geschichte war noch nicht zu Ende…
Der Follow-up Bug: Comdirect IDs mit Slashes
Das Problem kehrt zurück
Nur wenige Stunden nach dem Fix meldete sich das gleiche Symptom zurück: Force Import funktionierte nicht, alle Transaktionen wurden als Duplikate markiert.
Die Docker-Logs zeigten eine alarmierende Meldung:
[WARNING] Could not map 41 duplicate import IDs to transaction IDs
Moment – diese Warnung hatte ich gerade erst eingebaut. Wenn sie erscheint, bedeutet das, dass das Mapping zwischen YNAB-Duplikat-IDs und lokalen Transaktions-IDs fehlschlägt. Aber wie konnte das sein, wenn ich gerade erst das Format vereinheitlicht hatte?
Die Spurensuche
Ich schaute mir die Api.fs genauer an. Dort fand ich diese Zeile:
// ALTE VERSION
let cleanId = txIdPart.Split('/') |> Array.head
Diese Zeile sollte das Prefix BB: von der Import-ID abtrennen. Aber warum wurde an / gesplittet?
Dann fiel es mir wie Schuppen von den Augen: Comdirect Transaktions-IDs enthalten Slashes!
Eine typische Comdirect-ID sieht so aus:
3I2C21XS1ZXDAP9P/33825
Der Slash ist Teil der ID, nicht ein Trennzeichen. Aber der Code Split('/') zerlegte diese ID in zwei Teile und behielt nur den ersten:
Input: "BB:3I2C21XS1ZXDAP9P/33825"
Nach Split('/'): ["BB:3I2C21XS1ZXDAP9P", "33825"]
Array.head: "BB:3I2C21XS1ZXDAP9P" // FALSCH - Suffix fehlt!
Die lokalen Transaktionen hatten aber die vollständige ID 3I2C21XS1ZXDAP9P/33825. Da YNAB die abgeschnittene Version zurückgab, fand das Mapping nie einen Match.
Warum dieser Code überhaupt existierte
Ich habe im Git-Log nachgeschaut. Der Split('/') Code war ein Überbleibsel aus einer früheren Version, als Import-IDs ein anderes Format hatten. Jemand (wahrscheinlich ich selbst) hatte angenommen, dass / ein Format-Trennzeichen war, das sicher entfernt werden konnte.
Lesson Learned: Wenn du Code kopierst oder anpasst, verstehe WARUM er so geschrieben wurde. Ein Split('/') sieht harmlos aus, kann aber katastrophale Auswirkungen haben, wenn deine IDs dieses Zeichen enthalten.
Der Fix
Die Lösung war simpel – das fehlerhafte Split entfernen:
// NEUE VERSION
// Don't split on '/' - Comdirect IDs contain slashes as part of the ID!
let cleanId = txIdPart
Und natürlich habe ich Regression-Tests hinzugefügt:
testCase "handles Comdirect IDs with slashes" <| fun () ->
let txId = TransactionId "3I2C21XS1ZXDAP9P/33825"
let importId = generateImportId txId
Expect.isTrue (matchesImportId txId importId) "Should match Comdirect ID with slash"
testCase "Comdirect IDs with slashes round-trip correctly" <| fun () ->
let txId = TransactionId "ABC123DEF/99999"
let importId = generateImportId txId
// Simulate what YNAB returns - should still match
Expect.isTrue (matchesImportId txId importId) "Round-trip should work"
Änderungen:
src/Server/Api.fs– FehlerhaftenSplit('/')entferntsrc/Tests/DuplicateDetectionTests.fs– 2 neue Regression-Tests für Comdirect IDs
Build: Erfolgreich Tests: 377/377 bestanden (+2 neue Regression-Tests)
Die Meta-Lesson
Dieser Follow-up Bug zeigt ein wichtiges Muster:
Ein Bug kommt selten allein.
Wenn du einen Bug findest, der sich lange versteckt hat, prüfe die umgebenden Code-Pfade. Oft gibt es verwandte Bugs, die aus derselben falschen Annahme entstanden sind.
In diesem Fall:
- Bug 1: Format-Mismatch zwischen
BB:undBUDGETBUDDY: - Bug 2: Slash-Parsing zerstört Comdirect IDs
Beide Bugs hatten dieselbe Root Cause: Code, der Annahmen über das ID-Format machte, ohne diese Annahmen explizit zu dokumentieren oder zentral zu definieren.
Der Bug existierte wahrscheinlich seit der ersten Implementation des Duplikat-Checks. Er wurde nie gefunden, weil:
- Der normale Import-Flow trotzdem funktionierte (andere Heuristiken)
- Die Tests “grün” waren
- Force Import relativ selten verwendet wurde
Das ist das Heimtückische an solchen Bugs: sie verstecken sich in Edge Cases und zeigen sich erst, wenn mehrere ungünstige Umstände zusammenkommen.
Key Takeaways für Neulinge
-
Wenn zwei Code-Teile dasselbe Format verwenden, definiere es genau einmal. Nicht “die verwenden beide dasselbe Format” – sondern “die verwenden beide DIESE Konstante/Funktion”. Das ist ein fundamentaler Unterschied.
-
Tautologische Tests erkennen: Wenn dein Test und dein Produktionscode beide ein Format/eine Logik hardcoden, ist der Test wertlos. Der Test muss eine unabhängige Quelle der Wahrheit haben.
-
“Defensiv programmieren” heißt nicht “alle Fehler verstecken”. Ehrliches Scheitern mit gutem Logging ist fast immer besser als stilles Fehlverhalten.
-
Ein Bug kommt selten allein. Wenn du einen lange versteckten Bug findest, prüfe verwandte Code-Pfade. Oft gibt es weitere Bugs mit derselben Root Cause.
-
Verstehe den Code, bevor du ihn änderst. Ein harmlos aussehender
Split('/')kann katastrophale Auswirkungen haben, wenn deine Daten dieses Zeichen enthalten. Frage dich immer: “Warum wurde das so geschrieben?”