from __future__ import annotations import asyncio from datetime import UTC, datetime, timedelta from time import monotonic from typing import Any from sqlalchemy import text from app.config import load_settings from app.crm import CrmClient, CrmOutcome from app.repository import Repository from app.telemetry import ( init_telemetry, log_event, record_reconciliation, safe_span, shutdown_telemetry, ) class IncrementalReconciler: def __init__(self, repository: Repository, crm: CrmClient, registered_rest_field: str) -> None: self.repository = repository self.crm = crm self.registered_rest_field = registered_rest_field async def run_once(self, overlap_seconds: int) -> int: async with self.repository.transaction() as connection: acquired = ( await connection.execute( text( """ SELECT pg_try_advisory_xact_lock( hashtext('bitrix-contact-reconciliation') ) """ ) ) ).scalar_one() if not acquired: return 0 cursor = ( await connection.execute( text( """ SELECT watermark FROM bitrix_sync.reconciliation_cursors WHERE job_type='contact_incremental' FOR UPDATE """ ) ) ).scalar_one_or_none() started_at = datetime.now(UTC) since = (cursor or datetime(1970, 1, 1, tzinfo=UTC)) - timedelta(seconds=overlap_seconds) start = 0 scanned: list[tuple[str, str]] = [] while True: result = await self.crm.call( "crm.item.list", { "entityTypeId": 3, "select": ["id", "updatedTime"], "filter": { ">=updatedTime": since.replace(tzinfo=None).isoformat(timespec="seconds"), "opened": 1, self.registered_rest_field: 1, }, "start": start, }, mutating=False, ) if result.outcome != CrmOutcome.SUCCEEDED: raise RuntimeError(result.error_code or "reconciliation_failed") payload: Any = result.result or {} items = payload.get("items", []) if isinstance(payload, dict) else payload if not isinstance(items, list): raise RuntimeError("reconciliation_items_invalid") for item in items: if not isinstance(item, dict) or "id" not in item or "updatedTime" not in item: raise RuntimeError("reconciliation_item_missing_identity") scanned.append((str(item["id"]), str(item["updatedTime"]))) next_start = payload.get("next") if isinstance(payload, dict) else None if next_start is None: break start = int(next_start) await self._enqueue_changed(scanned) async with self.repository.transaction() as connection: await connection.execute( text( """ INSERT INTO bitrix_sync.reconciliation_cursors (job_type,watermark,overlap_seconds,last_success_at,last_scanned_count, created_at,updated_at) VALUES ('contact_incremental',:watermark,:overlap,now(),:count,now(),now()) ON CONFLICT (job_type) DO UPDATE SET watermark=excluded.watermark,overlap_seconds=excluded.overlap_seconds, last_success_at=now(),last_scanned_count=excluded.last_scanned_count, updated_at=now() """ ), {"watermark": started_at, "overlap": overlap_seconds, "count": len(scanned)}, ) return len(scanned) async def _enqueue_changed(self, changed_contacts: list[tuple[str, str]]) -> None: if not changed_contacts: return external_ids = [external_id for external_id, _ in changed_contacts] event_ids = [ f"contact.reconciliation:{external_id}:{updated_time}" for external_id, updated_time in changed_contacts ] async with self.repository.transaction() as connection: await connection.execute( text( """ INSERT INTO bitrix_sync.webhook_inbox (id,receiver_type,event_type,event_id,external_entity_id,status, coalesced_count,received_at,last_received_at) SELECT gen_random_uuid(),'contact','contact.reconciliation',candidate.event_id, candidate.external_id,'received',1,now(),now() FROM unnest(CAST(:ids AS text[]),CAST(:event_ids AS text[])) AS candidate(external_id,event_id) WHERE NOT EXISTS ( SELECT 1 FROM bitrix_sync.webhook_inbox w WHERE w.receiver_type='contact' AND w.external_entity_id=candidate.external_id AND w.status IN ('received','processing','retry_wait') ) ON CONFLICT (receiver_type,event_id) WHERE event_id IS NOT NULL DO NOTHING """ ), {"ids": external_ids, "event_ids": event_ids}, ) async def reconciliation_main() -> None: init_telemetry("bitrix-sync-reconciliation") repository: Repository | None = None crm: CrmClient | None = None started = monotonic() outcome = "error" count = 0 try: settings = load_settings() if not settings.enabled: outcome = "skipped" log_event("reconciliation.disabled", "Bitrix sync reconciliation is disabled") return assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host assert settings.contact_registered_field repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size) crm = CrmClient( settings.crm_rest_webhook_url.get_secret_value(), settings.portal_host, settings.http_timeout_sec, ) reconciler = IncrementalReconciler( repository, crm, settings.rest_field_name(settings.contact_registered_field) ) with safe_span("bitrix_sync.reconciliation.run"): count = await reconciler.run_once(settings.reconciliation_overlap_seconds) outcome = "success" log_event( "reconciliation.completed", "Bitrix sync reconciliation completed", attributes={"scanned_count": count}, ) finally: record_reconciliation(outcome, count, started) if crm is not None: await crm.close() if repository is not None: await repository.close() shutdown_telemetry() def run() -> None: asyncio.run(reconciliation_main())