mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
fix(cloud): eliminate periodic runtime CPU spikes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user