From 32f1118a11b72018310daa3e6536f1946ee702ff Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 14 Mar 2026 16:08:28 -0400 Subject: [PATCH] Expose minimum_check_interval, reduce task pickup latency (#3500) * perf: expose minimum_check_interval, reduce task pickup latency The Docket Worker polls for new tasks every minimum_check_interval (previously hardcoded to 250ms in pydocket). Expose this setting so users can tune it, default to 50ms, and override to 10ms in tests. This cuts average task pickup latency from ~125ms to ~5ms per task. * perf: reduce task test overhead and eliminate cross-test contamination - Expose minimum_check_interval setting (default 50ms, 10ms in tests) to reduce Docket Worker task pickup latency - Isolate fakeredis per test via unique memory:// URLs to prevent stale _async_blocking tasks from contaminating subsequent tests - Make client disconnect timeout configurable (default 5s, 1s in tests) - Add --durations=50 to CI for passive performance regression detection - Remove 15s timeout band-aids from task test conftest files - Add explicit @pytest.mark.timeout(10) to cancellation tests - Fix deprecated FastMCP.as_proxy() usage in test_task_proxy.py --- .github/actions/run-pytest/action.yml | 1 + src/fastmcp/client/client.py | 7 +++++- src/fastmcp/server/mixins/lifespan.py | 1 + src/fastmcp/settings.py | 22 +++++++++++++++++++ tests/client/tasks/conftest.py | 16 +------------- tests/conftest.py | 13 ++++++++++- tests/server/tasks/conftest.py | 16 +------------- .../tasks/test_server_tasks_parameter.py | 3 +++ tests/server/tasks/test_task_methods.py | 1 + tests/server/tasks/test_task_mount.py | 1 + tests/server/tasks/test_task_proxy.py | 4 ++-- 11 files changed, 51 insertions(+), 34 deletions(-) diff --git a/.github/actions/run-pytest/action.yml b/.github/actions/run-pytest/action.yml index ff429a4cc..b1a2f3017 100644 --- a/.github/actions/run-pytest/action.yml +++ b/.github/actions/run-pytest/action.yml @@ -38,6 +38,7 @@ runs: uv run --no-sync pytest \ --inline-snapshot=disable \ --timeout=$TIMEOUT \ + --durations=50 \ -m "$MARKER" \ $PARALLEL_FLAGS \ $EXTRA_FLAGS \ diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 94cbbe169..17fb7be90 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -336,6 +336,11 @@ class Client( elicitation_handler ) + # Maximum time to wait for a clean disconnect before giving up. + # Normally disconnects complete in <100ms; this is a safety net for + # unresponsive servers. + self._disconnect_timeout: float = fastmcp.settings.client_disconnect_timeout + # Session context management - see class docstring for detailed explanation self._session_state = ClientSessionState() @@ -511,7 +516,7 @@ class Client( # Use a timeout to prevent hanging during cleanup if the connection is in a bad # state (e.g., rate-limited). The MCP SDK's transport may try to terminate the # session which can hang if the server is unresponsive. - with anyio.move_on_after(5): + with anyio.move_on_after(self._disconnect_timeout): await self._disconnect() async def _connect(self): diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py index 583944d61..a6c0cb9ed 100644 --- a/src/fastmcp/server/mixins/lifespan.py +++ b/src/fastmcp/server/mixins/lifespan.py @@ -105,6 +105,7 @@ class LifespanMixin: "concurrency": settings.docket.concurrency, "redelivery_timeout": settings.docket.redelivery_timeout, "reconnection_delay": settings.docket.reconnection_delay, + "minimum_check_interval": settings.docket.minimum_check_interval, } if settings.docket.worker_name: worker_kwargs["name"] = settings.docket.worker_name diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index d98d7ed00..393b1ff2f 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -117,6 +117,21 @@ class DocketSettings(BaseSettings): ), ] = timedelta(seconds=5) + minimum_check_interval: Annotated[ + timedelta, + Field( + description=inspect.cleandoc( + """ + How frequently the worker polls for new tasks. Lower + values reduce latency for task pickup at the cost of + more CPU usage. The default of 50ms is a good balance; + increase for high-volume production deployments where + tasks are long-running. + """ + ), + ), + ] = timedelta(milliseconds=50) + class Settings(BaseSettings): """FastMCP settings.""" @@ -231,6 +246,13 @@ class Settings(BaseSettings): ), ] = None + client_disconnect_timeout: Annotated[ + float, + Field( + description="Maximum time to wait for a clean disconnect before giving up, in seconds.", + ), + ] = 5 + # Transport settings transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio" diff --git a/tests/client/tasks/conftest.py b/tests/client/tasks/conftest.py index d0921e057..29d0c9a10 100644 --- a/tests/client/tasks/conftest.py +++ b/tests/client/tasks/conftest.py @@ -1,15 +1 @@ -"""Configuration for client task tests. - -Task tests require Docket infrastructure (Redis-backed task queue) which can -take significant time to initialize, especially under parallel test execution. -The default 5s timeout is too tight for these tests. -""" - -import pytest - - -def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: - """Increase timeout for task tests that need Docket infrastructure.""" - for item in items: - if not item.get_closest_marker("timeout"): - item.add_marker(pytest.mark.timeout(15)) +"""Configuration for client task tests.""" diff --git a/tests/conftest.py b/tests/conftest.py index 1401d4141..1edf55c60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ import asyncio import logging +import secrets import socket import sys from collections.abc import Callable, Generator +from datetime import timedelta from pathlib import Path from typing import Any @@ -60,11 +62,20 @@ def isolate_settings_home(tmp_path: Path): This prevents file locking issues when multiple tests share the same storage directory in settings.home / "oauth-proxy". + + Also sets a fast Docket polling interval for tests — the default 50ms + is fine for production but still adds ~25ms average pickup latency per + task. 10ms makes task tests near-instant. """ test_home = tmp_path / "fastmcp-test-home" test_home.mkdir(exist_ok=True) - with temporary_settings(home=test_home): + with temporary_settings( + home=test_home, + docket__minimum_check_interval=timedelta(milliseconds=10), + docket__url=f"memory://{secrets.token_hex(4)}", + client_disconnect_timeout=1, + ): yield diff --git a/tests/server/tasks/conftest.py b/tests/server/tasks/conftest.py index 7d48204bb..496053bfa 100644 --- a/tests/server/tasks/conftest.py +++ b/tests/server/tasks/conftest.py @@ -1,15 +1 @@ -"""Configuration for server task tests. - -Task tests require Docket infrastructure (Redis-backed task queue) which can -take significant time to initialize, especially under parallel test execution. -The default 5s timeout is too tight for these tests. -""" - -import pytest - - -def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: - """Increase timeout for task tests that need Docket infrastructure.""" - for item in items: - if not item.get_closest_marker("timeout"): - item.add_marker(pytest.mark.timeout(15)) +"""Configuration for server task tests.""" diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py index 8bb0ec6c8..af3d16641 100644 --- a/tests/server/tasks/test_server_tasks_parameter.py +++ b/tests/server/tasks/test_server_tasks_parameter.py @@ -6,10 +6,13 @@ components (tools, prompts, resources), and that explicit component-level settings properly override the server default. """ +import pytest + from fastmcp import FastMCP from fastmcp.client import Client +@pytest.mark.timeout(10) async def test_server_tasks_true_defaults_all_components(): """Server with tasks=True makes all components default to supporting tasks.""" mcp = FastMCP("test", tasks=True) diff --git a/tests/server/tasks/test_task_methods.py b/tests/server/tasks/test_task_methods.py index 9dd4d7a62..07bef01ce 100644 --- a/tests/server/tasks/test_task_methods.py +++ b/tests/server/tasks/test_task_methods.py @@ -174,6 +174,7 @@ async def test_task_cancellation_workflow(endpoint_server): assert status.status == "cancelled" +@pytest.mark.timeout(10) async def test_task_cancellation_interrupts_running_coroutine(endpoint_server): """Task cancellation actually interrupts the running coroutine. diff --git a/tests/server/tasks/test_task_mount.py b/tests/server/tasks/test_task_mount.py index b601792e2..b20cc4d32 100644 --- a/tests/server/tasks/test_task_mount.py +++ b/tests/server/tasks/test_task_mount.py @@ -151,6 +151,7 @@ class TestMountedToolTasks: status = await task.status() assert status.status == "completed" + @pytest.mark.timeout(10) async def test_mounted_tool_task_cancellation(self, parent_server): """Can cancel a mounted tool task.""" async with Client(parent_server) as client: diff --git a/tests/server/tasks/test_task_proxy.py b/tests/server/tasks/test_task_proxy.py index f1b41a3dc..ce20a9a7b 100644 --- a/tests/server/tasks/test_task_proxy.py +++ b/tests/server/tasks/test_task_proxy.py @@ -17,7 +17,7 @@ from mcp.types import TextContent, TextResourceContents from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport -from fastmcp.server.providers.proxy import ProxyClient +from fastmcp.server import create_proxy @pytest.fixture @@ -60,7 +60,7 @@ def backend_server() -> FastMCP: @pytest.fixture def proxy_server(backend_server: FastMCP) -> FastMCP: """Create a proxy server that forwards to the backend.""" - return FastMCP.as_proxy(ProxyClient(transport=FastMCPTransport(backend_server))) + return create_proxy(FastMCPTransport(backend_server)) class TestProxyToolsSyncExecution: