mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* fix(scheduler): remove a request's queue entry once it stops waiting Requests admitted while a healthy deployment existed never left the priority queue, and neither did requests cancelled while waiting. The stale entries blocked later requests during cooldown and, with Redis, made add_request raise a TypeError on queues read back as JSON lists Both scheduling paths now share one polling helper that removes the entry in a finally block, whether the request was admitted, timed out or cancelled Related to #43059 * test(router): allowlist _wait_for_scheduler_turn in the router coverage check The coverage script only counts direct calls in test files. The helper is exercised through prioritized acompletion and atext_completion in test_router.py, like the other allowlisted entries * fix(scheduler): admit healthy requests before reading the queue, and clean up cancelled enqueues poll() raised on an empty queue before checking for healthy deployments. With the cleanup now rewriting the queue after every admission, a concurrent write from another replica can erase a waiting request's entry, and that request then failed while a deployment was healthy. poll() now admits as soon as a deployment is healthy and only reads the queue during cooldown add_request also moved inside the try block, so a request cancelled while its queue write is in flight still has its entry removed * refactor(scheduler): move the wait loop into Scheduler.wait_for_turn The router passes a healthy-deployments callable into the scheduler, so tests inject a Scheduler directly instead of replacing the router's scheduler attribute. The cancelled-mid-enqueue test moves to test_scheduler.py with the other scheduler tests, and poll() takes the deployments as a Sequence since it only checks whether any are healthy * test(scheduler): move scheduler tests into tests/unit * fix(scheduler): finish the queue removal when cancellation is delivered again during cleanup A second cancel, or an anyio cancel scope that re-cancels on every await, interrupted remove_request mid-write and left the entry in Redis. * fix(scheduler): read queue entries back from redis as tuples * fix(scheduler): admit a request whose queue entry vanished during cooldown * fix(scheduler): re-enqueue a request whose queue entry vanished during cooldown * test(integration): cover priority scheduler queue cleanup across instances Adds tests/integration/routing/test_priority_scheduler_queue_cleanup.py: 27 cells against the real proxy (two workers, Postgres, Redis, a scripted upstream) for every prioritized surface (/v1/chat/completions, /v1/completions, /queue/chat/completions, streaming and not, OpenAI SDK sync and async, raw httpx), the non-integer priority pass-through, the in-memory queue on one proxy, two proxies sharing a Redis queue (served requests leave no entry, a dead replica's entry is skipped or expires, a waiter that times out or disconnects removes only itself), a Redis outage mid burst and a SIGKILLed worker. Each cell asserts the caller's response, the upstream's requests by marker and the Redis queue contents. On the merge base the cross-instance cells fail with the list-of-lists TypeError and the in-memory cell with 408s behind the leaked entry; on this branch every cell passes --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
436 lines
16 KiB
Python
436 lines
16 KiB
Python
# What is this?
|
|
## Unit tests for the Scheduler.py (workload prioritization scheduler)
|
|
|
|
import asyncio
|
|
import importlib
|
|
import json
|
|
import os
|
|
from collections.abc import Sequence
|
|
from typing import Final
|
|
|
|
import pytest
|
|
|
|
import litellm
|
|
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
|
from litellm.scheduler import FlowItem, Scheduler, SchedulerCacheKeys
|
|
from litellm.utils import _invalidate_model_cost_lowercase_map
|
|
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scheduler_diff_model_names():
|
|
"""
|
|
Assert 2 requests to 2 diff model groups are top of their respective queue's
|
|
"""
|
|
scheduler = Scheduler()
|
|
|
|
item1 = FlowItem(priority=0, request_id="10", model_name="gpt-3.5-turbo")
|
|
item2 = FlowItem(priority=0, request_id="11", model_name="gpt-4")
|
|
await scheduler.add_request(item1)
|
|
await scheduler.add_request(item2)
|
|
|
|
assert await scheduler.poll(request=item1, health_deployments=[{"key": "value"}]) == True
|
|
assert await scheduler.poll(request=item2, health_deployments=[{"key": "value"}]) == True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scheduler_poll_persists_queue_to_cache():
|
|
class StubRedisCache:
|
|
def __init__(self):
|
|
self.store = {}
|
|
|
|
async def async_get_cache(self, key, **kwargs):
|
|
return self.store.get(key)
|
|
|
|
async def async_set_cache(self, key, value, **kwargs):
|
|
self.store[key] = value
|
|
|
|
redis_cache = StubRedisCache()
|
|
scheduler = Scheduler(redis_cache=redis_cache)
|
|
|
|
item1 = FlowItem(priority=0, request_id="10", model_name="gpt-3.5-turbo")
|
|
item2 = FlowItem(priority=0, request_id="11", model_name="gpt-3.5-turbo")
|
|
await scheduler.add_request(item1)
|
|
await scheduler.add_request(item2)
|
|
|
|
await scheduler.poll(request=item1, health_deployments=[])
|
|
|
|
queue_key = f"{SchedulerCacheKeys.queue.value}:{item1.model_name}"
|
|
updated_queue = redis_cache.store[queue_key]
|
|
assert updated_queue[0][1] == "11"
|
|
|
|
|
|
@pytest.mark.parametrize("p0, p1", [(0, 0), (0, 1), (1, 0)])
|
|
@pytest.mark.parametrize("healthy_deployments", [[{"key": "value"}], []])
|
|
@pytest.mark.asyncio
|
|
async def test_scheduler_prioritized_requests(p0, p1, healthy_deployments):
|
|
"""
|
|
2 requests for same model group
|
|
"""
|
|
scheduler = Scheduler()
|
|
|
|
item1 = FlowItem(priority=p0, request_id="10", model_name="gpt-3.5-turbo")
|
|
item2 = FlowItem(priority=p1, request_id="11", model_name="gpt-3.5-turbo")
|
|
await scheduler.add_request(item1)
|
|
await scheduler.add_request(item2)
|
|
|
|
if p0 == 0:
|
|
assert (
|
|
await scheduler.peek(
|
|
id="10",
|
|
model_name="gpt-3.5-turbo",
|
|
health_deployments=healthy_deployments,
|
|
)
|
|
== True
|
|
), "queue={}".format(await scheduler.get_queue(model_name="gpt-3.5-turbo"))
|
|
assert (
|
|
await scheduler.peek(
|
|
id="11",
|
|
model_name="gpt-3.5-turbo",
|
|
health_deployments=healthy_deployments,
|
|
)
|
|
== False
|
|
)
|
|
else:
|
|
assert (
|
|
await scheduler.peek(
|
|
id="11",
|
|
model_name="gpt-3.5-turbo",
|
|
health_deployments=healthy_deployments,
|
|
)
|
|
== True
|
|
)
|
|
assert (
|
|
await scheduler.peek(
|
|
id="10",
|
|
model_name="gpt-3.5-turbo",
|
|
health_deployments=healthy_deployments,
|
|
)
|
|
== False
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scheduler_queue_cleanup_on_timeout():
|
|
"""
|
|
Test that a timed-out request is properly removed from the queue.
|
|
This prevents memory leaks from accumulating timed-out requests.
|
|
"""
|
|
scheduler = Scheduler()
|
|
|
|
# Add multiple requests with different priorities
|
|
item1 = FlowItem(priority=0, request_id="req-0", model_name="gpt-3.5-turbo")
|
|
item2 = FlowItem(priority=1, request_id="req-1", model_name="gpt-3.5-turbo")
|
|
item3 = FlowItem(priority=2, request_id="req-2", model_name="gpt-3.5-turbo")
|
|
|
|
await scheduler.add_request(item1)
|
|
await scheduler.add_request(item2)
|
|
await scheduler.add_request(item3)
|
|
|
|
# Verify initial queue size
|
|
queue_before = await scheduler.get_queue(model_name="gpt-3.5-turbo")
|
|
assert len(queue_before) == 3, f"Expected 3 items in queue, got {len(queue_before)}"
|
|
|
|
# Simulate timeout cleanup - remove a non-front request (item2)
|
|
await scheduler.remove_request(request_id="req-1", model_name="gpt-3.5-turbo")
|
|
|
|
# Verify queue was cleaned up
|
|
queue_after = await scheduler.get_queue(model_name="gpt-3.5-turbo")
|
|
assert len(queue_after) == 2, f"Expected 2 items after cleanup, got {len(queue_after)}"
|
|
|
|
# Verify the correct request was removed
|
|
remaining_ids = [item[1] for item in queue_after]
|
|
assert "req-1" not in remaining_ids, "Expected req-1 to be removed"
|
|
assert "req-0" in remaining_ids, "Expected req-0 to remain"
|
|
assert "req-2" in remaining_ids, "Expected req-2 to remain"
|
|
|
|
# Verify remaining items are in correct priority order (0 should be first)
|
|
assert queue_after[0][1] == "req-0", "Expected req-0 (priority 0) to be at front"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_admits_request_missing_from_queue_while_a_deployment_is_healthy():
|
|
scheduler: Final = Scheduler()
|
|
|
|
assert await scheduler.poll(
|
|
request=FlowItem(priority=1, request_id="erased-by-concurrent-write", model_name="sched-model"),
|
|
health_deployments=[{"model_info": {"id": "a"}}],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_during_cooldown_admits_only_the_head_of_the_queue():
|
|
scheduler: Final = Scheduler()
|
|
later: Final = FlowItem(priority=2, request_id="later", model_name="sched-model")
|
|
head: Final = FlowItem(priority=1, request_id="head", model_name="sched-model")
|
|
await scheduler.add_request(later)
|
|
await scheduler.add_request(head)
|
|
|
|
assert not await scheduler.poll(request=later, health_deployments=[])
|
|
assert await scheduler.poll(request=head, health_deployments=[])
|
|
assert await scheduler.get_queue("sched-model") == [(2, "later")]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_during_cooldown_re_enqueues_a_request_a_concurrent_writer_erased_behind_the_head():
|
|
scheduler: Final = Scheduler()
|
|
still_queued: Final = FlowItem(priority=0, request_id="still-queued", model_name="sched-model")
|
|
erased: Final = FlowItem(priority=1, request_id="erased-by-concurrent-write", model_name="sched-model")
|
|
await scheduler.add_request(still_queued)
|
|
|
|
assert not await scheduler.poll(request=erased, health_deployments=[])
|
|
assert await scheduler.get_queue("sched-model") == [(0, "still-queued"), (1, "erased-by-concurrent-write")]
|
|
assert await scheduler.poll(request=still_queued, health_deployments=[])
|
|
assert await scheduler.poll(request=erased, health_deployments=[])
|
|
assert await scheduler.get_queue("sched-model") == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_poll_during_cooldown_admits_an_erased_request_that_outranks_the_queue():
|
|
scheduler: Final = Scheduler()
|
|
await scheduler.add_request(FlowItem(priority=2, request_id="still-queued", model_name="sched-model"))
|
|
|
|
assert await scheduler.poll(
|
|
request=FlowItem(priority=0, request_id="erased-urgent", model_name="sched-model"), health_deployments=[]
|
|
)
|
|
assert await scheduler.get_queue("sched-model") == [(2, "still-queued")]
|
|
|
|
|
|
class _ExpiringCache:
|
|
def __init__(self) -> None:
|
|
self.store: dict[str, object] = {}
|
|
|
|
async def async_get_cache(self, key: str, **kwargs: object) -> object:
|
|
return self.store.get(key)
|
|
|
|
async def async_set_cache(self, key: str, value: object, **kwargs: object) -> None:
|
|
self.store[key] = value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_turn_during_cooldown_survives_the_queue_key_expiring():
|
|
cache: Final = _ExpiringCache()
|
|
scheduler: Final = Scheduler(redis_cache=cache)
|
|
scheduler.cache.in_memory_cache.cache_dict.clear()
|
|
|
|
async def no_healthy_deployments_after_the_key_expired() -> Sequence[object]:
|
|
cache.store.clear()
|
|
scheduler.cache.in_memory_cache.cache_dict.clear()
|
|
return ()
|
|
|
|
await scheduler.wait_for_turn(
|
|
request=FlowItem(priority=1, request_id="sole-waiter", model_name="sched-model"),
|
|
timeout=5,
|
|
get_healthy_deployments=no_healthy_deployments_after_the_key_expired,
|
|
)
|
|
|
|
assert await scheduler.get_queue("sched-model") == []
|
|
|
|
|
|
|
|
class _JsonRoundTripRedisCache:
|
|
def __init__(self) -> None:
|
|
self.store: dict[str, str] = {}
|
|
|
|
async def async_get_cache(self, key: str, **kwargs: object) -> object:
|
|
raw: Final = self.store.get(key)
|
|
return None if raw is None else json.loads(raw)
|
|
|
|
async def async_set_cache(self, key: str, value: object, **kwargs: object) -> None:
|
|
self.store[key] = json.dumps(value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_second_replica_enqueues_behind_a_queue_decoded_from_redis():
|
|
redis_cache: Final = _JsonRoundTripRedisCache()
|
|
replica_a: Final = Scheduler(redis_cache=redis_cache)
|
|
replica_b: Final = Scheduler(redis_cache=redis_cache)
|
|
waiting_on_a: Final = FlowItem(priority=1, request_id="waiting-on-a", model_name="sched-model")
|
|
urgent_on_b: Final = FlowItem(priority=0, request_id="urgent-on-b", model_name="sched-model")
|
|
await replica_a.add_request(waiting_on_a)
|
|
|
|
await replica_b.add_request(urgent_on_b)
|
|
|
|
assert await replica_b.get_queue("sched-model") == [(0, "urgent-on-b"), (1, "waiting-on-a")]
|
|
assert await replica_b.poll(request=urgent_on_b, health_deployments=[])
|
|
assert await replica_b.poll(request=waiting_on_a, health_deployments=[])
|
|
await replica_b.remove_request(request_id="waiting-on-a", model_name="sched-model")
|
|
assert await replica_b.get_queue("sched-model") == []
|
|
|
|
class _PausesAfterEnqueueScheduler(Scheduler):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.enqueued: Final = asyncio.Event()
|
|
|
|
async def add_request(self, request: FlowItem) -> None:
|
|
await super().add_request(request)
|
|
self.enqueued.set()
|
|
await asyncio.Event().wait()
|
|
|
|
|
|
async def _no_healthy_deployments() -> Sequence[object]:
|
|
return ()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_turn_removes_entry_when_cancelled_mid_enqueue():
|
|
scheduler: Final = _PausesAfterEnqueueScheduler()
|
|
waiting: Final = asyncio.create_task(
|
|
scheduler.wait_for_turn(
|
|
request=FlowItem(priority=1, request_id="cancelled", model_name="sched-model"),
|
|
timeout=5,
|
|
get_healthy_deployments=_no_healthy_deployments,
|
|
)
|
|
)
|
|
await scheduler.enqueued.wait()
|
|
assert await scheduler.get_queue("sched-model") == [(1, "cancelled")]
|
|
|
|
waiting.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await waiting
|
|
|
|
assert await scheduler.get_queue("sched-model") == []
|
|
|
|
|
|
class _HeldRemovalScheduler(Scheduler):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.removing: Final = asyncio.Event()
|
|
self.finish_removal: Final = asyncio.Event()
|
|
|
|
async def remove_request(self, request_id: str, model_name: str) -> None:
|
|
self.removing.set()
|
|
await self.finish_removal.wait()
|
|
await super().remove_request(request_id=request_id, model_name=model_name)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_for_turn_finishes_removal_when_cancelled_again_during_cleanup():
|
|
scheduler: Final = _HeldRemovalScheduler()
|
|
await scheduler.add_request(FlowItem(priority=0, request_id="head", model_name="sched-model"))
|
|
polling: Final = asyncio.Event()
|
|
|
|
async def no_healthy_deployments() -> Sequence[object]:
|
|
polling.set()
|
|
return ()
|
|
|
|
waiting: Final = asyncio.create_task(
|
|
scheduler.wait_for_turn(
|
|
request=FlowItem(priority=1, request_id="cancelled", model_name="sched-model"),
|
|
timeout=5,
|
|
get_healthy_deployments=no_healthy_deployments,
|
|
)
|
|
)
|
|
await polling.wait()
|
|
waiting.cancel()
|
|
await scheduler.removing.wait()
|
|
waiting.cancel()
|
|
scheduler.finish_removal.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await waiting
|
|
|
|
assert await scheduler.get_queue("sched-model") == [(0, "head")]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _vcr_outcome_gate(request, vcr):
|
|
install_live_call_probe(request, vcr)
|
|
yield
|
|
record_vcr_outcome(request, vcr)
|
|
|
|
|
|
@pytest.fixture(scope="function", autouse=True)
|
|
def isolate_litellm_state():
|
|
"""
|
|
Per-function isolation fixture.
|
|
|
|
Resets litellm globals to their true defaults before each test and
|
|
restores them afterward, so tests don't leak side effects.
|
|
Works safely under pytest-xdist parallel execution.
|
|
"""
|
|
original_state = {}
|
|
for attr in (
|
|
"callbacks",
|
|
"success_callback",
|
|
"failure_callback",
|
|
"_async_success_callback",
|
|
"_async_failure_callback",
|
|
):
|
|
if hasattr(litellm, attr):
|
|
val = getattr(litellm, attr)
|
|
original_state[attr] = val.copy() if val else []
|
|
for attr in ("pre_call_rules", "post_call_rules"):
|
|
if hasattr(litellm, attr):
|
|
val = getattr(litellm, attr)
|
|
original_state[attr] = val.copy() if val else []
|
|
for attr in _SCALAR_DEFAULTS:
|
|
if hasattr(litellm, attr):
|
|
original_state[attr] = getattr(litellm, attr)
|
|
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
for attr in (
|
|
"callbacks",
|
|
"success_callback",
|
|
"failure_callback",
|
|
"_async_success_callback",
|
|
"_async_failure_callback",
|
|
"pre_call_rules",
|
|
"post_call_rules",
|
|
):
|
|
if hasattr(litellm, attr):
|
|
setattr(litellm, attr, [])
|
|
for attr, default_val in _SCALAR_DEFAULTS.items():
|
|
if hasattr(litellm, attr):
|
|
setattr(litellm, attr, default_val)
|
|
yield
|
|
asyncio.run(GLOBAL_LOGGING_WORKER.clear_queue())
|
|
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
for attr, original_value in original_state.items():
|
|
if hasattr(litellm, attr):
|
|
setattr(litellm, attr, original_value)
|
|
_invalidate_model_cost_lowercase_map()
|
|
|
|
|
|
_SCALAR_DEFAULTS = {
|
|
"num_retries": getattr(litellm, "num_retries", None),
|
|
"num_retries_per_request": getattr(litellm, "num_retries_per_request", None),
|
|
"request_timeout": getattr(litellm, "request_timeout", None),
|
|
"set_verbose": getattr(litellm, "set_verbose", False),
|
|
"cache": getattr(litellm, "cache", None),
|
|
"allowed_fails": getattr(litellm, "allowed_fails", 3),
|
|
"default_fallbacks": getattr(litellm, "default_fallbacks", None),
|
|
"enable_azure_ad_token_refresh": getattr(litellm, "enable_azure_ad_token_refresh", None),
|
|
"tag_budget_config": getattr(litellm, "tag_budget_config", None),
|
|
"model_cost": getattr(litellm, "model_cost", None),
|
|
"token_counter": getattr(litellm, "token_counter", None),
|
|
"disable_aiohttp_transport": getattr(litellm, "disable_aiohttp_transport", False),
|
|
"force_ipv4": getattr(litellm, "force_ipv4", False),
|
|
"drop_params": getattr(litellm, "drop_params", None),
|
|
"modify_params": getattr(litellm, "modify_params", False),
|
|
"api_base": getattr(litellm, "api_base", None),
|
|
"api_key": getattr(litellm, "api_key", None),
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def setup_and_teardown():
|
|
"""
|
|
Module-scoped setup. Reloads litellm only in single-process mode
|
|
(skipped under xdist to avoid cross-worker interference).
|
|
"""
|
|
import litellm
|
|
|
|
worker_id = os.environ.get("PYTEST_XDIST_WORKER", None)
|
|
if worker_id is None:
|
|
importlib.reload(litellm)
|
|
try:
|
|
if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"):
|
|
import litellm.proxy.proxy_server
|
|
|
|
importlib.reload(litellm.proxy.proxy_server)
|
|
except Exception:
|
|
pass
|
|
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
yield
|