Skip to content

Commit e00f3f4

Browse files
viswa-uipathclaude
andauthored
feat(governance): non-blocking LiveTrackEventDispatcher for /runtime/log (#1776)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1113014 commit e00f3f4

6 files changed

Lines changed: 880 additions & 3 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.1.87"
3+
version = "0.1.88"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/governance/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@
1010
from .compensate import FiredRule, GovernRequest
1111
from .policy import PolicyContext, PolicyResponse
1212

13+
# ``_live_track_event_dispatcher.LiveTrackEventDispatcher`` is intentionally
14+
# **not** re-exported. It is host-wiring glue (the runtime sink's
15+
# non-blocking ``track_event`` adapter), not a customer-facing API.
16+
# Internal callers import it via the explicit private path:
17+
#
18+
# from uipath.platform.governance._live_track_event_dispatcher import (
19+
# LiveTrackEventDispatcher,
20+
# )
21+
1322
__all__ = [
1423
"FiredRule",
1524
"GovernRequest",
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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

Comments
 (0)