459 lines
16 KiB
Markdown
459 lines
16 KiB
Markdown
# Beteiligte Sync - Architektur-Analyse
|
||
|
||
**Stand:** 7. Februar 2026
|
||
**Analysiert:** Bidirektionale EspoCRM ↔ Advoware Beteiligte-Synchronisation
|
||
|
||
---
|
||
|
||
## 🏗️ ARCHITEKTUR-ÜBERSICHT
|
||
|
||
### Komponenten
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ EspoCRM (Master) │
|
||
│ Webhooks → Motia │
|
||
└────────────────────────────┬────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ WEBHOOK HANDLER (3 Endpoints) │
|
||
│ • beteiligte_create_api_step.py │
|
||
│ • beteiligte_update_api_step.py ← Loop-Prevention entfernt │
|
||
│ • beteiligte_delete_api_step.py │
|
||
└────────────────────────────┬────────────────────────────────────┘
|
||
│ emits events
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ CENTRAL SYNC HANDLER (Event-Based) │
|
||
│ beteiligte_sync_event_step.py (~329 lines) │
|
||
│ │
|
||
│ Subscribes: vmh.beteiligte.{create,update,delete,sync_check} │
|
||
│ │
|
||
│ Flow: │
|
||
│ 1. Distributed Lock (Redis + syncStatus) │
|
||
│ 2. Fetch EspoCRM Entity │
|
||
│ 3. Route: CREATE, UPDATE/CHECK, DELETE │
|
||
│ 4. Release Lock + Update Status │
|
||
└────────────────────────────┬────────────────────────────────────┘
|
||
│
|
||
┌─────────┴─────────┐
|
||
▼ ▼
|
||
┌──────────────────┐ ┌──────────────────┐
|
||
│ handle_create │ │ handle_update │
|
||
│ │ │ │
|
||
│ • Map to Advo │ │ • Fetch Advo │
|
||
│ • POST │ │ • Compare │
|
||
│ • GET rowId │ │ • Sync/Skip │
|
||
│ • Write back │ │ • Update rowId │
|
||
└──────────────────┘ └──────────────────┘
|
||
│ │
|
||
└─────────┬─────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ SUPPORT SERVICES │
|
||
│ │
|
||
│ • BeteiligteSync (sync_utils) (~524 lines) │
|
||
│ - Locking, Compare, Merge, Conflict Resolution │
|
||
│ │
|
||
│ • BeteiligteMapper (~200 lines) │
|
||
│ - EspoCRM ↔ Advoware transformations │
|
||
│ - None-value filtering │
|
||
│ - Date format conversion │
|
||
│ │
|
||
│ • AdvowareAPI / EspoCRMAPI │
|
||
│ - HTTP clients mit Token-Caching │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## ✅ STÄRKEN (Was funktioniert gut)
|
||
|
||
### 1. **Robustheit durch Distributed Locking**
|
||
```python
|
||
# 2-stufiges Locking verhindert Race Conditions:
|
||
# 1. Redis Lock (atomic, TTL 15min)
|
||
# 2. syncStatus Update (UI visibility)
|
||
lock_key = f"sync_lock:cbeteiligte:{entity_id}"
|
||
acquired = self.redis.set(lock_key, "locked", nx=True, ex=LOCK_TTL_SECONDS)
|
||
```
|
||
✅ **Gut:** Verhindert parallele Syncs derselben Entity
|
||
✅ **Gut:** TTL verhindert Deadlocks bei Crashes
|
||
✅ **Gut:** UI-Sichtbarkeit via syncStatus
|
||
|
||
### 2. **Primäre Change Detection: rowId**
|
||
```python
|
||
# rowId ändert sich bei JEDEM Advoware PUT → sehr zuverlässig
|
||
if espo_rowid and advo_rowid:
|
||
advo_changed = (espo_rowid != advo_rowid)
|
||
espo_changed = (espo_modified > last_sync)
|
||
```
|
||
✅ **Sehr gut:** rowId ist Base64, ändert sich immer, keine NULLs
|
||
✅ **Gut:** Timestamp als Fallback vorhanden
|
||
✅ **Gut:** Konfliktlogik (beide geändert) implementiert
|
||
|
||
### 3. **API-Call-Optimierung (50% Reduktion)**
|
||
```python
|
||
# VORHER: PUT + GET (2 Calls)
|
||
# NACHHER: PUT Response enthält neue rowId (1 Call)
|
||
put_result = await advoware.api_call(...)
|
||
new_rowid = put_result[0].get('rowId') # direkt aus Response!
|
||
```
|
||
✅ **Exzellent:** Keine extra GETs nach PUT nötig
|
||
✅ **Gut:** Funktioniert für CREATE, UPDATE, Conflict Resolution
|
||
|
||
### 4. **Loop-Prevention auf EspoCRM-Seite**
|
||
```python
|
||
# ENTFERNT: should_skip_update() Filterung
|
||
# NEU: EspoCRM triggert keine Webhooks für rowId-Updates
|
||
```
|
||
✅ **Gut:** Vereinfacht Code erheblich
|
||
✅ **Gut:** Keine komplexe Filterlogik mehr nötig
|
||
✅ **Gut:** Vertraut auf EspoCRM-Konfiguration
|
||
|
||
### 5. **Mapper mit Validierung**
|
||
```python
|
||
# None-Filtering verhindert EspoCRM Validation Errors
|
||
espo_data = {k: v for k, v in espo_data.items() if v is not None}
|
||
|
||
# Datumsformat-Konversion
|
||
dateOfBirth = geburtsdatum.split('T')[0] # '2001-01-05T00:00:00' → '2001-01-05'
|
||
```
|
||
✅ **Gut:** Robuste Fehlerbehandlung
|
||
✅ **Gut:** Dokumentiert welche Felder funktionieren (nur 8 von 14)
|
||
|
||
### 6. **Error Handling mit Retry-Logik**
|
||
```python
|
||
MAX_SYNC_RETRIES = 5
|
||
if new_retry >= MAX_SYNC_RETRIES:
|
||
update_data['syncStatus'] = 'permanently_failed'
|
||
await self.send_notification(...)
|
||
```
|
||
✅ **Gut:** Verhindert Endlos-Retries
|
||
✅ **Gut:** Notification an User bei dauerhaftem Fehler
|
||
|
||
---
|
||
|
||
## 🔴 SCHWÄCHEN & VERBESSERUNGSPOTENZIALE
|
||
|
||
### 1. **CREATE-Flow ineffizient (Extra GET nach POST)**
|
||
|
||
**Problem:**
|
||
```python
|
||
# Nach POST: Extra GET nur für rowId
|
||
result = await advoware.api_call(..., method='POST', data=advo_data)
|
||
new_betnr = result.get('betNr')
|
||
|
||
# Extra GET!
|
||
created_entity = await advoware.api_call(f'.../{new_betnr}', method='GET')
|
||
new_rowid = created_entity.get('rowId')
|
||
```
|
||
|
||
**Lösung:**
|
||
```python
|
||
# POST Response sollte rowId bereits enthalten (prüfen!)
|
||
# Falls ja: Extrahiere direkt wie bei PUT
|
||
if isinstance(result, dict):
|
||
new_rowid = result.get('rowId')
|
||
elif isinstance(result, list):
|
||
new_rowid = result[0].get('rowId')
|
||
```
|
||
⚠️ **TODO:** Teste ob Advoware POST auch rowId zurückgibt (wie PUT)
|
||
|
||
---
|
||
|
||
### 2. **Doppeltes rowId-Update nach EspoCRM→Advoware**
|
||
|
||
**Problem:**
|
||
```python
|
||
# 1. Update via release_sync_lock extra_fields
|
||
await sync_utils.release_sync_lock(entity_id, 'clean',
|
||
extra_fields={'advowareRowId': new_rowid})
|
||
|
||
# 2. Aber VORHER bereits direktes Update!
|
||
if new_rowid:
|
||
await espocrm.update_entity('CBeteiligte', entity_id, {
|
||
'advowareRowId': new_rowid
|
||
})
|
||
```
|
||
|
||
**Lösung:** Entweder/oder - nicht beides!
|
||
```python
|
||
# OPTION A: Nur release_lock (weniger Code, eleganter)
|
||
await sync_utils.release_sync_lock(
|
||
entity_id, 'clean',
|
||
extra_fields={'advowareRowId': new_rowid}
|
||
)
|
||
|
||
# OPTION B: Direktes Update + release ohne extra_fields
|
||
await espocrm.update_entity('CBeteiligte', entity_id, {
|
||
'advowareRowId': new_rowid,
|
||
'syncStatus': 'clean',
|
||
'advowareLastSync': now()
|
||
})
|
||
await self.redis.delete(lock_key)
|
||
```
|
||
⚠️ **Recommendation:** Option A ist eleganter (1 API Call statt 2)
|
||
|
||
---
|
||
|
||
### 3. **Initial Sync Special Case nicht nötig?**
|
||
|
||
**Problem:**
|
||
```python
|
||
# Separate Logik für "kein lastSync"
|
||
if not espo_entity.get('advowareLastSync'):
|
||
context.logger.info(f"📤 Initial Sync → ...")
|
||
# ... exakt derselbe Code wie bei espocrm_newer!
|
||
```
|
||
|
||
**Lösung:** compare_entities sollte das automatisch erkennen
|
||
```python
|
||
# In compare_entities:
|
||
if not last_sync:
|
||
# Kein lastSync → EspoCRM neuer (always sync on first run)
|
||
return 'espocrm_newer'
|
||
```
|
||
✅ **Eliminiert:** ~30 Zeilen Duplikat-Code in event_step.py
|
||
|
||
---
|
||
|
||
### 4. **Conflict Resolution immer EspoCRM Wins**
|
||
|
||
**Problem:**
|
||
```python
|
||
# Hardcoded: EspoCRM gewinnt immer
|
||
elif comparison == 'conflict':
|
||
context.logger.warn(f"⚠️ KONFLIKT erkannt → EspoCRM WINS")
|
||
# ... force update zu Advoware
|
||
```
|
||
|
||
**Überlegungen:**
|
||
- Für **STAMMDATEN** ist das sinnvoll (EspoCRM ist Master)
|
||
- Für **Kontaktdaten** könnte Advoware Master sein
|
||
- Für **Adresse** sollte vielleicht Merge stattfinden
|
||
|
||
✅ **Status:** OK für aktuelle Scope (nur Stammdaten)
|
||
📝 **Später:** Konfigurierbare Conflict Strategy pro Feld-Gruppe
|
||
|
||
---
|
||
|
||
### 5. **Timestamp-Fallback verwendet geaendertAm (deprecated?)**
|
||
|
||
**Code:**
|
||
```python
|
||
return self.compare_timestamps(
|
||
espo_entity.get('modifiedAt'),
|
||
advo_entity.get('geaendertAm'), # ← Swagger deprecated?
|
||
espo_entity.get('advowareLastSync')
|
||
)
|
||
```
|
||
|
||
⚠️ **TODO:** Prüfe ob `geaendertAm` zuverlässig ist oder ob Advoware ein anderes Feld hat
|
||
|
||
---
|
||
|
||
### 6. **Keine Batch-Verarbeitung für Webhooks**
|
||
|
||
**Problem:**
|
||
```python
|
||
# Webhook-Handler: Emittiert Event pro Entity
|
||
for entity_id in entity_ids:
|
||
await context.emit({...}) # N Events
|
||
```
|
||
|
||
**Resultat:** Bei 100 Updates → 100 separate Event-Handler-Invocations
|
||
|
||
**Lösung (Optional):**
|
||
```python
|
||
# Batch-Event mit allen IDs
|
||
await context.emit({
|
||
'topic': 'vmh.beteiligte.update_batch',
|
||
'data': {
|
||
'entity_ids': list(entity_ids), # Alle auf einmal
|
||
'source': 'webhook'
|
||
}
|
||
})
|
||
|
||
# Handler verarbeitet in Parallel (mit Limit)
|
||
async def handler_batch(event_data, context):
|
||
entity_ids = event_data['entity_ids']
|
||
|
||
# Process max 10 parallel
|
||
semaphore = asyncio.Semaphore(10)
|
||
tasks = [sync_with_semaphore(id, semaphore) for id in entity_ids]
|
||
await asyncio.gather(*tasks)
|
||
```
|
||
📝 **Entscheidung:** Aktuell OK (Lock verhindert Probleme), aber bei >50 gleichzeitigen Updates könnte Batch helfen
|
||
|
||
---
|
||
|
||
### 7. **Fehlende Metriken/Monitoring**
|
||
|
||
**Was fehlt:**
|
||
- Durchschnittliche Sync-Dauer pro Entity
|
||
- Anzahl Konflikte pro Tag
|
||
- API-Call-Count (EspoCRM vs Advoware)
|
||
- Failed Sync Ratio
|
||
|
||
**Lösung:**
|
||
```python
|
||
# In sync_utils oder neues monitoring_utils.py
|
||
class SyncMetrics:
|
||
async def record_sync(self, entity_id, duration, result, comparison):
|
||
await redis.hincrby('metrics:sync:daily', 'total', 1)
|
||
await redis.hincrby('metrics:sync:daily', f'result_{result}', 1)
|
||
await redis.lpush('metrics:sync:durations', duration)
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 VERBESSERUNGS-EMPFEHLUNGEN
|
||
|
||
### Priorität 1: SOFORT (Effizienz)
|
||
|
||
1. **✅ Eliminiere doppeltes rowId-Update**
|
||
```python
|
||
# NUR in release_sync_lock, nicht vorher extra Update
|
||
```
|
||
Impact: -1 API Call pro EspoCRM→Advoware Update (ca. 50% weniger EspoCRM calls)
|
||
|
||
2. **✅ Teste POST Response für rowId**
|
||
```python
|
||
# Falls POST auch rowId enthält: Extra GET entfernen
|
||
```
|
||
Impact: -1 API Call pro CREATE (50% weniger bei Neuanlagen)
|
||
|
||
### Priorität 2: MITTELFRISTIG (Eleganz)
|
||
|
||
3. **📝 Merge Initial Sync in compare_entities**
|
||
```python
|
||
# Eliminiert Special Case, -30 Zeilen
|
||
```
|
||
Impact: Cleaner Code, leichter wartbar
|
||
|
||
4. **📝 Prüfe geaendertAm Timestamp**
|
||
```python
|
||
# Stelle sicher dass Fallback funktioniert
|
||
```
|
||
Impact: Robustheit falls rowId mal fehlt
|
||
|
||
### Priorität 3: OPTIONAL (Features)
|
||
|
||
5. **💡 Batch-Processing für Webhooks**
|
||
- Bei >50 gleichzeitigen Updates könnte Performance leiden
|
||
- Aktuell nicht kritisch (Lock verhindert Probleme)
|
||
|
||
6. **💡 Metriken/Dashboard**
|
||
- Sync-Statistiken für Monitoring
|
||
- Nicht kritisch aber nützlich für Ops
|
||
|
||
---
|
||
|
||
## 📊 PERFORMANCE-SCHÄTZUNG
|
||
|
||
### Aktueller Stand (pro Entity)
|
||
|
||
**CREATE:**
|
||
- 1× EspoCRM GET (Entity laden)
|
||
- 1× Advoware POST
|
||
- 1× Advoware GET (rowId holen) ← **OPTIMIERBAR**
|
||
- 1× EspoCRM PUT (betNr + rowId schreiben) ← **OPTIMIERBAR**
|
||
- 1× EspoCRM PUT (syncStatus + lastSync) ← Teil von Lock-Release
|
||
|
||
**Total: 5 API Calls** → Mit Optimierung: **3 API Calls (-40%)**
|
||
|
||
**UPDATE (EspoCRM→Advoware):**
|
||
- 1× EspoCRM GET (Entity laden)
|
||
- 1× Advoware GET (Vergleich)
|
||
- 1× Advoware PUT
|
||
- 1× EspoCRM PUT (rowId update) ← **DOPPELT**
|
||
- 1× EspoCRM PUT (Lock-Release mit rowId) ← **DOPPELT**
|
||
|
||
**Total: 5 API Calls** → Mit Optimierung: **4 API Calls (-20%)**
|
||
|
||
**UPDATE (Advoware→EspoCRM):**
|
||
- 1× EspoCRM GET
|
||
- 1× Advoware GET
|
||
- 1× EspoCRM PUT (Daten)
|
||
- 1× EspoCRM PUT (Lock-Release)
|
||
|
||
**Total: 4 API Calls** → Bereits optimal
|
||
|
||
---
|
||
|
||
## 🎨 ARCHITEKTUR-BEWERTUNG
|
||
|
||
### ✅ Was ist ROBUST?
|
||
|
||
1. **Distributed Locking** - Verhindert Race Conditions
|
||
2. **rowId Change Detection** - Sehr zuverlässig
|
||
3. **Retry Logic** - Graceful Degradation
|
||
4. **Error Handling** - Try-catch auf allen Ebenen
|
||
5. **TTL auf Locks** - Keine Deadlocks
|
||
|
||
### ✅ Was ist EFFIZIENT?
|
||
|
||
1. **PUT Response Parsing** - Spart GET nach Updates
|
||
2. **None-Filtering** - Verhindert unnötige Validierungsfehler
|
||
3. **Early Returns** - "no_change" skipped sofort
|
||
4. **Redis Token Caching** - Nicht bei jedem Call neu authentifizieren
|
||
|
||
### ✅ Was ist ELEGANT?
|
||
|
||
1. **Event-Driven Architecture** - Entkoppelt Webhook von Sync-Logik
|
||
2. **Mapper Pattern** - Transformationen zentral
|
||
3. **Utility Class** - Wiederverwendbare Funktionen
|
||
4. **Descriptive Logging** - Mit Emojis, sehr lesbar
|
||
|
||
### ⚠️ Was könnte ELEGANTER sein?
|
||
|
||
1. **Doppelte rowId-Updates** - Redundant
|
||
2. **Initial Sync Special Case** - Unnötige Duplikation
|
||
3. **Keine Config für Conflict Strategy** - Hardcoded
|
||
4. **Fehlende Metriken** - Monitoring schwierig
|
||
|
||
---
|
||
|
||
## 🏆 GESAMTBEWERTUNG
|
||
|
||
| Kategorie | Bewertung | Note |
|
||
|-----------|-----------|------|
|
||
| Robustheit | ⭐⭐⭐⭐⭐ | 9/10 - Sehr stabil durch Locking + Retry |
|
||
| Effizienz | ⭐⭐⭐⭐☆ | 7/10 - Gut, aber 2 klare Optimierungen möglich |
|
||
| Eleganz | ⭐⭐⭐⭐☆ | 8/10 - Sauber strukturiert, kleine Code-Duplikate |
|
||
| Wartbarkeit | ⭐⭐⭐⭐⭐ | 9/10 - Gut dokumentiert, klare Struktur |
|
||
| Erweiterbarkeit | ⭐⭐⭐⭐☆ | 8/10 - Event-Driven macht Extensions einfach |
|
||
|
||
**Gesamt: 8.2/10 - SEHR GUT**
|
||
|
||
---
|
||
|
||
## 🚀 EMPFOHLENE NÄCHSTE SCHRITTE
|
||
|
||
### Sofort (1-2h Aufwand):
|
||
1. ✅ Eliminiere doppeltes rowId-Update (5min)
|
||
2. ✅ Teste Advoware POST Response auf rowId (15min)
|
||
3. ✅ Falls ja: Entferne GET nach CREATE (5min)
|
||
|
||
### Mittelfristig (2-4h):
|
||
4. 📝 Merge Initial Sync in compare_entities (30min)
|
||
5. 📝 Add Metrics Collection (1-2h)
|
||
|
||
### Optional:
|
||
6. 💡 Batch-Processing (nur wenn Performance-Problem)
|
||
7. 💡 Configurable Conflict Strategy (bei neuen Requirements)
|
||
|
||
---
|
||
|
||
## 📝 FAZIT
|
||
|
||
**Das System ist produktionsreif und robust.**
|
||
|
||
- **Stärken:** Exzellentes Locking, zuverlässige Change Detection, gutes Error Handling
|
||
- **Optimierungen:** 2-3 kleine Fixes können 20-40% API Calls sparen
|
||
- **Architektur:** Sauber, wartbar, erweiterbar
|
||
|
||
**Recommendation:** Ship it mit den 2 Quick-Fixes (doppeltes Update + POST rowId-Check).
|