feat: make the telemetry pool declare what it costs
The pool was the only external resource in the library that pre-allocated: asyncpg's default min_size=10 turned pool creation into an all-or-nothing action, so on a shared instance running low on connection budget the first thing to fall over was the one component that must not fail silently (4 clients x 10 = 40 idle connections just to write telemetry). min_size=0 means "do not pre-connect" - asyncpg only builds holders - so pool creation becomes free and never touches the database; connection failures then land on acquire, the path that already drops one row and lets the pool recover. max_size and the write budget become the library's explicit statement about its own footprint, configurable through two new keys whose defaults live in config alone (the recorder parameters are required keyword-only, same discipline as auto_migrate). The whole write - prepare, acquire, execute - now runs inside one asyncio.timeout: acquire used to have no timeout at all, so a full pool would hang forever on the caller's path. Release is explicit rather than `async with`, because asyncpg shields release and reuses the acquire timeout, which would let a single telemetry write consume twice the budget.
This commit is contained in:
@@ -130,6 +130,27 @@ async def _record_minimal(
|
||||
return fields
|
||||
|
||||
|
||||
# 集成用例统一的池上限与写入预算(issue #15;两者是 recorder 的必填 keyword-only)。
|
||||
# 池上限取 config 的生产缺省(4),让本文件跑的就是下游真实会跑的那个形状。
|
||||
#
|
||||
# 预算却**远比生产的 5s 宽**,这不是抄错: `test_concurrent_writes_all_land` 一次
|
||||
# 发 50 行,50 行共享 4 条连接,实测跨内网 RTT 123ms 下整批约 3.2s——而那 50 个
|
||||
# `record_llm_call` 的预算是**同时**起算的,批越慢离预算越近。这个实例被多项目
|
||||
# 共用,别人的一次负载尖峰就能让批耗时翻几倍,于是"丢行"变成掷硬币(pool_max=2
|
||||
# 时实测批耗时 5.3s/15s 预算,已经在全套件里红过一次)。给它 60s 是把余量拉到
|
||||
# 近 20 倍,让这个用例只在真出 bug 时红(CLAUDE.md §4.6: 重跑一次就绿的测试是
|
||||
# 信号污染源)。突发排队本身超预算即丢行是设计上的既定取舍(设计 §6),不在此改。
|
||||
_POOL_MAX = 4
|
||||
_WRITE_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
def _recorder(dsn: str, *, auto_migrate: bool) -> PostgresRecorder:
|
||||
"""本文件唯一的 recorder 构造点: 池参数只写一遍,免得 16 处各抄一份。"""
|
||||
return PostgresRecorder(
|
||||
dsn, auto_migrate=auto_migrate, pool_max=_POOL_MAX, write_timeout_s=_WRITE_TIMEOUT_S
|
||||
)
|
||||
|
||||
|
||||
async def _fetch(dsn: str, sql: str, *args):
|
||||
import asyncpg
|
||||
|
||||
@@ -205,7 +226,7 @@ class TestObservabilityColumns:
|
||||
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
|
||||
|
||||
async def test_values_round_trip(self, dsn):
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64)
|
||||
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0)
|
||||
@@ -233,7 +254,7 @@ class TestObservabilityColumns:
|
||||
async def test_legacy_table_is_upgraded_in_place(self, legacy_schema):
|
||||
"""18 列旧表不补列的话,每行写入都会被逐行 warning 丢弃(遥测静默全失)。"""
|
||||
schema_dsn, schema = legacy_schema
|
||||
recorder = PostgresRecorder(schema_dsn, auto_migrate=True)
|
||||
recorder = _recorder(schema_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
|
||||
@@ -258,7 +279,7 @@ class TestObservabilityColumns:
|
||||
|
||||
class TestSchema:
|
||||
async def test_schema_has_frozen_columns_in_order(self, dsn):
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder)
|
||||
rows = await _fetch(
|
||||
@@ -271,7 +292,7 @@ class TestSchema:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_call_id_idempotent(self, dsn):
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("dup"))
|
||||
await _record_minimal(recorder, call_id=_cid("dup"), response="second")
|
||||
@@ -283,7 +304,7 @@ class TestSchema:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_concurrent_writes_all_land(self, dsn):
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50))
|
||||
@@ -301,14 +322,14 @@ class TestSchema:
|
||||
class TestDegradation:
|
||||
async def test_unreachable_server_degrades_silently(self):
|
||||
"""结构性失败(建池不通)→ warning 一次后永久降级,业务零感知。"""
|
||||
recorder = PostgresRecorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True)
|
||||
recorder = _recorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True)
|
||||
await _record_minimal(recorder) # 不抛
|
||||
await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_row_failure_does_not_poison_later_rows(self, dsn):
|
||||
"""运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。"""
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte")
|
||||
await _record_minimal(recorder, call_id=_cid("good"))
|
||||
@@ -322,7 +343,7 @@ class TestDegradation:
|
||||
await recorder.aclose()
|
||||
|
||||
async def test_aclose_idempotent(self, dsn):
|
||||
recorder = PostgresRecorder(dsn, auto_migrate=True)
|
||||
recorder = _recorder(dsn, auto_migrate=True)
|
||||
await _record_minimal(recorder)
|
||||
await recorder.aclose()
|
||||
await recorder.aclose()
|
||||
@@ -394,7 +415,7 @@ class TestLeastPrivilegeDeployment:
|
||||
async def test_records_land_without_schema_create_privilege(self, least_privilege_dsn):
|
||||
"""修复前: 建表被拒 → _failed → 整个进程一条不落(下游 150 次调用全丢)。"""
|
||||
low_dsn, schema = least_privilege_dsn
|
||||
recorder = PostgresRecorder(low_dsn, auto_migrate=True)
|
||||
recorder = _recorder(low_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("lp1"))
|
||||
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5)
|
||||
@@ -563,7 +584,7 @@ class TestCallerDimensionsAcceptance:
|
||||
async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema):
|
||||
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
|
||||
fresh_dsn, schema = fresh_schema
|
||||
recorder = PostgresRecorder(fresh_dsn, auto_migrate=True)
|
||||
recorder = _recorder(fresh_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
|
||||
@@ -597,7 +618,7 @@ class TestCallerDimensionsAcceptance:
|
||||
审计出来,历史欠账是可见、可量化、可补录的。
|
||||
"""
|
||||
schema_dsn, schema = pre_tenant_schema
|
||||
recorder = PostgresRecorder(schema_dsn, auto_migrate=True)
|
||||
recorder = _recorder(schema_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(
|
||||
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||
@@ -649,7 +670,7 @@ class TestCallerDimensionsAcceptance:
|
||||
置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多),
|
||||
且一旦 DBA 补上列也不会自愈——必须等重启。
|
||||
"""
|
||||
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn, auto_migrate=True)
|
||||
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
|
||||
assert recorder._failed is False
|
||||
@@ -745,7 +766,7 @@ class TestConflictTargetFreeInsert:
|
||||
断言"无写入失败 warning"是为了区分"冲突被忽略"与"整条被 PG 拒收"。
|
||||
"""
|
||||
fresh_dsn, _ = fresh_schema
|
||||
recorder = PostgresRecorder(fresh_dsn, auto_migrate=True)
|
||||
recorder = _recorder(fresh_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("nodup"))
|
||||
await _record_minimal(recorder, call_id=_cid("nodup"), response="second")
|
||||
@@ -766,7 +787,7 @@ class TestConflictTargetFreeInsert:
|
||||
遥测全线写不进去却一声不吭,只能靠"读不回来"暴露。
|
||||
"""
|
||||
part_dsn, _ = partitioned_schema
|
||||
recorder = PostgresRecorder(part_dsn, auto_migrate=True)
|
||||
recorder = _recorder(part_dsn, auto_migrate=True)
|
||||
try:
|
||||
await _record_minimal(recorder, call_id=_cid("part"), tenant_id="tenant-p")
|
||||
assert [m for m in captured_warnings if "写入失败" in m] == []
|
||||
@@ -797,7 +818,7 @@ class TestManualSchemaModeAcceptance:
|
||||
同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 25 之别。
|
||||
"""
|
||||
schema_dsn, schema = pre_tenant_schema
|
||||
recorder = PostgresRecorder(schema_dsn, auto_migrate=False)
|
||||
recorder = _recorder(schema_dsn, auto_migrate=False)
|
||||
try:
|
||||
recorded = await _record_minimal(
|
||||
recorder, call_id=_cid("man"), tenant_id="tenant-a", meta='{"k": 1}'
|
||||
@@ -837,7 +858,7 @@ class TestManualSchemaModeAcceptance:
|
||||
消灭的噪声。manual 档下 ALTER 压根不发,取而代之的是一条点名缺列并附可直接
|
||||
执行的 ALTER 的提示,而遥测照常落库。
|
||||
"""
|
||||
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn, auto_migrate=False)
|
||||
recorder = _recorder(least_privilege_pre_tenant_dsn, auto_migrate=False)
|
||||
try:
|
||||
recorded = await _record_minimal(
|
||||
recorder, call_id=_cid("manlp1"), tenant_id="tenant-b", meta='{"k": 2}'
|
||||
|
||||
Reference in New Issue
Block a user