fix(cloud): eliminate periodic runtime CPU spikes

This commit is contained in:
Junyan Qin
2026-07-29 12:47:53 +08:00
parent aa342d9347
commit e52d6880f5
14 changed files with 372 additions and 66 deletions
@@ -259,6 +259,7 @@ class DirectoryProjectionService:
await session.flush()
await self._reconcile_entitlement_snapshot_set(snapshot)
self._publish_runtime_execution_projection(snapshot.workspaces)
self._record_success()
self._consumer_cursor = snapshot.cursor
@@ -349,10 +350,49 @@ class DirectoryProjectionService:
returned.values(),
requested_workspace_uuids=requested,
)
self._publish_runtime_execution_projection(
returned.values(),
affected_workspace_uuids=requested,
)
if projection_caught_up:
self._record_success()
self._consumer_cursor = batch.cursor
def _publish_runtime_execution_projection(
self,
workspaces: Iterable[DirectoryWorkspace],
*,
affected_workspace_uuids: set[str] | None = None,
) -> None:
"""Retire stale runtime scopes without per-session database polling.
The signed directory transaction is already committed when this hook
runs. Runtime calls still validate the database fence before and after
side effects; this notification only releases idle resources promptly.
"""
tool_manager = getattr(self.ap, 'tool_mgr', None)
mcp_loader = getattr(tool_manager, 'mcp_tool_loader', None)
reconcile = getattr(mcp_loader, 'reconcile_execution_projection', None)
if not callable(reconcile):
return
active_generations = {
workspace.uuid: workspace.execution_generation
for workspace in workspaces
if workspace.status == WorkspaceStatus.ACTIVE.value
}
try:
reconcile(
self.instance_uuid,
active_generations,
affected_workspace_uuids=affected_workspace_uuids,
)
except Exception:
# Runtime retirement is a resource cleanup path, not an execution
# admission boundary. Database-backed call-time fences remain
# authoritative if a local runtime hook fails.
self.ap.logger.exception('Failed to publish the Cloud execution projection to MCP runtimes')
async def _reconcile_entitlement_snapshot_set(
self,
snapshot: DirectorySnapshot,
+20
View File
@@ -231,6 +231,14 @@ class Application:
'bots': len(getattr(self.platform_mgr, '_bots_by_key', {})),
'pipelines': len(getattr(self.pipeline_mgr, '_pipelines_by_key', {})),
'knowledge_bases': len(getattr(self.rag_mgr, 'knowledge_bases', {})),
'message_aggregation_buffers': len(getattr(self.msg_aggregator, 'buffers', {})),
'message_aggregation_scopes': len(
getattr(
self.msg_aggregator,
'_buffer_counts_by_scope',
{},
)
),
'plugin_installations': len(
getattr(
self.plugin_connector,
@@ -245,6 +253,18 @@ class Application:
'mcp_sessions': len(getattr(mcp_loader, '_sessions', {})),
'mcp_host_tasks': len(getattr(mcp_loader, '_hosted_mcp_tasks', ())),
'mcp_dispatch_tasks': len(getattr(mcp_loader, '_host_dispatch_tasks', ())),
'mcp_projection_retirements': len(getattr(mcp_loader, '_pending_projection_retirements', ())),
'mcp_projection_reconcile_active': int(
(
projection_task := getattr(
mcp_loader,
'_projection_reconcile_task',
None,
)
)
is not None
and not projection_task.done()
),
}
)
+19 -6
View File
@@ -71,6 +71,10 @@ class MessageAggregator:
def __init__(self, ap: app.Application):
self.ap = ap
self.buffers = {}
self._buffer_counts_by_scope: dict[
tuple[str, str, int],
int,
] = {}
self.lock = asyncio.Lock()
concurrency = self.ap.instance_config.data.get('concurrency', {})
self.max_buffers = max(int(concurrency.get('pending_queries', 1000)), 1)
@@ -194,12 +198,14 @@ class MessageAggregator:
async with self.lock:
buffer = self.buffers.get(aggregation_key)
if buffer is None:
workspace_buffer_count = sum(
1
for key in self.buffers
if key[0] == execution_context.instance_uuid
and key[1] == execution_context.workspace_uuid
and key[2] == execution_context.placement_generation
scope_key = (
execution_context.instance_uuid,
execution_context.workspace_uuid,
execution_context.placement_generation,
)
workspace_buffer_count = self._buffer_counts_by_scope.get(
scope_key,
0,
)
if len(self.buffers) >= self.max_buffers or workspace_buffer_count >= self.max_buffers_per_workspace:
bypass_aggregation = True
@@ -210,6 +216,7 @@ class MessageAggregator:
messages=[pending_msg],
)
self.buffers[aggregation_key] = buffer
self._buffer_counts_by_scope[scope_key] = workspace_buffer_count + 1
else:
if buffer.execution_context != execution_context:
raise ExecutionContextMismatchError('Aggregation buffer ExecutionContext changed for the same key')
@@ -282,6 +289,12 @@ class MessageAggregator:
if buffer.execution_context != execution_context:
raise ExecutionContextMismatchError('Timer ExecutionContext does not match the aggregation buffer')
self.buffers.pop(aggregation_key)
scope_key = aggregation_key[:3]
scope_count = self._buffer_counts_by_scope.get(scope_key, 0)
if scope_count <= 1:
self._buffer_counts_by_scope.pop(scope_key, None)
else:
self._buffer_counts_by_scope[scope_key] = scope_count - 1
if not buffer.messages:
return
+64 -20
View File
@@ -233,8 +233,6 @@ class MCPToolCallTimeoutError(TimeoutError):
class RuntimeMCPSession:
"""运行时 MCP 会话"""
_FENCE_POLL_INTERVAL = 5.0
ap: app.Application
server_name: str
@@ -362,15 +360,6 @@ class RuntimeMCPSession:
if binding.instance_uuid != self.execution_context.instance_uuid:
raise WorkspaceInvariantError('MCP session instance does not match the active Workspace binding')
async def _monitor_execution_fence(self) -> None:
"""Poll the placement fence while an MCP transport is idle."""
while not self._shutdown_event.is_set():
await asyncio.sleep(self._FENCE_POLL_INTERVAL)
if self._shutdown_event.is_set():
return
await self._assert_execution_active()
async def _sleep_with_execution_fence(self, delay: float) -> None:
"""Back off without reconnecting after the captured placement expires."""
@@ -576,16 +565,13 @@ class RuntimeMCPSession:
monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health())
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
fence_task = asyncio.create_task(self._monitor_execution_fence())
done, pending = await asyncio.wait(
[shutdown_task, monitor_task, reconnect_task, fence_task],
[shutdown_task, monitor_task, reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
if fence_task in done and not self._shutdown_event.is_set():
fence_task.result()
if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear()
self.ap.logger.info(
@@ -621,16 +607,13 @@ class RuntimeMCPSession:
else:
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
fence_task = asyncio.create_task(self._monitor_execution_fence())
done, pending = await asyncio.wait(
[shutdown_task, reconnect_task, fence_task],
[shutdown_task, reconnect_task],
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
if fence_task in done and not self._shutdown_event.is_set():
fence_task.result()
if reconnect_task in done and not self._shutdown_event.is_set():
self._reconnect_event.clear()
self.ap.logger.info(
@@ -1550,6 +1533,8 @@ class MCPLoader(loader.ToolLoader):
set[asyncio.Task],
] = {}
self._host_dispatch_tasks: set[asyncio.Task] = set()
self._pending_projection_retirements: set[tuple[str, str, int]] = set()
self._projection_reconcile_task: asyncio.Task[None] | None = None
config = getattr(getattr(ap, 'instance_config', None), 'data', {})
mcp_config = config.get('mcp', {}) if isinstance(config, dict) else {}
raw_lifecycle_concurrency = mcp_config.get('lifecycle_concurrency', 16) if isinstance(mcp_config, dict) else 16
@@ -1691,7 +1676,59 @@ class MCPLoader(loader.ToolLoader):
keys = tuple(self._session_keys_by_scope.pop(scope_key, ()))
sessions = [session for key in keys if (session := self._sessions.pop(key, None)) is not None]
await self._shutdown_sessions(sessions)
self._scope_generations.pop(scope_key[:2], None)
if self._scope_generations.get(scope_key[:2]) == scope_key[2]:
self._scope_generations.pop(scope_key[:2], None)
def reconcile_execution_projection(
self,
instance_uuid: str,
active_generations: typing.Mapping[str, int],
*,
affected_workspace_uuids: typing.Iterable[str] | None = None,
) -> None:
"""Queue stale MCP scopes for one coalesced, bounded cleanup worker."""
affected = None if affected_workspace_uuids is None else set(affected_workspace_uuids)
for workspace_scope, generation in tuple(self._scope_generations.items()):
scoped_instance_uuid, workspace_uuid = workspace_scope
if scoped_instance_uuid != instance_uuid:
continue
if affected is not None and workspace_uuid not in affected:
continue
if active_generations.get(workspace_uuid) == generation:
continue
self._pending_projection_retirements.add((*workspace_scope, generation))
if not self._pending_projection_retirements:
return
if self._projection_reconcile_task is not None and not self._projection_reconcile_task.done():
return
task = asyncio.create_task(
self._drain_projection_retirements(),
name='mcp-projection-reconcile',
)
self._projection_reconcile_task = task
task.add_done_callback(self._projection_reconcile_done)
async def _drain_projection_retirements(self) -> None:
while self._pending_projection_retirements:
scope_key = next(iter(self._pending_projection_retirements))
self._pending_projection_retirements.discard(scope_key)
await self._retire_runtime_scope(scope_key)
def _projection_reconcile_done(
self,
completed: asyncio.Task[None],
) -> None:
if self._projection_reconcile_task is completed:
self._projection_reconcile_task = None
if completed.cancelled():
return
exception = completed.exception()
if exception is not None:
self.ap.logger.error(
f'MCP projection reconciliation failed: {exception}',
)
async def _observe_execution_context(
self,
@@ -1713,6 +1750,13 @@ class MCPLoader(loader.ToolLoader):
async def _reset_runtime_state(self) -> None:
"""Cancel host tasks and close sessions before reload or shutdown."""
projection_task = self._projection_reconcile_task
self._projection_reconcile_task = None
self._pending_projection_retirements.clear()
if projection_task is not None and not projection_task.done():
projection_task.cancel()
await asyncio.gather(projection_task, return_exceptions=True)
dispatch_tasks = tuple(self._host_dispatch_tasks)
self._host_dispatch_tasks.clear()
for task in dispatch_tasks: