Files
motia/bitbylaw/docs/SYNC_CODE_ANALYSIS.md

18 KiB

Code-Analyse: Kommunikation Sync (Komplett-Review)

Datum: 8. Februar 2026

Executive Summary

Gesamtbewertung: ⚠️ GUT mit 4 KRITISCHEN BUGS

Der Code ist grundsätzlich gut strukturiert, verwendet intelligente Change Detection mit Hashes und implementiert alle 6 Varianten korrekt. ABER es gibt 4 kritische Bugs die dazu führen können, dass Systeme nicht synchron bleiben.


1. Architektur-Bewertung

Stärken

  1. 3-Way Diffing mit Hash-basierter Konflikt-Erkennung

    • Verwendet Kommunikations-Hash (MD5 der rowIds) statt Beteiligte-rowId
    • Erkennt wer geändert hat (Advoware vs EspoCRM vs beide)
    • Konfliktauflösung: EspoCRM wins
  2. Marker-Strategie ist elegant

    • [ESPOCRM:base64_value:kommKz] ermöglicht Matching auch bei Value-Änderungen
    • [ESPOCRM-SLOT:kommKz] für gelöschte Einträge (Advoware DELETE gibt 403)
    • User-Bemerkungen werden preserviert
  3. Alle 6 Varianten implementiert

    • Var1: Neu in EspoCRM → CREATE/REUSE in Advoware
    • Var2: Gelöscht in EspoCRM → Empty Slot in Advoware
    • Var3: Gelöscht in Advoware → DELETE in EspoCRM
    • Var4: Neu in Advoware → CREATE in EspoCRM
    • Var5: Geändert in EspoCRM → UPDATE in Advoware
    • Var6: Geändert in Advoware → UPDATE in EspoCRM
  4. Retry-Mechanismus mit Exponential Backoff

    • [1, 5, 15, 60, 240] Minuten
    • Auto-Reset nach 24h für permanently_failed
    • Round-trip Validation nach jedem Sync
  5. Distributed Locking

    • Redis + EspoCRM syncStatus
    • TTL: 15 Minuten (verhindert Deadlocks)

⚠️ Schwächen

  1. Komplexität: Hash-basierte Logik ist schwer zu debuggen
  2. Performance: Lädt ALLE Kommunikationen bei jedem Sync
  3. Error Handling: Einige Fehler werden nur geloggt, nicht propagiert
  4. Testing: Keine Unit-Tests für edge cases

2. Szenario-Matrix: ALLE möglichen Konstellationen

Legende

  • = Korrekt implementiert
  • = BUG gefunden
  • ⚠️ = Potentielles Problem

2.1 Single-Side Changes (keine Konflikte)

# Szenario EspoCRM Advoware Erwartung Status Bug-ID
1 Neu in EspoCRM +email - Var1: CREATE in Advoware -
2 Neu in Advoware - +phone Var4: CREATE in EspoCRM -
3 Gelöscht in EspoCRM -email (marker) Var2: Empty Slot -
4 Gelöscht in Advoware (entry) -phone Var3: DELETE in EspoCRM -
5 Geändert in EspoCRM email↑ (synced) Var5: UPDATE in Advoware -
6 Geändert in Advoware (synced) phone↑ Var6: UPDATE in EspoCRM -

2.2 Conflict Scenarios (beide Seiten ändern)

# Szenario EspoCRM Advoware Erwartung Status Bug-ID
7 Beide neu angelegt +emailA +phoneB EspoCRM wins: nur emailA BUG-1
8 Beide geändert (same) emailA→B emailA→B Beide Änderungen -
9 Beide geändert (diff) emailA→B emailA→C EspoCRM wins: A→B ⚠️ BUG-2
10 Einer neu, einer gelöscht +emailA -phoneX EspoCRM wins -
11 Primary flag conflict primary↑ primary↑ EspoCRM wins -

BUG-1: Bei Konflikt wird Var4 (neu in Advoware) zu Empty Slot, ABER wenn espo_wins=False, wird Var4 trotzdem zu EspoCRM übertragen → Daten-Inkonsistenz möglich

BUG-2: ⚠️ Bei gleichzeitiger Änderung wird Advoware-Änderung revertiert, ABER wenn User sofort wieder ändert, entsteht Ping-Pong

2.3 Initial Sync (kein kommunikationHash)

# Szenario EspoCRM Advoware Erwartung Status Bug-ID
12 Initial: Beide leer - - Kein Sync -
13 Initial: Nur EspoCRM +emails - CREATE in Advoware -
14 Initial: Nur Advoware - +phones CREATE in EspoCRM -
15 Initial: Beide haben Daten +emailA +phoneB Merge beide -
16 Initial: Gleiche Email +emailA +emailA Nur 1x BUG-3

BUG-3: Bei Initial Sync werden identische Werte doppelt angelegt (einmal mit Marker, einmal ohne)

2.4 Edge Cases mit Empty Slots

# Szenario EspoCRM Advoware Erwartung Status Bug-ID
17 User füllt Slot mit Daten - Slot→+phone Var4: Sync zu EspoCRM FIXED
18 EspoCRM füllt gleichen Slot +email Slot Var1: Slot reuse -
19 Mehrere Slots gleicher kommKz - Slot1, Slot2 First reused -
20 Slot mit User-Bemerkung - Slot+Text Text bleibt -

2.5 Direction Parameter

# Direction EspoCRM Change Advoware Change Erwartung Status Bug-ID
21 'both' +emailA +phoneB Beide synced -
22 'to_espocrm' +emailA +phoneB Nur phoneB→EspoCRM BUG-4
23 'to_advoware' +emailA +phoneB Nur emailA→Advoware, phoneB DELETED FIXED
24 'to_advoware' - phoneA→B phoneB→A revert FIXED

BUG-4: Bei direction='to_espocrm' werden EspoCRM-Änderungen ignoriert, ABER Hash wird trotzdem updated → Verlust von EspoCRM-Änderungen beim nächsten Sync

2.6 Hash Calculation

# Szenario Kommunikationen Hash Basis Status Bug-ID
25 Nur sync-relevante 4 synced, 5 andere 4 rowIds FIXED
26 Mit Empty Slots 3 synced, 2 slots 3 rowIds FIXED
27 Alle leer 0 synced "" → empty hash -

2.7 kommKz Detection

# Szenario Input Erwartet Status Notiz
28 Email ohne Marker test@mail.com kommKz=4 (MailGesch) Via pattern
29 Phone ohne Marker +4930123 kommKz=1 (TelGesch) Via pattern
30 Mit EspoCRM type 'Mobile' phone kommKz=3 Via type map
31 Mit EspoCRM type 'Fax' phone kommKz=2 Via type map
32 Mit Marker any kommKz aus Marker Priority 1

3. Gefundene BUGS

🔴 BUG-1: Konflikt-Handling inkonsistent (CRITICAL)

Problem: Bei espo_wins=False (kein Konflikt) wird Var4 (neu in Advoware) zu EspoCRM übertragen. ABER bei espo_wins=True wird Var4 zu Empty Slot. Das ist korrekt. ABER: Wenn nach Konflikt wieder espo_wins=False, wird der Slot NICHT automatisch wiederhergestellt.

Location: sync_bidirectional() Lines 119-136

if direction in ['both', 'to_espocrm'] and not espo_wins:
    # Var4 wird zu EspoCRM übertragen ✅
    espo_result = await self._apply_advoware_to_espocrm(...)
elif direction in ['both', 'to_espocrm'] and espo_wins:
    # Var4 wird zu Empty Slot ✅
    for komm in diff['advo_new']:
        await self._create_empty_slot(betnr, komm)

Problem: User legt in Advoware phone an → Konflikt → Slot → Konflikt gelöst → Slot bleibt leer, wird NICHT zu EspoCRM übertragen!

Fix: Nach Konflikt-Auflösung sollte ein "recovery" Sync laufen, der Slots mit User-Daten wieder aktiviert.

Severity: 🔴 CRITICAL - Datenverlust möglich


🔴 BUG-3: Initial Sync mit identischen Werten (CRITICAL)

Problem: Bei Initial Sync (kein Hash) werden identische Einträge doppelt angelegt:

  • Advoware hat test@mail.com (ohne Marker)
  • EspoCRM hat test@mail.com
  • → Ergebnis: 2x test@mail.com in beiden Systemen!

Location: _compute_diff() Lines 365-385

# Var4: Neu in Advoware
for komm in advo_without_marker:
    diff['advo_new'].append(komm)  # Wird zu EspoCRM übertragen

# Var1: Neu in EspoCRM
for value, espo_item in espo_values.items():
    if value not in advo_with_marker:
        diff['espo_new'].append((value, espo_item))  # Wird zu Advoware übertragen

Root Cause: Bei Initial Sync sollte Value-Matching statt nur Marker-Matching verwendet werden!

Fix: In _compute_diff() bei Initial Sync auch value in [komm['tlf'] for komm in advo_without_marker] prüfen.

Severity: 🔴 CRITICAL - Daten-Duplikate


🟡 BUG-4: direction='to_espocrm' verliert EspoCRM-Änderungen (MEDIUM)

Problem: Bei direction='to_espocrm' werden nur Advoware→EspoCRM Änderungen übertragen, ABER der Hash wird trotzdem updated. Beim nächsten Sync (direction='both') gehen EspoCRM-Änderungen verloren.

Beispiel:

  1. User ändert in EspoCRM: emailA→B
  2. Sync mit direction='to_espocrm' → emailA→B wird NICHT zu Advoware übertragen
  3. Hash wird updated (basiert auf Advoware rowIds)
  4. Nächster Sync: Diff erkennt KEINE Änderung (Hash gleich) → emailA→B geht verloren!

Location: sync_bidirectional() Lines 167-174

# Hash wird IMMER updated, auch bei direction='to_espocrm'
if total_changes > 0 or is_initial_sync:
    # ... Hash berechnen ...
    await self.espocrm.update_entity('CBeteiligte', beteiligte_id, {
        'kommunikationHash': new_komm_hash
    })

Fix: Hash nur updaten wenn:

  • direction='both' ODER
  • direction='to_advoware' UND Advoware wurde geändert ODER
  • direction='to_espocrm' UND EspoCRM wurde geändert UND zu Advoware übertragen

Severity: 🟡 MEDIUM - Datenverlust bei falscher direction-Verwendung


🟢 BUG-2: Ping-Pong bei gleichzeitigen Änderungen (LOW)

Problem: Bei gleichzeitiger Änderung wird Advoware revertiert. Wenn User sofort wieder ändert, entsteht Ping-Pong.

Beispiel:

  1. User ändert in Advoware: phoneA→B
  2. Admin ändert in EspoCRM: phoneA→C
  3. Sync: EspoCRM wins → phoneA→C in beiden
  4. User ändert wieder: phoneC→B
  5. Sync: phoneC→B in beiden (kein Konflikt mehr)
  6. Admin bemerkt Änderung, ändert zurück: phoneB→C
  7. → Endlos-Schleife

Fix: Conflict-Notification mit "Lock" bis Admin bestätigt.

Severity: 🟢 LOW - UX Problem, keine Daten-Inkonsistenz


4. Code-Qualität Bewertung

Eleganz: ☆ (4/5)

Gut:

  • Klare Trennung: Diff Computation vs. Apply Changes
  • Marker-Strategie ist clever und robust
  • Hash-basierte Konflikt-Erkennung ist innovativ

Schlecht:

  • Zu viele verschachtelte if-else (Lines 119-163)
  • _compute_diff() ist 300+ Zeilen lang → schwer zu testen
  • Duplikation von kommKz-Detection Logic

Verbesserung:

# Aktuell:
if direction in ['both', 'to_espocrm'] and not espo_wins:
    ...
elif direction in ['both', 'to_espocrm'] and espo_wins:
    ...
else:
    ...

# Besser:
should_apply_advo = direction in ['both', 'to_espocrm']
should_revert_advo = should_apply_advo and espo_wins

if should_apply_advo and not espo_wins:
    ...
elif should_revert_advo:
    ...

Effizienz: ☆☆ (3/5)

Performance-Probleme:

  1. Doppelte API-Calls:

    # Line 71-75: Lädt Advoware
    advo_result = await self.advoware.get_beteiligter(betnr)
    
    # Line 167-174: Lädt NOCHMAL
    advo_result_final = await self.advoware.get_beteiligter(betnr)
    

    Fix: Cache das Ergebnis, nur neu laden wenn Änderungen gemacht wurden.

  2. Hash-Berechnung bei jedem Sync:

    • Sortiert ALLE rowIds, auch wenn keine Änderungen
    • Fix: Lazy evaluation - nur berechnen wenn total_changes > 0
  3. N+1 Problem bei Updates:

    # _apply_advoware_to_espocrm(): Update einzeln
    for komm, old_value, new_value in diff['advo_changed']:
        await self.advoware.update_kommunikation(...)  # N API-Calls
    

    Fix: Batch-Update API (wenn Advoware unterstützt)

  4. Keine Parallelisierung:

    • Var1-6 werden sequenziell verarbeitet
    • Fix: asyncio.gather() für unabhängige Operations

Optimierte Version:

async def sync_bidirectional(self, ...):
    # 1. Load data (parallel)
    advo_task = self.advoware.get_beteiligter(betnr)
    espo_task = self.espocrm.get_entity('CBeteiligte', beteiligte_id)
    advo_bet, espo_bet = await asyncio.gather(advo_task, espo_task)
    
    # 2. Compute diff (sync, fast)
    diff = self._compute_diff(...)
    
    # 3. Apply changes (parallel where possible)
    tasks = []
    if direction in ['both', 'to_espocrm'] and not espo_wins:
        tasks.append(self._apply_advoware_to_espocrm(...))
    if direction in ['both', 'to_advoware']:
        tasks.append(self._apply_espocrm_to_advoware(...))
    
    results = await asyncio.gather(*tasks, return_exceptions=True)

Robustheit: ☆ (4/5)

Gut:

  • Distributed Locking verhindert Race Conditions
  • Retry mit Exponential Backoff
  • Auto-Reset nach 24h
  • Round-trip Validation

Probleme:

  1. Error Propagation unvollständig:

    # _apply_advoware_to_espocrm()
    except Exception as e:
        result['errors'].append(str(e))  # Nur geloggt, nicht propagiert!
        return result  # Caller weiß nicht ob partial failure
    

    Fix: Bei kritischen Fehlern Exception werfen, nicht nur loggen.

  2. Partial Updates nicht atomic:

    • Var1-6 werden sequenziell verarbeitet
    • Bei Fehler in Var3 sind Var1-2 schon geschrieben
    • Kein Rollback möglich (Advoware DELETE gibt 403!)

    Fix:

    • Phase 1: Collect all changes (dry-run)
    • Phase 2: Apply all or nothing (mit compensation)
    • Phase 3: Update hash only if Phase 2 successful
  3. Hash-Mismatch nach partial failure:

    # Hash wird updated auch bei Fehlern
    if total_changes > 0:  # total_changes kann > 0 sein auch wenn errors!
        await self.espocrm.update_entity(..., {'kommunikationHash': new_hash})
    

    Fix: if total_changes > 0 AND not result['errors']:

  4. Lock-Release bei Exception:

    async def sync_bidirectional(self, ...):
        try:
            ...
        except Exception:
            # Lock wird NICHT released!
            pass
    

    Fix: try/finally mit Lock-Release im finally-Block.


5. Korrektheit-Matrix

Alle 6 Varianten: Verarbeitung korrekt?

Variante Single-Side Konflikt Initial Sync Direction Gesamt
Var1: Neu EspoCRM ⚠️ BUG-4 🟡
Var2: Del EspoCRM N/A
Var3: Del Advoware N/A
Var4: Neu Advoware ⚠️ BUG-1 BUG-3 🔴
Var5: Chg EspoCRM ⚠️ BUG-4 🟡
Var6: Chg Advoware

Legende:

  • = Korrekt
  • ⚠️ = Mit Einschränkungen
  • = Fehlerhaft
  • 🔴 = Critical Bug
  • 🟡 = Medium Bug

6. Recommendations

6.1 CRITICAL FIXES (sofort)

  1. BUG-3: Initial Sync Duplikate

    def _compute_diff(self, ...):
        # Bei Initial Sync: Value-Matching statt nur Marker-Matching
        if is_initial_sync:
            advo_values = {k['tlf']: k for k in advo_without_marker if k.get('tlf')}
    
            for value, espo_item in espo_values.items():
                if value in advo_values:
                    # Match gefunden - setze Marker, KEIN Var1/Var4
                    komm = advo_values[value]
                    # Setze Marker in Advoware
                    ...
                elif value not in advo_with_marker:
                    diff['espo_new'].append((value, espo_item))
    
  2. BUG-1: Konflikt-Recovery

    # Nach Konflikt-Auflösung: Recovery-Check für Slots mit User-Daten
    if last_status == 'conflict' and new_status == 'clean':
        # Check für Slots die User-Daten haben
        recovery_komms = [
            k for k in advo_kommunikationen 
            if parse_marker(k.get('bemerkung', '')).get('is_slot') 
            and k.get('tlf')  # Slot hat Daten!
        ]
        if recovery_komms:
            # Trigger Var4-Sync
            for komm in recovery_komms:
                diff['advo_new'].append(komm)
    

6.2 MEDIUM FIXES (nächster Sprint)

  1. BUG-4: Hash-Update Logik

    should_update_hash = (
        (direction in ['both', 'to_advoware'] and result['espocrm_to_advoware']['created'] + result['espocrm_to_advoware']['updated'] + result['espocrm_to_advoware']['deleted'] > 0) or
        (direction in ['both', 'to_espocrm'] and result['advoware_to_espocrm']['emails_synced'] + result['advoware_to_espocrm']['phones_synced'] > 0)
    )
    
    if should_update_hash and not result['errors']:
        # Update hash
        ...
    
  2. Error Propagation

    async def _apply_advoware_to_espocrm(self, ...):
        critical_errors = []
    
        try:
            ...
        except CriticalException as e:
            critical_errors.append(e)
            raise  # Propagate up!
        except Exception as e:
            result['errors'].append(str(e))
    
        return result
    

6.3 OPTIMIZATIONS (Backlog)

  1. Performance: Batch-Updates, Parallelisierung
  2. Code-Qualität: Extract _compute_diff() in kleinere Methoden
  3. Testing: Unit-Tests für alle 32 Szenarien
  4. Monitoring: Metrics für Sync-Dauer, Fehler-Rate, Konflikt-Rate

7. Fazit

Ist der Code gut, elegant, effizient und robust?

  • Gut: ☆ (4/5) - Ja, grundsätzlich gut
  • Elegant: ☆ (4/5) - Marker-Strategie clever, aber zu verschachtelt
  • Effizient: ☆☆ (3/5) - N+1 Problem, keine Parallelisierung
  • Robust: ☆ (4/5) - Mit Einschränkungen (partial failures)

Werden alle Varianten korrekt verarbeitet?

JA, mit 3 CRITICAL EXCEPTIONS:

  • BUG-1: Konflikt-Recovery fehlt
  • BUG-3: Initial Sync mit Duplikaten
  • ⚠️ BUG-4: direction='to_espocrm' verliert EspoCRM-Änderungen

Sind alle Konstellationen abgedeckt?

Größtenteils JA: 28 von 32 Szenarien korrekt (87.5%)

Missing:

  • Initial Sync mit identischen Werten (BUG-3)
  • Konflikt-Recovery nach Auflösung (BUG-1)
  • Partial failure handling
  • Concurrent modifications während Sync

8. Next Steps

  1. Sofort: Fixe BUG-3 (Initial Sync Duplikate)
  2. Heute: Fixe BUG-1 (Konflikt-Recovery)
  3. Diese Woche: Fixe BUG-4 (Hash-Update Logik)
  4. Nächste Woche: Performance-Optimierungen
  5. Backlog: Unit-Tests für alle Szenarien

Review erstellt von: GitHub Copilot
Review-Datum: 8. Februar 2026
Code-Version: Latest (nach allen bisherigen Fixes)