refactor(usage-queue): ACK 后立即删除消息,清理 consumer 元数据

- 消费成功/移入 DLQ 后立即 XDEL,避免主流 Redis 保留已入库历史
- 缩小 usage_queue_stream_maxlen 默认值:200000 -> 2000(仅作短暂缓冲)
- 启动时清理 pending=0 且长期闲置的旧 consumer(防 consumer group 元数据累积)
- 停机时主动 XGROUP DELCONSUMER 移除自身
- 移除 cache_fingerprint 模块及其对 telemetry/recording_helpers 的引用
- 同步更新相关测试,验证 xdel 调用及 consumer 生命周期行为
This commit is contained in:
fawney19
2026-03-19 02:09:09 +08:00
parent 56f3c95763
commit 086efe6efe
7 changed files with 189 additions and 434 deletions

View File

@@ -1,229 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from src.config.settings import config
from src.services.provider.cache_fingerprint import build_request_cache_fingerprint
from src.services.usage._recording_helpers import sanitize_request_metadata
from src.services.usage.service import UsageService
from src.services.usage.telemetry import MessageTelemetry
def _build_openai_cli_body() -> dict[str, Any]:
return {
"model": "gpt-5.4",
"instructions": "You are precise.",
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}],
"tools": [
{
"type": "function",
"name": "lookup_weather",
"parameters": {
"type": "object",
"required": ["city", "country"],
"properties": {
"country": {"type": "string"},
"city": {"type": "string"},
},
},
}
],
"temperature": 0.2,
"prompt_cache_key": "pcache-123",
}
def test_build_request_cache_fingerprint_is_stable_for_dict_key_reordering() -> None:
body_a = _build_openai_cli_body()
body_b = {
"prompt_cache_key": "pcache-123",
"temperature": 0.2,
"tools": [
{
"parameters": {
"properties": {
"city": {"type": "string"},
"country": {"type": "string"},
},
"required": ["city", "country"],
"type": "object",
},
"name": "lookup_weather",
"type": "function",
}
],
"input": [{"content": [{"text": "hello", "type": "input_text"}], "role": "user"}],
"instructions": "You are precise.",
"model": "gpt-5.4",
}
fingerprint_a = build_request_cache_fingerprint(body_a, provider_api_format="openai:cli")
fingerprint_b = build_request_cache_fingerprint(body_b, provider_api_format="openai:cli")
assert fingerprint_a is not None
assert fingerprint_b is not None
assert fingerprint_a["version"] == 2
assert fingerprint_a["payload_sha256"] == fingerprint_b["payload_sha256"]
assert fingerprint_a["cache_relevant_sha256"] == fingerprint_b["cache_relevant_sha256"]
assert fingerprint_a["field_fingerprints"] == fingerprint_b["field_fingerprints"]
assert fingerprint_a["prompt_cache_key"] == "pcache-123"
assert fingerprint_a["cache_relevant_keys"] == [
"input",
"instructions",
"model",
"prompt_cache_key",
"tools",
]
assert fingerprint_a["field_fingerprints"]["instructions"]["bytes"] > 0
assert fingerprint_a["field_fingerprints"]["tools"]["bytes"] > 0
def test_build_request_cache_fingerprint_ignores_non_prompt_fields_in_cache_hash() -> None:
body_a = _build_openai_cli_body()
body_b = _build_openai_cli_body()
body_b["temperature"] = 0.9
fingerprint_a = build_request_cache_fingerprint(body_a, provider_api_format="openai:cli")
fingerprint_b = build_request_cache_fingerprint(body_b, provider_api_format="openai:cli")
assert fingerprint_a is not None
assert fingerprint_b is not None
assert fingerprint_a["payload_sha256"] != fingerprint_b["payload_sha256"]
assert fingerprint_a["cache_relevant_sha256"] == fingerprint_b["cache_relevant_sha256"]
assert fingerprint_a["field_fingerprints"] == fingerprint_b["field_fingerprints"]
assert "temperature" not in fingerprint_a["field_fingerprints"]
def test_build_request_cache_fingerprint_tracks_prompt_changes() -> None:
body_a = _build_openai_cli_body()
body_b = _build_openai_cli_body()
body_b["instructions"] = "You are terse."
fingerprint_a = build_request_cache_fingerprint(body_a, provider_api_format="openai:cli")
fingerprint_b = build_request_cache_fingerprint(body_b, provider_api_format="openai:cli")
assert fingerprint_a is not None
assert fingerprint_b is not None
assert fingerprint_a["cache_relevant_sha256"] != fingerprint_b["cache_relevant_sha256"]
assert (
fingerprint_a["field_fingerprints"]["instructions"]["sha256"]
!= fingerprint_b["field_fingerprints"]["instructions"]["sha256"]
)
assert (
fingerprint_a["field_fingerprints"]["input"]["sha256"]
== fingerprint_b["field_fingerprints"]["input"]["sha256"]
)
assert (
fingerprint_a["field_fingerprints"]["tools"]["sha256"]
== fingerprint_b["field_fingerprints"]["tools"]["sha256"]
)
def test_sanitize_request_metadata_preserves_cache_fingerprint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(config, "usage_metadata_max_bytes", 120, raising=False)
metadata = {
"trace": {"payload": "x" * 400},
"debug": {"payload": "y" * 400},
"cache_fingerprint": {
"payload_sha256": "a" * 64,
"cache_relevant_sha256": "b" * 64,
},
}
sanitized = sanitize_request_metadata(metadata)
assert sanitized["_metadata_truncated"] is True
assert sanitized["cache_fingerprint"]["payload_sha256"] == "a" * 64
@pytest.mark.asyncio
async def test_message_telemetry_record_success_keeps_response_shape_and_adds_fingerprint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
async def _fake_record_usage(**kwargs: Any) -> Any:
captured.update(kwargs)
return SimpleNamespace(total_cost_usd=0.0, input_tokens=1, output_tokens=2)
monkeypatch.setattr(UsageService, "record_usage", _fake_record_usage)
telemetry = MessageTelemetry(
db=SimpleNamespace(), # type: ignore[arg-type]
user=None,
api_key=None,
request_id="req-cache-fingerprint",
client_ip="127.0.0.1",
)
await telemetry.record_success(
provider="openai",
model="gpt-5.4",
input_tokens=1,
output_tokens=2,
response_time_ms=10,
status_code=200,
request_body={"messages": [{"role": "user", "content": "hello"}]},
request_headers={"user-agent": "codex desktop"},
response_body={"id": "resp-1"},
response_headers={"x-test": "1"},
provider_request_body=_build_openai_cli_body(),
response_metadata={"model_version": "gpt-5.4-2026-03-01"},
endpoint_api_format="openai:cli",
)
metadata = captured["metadata"]
assert metadata["model_version"] == "gpt-5.4-2026-03-01"
assert "response" not in metadata
assert metadata["cache_fingerprint"]["version"] == 2
assert metadata["cache_fingerprint"]["provider_api_format"] == "openai:cli"
assert metadata["cache_fingerprint"]["prompt_cache_key"] == "pcache-123"
assert "instructions" in metadata["cache_fingerprint"]["field_fingerprints"]
@pytest.mark.asyncio
async def test_message_telemetry_record_failure_keeps_request_metadata_and_adds_fingerprint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
async def _fake_record_usage(**kwargs: Any) -> Any:
captured.update(kwargs)
return SimpleNamespace()
monkeypatch.setattr(UsageService, "record_usage", _fake_record_usage)
telemetry = MessageTelemetry(
db=SimpleNamespace(), # type: ignore[arg-type]
user=None,
api_key=None,
request_id="req-cache-fingerprint-fail",
client_ip="127.0.0.1",
)
await telemetry.record_failure(
provider="openai",
model="gpt-5.4",
response_time_ms=10,
status_code=502,
error_message="upstream failed",
request_body={"messages": [{"role": "user", "content": "hello"}]},
request_headers={"user-agent": "codex desktop"},
is_stream=False,
provider_request_body=_build_openai_cli_body(),
request_metadata={"perf": {"ttfb_ms": 12}},
endpoint_api_format="openai:cli",
)
metadata = captured["metadata"]
assert metadata["perf"]["ttfb_ms"] == 12
assert metadata["cache_fingerprint"]["version"] == 2
assert metadata["cache_fingerprint"]["provider_api_format"] == "openai:cli"
assert metadata["cache_fingerprint"]["prompt_cache_key"] == "pcache-123"
assert "input" in metadata["cache_fingerprint"]["field_fingerprints"]

View File

@@ -527,6 +527,10 @@ class MockRedisPipeline:
self._commands.append(("xack", key, group, message_id))
return self
def xdel(self, key: str, message_id: str) -> Any:
self._commands.append(("xdel", key, message_id))
return self
async def execute(self) -> Any:
results = []
for cmd in self._commands:
@@ -534,6 +538,10 @@ class MockRedisPipeline:
_, key, group, message_id = cmd
self._parent.xack_calls.append((key, group, message_id))
results.append(1)
elif cmd[0] == "xdel":
_, key, message_id = cmd
self._parent.xdel_calls.append((key, message_id))
results.append(1)
return results
@@ -542,9 +550,12 @@ class MockRedisForConsumer:
def __init__(self) -> None:
self.xgroup_create_calls: list[tuple[str, str, str, bool]] = []
self.xgroup_delconsumer_calls: list[tuple[str, str, str]] = []
self.xinfo_consumers_result: list[dict[str, Any]] = []
self.xreadgroup_results: list[Any] = []
self.xautoclaim_results: list[Any] = []
self.xack_calls: list[tuple[str, str, str]] = []
self.xdel_calls: list[tuple[str, str]] = []
self.xadd_calls: list[tuple[str, dict[str, str], int | None, bool | None]] = []
self.xpending_range_results: list[Any] = []
self.xlen_result: int = 0
@@ -556,6 +567,13 @@ class MockRedisForConsumer:
if self.xgroup_create_error:
raise self.xgroup_create_error
async def xgroup_delconsumer(self, key: str, group: str, consumer: str) -> int:
self.xgroup_delconsumer_calls.append((key, group, consumer))
return 1
async def xinfo_consumers(self, key: str, group: str) -> list[dict[str, Any]]:
return list(self.xinfo_consumers_result)
async def xreadgroup(
self,
groupname: str,
@@ -578,6 +596,9 @@ class MockRedisForConsumer:
async def xack(self, key: str, group: str, message_id: str) -> Any:
self.xack_calls.append((key, group, message_id))
async def xdel(self, key: str, message_id: str) -> Any:
self.xdel_calls.append((key, message_id))
async def xadd(
self,
key: str,
@@ -700,6 +721,57 @@ async def test_consumer_start_stop() -> None:
assert not consumer._running
@pytest.mark.asyncio
async def test_consumer_start_cleans_stale_consumers(monkeypatch: Any) -> None:
"""启动时应清理 pending=0 且长期闲置的旧 consumer。"""
mock_redis = MockRedisForConsumer()
current_consumer = _consumer_name()
mock_redis.xinfo_consumers_result = [
{"name": current_consumer, "pending": 0, "idle": 999999999},
{"name": "stale:1", "pending": 0, "idle": 999999999},
{"name": "busy:1", "pending": 1, "idle": 999999999},
{"name": "fresh:1", "pending": 0, "idle": 1000},
]
async def _get_redis_client(require_redis: bool = False) -> Any:
return mock_redis
monkeypatch.setattr("src.services.usage.consumer_streams.get_redis_client", _get_redis_client)
consumer = UsageQueueConsumer()
consumer._run = AsyncMock() # type: ignore[method-assign]
await consumer.start()
await consumer.stop()
assert ("usage:events", "usage_consumers", "stale:1") in mock_redis.xgroup_delconsumer_calls
assert ("usage:events", "usage_consumers", "busy:1") not in mock_redis.xgroup_delconsumer_calls
assert ("usage:events", "usage_consumers", "fresh:1") not in mock_redis.xgroup_delconsumer_calls
@pytest.mark.asyncio
async def test_consumer_stop_removes_self_from_group(monkeypatch: Any) -> None:
"""停机时应主动删除自身 consumer避免 group 元数据累积。"""
mock_redis = MockRedisForConsumer()
async def _get_redis_client(require_redis: bool = False) -> Any:
return mock_redis
monkeypatch.setattr("src.services.usage.consumer_streams.get_redis_client", _get_redis_client)
consumer = UsageQueueConsumer()
consumer._run = AsyncMock() # type: ignore[method-assign]
await consumer.start()
await consumer.stop()
assert (
config.usage_queue_stream_key,
config.usage_queue_stream_group,
consumer._consumer,
) in mock_redis.xgroup_delconsumer_calls
@pytest.mark.asyncio
async def test_consumer_process_messages_success(monkeypatch: Any) -> None:
"""测试成功处理消息"""
@@ -735,6 +807,27 @@ async def test_consumer_process_messages_success(monkeypatch: Any) -> None:
assert call_messages[0][2].request_id == "req-test-1"
@pytest.mark.asyncio
async def test_consumer_process_streaming_batch_deletes_messages() -> None:
"""STREAMING 事件处理成功后应立即从主队列删除。"""
mock_redis = MockRedisForConsumer()
consumer = UsageQueueConsumer()
consumer._apply_streaming_event = AsyncMock() # type: ignore[method-assign]
event = build_usage_event(
event_type=UsageEventType.STREAMING,
request_id="req-stream-ok",
data={"provider": "test"},
)
await consumer._process_streaming_batch(mock_redis, [("stream-1", event)])
assert mock_redis.xack_calls == [
(config.usage_queue_stream_key, config.usage_queue_stream_group, "stream-1")
]
assert mock_redis.xdel_calls == [(config.usage_queue_stream_key, "stream-1")]
@pytest.mark.asyncio
async def test_consumer_process_messages_error_retry(monkeypatch: Any) -> None:
"""测试处理消息失败时 STREAMING 事件的重试行为"""
@@ -804,6 +897,7 @@ async def test_consumer_process_messages_move_to_dlq(monkeypatch: Any) -> None:
# 消息应该被 ack
assert len(mock_redis.xack_calls) == 1
assert mock_redis.xdel_calls == [(config.usage_queue_stream_key, message_id)]
finally:
config.usage_queue_max_retries = old_max_retries
config.usage_queue_dlq_maxlen = old_dlq_maxlen
@@ -1203,8 +1297,14 @@ async def test_consumer_process_record_batch_success(monkeypatch: Any) -> None:
records = mock_record_batch.call_args[0][1]
assert len(records) == 3
# 验证所有消息被 ACK
# 验证所有消息被 ACK 并从主队列删除
assert len(mock_redis.xack_calls) == 3
assert len(mock_redis.xdel_calls) == 3
assert mock_redis.xdel_calls == [
(config.usage_queue_stream_key, "msg-0"),
(config.usage_queue_stream_key, "msg-1"),
(config.usage_queue_stream_key, "msg-2"),
]
@pytest.mark.asyncio
@@ -1243,8 +1343,9 @@ async def test_consumer_process_record_batch_fallback(monkeypatch: Any) -> None:
fallback_records = mock_record_batch.call_args_list[1][0][1]
assert len(fallback_records) == 1
assert fallback_records[0]["request_id"] == "req-fallback"
# 消息被 ACK
# 消息被 ACK 并从主队列删除
assert len(mock_redis.xack_calls) == 1
assert mock_redis.xdel_calls == [(config.usage_queue_stream_key, "msg-1")]
@pytest.mark.asyncio