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
This commit is contained in:
Jeremiah Lowin 2026-03-14 16:08:28 -04:00 committed by GitHub
commit 32f1118a11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 51 additions and 34 deletions

View file

@ -38,6 +38,7 @@ runs:
uv run --no-sync pytest \
--inline-snapshot=disable \
--timeout=$TIMEOUT \
--durations=50 \
-m "$MARKER" \
$PARALLEL_FLAGS \
$EXTRA_FLAGS \

View file

@ -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):

View file

@ -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

View file

@ -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"

View file

@ -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."""

View file

@ -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

View file

@ -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."""

View file

@ -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)

View file

@ -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.

View file

@ -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:

View file

@ -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: