feat: port three-layer stream liveness watchdog
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
"""三层流式活性超时看门狗: 给任意异步迭代器施加首产出/产出间/总时长三层超时保护。
|
||||||
|
|
||||||
|
领域无关、不打日志、不触网: 仅以 asyncio.timeout 包裹取值,便于注入假异步
|
||||||
|
迭代器做单测。超时抛 StreamLivenessTimeout(携 kind/elapsed/是否已出首项),
|
||||||
|
由 transport 边界翻译为 TransientError 并埋点。
|
||||||
|
|
||||||
|
移植自 Video-Tree-TRM5 adapters/streaming.py(三项目同款,语义零改动)。
|
||||||
|
活性口径: 看门狗只感知"有无产出",不区分产出内容——transport 把
|
||||||
|
reasoning_content 增量同样作为流元素产出,思考流因此天然刷新计时(CHS R1)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, TypeVar
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
class StreamLivenessTimeout(Exception): # noqa: N818 — 三项目冻结的公共名
|
||||||
|
"""流活性超时异常。
|
||||||
|
|
||||||
|
属性:
|
||||||
|
kind: 超时类型,取值 {"ttft", "inter_token", "total"}。
|
||||||
|
elapsed_s: 自流开始到触发超时的秒数。
|
||||||
|
first_token_seen: 触发时是否已产出过首项。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, kind: str, elapsed_s: float, first_token_seen: bool) -> None:
|
||||||
|
self.kind = kind
|
||||||
|
self.elapsed_s = elapsed_s
|
||||||
|
self.first_token_seen = first_token_seen
|
||||||
|
super().__init__(f"流活性超时({kind}, elapsed={elapsed_s:.1f}s)")
|
||||||
|
|
||||||
|
|
||||||
|
async def _anext_within(
|
||||||
|
it: AsyncIterator[_T],
|
||||||
|
timeout_s: float,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
start: float,
|
||||||
|
first: bool,
|
||||||
|
) -> _T:
|
||||||
|
"""限时取下一项;本层 deadline 触发抛 StreamLivenessTimeout(kind)。
|
||||||
|
|
||||||
|
上游自抛的 TimeoutError 用 cm.expired() 区分,原样上抛不误吞。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(timeout_s) as cm:
|
||||||
|
return await it.__anext__()
|
||||||
|
except TimeoutError:
|
||||||
|
if not cm.expired():
|
||||||
|
raise # 上游自抛的 TimeoutError,非本层 deadline,原样上抛
|
||||||
|
raise StreamLivenessTimeout(kind, time.monotonic() - start, not first) from None
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_with_liveness_timeouts(
|
||||||
|
source: AsyncIterator[_T],
|
||||||
|
*,
|
||||||
|
ttft_s: float,
|
||||||
|
inter_token_s: float,
|
||||||
|
total_s: float,
|
||||||
|
) -> AsyncIterator[_T]:
|
||||||
|
"""逐项产出 source,并施加三层活性超时。
|
||||||
|
|
||||||
|
关键实现: 超时**只包裹单次 __anext__**,绝不包裹 yield——否则总时长
|
||||||
|
deadline 会在生成器挂起(消费者处理已产出项)期间继续计时,可能取消
|
||||||
|
消费者的 await 而非由本生成器稳定抛出(取消泄漏)。总时长由「每轮取
|
||||||
|
min(本层预算, 剩余总额) 钳制」实现: 钳到剩余总额时若超时即判 total,
|
||||||
|
否则判 ttft/inter_token。finally 关闭底层迭代器释放上游资源。
|
||||||
|
"""
|
||||||
|
it = source.__aiter__()
|
||||||
|
start = time.monotonic()
|
||||||
|
deadline = start + total_s
|
||||||
|
first = True
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
remaining_total = deadline - time.monotonic()
|
||||||
|
if remaining_total <= 0:
|
||||||
|
raise StreamLivenessTimeout("total", time.monotonic() - start, not first)
|
||||||
|
budget = ttft_s if first else inter_token_s
|
||||||
|
# 剩余总额比本层预算更紧 → 先到的是 total
|
||||||
|
clamped = remaining_total <= budget
|
||||||
|
kind = "total" if clamped else ("ttft" if first else "inter_token")
|
||||||
|
try:
|
||||||
|
item = await _anext_within(
|
||||||
|
it,
|
||||||
|
remaining_total if clamped else budget,
|
||||||
|
kind=kind,
|
||||||
|
start=start,
|
||||||
|
first=first,
|
||||||
|
)
|
||||||
|
except StopAsyncIteration:
|
||||||
|
return
|
||||||
|
first = False
|
||||||
|
yield item
|
||||||
|
finally:
|
||||||
|
# 收尾关闭上游迭代器;清理失败不得掩盖主异常,故显式抑制
|
||||||
|
aclose = getattr(it, "aclose", None)
|
||||||
|
if aclose is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await aclose()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""streaming.py 三层活性看门狗测试(移植自 VT adapters/streaming.py,语义零改动)。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.streaming import StreamLivenessTimeout, stream_with_liveness_timeouts
|
||||||
|
|
||||||
|
|
||||||
|
async def _emit(items, *, delay_s=0.0, first_delay_s=None):
|
||||||
|
"""可控节奏的假流;first_delay_s 单独控制首项延迟。"""
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
wait = first_delay_s if (i == 0 and first_delay_s is not None) else delay_s
|
||||||
|
if wait:
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
yield item
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect(agen):
|
||||||
|
return [item async for item in agen]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPassthrough:
|
||||||
|
async def test_items_pass_through_in_order(self):
|
||||||
|
wrapped = stream_with_liveness_timeouts(
|
||||||
|
_emit(["a", "b", "c"]), ttft_s=1.0, inter_token_s=1.0, total_s=5.0
|
||||||
|
)
|
||||||
|
assert await _collect(wrapped) == ["a", "b", "c"]
|
||||||
|
|
||||||
|
async def test_empty_stream_ends_cleanly(self):
|
||||||
|
wrapped = stream_with_liveness_timeouts(
|
||||||
|
_emit([]), ttft_s=1.0, inter_token_s=1.0, total_s=5.0
|
||||||
|
)
|
||||||
|
assert await _collect(wrapped) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestThreeLayers:
|
||||||
|
async def test_ttft_timeout(self):
|
||||||
|
wrapped = stream_with_liveness_timeouts(
|
||||||
|
_emit(["a"], first_delay_s=0.2), ttft_s=0.05, inter_token_s=1.0, total_s=5.0
|
||||||
|
)
|
||||||
|
with pytest.raises(StreamLivenessTimeout) as ei:
|
||||||
|
await _collect(wrapped)
|
||||||
|
assert ei.value.kind == "ttft"
|
||||||
|
assert ei.value.first_token_seen is False
|
||||||
|
|
||||||
|
async def test_inter_token_timeout(self):
|
||||||
|
wrapped = stream_with_liveness_timeouts(
|
||||||
|
_emit(["a", "b"], delay_s=0.2, first_delay_s=0.0),
|
||||||
|
ttft_s=1.0, inter_token_s=0.05, total_s=5.0,
|
||||||
|
)
|
||||||
|
with pytest.raises(StreamLivenessTimeout) as ei:
|
||||||
|
await _collect(wrapped)
|
||||||
|
assert ei.value.kind == "inter_token"
|
||||||
|
assert ei.value.first_token_seen is True
|
||||||
|
|
||||||
|
async def test_total_timeout(self):
|
||||||
|
# 每项都快,但项数多到总时长超限 → 判 total 而非 inter_token
|
||||||
|
wrapped = stream_with_liveness_timeouts(
|
||||||
|
_emit(list(range(100)), delay_s=0.02), ttft_s=1.0, inter_token_s=1.0, total_s=0.1
|
||||||
|
)
|
||||||
|
with pytest.raises(StreamLivenessTimeout) as ei:
|
||||||
|
await _collect(wrapped)
|
||||||
|
assert ei.value.kind == "total"
|
||||||
|
|
||||||
|
|
||||||
|
class TestForeignTimeoutAndCleanup:
|
||||||
|
async def test_upstream_timeout_error_not_reclassified(self):
|
||||||
|
"""上游自抛的 TimeoutError 不得被误判为本层活性超时。"""
|
||||||
|
|
||||||
|
async def bad_stream():
|
||||||
|
raise TimeoutError("upstream own timeout")
|
||||||
|
yield # pragma: no cover
|
||||||
|
|
||||||
|
wrapped = stream_with_liveness_timeouts(
|
||||||
|
bad_stream(), ttft_s=1.0, inter_token_s=1.0, total_s=5.0
|
||||||
|
)
|
||||||
|
with pytest.raises(TimeoutError) as ei:
|
||||||
|
await _collect(wrapped)
|
||||||
|
assert not isinstance(ei.value, StreamLivenessTimeout)
|
||||||
|
|
||||||
|
async def test_underlying_iterator_closed_on_abandon(self):
|
||||||
|
closed = asyncio.Event()
|
||||||
|
|
||||||
|
async def tracked():
|
||||||
|
try:
|
||||||
|
for i in range(100):
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
yield i
|
||||||
|
finally:
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
wrapped = stream_with_liveness_timeouts(
|
||||||
|
tracked(), ttft_s=1.0, inter_token_s=1.0, total_s=5.0
|
||||||
|
)
|
||||||
|
assert (await wrapped.__anext__()) == 0
|
||||||
|
await wrapped.aclose() # 消费方提前放弃 → finally 关闭底层迭代器
|
||||||
|
assert closed.is_set()
|
||||||
|
|
||||||
|
async def test_cancellation_propagates(self):
|
||||||
|
wrapped = stream_with_liveness_timeouts(
|
||||||
|
_emit(["a"], first_delay_s=10.0), ttft_s=30.0, inter_token_s=30.0, total_s=60.0
|
||||||
|
)
|
||||||
|
task = asyncio.ensure_future(_collect(wrapped))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
Reference in New Issue
Block a user