feat(sync): Implement auto-reset for permanently_failed entities and add retry backoff logic
- Added logic to reset permanently_failed entities that have reached their auto-reset threshold in `beteiligte_sync_cron_step.py`. - Enhanced event handling in `beteiligte_sync_event_step.py` to skip retries if the next retry time has not been reached. - Introduced validation checks after sync operations to ensure data consistency and integrity. - Created detailed documentation outlining the fixes and their impacts on the sync process. - Added scripts for analyzing sync issues and comparing entities to facilitate debugging and validation.
This commit is contained in:
@@ -27,6 +27,10 @@ TimestampResult = Literal["espocrm_newer", "advoware_newer", "conflict", "no_cha
|
||||
MAX_SYNC_RETRIES = 5
|
||||
# Lock TTL in seconds (prevents deadlocks)
|
||||
LOCK_TTL_SECONDS = 900 # 15 minutes
|
||||
# Retry backoff: Wartezeit zwischen Retries (in Minuten)
|
||||
RETRY_BACKOFF_MINUTES = [1, 5, 15, 60, 240] # 1min, 5min, 15min, 1h, 4h
|
||||
# Auto-Reset nach 24h (für permanently_failed entities)
|
||||
AUTO_RESET_HOURS = 24
|
||||
|
||||
|
||||
class BeteiligteSync:
|
||||
@@ -141,17 +145,35 @@ class BeteiligteSync:
|
||||
new_retry = current_retry + 1
|
||||
update_data['syncRetryCount'] = new_retry
|
||||
|
||||
# FIX #12: Exponential backoff - berechne nächsten Retry-Zeitpunkt
|
||||
if new_retry <= len(RETRY_BACKOFF_MINUTES):
|
||||
backoff_minutes = RETRY_BACKOFF_MINUTES[new_retry - 1]
|
||||
else:
|
||||
backoff_minutes = RETRY_BACKOFF_MINUTES[-1] # Letzte Backoff-Zeit
|
||||
|
||||
from datetime import timedelta
|
||||
next_retry = now_utc + timedelta(minutes=backoff_minutes)
|
||||
update_data['syncNextRetry'] = next_retry.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
self._log(f"Retry {new_retry}/{MAX_SYNC_RETRIES}, nächster Versuch in {backoff_minutes} Minuten")
|
||||
|
||||
# Check max retries - mark as permanently failed
|
||||
if new_retry >= MAX_SYNC_RETRIES:
|
||||
update_data['syncStatus'] = 'permanently_failed'
|
||||
|
||||
# FIX #12: Auto-Reset Timestamp für Wiederherstellung nach 24h
|
||||
auto_reset_time = now_utc + timedelta(hours=AUTO_RESET_HOURS)
|
||||
update_data['syncAutoResetAt'] = auto_reset_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
await self.send_notification(
|
||||
entity_id,
|
||||
f"Sync fehlgeschlagen nach {MAX_SYNC_RETRIES} Versuchen. Manuelle Prüfung erforderlich.",
|
||||
f"Sync fehlgeschlagen nach {MAX_SYNC_RETRIES} Versuchen. Auto-Reset in {AUTO_RESET_HOURS}h.",
|
||||
notification_type='error'
|
||||
)
|
||||
self._log(f"Max retries ({MAX_SYNC_RETRIES}) erreicht für {entity_id}", level='error')
|
||||
self._log(f"Max retries ({MAX_SYNC_RETRIES}) erreicht für {entity_id}, Auto-Reset um {auto_reset_time}", level='error')
|
||||
else:
|
||||
update_data['syncRetryCount'] = 0
|
||||
update_data['syncNextRetry'] = None
|
||||
|
||||
# Merge extra fields (e.g., betnr from create operation)
|
||||
if extra_fields:
|
||||
@@ -247,9 +269,31 @@ class BeteiligteSync:
|
||||
last_sync = espo_entity.get('advowareLastSync')
|
||||
espo_modified = espo_entity.get('modifiedAt')
|
||||
|
||||
# SPECIAL CASE: Kein lastSync → Initial Sync (EspoCRM→Advoware)
|
||||
# SPECIAL CASE: Kein lastSync → Initial Sync
|
||||
# FIX #11: Vergleiche Timestamps statt blind EspoCRM zu bevorzugen
|
||||
if not last_sync:
|
||||
self._log(f"Initial Sync (kein lastSync) → EspoCRM neuer")
|
||||
self._log(f"Initial Sync (kein lastSync) → Vergleiche Timestamps")
|
||||
|
||||
# Wenn beide Timestamps vorhanden, vergleiche sie
|
||||
if espo_ts and advo_ts:
|
||||
if espo_ts > advo_ts:
|
||||
self._log(f"Initial Sync: EspoCRM neuer ({espo_ts} > {advo_ts})")
|
||||
return 'espocrm_newer'
|
||||
elif advo_ts > espo_ts:
|
||||
self._log(f"Initial Sync: Advoware neuer ({advo_ts} > {espo_ts})")
|
||||
return 'advoware_newer'
|
||||
else:
|
||||
self._log(f"Initial Sync: Beide gleich alt")
|
||||
return 'no_change'
|
||||
|
||||
# Fallback: Wenn nur einer Timestamp hat, bevorzuge den
|
||||
if espo_ts and not advo_ts:
|
||||
return 'espocrm_newer'
|
||||
if advo_ts and not espo_ts:
|
||||
return 'advoware_newer'
|
||||
|
||||
# Wenn keine Timestamps verfügbar: EspoCRM bevorzugen (default)
|
||||
self._log(f"Initial Sync: Keine Timestamps verfügbar → EspoCRM bevorzugt")
|
||||
return 'espocrm_newer'
|
||||
|
||||
if espo_rowid and advo_rowid:
|
||||
@@ -496,6 +540,81 @@ class BeteiligteSync:
|
||||
except Exception as e:
|
||||
self._log(f"Fehler beim Handle Deleted: {e}", level='error')
|
||||
|
||||
async def validate_sync_result(
|
||||
self,
|
||||
entity_id: str,
|
||||
betnr: int,
|
||||
mapper,
|
||||
direction: str = 'to_advoware'
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
FIX #13: Validiert Sync-Ergebnis durch Round-Trip Verification
|
||||
|
||||
Args:
|
||||
entity_id: EspoCRM CBeteiligte ID
|
||||
betnr: Advoware betNr
|
||||
mapper: BeteiligteMapper instance
|
||||
direction: 'to_advoware' oder 'to_espocrm'
|
||||
|
||||
Returns:
|
||||
(success: bool, error_message: Optional[str])
|
||||
"""
|
||||
try:
|
||||
self._log(f"🔍 Validiere Sync-Ergebnis (direction={direction})...", level='debug')
|
||||
|
||||
# Lade beide Entities erneut
|
||||
espo_entity = await self.espocrm.get_entity('CBeteiligte', entity_id)
|
||||
|
||||
from services.advoware import AdvowareAPI
|
||||
advoware_api = AdvowareAPI(self.context)
|
||||
advo_result = await advoware_api.api_call(f'api/v1/advonet/Beteiligte/{betnr}', method='GET')
|
||||
|
||||
if isinstance(advo_result, list):
|
||||
advo_entity = advo_result[0] if advo_result else None
|
||||
else:
|
||||
advo_entity = advo_result
|
||||
|
||||
if not advo_entity:
|
||||
return False, f"Advoware Entity {betnr} nicht gefunden nach Sync"
|
||||
|
||||
# Validiere Stammdaten
|
||||
critical_fields = ['name', 'rechtsform']
|
||||
differences = []
|
||||
|
||||
if direction == 'to_advoware':
|
||||
# EspoCRM → Advoware: Prüfe ob Advoware die EspoCRM-Werte hat
|
||||
advo_mapped = mapper.map_cbeteiligte_to_advoware(espo_entity)
|
||||
|
||||
for field in critical_fields:
|
||||
espo_val = advo_mapped.get(field)
|
||||
advo_val = advo_entity.get(field)
|
||||
|
||||
if espo_val != advo_val:
|
||||
differences.append(f"{field}: expected '{espo_val}', got '{advo_val}'")
|
||||
|
||||
elif direction == 'to_espocrm':
|
||||
# Advoware → EspoCRM: Prüfe ob EspoCRM die Advoware-Werte hat
|
||||
espo_mapped = mapper.map_advoware_to_cbeteiligte(advo_entity)
|
||||
|
||||
for field in critical_fields:
|
||||
advo_val = espo_mapped.get(field)
|
||||
espo_val = espo_entity.get(field)
|
||||
|
||||
if advo_val != espo_val:
|
||||
differences.append(f"{field}: expected '{advo_val}', got '{espo_val}'")
|
||||
|
||||
if differences:
|
||||
error_msg = f"Validation failed: {', '.join(differences)}"
|
||||
self._log(f"❌ {error_msg}", level='error')
|
||||
return False, error_msg
|
||||
|
||||
self._log(f"✅ Validation erfolgreich", level='debug')
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"⚠️ Validation error: {e}", level='error')
|
||||
return False, f"Validation exception: {str(e)}"
|
||||
|
||||
async def resolve_conflict_espocrm_wins(
|
||||
self,
|
||||
entity_id: str,
|
||||
|
||||
Reference in New Issue
Block a user