|
| 1 | +"""Non-blocking dispatcher for governance track-event telemetry. |
| 2 | +
|
| 3 | +Wraps :meth:`UiPathPlatformGovernanceProvider.track_event_async` on a |
| 4 | +private background ``asyncio`` event loop so sync callers can fire |
| 5 | +telemetry events without blocking on the underlying ``POST /runtime/log`` |
| 6 | +HTTP round-trip. |
| 7 | +
|
| 8 | +:meth:`LiveTrackEventDispatcher.dispatch` is a sync fire-and-forget |
| 9 | +method that mirrors the kwargs of ``track_event_async``. Internally it |
| 10 | +schedules the async HTTP call onto a dedicated background loop, so the |
| 11 | +calling thread never blocks on network I/O and the underlying HTTP call |
| 12 | +remains async end-to-end. |
| 13 | +
|
| 14 | +Design notes: |
| 15 | +
|
| 16 | +- **Async HTTP inside, sync interface outside.** ``dispatch`` is a |
| 17 | + sync function. Internally it enqueues a coroutine that awaits |
| 18 | + ``provider.track_event_async``. |
| 19 | +
|
| 20 | +- **Loop affinity.** ``httpx.AsyncClient`` lazy-binds its connection |
| 21 | + pool to the first event loop that awaits on it. This dispatcher |
| 22 | + assumes it owns the provider's async HTTP path — nothing else in |
| 23 | + the process should await ``track_event_async`` (or any other |
| 24 | + ``*_async`` method on the same underlying service) on a *different* |
| 25 | + loop. See "one dispatcher per provider" below. |
| 26 | +
|
| 27 | +- **Backpressure.** A ``BoundedSemaphore`` caps in-flight coroutines; |
| 28 | + submissions that exceed the cap are dropped with a warning so |
| 29 | + memory stays bounded when the backend is slow. |
| 30 | +
|
| 31 | +- **Fire-and-forget contract.** Coroutine exceptions are observed on |
| 32 | + the returned ``concurrent.futures.Future`` (to suppress asyncio's |
| 33 | + "exception was never retrieved" warning) and logged at debug — they |
| 34 | + cannot reach the caller because ``dispatch`` returns before the |
| 35 | + coroutine runs. |
| 36 | +
|
| 37 | +One dispatcher per provider. The dispatcher's background loop must be |
| 38 | +the only loop that awaits the provider's async methods. |
| 39 | +""" |
| 40 | + |
| 41 | +from __future__ import annotations |
| 42 | + |
| 43 | +import asyncio |
| 44 | +import concurrent.futures |
| 45 | +import logging |
| 46 | +import threading |
| 47 | +from typing import Any |
| 48 | + |
| 49 | +from ._governance_provider import UiPathPlatformGovernanceProvider |
| 50 | + |
| 51 | +logger = logging.getLogger(__name__) |
| 52 | + |
| 53 | + |
| 54 | +class LiveTrackEventDispatcher: |
| 55 | + """Non-blocking sync adapter around ``provider.track_event_async``. |
| 56 | +
|
| 57 | + Schedules governance telemetry events on a private background |
| 58 | + ``asyncio`` loop so the calling thread is never blocked on the |
| 59 | + platform's ``/runtime/log`` HTTP call — and the HTTP call itself |
| 60 | + is awaited (not run on a sync thread pool). |
| 61 | +
|
| 62 | + .. code-block:: python |
| 63 | +
|
| 64 | + provider = UiPathPlatformGovernanceProvider(config=..., execution_context=...) |
| 65 | + dispatcher = LiveTrackEventDispatcher(provider) |
| 66 | + dispatcher.dispatch(event_name="agent.started") |
| 67 | + # ... |
| 68 | + dispatcher.shutdown() # at process exit |
| 69 | +
|
| 70 | + ``dispatch`` has the same kwargs as |
| 71 | + :meth:`UiPathPlatformGovernanceProvider.track_event_async` so it is |
| 72 | + a drop-in sync callable for anywhere the async method would go. |
| 73 | + """ |
| 74 | + |
| 75 | + _DEFAULT_MAX_INFLIGHT = 40 |
| 76 | + |
| 77 | + def __init__( |
| 78 | + self, |
| 79 | + provider: UiPathPlatformGovernanceProvider, |
| 80 | + *, |
| 81 | + max_inflight: int = _DEFAULT_MAX_INFLIGHT, |
| 82 | + ) -> None: |
| 83 | + """Construct a dispatcher bound to one provider. |
| 84 | +
|
| 85 | + Starts a daemon thread that runs a private ``asyncio`` event |
| 86 | + loop. All HTTP awaits happen on that loop; nothing else in the |
| 87 | + process should await the provider's async methods on a |
| 88 | + different loop (see the module docstring on loop affinity). |
| 89 | +
|
| 90 | + Args: |
| 91 | + provider: The platform governance provider whose |
| 92 | + ``track_event_async`` will be awaited on the background |
| 93 | + loop. |
| 94 | + max_inflight: Cap on concurrent in-flight coroutines. When |
| 95 | + exceeded, further ``dispatch`` calls are dropped with a |
| 96 | + warning so memory stays bounded under a slow backend. |
| 97 | + Default 40 is sized for a bursty-but-not-sustained |
| 98 | + event stream. |
| 99 | + """ |
| 100 | + self._provider = provider |
| 101 | + self._max_inflight = max_inflight |
| 102 | + self._inflight = threading.BoundedSemaphore(max_inflight) |
| 103 | + self._shutdown_event = threading.Event() |
| 104 | + self._futures_lock = threading.Lock() |
| 105 | + self._futures: set[concurrent.futures.Future[None]] = set() |
| 106 | + |
| 107 | + self._loop = asyncio.new_event_loop() |
| 108 | + self._loop_ready = threading.Event() |
| 109 | + self._loop_thread = threading.Thread( |
| 110 | + target=self._run_loop, |
| 111 | + name="governance-track-event-loop", |
| 112 | + daemon=True, |
| 113 | + ) |
| 114 | + self._loop_thread.start() |
| 115 | + # Block until the loop is running so the first ``dispatch`` cannot |
| 116 | + # race with startup and hit "loop not running" errors. |
| 117 | + self._loop_ready.wait() |
| 118 | + |
| 119 | + def _run_loop(self) -> None: |
| 120 | + """Body of the background loop thread — runs until ``shutdown``.""" |
| 121 | + asyncio.set_event_loop(self._loop) |
| 122 | + self._loop_ready.set() |
| 123 | + try: |
| 124 | + self._loop.run_forever() |
| 125 | + finally: |
| 126 | + # After ``run_forever`` returns (from ``stop()``), any tasks |
| 127 | + # that were still awaiting mid-flight need to be cancelled |
| 128 | + # and finalized before the loop can close cleanly. Without |
| 129 | + # this, ``loop.close()`` warns "Task was destroyed but it is |
| 130 | + # pending" for every unfinished awaiter. |
| 131 | + try: |
| 132 | + pending = asyncio.all_tasks(self._loop) |
| 133 | + for task in pending: |
| 134 | + task.cancel() |
| 135 | + if pending: |
| 136 | + self._loop.run_until_complete( |
| 137 | + asyncio.gather(*pending, return_exceptions=True) |
| 138 | + ) |
| 139 | + except Exception as exc: # noqa: BLE001 - teardown must not raise |
| 140 | + logger.debug("Loop cleanup swallowed exception: %s", exc) |
| 141 | + finally: |
| 142 | + try: |
| 143 | + self._loop.close() |
| 144 | + except Exception as exc: # noqa: BLE001 |
| 145 | + logger.debug("Loop close swallowed exception: %s", exc) |
| 146 | + |
| 147 | + def dispatch( |
| 148 | + self, |
| 149 | + *, |
| 150 | + event_name: str, |
| 151 | + data: dict[str, Any] | None = None, |
| 152 | + operation_id: str | None = None, |
| 153 | + ) -> None: |
| 154 | + """Schedule a track-event call on the background loop — returns immediately. |
| 155 | +
|
| 156 | + The kwargs mirror |
| 157 | + :meth:`UiPathPlatformGovernanceProvider.track_event_async` so |
| 158 | + this method is a drop-in sync callable for the async provider |
| 159 | + method. |
| 160 | +
|
| 161 | + Failure modes — all silent, never raised to the caller: |
| 162 | +
|
| 163 | + - **Post-shutdown**: dispatch after :meth:`shutdown` returns |
| 164 | + silently; the provider is not called. |
| 165 | + - **Saturated in-flight cap**: when ``max_inflight`` coroutines |
| 166 | + are already scheduled, the call is dropped with a warning. |
| 167 | + Telemetry must never grow memory without bound when the |
| 168 | + backend is slow. |
| 169 | + - **Loop unavailable**: ``asyncio.run_coroutine_threadsafe`` |
| 170 | + raises ``RuntimeError`` if the loop is stopped/closed |
| 171 | + (late-firing atexit path); the dispatcher rolls back the |
| 172 | + semaphore slot, closes the coroutine, and logs at debug. |
| 173 | + - **Coroutine exception**: the provider's HTTP call may raise |
| 174 | + for any reason (serialization, 5xx, transport). ``_run`` |
| 175 | + catches, logs at debug with ``exc_info=True``, and the |
| 176 | + done-callback observes the future to suppress asyncio's |
| 177 | + "exception was never retrieved" warning. |
| 178 | + """ |
| 179 | + if self._shutdown_event.is_set(): |
| 180 | + logger.debug( |
| 181 | + "Dispatcher shut down; dropping track_event (event_name=%s)", |
| 182 | + event_name, |
| 183 | + ) |
| 184 | + return |
| 185 | + |
| 186 | + if not self._inflight.acquire(blocking=False): |
| 187 | + logger.warning( |
| 188 | + "Telemetry pool saturated (>%d in flight); dropping track_event " |
| 189 | + "(event_name=%s)", |
| 190 | + self._max_inflight, |
| 191 | + event_name, |
| 192 | + ) |
| 193 | + return |
| 194 | + |
| 195 | + coro = self._run(event_name=event_name, data=data, operation_id=operation_id) |
| 196 | + try: |
| 197 | + future = asyncio.run_coroutine_threadsafe(coro, self._loop) |
| 198 | + except RuntimeError as exc: |
| 199 | + # Loop is stopped/closed — release the slot we took and |
| 200 | + # close the coroutine so it doesn't warn at GC time. |
| 201 | + coro.close() |
| 202 | + self._inflight.release() |
| 203 | + logger.debug( |
| 204 | + "Telemetry loop unavailable (event_name=%s): %s", |
| 205 | + event_name, |
| 206 | + exc, |
| 207 | + ) |
| 208 | + return |
| 209 | + |
| 210 | + with self._futures_lock: |
| 211 | + self._futures.add(future) |
| 212 | + future.add_done_callback(self._on_future_done) |
| 213 | + |
| 214 | + async def _run( |
| 215 | + self, |
| 216 | + *, |
| 217 | + event_name: str, |
| 218 | + data: dict[str, Any] | None, |
| 219 | + operation_id: str | None, |
| 220 | + ) -> None: |
| 221 | + """Coroutine body — the async HTTP call itself.""" |
| 222 | + try: |
| 223 | + await self._provider.track_event_async( |
| 224 | + event_name=event_name, |
| 225 | + data=data, |
| 226 | + operation_id=operation_id, |
| 227 | + ) |
| 228 | + except Exception as exc: # noqa: BLE001 - fire-and-forget contract |
| 229 | + logger.debug("Failed to dispatch track_event: %s", exc, exc_info=True) |
| 230 | + |
| 231 | + def _on_future_done(self, future: concurrent.futures.Future[None]) -> None: |
| 232 | + """Observe the future, drop it from the pending set, release the slot. |
| 233 | +
|
| 234 | + Uses ``future.exception()`` to observe the outcome so asyncio |
| 235 | + doesn't warn "exception was never retrieved" at GC time. |
| 236 | + ``concurrent.futures.Future.exception()`` *raises* |
| 237 | + ``CancelledError`` when the future was cancelled (the observe- |
| 238 | + without-raise semantics apply only to :class:`asyncio.Future`, |
| 239 | + not this ``concurrent.futures`` type), so the observation is |
| 240 | + wrapped in a targeted catch. The accounting — semaphore release |
| 241 | + and pending-set discard — runs in ``finally`` so success, |
| 242 | + failure, and cancellation all clean up correctly. |
| 243 | + """ |
| 244 | + try: |
| 245 | + future.exception() |
| 246 | + except concurrent.futures.CancelledError: |
| 247 | + # Cancellation during shutdown is expected; the underlying |
| 248 | + # coroutine's own exception (if any) was already logged by |
| 249 | + # ``_run``. |
| 250 | + pass |
| 251 | + finally: |
| 252 | + with self._futures_lock: |
| 253 | + self._futures.discard(future) |
| 254 | + self._inflight.release() |
| 255 | + |
| 256 | + def shutdown(self, *, wait: bool = True, timeout: float = 30.0) -> None: |
| 257 | + """Stop accepting new submissions; optionally drain pending, then stop the loop. |
| 258 | +
|
| 259 | + Call at process exit to avoid losing in-flight telemetry. |
| 260 | + Safe to call more than once — subsequent calls are no-ops. |
| 261 | +
|
| 262 | + Args: |
| 263 | + wait: When ``True`` (default), block until pending |
| 264 | + coroutines finish (bounded by ``timeout``) before |
| 265 | + stopping the loop. When ``False``, stop immediately; |
| 266 | + in-flight coroutines are cancelled by the loop's |
| 267 | + teardown path. |
| 268 | + timeout: Maximum seconds to wait for pending coroutines |
| 269 | + when ``wait=True``. Coroutines still in flight after |
| 270 | + the timeout are cancelled by loop teardown. |
| 271 | + """ |
| 272 | + if self._shutdown_event.is_set(): |
| 273 | + return |
| 274 | + self._shutdown_event.set() |
| 275 | + |
| 276 | + if wait: |
| 277 | + with self._futures_lock: |
| 278 | + pending = list(self._futures) |
| 279 | + if pending: |
| 280 | + concurrent.futures.wait(pending, timeout=timeout) |
| 281 | + |
| 282 | + try: |
| 283 | + self._loop.call_soon_threadsafe(self._loop.stop) |
| 284 | + except RuntimeError: |
| 285 | + # Loop already stopped. |
| 286 | + pass |
| 287 | + self._loop_thread.join(timeout=5.0) |
0 commit comments