"""Lifecycle event hooks for the Identity Sync Center. Other domains call :func:`enqueue_person_sync` after a person's data materially changes — hire, leave, role-change, dept-move. The hook walks every enabled, event-aware target and pushes that one person. Targets opt into event-driven pushes by setting: schedule_config.trigger ∈ ("event_only", "cron") ``manual``-only targets are skipped by event hooks. ``cron`` targets also receive event pushes (event = "additional", not "instead of" cron). Event hooks fire async. They never raise back to the caller — a sync push failure must not break onboarding. Failures land on the IdentitySyncRun + IdentitySyncState rows for the admin to triage. """ from __future__ import annotations import asyncio from uuid import UUID from app.core.safe_logging import get_safe_logger logger = get_safe_logger(__name__) async def enqueue_person_sync( person_id: UUID, *, reason: str, ) -> None: """Push one person to every event-aware enabled target. ``reason`` lands on the IdentitySyncRun.trigger_source as ``event:`` so the admin can filter by what kind of event drove the push. This function never raises — it's called from inside hire / leave workflows that must keep going even if sync fails. """ try: await _do_enqueue(person_id, reason=reason) except Exception: # noqa: BLE001 logger.exception( "identity_sync event hook for person=%s reason=%s failed", person_id, reason, ) async def _do_enqueue(person_id: UUID, *, reason: str) -> None: from sqlalchemy import select from app.core.database import async_session_factory from app.domains.identity_sync.models import IdentitySyncTarget from app.domains.identity_sync.service import IdentitySyncService async with async_session_factory() as session: targets = (await session.execute( select(IdentitySyncTarget).where( IdentitySyncTarget.deleted_at.is_(None), IdentitySyncTarget.enabled.is_(True), ) )).scalars().all() # event_only AND cron both accept event pushes. ``manual`` # explicitly opts OUT — admins set that for "I never want # automatic pushes, only my Sync Now button" use cases. eligible = [ t for t in targets if (t.schedule_config or {}).get("trigger") in ("event_only", "cron") ] # Fan out across targets concurrently — they hit different # downstream systems and shouldn't wait on each other. # # A single event can double-fire (an offboard is spawned by BOTH # ``advance_service.process_advance`` and the leaver-blocked # completion path), so two concurrent fan-outs may push the same # person to the same target. That duplicate is collapsed inside # ``sync_person_to_target`` by its per-(person, target) in-flight # guard (a silent ``skipped``, no run row, no target-lock collision) # — the invariant lives at that chokepoint so every caller is covered # uniformly, not just this fan-out. async def _push_one(target_id: UUID) -> None: async with async_session_factory() as work_session: svc = IdentitySyncService(work_session) # Runs on its OWN per-target session (async_session_factory # above), so a failure here cannot poison any caller's # transaction — the session is discarded on exit. # savepoint-exempt: dedicated per-target session try: await svc.sync_person_to_target( target_id=target_id, person_id=person_id, trigger_source=f"event:{reason}", ) await work_session.commit() except Exception: # noqa: BLE001 # Per-target failure is logged + recorded on the run # row; don't let it abort the fan-out. logger.exception( "identity_sync event push to target %s failed", target_id, ) await asyncio.gather( *[_push_one(t.id) for t in eligible], return_exceptions=False, )