mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
fix(models): preserve FIFO across request-admission handoff (#5459)
* fix(models): make request admission enqueue atomic * test(models): cover atomic request admission FIFO entry * docs(models): record atomic admission FIFO invariant * fix(models): address request-admission review follow-ups
This commit is contained in:
parent
8e94cc3432
commit
53dde30d4a
@ -3,7 +3,10 @@
|
|||||||
Request-admission waits follow the next scheduled admission and configured
|
Request-admission waits follow the next scheduled admission and configured
|
||||||
interval, capped at 50 ms; the cap must not become a minimum poll interval that
|
interval, capped at 50 ms; the cap must not become a minimum poll interval that
|
||||||
limits high-RPM throughput. Local `AdmissionError` is structurally non-retriable
|
limits high-RPM throughput. Local `AdmissionError` is structurally non-retriable
|
||||||
in LLM error handling regardless of its message text.
|
in LLM error handling regardless of its message text. Immediate admission and
|
||||||
|
joining the blocking FIFO are one lock-protected decision: do not split the
|
||||||
|
fast-path permit check from queue insertion, or an older caller can be overtaken
|
||||||
|
while handing off to the wait queue.
|
||||||
|
|
||||||
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
|
- `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection
|
||||||
- Supports `thinking_enabled` flag with per-model `when_thinking_enabled` overrides
|
- Supports `thinking_enabled` flag with per-model `when_thinking_enabled` overrides
|
||||||
@ -12,7 +15,7 @@ in LLM error handling regardless of its message text.
|
|||||||
- Supports `supports_vision` flag for image understanding models
|
- Supports `supports_vision` flag for image understanding models
|
||||||
- Config values starting with `$` resolved as environment variables
|
- Config values starting with `$` resolved as environment variables
|
||||||
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
|
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
|
||||||
- Optional `models[].request_admission` attaches a process-shared `BaseRateLimiter` at the model factory. Identical explicit groups share one FIFO across model instances, threads and event loops; implicit groups use the configured model name. Policies are immutable once registered and conflicting settings fail construction. A monotonic minimum interval spaces requests without idle-time burst credit; bounded waiters poll without occupying executor threads and unregister in `finally`. The factory strips the policy from provider kwargs and sets exposed SDK `max_retries=0` so middleware retries re-enter admission. This limits model invocations, not tokens or a distributed provider account; custom providers bypassing BaseChatModel hooks are outside the contract. Tests: `test_model_request_admission.py`.
|
- Optional `models[].request_admission` attaches a process-shared `BaseRateLimiter` at the model factory. Identical explicit groups share one FIFO across model instances, threads and event loops; implicit groups use the configured model name. Policies are immutable once registered and conflicting settings fail construction. A monotonic minimum interval spaces requests without idle-time burst credit; bounded waiters poll without occupying executor threads and unregister in `finally`. The factory strips the policy from provider kwargs and sets exposed SDK `max_retries=0` so middleware retries re-enter admission. This limits model invocations, not tokens or a distributed provider account; custom providers bypassing BaseChatModel hooks are outside the contract. Tests: `test_model_request_admission.py` and `test_model_request_admission_fifo_atomic.py`.
|
||||||
|
|
||||||
### Claude Code Credentials (`packages/harness/deerflow/models/credential_loader.py`)
|
### Claude Code Credentials (`packages/harness/deerflow/models/credential_loader.py`)
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ class RequestAdmission(BaseRateLimiter):
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._waiters: deque[object] = deque()
|
self._waiters: deque[object] = deque()
|
||||||
|
|
||||||
def _try(self, ticket: object | None) -> bool:
|
def _try(self, ticket: object) -> bool:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
now = monotonic()
|
now = monotonic()
|
||||||
if self._waiters and self._waiters[0] is not ticket:
|
if self._waiters and self._waiters[0] is not ticket:
|
||||||
@ -38,13 +38,20 @@ class RequestAdmission(BaseRateLimiter):
|
|||||||
self._next = now + self._interval
|
self._next = now + self._interval
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _enqueue(self) -> object:
|
def _try_or_enqueue(self, *, blocking: bool) -> tuple[bool, object | None]:
|
||||||
ticket = object()
|
"""Atomically admit immediately or join the FIFO before newcomers can pass."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
now = monotonic()
|
||||||
|
if not self._waiters and now >= self._next:
|
||||||
|
self._next = now + self._interval
|
||||||
|
return True, None
|
||||||
|
if not blocking:
|
||||||
|
return False, None
|
||||||
if len(self._waiters) >= self.config.max_queue_size:
|
if len(self._waiters) >= self.config.max_queue_size:
|
||||||
raise AdmissionError("LLM admission queue is full; reduce workload or increase queue capacity.")
|
raise AdmissionError("LLM admission queue is full; reduce workload or increase queue capacity.")
|
||||||
|
ticket = object()
|
||||||
self._waiters.append(ticket)
|
self._waiters.append(ticket)
|
||||||
return ticket
|
return False, ticket
|
||||||
|
|
||||||
def _remove(self, ticket: object) -> None:
|
def _remove(self, ticket: object) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@ -62,12 +69,12 @@ class RequestAdmission(BaseRateLimiter):
|
|||||||
return min(0.05, self._interval, until_next if until_next > 0 else self._interval, remaining)
|
return min(0.05, self._interval, until_next if until_next > 0 else self._interval, remaining)
|
||||||
|
|
||||||
def acquire(self, *, blocking: bool = True) -> bool:
|
def acquire(self, *, blocking: bool = True) -> bool:
|
||||||
if self._try(None):
|
acquired, ticket = self._try_or_enqueue(blocking=blocking)
|
||||||
|
if acquired:
|
||||||
return True
|
return True
|
||||||
if not blocking:
|
if ticket is None:
|
||||||
return False
|
return False
|
||||||
deadline = monotonic() + self.config.max_wait_seconds
|
deadline = monotonic() + self.config.max_wait_seconds
|
||||||
ticket = self._enqueue()
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
delay = self._delay(deadline)
|
delay = self._delay(deadline)
|
||||||
@ -78,12 +85,12 @@ class RequestAdmission(BaseRateLimiter):
|
|||||||
self._remove(ticket)
|
self._remove(ticket)
|
||||||
|
|
||||||
async def aacquire(self, *, blocking: bool = True) -> bool:
|
async def aacquire(self, *, blocking: bool = True) -> bool:
|
||||||
if self._try(None):
|
acquired, ticket = self._try_or_enqueue(blocking=blocking)
|
||||||
|
if acquired:
|
||||||
return True
|
return True
|
||||||
if not blocking:
|
if ticket is None:
|
||||||
return False
|
return False
|
||||||
deadline = monotonic() + self.config.max_wait_seconds
|
deadline = monotonic() + self.config.max_wait_seconds
|
||||||
ticket = self._enqueue()
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
delay = self._delay(deadline)
|
delay = self._delay(deadline)
|
||||||
|
|||||||
39
backend/tests/test_model_request_admission_fifo_atomic.py
Normal file
39
backend/tests/test_model_request_admission_fifo_atomic.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Regression coverage for atomic request-admission FIFO entry."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.config.model_config import RequestAdmissionConfig
|
||||||
|
from deerflow.models import request_admission as admission
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clock(monkeypatch):
|
||||||
|
now = [0.0]
|
||||||
|
monkeypatch.setattr(admission, "monotonic", lambda: now[0])
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocking_waiter_reserves_fifo_position_before_newcomer_can_take_due_slot(clock):
|
||||||
|
limiter = admission.RequestAdmission(RequestAdmissionConfig(requests_per_minute=60))
|
||||||
|
assert limiter.acquire(blocking=False)
|
||||||
|
|
||||||
|
acquired, ticket = limiter._try_or_enqueue(blocking=True)
|
||||||
|
assert acquired is False
|
||||||
|
assert ticket is limiter._waiters[0]
|
||||||
|
|
||||||
|
clock[0] = 1
|
||||||
|
assert limiter.acquire(blocking=False) is False
|
||||||
|
|
||||||
|
limiter._remove(ticket)
|
||||||
|
assert limiter.acquire(blocking=False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonblocking_probe_never_joins_the_fifo(clock):
|
||||||
|
limiter = admission.RequestAdmission(RequestAdmissionConfig(requests_per_minute=60))
|
||||||
|
assert limiter.acquire(blocking=False)
|
||||||
|
|
||||||
|
acquired, ticket = limiter._try_or_enqueue(blocking=False)
|
||||||
|
|
||||||
|
assert acquired is False
|
||||||
|
assert ticket is None
|
||||||
|
assert not limiter._waiters
|
||||||
Loading…
x
Reference in New Issue
Block a user