mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +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>
169 lines
6.7 KiB
Python
169 lines
6.7 KiB
Python
import asyncio
|
|
import enum
|
|
import heapq
|
|
import time
|
|
from collections.abc import Awaitable, Callable, Sequence
|
|
from typing import Final, TypeAlias
|
|
|
|
from pydantic import TypeAdapter
|
|
|
|
from litellm import print_verbose
|
|
from litellm._internal_context import with_service_target
|
|
from litellm.caching.caching import DualCache, RedisCache
|
|
from litellm.constants import DEFAULT_IN_MEMORY_TTL, DEFAULT_POLLING_INTERVAL
|
|
from litellm.exceptions import Timeout
|
|
from litellm.types.llms.base import LiteLLMBaseModel
|
|
|
|
SCHEDULER_QUEUE_TARGET: Final = "scheduler_queue"
|
|
QueueEntry: TypeAlias = tuple[int, str]
|
|
_QUEUE_ENTRIES: Final = TypeAdapter(list[QueueEntry])
|
|
|
|
|
|
class SchedulerCacheKeys(enum.Enum):
|
|
queue = "scheduler:queue"
|
|
default_in_memory_ttl = DEFAULT_IN_MEMORY_TTL # cache queue in-memory for 5s when redis cache available
|
|
|
|
|
|
class FlowItem(LiteLLMBaseModel):
|
|
priority: int # Priority between 0 and 255
|
|
request_id: str
|
|
model_name: str
|
|
|
|
|
|
class Scheduler:
|
|
cache: DualCache
|
|
|
|
def __init__(
|
|
self,
|
|
polling_interval: float | None = None,
|
|
redis_cache: RedisCache | None = None,
|
|
):
|
|
"""
|
|
polling_interval: float or null - frequency of polling queue. Default is 3ms.
|
|
"""
|
|
self.queue: list[QueueEntry] = []
|
|
default_in_memory_ttl: float | None = None
|
|
if redis_cache is not None:
|
|
# if redis-cache available frequently poll that instead of using in-memory.
|
|
default_in_memory_ttl = SchedulerCacheKeys.default_in_memory_ttl.value
|
|
self.cache = DualCache(redis_cache=redis_cache, default_in_memory_ttl=default_in_memory_ttl)
|
|
self.polling_interval = polling_interval or DEFAULT_POLLING_INTERVAL # default to 3ms
|
|
|
|
async def add_request(self, request: FlowItem):
|
|
# We use the priority directly, as lower values indicate higher priority
|
|
# get the queue
|
|
queue: Final = await self.get_queue(model_name=request.model_name)
|
|
# update the queue
|
|
heapq.heappush(queue, (request.priority, request.request_id))
|
|
|
|
# save the queue
|
|
await self.save_queue(queue=queue, model_name=request.model_name)
|
|
|
|
async def poll(self, request: FlowItem, health_deployments: Sequence[object]) -> bool:
|
|
"""
|
|
Return if request can be processed.
|
|
|
|
Returns:
|
|
- True:
|
|
* If healthy deployments are available
|
|
* OR If request at the top of queue
|
|
- False:
|
|
* If no healthy deployments available
|
|
* AND request not at the top of queue
|
|
|
|
A request the queue no longer holds (its cache key expired or a concurrent writer erased the entry)
|
|
is put back at its priority so it keeps its place in the order instead of failing or jumping ahead
|
|
"""
|
|
print_verbose(f"len(health_deployments): {len(health_deployments)}")
|
|
if len(health_deployments) > 0:
|
|
return True
|
|
|
|
queue: Final = await self.get_queue(model_name=request.model_name)
|
|
entry: Final = (request.priority, request.request_id)
|
|
print_verbose(f"queue: {queue}, seeking {entry}")
|
|
if entry not in queue:
|
|
print_verbose(f"queue no longer holds {entry}, re-enqueueing it")
|
|
heapq.heappush(queue, entry)
|
|
if queue[0] != entry:
|
|
await self.save_queue(queue=queue, model_name=request.model_name)
|
|
return False
|
|
elif queue[0] != entry:
|
|
return False
|
|
|
|
heapq.heappop(queue)
|
|
await self.save_queue(queue=queue, model_name=request.model_name)
|
|
print_verbose(f"Popped id: {request.request_id}")
|
|
return True
|
|
|
|
async def wait_for_turn(
|
|
self,
|
|
request: FlowItem,
|
|
timeout: float,
|
|
get_healthy_deployments: Callable[[], Awaitable[Sequence[object]]],
|
|
) -> None:
|
|
try:
|
|
await self.add_request(request=request)
|
|
end_time: Final = time.monotonic() + timeout
|
|
while time.monotonic() < end_time:
|
|
if await self.poll(request=request, health_deployments=await get_healthy_deployments()):
|
|
return
|
|
await asyncio.sleep(self.polling_interval)
|
|
finally:
|
|
await asyncio.shield(self.remove_request(request_id=request.request_id, model_name=request.model_name))
|
|
raise Timeout(message="Request timed out while polling queue", model=request.model_name, llm_provider="openai")
|
|
|
|
async def remove_request(self, request_id: str, model_name: str) -> None:
|
|
"""
|
|
Remove a specific request from the priority queue for a model.
|
|
Used when a request times out while waiting in the queue.
|
|
"""
|
|
queue: Final = await self.get_queue(model_name=model_name)
|
|
filtered_queue: Final = [item for item in queue if item[1] != request_id]
|
|
heapq.heapify(filtered_queue) # restore heap invariant after filtering
|
|
await self.save_queue(queue=filtered_queue, model_name=model_name)
|
|
print_verbose(f"Removed request_id: {request_id} from queue for model: {model_name}")
|
|
|
|
async def peek(self, id: str, model_name: str, health_deployments: list) -> bool:
|
|
"""Return if the id is at the top of the queue. Don't pop the value from heap."""
|
|
queue: Final = await self.get_queue(model_name=model_name)
|
|
if not queue:
|
|
raise Exception(f"Incorrectly setup. Queue is invalid. Queue={queue}")
|
|
|
|
# ------------
|
|
# Setup values
|
|
# ------------
|
|
|
|
# Check if the id is at the top of the heap
|
|
if queue[0][1] == id:
|
|
return True
|
|
|
|
return False
|
|
|
|
def get_queue_status(self):
|
|
"""Get the status of items in the queue"""
|
|
return self.queue
|
|
|
|
@with_service_target(SCHEDULER_QUEUE_TARGET)
|
|
async def get_queue(self, model_name: str) -> list[QueueEntry]:
|
|
"""
|
|
Return a queue for that specific model group.
|
|
|
|
Redis hands the queue back as JSON lists, so every entry is validated into the
|
|
(priority, request_id) tuple the heap operations compare against.
|
|
"""
|
|
if self.cache is not None:
|
|
_cache_key: Final = f"{SchedulerCacheKeys.queue.value}:{model_name}"
|
|
response: Final = await self.cache.async_get_cache(key=_cache_key)
|
|
if not isinstance(response, list):
|
|
return []
|
|
return _QUEUE_ENTRIES.validate_python(response)
|
|
return self.queue
|
|
|
|
@with_service_target(SCHEDULER_QUEUE_TARGET)
|
|
async def save_queue(self, queue: list[QueueEntry], model_name: str) -> None:
|
|
"""
|
|
Save the updated queue of the model group
|
|
"""
|
|
if self.cache is not None:
|
|
_cache_key: Final = f"{SchedulerCacheKeys.queue.value}:{model_name}"
|
|
await self.cache.async_set_cache(key=_cache_key, value=queue)
|