Coverage for src/lilbee/server/chat_dispatch/concurrency.py: 100%

82 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Process-wide chat-generation admission gate shared by every chat route.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6from collections import deque 

7from functools import lru_cache 

8 

9# Default upper bound for waiting on a free chat slot before surfacing a real 

10# "busy" response. Tuned to absorb the typical opencode retry storm (one user 

11# turn can take 30-60s on slow local models) while still bounding the worst case 

12# for a genuinely-stuck backend. 

13DEFAULT_BUSY_WAIT_S = 60.0 

14 

15 

16class ChatBusyError(Exception): 

17 """Raised when every chat slot is in use after waiting.""" 

18 

19 

20class ChatGate: 

21 """Admit up to the backend's live slot capacity; the rest wait FIFO, then 429. 

22 

23 Capacity is read at admission time, not fixed when the gate is created, so a 

24 model swap or provider change that changes the slot count takes effect at 

25 once, and a single in-process model (capacity 1) is never oversubscribed. 

26 The fleet reports its ``--parallel`` slot count, so its continuous-batching 

27 slots are actually used instead of one request at a time. The latest count 

28 any caller reports is remembered, so a capacity increase also admits the 

29 waiters that are already queued rather than leaving them to time out 

30 against a backend that now has room. 

31 

32 Hand-rolled on purpose, and the reason is worth keeping because the obvious 

33 replacement almost fits. ``anyio.CapacityLimiter`` has a runtime-settable 

34 ``total_tokens`` that would make a capacity increase wake waiters for free, 

35 but it binds each token to the borrowing task and raises ``RuntimeError`` 

36 when another task releases it. Slots here are released from wherever cleanup 

37 happens first, including a response's after-send hook, which is not the task 

38 that acquired. ``asyncio.Semaphore`` allows the cross-task release but has no 

39 live capacity. Neither fits until the streaming cleanup paths are collapsed 

40 onto one task. 

41 

42 Slots are *handed to* waiters rather than merely signalled. Waking a waiter 

43 reserves its slot in the same synchronous step, and a request arriving while 

44 a waiter is queued joins the back of the queue instead of testing the 

45 counter. Without both, a newcomer could take the slot in the window between 

46 the wake-up and the woken waiter resuming, and the waiter would rejoin at 

47 the tail: the requests that had waited longest would be overtaken 

48 repeatedly and would be the ones to time out. 

49 """ 

50 

51 def __init__(self) -> None: 

52 self._in_flight = 0 

53 # Last slot count reported by a caller; the gate has no other view of it. 

54 self._ceiling = 1 

55 self._waiters: deque[asyncio.Future[None]] = deque() 

56 

57 @property 

58 def in_flight(self) -> int: 

59 """Chat generations currently admitted.""" 

60 return self._in_flight 

61 

62 async def acquire(self, capacity: int, timeout: float) -> None: 

63 """Reserve a slot, waiting up to *timeout*; raise ChatBusyError if full.""" 

64 loop = asyncio.get_running_loop() 

65 self._observe_capacity(max(1, capacity)) 

66 # Admit straight away only with room AND nobody ahead in the queue. 

67 if not self._waiters and self._in_flight < self._ceiling: 

68 self._in_flight += 1 

69 return 

70 if timeout <= 0: 

71 raise ChatBusyError(_busy_message(timeout)) 

72 waiter: asyncio.Future[None] = loop.create_future() 

73 self._waiters.append(waiter) 

74 try: 

75 # asyncio.timeout, not wait_for: 3.11's wait_for swallows a task 

76 # cancellation that races a completed waiter (fixed in 3.12). 

77 async with asyncio.timeout(timeout): 

78 await waiter 

79 # Returning means a slot was reserved for us by whoever woke us; 

80 # there is nothing left to claim. 

81 except TimeoutError as exc: 

82 self._abandon(waiter) 

83 raise ChatBusyError(_busy_message(timeout)) from exc 

84 except asyncio.CancelledError: 

85 self._abandon(waiter) 

86 raise 

87 finally: 

88 if waiter in self._waiters: 

89 self._waiters.remove(waiter) 

90 

91 async def release(self) -> None: 

92 """Free an acquired slot and admit whoever it makes room for. 

93 

94 Contains no awaits: the decrement and wake-ups run synchronously on the 

95 event loop, so a cancellation delivered to the caller (for example a 

96 client disconnect tearing down a streaming response) can never abort 

97 the release halfway and leak the slot. 

98 """ 

99 if self._in_flight > 0: 

100 self._in_flight -= 1 

101 self._admit_waiters() 

102 

103 def _observe_capacity(self, ceiling: int) -> None: 

104 """Record the caller's live slot count and admit anyone it now fits.""" 

105 self._ceiling = ceiling 

106 self._admit_waiters() 

107 

108 def _admit_waiters(self) -> None: 

109 """Hand a reserved slot to each queued waiter that now fits.""" 

110 while self._in_flight < self._ceiling: 

111 waiter = self._pop_live_waiter() 

112 if waiter is None: 

113 return 

114 # Reserve before waking: the slot is the waiter's from this moment, 

115 # so nothing entering acquire() in between can take it. 

116 self._in_flight += 1 

117 waiter.set_result(None) 

118 

119 def _pop_live_waiter(self) -> asyncio.Future[None] | None: 

120 """Remove and return the oldest waiter still able to take a slot.""" 

121 while self._waiters: 

122 waiter = self._waiters.popleft() 

123 if not waiter.done(): 

124 return waiter 

125 return None 

126 

127 def _abandon(self, waiter: asyncio.Future[None]) -> None: 

128 """Give back a slot that was reserved for a waiter that then bailed out.""" 

129 if waiter.done() and not waiter.cancelled(): 

130 if self._in_flight > 0: 

131 self._in_flight -= 1 

132 self._admit_waiters() 

133 

134 

135def _busy_message(timeout: float) -> str: 

136 rendered = f"{timeout:.1f}s" if timeout < 1 else f"{timeout:.0f}s" 

137 return f"Chat backend busy: all slots in use after {rendered}. Retry shortly." 

138 

139 

140@lru_cache(maxsize=1) 

141def chat_gate() -> ChatGate: 

142 """Return the process-wide chat admission gate (created lazily).""" 

143 return ChatGate() 

144 

145 

146async def acquire_chat_slot_or_busy(capacity: int, timeout: float | None = None) -> None: 

147 """Reserve one of *capacity* chat slots, waiting up to *timeout*. 

148 

149 Raises :class:`ChatBusyError` only when the wait times out. Returns with the 

150 slot held; callers must ``await release_chat_slot()`` in a ``finally``. 

151 *timeout* defaults to :data:`DEFAULT_BUSY_WAIT_S`. 

152 """ 

153 effective_timeout = DEFAULT_BUSY_WAIT_S if timeout is None else timeout 

154 await chat_gate().acquire(capacity, effective_timeout) 

155 

156 

157async def release_chat_slot() -> None: 

158 """Release a chat slot reserved by :func:`acquire_chat_slot_or_busy`.""" 

159 await chat_gate().release() 

160 

161 

162class ChatSlotGuard: 

163 """Releases one acquired chat slot at most once across multiple cleanup paths. 

164 

165 A streaming route acquires its slot before the SSE generator runs; a client 

166 that disconnects before the generator's first iteration means the generator 

167 body (and its ``finally``) never executes. Every cleanup path (generator 

168 ``finally``, response after-send hook, explicit ``aclose``) releases through 

169 the same guard, so whichever fires first frees the slot and the rest no-op. 

170 """ 

171 

172 def __init__(self) -> None: 

173 self._released = False 

174 

175 @property 

176 def released(self) -> bool: 

177 """True once the slot has been freed.""" 

178 return self._released 

179 

180 async def release(self) -> None: 

181 """Free the slot on first call; later calls are no-ops.""" 

182 if self._released: 

183 return 

184 self._released = True 

185 # ChatGate.release never yields to the event loop, so no cancellation 

186 # can land between flipping the flag and the slot actually freeing. 

187 await release_chat_slot()