"""Long-running campaign-clusterer worker. Mirrors :mod:`decnet.clustering.worker` for the layer above. Bus-woken on ``identity.>`` (not ``attacker.>`` — the campaign clusterer reads identities, not raw observations); falls back to a 60s slow-tick poll when the bus is unavailable. Publishes the four ``campaign.*`` events plus the cross-family ``identity.campaign.assigned`` so existing identity-stream subscribers see campaign-id changes without subscribing to ``campaign.>``. """ from __future__ import annotations import asyncio import contextlib from typing import Optional from decnet.bus import topics as _topics from decnet.bus.base import BaseBus from decnet.bus.factory import get_bus from decnet.bus.publish import ( publish_safely, run_control_listener_signal as _run_control_listener_signal, run_health_heartbeat as _run_health_heartbeat, ) from decnet.clustering.campaign.base import ( CampaignClusterer, CampaignClusterResult, ) from decnet.clustering.campaign.factory import get_campaign_clusterer from decnet.logging import get_logger from decnet.web.db.repository import BaseRepository log = get_logger("clustering.campaign.worker") _DEFAULT_POLL_SECS = 60.0 _WORKER_NAME = "campaign-clusterer" async def run_campaign_clusterer_loop( repo: BaseRepository, *, poll_interval_secs: float = _DEFAULT_POLL_SECS, clusterer: Optional[CampaignClusterer] = None, shutdown: Optional[asyncio.Event] = None, ) -> None: """Run the campaign clusterer until cancelled.""" if clusterer is None: clusterer = get_campaign_clusterer() log.info( "campaign-clusterer started impl=%s poll_interval_secs=%s", clusterer.name, poll_interval_secs, ) bus: Optional[BaseBus] = None wake = asyncio.Event() wake_tasks: list[asyncio.Task] = [] heartbeat_task: Optional[asyncio.Task] = None try: candidate = get_bus(client_name=_WORKER_NAME) await candidate.connect() bus = candidate # Wake on any identity-layer event — formed / linked / merged / # unmerged all change the input set the campaign clusterer # operates over. wake_tasks.append(asyncio.create_task( _wake_on(bus, wake, f"{_topics.IDENTITY}.>"), )) heartbeat_task = asyncio.create_task( _run_health_heartbeat(bus, _WORKER_NAME), ) wake_tasks.append(asyncio.create_task( _run_control_listener_signal(bus, _WORKER_NAME), )) except Exception as exc: # noqa: BLE001 log.warning( "campaign-clusterer: bus unavailable, running in poll-only " "mode: %s", exc, ) if shutdown is None: shutdown = asyncio.Event() try: while not shutdown.is_set(): try: result = await clusterer.tick(repo) except Exception: # noqa: BLE001 log.exception("campaign-clusterer: tick failed") result = CampaignClusterResult() await _publish_result(bus, result) try: await asyncio.wait_for( wake.wait(), timeout=float(poll_interval_secs), ) except asyncio.TimeoutError: pass wake.clear() except (asyncio.CancelledError, KeyboardInterrupt): log.info("campaign-clusterer stopped") finally: for t in wake_tasks: t.cancel() if heartbeat_task is not None: heartbeat_task.cancel() for task in (*wake_tasks, heartbeat_task): if task is None: continue with contextlib.suppress(asyncio.CancelledError, Exception): await task if bus is not None: with contextlib.suppress(Exception): await bus.close() async def _publish_result( bus: Optional[BaseBus], result: CampaignClusterResult, ) -> None: """Fan ``CampaignClusterResult`` out to ``campaign.*`` topics + cross-family ``identity.campaign.assigned``.""" for formed in result.campaigns_formed: await publish_safely( bus, _topics.campaign(_topics.CAMPAIGN_FORMED), formed, event_type=_topics.CAMPAIGN_FORMED, ) # Also fire identity.campaign.assigned per identity so the # existing identity SSE stream sees the badge update. for identity_uuid in formed.get("identity_uuids", []): await publish_safely( bus, _topics.identity(_topics.IDENTITY_CAMPAIGN_ASSIGNED), { "identity_uuid": identity_uuid, "campaign_uuid": formed["campaign_uuid"], "prior_campaign_uuid": None, }, event_type=_topics.IDENTITY_CAMPAIGN_ASSIGNED, ) for assigned in result.identities_assigned: await publish_safely( bus, _topics.campaign(_topics.CAMPAIGN_IDENTITY_ASSIGNED), assigned, event_type=_topics.CAMPAIGN_IDENTITY_ASSIGNED, ) await publish_safely( bus, _topics.identity(_topics.IDENTITY_CAMPAIGN_ASSIGNED), { "identity_uuid": assigned["identity_uuid"], "campaign_uuid": assigned["campaign_uuid"], "prior_campaign_uuid": assigned.get("prior_campaign_uuid"), }, event_type=_topics.IDENTITY_CAMPAIGN_ASSIGNED, ) for merged in result.campaigns_merged: await publish_safely( bus, _topics.campaign(_topics.CAMPAIGN_MERGED), merged, event_type=_topics.CAMPAIGN_MERGED, ) for unmerged in result.campaigns_unmerged: await publish_safely( bus, _topics.campaign(_topics.CAMPAIGN_UNMERGED), unmerged, event_type=_topics.CAMPAIGN_UNMERGED, ) async def _wake_on(bus: BaseBus, wake: asyncio.Event, pattern: str) -> None: try: sub = bus.subscribe(pattern) async with sub: async for _event in sub: wake.set() except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 log.warning( "campaign-clusterer: subscriber for %s died (%s); falling back " "to poll", pattern, exc, ) __all__ = ["run_campaign_clusterer_loop"]