Skip to content

Commit 54cb9ec

Browse files
cristipufuclaude
andcommitted
feat: evict old terminal runs to cap history at 50
RunService.runs grows indefinitely, leaking memory on long-running servers. Add eviction logic that removes oldest completed/failed runs when total exceeds MAX_RUNS (50), preserving active runs. Clean up WebSocket subscriptions for evicted runs via on_run_removed callback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 050e7f0 commit 54cb9ec

3 files changed

Lines changed: 39 additions & 0 deletions

File tree

src/uipath/dev/server/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def __init__(
8383
on_state=self._on_state,
8484
on_interrupt=self._on_interrupt,
8585
debug_bridge_factory=lambda mode: WebDebugBridge(mode=mode),
86+
on_run_removed=self.connection_manager.remove_run_subscriptions,
8687
)
8788

8889
def create_app(self) -> Any:

src/uipath/dev/server/ws/manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ def unsubscribe(self, websocket: WebSocket, run_id: str) -> None:
123123
if not self._subscriptions[run_id]:
124124
del self._subscriptions[run_id]
125125

126+
def remove_run_subscriptions(self, run_id: str) -> None:
127+
"""Remove all subscriptions for a run."""
128+
self._subscriptions.pop(run_id, None)
129+
126130
def broadcast_run_updated(self, run: ExecutionRun) -> None:
127131
"""Broadcast a run update to all subscribers (safe from sync context)."""
128132
msg = server_message(ServerEvent.RUN_UPDATED, serialize_run(run))

src/uipath/dev/services/run_service.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
from uipath.dev.models.execution import ExecutionMode, ExecutionRun
4646
from uipath.dev.services.chat_bridge import WebChatBridge
4747

48+
MAX_RUNS = 50
49+
4850
RunUpdatedCallback = Callable[[ExecutionRun], None]
4951
LogCallback = Callable[[LogData], None]
5052
TraceCallback = Callable[[TraceData], None]
@@ -98,6 +100,7 @@ def __init__(
98100
on_state: StateCallback | None = None,
99101
on_interrupt: InterruptCallback | None = None,
100102
debug_bridge_factory: DebugBridgeFactory | None = None,
103+
on_run_removed: Callable[[str], None] | None = None,
101104
) -> None:
102105
"""Initialize RunService with runtime factory and trace manager."""
103106
self.runtime_factory = runtime_factory
@@ -111,6 +114,7 @@ def __init__(
111114
self.on_state = on_state
112115
self.on_interrupt = on_interrupt
113116
self._debug_bridge_factory = debug_bridge_factory
117+
self._on_run_removed = on_run_removed
114118

115119
self._exporter = RunContextExporter(
116120
on_trace=self.handle_trace,
@@ -125,6 +129,36 @@ def register_run(self, run: ExecutionRun) -> None:
125129
"""Register a new run and emit an initial update."""
126130
self.runs[run.id] = run
127131
self._emit_run_updated(run)
132+
self._evict_old_runs()
133+
134+
def _evict_old_runs(self) -> None:
135+
"""Remove oldest terminal runs when total exceeds MAX_RUNS."""
136+
if len(self.runs) <= MAX_RUNS:
137+
return
138+
139+
active = []
140+
terminal = []
141+
for run in self.runs.values():
142+
if run.status in ("pending", "running", "suspended"):
143+
active.append(run)
144+
else:
145+
terminal.append(run)
146+
147+
keep_terminal = MAX_RUNS - len(active)
148+
if keep_terminal < 0:
149+
keep_terminal = 0
150+
151+
if len(terminal) <= keep_terminal:
152+
return
153+
154+
terminal.sort(
155+
key=lambda r: r.end_time or datetime.min.replace(tzinfo=timezone.utc),
156+
reverse=True,
157+
)
158+
for run in terminal[keep_terminal:]:
159+
del self.runs[run.id]
160+
if self._on_run_removed is not None:
161+
self._on_run_removed(run.id)
128162

129163
def get_run(self, run_id: str) -> ExecutionRun | None:
130164
"""Get a registered run."""

0 commit comments

Comments
 (0)