38 Commits

Author SHA1 Message Date
iomgaa f31f7caf99 Merge branch 'feat/issue-14-circuit-open-policy'
Close issue #14: an open circuit could only kill the call on the spot.

Three things. retry_after_s now means "how long until a retry is
certainly worth attempting", so a half-open gate and an admitted probe
both report 0.0 -- which also closes a bug the issue never spotted: that
value was fed into the source cooldown memo, whose set_until only moves
forward, so a source stayed skipped in-process for a whole probe lease
(up to 2x timeout) after its probe succeeded and the gate closed. Multi
source deployments were hit too; other sources just absorbed the load.

{SCOPE}__CIRCUIT_OPEN=fail_fast|wait fills the missing cell of the
admission matrix, shaped like QUOTA_FULL. Default fail_fast keeps every
existing control flow byte-identical; single-source scopes want wait.

And the admission logic that all three governance loops had copied
verbatim now lives once, in SourceAdmission -- otherwise this fix would
have left embedding and OCR behind as divergent corners.
2026-08-20 04:00:43 -04:00
iomgaa 41bca375d2 chore: cut 1.2.4 and date its changelog entry
README first, since packaging freezes whatever it says at build time:
version pin bumped, and the capability table now mentions that an open
circuit can wait as well as fail fast. Verified the numeric claims by
measurement rather than memory -- record_llm_call still takes 24 fields,
schema.COLUMNS still has 24, meta still caps at 16 keys.
2026-08-20 03:56:51 -04:00
iomgaa 84ee6dee84 docs: file the branch review outcome in the wiki
Records what the two Codex review rounds found, which findings held up
under verification, and how each was resolved -- including the one that
changed docs rather than code. Also lists the evidence behind the
completion claim: suite counts, coverage, the 19-minute real-wait Redis
run, and the import contract.
2026-08-20 01:04:43 -04:00
iomgaa c5b2b3fade docs: correct how a wait-mode call actually dies on a dead source
Branch review caught the docs claiming something the code does not do.
CHANGELOG, README and the design's behaviour matrix all said a
force-opened source under circuit_open=wait waits out the full stall
window. It does not: the probe let through after each cooldown is a
real attempt, so it burns a max_attempts slot like any other, and a
401 source usually runs out of retry budget first -- reason is
retry_exhausted, not stalled. Which budget wins depends on
max_attempts against the cooldowns and the stall window.

The behaviour is right; only the prose was wrong. Charging the probe
to the retry budget is exactly the split issue #8 settled: the
question is who spends max_attempts, and a probe does send a real
request. A test now pins it so the claim cannot drift again.

Also drops the planned "woke up" log line. Each wait round already
logs on entry with its duration, and a still-blocked wake-up logs the
next round immediately, so a second line would only double the volume.
2026-08-20 01:00:47 -04:00
iomgaa 5a025b6e5d style: run the formatter over the issue 14 changes
ruff format only; no semantic change.
2026-08-20 00:44:21 -04:00
iomgaa d9ceaecf20 docs: record the circuit-open wait policy and the retry_after contract
README gains the key with the reason a single-source scope wants wait,
and the price of choosing it. .env.example carries the same warning
since README points at it as the full key list. ARCHITECTURE 7.4 records
why the missing cell is unrelated to source count -- and why keying on
len(sources) would be the worse debt -- plus the six-exit retry_after_s
contract and the admission convergence; 9 registers the key.

CHANGELOG stays unreleased per the release checklist: the version bump
belongs to the release run, not here. Its "read this first" section
covers the half-open retry_after_s change, which is visible even on the
default fail_fast setting.
2026-08-20 00:36:36 -04:00
iomgaa 2a9bc44abf docs: take the retry duty back into the library
The GatewayUnavailableError docstring told callers to catch it and
retry later, which reads as an invitation for every downstream to write
its own retry layer. Two layers drift -- the library retunes its
backoff and the caller never hears, the caller changes its patience and
the telemetry cannot see it -- and after that nothing can answer how
long a call actually waited or how many attempts it made.

Call-level retry, backoff, source switching and cooldown waiting all
live in the library. The exception means that budget is spent. Retrying
past it is task-level retry, a different thing, and stays outside
(ARCH 7.2, single-layer retry). Also states what retry_after_s means
now and points at CIRCUIT_OPEN.
2026-08-20 00:30:30 -04:00
iomgaa 6edf4ac9de feat: let circuit_open=wait queue instead of killing the call
on_no_runnable now dispatches on why every source was rejected instead
of falling through two serial branches. Under wait, a fully open circuit
sleeps out the cooldown and comes back for another round; the breaker's
protection is untouched (still not a single request leaves during the
wait, so no quota or money burns) -- what changes is whether the caller
dies on the spot or queues.

Dispatching is not cosmetic. Left serial, wait would fall into the quota
branch and a caller with quota_full=fail_fast would get a
quota_exhausted error while its quota was in fact fine.

_nap sleeps to the cooldown deadline rather than polling every 10ms,
which for a 60s cooldown is 6000 round trips per in-flight call on the
Redis backend. Jitter is added on top instead of scaling the wait, since
waking early before a known deadline just earns another rejection. Both
arms clamp to the remaining stall budget, so the worst case per call is
stall_window plus one poll and does not drift with max_cooldown_s. The
clamp's lower bound is the jitter itself, not poll_interval -- the
latter would have lifted the existing [0.5p, 1.0p] quota polling.
2026-08-20 00:27:00 -04:00
iomgaa eb956b2cdf feat: add the {SCOPE}__CIRCUIT_OPEN admission policy key
Limiter rejections have always chosen between waiting and failing fast;
breaker rejections had no such choice. The new key is the missing cell
of that matrix, shaped exactly like QUOTA_FULL so there is nothing new
to learn. It defaults to fail_fast: flipping the default would move
every existing deployment's worst-case wall clock from milliseconds to
the stall window, which is the wrong direction to impose on anyone.
Single-source scopes are the ones that want wait, and they now have a
way to say so.

The two keys stay separate despite sharing a domain, because a full
quota is "queue for your share" (your turn always comes) while an open
circuit is "wait for the source to recover" (it might not).

Policy validation collapses into SourceAdmission, the only consumer.
The three client constructors used to each carry their own copy of the
quota_full check; adding a second key there would have made eight
copies of the same two lines. Rejection timing and message are
unchanged -- admission is built inside those constructors.

This commit only wires the key through; the control flow that reads it
lands next.
2026-08-20 00:17:46 -04:00
iomgaa 8edd3fb2cd fix: pin retry_after_s to the next certain retry moment
retry_after_s never had a written definition, so each backend improvised
and they drifted apart. It now answers exactly one question: how long
until a retry is *certainly* worth attempting. OPEN has such a moment
(the cooldown deadline); HALF_OPEN does not, because the probe can come
back at any time -- so it reports 0.0, which already means "retry now"
elsewhere in the library.

Six exits are brought in line. The half-open rejection is the one issue
14 reported: it returned the probe lease remainder, a deadlock-guard
value derived from 2x the slowest timeout, so a 60s cooldown told
callers to wait 600s. Worse, retry.py fed that number into the source
cooldown memo, whose set_until only moves forward -- a source stayed
skipped in-process for the whole lease even after its probe succeeded
and the gate closed. That now writes an already-expired deadline, so
the memo goes back to recording only real OPEN cooldowns.

The other five were pre-existing memory/redis divergences hidden by a
contract-test blind spot (the suite pinned that a second caller gets
rejected, never what number it got): redis reported the probe TTL on
grant and the lease remainder on fenced-out writes, where memory has
always reported 0. Contract cases now pin all four half-open exits on
both backends, with 1:1 real-wait variants for redis since the
fake-clock ones skip there.
2026-08-20 00:09:20 -04:00
iomgaa 942af99856 refactor: share one admission path across the three governance loops
_pick_runnable and _on_no_runnable lived in three copies (retry.py,
embedding.py, ocr.py), the latter two being verbatim subsets of the
first. Admission semantics keep evolving -- issue #8 changed the stall
accounting, M2.5 added the AIMD pacer, issue #14 is about to add a wait
policy -- and every round had to be applied three times.

SourceAdmission now owns picking a runnable source and deciding what
happens when none is available. The three loops keep their QuotaGate,
BreakerGate and pacer references because _attempt still needs them for
write-back and pacer.leave(); those instances are shared, not rebuilt
(a second pacer would split the in-flight counter). The cooldown memo
moves in wholesale since only admission consumes it.

Behaviour is unchanged: pick differs from the old chat copy only by the
pacer None-guards, on_no_runnable is verbatim identical, and the suite
reports the same 967 passed / 21 skipped / 32 deselected as before. The
one visible change is the settle-and-release warning text, which had
three variants ("permit", "embedding permit", "OCR permit") and is now
one. Tests importing _demote_call_failures follow it to its new home.
2026-08-19 23:57:01 -04:00
iomgaa 0b3e84b3be docs: design the circuit-open wait policy for issue 14
The breaker conflates "this source is unhealthy" with "kill this call
now". Limiter rejections already choose between wait and fail_fast;
breaker rejections had no such choice, so a single-source scope loses
its whole retry budget the moment the gate opens.

Design adds {SCOPE}__CIRCUIT_OPEN (default fail_fast, so existing
deployments keep their control flow) and pins retry_after_s to "time
until a *certain* retry moment" across all six gate exits. The latter
also fixes a separate bug the issue missed: a half-open rejection fed
the probe lease (up to 2x timeout) into the source cooldown memo, whose
set_until only moves forward -- so a recovered source stayed blacklisted
in-process long after the gate closed. That one bites multi-source
deployments too, it is just hidden when other sources absorb the load.

Human-approved 2026-08-19; both documents revised after Codex review.
2026-08-19 23:45:57 -04:00
iomgaa 296c765337 chore: cut 1.2.3 and date its changelog entry
Dates the unreleased section as 1.2.3 (2026-08-19) and moves both version
strings from 1.2.1 in lockstep. The human picked a patch number knowing
this release carries five breaking changes; that is deliberate.

Two lines added to the upgrade hints: the install pin move, matching what
1.2.1 recorded for its own, and a pointer saying the zero-row RLS
self-check now also lives in the README, since CHANGELOG.md never reaches
anyone who only reads the packaged README.
2026-08-19 22:38:43 -04:00
iomgaa 429d767737 docs: carry the 1.2.3 boundary into the packaged README
The README is the only prose the sdist freezes, so anything a downstream
needs after `pip install` has to be in it before the build.

Four gaps: the install pin still floored at 1.2.1, which lets an explicit
install land on a version without the schema mode or the text cap the
same README documents; the capability table never mentioned either new
key, telemetry_schema_sql, the retention script, or the DDL template; the
"your llm_calls may be silently empty" warning about the 1.2.1 RLS
template lived only in CHANGELOG.md, which is not in the sdist; and both
references to tools/telemetry_retention.py read as if pip shipped it.

The RLS note goes above the pg-template:rls anchor, not between it and
the fence, so the block parser in test_postgres_telemetry.py still finds
all seven blocks. Telemetry field count re-measured against
inspect.signature(TelemetryRecorder.record_llm_call) and schema.COLUMNS:
still 24, so the table's number stands.
2026-08-19 22:35:45 -04:00
iomgaa 91354e4e10 Merge branch 'feat/issue-12-telemetry-retention'
issue #12: downstreams now have a way to control what the telemetry table
keeps, for how long, and who can read it. PGW_TELEMETRY_TEXT_CAP caps
message bodies, responses and thinking at the single telemetry call site
-- default None, so nothing changes unless asked. Retention ships as
tools/telemetry_retention.py, dry-run by default and stepping aside for
DROP PARTITION on partitioned tables, so the library itself never holds
DELETE rights.

The README gains a production deployment template -- three roles,
REVOKE UPDATE/DELETE, RANGE partitioning, RLS -- whose SQL the
integration test parses out of the README itself and runs against a real
Postgres, so the document cannot drift from what works. Writing it
surfaced a defect in the 1.2.1 RLS template: it bound the write-side
policy to a GUC the recorder never sets, which rejected every INSERT and
left the table silently empty.
2026-08-19 15:27:13 -04:00
iomgaa 4b06093d6c refactor: split the retention arg checks per backend
_validate carried the whole matrix in one function (cc C/13, over the
branch quality gate). Splitting it by what is actually being checked —
shared, sqlite-only, postgres-only — puts every piece at A/B.

Ordering is the part that had to survive: the chain's order is the error
messages' priority, so a run with several bad flags still reports the
same one it did before. The --vacuum/--apply pairing therefore stays in
the shared step ahead of the backend branch, where it was; it is a "do
not rewrite the whole file when you only meant to look" rule, which
holds before the question of which backend a flag belongs to.

No behavior change: all nine parser.error strings are byte-identical and
in the same order, and the eight usage-error cases pass unmodified.
2026-08-19 15:19:35 -04:00
iomgaa ea9b6fbcd9 docs: record the retention boundary and its knobs
The unreleased entry now covers both issues as one release note: #13 hands
schema control to downstreams, #12 hands over the other half — deleting
data — and ships three knobs that change nothing by default.

Top of the section is the 1.2.1 RLS template defect Task 4 found. That
template bound the write-side policy to app.tenant_id, but PostgresRecorder
writes every tenant through one pool and never calls set_config, so every
INSERT is rejected — and telemetry degrades silently, so the symptom is an
empty table, not an error. The entry says how to check for it (count rows
with a BYPASSRLS role; grep the per-row write warning) and what the new
WITH CHECK (true) template trades away.

ARCHITECTURE gets #12's half of D15: the library must not even hold the
means to delete, because REVOKE UPDATE, DELETE and a retention policy can
only be reconciled by DROP PARTITION (owner) rather than DELETE (app).
7.8 and 9 record the text cap, its default of no truncation, and why the
cut is per text rather than over the serialized JSON.
2026-08-19 15:09:55 -04:00
iomgaa daf7ab3268 docs: ship a production deployment template with its own test
README 的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节: 三角色、
REVOKE + 触发器兜底、created_at RANGE 分区与 pg_partman retention、
库需要的最小权限、合规下游的推荐配置、截断覆盖面的诚实声明,以及
SQLite 侧按天轮转库文件的保留期建议。

模板 SQL 只有 README 里这一份: 集成测试用 HTML 注释锚点
(`<!-- pg-template:* -->`)把它解析出来,做受控标识符替换后在真实
PG 的临时 schema + 临时角色上逐条执行(doctest 同款范式)。测试里
另抄一份就会与 README 各自漂移,而"README 的 SQL 能跑"这个承诺只在
同源时才成立;解析不到必须当场红,故块名与占位符都显式钉死。

新增 5 条真实 PG 用例: app 能 INSERT 不能 UPDATE/DELETE(拿到的是
权限错而非触发器错)、report 只读、未设 app.tenant_id 时读为零行且
设了只见本租户、行落进当月分区、触发器拦得住 DELETE 却拦不住
DROP PARTITION(这是"清理只能走分区"的机械化依据)。

写侧 policy 定为 WITH CHECK (true) 而非等值比较: 库用一个连接池给
所有租户写遥测且从不发 set_config,把写侧绑到 GUC 上会让每条 INSERT
被拒,而遥测的失败方向是静默降级——表现是整表零行。
2026-08-19 14:42:36 -04:00
iomgaa 511aa4899c feat: add a retention script downstreams can schedule
The library only ever SELECTs/INSERTs into llm_calls (D15), so expiring
rows has to live outside it — holding DELETE would contradict the
REVOKE UPDATE, DELETE the deployment template recommends.

tools/telemetry_retention.py is dry-run by default and prints the row
count, the created_at window and the tenant_id spread so an operator can
tell whether the rows about to go are the intended ones. The Postgres
branch refuses partitioned targets with exit code 3 (DETACH/DROP
PARTITION is O(1); DELETE is not) and otherwise deletes in per-batch
transactions. Missing asyncpg exits 2 rather than degrading quietly:
this is an ops tool, and a silent "0 rows" reads as "already clean".

Exit codes are the contract with the scheduler, so argparse errors were
moved off 2 (now 1) to keep "bad flags" distinguishable from "cannot
reach the database".

The Postgres cases run against the real instance in throwaway schemas —
never public.llm_calls — and the batch case asserts the shared table's
row count is unchanged, so a search_path that failed to apply lands as a
red test instead of a deletion.
2026-08-19 14:11:22 -04:00
iomgaa c26b34e854 feat: wire the telemetry text cap through settings
`PGW_TELEMETRY_TEXT_CAP` now reaches the emitter on every assembly path.
Unset means no truncation, which stays the default: a truncated row is
no longer audit evidence and cannot be replayed, and downstreams rely on
that today. The flip side — contracts and bids sitting in `llm_calls`
indefinitely, multi-tenant — is spelled out in `.env.example` so readers
can weigh both.

All three `from_settings` paths are wired (chat, embedding, OCR): they
write the same table, so capping only chat would leave half of it
uncontrolled. `TelemetryEmitter.__init__` now rejects `text_cap <= 0`;
it is the single point where the three clients converge, so the direct
construction path — a public assembly route the settings guard never
sees — is covered too. `0` would otherwise reduce every body to a bare
elision marker.
2026-08-19 13:57:15 -04:00
iomgaa 33ed7ecdfc feat: cap telemetry bodies at a configurable length
Chat rows stored full message and response text with no upper bound, so
downstream contracts and tenders lived in llm_calls indefinitely. Add
_cap_text/_cap_messages in the single telemetry exit (_record), applied
after digest_messages and before json.dumps, plus to response/thinking.

Capping is per text, not over the serialized JSON: cutting the whole
string would emit invalid JSON into an unvalidated TEXT column. The cap
builds new dicts and never mutates in place — digest_messages passes
non-list content straight through as the same object, so an in-place cut
would silently poison the caller's messages and the cache key.

text_cap is required on TelemetryEmitter (internal class, three known
construction sites) and defaults to None on the three public clients, so
the default behaviour stays byte-for-byte identical. Settings wiring
lands separately.
2026-08-19 13:39:17 -04:00
iomgaa e0a33ecf93 Merge branch 'feat/issue-13-schema-mode'
issue #13: the library no longer alters a downstream Postgres table on
its own. PGW_TELEMETRY_SCHEMA_MODE is tri-state and defaults by backend
-- SQLite keeps auto-migrating a local file, Postgres switches to manual,
where a stale table gets a named warning with runnable SQL and the INSERT
is trimmed to the columns that exist rather than dropping every row.

Schema constants now live in telemetry/schema.py so the SQL the library
prints cannot drift from the DDL it runs, and telemetry_schema_sql is
exported for downstreams writing their own migrations. The PG write drops
its conflict target, which partitioned tables require and which issue #12
depends on.
2026-08-19 13:24:05 -04:00
iomgaa 0721cf60aa fix: reject an empty column set in insert_sql
`insert_sql(backend, [])` 此前返回 `INSERT OR IGNORE INTO llm_calls () VALUES ()`
与 `INSERT INTO llm_calls () VALUES () ON CONFLICT DO NOTHING`,两条都语法非法。
入参正来自数据库列探测(遇到一张与本库毫无共同列的同名表,裁剪结果就是空),
把"非空"押在调用方的不变量上不成立——共享构造器自己拒,与它既有的"未知
backend""非 COLUMNS 子集"两道校验同款。

连带风险已实测确认: 两个 recorder 的空集回落都发生在调用 `insert_sql` **之前**,
故新增的 raise 不会逃出 SQLite 的 `__init__`(遥测初始化失败必须静默降级)或
PG 的准备期(`_prepare_schema` 里那次调用在 try 之外,异常会一路冒给业务调用方)。
新增 SQLite 空探测结果用例: 构造成功、写入照常、只有 warning。

同时补 PG 侧"探测结果与 COLUMNS 无交集"的回落用例(此前只有 SQLite 侧有),
并把承认缺口的那段测试注释改成断言拒绝。
2026-08-19 13:18:21 -04:00
iomgaa ba4a138692 docs: document the schema mode and the expand-contract promise
README 新增「遥测表 schema 与升级纪律」: 库对下游库只发探测/INSERT/建表
三类语句、PGW_TELEMETRY_SCHEMA_MODE 三态与两端不对称缺省的理由、
telemetry_schema_sql 用法,以及五条 Expand/Contract 承诺。CHANGELOG 未发布段
把三处破坏性变更放在最前。ARCHITECTURE 新增 D15 并在 §7.8/§9 记下 schema
单一事实源与无冲突目标写入。

新增集成用例把 telemetry_schema_sql("postgres") 的输出在空临时 schema 里执行
两遍: 断言物理列 == COLUMNS ∪ {created_at},且第二遍不报错(补列语句的
IF NOT EXISTS 幂等性)。去掉 IF NOT EXISTS 该用例即红。
2026-08-19 13:03:45 -04:00
iomgaa 483683b834 test: prove manual mode leaves a stale table untouched
真实 Postgres 上验收 issue #13 的 manual 档: 22 字段旧表加 auto_migrate=False,
information_schema 断言列一个不加(23 列而非 auto 档的 25),裁剪后的 INSERT 照常
落库,其余 22 列逐列与提交值相等;least_privilege_pre_tenant_dsn(缺列旧表 + 只授
SELECT/INSERT 的角色)下补列失败与写入失败两类 warning 全部消失,只剩一条点名
tenant_id/meta 并附可直接执行 ALTER 的准备期提示。

沿用既有隔离纪律: 临时 schema + search_path,teardown 只删自建对象,不碰共享的
public.llm_calls。

红证据(两种取法都做了):
① 把两例的 auto_migrate 临时改成 True —— 列断言红("Left contains 2 more items,
   first extra item: 'tenant_id'"),补列断言红("Postgres 遥测补列失败(写入将逐行
   降级): must be owner of table llm_calls")。
② 把 postgres.py 的 _trim_columns 临时退回 Task 3 之前(manual 档不裁剪不提示)
   —— 两例均红于 "Postgres 遥测写入失败(丢弃该行): column \"tenant_id\" of
   relation \"llm_calls\" does not exist"。
两次红都已还原,18/18 通过。

_record_minimal 改为返回实际提交的字段: 逐列断言另抄一份期望值时,抄错的列会伪装
成"库写错列位",漏抄的列则根本不被验证。
2026-08-19 12:41:26 -04:00
iomgaa 7b49e580c0 feat: expose the telemetry schema SQL to downstreams 2026-08-19 12:31:19 -04:00
iomgaa e17e1067a1 feat: derive the schema mode from the telemetry backend 2026-08-19 12:25:52 -04:00
iomgaa 21a19ab374 refactor: converge the missing-column warning into the schema module
两个 recorder 里逐字重复的 `_missing_columns_message` 收敛为 `schema.py` 的
`missing_columns_warning(backend, missing, *, alien_table)`。这条消息拼的是
给人执行的 DDL,与库自己执行的 ALTER 必须同源——留在两个 recorder 里等于在
单一事实源上开了个口子,而 Task 7 的文档还要引用这个消息格式。

纯收敛,行为零变化: 两端语句仍分别取自各自的 `SQLITE_BACKFILL` / `PG_BACKFILL`
(函数内不硬编码任何 DDL 文本),措辞、标点与换行逐字保留。已用改动前后的两份
实现对 16 组入参(4 种缺列组合 × alien 两态 × 两后端)逐串比对,输出完全相同。

顺带把 postgres.py 从 281 行降到 250、sqlite.py 降到 157。
2026-08-19 12:12:15 -04:00
iomgaa e949edb62a feat: gate the automatic ALTER behind an explicit mode
两个 recorder 的 `__init__` 增 keyword-only 必填 `auto_migrate`(设计 D-c:
缺省规则只写在 config 一处,不与类签名漂移),并把写入语句从模块级常量改为
实例级: manual 档探测到旧表缺列时一条 ALTER 都不发,改按现有列裁剪 INSERT,
准备期发一次 warning(逐列点名 + "以下维度不会被记录" + 可直接执行的补列 SQL)。

裁剪是关掉 ALTER 的前提而非增强: 旧表缺列时若既不 ALTER 又不裁剪,每一行
INSERT 都撞 `no column named tenant_id` 被整行丢弃,比自动 ALTER 更严重地
违反"遥测必录"。auto 档行为逐字不变(先探测后 ALTER、duplicate column 视为
成功、失败只 warning 不判死、写入沿用全量列)。

探测失败、或探测结果与 COLUMNS 毫无交集,两档都保守回落全量列——空列集会让
`insert_sql` 产出 `INSERT INTO llm_calls () VALUES ()`(它不拒空列表,空集
技术上是子集)。PG 侧 `_columns`/`_insert` 与 `_schema_ready` 在同一处一起
赋值,不留"已就绪但语句还是旧的"窗口。

同批改 `GatewaySettings.telemetry_auto_migrate`(按后端派生: PG False、
SQLite True)与 `client._build_telemetry` 透传: 签名变更与其唯一调用点必须
落在同一次提交,否则该提交点整条装配路 TypeError。env 键留给下一步。
2026-08-19 11:57:41 -04:00
iomgaa ecc22b34fc fix: drop the conflict target so partitioned tables can accept writes
PG requires a partitioned table's unique constraints to include the
partition key, so issue #12's RANGE partitioning on created_at forces
the primary key to (call_id, created_at). The old
`ON CONFLICT (call_id) DO NOTHING` then matches no constraint and PG
rejects every row with

    there is no unique or exclusion constraint matching the
    ON CONFLICT specification

which the recorder swallows as a per-row warning: telemetry would go
silently dark under a partitioned deployment. The target-free form is
valid on both table shapes and is literally equivalent on a plain table
(the primary key is its only unique constraint). SQLite's
`INSERT OR IGNORE` already carries no target and is untouched.

Integration coverage on the real PG instance, both inside self-created
temp schemas: a plain table still keeps one row per call_id, and a
table partitioned by created_at now accepts writes and reads them back.
The second case was red before this change with the error above.
2026-08-19 11:34:13 -04:00
iomgaa d4b40b0e64 style: reformat three files the current ruff would rewrite
Not related to the schema work. These three fail ruff format --check on
main as well -- the pinned ruff is newer than whatever last formatted
them -- and a red make check makes the per-task quality gate useless for
everything that follows.
2026-08-19 11:22:07 -04:00
iomgaa 1471e0a2c6 refactor: make the telemetry schema a single source of truth
DDL, column order and backfill statements lived twice, once in each
recorder. A public telemetry_schema_sql() would have made three copies,
and the drift shows up downstream as "I ran the printed SQL and the
library still reports a missing column".

Move both DDLs, both backfill lists and the 24 INSERT fields into
telemetry/schema.py verbatim; the recorders now import them and build
_INSERT through insert_sql(backend, COLUMNS) at import time. The
generated statements are byte-identical to the previous constants, so
runtime behaviour is unchanged (the postgres conflict target stays
bound to call_id for now).

insert_sql() validates its columns against COLUMNS: from the next task
on those names come from database probing, not from a constant, so the
subset check is the gate on the only injection surface. The new
telemetry_schema_sql() prints a paste-ready migration script; its
postgres backfill deliberately uses ADD COLUMN IF NOT EXISTS while the
library's own statements do not, because that form takes an ACCESS
EXCLUSIVE lock even when the column exists. Both variants are derived
from one declaration list so their column sets cannot drift.
2026-08-19 11:18:06 -04:00
iomgaa e9adb36577 Merge branch 'design/issue-12-13'
Designs and plans for issues #13 and #12, both reviewed by Codex and
approved by the human gate.
2026-08-19 11:02:44 -04:00
iomgaa 172f3180e5 docs: record what the plan review changed 2026-08-19 09:27:54 -04:00
iomgaa 8f792bc697 docs: correct the plans against what the code actually does
The plan review caught three mistakes that would have gone red in the
tests rather than in the implementation. Column counts: COLUMNS is the
insert field list and excludes the database-filled created_at, so a
stale table has 23 physical columns and a current one 25, not 22 and 24.
Warning capture: the library logs through loguru, which never reaches
caplog, so that assertion would have passed forever without seeing a
single line. And the stale-table-under-least-privilege fixture is
least_privilege_pre_tenant_dsn -- the other one builds a complete table
and never reaches the missing-column path at all.

Three more: make lint rewrites files, so verification uses make check;
the recorder signature change now ships with its only call site instead
of leaving a TypeError between two commits; and the backfill statements
the library runs are not the ones it prints -- the library probes first
to dodge the exclusive lock, while a script handed to a DBA has to carry
IF NOT EXISTS or it cannot be run twice.

On the cap side, all three clients build their emitter inside __init__,
so a required parameter there would strand anyone constructing a client
directly. The emitter stays required, the clients take a defaulted one.
2026-08-19 09:25:33 -04:00
iomgaa 5b2e3ba82d docs: plan both telemetry changes down to the task level
Twelve tasks across the two plans, each with the files it touches, the
evidence it has to produce, and the command that proves it. #13 goes
first: both branches edit config.py and client.py, and #12's
partitioning template leans on the schema SQL helper and the untargeted
conflict clause that #13 introduces.

Writing the cap plan surfaced a trap worth its own guard. digest_messages
appends the very same dict when a message's content is not a list, so
the telemetry copy, the caller's messages and the cache key all share
one object -- capping in place would poison the caller's request and the
cache key at once, silently. Two red-line tests now pin that down, and
the plan asks for an in-place version to be written and run first, to
prove the tests actually catch it.
2026-08-19 09:13:20 -04:00
iomgaa 39fcf2631d docs: fix the partitioning conflict the review caught
Postgres requires a partitioned table's unique constraints to cover the
partition key, so ranging on created_at forces the primary key to
(call_id, created_at) -- and ON CONFLICT (call_id) DO NOTHING then
matches no constraint at all. The retention design claimed INSERT stays
transparent under partitioning; that holds for the routing, not for the
conflict target, and telemetry would have failed outright on any
partitioned deployment. The write drops its conflict target, which is
byte-equivalent on a plain table and legal on both.

The cap design gains the three emitter construction sites it has to
touch and the relationship to the 200-char caps embed and OCR already
carry: they stay, and the new cap is the stricter of the two. Covering
all three call paths is deliberate -- their rows land in one table, and
issue #11 settled that argument already.
2026-08-19 08:59:30 -04:00
iomgaa 72b6b54719 docs: design the telemetry schema gate and the retention boundary
Both open issues ask the same question from opposite sides: how much
power the library holds over a downstream database. #13 wants the
structural writes back, #12 wants the data retention back. The two
designs share one boundary -- the library does SELECT and INSERT plus
an optional CREATE, and everything that alters structure or deletes
rows belongs to the downstream, with the library obliged to print the
exact SQL they need to run.

Two findings shape #13 beyond what the issue argues. The precedents it
cites (Hangfire's lock queue, Prefect's multi-instance race, Alembic's
audit trail) all live on a shared production Postgres, while the SQLite
side is a local file with no DBA and no migration tool, so the defaults
split by backend rather than uniformly. And turning ALTER off only
works together with trimming the INSERT to the columns that exist:
without it a stale table drops every row instead of two columns, which
breaks the telemetry rule harder than the automatic ALTER ever did.

For #12 only the body cap touches library code; retention and access
control land in the README, because the sdist carries src and the
README alone -- a template that lives in the wiki is one a downstream
pip install cannot reach.
2026-08-19 08:48:27 -04:00
52 changed files with 5573 additions and 698 deletions
+22 -1
View File
@@ -48,7 +48,12 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
# LLM__BREAKER__MAX_COOLDOWN_S=300 # 开路指数退避封顶(缺省 max(300, cooldown)) # LLM__BREAKER__MAX_COOLDOWN_S=300 # 开路指数退避封顶(缺省 max(300, cooldown))
# ── AIMD 自适应并发(M2.5,库常量非 env 键): 每源初始 8,429 ×0.5,成功 +1/limit, # ── AIMD 自适应并发(M2.5,库常量非 env 键): 每源初始 8,429 ×0.5,成功 +1/limit,
# ── ceiling = max(64, 源级 MAX_CONCURRENCY);禁用需构造函数注入自定义 pacer ── # ── ceiling = max(64, 源级 MAX_CONCURRENCY);禁用需构造函数注入自定义 pacer ──
# LLM__QUOTA_FULL=wait # wait(默认) | fail_fast # LLM__QUOTA_FULL=wait # 配额满: wait(默认) | fail_fast
# ── 熔断全拒时的处置(issue #14)。单源 scope 建议 wait: 只有一个源时
# ── "停用这个源"等于"整个 scope 停服",fail_fast 会让开路期间的每次调用
# ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样
# ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ──
# LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait
# ══ 装配选择(PGW_*)══ # ══ 装配选择(PGW_*)══
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis) PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
@@ -56,7 +61,23 @@ PGW_BREAKER_BACKEND=memory # memory | redis
PGW_CACHE_BACKEND=none # redis | memory | none(必填,显式优于隐式) PGW_CACHE_BACKEND=none # redis | memory | none(必填,显式优于隐式)
PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填) PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
# PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填 # PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
# PGW_TELEMETRY_SCHEMA_MODE=manual # auto | manual;三态: 不设 = 按后端派生(sqlite→auto、postgres→manual),
# # 显式设置则两侧都可覆盖。auto = 库给已存在的旧表自动 ALTER 补列;
# # manual = 库不发 ALTER,只 warning 点名缺列并打印可执行 SQL,
# # 按现有列裁剪 INSERT 继续写(遥测不会因缺列而全线丢失)。
# # 缺省为何不对称: postgres 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁,
# # 会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 await;
# # 且这类部署有 DBA、有迁移工具、讲最小权限,DDL 该由他们择时执行。
# # sqlite 则是下游自己的本地文件(runs/*.db):没有 DBA、没有迁移工具、
# # 没有第二个系统碰它,ALTER 是毫秒级元数据操作,强加手工 SQL 步骤是净损失。
# PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway) # PGW_TELEMETRY_PG_DSN=postgresql://user:pass@host:5432/polygateway # postgres 时必填;严禁指向在用业务库(实验室约定: 专用库 polygateway)
# PGW_TELEMETRY_TEXT_CAP=2000 # 遥测落库正文的字符上限,须 > 0;**不设 = 不截断**(缺省,逐字节留全文)。
# # 作用于 messages 的每条文本 content、多模态 text part、response 与 thinking;
# # 超出部分头部保留、尾部换成 `…(略 N 字)`。多模态 image_url 的 sha256 摘要不受影响。
# # 缺省为何是"不截断": 遥测被下游当**审计证据**用——出了问题要回答"当时到底发了什么",
# # 也要能拿原样的请求复现与重放;截断后这两件事都做不成,而既有下游正依赖这一行为。
# # 反面同样要看清: 不截断意味着客户合同、标书全文无限期留在 llm_calls 里,
# # 多租户下还混在同一张表。真在意留存面的部署应显式设一个上限,并配保留期与访问控制。
# PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None # PGW_PRICING_PATH=config/prices.json # 可选: {"<model>": {"input_per_1m": x, "output_per_1m": y}};缺省 cost 恒 None
# # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价; # # 可选第三档 "cached_input_per_1m": z —— 供应商 prompt cache 命中部分的单价;
# # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高 # # 不填即命中部分也按 input 全额计(库不猜折扣率),cost 会偏高
+104
View File
@@ -1,5 +1,109 @@
# Changelog # Changelog
## 1.2.4(2026-08-20)
熔断开路时,调用方第一次可以选择**等**而不是当场失败(issue #14)。此前准入侧有一格是空的:限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 `wait`),熔断门拒绝时**只有 fail-fast 一档且不可配**——而两者在准入语义上是同构的,都没发出请求、都带着"稍后再来"的提示。新键 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 补上这一格,形状与 `QUOTA_FULL` 逐项对齐。
**缺省是 `fail_fast`,即今天的行为**,存量部署无需改动任何配置。要改的是单源 scope:熔断的设计前提是"这个源坏了,把流量导到别的源",只配了一个源时这个前提不成立,同一段代码做的事就变成"这个源坏了,所以整个 scope 停止服务"。提交方实测:中转抖动 36 秒(22 次尝试 / 19 次 503)触发失败率通道开路,随后 30 次调用全部在 7-74 毫秒内失败,`MAX_ATTEMPTS=8` 一格没用上,一条跑了 3 小时 18 分钟的实验臂当场报废。配 `wait` 之后,熔断对配额和钱包的保护完整保留(等待期照样一个请求都不发),改变的只是调用方当场死还是排队等;代价是单次调用最坏墙钟被拉长——上限是 `STALL_WINDOW_S`(缺省 300 秒)。**但 `wait` 并不豁免重试预算**: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `MAX_ATTEMPTS`,所以密钥失效(401/403)这类一击即熔的源通常更早以 `reason=retry_exhausted` 失败,而不是等满窗口后的 `stalled`;两者哪个先到取决于 `MAX_ATTEMPTS` 与冷却时长、`STALL_WINDOW_S` 的相对大小。库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不当场死"。
### 请先读这一条: `retry_after_s` 在半开状态下的取值变了(缺省档同样生效)
`retry_after_s` 从来没有写下来的定义,于是两个后端各自发挥、互相漂移。现在它只回答一个问题:**距离确定可再试的时刻还有多久**。健康与准入允许 → `0.0`;开路 → 剩余冷却;**半开(探针在途)→ `0.0`**,因为探针随时可能出结果,不存在确定的时刻——而 `0 = 可立即重试` 本就是这个字段的既有约定。
变更点在半开:此前返回的是**探针租约剩余**。那是个死锁保护参数,派生自 `max(2 × 最慢源 TIMEOUT_S, COOLDOWN_S, TIMEOUT_S + 5)`,与"这个源多久能恢复"没有任何因果关系。`TIMEOUT_S=300` 的部署里它是 600 秒,而冷却期只有 60 秒。**照它延期重投的下游,等的是一个物理上无意义的数。**
更重的后果在库内,提交方也没发现:这个值被写进了源冷却备忘,而备忘的 `set_until` 取更晚者、不可回退。于是——源开路、冷却到期、调用①拿到探针、并发的调用②被拒并给该源记下 600 秒本地冷却、调用①的探针成功、门恢复 CLOSED——**本进程此后仍然跳过这个健康的源将近 10 分钟**。单源下每次调用照旧抛 `CircuitOpenError`;多源部署同样中招,只是别的源接住了流量,池子越大越隐蔽。修正后备忘写进的是一个已经过期的时刻,自动回到"只记开路的确定冷却期"。
同批统一了两个后端在**六个出口**上的口径。其中四处是既有的分叉:Redis 在授予探针时返回探针 TTL、在写回被 fencing 拒时返回租约剩余,而内存后端一直返回 0。契约测试此前只钉了"第二个进入者会被拒绝",从没钉过它拿到的是什么数,这个盲区把分叉掩护到了今天。
### 其他
- `_pick_runnable`/`_on_no_runnable` 此前在 chat/embedding/OCR 三条治理循环里各存一份逐字复制,现收敛为 `middleware/admission.py::SourceAdmission` 一份。行为不变——差异用注入表达(调用内降权传空计数时恒等、AIMD pacer 为 `None` 时跳过),`permit` 结算的 warning 文案由三种归一为一种。
- `GatewayUnavailableError` 的文档收回了重试职责:调用级的重试、退避、换源、等待冷却全部在库内,本异常表示那份预算已经用尽;下游据此再投属于**任务级**重试,语义不同。此前那句"业务侧 catch 本类做延期重投"读起来像在鼓励每个下游各写一份重试逻辑,而两边各写一份必然漂移。
## 1.2.3(2026-08-19)
遥测表 `llm_calls` 的结构变更从此**由下游掌控**(issue #13)。此前两个后端都会在初始化期对下游数据库发 DDL:表不存在则建表,表存在但缺列则逐列 `ALTER TABLE ADD COLUMN`,而补列**没有任何开关**——库一升级、下次调用即自动执行。在共享的生产 Postgres 上这有三重问题:`ALTER` 取 ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询(而遥测是业务路径上的内联 `await`),多进程多版本共存时谁先补列是竞态,且这些 DDL 不进任何迁移记录、事后无从审计。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里没有一个把它作为默认行为。
同一版里,issue #12 补上这条边界的另一半——**删数据**,并把它落成三样**手段**: 遥测正文的可配置上限、`tools/` 下的独立保留期脚本、README 里的一份生产部署 DDL 模板。三样**没有一样改变缺省行为**——不设 `PGW_TELEMETRY_TEXT_CAP` 即逐字节存全文,与今天完全一致。缺省不截断是刻意取舍: 截断之后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的用法;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。库本体同样不因此持有 `DELETE`/`DROP` 权限: 保留期是 `tools/` 下的独立脚本,库不 import 它。
### 请先读这一条: 照抄过 1.2.1 那份 RLS 模板的 Postgres 部署,遥测表很可能是空的
1.2.1 的 README 给的 RLS 模板把**写侧**也绑在了 `app.tenant_id` 这个 GUC 上:
```sql
-- 1.2.1 的模板,有缺陷,勿用
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''))
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
```
`PostgresRecorder` 用**一个连接池给所有租户**写遥测,源码里从不发 `set_config('app.tenant_id', ...)`——库既拿不到也不该猜租户上下文该怎么设。于是 `WITH CHECK` 里的 `current_setting` 恒为 NULL、等值比较恒不为真,**库的每一条 `INSERT` 都被 policy 拒绝**。而遥测的失败方向是静默降级,所以表现不是报错,是**整张表零行**——业务调用一切正常,不看日志根本发现不了。
照抄过就请现在查这两条:
| 查什么 | 中招的样子 |
|---|---|
| `SELECT count(*) FROM llm_calls;`,且必须用能**绕过 RLS** 的角色(superuser 或带 `BYPASSRLS` 属性的角色)——`FORCE` 之下表属主自己也受 policy 管,用它查出的 0 行分不清是"没数据"还是"读不到" | 启用 RLS 之后一直是 0,或从某个时刻起不再增长 |
| 应用日志里遥测写入的降级告警,前缀 `Postgres 遥测写入失败(丢弃该行):` | 每次调用刷一条,附带的 PG 原话是 `new row violates row-level security policy for table "llm_calls"` |
本版的新模板把写侧改为 `WITH CHECK (true)`,隔离交由**读侧**的 `USING` 承担: 在这个模型里写入方是库自己(可信),要隔离的是读取方。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(同样只留一条 warning)。完整理由与四个陷阱见 README「生产部署 DDL 模板(PostgreSQL)」第 4 小节。
### 破坏性变更(五项)
| # | 变更 | 影响与应对 |
|---|---|---|
| ① | **Postgres 侧不再自动补列**(缺省转为 manual 档) | 库升级带来新列时,旧表不会被自动 `ALTER`:库改为发**一条** warning 点名缺失的维度并附上可直接执行的 SQL,同时按现有列裁剪 `INSERT` 继续写入——**缺的那几列静默不落库**,直到有人执行那几条 SQL。要恢复旧行为设 `PGW_TELEMETRY_SCHEMA_MODE=auto`。SQLite 侧缺省不变(仍 auto),理由见下 |
| ② | 两个 recorder 新增 **keyword-only 必填**参数 `auto_migrate` | `SQLiteRecorder(db_path, *, auto_migrate)` 与 `PostgresRecorder(dsn, *, pool=None, auto_migrate)`;直接构造 recorder 的调用点必须补这个参数,不传即 `TypeError`。**故意不给默认值**:缺省规则只写在 config 一处,不与类签名漂移 |
| ③ | `GatewaySettings` 新增**必填**字段 `telemetry_auto_migrate: bool` | 只影响「构造函数全量注入」这条装配路(测试/高级用法);`from_env()` / `from_settings()` 的用户零改动。`telemetry_backend="none"` 时该字段在 `__post_init__` 归一为 `False` |
| ④ | `GatewaySettings` 再新增**必填**字段 `telemetry_text_cap: int \| None` | 同 ③,只影响直接构造这条路。`None`(不截断)是**取值**而不是默认值——字段本身没有默认值;`<= 0``__post_init__` 直接 `ValueError`,不会被当成"不截断" |
| ⑤ | `TelemetryEmitter` 新增 **keyword-only 必填**参数 `text_cap` | 库内部类,库内唯一构造者是三个公共 Client(本版已全部接通);直接构造过它的测试/高级用法不传即 `TypeError`。同样**故意不给默认值**: 漏传会静默改变落库正文。它也是值域校验的收口处——三个 Client 的 `text_cap` 全汇流到这里,而 `GatewaySettings` 那道只管 env 一条路 |
### 新增
- **`PGW_TELEMETRY_SCHEMA_MODE`(可选键,值域 `auto` / `manual`)**,**三态**:不设 = 按后端派生,显式设置 = 两侧都可覆盖。派生规则**有意不对称**——`postgres``manual`,`sqlite``auto`。理由:PG 侧是共享的生产表,有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑;SQLite 侧是下游自己的本地文件(典型是 `runs/*.db`),没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。
- **公共函数 `telemetry_schema_sql(backend) -> str`**(已进顶层 `__all__`):返回可直接粘进迁移文件的完整脚本——注释头 + `CREATE TABLE IF NOT EXISTS`(全量列)+ 各补列语句。PG 变体带 `ADD COLUMN IF NOT EXISTS`,整段**可重复执行**;SQLite 无该语法,以注释标明"仅当该列不存在时执行"。非法 `backend``ValueError`
- **manual 档的缺列告警**逐列点名并写明后果(「以下维度不会被记录: tenant_id, meta」),附上可直接执行的 ALTER,且**只在准备期发一次**,不逐行刷屏。只说"缺列"是不够的:静默丢维度的后果是多租户账目全归空串且无任何报错。
issue #12 交付的三样手段列在下表——它们改变的是**能做什么**,不是**默认做什么**:
| 手段 | 内容 |
|---|---|
| **`PGW_TELEMETRY_TEXT_CAP`**(可选正整数键) | 遥测落库正文的字符上限;**不设 = 不截断**(缺省)。作用面正好四处: `messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列;超出部分头部保留、尾部换成 `…(略 N 字)`。**按每条文本切,而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**覆盖面到此为止**: 调用方塞进 `tool_calls.function.arguments``name``content` 之外字段的内容不在其中,开了 cap 不等于表里没有全文残留 |
| **`tools/telemetry_retention.py`**(独立运维脚本) | 按 `created_at` 清理过期行。**默认 dry-run**: 先打出将删行数、`created_at` 窗口与按 `tenant_id` 的分布,让运维先判断"要删的是不是我想删的",给了 `--apply` 才真动手。退出码是与调度器(cron/systemd)的契约: `0` 正常(含 dry-run)、`1` 参数错误、`2` 连接/权限/目标表不可用(**含缺 `asyncpg`**——明确报错退出,绝不静默变成"删了 0 行")、`3` 目标是 PostgreSQL 分区表,此时脚本**拒绝 DELETE**,让路给 O(1) 的 `DETACH` + `DROP PARTITION`。请用维护角色跑,不要用应用账号(模板已对它 `REVOKE UPDATE, DELETE`) |
| **README 新增「生产部署 DDL 模板(PostgreSQL)」一节** | 三角色、`created_at` RANGE 分区与 `pg_partman` retention、`REVOKE UPDATE, DELETE` 加触发器兜底、RLS、**库自己需要的最小权限**、合规下游可直接照抄的组合配置、SQLite 侧按天轮转库文件。7 个 SQL 块带 `<!-- pg-template:* -->` 锚点,由 `tests/integration/test_postgres_telemetry.py` 从 README 解析出来在真实 PG 上逐条执行——**模板只有这一份**,不会与测试各自漂移。上面那条 RLS 缺陷正是"文档里的 SQL 从没被执行过"的产物 |
### 变更
- **Postgres 的写入去掉了冲突目标**:`ON CONFLICT (call_id) DO NOTHING``ON CONFLICT DO NOTHING`。普通表上语义**逐字等价**(表上只有主键这一个唯一约束),但带目标的版本要求恰好匹配 `(call_id)` 的唯一约束,而 PostgreSQL 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区后主键变成 `(call_id, created_at)`,该语句会被 PG 直接拒收,且失败只逐行 warning,表现为分区部署下遥测全线静默丢数据。SQLite 的 `INSERT OR IGNORE` 本就无目标,未动。
- **manual 档按现有列裁剪 `INSERT`**。这不是可选增强而是关掉 `ALTER` 的前提:旧表缺列时若仍发全量 `INSERT`,每一行都会因未知列被拒 → 遥测彻底丢失,比自动补列更严重地违反「遥测必录」。列探测失败、或探测结果与库认识的列毫无交集时,保守回落全量列(与今天的行为一致)。
- **schema 常量收敛为单一事实源** `telemetry/schema.py`(内部模块):列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警此前在两个 recorder 各存一份。收敛的理由是**正确性**而非整洁——打印给下游的 SQL 必须与库真正执行的 DDL 同源,多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
### 不变
- **manual 档仍然建表**。issue 把建表列为现状描述而非指控(它已在 #9 收口为"PG 侧先 `to_regclass` 探测、表在就不发 DDL")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。
- **auto 档行为与从前逐字相同**,包括补列失败时**不裁剪**:该档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
- 降级方向不变:缺列、补列失败、写入失败一律只 warning,绝不冒泡打断业务调用;列名与列序不变;错误面零变更。
- **遥测缺省不截断**: 不设 `PGW_TELEMETRY_TEXT_CAP` 时落库正文与今天逐字节相同。`digest_messages`(缓存 key 与遥测共用的那个摘要函数)一个字节没改,截断只发生在遥测分支、缓存路径不经过它;且截断**只产出新对象、绝不就地修改**——`digest_messages` 对非 list 的 `content` 是原样透传**同一个 dict 对象**,就地改会一并污染调用方持有的 messages、后续重试的请求体与缓存写入的 key,而且全程没有任何报错。两条红线测试分别钉死这两件事: 同一组 messages 在 cap 生效前后 `build_cache_key` 的输出逐字节相同、落库那份被截断而调用方持有的那份(含嵌套 part)一字未改。
- embedding 与 OCR 两条链路各自既有的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系;多模态 `image_url` 早已是 sha256 摘要,不受 cap 影响。
### 库对下游数据库的承诺(Expand/Contract,本版成文)
以下五条此前已被实现满足,但从未写成承诺。本版起它们是**承诺**:新列**只增不删不改名**且一律追加在既有列之后;新列必**可空**或带**非易失常量默认值**(PG 11+ 补列不重写全表,SQLite 补列是元数据操作);`INSERT` **永远显式写出列名**;库**从不 `SELECT *`**、从不读回这张表的数据(库只写不读,连探测都只查 catalog);写入的**冲突处理不绑定具体约束**。
合起来它们保证:你可以自行给 `llm_calls` 加列、加索引、挂 RLS,乃至把它建成 `PARTITION BY RANGE (created_at)` 的分区表,库的探测、补列与写入都照常工作。完整说明见 README「遥测表 schema 与升级纪律」——那份随包分发,`research-wiki/` 不在 sdist 内。
同一条边界的另一半是**删数据**: 库不持有 `DELETE`/`DROP` 权限,保留期与访问控制以 README 模板加 `tools/` 独立脚本交付。这不是保守,是两条诉求的权限张力逼出来的唯一解——模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`(那是 DDL,同样不触发不可变性触发器)。分区在这里**不可替代**,不是性能偏好。
### 升级提示
-`from_env()` / `from_settings()` 装配的下游**无需改代码**;Postgres 下游升级后建议执行一次 `python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))"` 的输出,把新列补齐(不补则新维度不落库,库会在首次写入前用一条 warning 点名)。
- 直接构造 `SQLiteRecorder` / `PostgresRecorder` 或直接构造 `GatewaySettings` 的调用点必须补上新参数/新字段,否则 `TypeError`
- **截断不需要任何升级动作**: 不设 `PGW_TELEMETRY_TEXT_CAP` 就维持全文。真在意留存面的部署应显式设一个上限,并同时配上保留期与访问控制——三件事要一起上才有意义,README 给了可直接照抄的组合。
- 已按 1.2.1 的 RLS 模板部署过 Postgres 的,请先做本版开头那两条自查,再换用新模板。该自查也进了 README 的 RLS 小节——CHANGELOG 不在 sdist 内,只读包内 README 的人否则看不到。
- README 的安装 pin 由 `>=1.2.1,<2` 收紧为 `>=1.2.3,<2`。按旧 pin 装的下游不会被锁死(仍会拿到本版),但**显式装 1.2.1/1.2.2 就没有本版的 schema 档位与截断开关**,而包内那份 README 描述的正是它们。
## 1.2.1(2026-08-18) ## 1.2.1(2026-08-18)
每次调用现在可以带上**租户标识与任意调用方自定义维度**,并逐条落进遥测表(issue #11)。`llm_calls` 存的是**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列**没有任何租户维度**——能区分来源的只有 `session_id` / `parent_call_id` 两个调用方自填、库内不校验的自由字符串。 每次调用现在可以带上**租户标识与任意调用方自定义维度**,并逐条落进遥测表(issue #11)。`llm_calls` 存的是**完整正文**(`digest_messages` 只对多模态 `image_url` 做 sha256,纯文本原样透传),多租户下游的合同与标书全文因此混在同一张表里,而原先的 22 列**没有任何租户维度**——能区分来源的只有 `session_id` / `parent_call_id` 两个调用方自填、库内不校验的自由字符串。
+235 -15
View File
@@ -13,13 +13,14 @@
| 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 | | 多源多账号 | `{SCOPE}__{PROVIDER}__{N}__*` 配置任意多源;健康感知选源(EWMA×在途 P2C)自动避开坏源 |
| 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) | | 限流 | 并发/RPM/TPM × 全局/单源六道闸;TPM 预扣入场、按实际用量结算退款;Redis 后端跨进程原子(Lua) |
| 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After | | 错误分类重试 | 一切失败落入四分类(见下),由分类决定重试/换源/熔断;429 属 pushback 不消耗重试预算;退避含 jitter 且尊重 Retry-After |
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增 | | 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 | | 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
| 背压与判死 | 配额满可选等待或快速失败;等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 | | 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | | 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数,多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 | | 遥测与成本 | 每次调用(含缓存命中与失败)必录 24 字段;SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** | | 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 | | 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` | | OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` |
| Embedding | 分批、维度校验、与 chat 同一治理栈 | | Embedding | 分批、维度校验、与 chat 同一治理栈 |
@@ -32,7 +33,7 @@
```bash ```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]>=1.2.1,<2" "polygateway[redis,postgres,structured]>=1.2.4,<2"
``` ```
核心仅依赖 `httpx` + `pydantic`;按需选 extras: 核心仅依赖 `httpx` + `pydantic`;按需选 extras:
@@ -125,32 +126,241 @@ resp = await client.chat(
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"``"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。 **1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"``"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表自动补列,**老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。 存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
**库只提供列,不启用 RLS、不建索引。** 数据库层的强制隔离,以下 DDL 是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。 **库只提供列,不启用 RLS、不建索引。** 数据库层的强制隔离是**下游 DBA 的职责,库不会代劳**;不执行则 `tenant_id` 只是一个可查可过滤的普通列,没有任何数据库层强制。库不代劳的原因是 default-deny:启用 RLS 而没有匹配的 policy = 零行可写且静默不报错,会让非多租户部署的遥测全量写失败。三角色、RLS policy、分区与保留期的完整可执行模板见[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)。
## 遥测表 schema 与升级纪律
`llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类,别的一概不发:
| 库会发 | 库不发 |
|---|---|
| 列/表探测:PG 走 `to_regclass` + `pg_attribute`,SQLite 走 `PRAGMA table_info`(都只读 catalog) | `SELECT` 表数据——**库只写不读**,故你加多少列、建多少索引、怎么分区都不影响它 |
| `INSERT`,**永远显式列名**,冲突处理不绑定具体约束(PG `ON CONFLICT DO NOTHING` / SQLite `INSERT OR IGNORE`) | `UPDATE` / `DELETE` / `TRUNCATE` / `DROP`——保留期与清理全归下游 |
| 表不存在时 `CREATE TABLE IF NOT EXISTS`(PG 侧先探测,表在就不发) | `ALTER TABLE`,**除非**该后端处于 auto 档(见下);manual 档一条 DDL 都不发 |
### 补列档位 `PGW_TELEMETRY_SCHEMA_MODE`
| 取值 | 含义 |
|---|---|
| 不设(**缺省**) | 按后端派生:`sqlite` → auto、`postgres`**manual** |
| `auto` | 旧表缺列时库逐列 `ALTER TABLE ADD COLUMN` 补齐 |
| `manual` | 库一条 `ALTER` 都不发;缺列只发**一条** warning(点名缺的维度 + 附上可直接执行的 SQL),并按现有列裁剪 `INSERT` 继续写 |
**缺省为什么两端不对称**:PG 侧是共享的生产表,`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的**所有**查询,而遥测是业务路径上的内联 `await`;这类部署有 DBA、有迁移工具、讲最小权限,DDL 的执行时机该由他们挑。SQLite 侧是下游自己的本地文件(现有下游典型是 `runs/*.db`):没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作,要求"升级后手工跑一条 SQL"是给零运维场景强加运维步骤。调研过的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)里,**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。同一个键两侧都可显式覆盖。
| 表状态 | `auto` | `manual` |
|---|---|---|
| 不存在 | 建表 | **仍然建表**(新表无既有数据、无并发访问者,不存在锁队列风险;停掉它会让"零配置起步"断掉) |
| 存在、列齐 | 不发任何 DDL | 不发任何 DDL |
| 存在、缺列 | 逐列 `ALTER`;**失败不裁剪**,缺列以逐行 warning 暴露(承诺的是"把列补上",补不上就让问题可见;要降级写入请显式选 `manual`) | 不发 DDL,裁剪写入,缺的维度不落库 |
无论哪档,遥测的失败方向都是**静默降级**:缺列、补列失败、写入失败都只 warning,绝不冒泡打断业务调用。
### 自取建表脚本
`telemetry_schema_sql` 输出与库运行时执行的 DDL **同源**(同一份常量),照它建完表,库探测到的列就是齐的:
```python
import polygateway
print(polygateway.telemetry_schema_sql("postgres")) # 或 "sqlite";非法值抛 ValueError
```
```bash
# 直接落成迁移文件:注释头 + CREATE TABLE IF NOT EXISTS(全量列)+ 各补列语句
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
> migrations/001_llm_calls.sql
```
PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`,**整段可重复执行**(它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故请挑低峰);SQLite 没有该语法,脚本以注释标明"仅当该列不存在时执行"。注意这与库**内部**执行的 ALTER 是两份文本:库侧一律先探测后 ALTER,不用 `IF NOT EXISTS`,正是为了在稳态下一条排他锁都不取。
### Expand/Contract 承诺
这张表的演进只走 expand,不走 contract。以下五条既是当前实现,也是**库对下游的承诺**——库此后的演进受它们约束:
| 承诺 | 你可以据此做什么 |
|---|---|
| 新列**只增不删不改名**,一律追加在既有列**之后** | 已有的视图、报表、ETL 不会因升级而失效 |
| 新列必**可空**,或带**非易失常量默认值** | PG 11+ 补列不重写全表,SQLite 补列是元数据操作——大表升级也是秒级 |
| `INSERT` **永远显式写出列名** | 你可以自行加列(业务维度、生成列),库的写入不受影响 |
| 库从不 `SELECT *`,也从不读回这张表的数据 | 库侧根本没有读路径,你加索引、加自己的列、挂 RLS 都影响不到它 |
| 写入的冲突处理**不绑定具体约束** | 你可以把 `llm_calls` 建成 `PARTITION BY RANGE (created_at)` 的分区表(此时主键必须是 `(call_id, created_at)`,PG 要求分区表唯一约束含分区键),库的探测、补列与写入照常工作 |
## 生产部署 DDL 模板(PostgreSQL)
上一节讲的是**库怎么对待这张表**(只探测、只 INSERT、可选建表);本节讲的是**你该把这张表部署成什么样**:谁能读、谁能写、写进去的行能不能被改、存多久。这些库一件都不代劳——它没有、也不该有这些权限。
<!-- 下面带 `pg-template:*` 锚点的 SQL 块被 tests/integration/test_postgres_telemetry.py 逐条解析并在真实 PG 上执行;改动块内容或锚点名请同步该测试。 -->
模板按下表顺序执行,标识符(角色名、schema、分区月份、密码)按你的环境改;`llm_calls` 一律不写 schema 限定,靠 `search_path` 解析,与库的写入口径一致。
| # | 锚点 | 做什么 |
|---|---|---|
| 1 | `roles` | 建三角色并授 schema 级权限 |
| 2 | `table` | 把 `llm_calls` 改造成按 `created_at` 的 RANGE 分区表,属主归 `polygateway_owner` |
| 3 | `partition` | 建一个月分区(生产用 `pg_partman` 自动滚动) |
| 4 | `grants` | 授表级权限并 `REVOKE UPDATE, DELETE` |
| 5 | `immutable` | 触发器兜底(只防误操作) |
| 6 | `rls` | 启用并 `FORCE` RLS + 两条 policy |
| 7 | `index` | `(tenant_id, created_at)` 复合索引 |
### 1. 三角色
| 角色 | 拿到什么 | 谁在用 |
|---|---|---|
| `polygateway_owner` | 表属主:DDL、加分区、删分区 | DBA / 定时任务;**不用它连库跑业务** |
| `polygateway_app` | `INSERT` + 受 RLS 约束的 `SELECT` | 库的连接串用这个 |
| `polygateway_report` | 受 RLS 约束的 `SELECT` | BI、对账、成本报表 |
<!-- pg-template:roles -->
```sql
CREATE ROLE polygateway_owner NOLOGIN;
CREATE ROLE polygateway_app LOGIN PASSWORD 'CHANGE_ME_APP';
CREATE ROLE polygateway_report LOGIN PASSWORD 'CHANGE_ME_REPORT';
GRANT polygateway_owner TO CURRENT_USER; -- 下一块要把表属主改过去,须先成为它的成员
GRANT USAGE ON SCHEMA public TO polygateway_owner, polygateway_app, polygateway_report;
GRANT CREATE ON SCHEMA public TO polygateway_owner; -- 滚动分区要在该 schema 里建表
```
### 2. 分区表
分区表**必须下游先手工建**:库的 `CREATE TABLE` 只会建普通表。列不在这里重抄一份——抄了就会漂移,故先用库自带脚本建出普通表,再原地改造:
```bash
python -c "import polygateway; print(polygateway.telemetry_schema_sql('postgres'))" \
| psql "$PGW_TELEMETRY_PG_DSN"
```
<!-- pg-template:table -->
```sql
ALTER TABLE llm_calls RENAME TO llm_calls_seed; -- 上一步建出的普通表当模子
CREATE TABLE llm_calls (
LIKE llm_calls_seed INCLUDING DEFAULTS, -- 列/类型/NOT NULL/DEFAULT 全照搬
PRIMARY KEY (call_id, created_at) -- 分区表的唯一约束必须含分区键
) PARTITION BY RANGE (created_at);
DROP TABLE llm_calls_seed;
ALTER TABLE llm_calls OWNER TO polygateway_owner;
```
<!-- pg-template:partition -->
```sql
CREATE TABLE llm_calls_2026_01 PARTITION OF llm_calls
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');
ALTER TABLE llm_calls_2026_01 OWNER TO polygateway_owner;
```
生产不要手工滚月份,交给 [`pg_partman`](https://github.com/pgpartman/pg_partman):5.x 用 `create_parent(p_parent_table := 'public.llm_calls', p_control := 'created_at', p_interval := '1 month')`(4.x 的参数序不同,以你装的版本文档为准),再把 `part_config.retention` 设成 `'6 months'``retention_keep_table` 设成 `false`,`run_maintenance_proc()` 就会到期 `DROP` 整个分区。清理必须走 `DETACH`/`DROP PARTITION` 而**不是** `DELETE`——这不是性能偏好,是权限张力的唯一解:下一块要对应用角色 `REVOKE DELETE`,而 `DROP PARTITION` 是属主的 DDL,两者不冲突,`DELETE` 则必然冲突。
**分区部署改变了幂等键**,按 `cache_hit` 出报表的下游必须知道:普通表上主键是 `call_id`,分区表上是 `(call_id, created_at)`。库的写入是无冲突目标的 `ON CONFLICT DO NOTHING`,两种表形态都合法;但 `emit_cache_hit` 复用的是响应里的**历史** `call_id`,于是同一次缓存命中的重复回放,在普通表上第二次起被 `DO NOTHING` 吞掉、在分区表上**每次都落一行**(`created_at``DEFAULT now()` 生成,主键不再重复)。逐次尝试行不受影响(每次尝试都是新 `call_id`)。
### 3. 权限与不可变性
`llm_calls` 按**不可变审计表**对待:写进去的行谁都不许改、不许删,过期数据靠 `DROP PARTITION` 整块消失。
<!-- pg-template:grants -->
```sql
GRANT INSERT, SELECT ON llm_calls TO polygateway_app;
GRANT SELECT ON llm_calls TO polygateway_report;
REVOKE UPDATE, DELETE, TRUNCATE ON llm_calls FROM polygateway_app, polygateway_report;
```
<!-- pg-template:immutable -->
```sql
CREATE FUNCTION llm_calls_reject_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'llm_calls 是不可变审计表,% 被拒绝', TG_OP;
END;
$$;
CREATE TRIGGER llm_calls_immutable BEFORE UPDATE OR DELETE ON llm_calls
FOR EACH ROW EXECUTE FUNCTION llm_calls_reject_mutation();
```
触发器**只防误操作,不防恶意**:表属主可以 `ALTER TABLE llm_calls DISABLE TRIGGER llm_calls_immutable` 把它关掉。真正的强制是上一块的 `REVOKE`——权限检查发生在触发器之前,应用角色连触发器都碰不到。要防属主本人,需要的是数据库之外的手段(WAL 归档、只追加的外部存证),不是本表能解决的。
`DROP PARTITION``DETACH PARTITION` 是 DDL,**不会触发**行级触发器,故保留期清理不受这一块影响。
### 4. 行级安全与多租户隔离
> **照抄过 1.2.1 那份 RLS 模板的部署请先查一遍**:那份模板把**写侧**也绑在 `app.tenant_id` 这个 GUC 上,而库从不设这个 GUC,于是它的每一条 `INSERT` 都被 policy 拒绝——遥测的失败方向是静默降级,表现不是报错而是**整张表零行**。用能绕过 RLS 的角色(superuser 或带 `BYPASSRLS`)执行 `SELECT count(*) FROM llm_calls;`,并在应用日志里搜 `Postgres 遥测写入失败(丢弃该行):`。下面这份是修正后的模板。
<!-- pg-template:rls -->
```sql ```sql
ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY; ALTER TABLE llm_calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免 ALTER TABLE llm_calls FORCE ROW LEVEL SECURITY; -- 属主不豁免
CREATE POLICY llm_calls_tenant_isolation ON llm_calls TO polygateway_app CREATE POLICY llm_calls_app_write ON llm_calls FOR INSERT TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')) WITH CHECK (true);
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')); CREATE POLICY llm_calls_app_read ON llm_calls FOR SELECT TO polygateway_app
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
CREATE POLICY llm_calls_report_read ON llm_calls FOR SELECT TO polygateway_report
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), ''));
``` ```
<!-- pg-template:index -->
```sql ```sql
CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created CREATE INDEX idx_llm_calls_tenant_created ON llm_calls (tenant_id, created_at);
ON llm_calls (tenant_id, created_at);
``` ```
`current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。 `current_setting(..., true)` 的第二参数令 GUC 未设时返回 NULL 而非抛错,外层 `NULLIF` 把空串归一为 NULL——合起来使**未设租户 = 零行**(fail-closed)而不是全部行。索引列序不可颠倒:启用 RLS 后 policy 给每条查询隐式追加 `tenant_id` 等值谓词,它出现在 100% 的谓词里,必然是前导列。分区表上**不能**用 `CREATE INDEX CONCURRENTLY`(PG 不支持在分区父表上并发建索引);父表此时还没有数据,直接建即可,给已有数据的普通表补索引才需要逐个分区 `CONCURRENTLY`
三个陷阱,每一个的失败形态都是**静默的**: **写侧 policy 为什么是 `WITH CHECK (true)` 而不是等值比较**:库用一个连接池给**所有**租户写遥测,且从不发 `set_config('app.tenant_id', ...)`(源码里没有这条语句)。把写侧也绑到 GUC 上,库的每一条 `INSERT` 都会被 policy 拒绝——而遥测的失败方向是静默降级,表现是逐行 warning + 整表零行。隔离在这个模型里由**读侧**承担:写入方是库自己(可信),读取方才是要隔离的人。若你的调用点保证每次调用都带 `tenant_id`,可把写侧收紧成 `WITH CHECK (tenant_id <> '')`,代价是漏传 `tenant_id` 的调用点会**丢遥测行**(只留一条 warning)。
四个陷阱,每一个的失败形态都是**静默的**:
| 陷阱 | 后果 | | 陷阱 | 后果 |
|---|---| |---|---|
| 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 | | 表属主默认**豁免** RLS | 只写 `ENABLE` 而漏 `FORCE`,用属主角色连库时隔离形同虚设,且查询一切正常看不出来 |
| `FORCE` 之后属主自己也被 policy 管 | 模板没给 `polygateway_owner` 任何 policy,故它读不到、也写不进任何行——这是有意的(它只用来做 DDL),但别拿它跑报表 |
| 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 | | 租户上下文必须在**显式事务内**用 `set_config('app.tenant_id', ..., true)` | asyncpg 默认 autocommit,单发 `SET LOCAL` 会当场失效,而 PG **只发 warning 不报错**;表现是 policy 永远拿不到租户 → fail-closed 到零行 |
| policy 必须同时`USING` `WITH CHECK` | 只写前者则租户 A 读不到 B 的行,却**能插入标着 B 的行**——污染发生在写入侧,读侧查不出来 | | 读侧 policy `USING` | `FOR SELECT` 的 policy 只认 `USING`;写成 `WITH CHECK` 不报错也不生效,隔离直接落空 |
### 5. 库本身需要的最小权限
按上面的模板部署后,库的连接串用 `polygateway_app`,它需要的权限恰好是下表这些——多一分都不必给:
| 库会发的语句 | 需要什么 |
|---|---|
| 连库 | 数据库 `CONNECT` + schema `USAGE` |
| `SELECT to_regclass('llm_calls')`、查 `pg_attribute`(列探测) | 无需额外授权(系统 catalog 默认对 `PUBLIC` 可读) |
| `INSERT INTO llm_calls (...)` | 表 `INSERT`;RLS 打开后还须有一条允许写的 policy |
| `CREATE TABLE IF NOT EXISTS`(**仅当表不存在**) | schema `CREATE`。生产建议**不给**:表由 `owner` 先建好,库探测到表在就不发这条 |
| `ALTER TABLE ADD COLUMN`(**仅 `PGW_TELEMETRY_SCHEMA_MODE=auto`**) | 表**属主**——PG 的 `ALTER TABLE` 只认属主,这一项无法单独 `GRANT`。PG 侧缺省就是 `manual`,补列交给 DBA |
### 6. 合规下游的推荐配置
三件事(截断、保留期、访问控制)要一起上才有意义,故给一份可直接照抄的组合,而不是让你自己拼:
```dotenv
PGW_TELEMETRY_BACKEND=postgres
PGW_TELEMETRY_PG_DSN=postgresql://polygateway_app:...@db:5432/telemetry
PGW_TELEMETRY_SCHEMA_MODE=manual # PG 侧本就是缺省;写出来是为了不依赖缺省
PGW_TELEMETRY_TEXT_CAP=2000 # 落库正文的字符上限;不设 = 存全文
```
| 层 | 配置 |
|---|---|
| 正文体量 | `PGW_TELEMETRY_TEXT_CAP=2000`(按需调);超出部分头部硬切并附 `…(略 N 字)` |
| 保留期 | 上面的分区模板 + `pg_partman``retention`,过期分区整块 `DROP` |
| 访问控制 | 上面的三角色 + `REVOKE UPDATE, DELETE` + `FORCE` RLS |
| 存量兜底 | 已经攒成一张大普通表、来不及改造分区时,用 `tools/telemetry_retention.py`(默认 dry-run,`--apply` 才动手;探测到分区表会直接退出让路给 `DROP PARTITION`) |
**`PGW_TELEMETRY_TEXT_CAP` 的覆盖面必须说清,否则合规判断会出错。** cap 落在四处:`messages` 里每条消息的字符串 `content`、多模态 content 数组中 `type == "text"` 的 part 的 `text`,以及 `response``thinking` 两列。消息侧的这个面与缓存摘要函数 `digest_messages` 一致——**只碰 `content`**,消息里别的字段一概不碰。所以调用方自己塞进 `tool_calls.function.arguments``name` 等字段的内容**不在覆盖范围内**:开了 cap 不等于表里没有全文残留。另需知道:缺省是**不截断**(存全文),而截断之后遥测不再是可复现重放的证据。
### 7. SQLite 侧的保留期
SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**按天/按实验轮转库文件**——`runs/<date>.db``runs/<experiment>.db` 这样,到期直接删文件。这是三个现有下游(Video-Tree-TRM5 / CHSAnalyzer / dissect)天然就有的形态,比删行省事也安全得多:删文件是 O(1) 且不可能删错行,而 `VACUUM` 会重写整库、期间需要一倍磁盘空间,还会把并发写入方挡在外面。
`tools/telemetry_retention.py` 的 SQLite 分支是给**存量场景**兜底的——已经攒成一个大库、来不及改轮转时用它,不是推荐路径。
该脚本**随仓库分发,不在 pip 包内**(它是运维工具而非库能力,库本体不 import 它,也不该拿到 `DELETE` 权限),请从仓库的 [`tools/telemetry_retention.py`](https://gitea.iomgaa.online/iomgaa/PolyGateway/src/branch/main/tools/telemetry_retention.py) 取,用维护角色跑。
## 错误模型(四分类) ## 错误模型(四分类)
@@ -190,13 +400,23 @@ CREATE INDEX CONCURRENTLY idx_llm_calls_tenant_created
|---|---| |---|---|
| `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) | | `{SCOPE}__{PROVIDER}__{N}__{FIELD}` | 第 N 个源;FIELD **全集** = BASE_URL/API_KEY/MODEL/TIMEOUT_S/MAX_CONCURRENCY/RPM/TPM/EST_TOKENS/TTFT_TIMEOUT_S/INTER_TOKEN_TIMEOUT_S/ENABLE_THINKING/MISSING_DONE/TRUST_ENV/EXTRA_BODY(表外的 FIELD 直接报错) |
| `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) | | `{SCOPE}__GLOBAL__*` | scope 级全局限额(跨源并发/RPM/TPM) |
| `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) | | `{SCOPE}__RETRY__*` / `BREAKER__*` / `BACKPRESSURE__*` / `SELECTOR` / `QUOTA_FULL` / `CIRCUIT_OPEN` | per-scope 韧性参数;缺省回落平铺键(`LLM_MAX_RETRIES` 等,兼容旧项目习惯) |
| `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) | | `{SCOPE}__BATCH_SIZE` / `NORMALIZE` / `EXPECTED_DIM` | 仅 `EmbeddingClient` 消费;`BATCH_SIZE` 必填(分批是行为关键,不设默认) |
| `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) | | `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND` | `memory`(单进程)或 `redis`(跨进程共享,需 `REDIS_URL`) |
| `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) | | `PGW_CACHE_BACKEND` | `none` / `memory` / `redis`;非 `none` 时需 `PGW_CACHE_NAMESPACE` + `PGW_CACHE_TTL_S`(须 > 0) |
| `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) | | `PGW_TELEMETRY_BACKEND` | `none` / `sqlite`(需 `PGW_TELEMETRY_SQLITE_PATH`)/ `postgres`(需 `PGW_TELEMETRY_PG_DSN`) |
| `PGW_TELEMETRY_SCHEMA_MODE` | 可选:`auto` / `manual`;**不设则按后端派生**(sqlite→`auto`、postgres→`manual`),显式设置则两侧都可覆盖。决定库是否给已存在的旧表自动 `ALTER` 补列,详见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律) |
| `PGW_TELEMETRY_TEXT_CAP` | 可选正整数:遥测落库正文的字符上限(作用于每条消息的文本 `content`、多模态 part 的 `text``response``thinking`);**不设 = 不截断**,详见[合规下游的推荐配置](#6-合规下游的推荐配置) |
| `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) | | `PGW_PRICING_PATH` / `PGW_STRUCTURED_MAX_RETRIES` / `PGW_LEASE_TTL_S` | 可选:价格表(缺省则成本恒 `None`)/ 结构化重问上限(缺省 2)/ permit 租约秒数(缺省 1500,须 ≥ 最大源 `TIMEOUT_S`) |
**`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 `fail_fast`)——单源 scope 请配 `wait`**
熔断的设计前提是"这个源坏了,把流量导到别的源"。**只配了一个源时这个前提不成立**,同一段代码做的事变成"这个源坏了,所以整个 scope 停止服务":开路期间每一次调用都在几毫秒内失败,`MAX_ATTEMPTS` 一格用不上,一个网络包都没发出去。中转抖动几十秒就足以打断一条跑了几小时的长任务。
`wait` 档改变的**只是**"调用方当场失败还是排队等":等待期间照样一个请求都不发,熔断对配额和钱包的保护完整保留。代价是单次调用的最坏墙钟被拉长,上限为 `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S`(缺省 300 秒)。**`wait` 不豁免重试预算**——冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `MAX_ATTEMPTS`;因此密钥失效(401/403)这类一击即熔的源通常更早以 `reason=retry_exhausted` 失败,而非等满窗口的 `stalled`。库无法区分"密钥坏了"和"中转抖了",选 `wait` 就是声明"宁可等也不要当场死"。多源部署保持 `fail_fast`:有源可换时,换源比等待快。
该键与 `{SCOPE}__QUOTA_FULL` 同形但**不可互相替代**:配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),所以两者分开配置。
两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。 两个易被忽略的源级键:`MISSING_DONE` 决定 SSE 缺 `[DONE]` 时的处置(`retry` 默认判瞬时重试 / `salvage` 收下已收内容并把用量可信度降为 `estimated`;零内容恒 `retry`,不受该键影响);`EXTRA_BODY` 是该源**恒定**的采样参数(JSON 对象串,并入请求体,优先级低于 `chat(overlay=...)`),禁用键 `model` / `messages` / `stream` / `stream_options` 配了直接报错,OCR 与 EMBED scope 不消费该键(配了忽略并 warning)。
`SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。 `SCOPE` 是逻辑角色(LLM/VLM/OCR/EMBED/JUDGE/SEARCH…任意大写名),同一进程可按角色装配多个 client,各自独立配置与治理状态。
@@ -224,7 +444,7 @@ graph LR
| `telemetry/` | SQLite / Postgres 遥测后端 | | `telemetry/` | SQLite / Postgres 遥测后端 |
| `structured/` | 结构化输出策略 | | `structured/` | 结构化输出策略 |
依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D14 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。 依赖纪律由 import-linter 机械化执法(`make lint`)。完整架构决策(D1-D15 含论证过程)见 [research-wiki/ARCHITECTURE.md](research-wiki/ARCHITECTURE.md)。
## 可靠性证据 ## 可靠性证据
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.2.1" version = "1.2.4"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
+30 -1
View File
@@ -119,7 +119,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
--- ---
## 3. 架构决策记录(D1D14,含讨论过程与备选方案) ## 3. 架构决策记录(D1D15,含讨论过程与备选方案)
> 每条决策记录格式:**决策 / 背景与讨论 / 被否决的备选 / 影响**。这些决策已与人类逐条确认;推翻任何一条需要人类批准并修订本节。 > 每条决策记录格式:**决策 / 背景与讨论 / 被否决的备选 / 影响**。这些决策已与人类逐条确认;推翻任何一条需要人类批准并修订本节。
@@ -248,6 +248,22 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**影响**: §5.2 `structured` 参数三档语义、§7.9 重写为阶梯、§6.1 ResultInvalid 行注 D14;缓存写入发生在阶梯通过之后(§7.5 "不固化坏结果"的执行点);反馈模板与策略升级细则留 M1 设计文档。 **影响**: §5.2 `structured` 参数三档语义、§7.9 重写为阶梯、§6.1 ResultInvalid 行注 D14;缓存写入发生在阶梯通过之后(§7.5 "不固化坏结果"的执行点);反馈模板与策略升级细则留 M1 设计文档。
### D15 库对下游数据库只做 SELECT/INSERT + 可选 CREATE;改结构与删数据归下游(2026-08-19,issue #13 立,issue #12 补删数据一面)
**决策**: 遥测表 `llm_calls` 是**下游的表**,不是库的私有存储。库对它发出的语句只有三类——catalog 探测(PG `to_regclass` + `pg_attribute`,SQLite `PRAGMA table_info`)、显式列名的 `INSERT`、以及表不存在时的 `CREATE TABLE IF NOT EXISTS`;**改结构(`ALTER`)与删数据(`UPDATE`/`DELETE`/`TRUNCATE`/`DROP`)一律归下游**。`ALTER` 保留唯一一个受控出口:`PGW_TELEMETRY_SCHEMA_MODE=auto` 时给已存在的旧表补列,而该档在 PG 侧**不是缺省**(缺省按后端派生: sqlite→auto、postgres→manual)。配套五条 Expand/Contract 承诺:新列只增不删不改名且追加在既有列之后、新列必可空或带非易失常量默认值、`INSERT` 永远显式列名、库从不 `SELECT *` 也从不读回该表数据、写入的冲突处理不绑定具体约束。
**背景与讨论**: 补列此前没有任何开关,库一升级、下次调用即在下游生产库上发 DDL。issue #13 的三条指控成立: ① 与最小权限原则冲突;② 多进程/多版本共存时谁先补列是竞态;③ DDL 不进任何迁移记录,DBA 事后无从审计。量级判据是 `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后的所有查询,而遥测是业务路径上的内联 `await`。调研的 11 个同类系统(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中**没有一个**把"库在下游库里自动 ALTER 出列"作为默认行为。
两条边界是讨论出来的、不是照抄先例: **① 缺省按后端不对称**(D-a,人类拍板)——issue 引用的全部先例语境都是共享的生产 PG,而本库的 SQLite 侧是下游自己的本地文件(没有 DBA、没有迁移工具、没有第二个系统碰它,`ALTER` 是毫秒级元数据操作),两侧统一 manual 会给零运维场景强加运维步骤;两侧有意不对称在本库已有先例(§7.8 的建表探测,issue #9)。**② manual 档不连 `CREATE TABLE` 一起停**——新建表没有既有数据与并发访问者,不存在锁队列与数据风险,停掉它会让"零配置起步"断掉(Celery 的先例同样是"自动建表 + 永不 ALTER")。**③ 关掉 `ALTER` 必须配套按现有列裁剪 `INSERT`**,否则旧表缺列时每行写入都被拒,是把自动补列换成静默全失能,比原问题更严重地违反「遥测必录」。
五条承诺本身是既有实现的**成文化**(零代码变更),但成文后才可被下游依赖——它同时是遥测保留期方案(issue #12)能成立的前提: 下游拿这份 schema 自己加 `PARTITION BY RANGE (created_at)` 建成分区表后,库的 `to_regclass` 探测、列探测与 `INSERT` 路由都照常工作。第五条(冲突处理不绑定约束)是审查带出的**新增**承诺,并伴随一处真实修复,见 §7.8。
**删数据这一半(2026-08-19,issue #12)**: D15 里 `DELETE`/`TRUNCATE`/`DROP` 归下游,不只是"库不去做",是库连**手段**都不该持有——保留期与访问控制因此以 README 的 DDL 模板加 `tools/telemetry_retention.py` 独立脚本交付,库本体不 import 该脚本,连接串上也不需要任何删权限。这是 (b) 保留期与 (c) 不可变性两条诉求的**权限张力**逼出来的唯一解: 模板建议对应用角色 `REVOKE UPDATE, DELETE ON llm_calls`(按不可变审计表对待),那么过期清理就不可能再由应用角色的 `DELETE` 完成,只能是属主对 `created_at` RANGE 分区的 `DETACH` + `DROP PARTITION`——分区在这里**不可替代**,不是性能偏好(`DROP PARTITION` 是 DDL,同样不触发行级的不可变性触发器,且 O(1)、不留膨胀)。脚本只是存量普通表的兜底: 默认 dry-run,探测到分区表即以退出码 3 让路。库本体在 #12 里唯一的代码面是**预防性**的正文截断(§7.8)——没写进去的数据不需要删,这也是三个子问题里唯一能靠库解决的那个。
**被否决的备选**: 两侧统一缺省 manual(语义最一致,但现有 SQLite 下游升级即需人工干预,而这些场景根本没有承接手工 SQL 的角色);保持 auto 缺省只加关闭档(默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足);Celery 式"自动建表但永不 ALTER、无开关"(SQLite 场景纯净损失,且真想要自动补列的下游没有出路);APScheduler 4.x 式"schema 不认识就拒绝启动"(与「遥测初始化失败必须静默降级」的库铁律正面冲突,不可选)。
**影响**: §7.8 补列一节按档位重写;新增配置键 `PGW_TELEMETRY_SCHEMA_MODE`(§9)与公共函数 `telemetry_schema_sql`;两个 recorder 新增 keyword-only 必填参数 `auto_migrate``GatewaySettings` 新增必填字段 `telemetry_auto_migrate`(缺省规则只写在 config 一处,不与类签名漂移);五条承诺进 README(随包分发)。issue #12 实现同一条边界的"删数据"一面: 新增可选键 `PGW_TELEMETRY_TEXT_CAP` 与遥测正文截断(§7.8、§9),保留期与访问控制走文档模板 + `tools/` 脚本,库的权限面不扩大。
--- ---
## 4. 总体架构 ## 4. 总体架构
@@ -456,6 +472,12 @@ flowchart TB
**M2.5 双通道开路(2026-07-21,设计 designs/2026-07-21-m25-resilience-design.md;对 CHS 连续失败语义的有意扩展)**: P6 压测实证纯连续失败语义对"高失败率但偶尔成功"的半死源失明(10% 成功率源永不开路,吃掉 76% 尝试)。判据改为满足任一即开路——① 连续失败 ≥ 阈值(CHS 兼容,保留);② 窗口(双 30s 桶,服务器钟)样本 ≥ `min_calls`(缺省 10)且失败率 ≥ `fail_rate`(缺省 0.6)。**429 不入两通道**(限速是背压不是源故障,Envoy outlier detection 同款;交健康选源软处理);ResultInvalid/网关健康拒绝不计窗口样本(坏结果 ≠ 坏服务)。开路时长指数递增 `cooldown × 2^(streak-1)` 封顶 `max_cooldown_s`(缺省 max(300, cooldown)),仅率通道开路与探针失败重开递增 streak(连续通道误熔健康源的代价封顶单次 cooldown);CLOSED 稳定满 2×cooldown_eff 后首次成功衰减归零。探针撞 429 按无果归还语义放下家接管。原则沉淀: **治理状态的粒度必须等于配额的粒度**(限流/账号退避按配额主体建 key;缓存 key 含租户同理)。 **M2.5 双通道开路(2026-07-21,设计 designs/2026-07-21-m25-resilience-design.md;对 CHS 连续失败语义的有意扩展)**: P6 压测实证纯连续失败语义对"高失败率但偶尔成功"的半死源失明(10% 成功率源永不开路,吃掉 76% 尝试)。判据改为满足任一即开路——① 连续失败 ≥ 阈值(CHS 兼容,保留);② 窗口(双 30s 桶,服务器钟)样本 ≥ `min_calls`(缺省 10)且失败率 ≥ `fail_rate`(缺省 0.6)。**429 不入两通道**(限速是背压不是源故障,Envoy outlier detection 同款;交健康选源软处理);ResultInvalid/网关健康拒绝不计窗口样本(坏结果 ≠ 坏服务)。开路时长指数递增 `cooldown × 2^(streak-1)` 封顶 `max_cooldown_s`(缺省 max(300, cooldown)),仅率通道开路与探针失败重开递增 streak(连续通道误熔健康源的代价封顶单次 cooldown);CLOSED 稳定满 2×cooldown_eff 后首次成功衰减归零。探针撞 429 按无果归还语义放下家接管。原则沉淀: **治理状态的粒度必须等于配额的粒度**(限流/账号退避按配额主体建 key;缓存 key 含租户同理)。
**熔断拒绝补齐等待档(2026-08-19,issue #14,设计 `designs/2026-08-19-issue14-admission-wait-policy-design.md`;人类确认缺省与实施边界)**: 准入侧此前有一格是空的——限流闸满时库允许排队(`{SCOPE}__QUOTA_FULL=wait|fail_fast`,缺省 wait),熔断门拒时**只有 fail-fast 一档且不可配**。两者在准入语义上同构(都不发请求、都带 `retry_after` 提示),处置却分叉。补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(缺省 **fail_fast**,不跟随 quota_full——把最坏墙钟从毫秒抬到 stall 窗口是"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游)。`wait` 档下熔断的保护作用完整保留(等待期一个请求都不发),改变的只是调用方当场死还是排队等。**这一格的缺失与源数量无关**: 多源全部同时开路(共同上游挂掉、全网抖动)行为一模一样,单源只是把"全部开路"的概率从罕见变成必然;故实现上**严禁按池大小分叉**(`if len(sources) == 1` 会让行为随配置突变且无法组合测试)。等待时长按 `retry_after_s` 睡到冷却截止(而非 `poll_interval` 空转——60 秒冷却用 10ms 轮询是 6000 次往返 × 每个在途调用),抖动**上**加不缩放(对确定的截止时刻提前醒必然白醒),并夹到剩余 stall 预算,故单次调用最坏墙钟 = `stall_window_s` + 一个 poll 间隔,不随 `max_cooldown_s` 漂移。控制流必须**按拒绝原因分派**而非串行: 串行写法下 `circuit_open=wait` 不抛之后会掉进配额分支,`quota_full=fail_fast` 的调用方会收到 `reason=quota_exhausted` 而配额其实是满的。
**`retry_after_s` 的契约定死(同批,issue #14)**: 语义 = "距离**确定**可再试的时刻还有多久"。CLOSED/准入允许 → `0.0`(现在就能试);OPEN → 剩余冷却(确定时刻);**HALF_OPEN → `0.0`**——探针随时可能出结果,不存在确定时刻,而 `0 = 可立即重试` 本就是库既有约定。此前 HALF_OPEN 返回**探针租约剩余**,那是死锁保护参数(派生自 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)`),与"源多久能恢复"无因果关系: 现场 `TIMEOUT_S=300` 时它是 600s 而冷却只有 60s。**更重的后果不在对外报数而在库内**: 该值被喂进源冷却备忘(`SourceCooldownMemo.set_until` 取更晚者、不可回退),于是探针成功、门已恢复 CLOSED 之后,本进程仍跳过该源整整一个租约——单源下每次调用照旧判死,多源下则是"池子里少一个源"且被其他源接住流量所掩盖(issue 提交方未发现这一条)。修正后备忘写入的是已过期时刻,自动回归"只记 OPEN 的确定冷却期"。契约在**六个出口**上统一(memory 三处 + redis 六个 Lua 返回格),其中后四处是**既有的双后端分叉**(redis 在授予探针时返回 probe TTL、在 fencing 未命中时返回租约剩余,而 memory 一直是 0),由契约测试盲区掩护至今——旧用例只钉"第二个进入者被拒",从没钉它拿到什么数。
**准入逻辑三处收敛(同批)**: `_pick_runnable`/`_on_no_runnable` 此前在 `middleware/retry.py``embedding.py``ocr.py` 各存一份逐字复制(后两份是第一份的子集)。准入语义一直在演进(issue #8 的 stall 口径、M2.5 的 pacer、本次的等待档),每次都要三处同步。收敛为 `middleware/admission.py::SourceAdmission`,差异用注入表达而非分支: 调用内降权传空 `attempt_fails` 时恒等、AIMD pacer 为 `None` 时跳过。`QuotaGate`/`BreakerGate`/`AdaptivePacer` 由三条循环持有并与 admission **共享同一实例**(三处 `_attempt` 仍要用它们做记账写回与 `pacer.leave()`;pacer 有在途计数,分裂成两个计数器会让 admit/enter 与 leave 记到不同账上),`SourceCooldownMemo` 归 admission 独占。
### 7.5 响应缓存 ### 7.5 响应缓存
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:` **key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling}))`,前缀 `pgw:cache:`
@@ -495,6 +517,10 @@ flowchart TB
(`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。 (`cached_prompt_tokens`/`model_reported` 为 2026-07-31 issue #3 新增,端口由 18 字段扩为 20;两个后端在初始化期对已存在的旧表幂等补列——`CREATE TABLE IF NOT EXISTS` 不会给旧表加列,不补则每行写入都被逐行 warning 丢弃。补列一律**先探测缺列再 ALTER**(`ADD COLUMN IF NOT EXISTS` 即使列已存在也先取 ACCESS EXCLUSIVE 锁,而遥测内联 await,锁共享审计表会拖垮业务调用),且**失败只逐行降级、绝不置结构性失能标志**。**建表同理(2026-08-07,issue #9)**: PG 对 schema 的 CREATE 权限检查早于 `IF NOT EXISTS` 的存在性判断(16.14 实测,只授表级 `SELECT, INSERT` 的角色写得进去却建不了表),故 PG 侧必须**先 `to_regclass` 探测、表在就不发 DDL**;SQLite 侧实测在解析期即短路(持排他锁/只读文件下该语句均通过),无同款风险,**有意不加探测**。由此把"结构性失能"的判据从「初始化时出过异常」收窄为「确定写不进去」——仅建池失败与"表确定不存在且建不出来"判死,探测/取连接失败只跳过本次并留待下次重试。新列在 DDL 里必须排在 `created_at` **之后**,与 `ALTER TABLE ADD COLUMN` 的追加位置一致,否则新建库与升级库的物理列序分叉)。链路: `session_id`/`parent_call_id` 由调用方传入贯穿(agent step → LLM call)。`messages` 落库前对多模态 part 先摘要(与缓存 key 共用同一摘要函数,§7.5)——Video-Tree 现状 base64 整段进 SQLite 导致 db 膨胀(`llm.py:330`),库内修复(2026-07-20,VT 迁移缺口 R12)。
**schema 单一事实源、档位与冲突目标(2026-08-19,issue #13,决策见 D15)**: 列序、两端 DDL、两端补列语句、`INSERT` 构造与缺列告警收敛进 `telemetry/schema.py`——此前在两个 recorder 各存一份,而公共函数 `telemetry_schema_sql` 打印给下游的 SQL 必须与库真正执行的 DDL **同源**,三份必然漂移,漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。补列自此由 `PGW_TELEMETRY_SCHEMA_MODE` 控制(三态: 不设按后端派生 sqlite→auto / postgres→manual,显式设置两侧均可覆盖): manual 档一条 DDL 都不发,改为按探测到的现有列**裁剪 `INSERT`**(裁剪是关掉 ALTER 的前提,否则缺列旧表每行写入都被拒 = 遥测全失)并发**一条**点名缺列、附可执行 SQL 的 warning;auto 档行为不变,且补列失败时**不裁剪**(该档承诺"把列补上",补不上就让缺列以逐行 warning 暴露)。**库内执行的补列语句与打印给人的那份是两套文本**: 库内不用 `ADD COLUMN IF NOT EXISTS`(它即便列已存在也先取 ACCESS EXCLUSIVE 锁,故库侧一律先探测后 ALTER),打印的那份带,以保证下游可重复执行。同批把 PG 写入的 `ON CONFLICT (call_id) DO NOTHING` 改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 带目标的语句要求恰好匹配 `(call_id)` 的唯一约束,而 PG 要求分区表的唯一约束必须包含分区键——按 `created_at` 分区(issue #12)后主键变成 `(call_id, created_at)`,该语句被 PG 直接拒收,而写失败只逐行 warning,表现为分区部署下遥测全线静默丢数据;无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键这一个唯一约束),SQLite 的 `INSERT OR IGNORE` 本就无目标。
**正文截断(2026-08-19,issue #12)**: `PGW_TELEMETRY_TEXT_CAP` 给落库正文一个可配置的字符上限,**缺省不设 = 不截断**(人类决策 E-a): 截断后的遥测不再是审计证据,也无法拿原样的请求复现与重放,而这正是既有下游在依赖的行为,默认改动即破坏;代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决一半——默认仍是全文,但下游第一次有了不写全文的手段。截断落在 `TelemetryEmitter._record`(全库唯一遥测出口,单一 helper 铁律)内,位于 `digest_messages` 之后、`json.dumps` 之前,作用面四处: 每条消息的字符串 `content`、多模态 part 中 `type == "text"``text``response``thinking`;超出部分头部硬切并附 `…(略 N 字)`。**按每条文本切而不是切整串 JSON**——后者会往不做任何校验的 TEXT 列里写进非法 JSON,让此后一切按 JSON 解析该列的分析全废。**且只产出新对象、绝不就地修改**: `digest_messages` 对非 list 的 `content` 原样透传同一个 dict 对象,就地截断会同时污染调用方持有的 messages、后续重试的请求体与缓存写入的 key 且全程无报错——红线由"cap 开与关两态下 `build_cache_key` 输出逐字节相同"的测试钉死。覆盖面须诚实声明: 只碰 `content`(与 `digest_messages` 处理面一致),调用方放进 `tool_calls.function.arguments` 等字段的内容不在其中。embedding 与 OCR 两条链路各自既有的 200 字符上限保留不动,与新 cap 是取更严者的关系。
- 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder` - 后端: `SQLiteRecorder`(默认;WAL + busy_timeout、`INSERT OR IGNORE` 幂等、`asyncio.to_thread` 桥接、初始化/写入失败全降级不冒泡)与 `PostgresRecorder`
- **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。 - **单一 helper 铁律**: 遥测调用点收敛为一个内部函数/上下文管理器;Video-Tree 与 GovDoc 各有 4-5 处逐字复制的 `record_llm_call(15 个参数)` 是本条的直接教训。
- 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。 - 成本: `pricing.py` 维护 model → (input 单价, output 单价, **可选** cached_input 单价) 表,遥测时换算 `cost` 字段;查不到价格记 None 并 warning,**不阻塞调用**。缓存读取单价(2026-07-31,issue #3)只在配置了该档且本次有命中时启用,按 `(prompt - cached) × input + cached × cached_input` 分段计价;**未配该档绝不按经验折扣率猜**,退化为全额输入价(P5)。命中数超过输入总数时按总数夹取并 warning,不产生负成本。
@@ -558,6 +584,9 @@ src/polygateway/
- **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。 - **per-scope 韧性配置(2026-07-20,CHS 迁移缺口 G4)**: 韧性参数支持按 scope 覆盖——`{SCOPE}__RETRY__MAX_ATTEMPTS` / `{SCOPE}__BREAKER__FAIL_THRESHOLD` / `{SCOPE}__BREAKER__COOLDOWN_S` / `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S` / `{SCOPE}__SELECTOR` / `{SCOPE}__GLOBAL__MAX_CONCURRENCY|RPM|TPM`(CHS 现状: VLM 与 OCR 两 scope 参数各异)。平铺键(`LLM_*`)是单 scope 场景的简写;两者并存时 scope 键优先。
- **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。 - **装配只有两条路**: `GatewayClient.from_env()`/`from_settings(settings)`(工厂,覆盖 90% 用户;补上三项目每次手写、GovDoc 缺失的"配置→client"一段)或构造函数全量依赖注入(测试/高级用户)。库内部任何组件**不得自读环境变量**(显式优于隐式)。
- 后端选择即配置: 如 `PGW_LIMITER_BACKEND=memory|redis``PGW_TELEMETRY_BACKEND=sqlite|postgres``PGW_QUOTA_FULL=wait|fail_fast`(命名待 M1 设计文档定稿)。 - 后端选择即配置: 如 `PGW_LIMITER_BACKEND=memory|redis``PGW_TELEMETRY_BACKEND=sqlite|postgres``PGW_QUOTA_FULL=wait|fail_fast`(命名待 M1 设计文档定稿)。
- **`{SCOPE}__CIRCUIT_OPEN=fail_fast|wait`(2026-08-19,issue #14)**: 熔断全拒时的处置,与 `{SCOPE}__QUOTA_FULL` 同形同族(上一条"后端选择即配置"里记的 `PGW_QUOTA_FULL` 是 M1 定稿前的暂拟名,实际落地为 scope 键 `{SCOPE}__QUOTA_FULL`)。缺省 **fail_fast** = 存量下游的控制流逐字不变;**单源 scope 应显式配 `wait`**。两键值域相同但语义不同故分列: 配额满是"排队等自己的份额"(必然轮到),熔断开路是"等这个源恢复"(未必恢复),调用方可能想要"配额满就等、源坏了就立刻失败"。落到 `GatewaySettings.circuit_open`(无默认值,与既有全部字段一致),校验收敛在唯一消费者 `SourceAdmission` 一处——三个客户端构造函数此前各带一份 `quota_full` 校验,再加一键就是八处复制。
- **`PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(2026-08-19,issue #13,D15)**: 可选键、**三态**——不设 = 按后端派生(sqlite→auto、postgres→manual),显式设置则两侧都可覆盖。派生只发生在 config 层一处,落到 `GatewaySettings.telemetry_auto_migrate`(无默认值,与既有全部字段一致;`telemetry_backend=none` 时无人消费,归一为 `False`),recorder 的 `auto_migrate` 是 keyword-only **必填**参数——关键行为参数不给默认值(P4),缺省规则也就不会与类签名漂移。
- **`PGW_TELEMETRY_TEXT_CAP`(2026-08-19,issue #12)**: 可选正整数键、**二态**——不设 = 不截断(缺省)。与相邻的 `SCHEMA_MODE` 不同,这里"未设"本身就是最终答案,没有需要按后端派生的第二种缺省。落到 `GatewaySettings.telemetry_text_cap: int | None`(同样无默认值),`TelemetryEmitter.text_cap` 是 keyword-only 必填参数。值域(`> 0`)在 settings 与 emitter **两处**校验: 前者只管 env 一条路,而"构造函数全量注入"是库承诺的另一条公共装配路,`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
--- ---
@@ -0,0 +1,145 @@
# issue #12 设计: 遥测表的正文体量、保留期与访问控制
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #12、#11(维度落地)、#10(截断先例)
> 同批交付: [issue #13 遥测 schema 档位](2026-08-19-issue13-schema-mode-design.md)
## 1. 问题
`llm_calls` 存的是**完整正文**: `messages` 落库前只过 `digest_messages`,而它只对多模态 part 里的 `image_url` 做 sha256,纯文本原样透传;`response` 同理。Embedding 路径有 200 字符上限,LLM 路径没有。issue #11 之后 `tenant_id` 已是真实列、RLS 模板已进 README,但另外两件事仍是空白:
1. **保留期**: 没有任何 TTL、归档或清理机制,写进去的行永久留存。删除请求(数据主体权利)无处执行。
2. **访问控制的默认状态**: 库不执行任何 GRANT/REVOKE,也不建议下游怎么分角色。默认是"任何能连库的账号都能读全部租户的全文"。RLS 只挡住"用错租户上下文查询",挡不住"用一个有全表权限的账号连上来"。
这与 #11 的不可逆性论证同类: 数据一旦以当前形态写进去,事后再补保留期,已经超期的那部分**已经存在了**。
## 2. 已定决策(人类,2026-08-19)
| # | 决策 | 选择 |
|---|---|---|
| E-a | 正文截断 | 新增**可配置**上限,**缺省不截断**(保持现状全文) |
| E-b | 保留期 | 文档模板 **+** `tools/` 独立脚本;库本体不持有 DELETE/DROP 权限 |
| E-c | 交付节奏 | 独立分支实现,与 issue #13 合并发 1.2.3 |
E-a 取"缺省不截断"的理由: 截断后遥测不再是审计证据、也无法用于复现与重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只被解决了一半——默认仍是全文,但下游第一次有了不写全文的手段。
## 3. 三个子问题的边界
| 子问题 | 库能做什么 | 性质 |
|---|---|---|
| (a) 正文体量 | 遥测路径可配置截断 | **唯一改库本体代码的**,也是唯一**预防性**手段: 没写进去的数据不需要删 |
| (b) 保留期 | 分区 + retention 模板;`tools/` 清理脚本 | 文档 + 可选工具,库不执行 DELETE/DROP |
| (c) 访问控制与不可变性 | 角色划分模板、`REVOKE UPDATE, DELETE`、分区 | 纯文档 |
(b)(c) 不进库本体,与 issue #11 对 RLS 的结论、issue #13 对 DDL 的收缩同一条边界: **库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。** 建议将其写进 ARCHITECTURE 作为一条独立决策(D15),两条 issue 各实现它的一面。
## 4. 备选方案对比
| 方案 | 内容 | 权衡 | 结论 |
|---|---|---|---|
| **A(采纳)** | 可配置截断(缺省 None) + 文档模板 + tools 脚本 | 三个子问题都有落点;库权限面不扩大;下游按需取用 | ✅ |
| B | 缺省即截断(如对齐 embedding 的 200 或更宽松的 4096) | 合规面默认安全 | ❌ 破坏性: 所有现有下游升级后遥测正文被静默削短,而它们的分析/复现正建立在全文之上 |
| C | 库内建 TTL/清理(定时任务或写入时顺带删) | 下游零运维 | ❌ 库需要 DELETE 权限,与 (c) 的 `REVOKE UPDATE, DELETE` 建议直接冲突;且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务 |
| D | 给 `TelemetryRecorder` 端口加 `purge_before(ts)` | 语义清晰、下游自己调度 | ❌ 冻结签名的端口扩展 + 库仍需 DELETE 权限,同 C 的冲突 |
| E | 什么都不做,只在文档写"本表存全文,请自行评估合规" | 零代码零风险 | ❌ 下游唯一的手段是不用遥测 |
## 5. 设计: (a) 正文截断
### 5.1 配置与装配
| 层 | 形态 |
|---|---|
| 环境 | `PGW_TELEMETRY_TEXT_CAP`(可选键,正整数;未设 = 不截断) |
| `GatewaySettings` | 新增字段 `telemetry_text_cap: int \| None`(无默认值,与既有字段一致);`<= 0``ValueError` |
| `TelemetryEmitter` | 新增 keyword-only **必填**参数 `text_cap: int \| None`(与 issue #13 的 D-c 同一纪律: 关键行为参数不给默认值);库内三个构造点 `client.py:149` / `embedding.py:131` / `ocr.py:130` 必须同步传参,否则 `TypeError`(测试内另有十余处) |
### 5.2 作用面与切法
截断发生在 `TelemetryEmitter._record` ——全库**唯一**的遥测调用点(铁律),在 `digest_messages` 之后、`json.dumps` 之前。作用于 `messages` 的每条文本 `content`(含多模态 part 中 `type == "text"``text` 字段)、`response``thinking`
**按每条文本切,而不是切整串 JSON**: 后者会产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据会静默存进去)。
**头部硬切 + 标记省略字数**(形如 `…(略 12345 字)`),**不复用** `_http_errors.summarize_body`: 那个函数折叠空白并保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,而保头保尾服务的是"诊断时要看清 type/code/request_id",与"我不想存全文"这个用途无关。视觉标记口径保持一致,实现各自独立。
非字符串 `content`(外部输入,可能是任意 JSON 值)原样放行,不做类型强转(P5: 校验后使用,但遥测路径不得因输入形状抛错)。
**覆盖面的诚实声明**: 截断作用于 `content` 文本,与 `digest_messages` 的处理面一致。调用方放进 `tool_calls.function.arguments` 等其他字段的内容不在覆盖范围内,文档须写明。
**三条链路全覆盖,不只 chat**(Codex 审查提出后核实定稿): `_record` 是 chat / embed / OCR 共同的出口,cap 自然作用于全部三条。这与 issue #11 的判断同款——三条链路的行落**同一张表**,只覆盖一条会让同表内一部分行受控、一部分不受控。核实后的实际影响远小于直觉: `embedding.py:73``ocr.py:73` 各已有 200 字符的自有上限(embed 截 `texts`、OCR 的 `messages` 本就是 `<ocr:kind image_bytes=N>` 占位、`response``_summarize` 截 200),两者**保留不动**,与新 cap 是"取更严者"的关系。issue #12 那句"LLM 路径没有上限"因此是准确的——真正没有上限的只有 chat 路径。
### 5.3 红线
**`digest_messages` 一个字节都不能碰。** 它是缓存 key 与遥测共用的函数(`middleware/cache.py:31`),动它 = 全量缓存 miss + 缓存 key 口径分叉。截断只发生在遥测分支,缓存路径不经过它。此红线有机械化验收(见 §8)。
## 6. 设计: (b) 保留期
**README 模板**: PG 侧给 `created_at` 的 RANGE 月分区 + `pg_partman` retention(过期靠 DETACH/DROP 分区实现 O(1) 清理,而非 `DELETE`——审计表通行做法);SQLite 侧给文件轮转建议(按天/按实验一个库文件,是三个现有下游天然的形态)。
### 6.1 分区与幂等写入的冲突(Codex 审查发现,阻断级)
PostgreSQL 要求分区表上的唯一约束(含主键)**必须包含分区键**。按 `created_at` 做 RANGE 分区后,`call_id TEXT PRIMARY KEY` 不再合法,主键须改为 `(call_id, created_at)`;而库今天的写入语句是 `ON CONFLICT (call_id) DO NOTHING`,它需要一个恰好匹配 `(call_id)` 的唯一约束——分区表上不存在,写入会**直接报错**。原设计"INSERT 路由对分区表透明"只对普通 INSERT 成立,对冲突目标不成立。
修法: 库的写入改为**无冲突目标**的 `ON CONFLICT DO NOTHING`。它在两种表形态上都合法,且在普通表上与今天逐字等价(表上只有主键一个唯一约束)。**该改动归入 issue #13 实现**——#13 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。
**分区部署的语义差异须写进文档**: 分区表上幂等键实际是 `(call_id, created_at)`,而 `created_at` 由数据库 `DEFAULT now()` 生成,故同一 `call_id` 重复写入不再被拦。这对逐次尝试行无影响(每次尝试一个新 `call_id`),但会改变**缓存命中行**的表现——`emit_cache_hit` 复用的是响应里的历史 `call_id`,在普通表上第二次及以后的命中会被 `DO NOTHING` 吞掉,在分区表上则每次都落一行。这是既有行为在两种部署形态下的差异,不是本次引入的变更,库不做二次判定,但下游按 `cache_hit` 统计时必须知道。
### 6.2 模板与工具
分区表**必须由下游先手工建**,库的 `CREATE TABLE` 只会建普通表。这正是 issue #13`telemetry_schema_sql()` 的用途: 下游取到库要求的最小 schema,自己加上 `PARTITION BY RANGE (created_at)` 再建。库的 `to_regclass` 探测与 INSERT 路由对分区表透明,列探测同样有效(#13 的 Expand/Contract 承诺保证这一点)。
**`tools/telemetry_retention.py`**(独立脚本,不被 import,符合 `tools/` 规则):
| 项 | 设计 |
|---|---|
| 参数 | `--backend sqlite\|postgres``--path/--dsn``--older-than-days N``--apply`(**默认 dry-run**)、`--batch-size``--vacuum`(仅 SQLite,显式) |
| 输出 | 将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布 |
| PG | 分批 DELETE(避免长事务与锁膨胀);检出目标是分区表时**改为提示用 DROP PARTITION** 并拒绝 DELETE |
| 权限 | 文档写明: 用维护角色跑,不要用应用账号(应用账号已被 `REVOKE DELETE`) |
| 依赖 | SQLite 走标准库;PG 需 `asyncpg`,缺失时明确报错退出(不静默降级——这是运维工具不是库路径) |
## 7. 设计: (c) 访问控制与不可变性(纯文档)
README 现有的多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节。文档落点必须是 **README**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),放进 wiki 的模板下游 `pip install` 后读不到——这正是 56f3805 的教训。Wiki 同步一份并互链。
| 内容 | 要点 |
|---|---|
| 三角色 | `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束) |
| 不可变性 | `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底只防误操作**不防恶意**(属主可 disable),须写明 |
| 分区 | 与 §6 的 retention 模板同一段落 |
| 库需要的权限 | 明确列出: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。下游据此最小授权 |
**权限张力必须写明**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
## 8. 非功能维度
| 维度 | 结论 |
|---|---|
| 并发与取消 | 截断是纯计算,不新增 await 点、不新增锁;`_record` 既有的 `except asyncio.CancelledError: raise` 保持在最外层,取消穿透路径不变 |
| 降级方向 | 不变(遥测静默降级): 截断逻辑若抛错,仍被 `_record` 的降级 `try` 接住 → warning + 丢一行,不冒泡给调用方 |
| 幂等与重复 | 截断是纯函数,同输入同输出;`call_id` 幂等键与写入语义不变 |
| 持久化与原子性 | 库本体不变;`tools/` 脚本的 PG 分批删除每批一个事务,中断只影响未删批次,不产生半行数据 |
## 9. 错误处理与测试策略
| 层 | 用例 |
|---|---|
| unit | `cap=None` → 正文原样;`cap=N` → 每条 content 被截且整串 JSON 仍可解析;多模态 part 的 `text` 被截而 `image_url` 的 sha256 不动;`response`/`thinking` 被截;标记含省略字数;非字符串 content 不抛错 |
| unit(**红线验收**) | 同一组 messages 在 `cap` 开与关两态下 `build_cache_key` 输出**逐字节相同**——机械化钉死"截断不得污染缓存 key" |
| unit | config: 未设 → `None`;`<= 0``ValueError`;合法值透传到 emitter |
| unit | `tools/` 脚本: 真实临时 SQLite 上 dry-run 不删任何行、`--apply` 删除且仅删除超期行、`--older-than-days 0` 的边界 |
| unit | OCR 与 embed 两条链路的遥测行同样受 cap 约束(与既有 200 上限取更严者),三个 emitter 构造点全部传参 |
| integration(真实 PG) | 无冲突目标的 `ON CONFLICT DO NOTHING` 在**普通表与分区表上都能幂等写入**(分区表主键为 `(call_id, created_at)`);此条与 issue #13 的实现同批验收 |
| integration(真实 PG) | **README 的模板 SQL 逐条执行**: 三角色 + REVOKE + 分区 + RLS 建起来后,app 角色能 INSERT 不能 DELETE、report 角色只读、跨租户查询为零行。README 里的 SQL 若有错,下游照抄就中招,故文档模板必须有机械化验收 |
遥测路径的一切失败仍不落四分类;配置校验抛裸 `ValueError`(公共入口先例)。
## 10. 兼容性、文档与发布
**非破坏性**(除 §5.1 两处必填参数带来的直接构造路改动,与 issue #13 同批): 缺省 `text_cap=None` 时行为与今天逐字节相同。
文档同步: README(截断配置 + 生产部署 DDL 模板 + 库所需最小权限)、`.env.example`、ARCHITECTURE(D15 边界 + §7.8 遥测字段说明)、Wiki `指南-遥测与成本` / `参考-配置键` / `参考-公共API`、CHANGELOG。
## 11. 开放问题
1. `tools/telemetry_retention.py` 是否需要覆盖"按 `tenant_id` 定向删除"(数据主体删除请求的实际形态)。本设计只做按时间清理;定向删除涉及"删哪些行由业务判断",偏向下游职责,暂不纳入。
2. 触发器兜底模板是否纳入 README(本设计: 纳入,但明确标注它只防误操作)。
3. Codex 提出"缺省不截断只解决了 issue 一半的默认安全诉求"——这是人类已定的 E-a 决策,不是疏漏,设计 §2 已显式记录取舍。作为补偿,README 须给出**合规下游的推荐配置**(cap + 分区 retention + 三角色)作为一段可直接照抄的组合,而不是把三件事散在各处让下游自己拼。
@@ -0,0 +1,146 @@
# issue #13 设计: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
> 状态: 待人类审批 | 日期: 2026-08-19 | 关联: issue #13、#11(同源)、#9(探测纪律)、#3(补列由来)
> 同批交付: [issue #12 遥测保留期与访问控制](2026-08-19-issue12-telemetry-retention-design.md)
## 1. 问题
两个遥测后端在构造期(SQLite)/首次写入前(PG)会对下游数据库发 DDL: 表不存在则 `CREATE TABLE`,表存在但缺列则逐列 `ALTER TABLE ... ADD COLUMN`。**补列没有任何开关**,库升级后首次调用即自动执行,而 issue #11 刚给这张表加了两列,这条路径的使用频率正在上升。
issue #13 的三条指控成立: ① 库在下游**生产**表上发不受控 DDL,与最小权限原则冲突; ② 多进程/多版本共存时谁先补列是竞态; ③ DDL 不进任何迁移记录,下游 DBA 事后无从审计表何时被谁改过。调研的 11 个同类先例(Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow)中,**没有一个支持"库在下游库里自动 ALTER 出列"作为默认行为**。
### 1.1 issue 未区分、但决定方案形状的两点
**① SQLite 与 Postgres 的风险完全不对称。** issue 引用的全部先例(Hangfire 的锁队列雪崩、Prefect 的多实例竞态、Alembic 的 DBA 审计链)语境都是**共享的生产 PG**: `ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联 await。本库的 SQLite 侧则是下游自己的本地文件(VT / CHSAnalyzer / dissect 的 `runs/*.db` 全是这个形态): 没有 DBA、没有迁移工具、没有第二个系统碰它,ALTER 是毫秒级元数据操作。让 SQLite 也要求"升级后手工跑一条 SQL",是给零运维场景强加运维步骤。两侧有意不对称在本库已有先例——`sqlite.py` 文件头写着"别为了代码对称把建表探测加回来"(issue #9)。
**② 关掉 ALTER 必须配套"按现有列裁剪 INSERT",否则是把自动补列换成静默全失能。** 今天 `_INSERT` 是 24 列的固定语句。旧表缺 `tenant_id` 时若不 ALTER,INSERT 会因未知列**全部失败** → 逐行 warning → 遥测彻底丢失。这比自动 ALTER 更严重地违反"遥测必录"。故降级写入不是可选增强,是本变更成立的前提。
## 2. 已定决策(人类,2026-08-19)
| # | 决策 | 选择 |
|---|---|---|
| D-a | 默认档 | **不对称**: PG 默认 manual(不 ALTER),SQLite 默认 auto(保持自动);同一配置项两侧均可覆盖 |
| D-b | SQL 投放渠道 | warning 打印完整语句 **+** 新增公共函数供下游主动索取 |
| D-c | 缺省规则落点 | **config 层派生**,recorder 的开关参数为 keyword-only **必填** |
| D-d | 交付节奏 | 独立分支实现,与 issue #12 合并发 **1.2.3** |
## 3. 备选方案对比
| 方案 | 内容 | 权衡 | 结论 |
|---|---|---|---|
| **A(采纳)** | 按后端不对称默认 + 三态配置 + 裁剪写入 + schema SQL 公共函数 | PG 侧满足 issue 全部诉求;SQLite 侧零运维负担不变;代价是同一配置键在两后端缺省值不同,须文档讲清 | ✅ |
| B | 两侧统一默认 manual | 语义最一致、最贴 issue 原文 | ❌ 现有 SQLite 下游(VT/CHS/dissect)升级即需人工干预,否则新维度静默缺失,而这些场景根本没有承接手工 SQL 的角色 |
| C | 保持 auto 默认,只加关闭档 | 非破坏性 | ❌ 默认状态仍是"库在下游生产表上发不受控 DDL",issue 的核心诉求未被满足,只是提供了绕法 |
| D | Celery 式: 自动建表但**永不** ALTER,无开关 | 最简、无配置面 | ❌ SQLite 场景纯净损失;且下游若确实想要自动补列,库不给任何出路 |
| E | APScheduler 4.x 式: schema 不认识就 `RuntimeError` 拒绝启动 | 最安全的一致性保证 | ❌ 与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选 |
## 4. 设计
### 4.1 配置与装配
新增环境键 `PGW_TELEMETRY_SCHEMA_MODE`,值域 `auto | manual`,**三态**: 未设 = 按后端派生,显式设置 = 两侧都可覆盖。
| 层 | 形态 | 理由 |
|---|---|---|
| 环境 | `PGW_TELEMETRY_SCHEMA_MODE`(可选键),经既有 `_load_choice` 校验值域 | 与 `PGW_LIMITER_BACKEND` 等同族 |
| `GatewaySettings` | 新增字段 `telemetry_auto_migrate: bool`,**无默认值**(与既有全部字段一致) | settings 承载的是装配事实而非环境文本;派生只发生一次 |
| recorder | `SQLiteRecorder(db_path, *, auto_migrate: bool)``PostgresRecorder(dsn, *, pool=None, auto_migrate: bool)`,keyword-only **必填** | D-c: 关键行为参数不给默认值(P4);缺省规则只写在 config 一处,不会与类签名漂移 |
`telemetry_backend=none` 时无 recorder 消费该字段,派生为 `False`
### 4.2 行为矩阵
| 场景 | auto(今天的行为) | manual(新增) |
|---|---|---|
| 表不存在 | 建表 | **仍然建表** |
| 表存在、列齐 | 不发任何 DDL | 不发任何 DDL |
| 表存在、缺列 | 逐列 ALTER;失败只 warning,不判死 | **不发 DDL**;warning 逐列点名 + 打印可执行 SQL(仅一次);按现有列裁剪 INSERT 继续写入 |
| 列探测失败 | warning,沿用全量 24 列 | warning,沿用全量 24 列 |
**manual 档为什么不连 `CREATE TABLE` 一起停**: issue 把建表列为现状描述而非指控(它已在 #3/#9 收口为"先探测后建")。新建表没有既有数据、没有并发访问者,不存在锁队列与数据风险,而停掉它会让"零配置起步"这条路彻底断掉。Celery 的先例同样是"自动建表 + 永不 ALTER"。
### 4.3 裁剪写入
`effective_columns = [c for c in COLUMNS if c in existing]`(保序),据此实例级构造 INSERT 语句,`record_llm_call``self._columns` 取值。SQLite 在 `__init__` 末尾定型,PG 在 `_prepare_schema` 成功后与 `_schema_ready` **一起**赋值(两者必须同时生效,否则会出现"已就绪但语句还是旧的"的窗口)。
缺列 warning 必须**逐列点名**并写明后果("以下维度不会被记录: tenant_id, meta"),不能只说"缺列"——静默丢维度的后果是多租户账目全归空串且无任何报错。warning 只在准备期发一次,不逐行。
`call_id` 若不在现有列内,说明该表不是本库的 `llm_calls`(下游魔改或撞名),warning 升级措辞并照常尝试写入(由数据库自己拒绝),库不做二次判定。
### 4.4 新公共函数(D-b)
```python
polygateway.telemetry_schema_sql(backend: str) -> str
```
返回可直接粘进迁移文件的完整脚本: 注释头 + `CREATE TABLE IF NOT EXISTS`(全量列) + 分隔注释 + 各补列语句(PG 用 `ADD COLUMN IF NOT EXISTS`;SQLite 无该语法,以注释标明"仅当列不存在时执行")。非法 `backend``ValueError`(公共入口显式校验,先例同 issue #11 的维度校验)。
**这不是锦上添花而是正确性要求**: 打印的 SQL 必须与库真正执行的 DDL 同源。今天 `_DDL` / `_BACKFILL` / `_COLUMNS``sqlite.py``postgres.py` 各存一份,公共函数若再写一份,三份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。故新增 `telemetry/schema.py` 收敛为单一事实源,两个 recorder 与公共函数共用;顶层 `__init__` re-export 进 `__all__`。依赖方向不变(schema.py 在 telemetry 层内部,不 import 任何其他层),import-linter 契约无需改动。
### 4.5 Expand/Contract 成文化(零代码)
库已满足前三条,但从未文档化为承诺。本次写进 README 与 ARCHITECTURE §7.8: **新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`(库只写不读)、写入的冲突处理不绑定具体约束**。最后一条是 Codex 审查带出的**新增承诺**,见 §4.6。
它同时是 issue #12 分区方案能成立的前提——下游把 `llm_calls` 建成分区表后,库的 `to_regclass` 探测、列探测与 INSERT 路由都照常工作。
### 4.6 冲突目标改为无绑定(Codex 审查发现,阻断级)
PG 侧今天的写入是 `ON CONFLICT (call_id) DO NOTHING`,它要求一个恰好匹配 `(call_id)` 的唯一约束。而 PostgreSQL 要求分区表的唯一约束**必须包含分区键**——issue #12 的按 `created_at` 分区方案会把主键逼成 `(call_id, created_at)`,届时该语句**直接报错**,遥测在分区部署下全线写不进去。
改为**无冲突目标**的 `ON CONFLICT DO NOTHING`: 两种表形态都合法,普通表上与今天逐字等价(表上只有主键这一个唯一约束),SQLite 侧的 `INSERT OR IGNORE` 本就无目标、无需改动。
改动归属本 issue 而非 #12: 本 issue 已经在重写 INSERT 语句的构造逻辑并把 schema 常量收敛进 `telemetry/schema.py`,两条分支不应改同一行。分区部署下幂等语义的差异(缓存命中行复用历史 `call_id`)由 #12 的文档承接。
## 5. 旧版行为审计
| 既有行为 | 处置 |
|---|---|
| SQLite 构造期 `PRAGMA table_info` 探测 | 保留 |
| SQLite 逐列独立 try、`duplicate column` 视为成功(多进程共库竞态) | 保留(auto 档) |
| SQLite 补列失败只 warning、绝不清空 `_conn` | 保留 |
| SQLite 不做建表前探测(issue #9 的有意不对称) | 保留 |
| PG `to_regclass` 建表前探测(权限检查早于 IF NOT EXISTS) | 保留 |
| PG `pg_attribute` 列探测(避开 `ADD COLUMN IF NOT EXISTS` 的排他锁) | 保留 |
| PG 补列失败不置 `_failed`、探测失败只跳过本次下次重试 | 保留 |
| 24 列模块级固定 INSERT 常量 | **替换**为按探测结果裁剪的实例语句 |
| `_DDL`/`_BACKFILL`/`_COLUMNS` 两文件各一份 | **替换**为 `telemetry/schema.py` 单一事实源 |
| 补列无开关、库升级即自动执行 | **替换**为 `schema_mode` 三态配置 |
| PG `ON CONFLICT (call_id) DO NOTHING` | **替换**为无冲突目标的 `ON CONFLICT DO NOTHING`(§4.6);普通表上语义逐字等价 |
| SQLite `INSERT OR IGNORE` | 保留(本就无冲突目标) |
| 列序纪律(新列追加末尾) | 保留,并升格为文档化承诺 |
无有意放弃项。
## 6. 非功能维度
| 维度 | 结论 |
|---|---|
| 并发与取消 | DDL 与探测仍只发生在构造期(SQLite)/首次准备期(PG,由既有 `_init_lock` 串行);manual 档不发 DDL,多进程竞态面积**缩小**;裁剪是纯计算,不新增 await 点;PG 既有 `except asyncio.CancelledError: raise` 全部保留 |
| 降级方向 | 遥测属静默降级档: 缺列 → 降级写入 + warning,**绝不判死、绝不报错**;与"限流/熔断后端不可用须报错"的方向差异不变 |
| 幂等与重复 | 探测与裁剪是纯读,重复执行安全;auto 档 ALTER 经探测 + duplicate 容错幂等;`ON CONFLICT (call_id) DO NOTHING` / `INSERT OR IGNORE` 不受影响 |
| 持久化与原子性 | 无跨行事务;单条 INSERT 原子;裁剪不触及主键 `call_id`,幂等键语义不变;部分写入不可能发生 |
## 7. 错误处理与测试策略
遥测路径的一切失败仍不落四分类、不冒泡;`telemetry_schema_sql` 的非法参数是公共入口校验,抛裸 `ValueError`
| 层 | 用例 |
|---|---|
| unit(真实临时 SQLite) | manual + 22 列旧表 → `PRAGMA` 列数不变(证明未 ALTER)、INSERT 成功且能读回、warning 同时含缺列名与 ALTER 语句;auto + 22 列旧表 → 补列(现状回归) |
| unit | `telemetry_schema_sql``COLUMNS` 同源(输出含全部列名且顺序一致)、非法 backend 报 `ValueError` |
| unit | config 派生: 未设键 → sqlite `True` / postgres `False`;显式设置覆盖两侧;非法值报错;`backend=none``False` |
| integration(真实 PG) | 无目标 `ON CONFLICT DO NOTHING` 在普通表上幂等(重复 `call_id` 只落一行)、在主键为 `(call_id, created_at)` 的分区表上写入成功 |
| integration(真实 PG) | manual + 22 列旧表 → `information_schema` 断言无新列、写入成功、缺列不写;仅授 `SELECT, INSERT` 的角色在 manual 下不再产生 ALTER 失败 warning |
每条行为变更须有先失败后通过的证据(测试结果门)。
## 8. 兼容性、文档与发布
**破坏性**(CHANGELOG 须给"请先读这一条"待遇): ① PG 下游升级后不再自动补列,新列需手工执行(库会打印语句); ② 两个 recorder 新增 keyword-only 必填参数,直接构造的调用点需改(全库 35 处,除 `client.py` 的两处装配点外均在测试内); ③ `GatewaySettings` 新增必填字段,影响"构造函数全量注入"这条装配路。
文档同步: README(配置键、Expand/Contract 承诺、schema SQL 用法)、`.env.example`、ARCHITECTURE §7.8、Wiki `参考-配置键` / `参考-公共API` / `指南-遥测与成本`
## 9. 开放问题
1. 目标版本 1.2.3 与 SemVer 的张力: 破坏性行为变更 + 新公共 API 通常走 minor。人类已定 1.2.3,发布时可再定。
2. manual 档是否也该停 `CREATE TABLE`(本设计: 否,理由见 §4.2)。
@@ -0,0 +1,227 @@
# 熔断拒绝补齐等待档: 把"源不健康"与"调用判死"解耦
- **issue**: #14(dissect,单源第三方中转部署)
- **核查基准**: HEAD 1.2.3;issue 按 1.2.1 提交,逐条复核后**全部仍然成立**(`backends/memory/breaker.py` md5 `630ed36ddeb87e08a9bac58260056046`,1.0.6→1.2.3 逐字节未变)
- **状态**: 人类已确认(2026-08-19);经 Codex 审查修正(2026-08-19,修正点见 §3.1/§3.4/§3.5/§6 标注),待实施
## 1. 问题的真实形状
issue 把问题命名为"单源 scope 下熔断等于整体停服"。这个命名会把方案引向错误的方向——**单源不是病因,是让病灶 100% 复现的放大器**。三条独立缺陷叠加成了现场那 30 次瞬死,必须分开命名才修得干净。
### 1.1 缺陷一: 准入策略矩阵缺了一格
`_pick_runnable` 有四种"拒绝",库对它们的处置并不对称:
| 拒绝原因 | 计入 `gate_rejections` | 全被拒时的处置 | 可配? |
|---|---|---|---|
| `rate_limited`(permit 拿不到) | 否 | 走 `quota_full` 分支 | **是**(`wait`/`fail_fast`) |
| `adaptive_paced`(AIMD 超限) | 否 | 走 `quota_full` 分支 | **是**(同上) |
| `circuit_open`(熔断门拒) | 是 | 当场抛 `CircuitOpenError` | **否** |
| `cooldown`(源冷却备忘) | 是 | 同上 | **否** |
限流闸满时库不判死、允许排队(`quota_full=wait`,缺省);熔断门拒时库**只有 fail-fast 一档且不可配**。两者在准入语义上完全同构(都不发请求、都带 `retry_after` 提示),处置却分叉。
**这一格的缺失与源数量无关**:多源全部同时开路(共同上游的中转挂了、一次全网抖动)时行为一模一样。单源只是把"全部开路"的概率从"罕见"变成"必然"。因此**任何形态的单源特判(`if len(sources) == 1`)都是错的**——它会让行为随池大小突变、无法组合测试,是比现状更重的债。
### 1.2 缺陷二: `retry_after_s` 在 HALF_OPEN 下返回了一个物理上无意义的数
`try_enter` 在 HALF_OPEN 拒绝时返回 `probe_expires - now`,即**探针租约的剩余时长**。而 `probe_ttl_s` 派生自 `max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)`(`config.py:400-407`),现场 `TIMEOUT_S=300`**600 秒**,而冷却期只有 60 秒。
探针租约的长度回答的是"探针最长可以占用这个名额多久"(死锁保护参数),与"这个源多久能恢复"没有任何因果关系。两个后端同款(`backends/redis/breaker.py``TRY_ENTER`/`RETRY_AFTER` 两个 Lua 均返回 `probe_until - now`)。
### 1.3 缺陷三(issue 未发现,伤害最重): 恢复了的源被本进程屏蔽整个探针租约
缺陷二的值被喂进了源冷却备忘:
```text
retry.py:354 self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
sources.py:139 self._until[name] = max(已有, until) # 取更晚者,不可回退
```
于是:源 A 冷却到期 → 调用 1 拿到探针 → 并发的调用 2 被拒、拿到 600 → **给 A 记 600 秒本地冷却** → 调用 1 的探针成功、门恢复 CLOSED → **本进程此后 600 秒仍然跳过 A**,且 `reasons[A]="cooldown"` 计入 `gate_rejections`,单源下每次调用照旧抛 `CircuitOpenError`
实测复现(`InMemoryGate` + 注入时钟,`cooldown_s=60``probe_ttl_s=600`):
```text
B 决定: allowed=False state=half_open retry_after_s=600.0 <- 冷却只有 60s
B 给 s1 记的本地冷却剩余: 600.0 秒
探针成功后门 state: closed
门已 CLOSED,memo.active('s1') = True
再过 120 秒(远超 60s 冷却)memo.active = True 剩余 480.0 秒
```
**这条与源数量、与是否单源都无关**:多源部署里,一个源每开路一次就会被本进程从池中除名 `probe_ttl_s`(可达 2 × timeout),池子越大越难被观测到,因为别的源接住了流量。现场那"30 次瞬死横跨 20 秒"里有多少来自这一条无法反推,但机制确凿。
## 2. 备选方案与否决理由
issue 给了 A/B/C/D 四条。逐条判:
| 方案 | 判定 | 理由 |
|---|---|---|
| A `PGW_BREAKER_BACKEND=noop` | **否决** | 关掉的是"保护"(401/403/配额耗尽的一击即熔一并失效,坏密钥持续撞墙),而诉求是"别当场判死"。且开了"治理组件可整个关掉"的先例,限流迟早跟进。三条缺陷一条都不解决 |
| B `{SCOPE}__CIRCUIT_OPEN=wait\|fail_fast` | **采纳为主干** | 与 `quota_full` 严格同构,补的正是 §1.1 那一格。但 issue 版的 B 未答"wait 档等多久",而这个答案依赖 C |
| C 修 HALF_OPEN 的 `retry_after_s` | **采纳,且不是"治标"** | issue 把它列为"可并行的小修"。实际上它是 B 的**前提**:wait 档要按 `retry_after` 睡,睡一个 600 秒的假数就是新事故。它还是 §1.3 的病根 |
| D 只写文档 | **否决** | 把配置项的副作用固化成公开契约,将来动阈值逻辑即破坏;且解决不了 `force_open` |
**方案 = B + C,合并为一件事**:B 依赖 C 的正确性,C 修完 §1.3 自动消失。
## 3. 设计
### 3.1 `retry_after_s` 的契约定死为"确定的最早可尝试时刻"
| 门状态 | 返回值 | 依据 |
|---|---|---|
| CLOSED | `0.0` | 现状,不变 |
| OPEN | `open_until - now` | 现状,不变。冷却截止是确定时刻 |
| HALF_OPEN(被拒) | **`0.0`** | 探针随时可能出结果,**不存在**确定的等待时刻 |
`0.0` 不是新约定:`errors.py` 早已定义 `retry_after_s``0 = 可立即重试`,契约测试 `test_retry_after_semantics` 也以"健康 → 0、冷却到期 → 0"钉着这个语义。HALF_OPEN 归入"无确定等待"是同一语义的自然延伸,而非发明。
信息不丢失:`GateDecision.state` 已经携带 `HALF_OPEN`,调用方要区分"门闭着"与"探针在途"照样能区分。
**惊群由既有机制承担,不由这个数承担**:门自身的单探针租约保证第二个 caller 拿不到名额;wait 档的复查间隔由 middleware 的 `poll_interval_s` 抖动睡眠承担(§3.3)。
**§1.3 随之闭合**:`set_until(now + 0.0)` 写入一个已过期的截止时刻,`active()` 恒 False——HALF_OPEN 拒绝自此不再污染备忘,无需在 `retry.py` 加任何状态分支。备忘回归它唯一正当的用途:**记 OPEN 的确定冷却期**。
**准入被允许时恒 `0.0`**:`allowed=True` 意味着现在就能试,这个字段没有别的合理取值。
**改动面是五个出口,不是两个(Codex 审查修正)**。原稿只点了 `try_enter``retry_after_s()`,漏了 `GateUpdate` 那一侧;逐一核实后发现**两个后端在这两处本就已经分叉**——本 issue 的病根正是"`retry_after_s` 语义从未被定死,于是各后端各自发挥",不一并收口就是定了新契约却留两个后端不遵守:
| 出口 | memory 现状 | redis 现状 | 统一为 |
|---|---|---|---|
| `try_enter` 拒绝(OPEN) | `open_until - now` | 同 | 不变 |
| `try_enter` 拒绝(HALF_OPEN) | `probe_expires - now` | `probe_until - now` | **`0.0`** |
| `try_enter` **授予探针** | `0.0`(`memory:114`) | **`probe_ttl_ms`**(`redis:53`) | **`0.0`**(redis 侧改) |
| `GateUpdate`(fencing 未命中,HALF_OPEN) | `0.0`(`memory:175-177` 非 OPEN 一律 0) | **`probe_until - now`**(`redis:127/158/258`) | **`0.0`**(redis 侧三处改) |
| `retry_after_s()` 跨源取 min | HALF_OPEN 记 `probe_expires - now` | 同 | **HALF_OPEN 记 `0.0`** |
后两行是**既有缺陷**,与本 issue 同源、由契约测试盲区掩护至今(现有用例只钉"第二个进入者被拒",没钉它拿到什么数)。同源缺陷一并修,不作为独立议题。
memory 侧抽 `_remaining(g)` 私有纯方法供三处共用;redis 侧四个 Lua(`TRY_ENTER`/`RECORD_SUCCESS`/`RECORD_FAILURE`/`RELEASE_PROBE`)与 `RETRY_AFTER` 各改一处(Lua 无法共享函数,这是既有约束,`_WINDOW_HELPERS` 已是同款处理),由同一批双后端参数化契约用例锁死。
### 3.2 新配置键 `{SCOPE}__CIRCUIT_OPEN`
`quota_full` 逐项对齐,不发明新形状:
| 维度 | `quota_full`(既有) | `circuit_open`(新增) |
|---|---|---|
| 合法域 | `_QUOTA_FULL = {"wait","fail_fast"}` | `_CIRCUIT_OPEN = {"wait","fail_fast"}` |
| 缺省 | `wait` | **`fail_fast`**(见 §3.5) |
| env 键 | `{SCOPE}__QUOTA_FULL` | `{SCOPE}__CIRCUIT_OPEN` |
| 装配 | settings → `GatewayClient` → 三条循环 | 同 |
| 校验 | `_validate_backends` 表驱动 + 构造期 | 同(各加一行) |
改动面: `config.py`(常量 / 字段 / 校验元组 / `from_env` 各一行)、`client.py`(签名 + 透传各一处)、`SourceAdmission`(§3.4)一处。
### 3.3 `_on_no_runnable` 的控制流
现状两个分支是**串行**的。今天走不到那个坑(没有 wait 档,第一分支必抛),但**只要把第一分支改成"wait 时不抛"就会立刻踩中**:控制流会往下掉进 `quota_full` 分支,`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`。必须改成按拒绝原因分派:
```text
if gate_rejections == len(sources): # 全部因熔断类原因被拒
if circuit_open == "fail_fast": raise CircuitOpenError(retry_after=gate.retry_after_s(names))
hint = await gate.retry_after_s(names) # OPEN 有确定值;全 HALF_OPEN 得 0
else: # 至少一源是被配额/AIMD 挡的
if quota_full == "fail_fast": raise AllSourcesExhausted("quota_exhausted")
hint = 0.0
if await self._stalled(clock): raise AllSourcesExhausted("stalled", ...)
await self._sleep(self._nap(hint, clock))
```
睡眠时长 `_nap(hint, clock)`,三条约束同时满足:
| 约束 | 实现 | 理由 |
|---|---|---|
| 不空转 | `hint > 0` 时睡到冷却结束再加抖动,而非 50ms 轮询 | 60 秒冷却下,`poll_interval=0.05` 会产生 1200 次无谓复查;memory 后端只是字典查询,**redis 后端是 1200 次往返 × 每个在途调用** |
| 不白醒 | 抖动**上**加(`hint + poll_interval × (0.5+0.5×rng)`),不缩放 | 对一个确定的截止时刻提前醒必然被再拒一次 |
| 等待有可解释上界 | 夹到剩余 stall 预算:`min(睡眠, stall_window - clock.stalled_s())`,下界 `poll_interval` | 最迟在 stall 窗口耗尽那一刻醒来判死,单次调用最坏墙钟 = `stall_window_s`(缺省 300s),不随 `max_cooldown_s` 漂移 |
`hint = 0` 时该式退化为现有的 `poll_interval × (0.5+0.5×rng)`,配额等待路径逐字不变。
**计时归属无需改动**:这段睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3 "熔断冷却属非生产性等待"的既定口径一致。
### 3.4 前置收敛: 准入逻辑三处复制归一
`_pick_runnable` / `_on_no_runnable` 目前在 `middleware/retry.py``embedding.py``ocr.py` **各有一份**,后两份是第一份的逐字子集(少 AIMD pacer 与调用内降权)。若只改 chat 一处,embedding/ocr 就成了行为分叉的角落——**那才是本次真正会留下的技术债**(CLAUDE.md 铁律痛斥的"三项目 4 处复制"的库内同款)。
`middleware/admission.py::SourceAdmission`,持有 sources/selector/QuotaGate/BreakerGate/memo/backpressure/两个策略键/时钟三件套,暴露 `pick()``on_no_runnable()`。三条循环的差异用注入表达,不留分支:
| 差异 | 处理 | 行为等价性 |
|---|---|---|
| 调用内降权(仅 chat) | `attempt_fails``pick()` 入参 | embedding/ocr 传空 dict 时 `_demote_call_failures` 恒等返回原序(`demoted` 为空即 `return ordered`) |
| AIMD pacer(仅 chat) | `pacer: AdaptivePacer \| None = None` | None 时跳过 `admit`/`enter`,无副作用 |
| `_settle_and_release` 三份复制 | 提为 `middleware/` 模块级 async 函数 | chat/embedding 签名为 `(permit, actual)`,**OCR 为 `(permit)` 且体内恒 `settle(0)`**(`ocr.py:438`,Codex 审查补)。OCR 侧改为传 `0`,逐字等价;唯一可见变化是 warning 文案由"OCR permit 结算/释放失败"归一 |
已逐字 diff 核实(`embedding``ocr` 两份**完全相同**;chat 多出的只有上表三类)。另有两处**不在抽取边界内**、须原样保留:chat 主循环顶部额外的一次 `_stalled` 预判(`retry.py:286`),以及 OCR 的健康喂数——它们属于各自的主循环与 `_attempt`,本次一行不动。
**这不是任务外重构**:修复本来就必须落在这三处,"改三遍"与"抽一份改一遍"工作量相当而后者才符合 P7;且这是既有方向的延续——`StallClock``backoff_delay` 已按同一原则收敛为共享单元(ARCH §7.3)。边界严格限定在准入与无源可跑的处置,**`_attempt` 一行不动**(三者差异大: 流式 / 批 / 图)。
执行分两个提交:①纯重构,验收标准是全套件逐字绿、无行为变更;②在单一位置加语义。①先行以保回滚点。
### 3.5 缺省值取 `fail_fast`
`quota_full` 缺省 `wait`,但 `circuit_open` **不跟随**,理由是变更方向的危险性不对称:
| 取值 | 对存量下游的影响 |
|---|---|
| `fail_fast`(采纳) | **控制流**逐字不变(全源被熔断拒仍当场抛 `CircuitOpenError`) |
| `wait` | 把所有人的最坏墙钟从毫秒抬到 `stall_window_s`,且是"快速失败 → 长时间挂起"这个最危险的方向 |
issue 的诉求本身也不是改默认值,而是**表达能力**——其 §2.3 的原话是"库对这两种情形用的是同一套默认值、且**不允许调用方表达自己属于哪一种**"。多源下 fail-fast 确实是对的(换源比等待快),单源下调用方显式配 `wait` 即可。README 与 wiki 需明写"单源 scope 建议配 `wait`"。
### 3.6 `errors.py` 的职责边界补写
issue 要求修订 `GatewayUnavailableError` 那句"业务侧 catch 本类做延期重投"——它读起来像在鼓励每个下游各写一份重试逻辑。改为明确边界:调用级的重试/退避/换源/等待**全部在库内**,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是**任务级重试**,语义与调用级重试不同。
这不是新决策,是把 ARCH §7.2 已经写明的"单层重试原则"补进 docstring。零代码风险。
**"缺省档零感知"须诚实收窄(Codex 审查修正)**: 缺省档保证的是**控制流**不变,不是零可见变更。`retry_after_s` 的语义修正在缺省档下同样生效——全源 HALF_OPEN 时 `CircuitOpenError.retry_after_s` 由"探针租约剩余"变为 `0.0`,而它是公开字段(`errors.py:118`)。这正是本次记 **1.3.0** 而非补丁号、且 CHANGELOG 需"请先读这一条"待遇的原因。另需注意 `GatewaySettings` 全部字段均无默认值(既有风格),新增 `circuit_open` 沿用之,直接构造该类的调用方须补一个参数。
## 4. 行为矩阵
| 场景 | `fail_fast`(缺省,= 现状) | `wait` |
|---|---|---|
| 单源 OPEN,冷却 60s | 立即 `CircuitOpenError(retry_after=剩余冷却)` | 睡到冷却结束(夹在 stall 预算内)→ 探针 → 成功即返回 |
| 单源 `force_open`(401/403) | 立即失败 | 等 60 → 探针又 401(**烧掉一格 `max_attempts`**)→ 等 120 → …… 以**先耗尽的那个预算**的 reason 失败: `max_attempts` 先尽则 `retry_exhausted`,冷却累计超过 stall 预算则 `stalled`。**代价须进文档** |
| 多源部分开路 | 不变(有源可跑就不进这个分支) | 不变 |
| 多源全部开路 | 立即失败 | 等最早恢复的那个源(`retry_after_s` 取 min) |
| 全部 HALF_OPEN(探针在途) | `CircuitOpenError(retry_after=0)`,语义准确(随时可能好) | `poll_interval` 抖动复查,秒级拿到探针结果 |
| 配额满 / AIMD 超限 | 归 `quota_full` 管,逐字不变 | 逐字不变 |
## 5. 测试策略
行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。分三层:
**契约层**(`tests/contracts/test_breaker_contract.py`,双后端参数化自动覆盖 memory + redis):
按 §3.1 那张表**逐个出口**钉——HALF_OPEN 被拒、授予探针、`GateUpdate` fencing 未命中、`retry_after_s()` 探针在途,四处均须 `== 0.0`;OPEN 语义不变(现有 `test_retry_after_semantics` 保持绿)。现有用例只钉了"第二个进入者被拒",没钉它拿到什么数,正是这个盲区放过了两处双后端分叉。Redis 侧依赖时间快进的变体在契约层会 skip,须同步补 `tests/integration/test_redis_governance_time.py` 的真实等待变体(既有约定,不缩放时长)。
**单元层**(`tests/unit/test_backpressure.py` 邻域,注入时钟/睡眠/rng):
§1.3 的回归钉子——探针成功后备忘不再屏蔽该源(直接由 §3.1 的复现脚本转化);`circuit_open=wait` 下全源开路不抛 `CircuitOpenError` 而按 `retry_after` 睡;`wait` + `quota_full=fail_fast` 组合下熔断等待**不**被误报成 `quota_exhausted`(§3.3 那个坑的钉子);`wait` 档最坏墙钟 ≤ `stall_window_s` 且判死 reason 为 `stalled``per_source_reasons``circuit_open`;`fail_fast` 缺省下全部现有用例逐字绿。
**收敛层**: §3.4 的重构提交以"三条循环现有测试全绿、零新增用例"为验收——有新增用例即说明行为被动了。
## 6. 非功能与已知取舍
| 维度 | 结论 |
|---|---|
| 取消穿透 | `_nap` 的长睡眠是 `await self._sleep(...)`,`CancelledError` 逐字穿透;无新增 finally 资源 |
| 后端往返 | wait 档每个冷却周期约 1 次 gate 查询(vs. `poll_interval` 轮询的 1200 次),Redis 压力低于按现状实现的朴素 wait |
| 遥测 | **不加列**。wait 等待期不发请求,无 attempt 行可记;调用级总等待下游可自测。进入/退出等待各打一条 `logger.info`(scope、per-source reasons、预计等待),使"等了多久"可从日志还原 |
| 等待上界的精确值 | `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`,Codex 审查补)。睡眠恰好夹到剩余预算时,醒来 `stalled_s()` 等于窗口而不大于,不判死。故 `_nap` 夹到 `剩余预算 + poll_interval_s`,一次到位;最坏墙钟精确表述为 `stall_window_s + 一个 poll 间隔`,不是"恰好 stall_window_s" |
| 备忘的跨进程滞后 | 本进程记了 OPEN 冷却后,即便别的进程的探针已把共享门关回 CLOSED,本进程仍会跳到本地备忘自然过期(`_pick_runnable` 先查备忘再问门)。这是备忘"以本地记录换 Redis 往返"的固有代价,误差有界(≤ 一个 cooldown),**既有性质、本次不改**;备忘是进程内存,无持久化,故不存在滚动升级残留 |
| 无限等待 | `_stalled` 是双条件合取,同 scope 其他调用仍在出餐时本调用不判死(ARCH §7.3 已承认的残余性质)。单源全开路时无人出餐,条件 B 必然成立,会判死;多源部分开路则走不到这个分支。文档沿用既有措辞:需要硬上限的调用方自行 `asyncio.wait_for` |
| 未解决 | `force_open` 在 wait 档下把坏密钥的失败从毫秒拖长(上限 stall 窗口)。**有意不特判**——库无法区分"密钥坏了"与"中转抖了",选 `wait` 即声明"宁可等也不当场死" |
| 两个预算并行(整分支审查发现,2026-08-20) | `wait` **不豁免重试预算**: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `max_attempts`(issue #8 的划分依据是"谁消耗重试预算",探针发出了真实请求,理应记在重试预算上)。故 force_open 的源常以 `retry_exhausted` 而非 `stalled` 结束。原稿 §4 只写了 stall 一种结局,已更正;由 `test_wait_does_not_exempt_probes_from_the_retry_budget` 钉住 |
## 7. 文档与发布
ARCH §7.4 增补本次决策与三条缺陷的成因;§9 配置面登记新键;README 能力表与配置表;Gitea wiki 按 `docs-convention.md` §2 同步;CHANGELOG 记为 **1.3.0**(新增配置键 + `retry_after_s` 语义变更,后者对下游可见,需"请先读这一条"待遇)。
`GateDecision` 的字段与 `ProviderGate` 端口签名**均不变**,故不触碰迁移兼容约束(ARCH §5.1)。
## 8. 已定决策(人类,2026-08-19)
| # | 决策 | 随之固定的实施边界 |
|---|---|---|
| 1 | 缺省取 **`fail_fast`**(§3.5) | 存量下游零感知;issue 提交方需自行加 `{SCOPE}__CIRCUIT_OPEN=wait`。README/wiki 必须明写"单源 scope 建议配 wait",否则这个开关等于不存在 |
| 2 | §3.4 的三处收敛**本次一并做** | 拆为独立前置提交,验收标准是"全套件绿 + 零新增用例";该提交即回滚点 |
@@ -0,0 +1,23 @@
---
type: design
node_id: design:issue12-telemetry-retention
title: "issue #12: 遥测表的正文体量、保留期与访问控制"
date: 2026-08-19
---
# issue #12: 遥测表的正文体量、保留期与访问控制
正文: `2026-08-19-issue12-telemetry-retention-design.md`。状态: **待人类审批**。同批交付 [[design:issue13-schema-mode]]。
- **选定方案**: 三个子问题分层落点——(a) 正文体量: 新增 `PGW_TELEMETRY_TEXT_CAP`,**缺省 None 即不截断**,截断只发生在 `TelemetryEmitter._record`; (b) 保留期: README 分区 + `pg_partman` retention 模板 + `tools/telemetry_retention.py` 独立脚本(默认 dry-run),库本体不持有 DELETE/DROP 权限; (c) 访问控制: 纯文档,三角色划分 + `REVOKE UPDATE, DELETE` + 不可变性说明。
- **只有 (a) 改库本体代码**,且它是唯一**预防性**手段: 没写进去的数据不需要删。
- **缺省不截断的理由**(人类决策): 截断后遥测不再是审计证据、也无法复现重放,而这是既有下游正在依赖的行为,默认改动即破坏。代价是 issue 那句"无限期保留全部租户全文不应是默认状态"只解决一半——默认仍是全文,但下游第一次有了不写全文的手段。
- **按每条文本切而不是切整串 JSON**: 后者产出非法 JSON,让此后一切按 JSON 解析该列的分析全废(SQLite 的 `messages` 是 TEXT 列,不做任何 JSON 校验,坏数据静默存进去)。
- **不复用 `_http_errors.summarize_body`**: 它折叠空白 + 保头保尾,是为错误 JSON 设计的——折叠空白会破坏正文里的代码块与缩进,保头保尾服务的是诊断而非"不想存全文"。视觉标记口径一致,实现各自独立。
- **红线**: `digest_messages` 一个字节都不能碰(缓存 key 与遥测共用,`middleware/cache.py:31`),动它 = 全量缓存 miss + key 口径分叉。已设机械化验收: 同一组 messages 在 cap 开关两态下 `build_cache_key` 输出逐字节相同。
- **权限张力**: 既要 `REVOKE DELETE` 又要清理,就只能走 `DROP PARTITION`(owner 操作)而非 `DELETE`(应用角色)。这是分区方案不可替代的理由,不是性能偏好。
- **文档必须进 README 而非 wiki**: sdist 只打包 `src/` 与 README(无 MANIFEST.in),wiki 里的模板下游 `pip install` 后读不到——56f3805 的教训。README 的模板 SQL 另设真实 PG 集成测试逐条执行,因为下游照抄错 SQL 就中招。
- **被否决备选**: 缺省即截断(所有现有下游遥测正文被静默削短);库内建 TTL/清理(库需 DELETE 权限,与 (c) 的 REVOKE 建议直接冲突,且"纯 asyncio 中立、无全局状态"铁律排斥库内定时任务);给 `TelemetryRecorder``purge_before(ts)`(冻结签名的端口扩展 + 同样的权限冲突);只写文档不改代码(下游唯一手段是不用遥测)。
- **共同边界(建议入 ARCHITECTURE D15)**: 库对下游库只做 SELECT/INSERT(加可选建表),一切改结构与删数据的操作交给下游,库的义务是把需要执行的 SQL 明明白白告诉下游。本设计与 [[design:issue13-schema-mode]] 各实现它的一面。
- **审查留痕(Codex,2026-08-19)**: 报 3 项,**采纳 1 项、部分采纳 1 项、不采纳 1 项**。① 阻断级的分区表与幂等冲突已采纳,修法归 [[design:issue13-schema-mode]] §4.6,本设计 §6.1 承接分区部署下的语义差异(缓存命中行复用历史 `call_id`,分区表上不再被幂等吞掉)。② `text_cap` 漏列 emitter 构造点——缺口成立(`client.py:149`/`embedding.py:131`/`ocr.py:130` 三处不改即 `TypeError`),已补;但其"覆盖 embed/OCR 属语义扩散"的价值判断**不采纳**: 三条链路的行落同一张表,只覆盖一条会让同表内一半受控一半不受控(issue #11 同款判断),且核实后 embed 与 OCR 各已有 200 字符自有上限,新 cap 与之是"取更严者",实际影响远小于顾虑。③ "缺省不截断只解决一半"是人类已定的 E-a 决策而非疏漏,不改;作为补偿,README 须给一段可直接照抄的**合规下游推荐配置**(cap + 分区 retention + 三角色),不把三件事散着让下游自己拼。
@@ -0,0 +1,21 @@
---
type: design
node_id: design:issue13-schema-mode
title: "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位"
date: 2026-08-19
---
# issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位
正文: `2026-08-19-issue13-schema-mode-design.md`。状态: **待人类审批**。同批交付 [[design:issue12-telemetry-retention]]。
- **选定方案**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设时**按后端派生**: SQLite→auto、Postgres→manual)。manual 档探测真实列集合后**不发 DDL**,改为 warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT 继续写入。新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取建表/补列脚本。
- **为什么两侧不对称**: issue 引用的全部先例(Hangfire 锁队列雪崩、Prefect 多实例竞态、Alembic 审计链)语境都是**共享的生产 PG**——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE 锁,排在长事务后会阻塞该表其后所有查询,而遥测是业务路径上的内联 await。SQLite 侧则是下游自己的本地文件(VT/CHSAnalyzer/dissect 的 `runs/*.db` 全是这个形态): 无 DBA、无迁移工具、无第二个系统碰它。强加手工 SQL 是净损失。两侧有意不对称在本库已有先例(issue #9 的建表探测)。
- **关掉 ALTER 必须配套裁剪写入**: 今天 `_INSERT` 是 24 列固定语句,旧表缺列时若不 ALTER 则 INSERT **全部失败** → 逐行 warning → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"。降级写入不是增强,是本变更成立的前提。
- **打印的 SQL 必须与执行的 DDL 同源**: `_DDL`/`_BACKFILL`/`_COLUMNS` 今天在两个 recorder 各存一份,公共函数再写一份则三份必然漂移,表现为"下游照打印的 SQL 建完表,库仍报缺列"。故收敛进新的 `telemetry/schema.py` 作单一事实源——这是正确性要求,不是顺手重构。
- **manual 档不停 `CREATE TABLE`**: issue 把建表列为现状描述而非指控(已在 #3/#9 收口为先探测后建);新建表无既有数据、无并发访问者,不存在锁与数据风险,停掉它会断掉零配置起步。Celery 先例同样是"自动建表 + 永不 ALTER"。
- **缺省规则落 config 层**(人类决策): recorder 的 `auto_migrate` 为 keyword-only **必填**,派生只写在 config 一处,不与类签名漂移。代价是 35 处直接构造点需改。
- **被否决备选**: 两侧统一默认 manual(现有 SQLite 下游升级即需人工干预,而这些场景没有承接手工 SQL 的角色);保持 auto 默认只加开关(默认状态仍是库在下游生产表发不受控 DDL,核心诉求未满足);Celery 式无开关永不 ALTER(SQLite 净损失且下游无出路);**APScheduler 4.x 式"schema 不认识就拒绝启动"**——与"遥测初始化失败必须静默降级、不得拖垮业务调用"的库铁律正面冲突,不可选。
- **附带成文化**: Expand/Contract 纪律(新列只增不删不改名、必可空或带非易失默认、INSERT 显式列名、库从不 `SELECT *`)升格为文档化承诺。它是 [[design:issue12-telemetry-retention]] 分区方案能成立的前提——下游把表建成分区表后,库的 `to_regclass` 探测与 INSERT 路由才对分区透明。
- **审查留痕(Codex,2026-08-19)**: 报 3 项。**采纳 1 项(阻断级)**——PG 的 `ON CONFLICT (call_id) DO NOTHING` 与 issue #12 的分区方案不兼容: PostgreSQL 要求分区表的唯一约束必须包含分区键,按 `created_at` 分区后主键被逼成 `(call_id, created_at)`,该语句再也匹配不到约束,遥测在分区部署下全线写不进去。改为无冲突目标的 `ON CONFLICT DO NOTHING`(两种表形态都合法,普通表上逐字等价),改动归本 issue(它已在重写 INSERT 构造逻辑),见正文 §4.6。原设计"INSERT 路由对分区表透明"的判断只对普通 INSERT 成立,对冲突目标不成立——这是"透明"二字被推得过宽的典型。
+53
View File
@@ -165,6 +165,31 @@
"id": "plan:issue11-caller-dimensions", "id": "plan:issue11-caller-dimensions",
"label": "调用方自定义维度实现计划(issue #11)", "label": "调用方自定义维度实现计划(issue #11)",
"type": "plan" "type": "plan"
},
{
"id": "design:issue13-schema-mode",
"label": "issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位",
"type": "design"
},
{
"id": "design:issue12-telemetry-retention",
"label": "issue #12: 遥测表的正文体量、保留期与访问控制",
"type": "design"
},
{
"id": "plan:plan-issue13-schema-mode",
"label": "实现计划: issue13-schema-mode",
"type": "plan"
},
{
"id": "plan:plan-issue12-telemetry-retention",
"label": "实现计划: issue12-telemetry-retention",
"type": "plan"
},
{
"id": "review:issue14-branch-review",
"label": "整分支审查: issue #14 熔断等待档",
"type": "review"
} }
], ],
"links": [ "links": [
@@ -300,6 +325,34 @@
"relation": "implements", "relation": "implements",
"evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路", "evidence": "按已批准设计拆解为 8 个任务,含设计范围外发现的 OCR 第三条链路",
"added": "2026-08-17T10:09:08.967997+00:00" "added": "2026-08-17T10:09:08.967997+00:00"
},
{
"source": "plan:plan-issue13-schema-mode",
"target": "design:issue13-schema-mode",
"relation": "implements",
"evidence": "research-wiki/plans/2026-08-19-issue13-schema-mode.md",
"added": "2026-08-19T13:10:55.616264+00:00"
},
{
"source": "plan:plan-issue12-telemetry-retention",
"target": "design:issue12-telemetry-retention",
"relation": "implements",
"evidence": "research-wiki/plans/2026-08-19-issue12-telemetry-retention.md",
"added": "2026-08-19T13:10:57.986963+00:00"
},
{
"source": "plan:plan-issue14-admission-wait-policy",
"target": "design:2026-08-19-issue14-admission-wait-policy-design",
"relation": "implements",
"evidence": "research-wiki/plans/plan-issue14-admission-wait-policy.md;T0-T8 逐节映射设计 §3.1-§3.6",
"added": "2026-08-20T03:30:06.280582+00:00"
},
{
"source": "review:issue14-branch-review",
"target": "plan:plan-issue14-admission-wait-policy",
"relation": "informs",
"evidence": "Important 项促使修正 CHANGELOG/README/设计 §4/计划 T5 对 wait 档失败 reason 的描述",
"added": "2026-08-20T05:01:16.206639+00:00"
} }
] ]
} }
+16 -3
View File
@@ -1,8 +1,8 @@
# Research Wiki 索引 # Research Wiki 索引
> 自动生成,更新时间:2026-08-17 10:09 UTC > 自动生成,更新时间:2026-08-20 05:01 UTC
## design (30) ## design (35)
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
- [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design`
@@ -17,10 +17,15 @@
- [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design` - [2026-08-06-issue8-stall-budget-design](designs/2026-08-06-issue8-stall-budget-design.md) `design:2026-08-06-issue8-stall-budget-design`
- [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design` - [2026-08-16-issue10-error-body-retention-design](designs/2026-08-16-issue10-error-body-retention-design.md) `design:2026-08-16-issue10-error-body-retention-design`
- [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design` - [2026-08-17-issue11-caller-dimensions-design](designs/2026-08-17-issue11-caller-dimensions-design.md) `design:2026-08-17-issue11-caller-dimensions-design`
- [2026-08-19-issue12-telemetry-retention-design](designs/2026-08-19-issue12-telemetry-retention-design.md) `design:2026-08-19-issue12-telemetry-retention-design`
- [2026-08-19-issue13-schema-mode-design](designs/2026-08-19-issue13-schema-mode-design.md) `design:2026-08-19-issue13-schema-mode-design`
- [2026-08-19-issue14-admission-wait-policy-design](designs/2026-08-19-issue14-admission-wait-policy-design.md) `design:2026-08-19-issue14-admission-wait-policy-design`
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
- [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention` - [HTTP 错误响应体留存(Issue #10)](designs/issue10-error-body-retention.md) `design:issue10-error-body-retention`
- [issue #12: 遥测表的正文体量、保留期与访问控制](designs/issue12-telemetry-retention.md) `design:issue12-telemetry-retention`
- [issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位](designs/issue13-schema-mode.md) `design:issue13-schema-mode`
- [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design` - [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design`
- [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed` - [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed`
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience` - [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
@@ -48,7 +53,7 @@
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (25) ## plan (30)
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
@@ -61,6 +66,8 @@
- [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget` - [2026-08-06-issue8-stall-budget](plans/2026-08-06-issue8-stall-budget.md) `plan:2026-08-06-issue8-stall-budget`
- [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention` - [2026-08-16-issue10-error-body-retention](plans/2026-08-16-issue10-error-body-retention.md) `plan:2026-08-16-issue10-error-body-retention`
- [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions` - [2026-08-17-issue11-caller-dimensions](plans/2026-08-17-issue11-caller-dimensions.md) `plan:2026-08-17-issue11-caller-dimensions`
- [2026-08-19-issue12-telemetry-retention](plans/2026-08-19-issue12-telemetry-retention.md) `plan:2026-08-19-issue12-telemetry-retention`
- [2026-08-19-issue13-schema-mode](plans/2026-08-19-issue13-schema-mode.md) `plan:2026-08-19-issue13-schema-mode`
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan` - [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
@@ -68,13 +75,19 @@
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience` - [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr` - [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
- [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy`
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan` - [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
- [实现计划: issue12-telemetry-retention](plans/plan-issue12-telemetry-retention.md) `plan:plan-issue12-telemetry-retention`
- [实现计划: issue13-schema-mode](plans/plan-issue13-schema-mode.md) `plan:plan-issue13-schema-mode`
- [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error` - [实现计划: 治理后端故障归位为 scope 级不可用(Issue #7)](plans/governance-backend-error.md) `plan:governance-backend-error`
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability` - [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions` - [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
## review (1)
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
## schema (1) ## schema (1)
- [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls` - [表结构: llm_calls(遥测 22 字段)](schemas/llm-calls.md) `schema:llm-calls`
+17
View File
@@ -106,3 +106,20 @@
- [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions) - [2026-08-17 10:09 UTC] 新增 plan: 调用方自定义维度实现计划(issue #11) (plan:issue11-caller-dimensions)
- [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions - [2026-08-17 10:09 UTC] 新增边: plan:issue11-caller-dimensions --implements--> design:issue11-caller-dimensions
- [2026-08-17 10:09 UTC] 重建索引: 70 篇页面 - [2026-08-17 10:09 UTC] 重建索引: 70 篇页面
- [2026-08-19 12:44 UTC] 新增 design: issue #13: 遥测 schema 自动 ALTER 降级为按后端不对称的显式档位 (design:issue13-schema-mode)
- [2026-08-19 12:44 UTC] 新增 design: issue #12: 遥测表的正文体量、保留期与访问控制 (design:issue12-telemetry-retention)
- [2026-08-19 12:45 UTC] 重建索引: 74 篇页面
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue13-schema-mode (plan:plan-issue13-schema-mode)
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue13-schema-mode --implements--> design:issue13-schema-mode
- [2026-08-19 13:10 UTC] 新增 plan: 实现计划: issue12-telemetry-retention (plan:plan-issue12-telemetry-retention)
- [2026-08-19 13:10 UTC] 新增边: plan:plan-issue12-telemetry-retention --implements--> design:issue12-telemetry-retention
- [2026-08-19 13:10 UTC] 重建索引: 78 篇页面
- [2026-08-20 03:29 UTC] 新增 design: 熔断拒绝补齐等待档(issue #14) (design:issue14-admission-wait-policy)
- [2026-08-20 03:30 UTC] 新增 plan: 实现计划: 熔断拒绝补齐等待档(issue #14) (plan:issue14-admission-wait-policy)
- [2026-08-20 03:30 UTC] 新增边: plan:issue14-admission-wait-policy --implements--> design:issue14-admission-wait-policy
- [2026-08-20 03:30 UTC] 重建索引: 82 篇页面
- [2026-08-20 03:30 UTC] 重建索引: 80 篇页面
- [2026-08-20 05:01 UTC] 新增边: review:issue14-branch-review --informs--> plan:plan-issue14-admission-wait-policy
- [2026-08-20 05:01 UTC] 重建索引: 80 篇页面
- [2026-08-20 05:01 UTC] 新增 review: 整分支审查: issue #14 熔断等待档 (review:issue14-branch-review)
- [2026-08-20 05:01 UTC] 重建索引: 81 篇页面
@@ -0,0 +1,180 @@
# 实现计划: 遥测正文体量、保留期与访问控制(issue #12)
- **目标**: 让下游第一次有手段控制遥测表里存什么、留多久、谁能读——正文可配置截断,保留期与访问控制以可执行模板 + 独立脚本交付,库本体不持有 DELETE/DROP 权限。
- **方案概述**: 新增 `PGW_TELEMETRY_TEXT_CAP`(缺省 `None` 即不截断),截断只发生在 `TelemetryEmitter._record` 这个唯一遥测调用点,按**每条文本**切而非切整串 JSON;保留期走 README 的 RANGE 分区 + `pg_partman` 模板与 `tools/telemetry_retention.py`(默认 dry-run);访问控制是纯文档的三角色模板 + `REVOKE UPDATE, DELETE`。README 的模板 SQL 有真实 PG 集成测试逐条执行。
- **依据设计**: `research-wiki/designs/2026-08-19-issue12-telemetry-retention-design.md`(已人类审批 2026-08-19)。
- **涉及技术**: Python 3.11+、argparse、sqlite3、asyncpg、pytest、PostgreSQL 分区与 RLS。
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**
- **前置依赖**: **issue #13 的计划须先合并,本分支必须从合并后的 main 开出**(不可两条分支并行改再靠自动合并)。两者都动 `config.py:118-137` 的字段列表、`config.py:423-451``_load_pgw` 返回键与 `client.py:396-407` 的装配,字段顺序与返回键极易冲突且冲突后是静默的。两条分支都会改 `config.py`(新增 settings 字段)与 `client.py`(装配透传),且本计划 Task 4 的分区模板依赖 #13`telemetry_schema_sql()` 与无冲突目标的写入。本分支从 #13 合并后的 main 起。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/middleware/telemetry.py` | 修改 | `_cap_text`/`_cap_messages`;`TelemetryEmitter``text_cap` 必填 |
| `src/polygateway/config.py` | 修改 | `PGW_TELEMETRY_TEXT_CAP` 解析与校验;`GatewaySettings``telemetry_text_cap` |
| `src/polygateway/client.py` | 修改 | `client.py:149` 的 emitter 构造点传参 |
| `src/polygateway/embedding.py` | 修改 | `embedding.py:131` 同上(既有 200 上限保留不动) |
| `src/polygateway/ocr.py` | 修改 | `ocr.py:130` 同上(既有 200 上限保留不动) |
| `tools/telemetry_retention.py` | **创建** | 独立清理脚本,不被库 import |
| `tests/unit/test_telemetry.py` | 修改 | 截断行为、三链路覆盖 |
| `tests/unit/test_cache.py` | 修改 | **红线**: 缓存 key 不受 cap 影响 |
| `tests/unit/test_config.py` | 修改 | 配置校验 |
| `tests/unit/test_retention_tool.py` | **创建** | 脚本 dry-run/apply(经 subprocess) |
| `tests/integration/test_postgres_telemetry.py` | 修改 | README 模板 SQL 逐条执行 |
| `README.md``CHANGELOG.md``.env.example` | 修改 | 生产部署模板、推荐配置组合、配置键 |
**依赖顺序**: Task 1 → Task 2 → (Task 3 ‖ Task 4) → Task 5。
---
## 关键接口(跨任务消费,此处定稿)
截断函数(`middleware/telemetry.py` 模块级私有,紧邻 `_canonical_meta_json`):
```python
def _cap_text(text: str, cap: int | None) -> str:
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
"""
```
`TelemetryEmitter` 构造签名(`text_cap` **keyword-only 必填**,无默认值):
```python
class TelemetryEmitter:
def __init__(
self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None,
text_cap: int | None,
) -> None: ...
```
三个公共 Client 的 `__init__` 各增 keyword-only `text_cap`,**带默认值 `None`**(与既有全部可选参数同款,非破坏性):
```python
class GatewayClient: # client.py:130 起的构造签名
def __init__(self, *, ..., text_cap: int | None = None) -> None: ...
# EmbeddingClient / OcrClient 同款
```
**为什么 emitter 必填而 Client 带默认**: `TelemetryEmitter` 是库内部类,唯一构造者是这三个 Client,必填能保证没有一处漏传;而三个 Client 是**公共装配路**(下游可直接构造并注入自己的 recorder),给它们加必填参数会破坏既有调用点,且默认 `None` 恰好等于全局缺省行为(不截断)。少了这一层,直接构造的下游要么撞 `TypeError`,要么永远没法启用 cap。
`GatewaySettings` 新字段(无默认值),排在 `telemetry_auto_migrate` 之后:
```python
telemetry_text_cap: int | None
```
`tools/telemetry_retention.py` 的 CLI 契约:
```text
--backend sqlite|postgres 必填
--path PATH | --dsn DSN 按 backend 二选一,必填
--older-than-days N 必填,N >= 0
--apply 缺省不带即 dry-run(只统计不删)
--batch-size N 仅 postgres,缺省 1000
--vacuum 仅 sqlite,须与 --apply 同时给
退出码: 0 正常;1 参数错误;2 连接/权限失败;3 目标是分区表(PG,提示改用 DROP PARTITION)
```
---
## Task 1: 正文截断与 emitter 参数
- [ ] **文件**: `src/polygateway/middleware/telemetry.py``src/polygateway/client.py``src/polygateway/embedding.py``src/polygateway/ocr.py`;`tests/unit/test_telemetry.py``tests/unit/test_cache.py`
- **行为**:
- 按上文签名实现两个截断函数;`_record` 内在 `digest_messages(...)` 之后、`json.dumps(...)` 之前调用 `_cap_messages`,并对 `response_text``thinking` 调用 `_cap_text`
- `TelemetryEmitter` 增必填 `text_cap`;库内三个构造点(`client.py:149``embedding.py:131``ocr.py:130`)同步传参;**三个 Client 的 `__init__` 各增带默认值的 `text_cap` 参数**(见上,否则直接构造路要么 `TypeError` 要么永远用不上 cap);测试内十余处 emitter 构造点一并补齐。
- **`digest_messages` 一个字节都不改**(它是缓存 key 与遥测共用的函数,`middleware/cache.py:31`)。
- **`_cap_messages` 必须产出新对象,严禁就地修改**。这是本任务最容易踩的坑: `digest_messages` 对 content 不是 list 的消息是**原样 append 同一个 dict 对象**(`cache.py:43`),即遥测拿到的 dict 与调用方传入的、以及缓存 key 计算用的是**同一份**。就地改它会同时污染调用方的 `messages`、后续重试尝试的请求体与缓存写入的 key,且全程无任何报错。多模态 part 同理(`_digest_part` 对非 image_url 的 part 也是原样返回)。
- `embedding.py:73``ocr.py:73` 各自的 200 字符上限**保留不动**,与新 cap 是"取更严者"的关系。
- **验收**:
- `cap=None` → 落库正文与今天逐字节相同。
- `cap=N` → 每条 content 被切且整串 `messages` JSON 仍可 `json.loads`;标记含省略字数。
- 多模态消息: `type == "text"` 的 part 被切,`image_url` 的 sha256 摘要原样不动。
- 非字符串 content(如 `123``None`、嵌套 dict)不抛异常。
- `response`/`thinking` 同样受 cap。
- OCR 与 embed 两条链路的行同样受 cap(它们共用 `_record`)。
- **测试**:
- 上述六条各一例(`tests/unit/test_telemetry.py`)。
- **红线用例之一**(`tests/unit/test_cache.py`): 取一组含长文本的 messages,先算一次 `build_cache_key(...)`,再经 `cap=8` 的 emitter 走一遍遥测,然后**用同一个 messages 对象**再算一次 key —— 两次输出必须逐字节相同。这测的是"截断没有就地改掉调用方的对象",而不只是"截断函数是纯的"。
- **红线用例之二**(`tests/unit/test_telemetry.py`): `cap=8` 走一遍遥测后,断言传入的 `messages` 结构与内容**完全未变**(含嵌套的多模态 part),落库的那份则已被截断。
- 先失败证据: 参数不存在时 `TypeError`;截断未实现时 `cap=8` 的用例读回全文;就地修改的实现会让两条红线用例直接失败(先写一版就地改的实现跑一遍,把失败输出留档,证明红线用例真的能抓住它)。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py tests/unit/test_cache.py tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v` → PASS。
- **提交**: `feat: cap telemetry bodies at a configurable length`
## Task 2: 配置与装配
- [ ] **文件**: `src/polygateway/config.py``.env.example`;`tests/unit/test_config.py`
- **行为**: `_load_pgw` 解析 `PGW_TELEMETRY_TEXT_CAP`(未设 → `None`;设了则转 `int`);`GatewaySettings``telemetry_text_cap: int | None`,`_validate_telemetry` 内校验 `<= 0``ValueError`(错误信息含键名);`client.py` 把它传给 emitter;`.env.example` 加注释行,写明缺省不截断及其取舍(截断后遥测不再是审计证据、无法复现重放)。
- **验收**: 未设 → `None`;`"0"``"-1"``ValueError`;非整数字符串报 `ValueError`;合法值透传到 emitter 并生效(端到端一例)。
- **测试**: 上述四条各一例。先失败证据: 字段不存在时 `AttributeError`
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
- **提交**: `feat: wire the telemetry text cap through settings`
## Task 3: 保留期脚本
- [ ] **文件**: 创建 `tools/telemetry_retention.py`;创建 `tests/unit/test_retention_tool.py`
- **行为**: 按上文 CLI 契约实现。
- **缺省 dry-run**: 不带 `--apply` 时只统计并打印将删除的行数、`created_at` 时间范围、按 `tenant_id` 的分布,一行不删。
- SQLite: `DELETE FROM llm_calls WHERE created_at < ?`;`--vacuum` 才执行 `VACUUM`(它重写整库,不得默认)。
- PG: 分批 DELETE(每批一个事务,`--batch-size` 控制),避免长事务与锁膨胀;**先探测目标是否为分区表**(`pg_partitioned_table`),是则打印"改用 DETACH/DROP PARTITION"并以退出码 3 结束,不执行 DELETE。
- 脚本不被库 import(`tools/` 规则);缺 `asyncpg` 时明确报错退出码 2,**不静默降级**(这是运维工具不是库路径)。
- 文档串: 帮助文本写明"用维护角色跑,不要用应用账号(应用账号已被 REVOKE DELETE)"。
- **验收**: 见测试。
- **测试**(经 `subprocess.run([sys.executable, "tools/telemetry_retention.py", ...])`,真实临时 SQLite):
- dry-run 后行数不变,stdout 含将删行数与时间范围。
- `--apply` 后仅超期行被删,未超期行完好。
- `--older-than-days 0` 的边界(删到"此刻之前")行为明确且与文档一致。
- 参数缺失/冲突(如 backend=sqlite 却给 `--dsn`)退出码 1。
- `--vacuum` 不带 `--apply` 时退出码 1。
- **PG 分支必须自带证据**(集成,真实 PG,临时 schema 隔离): ① 临时 schema 内建**分区表**,脚本探测到后打印改用 DETACH/DROP PARTITION 的提示并以退出码 **3** 结束、**一行都没删**; ② 临时 schema 内建普通表灌入跨日期的行,`--apply --batch-size 2` 后仅超期行被删且分多批提交; ③ 缺 `asyncpg` 时退出码 **2**——用一个只含 `raise ImportError` 的临时 `asyncpg.py` 目录挂进 `PYTHONPATH` 跑 subprocess 来构造该场景,不要靠 monkeypatch(脚本走的是子进程)。
- 先失败证据: 脚本不存在时 subprocess 返回非零且 stderr 含 `No such file`;PG 三例在脚本只实现 SQLite 分支时分别以"未知 backend"或退出码 1 失败。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_retention_tool.py tests/integration/test_retention_tool_pg.py -v` → PASS(PG 三例须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算通过)。
- **提交**: `feat: add a retention script downstreams can schedule`
## Task 4: 生产部署模板与其机械化验收
- [ ] **文件**: `README.md`;`tests/integration/test_postgres_telemetry.py`
- **行为**: README 现有多租户 RLS 段扩为完整的"生产部署 DDL 模板"一节,包含:
- **三角色**: `owner`(DDL 与清理)、`app`(INSERT + 受 RLS 约束读自己租户)、`report`(只读 + 受 RLS 约束)。
- **不可变性**: `REVOKE UPDATE, DELETE ON llm_calls FROM app, report`;触发器兜底明确标注"只防误操作,不防恶意(属主可 disable)"。
- **分区**: `PARTITION BY RANGE (created_at)`、主键 `(call_id, created_at)``pg_partman` retention;并写明**分区部署下幂等键实际是 `(call_id, created_at)`**,`emit_cache_hit` 复用历史 `call_id`,故缓存命中行在普通表上第二次起会被吞掉、在分区表上每次都落一行——按 `cache_hit` 统计的下游必须知道。
- **库需要的最小权限**: catalog SELECT(探测)+ INSERT +(可选)CREATE;auto 档另需 ALTER。
- **合规下游推荐配置**: 一段可直接照抄的组合(`PGW_TELEMETRY_TEXT_CAP` + 分区 retention + 三角色),不把三件事散着让下游自己拼。
- **截断覆盖面的诚实声明**(设计 §5.2,不得省): cap 作用于消息的 `content` 文本与多模态 part 中 `type == "text"``text`,与 `digest_messages` 的处理面一致;调用方放进 `tool_calls.function.arguments` 等其他字段的内容**不在覆盖范围内**。漏写这条,下游会以为开了 cap 就没有全文残留,合规判断直接出错。
- **SQLite 侧的保留期**(设计 §6,不得省): 给按天/按实验轮转库文件的建议——这是 VT / CHSAnalyzer / dissect 三家现成的形态,比对本地文件跑 DELETE + VACUUM 更省事也更安全;`tools/telemetry_retention.py` 的 SQLite 分支是给"已经攒成一个大库"的存量场景兜底,不是推荐路径。
- 每个代码块 ≤15 行(输出规范),超长的拆成相邻多块。
- **验收**: 模板 SQL 在真实 PG 上逐条可执行;README 里的行为描述与实测一致。
- **测试**(集成,真实 PG,**新建自己的 fixture**,手法照搬 `least_privilege_dsn` 的临时 schema + 临时角色 + teardown 删净,**严禁碰共享的 `public.llm_calls`**): 新增一例,把 README 的模板 SQL 逐条执行后断言:
- `app` 角色能 INSERT、**不能** DELETE(报权限错)。
- `report` 角色能读、不能写。
- 未设 `app.tenant_id` 时查询为**零行**(fail-closed),设了则只看到本租户的行。
- 分区表上写入成功且落进当月分区。
- 先失败证据: 模板尚未写进 README 时该测试无 SQL 可读、直接失败。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 且账号有 `CREATEROLE` 的环境实跑;无权限时 skip,**skip 不算通过**)。
- **提交**: `docs: ship a production deployment template with its own test`
## Task 5: CHANGELOG 与 wiki
- [ ] **文件**: `CHANGELOG.md`、Gitea wiki(`指南-遥测与成本`/`参考-配置键`/`参考-公共API`)、`research-wiki/ARCHITECTURE.md`
- **行为**: CHANGELOG 写明新配置键、缺省不截断的取舍、保留期脚本与部署模板的位置;ARCHITECTURE 的 D15(库对下游库的权限边界)若 issue #13 已建,此处只补 #12 的一面;wiki 三页按 docs-convention §2 同步。
- **验收**: 版本条目里能一眼看出"默认行为未变,新增的是手段";wiki 与 README 不重复叙述(深度内容只放指针)。
- **测试**: 无自动化测试。
- **验证**: `conda run -n PolyGateway make ci` → 全绿。
- **提交**: `docs: record the retention boundary and its knobs`
---
## 完成判据
1. 五个任务的提交点全部落地,`make ci` 全绿。
2. 每条行为变更能出示先失败后通过的测试证据;Task 1 的缓存 key 红线用例与 Task 4 的模板 SQL 用例必须在本会话内实跑并留下输出。
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
4. 与 issue #13 合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步——**README 必须在构建之前定稿**(sdist 会把当时那份固化进包)。
@@ -0,0 +1,177 @@
# 实现计划: 遥测 schema 档位与裁剪写入(issue #13)
- **目标**: 让库不再默认在下游 Postgres 生产表上发不受控 DDL——探测到缺列时打印 SQL 并按现有列降级写入,而不是自己 ALTER。
- **方案概述**: 新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`(三态,未设按后端派生: SQLite→auto、PG→manual)。manual 档探测真实列集合后不发 DDL,warning 逐列点名 + 打印可执行 SQL,并按现有列裁剪 INSERT。DDL/列序/补列语句收敛进新的 `telemetry/schema.py` 单一事实源,新增公共函数 `telemetry_schema_sql(backend)` 供下游主动索取。PG 写入的冲突目标同时去绑定,为 issue #12 的分区方案让路。
- **依据设计**: `research-wiki/designs/2026-08-19-issue13-schema-mode-design.md`(已人类审批 2026-08-19)。
- **涉及技术**: Python 3.11+、sqlite3、asyncpg、pytest、frozen dataclass。
- **保真校验**: **本计划不涉及参考实现迁移,保真校验不适用**(改的是本库自有的 issue #3/#9 收口逻辑)。
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/telemetry/schema.py` | **创建** | 24 列列序、两端 DDL 与补列语句、`insert_sql()`、公共 `telemetry_schema_sql()` |
| `src/polygateway/telemetry/sqlite.py` | 修改 | 常量改从 schema.py 取;`auto_migrate` 必填;manual 档裁剪写入 |
| `src/polygateway/telemetry/postgres.py` | 修改 | 同上;`ON CONFLICT` 去冲突目标 |
| `src/polygateway/config.py` | 修改 | 解析 `PGW_TELEMETRY_SCHEMA_MODE` 并派生;`GatewaySettings``telemetry_auto_migrate` |
| `src/polygateway/client.py` | 修改 | `_build_telemetry` 透传 `auto_migrate` |
| `src/polygateway/__init__.py` | 修改 | 导出 `telemetry_schema_sql` |
| `tests/unit/test_telemetry.py` | 修改 | 两档行为、裁剪写入、warning 内容 |
| `tests/unit/test_config.py` | 修改 | 派生规则与值域校验 |
| `tests/unit/test_package.py` | 修改 | 公共导出面 |
| `tests/integration/test_postgres_telemetry.py` | 修改 | 真实 PG: manual 旧表、最小权限、无目标幂等、分区表 |
| `.env.example``README.md``CHANGELOG.md` | 修改 | 配置键、Expand/Contract 承诺、破坏性说明 |
**依赖顺序**: Task 1 → (Task 2 ‖ Task 3) → Task 4 → Task 5 → Task 6 → Task 7。
---
## 关键接口(跨任务消费,此处定稿)
`schema.py` 的模块级常量(名称固定,两个 recorder 与公共函数共用):
```python
COLUMNS: tuple[str, ...] # 24 个 INSERT 字段(call_id 起、meta 止)
SQLITE_DDL: str # CREATE TABLE IF NOT EXISTS(全量列)
PG_DDL: str
SQLITE_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, "TEXT NOT NULL DEFAULT ''")
PG_BACKFILL: tuple[tuple[str, str], ...] # 库内执行: (列名, 不带 IF NOT EXISTS 的 ALTER)
```
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它有 `DEFAULT now()`/`datetime('now')`,库从不显式写它)。**物理表列 = 24 + `created_at` = 25**;issue #11 之前的旧表则是 22 + `created_at` = 23。所有列数断言必须按物理列数写,混用两套口径是本计划最容易写错的地方(现有集成测试的 `_EXPECTED_COLUMNS``created_at`,可作对照)。
**库内执行的补列语句与打印给下游的语句是两份,不是一份**: 库内**不用** `ADD COLUMN IF NOT EXISTS`——PG 对它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,故库侧一律"先探测后 ALTER"(`postgres.py` 现有注释已记这条实测)。而 `telemetry_schema_sql` 打印给人执行的脚本**必须**带 `IF NOT EXISTS`,否则重复执行即失败,称不上"可直接粘进迁移文件";那条语句由 DBA 在自己选的时机执行,锁风险是他的职责。
两个语句构造函数:
```python
def insert_sql(backend: str, columns: Sequence[str]) -> str:
"""按给定列构造 INSERT;列必须是 COLUMNS 的子集,否则 ValueError。
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,
不校验就等于把外部字符串拼进 SQL。sqlite 用 `?`、postgres 用 `$n`。
"""
def telemetry_schema_sql(backend: str) -> str:
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。"""
```
recorder 构造签名(`auto_migrate` **keyword-only 必填**,无默认值):
```python
class SQLiteRecorder:
def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None: ...
class PostgresRecorder:
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None: ...
```
`GatewaySettings` 新字段(无默认值,与既有全部字段一致),排在 `telemetry_pg_dsn` 之后:
```python
telemetry_auto_migrate: bool
```
---
## Task 1: 建 `telemetry/schema.py` 单一事实源
- [ ] **文件**: 创建 `src/polygateway/telemetry/schema.py`;修改 `src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py`;修改 `tests/integration/test_postgres_telemetry.py`(它 `from polygateway.telemetry.postgres import _DDL`,改为从 schema.py 取)。
- **行为**: 把 `sqlite.py``_DDL`/`_BACKFILL_COLUMNS`/`_COLUMNS``postgres.py``_DDL`/`_BACKFILL`/`_COLUMNS` 原样搬进 schema.py,按上文命名导出;两个 recorder 改为 import 使用,`_INSERT` 改为在模块加载时调用 `insert_sql(backend, COLUMNS)` 得到(本任务不改变任何行为)。新增 `insert_sql()``telemetry_schema_sql()`
- **验收**:
- 两端 DDL 文本与搬迁前逐字节相同(列名、列序、类型、默认值);`COLUMNS` 24 项且顺序未变。
- `insert_sql("sqlite", COLUMNS)` 与搬迁前的 `_INSERT` 字符串相同;PG 侧同理(**本任务不改冲突目标**,那是 Task 2)。
- `insert_sql` 收到非 `COLUMNS` 子集的列名抛 `ValueError`;收到未知 backend 抛 `ValueError`
- `telemetry_schema_sql` 输出包含全部 24 个列名 + `created_at`,列名出现顺序与建表 DDL 一致;PG 变体的补列语句带 `ADD COLUMN IF NOT EXISTS`(与库内执行的那份不同,见上);未知 backend 抛 `ValueError`
- **测试**(`tests/unit/test_telemetry.py` 新增 `TestSchemaModule`): 上述四条各一例。先失败证据: schema.py 不存在时 import 失败。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS;`make check` → 通过(**不要用 `make lint`,它带 `ruff --fix` 会改文件、掩盖问题并污染待审 diff**;import-linter 契约不得报新违规: schema.py 只依赖标准库)。
- **提交**: `refactor: make the telemetry schema a single source of truth`
## Task 2: PG 写入去掉冲突目标
- [ ] **文件**: `src/polygateway/telemetry/schema.py`(PG 分支的 INSERT 尾巴)、`tests/integration/test_postgres_telemetry.py`
- **行为**: PG 的 `ON CONFLICT (call_id) DO NOTHING` 改为 `ON CONFLICT DO NOTHING`。SQLite 的 `INSERT OR IGNORE` 不动(本就无目标)。
- **为什么**(设计 §4.6): PostgreSQL 要求分区表的唯一约束必须包含分区键,issue #12`created_at` 分区后主键变成 `(call_id, created_at)`,带目标的语句再也匹配不到约束,遥测在分区部署下全线写不进去。无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
- **验收**: 普通表上重复 `call_id` 仍只落一行;主键为 `(call_id, created_at)` 的分区表上写入成功不报错。
- **测试**(集成,真实 PG,沿用 `legacy_schema` 同款临时 schema 隔离——**严禁碰共享的 `public.llm_calls`**): 新增两例,① 临时 schema 内建普通表,同 `call_id` 写两次,`COUNT(*) == 1`; ② 临时 schema 内建 `PARTITION BY RANGE (created_at)` 的表 + 一个覆盖当前月的分区 + 主键 `(call_id, created_at)`,写入成功且能读回。先失败证据: 例 ② 在改动前必然抛 `there is no unique or exclusion constraint matching the ON CONFLICT specification`,把该错误信息记进提交说明。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(无 `PGW_TELEMETRY_PG_DSN` 时 skip,**skip 不算通过**,必须在有 DSN 的环境跑一次并留下输出)。
- **提交**: `fix: drop the conflict target so partitioned tables can accept writes`
## Task 3: 两个 recorder 加 `auto_migrate` 与裁剪写入(含 settings 字段与装配透传)
- [ ] **文件**: `src/polygateway/telemetry/sqlite.py``src/polygateway/telemetry/postgres.py`、**`src/polygateway/config.py`**(只加 `telemetry_auto_migrate` 字段与派生)、**`src/polygateway/client.py`**(`_build_telemetry` 透传);`tests/unit/test_telemetry.py`
- **为什么装配透传必须并进本任务**: `_build_telemetry` 现在调用 `PostgresRecorder(dsn)` / `SQLiteRecorder(path)`,参数一旦必填,不同步改这里整条装配路当场 `TypeError`。签名变更与其唯一调用点必须落在同一次提交,否则该提交点跑不通全套件——每个提交点都必须独立可验证。env 键解析与 `.env.example` 仍留给 Task 4。
- **行为**:
- 两个 recorder 的 `__init__` 增 keyword-only **必填** `auto_migrate: bool`
- 列探测后计算 `effective = [c for c in COLUMNS if c in existing]`(保序),据此 `self._columns``self._insert = insert_sql(backend, effective)`;`record_llm_call``self._columns` 取值。
- `auto_migrate=True`: 行为与今天完全一致(先探测后 ALTER、`duplicate column` 视为成功、失败只 warning 不判死),补列成功后 `effective` 为全量。
- `auto_migrate=False`: **不发任何 ALTER**;缺列时 warning **一次**,内容须同时包含 ① 逐列点名的缺失列; ② 一句"以下维度不会被记录"; ③ 可直接执行的补列 SQL。
- 探测失败: 两档都保守回落到全量 `COLUMNS`(今天的行为),warning。
- `call_id` 不在 `effective` 内时 warning 升级措辞(该表不是本库的 `llm_calls`),仍照常尝试写入,库不做二次判定。
- PG 侧 `self._columns`/`self._insert` 必须与 `_schema_ready` **在同一处一起赋值**,不得出现"已就绪但语句还是旧的"的窗口。
- 建表(`CREATE TABLE`)两档都保留,manual 只管 ALTER(设计 §4.2)。
- **验收**: 见测试。
- **测试**(单元,真实临时 SQLite 文件,`tmp_path`):
- manual + 手工建的旧表(22 个 INSERT 字段 + `created_at` = **23 个物理列**) → 写入成功且能读回、`PRAGMA table_info` 行数**保持 23**(证明未 ALTER)、捕获到的 warning 恰有一条且同时含 `tenant_id``meta``ALTER TABLE`
- auto + 同款旧表 → 物理列数变 **25**(24 个 INSERT 字段 + `created_at`,现状回归)。
- manual + 全新库 → 建表且 25 个物理列齐全(建表未被停掉)。
- **warning 捕获不能用 `caplog`**: 库用 loguru,它不经标准 logging,`caplog` 一条也抓不到(那条断言会静默永远绿)。照搬 `tests/integration/test_postgres_telemetry.py:436``captured_warnings` fixture 形态(`logger.add(messages.append, level="WARNING")` + teardown `logger.remove`),在 `tests/unit/test_telemetry.py` 内新建同款 fixture;别命名为 `warnings`,那会遮蔽标准库模块名。
-`call_id` 的畸形表 → warning 升级措辞,不抛异常。
- 先失败证据: 新参数不存在时 `TypeError`;裁剪未实现时 manual 旧表用例因 `no column named tenant_id` 全行丢弃而读不回。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_telemetry.py -v` → PASS。
- **提交**: `feat: gate the automatic ALTER behind an explicit mode`
## Task 4: 配置派生与装配
- [ ] **文件**: `src/polygateway/config.py``.env.example`;`tests/unit/test_config.py`。(`GatewaySettings` 字段与 `client.py` 透传已在 Task 3 落地;本任务只补 env 键解析、派生规则与模板注释。)
- **行为**:
- `config.py``_SCHEMA_MODES = frozenset({"auto", "manual"})`;`_load_pgw` 内: 键未设 → `auto_migrate = telemetry_backend == "sqlite"`;键已设 → 经 `_load_choice` 校验后 `== "auto"`。**派生只写在这一处**。
- `GatewaySettings``telemetry_auto_migrate: bool`(无默认值),`telemetry_backend == "none"` 时恒 `False`
- `.env.example``PGW_TELEMETRY_BACKEND` 附近加注释行,写明三态与两端缺省的不对称及理由。
- **验收**: 未设键 → sqlite `True` / postgres `False` / none `False`;显式 `manual` 让 sqlite 也变 `False`,显式 `auto` 让 postgres 也变 `True`;非法值报 `ValueError` 且错误信息含键名。
- **测试**(`tests/unit/test_config.py`): 上述五条各一例。先失败证据: 字段不存在时 `AttributeError`
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_config.py tests/unit/test_client.py -v` → PASS。
- **提交**: `feat: derive the schema mode from the telemetry backend`
## Task 5: 公共导出
- [ ] **文件**: `src/polygateway/__init__.py``tests/unit/test_package.py`
- **行为**: `telemetry_schema_sql` 加入顶层导出与 `__all__`(按字母序插入)。
- **验收**: `from polygateway import telemetry_schema_sql` 可用;`__all__` 排序未乱;导入顶层包不产生循环导入。
- **测试**: 导出面测试加断言(该名在 `__all__` 内且可调用)。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_package.py -v` → PASS。
- **提交**: `feat: expose the telemetry schema SQL to downstreams`
## Task 6: 真实 Postgres 集成验收
- [ ] **文件**: `tests/integration/test_postgres_telemetry.py`
- **行为**: 新增 manual 档的两例,沿用既有 `legacy_schema` / `least_privilege_pre_tenant_dsn` fixture 的隔离纪律(临时 schema + `search_path`,teardown 删净,**严禁 DROP/TRUNCATE 共享表**)。
- **验收**:
- manual + 22 列旧表 → `information_schema.columns` 断言**没有**新增列、写入成功、缺的两列不写、其余 22 列值正确。
- **`least_privilege_pre_tenant_dsn`**(`tests/integration/test_postgres_telemetry.py:496`——缺列旧表 + 只授 `SELECT, INSERT` 的角色)+ manual → 不再出现补列失败的 warning,写入照常且缺的两列不写。**不要用 `least_privilege_dsn`**: 它用完整 DDL 建的是列齐全的表,压根触发不到缺列路径,那条测试会假绿。
- **测试**: 即上述两例。先失败证据: 改动前 manual 档不存在,构造 recorder 即 `TypeError`
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS(必须在有 `PGW_TELEMETRY_PG_DSN` 的环境实跑,skip 不算数)。
- **提交**: `test: prove manual mode leaves a stale table untouched`
## Task 7: 文档与承诺
- [ ] **文件**: `README.md``CHANGELOG.md``research-wiki/ARCHITECTURE.md`(§7.8)、Gitea wiki(`参考-配置键`/`参考-公共API`/`指南-遥测与成本`)。
- **行为**:
- README: 新配置键与两端不对称缺省及理由;`telemetry_schema_sql` 用法(≤15 行代码块);**Expand/Contract 承诺**成文——新列只增不删不改名、必可空或带非易失默认值、INSERT 永远显式列名、库从不 `SELECT *`、写入的冲突处理不绑定具体约束。
- CHANGELOG: 破坏性三条给"请先读这一条"待遇——① PG 不再自动补列; ② 两个 recorder 新增必填参数; ③ `GatewaySettings` 新增必填字段(影响全量注入装配路)。
- ARCHITECTURE §7.8 补一句 schema 单一事实源与冲突目标的变化;并按设计建议新增 **D15**(库对下游库只做 SELECT/INSERT + 可选 CREATE,改结构与删数据交给下游)。
- **验收**: README 的 SQL 片段可直接复制执行;CHANGELOG 的破坏性段落在版本条目最前;wiki 三页同步(docs-convention §2 的发版清单)。
- **测试**(集成,真实 PG,临时 schema 隔离): README 叫下游执行的就是 `telemetry_schema_sql("postgres")` 的输出,故该输出本身必须有机械化验收——在空的临时 schema 里执行一遍,断言建出的表物理列集合 == `COLUMNS` `{created_at}`;**再执行一遍,不报错**(这同时验证补列语句带 `IF NOT EXISTS` 的幂等性)。人工核对不构成可重复的回归保护,后续改 README 就会失去它。
- **验证**: `conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -v` → PASS;`make ci` → 全绿。
- **提交**: `docs: document the schema mode and the expand-contract promise`
---
## 完成判据
1. 七个任务的提交点全部落地,`make ci` 全绿。
2. 每条行为变更能出示先失败后通过的测试证据(Task 2 的 PG 报错原文必须留档)。
3. 合并前派全新上下文 verifier subagent 独立验证(CLAUDE.md §3 硬门)。
4. 本计划与 issue #12 的计划合并后一起发 1.2.3,发布走 CLAUDE.md §4.4.1 九步。
@@ -0,0 +1,18 @@
---
type: plan
node_id: plan:plan-issue12-telemetry-retention
title: "实现计划: issue12-telemetry-retention"
date: 2026-08-19
---
# 实现计划: issue12-telemetry-retention
正文: `2026-08-19-issue12-telemetry-retention.md`。实现 [[design:issue12-telemetry-retention]]。
五个任务: ① 截断函数 + emitter `text_cap` 必填 + 三构造点; ② 配置与装配; ③ `tools/telemetry_retention.py`(默认 dry-run); ④ README 生产部署模板 + 其真实 PG 机械化验收; ⑤ CHANGELOG 与 wiki。
**前置**: issue #13 须先合并(两条分支都改 `config.py`/`client.py`,且分区模板依赖 #13`telemetry_schema_sql()` 与无冲突目标写入)。
写计划时挖出的实现陷阱: `digest_messages` 对 content 非 list 的消息**原样 append 同一个 dict**,遥测拿到的与调用方传入的、缓存 key 用的是同一份对象——`_cap_messages` 若就地改,会同时污染调用方 messages、后续重试请求体与缓存写入 key,且全程无报错。计划已为此设两条红线用例,并要求先写一版就地改的实现证明红线能抓住它。
- **审查留痕(Codex 计划审,2026-08-19)**: 报 5 项与本计划相关,**全部采纳**。最实质的一条是**三个公共 Client 的直接构造路**: `TelemetryEmitter``text_cap` 必填,而 `GatewayClient`/`EmbeddingClient`/`OcrClient``__init__` 都在内部构造 emitter,只改 `from_settings` 那条路会让直接构造的下游要么撞 `TypeError`、要么永远启用不了 cap。定稿: emitter 保持必填(库内部类,唯一构造者就是这三个 Client,必填保证无一处漏传),三个 Client 各加**带默认值 `None`** 的 `text_cap`(公共装配路,而默认值恰好等于全局缺省的不截断)。其余四条: `tools` 脚本的 PG 分支(分批删除、分区探测退出码 3、缺 asyncpg 退出码 2)原本一条测试证据都没有,已补三例集成用例(缺依赖那例用只含 `raise ImportError` 的临时 `asyncpg.py``PYTHONPATH` 构造);设计要求的**截断覆盖面声明**(`tool_calls.function.arguments` 不在覆盖内)与 **SQLite 文件轮转建议**都漏了文档落点,已补进 Task 4;与 #13 的合并冲突面(`config.py` 的字段列表与 `_load_pgw` 返回键、`client.py` 的装配)措辞已强化为必须从 #13 合并后的 main 开分支。
@@ -0,0 +1,16 @@
---
type: plan
node_id: plan:plan-issue13-schema-mode
title: "实现计划: issue13-schema-mode"
date: 2026-08-19
---
# 实现计划: issue13-schema-mode
正文: `2026-08-19-issue13-schema-mode.md`。实现 [[design:issue13-schema-mode]]。
七个任务: ① 建 `telemetry/schema.py` 单一事实源(纯搬迁,行为不变)+ `insert_sql()`/`telemetry_schema_sql()`; ② PG 写入去掉冲突目标(为分区让路); ③ 两个 recorder 加必填 `auto_migrate` 与裁剪写入; ④ config 派生 + 装配 + `.env.example`; ⑤ 顶层导出; ⑥ 真实 PG 集成验收(临时 schema 隔离,严禁碰共享表); ⑦ 文档与 Expand/Contract 承诺。
`insert_sql` 的列名来自数据库探测结果而非常量,故**子集校验是注入面的闸**,不是形式主义。
- **审查留痕(Codex 计划审,2026-08-19)**: 报 8 项与本计划相关,**全部采纳**。最有价值的三条都会让计划照着写就红在测试本身而非实现: ① 列数断言写成 22/24 是错的——`COLUMNS`**INSERT 字段序**,不含数据库自填的 `created_at`,物理列是 23/25,两套口径混用会写出永远对不上的断言; ② 用 `caplog` 抓 warning 一条也抓不到(库用 loguru,不经标准 logging),那条断言会**静默永远绿**,须照搬 `captured_warnings` 的 loguru sink 形态; ③ 缺列旧表的最小权限现场是 `least_privilege_pre_tenant_dsn` 而非 `least_privilege_dsn`(后者用完整 DDL 建的是列齐全的表,触发不到缺列路径)。另外三条: `make lint``--fix` 会改文件,验证命令须用 `make check`;Task 3 让 recorder 参数必填而 Task 4 才改 `_build_telemetry`,中间那个提交点会 `TypeError`,两者已合并为同一任务;库内执行的补列语句(不带 `IF NOT EXISTS`,先探测以避 ACCESS EXCLUSIVE 锁)与打印给下游的脚本(必须带 `IF NOT EXISTS` 才幂等)**是两份不是一份**,原计划那句「原样搬迁」会产出不可重复执行的迁移 SQL。Task 7 的 README 验收也从人工核对升级为机械化: `telemetry_schema_sql` 的输出在临时 schema 执行两遍,断言列集合正确且第二遍不报错。
@@ -0,0 +1,312 @@
# 实现计划: 熔断拒绝补齐等待档(issue #14)
- **设计**: `research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md`(人类已确认 + Codex 已审)
- **分支**: `feat/issue-14-circuit-open-policy`
- **版本**: 1.3.0(新增配置键 + `retry_after_s` 语义变更)
## 目标
让"源不健康"不再等同于"这次调用当场判死"——补上 `{SCOPE}__CIRCUIT_OPEN=fail_fast|wait` 这一格准入策略,并把 `retry_after_s` 的语义在两个后端的五个出口上定死。
## 方案概述
三件事环环相扣: ①把 `retry_after_s` 定义为"距离**确定**可再试的时刻还有多久",HALF_OPEN 与准入允许一律 `0.0`(顺带修掉源冷却备忘被探针租约污染的 bug);②新增 `circuit_open` 策略键,`wait` 档下不抛 `CircuitOpenError` 而按 `retry_after` 睡、由 stall 预算兜底;③前置把三条治理循环里逐字复制的准入逻辑收敛成一份,否则本次修复会在 embedding/ocr 留下两个行为分叉的角落。
涉及技术: Python 3.11 asyncio、Redis Lua(EVALSHA)、pytest 双后端参数化契约测试。
## 保真校验适用性
**适用**。熔断状态机是 ARCHITECTURE.md §1.4 关键资产(蓝本 `reference/Video-Tree-TRM5/adapters/breaker.py``reference/CHSAnalyzer/app/coordination/provider_gate.py`),准入循环蓝本为 `reference/CHSAnalyzer/app/providers/governance.py:107-285`。T1 与 T2/T3 各带保真校验检查点。
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/polygateway/middleware/admission.py` | **新建** | `SourceAdmission`(准入与无源可跑的处置,三条循环共用)+ 模块级 `settle_and_release` |
| `src/polygateway/middleware/retry.py` | 修改 | 删除本地 `_pick_runnable`/`_on_no_runnable`/`_settle_and_release`,改用 `SourceAdmission`;主循环与 `_attempt` 不动 |
| `src/polygateway/embedding.py` | 修改 | 同上 |
| `src/polygateway/ocr.py` | 修改 | 同上(注意 `_settle_and_release` 原签名只有 `permit`) |
| `src/polygateway/backends/memory/breaker.py` | 修改 | 抽 `_remaining(g)`,三处出口共用;HALF_OPEN 与授予探针恒 `0.0` |
| `src/polygateway/backends/redis/breaker.py` | 修改 | 五个 Lua 出口同步(`TRY_ENTER` 两处、`RECORD_SUCCESS`/`RECORD_FAILURE`/`RELEASE_PROBE` 各一处、`RETRY_AFTER` 一处) |
| `src/polygateway/config.py` | 修改 | `_CIRCUIT_OPEN` 常量、`GatewaySettings.circuit_open` 字段、`_validate_backends` 元组、`from_env` 装载 |
| `src/polygateway/client.py` | 修改 | 构造签名 + 透传 |
| `src/polygateway/errors.py` | 修改 | `GatewayUnavailableError` docstring 职责边界 |
| `tests/contracts/test_breaker_contract.py` | 修改 | 按五个出口逐个钉 `retry_after_s` |
| `tests/integration/test_redis_governance_time.py` | 修改 | Redis 真实等待变体补 HALF_OPEN 出口 |
| `tests/unit/test_backpressure.py` | 修改 | `circuit_open` 行为矩阵、备忘污染回归、`_nap` 上界 |
| `tests/unit/test_config.py` | 修改 | 新键的合法域、缺省、两条装配路一致 |
## 关键接口(跨任务消费,此处定死)
`SourceAdmission` 构造与两个方法:
```python
class SourceAdmission:
def __init__(self, *, scope: str, sources: list[SourceConfig],
selector: SourceSelector, quota: QuotaGate, breaker: BreakerGate,
memo: SourceCooldownMemo, backpressure: BackpressurePolicy,
quota_full: str, circuit_open: str,
pacer: AdaptivePacer | None = None,
health_view: Callable[[str], float] | None = None,
now=time.monotonic, sleep=asyncio.sleep, rng=random.random) -> None: ...
async def pick(self, reasons: dict[str, str], attempt_fails: dict[str, int]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]: ...
async def on_no_runnable(self, gate_rejections: int, reasons: dict[str, str],
clock: StallClock) -> None: ...
async def stalled(self, clock: StallClock) -> bool: ...
```
`quota`/`breaker`/`pacer`/`selector`/`sources` 均为**调用方传入的同一实例**(不在 admission 内新建),因为三处 `_attempt` 仍需引用它们;`memo` 则由 admission 独占。`health_view` 对应 chat 的 `self._health_view`(由 `isinstance(selector, OutcomeAwareSelector)` 在 RetryMW 构造期判定一次),embedding/ocr 传 `None`
模块级结算函数(三处 `_attempt` 的 finally 与 admission 共用):
```python
async def settle_and_release(permit: Permit, actual: int) -> None:
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
```
睡眠时长(T5 实现,写死在 `SourceAdmission._nap`):
```python
def _nap(self, hint: float, clock: StallClock) -> float:
jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
wait = hint + jitter if hint > 0 else jitter
return max(jitter, min(wait, budget))
```
`hint == 0` 时该式退化为 `jitter`,即现有 quota-wait 行为逐字不变(`tests/unit/test_backpressure.py` 已钉 `[0.5p, 1.0p]`)。**下界取 `jitter` 而非 `poll_interval_s`(实施期修正)**: 后者会把 `rng → 0` 那半边从 `0.5p` 抬到 `1.0p`,既有的 `test_poll_jitter_bounds` 当场变红;`jitter` 同样能在预算为负时兜住不返回负数、不忙循环。`budget` 加一个 `poll_interval_s` 是因为 `_stalled` 判据是 `>` 而非 `>=`(`retry.py:368`),恰好夹到窗口不会判死。
**调用约束**: `_nap` 必须在 `stalled()` 判定**之后**调用。若已 stall 超窗才进来,`budget` 为负,外层 `max(poll_interval_s, ...)` 会兜成一个 poll 间隔(不会返回负数),但那意味着本该判死却又睡了一轮——顺序由 `on_no_runnable` 保证(两条路汇合后统一判 `stalled()` 再 sleep)。验算示例: `hint=60, stall_window=300, 已 stall 290, poll=0.05``jitter∈[0.025,0.05]``budget=10.05` → 返回 `10.05`,醒来累计约 `300.05` > 300,下一轮判死。
## 任务清单
### T0 — 分支与基线
- [ ] 建分支 `feat/issue-14-circuit-open-policy`(从 main)
- [ ] 记录基线: `conda run -n PolyGateway python -m pytest tests/ -q``make check` + `lint-imports` 全绿,记下**本机本环境**的用例计数(执行时实测,2026-08-19 为 988 passed / 32 deselected)。该数只作同环境参照——`addopts = "-m 'not slow'"` 与 Redis 可达性都会改变它,不作硬验收
**验证**: `conda run -n PolyGateway python -m pytest tests/ -q` → 全 PASS;`git rev-parse --abbrev-ref HEAD` → 分支名正确
---
### T1 — 纯重构: 准入逻辑三处收敛(回滚点)
**动**: 新建 `src/polygateway/middleware/admission.py`;改 `middleware/retry.py``embedding.py``ocr.py`
**要实现的行为**: 把 `_pick_runnable`/`_on_no_runnable`/`_stalled`/`_settle_and_release` 从三处搬进 `SourceAdmission` 与模块级 `settle_and_release`,三条循环改为持有 `SourceAdmission` 实例并调用其方法。**本任务不引入 `circuit_open` 参数**(构造签名先只收 `quota_full`,T4 再加),控制流一字不改。
三条循环的差异只用注入表达,不留 `if` 分支:
| 差异 | 处理 | 等价性依据 |
|---|---|---|
| 调用内降权(仅 chat) | `attempt_fails``pick()` 入参,内部无条件调 `_demote_call_failures` | 传空 dict 时 `demoted` 为空 → `return ordered` 原对象返回,恒等(`retry.py:148-150`) |
| AIMD pacer(仅 chat) | `pacer: AdaptivePacer \| None = None` | None 时跳过 `admit()``enter()` 两个调用点,无副作用 |
| `_settle_and_release` 签名 | OCR 原为 `(permit)`、体内恒 `settle(0)`;改为调 `settle_and_release(permit, 0)` | 逐字等价 |
| warning 文案**三处都不同** | 归一为 "permit 结算/释放失败(不掩盖主异常)" | chat `retry.py:536` 已是该文案;embedding `embedding.py:411` 为 "embedding permit …"、OCR `ocr.py:448` 为 "OCR permit …" 将被归一(Codex 审查补,原稿只承认了 OCR)。这是本任务**唯一**的可见行为变化,须在提交信息里点名 |
| `_stalled` 形态 | chat 已抽成方法,embedding/ocr 为内联表达式 | 两者语义逐字相同(已 diff 核实),统一用 `SourceAdmission.stalled()` |
**搬走 vs 共享(自审修正,这一条决定 T1 能否成立)**: 三处 `_attempt` 仍在引用 `self._breaker`(记账写回)、`self._quota`(mark_progress)、`self._pacer`(leave)、OCR 还有 `self._selector`(健康喂数,`ocr.py:426`)。因此这些字段**不搬走,而是共享同一实例**——循环保留自己的引用,构造 `SourceAdmission` 时把同一对象传进去(`AdaptivePacer` 有在途计数状态,必须是同一实例而非新建,否则 `admit`/`enter``leave` 分裂到两个计数器上)。真正搬走的只有 `_pick_runnable`/`_on_no_runnable`/`_stalled` 三个方法与 `self._memo`(仅被 `pick` 消费)。
**`_attempt` 的唯一改动**: `self._settle_and_release(permit, actual)` → 模块级 `settle_and_release(permit, actual)`,OCR 侧由 `(permit)` 变为 `(permit, 0)`。除此之外 `_attempt` 一行不动。原稿"三处 `_attempt` 本体不在边界内"的说法与"搬走 `_settle_and_release`"自相矛盾,此处更正。
**不在边界内、须原样保留**: chat 主循环顶部那次额外的 `_stalled` 预判(`retry.py:286`)、OCR 的 `_gate_on_terminal`(`ocr.py:412`)与健康喂数。
**保真校验检查点**: 对照 `reference/CHSAnalyzer/app/providers/governance.py:107-285`,确认搬运后 `_pick_runnable` 的候选跳过顺序(备忘 → pacer → 配额 → 熔断门)、`gate_rejections` 的计入规则(备忘与熔断门计入,pacer 与配额不计入)、`_on_no_runnable` 的三段判定顺序逐段未变。
**测试要求(本任务特殊)**: **不新增行为用例**。全套件绿是必要条件而非充分条件——它证明不了"逐字不变",故本任务额外要求一次**机械差异审查**: 把搬迁前后的 `pick`/`on_no_runnable` 逐语句对照,确认候选跳过顺序、`gate_rejections` 计入规则、`reasons``[]=``setdefault` 用法(两者语义不同,不可互换)一字未变。
**已知会碰到的既有测试**: `tests/unit/test_health_selector.py:146` 断言 `client._terminal._pacer._ceiling`,`tests/unit/test_client.py:380` 断言 `._terminal._emitter._text_cap`——这两个字段必须留在 `RetryMW` 上(与上面"共享而非搬走"一致),否则这些用例会红。
**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/ -q # 期望: 全 PASS,计数与 T0 同环境基线一致
conda run -n PolyGateway make check # 只读: ruff format --check + ruff check
conda run -n PolyGateway lint-imports # 依赖铁律
```
**不要用 `make lint` 做验证**——它带 `--fix` 会自动改文件(`Makefile:11`),只读验证用 `make check` + `lint-imports`。用例计数只作**同环境**参照,不作硬验收: `pytest` 默认 `-m 'not slow'`(`pyproject.toml:51`),且无 `REDIS_URL` 时 Redis 用例 skip,计数随环境浮动。
import-linter 层级(`pyproject.toml:76`)允许 `middleware/admission.py` 依赖 `ports`/`types`/`errors`/`sources`(更内层),但不得 import 任何 `backends/``transports/``telemetry/`。搬迁后须清理三个原文件中失去引用的 import(`CircuitOpenError``QuotaGate``BreakerGate``SourceCooldownMemo` 等),否则 ruff 报未使用导入。
- [ ] 提交: `refactor: 把三条治理循环的准入逻辑收敛为 SourceAdmission`
---
### T2 — `retry_after_s` 语义统一(两个后端一次到位)
**动**: `src/polygateway/backends/memory/breaker.py``src/polygateway/backends/redis/breaker.py``tests/contracts/test_breaker_contract.py``tests/integration/test_redis_governance_time.py`
**为什么两个后端必须同一个提交(Codex 审查修正)**: 原稿把 memory 与 redis 拆成 T2/T3 两次提交,中间 redis 侧契约用例会处于 red。但 `.claude/settings.json` 注册的 `pre-commit-guard.sh` 在检测到 `git commit` 时会跑 `pytest tests/ --tb=line -q`(`pre-commit-guard.sh:61`),红态直接卡住提交。且两者本就是**同一个契约的两个实现**,分开提交没有独立意义。
**要实现的行为**: `retry_after_s` = "距离**确定**可再试的时刻还有多久"。HALF_OPEN 下探针随时可能出结果,不存在确定时刻,故 `0.0`;准入被允许时同样恒 `0.0``0 = 可立即重试` 是库既有约定(`errors.py` 与现有契约用例"健康 → 0、冷却到期 → 0")。
memory 侧: 抽私有纯方法 `_remaining(g: _SourceGate) -> float`(OPEN 返回 `max(0.0, g.open_until - now)`,其余状态含 HALF_OPEN 返回 `0.0`),`try_enter` 的 HALF_OPEN 拒绝分支(`memory:148`)与 `retry_after_s()`(`memory:267`)改用它。`_snapshot`(`memory:169`)与授予探针(`memory:114`)已符合新契约,保持不变。
redis 侧共**六个返回格**,逐处点名(改前先确认行号仍对得上):
| 脚本 | 位置 | 现状 | 改为 |
|---|---|---|---|
| `TRY_ENTER` HALF_OPEN 拒绝 | `redis:44` | `probe_until - now` | `0` |
| `TRY_ENTER` 授予探针 | `redis:53` | `tonumber(ARGV[2])`(= probe TTL) | `0` |
| `RECORD_SUCCESS` fencing 未命中 | `redis:124` | half_open 取 `probe_until` | half_open 记 `0`(只 OPEN 取 `open_until - now`) |
| `RECORD_FAILURE` fencing 未命中 | `redis:155` | 同上 | 同上 |
| `RELEASE_PROBE` fencing 未命中 | `redis:255` | 同上 | 同上 |
| `RETRY_AFTER` | `redis:275` | half_open 取 `probe_until` | half_open 记 `0` |
后四行修的是**既有的双后端语义分叉**(memory `_snapshot` 对非 OPEN 一律 `0.0`),与本 issue 同源,由契约测试盲区掩护至今——现有用例只钉"第二个进入者被拒",没钉它拿到什么数。
**保真校验检查点**: 状态机转换、双通道开路判据、`_cooldown_eff` 指数退避、epoch fencing 匹配条件、Lua 的原子性结构与 `redis.call('TIME')` 服务器时钟口径**一律不动**——本任务只改"对外报几"这一件事,即 return 元组里 `retry_after_ms` 那一格。改完逐脚本与 memory 实现对照走一遍状态机。
**测试要求**(先失败后通过,`tests/contracts/` 双后端参数化,一次覆盖 memory + redis):
- HALF_OPEN 被拒: `decision.retry_after_s == 0.0``decision.state is GateState.HALF_OPEN`
- 授予探针的决定: `retry_after_s == 0.0`
- `record_*` 在 fencing 未命中且门处于 HALF_OPEN: `GateUpdate.retry_after_s == 0.0`(须同时断言 `applied is False``state is HALF_OPEN`,否则用例可能在别的分支上误绿)
- `gate.retry_after_s(("s1",))` 探针在途时返回 `0.0`
- 现有 `test_retry_after_semantics` / `test_retry_after_takes_min_across_sources` 保持绿(OPEN 语义未变)
**Redis 时间语义变体**: 契约层用 `clock.advance()` 的用例在 redis 参数下会 skip(`conftest.py:39``SkipClock` 哨兵),故须在 `tests/integration/test_redis_governance_time.py` 补 1:1 真实等待变体(既有约定: 不缩放时长)。该文件的 `test_meta_variants_cover_all_time_cases`(`:56`)会**机械拦截**漏配,漏了就红。
**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/contracts/test_breaker_contract.py -q # 双后端全 PASS
conda run -n PolyGateway python -m pytest tests/integration/test_redis_governance_time.py -m slow -q
```
第二条**必须带 `-m slow`**: `pyproject.toml:51``addopts = "-m 'not slow'"` 默认排除真实等待变体,不加就是空跑(该文件单跑 12-15 分钟)。需真实 Redis(db3),不 mock Lua 行为。
- [ ] 提交: `fix: 把 retry_after_s 定义为确定可再试时刻,HALF_OPEN 归零(双后端)`
---
### T3 — (已并入 T2)
原计划把 redis 侧拆为独立任务,因 pre-commit hook 会拦截中间红态而合并进 T2。此编号保留以免后续引用错位。
---
### T4 — 新配置键 `{SCOPE}__CIRCUIT_OPEN`
**动**: `src/polygateway/config.py``src/polygateway/client.py``src/polygateway/middleware/admission.py``embedding.py``ocr.py``tests/unit/test_config.py`
**要实现的行为**: 与 `quota_full` 逐项同构,不发明新形状。
| 位置 | 改动 |
|---|---|
| `config.py` 常量区 | `_CIRCUIT_OPEN = frozenset({"wait", "fail_fast"})`,紧邻 `_QUOTA_FULL` |
| `GatewaySettings` | 新增字段 `circuit_open: str`,**无默认值**(与该类全部既有字段一致),位置紧随 `quota_full` |
| `_validate_backends` | 校验元组加一行 `("circuit_open", _CIRCUIT_OPEN)` |
| `from_env` | `circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast")` |
| `client.py` | `GatewayClient.__init__``circuit_open: str = "fail_fast"`;`from_settings` 透传 `settings.circuit_open` |
| `admission.py` | 构造收 `circuit_open`,同 `quota_full` 做构造期域校验并抛 `ValueError` |
| `embedding.py` / `ocr.py` | 两个客户端的构造签名与"从 GatewayClient 派生"路径(`embedding.py:561``ocr.py:574` 邻域)各透传一处 |
**缺省取 `fail_fast`**(人类 2026-08-19 决策): 保证控制流对存量下游不变。
**测试要求**(先失败后通过):
- 缺省档: 不设该键时 `settings.circuit_open == "fail_fast"`
- 合法域: 设为 `"nope"``from_env` 与直接构造**两条路**都抛 `ValueError` 且消息点出键名/字段名
- 两条装配路一致: `from_env` 与直接构造同一取值产出同一行为
- `dataclasses.replace(settings, circuit_open="wait")` 仍通过全部装配守卫
- 透传链: 从 `GatewaySettings` 一路到三条循环的 `SourceAdmission` 实例上取值正确
**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/unit/test_config.py tests/unit/test_client.py -q
```
- [ ] 提交: `feat: 新增 {SCOPE}__CIRCUIT_OPEN 策略键(缺省 fail_fast)`
---
### T5 — `on_no_runnable` 按原因分派 + `_nap`
**动**: `src/polygateway/middleware/admission.py``tests/unit/test_backpressure.py`
**要实现的行为**: 把现状串行的两个分支改为按拒绝原因分派(伪码见设计 §3.3)。要点:
1. `gate_rejections == len(sources)`(全部因熔断类原因被拒)时,`fail_fast``CircuitOpenError`(现行为),`wait``hint = await breaker.retry_after_s(names)` 后**不抛**;
2. 否则(至少一源是被配额/AIMD 挡的)走 `quota_full` 分支,`hint = 0.0`;
3. 两条路汇合后统一判 `stalled()`,再 `await sleep(self._nap(hint, clock))`
**必须避免的坑**: 若只把第一分支改成"wait 时不抛"而不做分派,控制流会掉进 `quota_full` 分支——`quota_full=fail_fast` 的调用方会看到熔断等待被误报成 `reason="quota_exhausted"`
**可观测性**: `wait` 档每轮进入等待时 `logger.info` 一条(scope、`per_source_reasons`、本次睡眠秒数)。**只此一条,不打"醒来"那条**(实施期决定): 每一轮等待各自留痕,时间线已可完整还原,而醒来后若仍被拒会立刻打下一条——补一条"醒来"只会让日志量翻倍且信息重复。**不新增遥测列**(等待期不发请求,无 attempt 行可记;调用级总耗时下游可自测)。
**计时归属**: 睡眠发生在 `clock.attempting()` 之外,自动计入 stall 账,与 ARCH §7.3"熔断冷却属非生产性等待"一致——**无需改 `StallClock`**。
**取消穿透**: `_nap` 只做算术,睡眠是裸 `await self._sleep(...)`,不得包 `try/except`
**测试要求**(先失败后通过,注入时钟/睡眠/rng 保持确定性):
- `circuit_open=wait` + 全源开路 → **不**抛 `CircuitOpenError`,而是按 `retry_after` 睡;冷却结束后拿到探针并成功返回
- `circuit_open=wait` + `quota_full=fail_fast` + 全源开路 → **不**抛 `quota_exhausted`(这是上面那个坑的钉子)
- `circuit_open=wait` + 冷却比 stall 预算还长 → 抛 `AllSourcesExhausted(reason="stalled")`,`per_source_reasons``circuit_open`,累计墙钟 ≤ `stall_window_s + poll_interval_s`
- `circuit_open=wait` + 源持续 `force_open`**`retry_exhausted` 而非 `stalled`**(整分支审查发现,原稿写错): 冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`,故两个预算里先耗尽的那个决定 reason
- 混合原因(部分 `circuit_open` + 部分 `rate_limited`)→ 走 quota 分支,`per_source_reasons` 如实混合
- `hint == 0` 时睡眠落在 `[0.5p, 1.0p]`(现有 quota-wait 行为逐字不变)
- `wait` 档等待中收到 `CancelledError` → 逐字穿透,in-flight permit 已释放
- `circuit_open=fail_fast`(缺省)下,全部现有用例逐字绿
- **备忘污染回归**(issue #14 §1.3): 探针成功后 `memo.active(源名)` 为 False,该源立即重新可选——此用例由 `/tmp/.../probe_repro.py` 的复现脚本转化而来,在 T2 之前必然 red
**验证**:
```bash
conda run -n PolyGateway python -m pytest tests/unit/test_backpressure.py tests/unit/test_retry.py -q
conda run -n PolyGateway python -m pytest tests/ -q # 全套件
```
- [ ] 提交: `feat: circuit_open=wait 下熔断拒绝改为等待而非当场判死`
---
### T6 — `errors.py` 职责边界补写
**动**: `src/polygateway/errors.py`
**要实现的行为**: 改写 `GatewayUnavailableError` 的 docstring。现文"业务侧 catch 本类做延期重投(CHS arq 模式)"读起来像鼓励每个下游各写一份重试逻辑;改为明确边界——调用级的重试/退避/换源/等待全部在库内,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是**任务级重试**,语义与调用级重试不同(ARCH §7.2 单层重试原则)。
`retry_after_s` 那句保留并补一句: 它是"距离确定可再试的时刻",`0` 表示无确定等待(可立即重试)。
**测试要求**: 纯 docstring,无行为变更。验收为 `tests/unit/test_errors.py` 保持绿。
**验证**: `conda run -n PolyGateway python -m pytest tests/unit/test_errors.py -q`
- [ ] 提交: `docs: 收回 GatewayUnavailableError 的重试职责边界`
---
### T7 — 文档同步
**动**: `research-wiki/ARCHITECTURE.md``README.md``CHANGELOG.md`、Gitea wiki。
| 目标 | 内容 |
|---|---|
| ARCH §7.4 | 增补本次决策: 三条缺陷的成因、`retry_after_s` 的契约定义(五个出口)、`circuit_open` 策略键与缺省理由 |
| ARCH §9 配置面 | 登记 `{SCOPE}__CIRCUIT_OPEN` |
| README | 配置表新增该键;**明写"单源 scope 建议配 `wait`"**——缺了这句,这个开关等于不存在;核对安装命令的版本约束是否需要跟着改 |
| CHANGELOG | 记 1.3.0,`retry_after_s` 语义变更给"请先读这一条"待遇(缺省档下 `CircuitOpenError.retry_after_s` 在全源 HALF_OPEN 时由探针租约剩余变为 0) |
| Gitea wiki | 按 `research-wiki/docs-convention.md` §2 清单同步 |
**验证**: 人工逐项核对上表;`grep -n "CIRCUIT_OPEN" README.md research-wiki/ARCHITECTURE.md` 各有命中。
- [ ] 提交: `docs: 记录熔断等待档与 retry_after_s 契约`
---
### T8 — 合并前独立验证
- [ ] 派**全新上下文** verifier subagent(`verification-before-completion`),逐条核对: 设计每一节是否有对应实现、五个 `retry_after_s` 出口是否都改到、三条循环行为是否一致、测试证据是否都是"先失败后通过"
- [ ] `conda run -n PolyGateway make check` + `conda run -n PolyGateway lint-imports` 全绿(**不用 `make lint`**,它带 `--fix` 会改文件)
- [ ] `conda run -n PolyGateway make test` 全套件绿 + 覆盖率 ≥ 80%
- [ ] Redis integration 套件在真实 Redis 上绿,含 `-m slow` 的时间语义变体(默认 addopts 会排除它)
- [ ] `requesting-code-review` 走一次整分支审查
- [ ] `finishing-a-development-branch`: `--no-ff` 合并 main,合并后在 main 上重跑 lint 与全套件
**注**: 发布(tag/构建/上传 registry/建 Release)按 CLAUDE.md §4.4.1 九步走,**不在本计划范围**,需人类确认后单独执行。
## 自审记录
- 设计每一节到任务的映射: §3.1→T2+T3、§3.2→T4、§3.3→T5、§3.4→T1、§3.5→T4(缺省值)+T7(文档)、§3.6→T6、§4 行为矩阵→T5 测试、§5 测试策略→T2/T3/T5、§6 非功能→T5(取消/计时/上界)
- 无 TBD/TODO/"适当的错误处理"类占位
- 跨任务消费的 `SourceAdmission` 签名、`settle_and_release``_nap` 公式已在"关键接口"写出实际代码
- 任务顺序有硬依赖: T1(收敛)必须先于 T5(在单一位置加语义)。原 T2/T3 拆分已合并——pre-commit hook 跑全套件,任何跨提交的红态都会被拦
@@ -0,0 +1,30 @@
---
type: review
node_id: review:issue14-branch-review
title: "整分支审查: issue #14 熔断等待档"
date: 2026-08-20
---
# 整分支审查: issue #14 熔断等待档
- **范围**: `feat/issue-14-circuit-open-policy`,296c765..5a025b6(8 提交,src 6 文件 + tests 5 文件)
- **审查方**: Codex 全新上下文只读审查(两轮: 独立验收 + 整分支审查)
- **结论**: **needs_changes → 修正后 approved**;Critical 0 项
## 发现与处置
| 级别 | 发现 | 核实 | 处置 |
|---|---|---|---|
| Important | `circuit_open=wait` + 持续 `force_open` 实际抛 `retry_exhausted` 而非文档声称的 `stalled` | **成立**。冷却结束后放行的探针是真实尝试,失败照样烧一格 `max_attempts`;审查方以单源 + 连续 `SourceDeadError("401")` 复现,本地补测试复现一致 | **改文档不改代码**——该行为符合 issue #8 确立的"划分依据是谁消耗重试预算"。修正 CHANGELOG / README / 设计 §4 行为矩阵 / 计划 T5,并补 `test_wait_does_not_exempt_probes_from_the_retry_budget` 钉死 |
| Minor | 计划要求进入/退出等待各一条日志,实现只有进入那条 | 成立 | **保持一条**,修计划措辞: 每轮等待各自留痕已可还原时间线,醒来后若仍被拒会立刻打下一条,补"醒来"只会让日志量翻倍 |
| — | 上一轮独立验收挑出计划 `_nap` 伪码下界与实现不一致(`poll_interval_s` vs `jitter`) | 成立 | 实现是对的(用 `poll_interval_s` 会把既有 quota 轮询的 `rng→0` 半边从 `0.5p` 抬到 `1.0p`),已回填计划 |
审查方两轮均确认: T1 收敛行为等价、六个 `retry_after_s` 出口齐备、备忘污染闭合、取消穿透与 permit/pacer 配对无泄漏、缺省档控制流不变。
## 验证证据(本会话工具输出)
- 全套件 `pytest tests/ -q`: **980 passed, 25 skipped, 36 deselected**(基线 967 passed;+13 为新增用例)
- 覆盖率 `make test`: 总 **94%**(`admission.py` 93%、`config.py` 99%、`memory/breaker.py` 96%)
- Redis 时间语义全变体 `-m slow`: **18 passed in 1151s**(19 分 11 秒,真实等待不缩放),含本次新增 4 个
- `make check``lint-imports`: 全绿,**Contracts: 1 kept, 0 broken**
+3 -1
View File
@@ -23,6 +23,7 @@ from polygateway.errors import (
from polygateway.ocr import OcrClient from polygateway.ocr import OcrClient
from polygateway.pricing import ModelPrice, PricingTable from polygateway.pricing import ModelPrice, PricingTable
from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider from polygateway.providers import DEFAULT_PROFILES, ProviderProfile, register_provider
from polygateway.telemetry.schema import telemetry_schema_sql
from polygateway.types import ( from polygateway.types import (
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
@@ -32,7 +33,7 @@ from polygateway.types import (
SourceConfig, SourceConfig,
) )
__version__ = "1.2.1" __version__ = "1.2.4"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
@@ -64,4 +65,5 @@ __all__ = [
"__version__", "__version__",
"gather_bounded", "gather_bounded",
"register_provider", "register_provider",
"telemetry_schema_sql",
] ]
+20 -16
View File
@@ -100,6 +100,22 @@ class InMemoryGate:
streak = max(1, g.reopen_streak) streak = max(1, g.reopen_streak)
return min(self._cfg.cooldown_s * (2 ** (streak - 1)), self._cfg.max_cooldown_s) return min(self._cfg.cooldown_s * (2 ** (streak - 1)), self._cfg.max_cooldown_s)
def _remaining(self, g: _SourceGate) -> float:
"""距离**确定**可再试的时刻还有多久(issue #14 的契约定义)。
OPEN 的冷却截止是确定时刻;HALF_OPEN 下探针随时可能出结果,**不存在**
确定时刻,故 `0.0`——`0 = 可立即重试` 是库既有约定。此前这里返回探针
租约剩余,而租约长度是死锁保护参数(派生自 `2 × 最慢源 timeout`),与
"源多久能恢复"无因果关系;它还被喂进源冷却备忘,而备忘 `set_until`
取更晚者不可回退,于是门恢复 CLOSED 后本进程仍跳过该源整整一个租约。
三个出口(`try_enter` 拒绝、`_snapshot`、`retry_after_s`)共用本方法,
避免同一语义在三处各算一遍而漂移。
"""
if g.state is GateState.OPEN:
return max(0.0, g.open_until - self._now())
return 0.0
def _grant_probe(self, g: _SourceGate, source_name: str, owner: str) -> GateDecision: def _grant_probe(self, g: _SourceGate, source_name: str, owner: str) -> GateDecision:
g.state = GateState.HALF_OPEN g.state = GateState.HALF_OPEN
g.probe_owner = owner g.probe_owner = owner
@@ -140,7 +156,7 @@ class InMemoryGate:
epoch=g.epoch, epoch=g.epoch,
is_probe=False, is_probe=False,
probe_owner=None, probe_owner=None,
retry_after_s=g.open_until - now, retry_after_s=self._remaining(g),
) )
# HALF_OPEN: 探针在途;租约过期则接管,否则拒绝(防惊群) # HALF_OPEN: 探针在途;租约过期则接管,否则拒绝(防惊群)
if now >= g.probe_expires: if now >= g.probe_expires:
@@ -152,7 +168,7 @@ class InMemoryGate:
epoch=g.epoch, epoch=g.epoch,
is_probe=False, is_probe=False,
probe_owner=None, probe_owner=None,
retry_after_s=g.probe_expires - now, retry_after_s=self._remaining(g),
) )
def _fenced(self, g: _SourceGate, entry: GateDecision) -> bool: def _fenced(self, g: _SourceGate, entry: GateDecision) -> bool:
@@ -172,9 +188,7 @@ class InMemoryGate:
state=g.state, state=g.state,
epoch=g.epoch, epoch=g.epoch,
failure_count=g.fails, failure_count=g.fails,
retry_after_s=max(0.0, g.open_until - self._now()) retry_after_s=self._remaining(g),
if g.state is GateState.OPEN
else 0.0,
) )
def _open(self, g: _SourceGate, reason: str, *, bump_streak: bool) -> None: def _open(self, g: _SourceGate, reason: str, *, bump_streak: bool) -> None:
@@ -258,14 +272,4 @@ class InMemoryGate:
"""集合中最早可尝试时间;健康/到期返回 0。""" """集合中最早可尝试时间;健康/到期返回 0。"""
if not sources: if not sources:
raise ValueError("sources 不能为空") raise ValueError("sources 不能为空")
now = self._now() return min(self._remaining(self._gate(name)) for name in sources)
waits = []
for name in sources:
g = self._gate(name)
if g.state is GateState.OPEN:
waits.append(max(0.0, g.open_until - now))
elif g.state is GateState.HALF_OPEN:
waits.append(max(0.0, g.probe_expires - now))
else:
waits.append(0.0)
return min(waits)
+18 -16
View File
@@ -42,7 +42,8 @@ if state == 'open' and now < open_until then
return {0, state, epoch, 0, '', open_until - now} return {0, state, epoch, 0, '', open_until - now}
end end
if state == 'half_open' and now < probe_until then if state == 'half_open' and now < probe_until then
return {0, state, epoch, 0, '', probe_until - now} -- 探针在途: 无确定的可再试时刻 → 0(issue #14,与 memory `_remaining` 同口径)
return {0, state, epoch, 0, '', 0}
end end
local next_probe_until = now + tonumber(ARGV[2]) local next_probe_until = now + tonumber(ARGV[2])
@@ -50,7 +51,7 @@ redis.call('HSET', KEYS[1],
'state', 'half_open', 'state', 'half_open',
'probe_owner', ARGV[1], 'probe_owner', ARGV[1],
'probe_until', next_probe_until) 'probe_until', next_probe_until)
return {1, 'half_open', epoch, 1, ARGV[1], tonumber(ARGV[2])} return {1, 'half_open', epoch, 1, ARGV[1], 0}
""" """
# M2.5 窗口/退避公共片段(拼接进 success/failure 脚本;Lua 脚本间无法共享函数) # M2.5 窗口/退避公共片段(拼接进 success/failure 脚本;Lua 脚本间无法共享函数)
@@ -124,8 +125,6 @@ end
local deadline = 0 local deadline = 0
if state == 'open' then if state == 'open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0') deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
elseif state == 'half_open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
end end
return {0, state, epoch, failures, math.max(deadline - now, 0)} return {0, state, epoch, failures, math.max(deadline - now, 0)}
""" """
@@ -156,8 +155,6 @@ if not matches then
local deadline = 0 local deadline = 0
if state == 'open' then if state == 'open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0') deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
elseif state == 'half_open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
end end
return {0, state, epoch, failures, math.max(deadline - now, 0)} return {0, state, epoch, failures, math.max(deadline - now, 0)}
end end
@@ -255,8 +252,6 @@ end
local deadline = 0 local deadline = 0
if state == 'open' then if state == 'open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0') deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
elseif state == 'half_open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
end end
return {0, state, epoch, failures, math.max(deadline - now, 0)} return {0, state, epoch, failures, math.max(deadline - now, 0)}
""" """
@@ -272,9 +267,6 @@ for _, key in ipairs(KEYS) do
if state == 'open' then if state == 'open' then
local deadline = tonumber(redis.call('HGET', key, 'open_until') or '0') local deadline = tonumber(redis.call('HGET', key, 'open_until') or '0')
remaining = math.max(deadline - now, 0) remaining = math.max(deadline - now, 0)
elseif state == 'half_open' then
local deadline = tonumber(redis.call('HGET', key, 'probe_until') or '0')
remaining = math.max(deadline - now, 0)
end end
if minimum == nil or remaining < minimum then minimum = remaining end if minimum == nil or remaining < minimum then minimum = remaining end
end end
@@ -367,7 +359,9 @@ class RedisGate:
keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms] keys=[self._key(source_name)], args=[owner, self._probe_ttl_ms]
) )
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 try_enter 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"熔断后端 try_enter 失败: {exc}", scope=self._scope
) from exc
return self._decision(source_name, result) return self._decision(source_name, result)
async def record_success( async def record_success(
@@ -385,7 +379,9 @@ class RedisGate:
try: try:
result = await self._success_lua(keys=[self._key(entry.source_name)], args=args) result = await self._success_lua(keys=[self._key(entry.source_name)], args=args)
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 record_success 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"熔断后端 record_success 失败: {exc}", scope=self._scope
) from exc
return self._update(result) return self._update(result)
async def record_failure( async def record_failure(
@@ -407,7 +403,9 @@ class RedisGate:
try: try:
result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args) result = await self._failure_lua(keys=[self._key(entry.source_name)], args=args)
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 record_failure 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"熔断后端 record_failure 失败: {exc}", scope=self._scope
) from exc
return self._update(result) return self._update(result)
async def release_probe(self, entry: GateDecision) -> GateUpdate: async def release_probe(self, entry: GateDecision) -> GateUpdate:
@@ -419,7 +417,9 @@ class RedisGate:
keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner] keys=[self._key(entry.source_name)], args=[entry.epoch, entry.probe_owner]
) )
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 release_probe 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"熔断后端 release_probe 失败: {exc}", scope=self._scope
) from exc
return self._update(result) return self._update(result)
async def retry_after_s(self, sources: tuple[str, ...]) -> float: async def retry_after_s(self, sources: tuple[str, ...]) -> float:
@@ -429,7 +429,9 @@ class RedisGate:
try: try:
result = await self._retry_after_lua(keys=[self._key(s) for s in sources]) result = await self._retry_after_lua(keys=[self._key(s) for s in sources])
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"熔断后端 retry_after_s 失败: {exc}", scope=self._scope
) from exc
return int(result) / 1000.0 return int(result) / 1000.0
async def aclose(self) -> None: async def aclose(self) -> None:
+15 -5
View File
@@ -247,7 +247,9 @@ class RedisLimiter:
], ],
) )
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"限流后端 try_acquire 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"限流后端 try_acquire 失败: {exc}", scope=self._scope
) from exc
if ok != 1: if ok != 1:
return None return None
return _RedisPermit(self, source_key, lease_id, est_tokens, window) return _RedisPermit(self, source_key, lease_id, est_tokens, window)
@@ -265,7 +267,9 @@ class RedisLimiter:
try: try:
await self._release_lua(keys=[gl, sl], args=[lease_id]) await self._release_lua(keys=[gl, sl], args=[lease_id])
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"限流后端 release 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"限流后端 release 失败: {exc}", scope=self._scope
) from exc
async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None: async def _settle_tpm(self, source_key: str, delta: int, window: int) -> None:
wk = self._window_keys(source_key, window) wk = self._window_keys(source_key, window)
@@ -283,7 +287,9 @@ class RedisLimiter:
wk = self._window_keys(source_key, window) wk = self._window_keys(source_key, window)
res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]]) res = await self._stats_lua(keys=[sl, wk["s_rpm"], wk["s_tpm"]])
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"限流后端 source_stats 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"限流后端 source_stats 失败: {exc}", scope=self._scope
) from exc
return SourceStats( return SourceStats(
inflight=int(res[0]), inflight=int(res[0]),
rpm_used=max(0, int(res[1])), rpm_used=max(0, int(res[1])),
@@ -295,14 +301,18 @@ class RedisLimiter:
try: try:
await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S]) await self._progress_mark_lua(keys=[self._progress_key()], args=[_PROGRESS_TTL_S])
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"限流后端 mark_progress 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"限流后端 mark_progress 失败: {exc}", scope=self._scope
) from exc
async def progress_age_s(self) -> float: async def progress_age_s(self) -> float:
"""距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。""" """距上次全局成功的秒数;仅键缺失(-1)= 从未进展 → inf(CHS limiter.py:208)。"""
try: try:
res = await self._progress_age_lua(keys=[self._progress_key()]) res = await self._progress_age_lua(keys=[self._progress_key()])
except RedisError as exc: except RedisError as exc:
raise GovernanceBackendError(f"限流后端 progress_age_s 失败: {exc}", scope=self._scope) from exc raise GovernanceBackendError(
f"限流后端 progress_age_s 失败: {exc}", scope=self._scope
) from exc
return float("inf") if int(res) == -1 else int(res) / 1000.0 return float("inf") if int(res) == -1 else int(res) / 1000.0
async def aclose(self) -> None: async def aclose(self) -> None:
+16 -3
View File
@@ -134,8 +134,10 @@ class GatewayClient:
retry: RetryPolicy, retry: RetryPolicy,
backpressure: BackpressurePolicy, backpressure: BackpressurePolicy,
quota_full: str = "wait", quota_full: str = "wait",
circuit_open: str = "fail_fast",
telemetry: TelemetryRecorder | None = None, telemetry: TelemetryRecorder | None = None,
pricing: PricingTable | None = None, pricing: PricingTable | None = None,
text_cap: int | None = None,
cache: CacheBackend | None = None, cache: CacheBackend | None = None,
cache_namespace: str | None = None, cache_namespace: str | None = None,
cache_ttl_s: int | None = None, cache_ttl_s: int | None = None,
@@ -146,7 +148,11 @@ class GatewayClient:
sleep: Any = asyncio.sleep, sleep: Any = asyncio.sleep,
rng: Any = random.random, rng: Any = random.random,
) -> None: ) -> None:
emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry is not None else None emitter = (
TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap)
if telemetry is not None
else None
)
terminal = RetryMW( terminal = RetryMW(
scope=scope, scope=scope,
sources=sources, sources=sources,
@@ -157,6 +163,7 @@ class GatewayClient:
retry=retry, retry=retry,
backpressure=backpressure, backpressure=backpressure,
quota_full=quota_full, quota_full=quota_full,
circuit_open=circuit_open,
cooldown_memo=SourceCooldownMemo(now=now), cooldown_memo=SourceCooldownMemo(now=now),
# AIMD ceiling 尊重源级静态并发上限(独立核验 I1: 不得静默钳制大于 64 的配置) # AIMD ceiling 尊重源级静态并发上限(独立核验 I1: 不得静默钳制大于 64 的配置)
pacer=AdaptivePacer( pacer=AdaptivePacer(
@@ -308,10 +315,12 @@ class GatewayClient:
retry=settings.retry, retry=settings.retry,
backpressure=settings.backpressure, backpressure=settings.backpressure,
quota_full=settings.quota_full, quota_full=settings.quota_full,
circuit_open=settings.circuit_open,
telemetry=telemetry if telemetry is not None else _build_telemetry(settings), telemetry=telemetry if telemetry is not None else _build_telemetry(settings),
pricing=PricingTable.from_file(settings.pricing_path) pricing=PricingTable.from_file(settings.pricing_path)
if settings.pricing_path is not None if settings.pricing_path is not None
else None, else None,
text_cap=settings.telemetry_text_cap,
cache=cache if cache is not None else _build_cache(settings), cache=cache if cache is not None else _build_cache(settings),
cache_namespace=settings.cache_namespace, cache_namespace=settings.cache_namespace,
cache_ttl_s=settings.cache_ttl_s, cache_ttl_s=settings.cache_ttl_s,
@@ -400,11 +409,15 @@ def _build_telemetry(settings: GatewaySettings) -> TelemetryRecorder | None:
from polygateway.telemetry.postgres import PostgresRecorder from polygateway.telemetry.postgres import PostgresRecorder
assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证 assert settings.telemetry_pg_dsn is not None # 内部不变量: _validate_telemetry 已保证
return PostgresRecorder(settings.telemetry_pg_dsn) return PostgresRecorder(
settings.telemetry_pg_dsn, auto_migrate=settings.telemetry_auto_migrate
)
from polygateway.telemetry.sqlite import SQLiteRecorder from polygateway.telemetry.sqlite import SQLiteRecorder
assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证 assert settings.telemetry_sqlite_path is not None # 内部不变量: _validate_telemetry 已保证
return SQLiteRecorder(settings.telemetry_sqlite_path) return SQLiteRecorder(
settings.telemetry_sqlite_path, auto_migrate=settings.telemetry_auto_migrate
)
def _build_structured( def _build_structured(
+92
View File
@@ -50,11 +50,19 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"}) _RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"}) _SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
_QUOTA_FULL = frozenset({"wait", "fail_fast"}) _QUOTA_FULL = frozenset({"wait", "fail_fast"})
# 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是
# "排队等自己的份额"(必然轮到),熔断开路是"等源恢复"(未必恢复),故分列两键
_CIRCUIT_OPEN = frozenset({"wait", "fail_fast"})
# 后端合法域: env 解析与构造期校验共用一份定义,避免两处分叉 # 后端合法域: env 解析与构造期校验共用一份定义,避免两处分叉
_LIMITER_BACKENDS = frozenset({"memory", "redis"}) _LIMITER_BACKENDS = frozenset({"memory", "redis"})
_BREAKER_BACKENDS = frozenset({"memory", "redis"}) _BREAKER_BACKENDS = frozenset({"memory", "redis"})
_CACHE_BACKENDS = frozenset({"redis", "memory", "none"}) _CACHE_BACKENDS = frozenset({"redis", "memory", "none"})
_TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"}) _TELEMETRY_BACKENDS = frozenset({"sqlite", "postgres", "none"})
# 遥测 schema 档位(issue #13): auto 允许 recorder 给旧表 ALTER 补列,manual 不发 DDL
_SCHEMA_MODES = frozenset({"auto", "manual"})
_SCHEMA_MODE_KEY = "PGW_TELEMETRY_SCHEMA_MODE"
# 遥测正文字符上限(issue #12);二态键,未设 = 不截断
_TEXT_CAP_KEY = "PGW_TELEMETRY_TEXT_CAP"
_REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend") _REDIS_DEPENDENT_BACKENDS = ("limiter_backend", "breaker_backend", "cache_backend")
# 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源) # 背压默认(M1 仅 poll 生效;CHS _BACKOFF_S=0.05 同源)
_DEFAULT_STALL_WINDOW_S = 300.0 _DEFAULT_STALL_WINDOW_S = 300.0
@@ -123,6 +131,9 @@ class GatewaySettings:
backpressure: BackpressurePolicy backpressure: BackpressurePolicy
selector: str selector: str
quota_full: str quota_full: str
# 熔断全拒时是当场判死还是等冷却过去(issue #14);缺省 fail_fast 保持
# 存量下游的控制流不变,单源 scope 应显式配 wait
circuit_open: str
limiter_backend: str limiter_backend: str
breaker_backend: str breaker_backend: str
cache_backend: str cache_backend: str
@@ -131,6 +142,16 @@ class GatewaySettings:
telemetry_backend: str telemetry_backend: str
telemetry_sqlite_path: str | None telemetry_sqlite_path: str | None
telemetry_pg_dsn: str | None telemetry_pg_dsn: str | None
# 是否允许 recorder 给已存在的旧表自动 ALTER 补列(issue #13);env 的三态
# 派生只写在 `_load_schema_mode` 一处,不与 recorder 的类签名漂移。
# backend=none 时恒 False 这条跨字段不变量则由 `_validate_telemetry`
# 把关,对直接构造与 `dataclasses.replace` 同样生效
telemetry_auto_migrate: bool
# 遥测落库正文的字符上限(issue #12);None = 不截断,与本字段出现之前逐字节相同。
# 缺省不截断是人类决策: 截断后的遥测不再是审计证据、也无法用于复现与重放,而
# 既有下游正依赖这一行为。值域(> 0)由 `_validate_telemetry` 把关,直接构造、
# `dataclasses.replace` 与 env 三条路一并覆盖
telemetry_text_cap: int | None
redis_url: str | None redis_url: str | None
pricing_path: str | None pricing_path: str | None
structured_max_retries: int structured_max_retries: int
@@ -188,6 +209,7 @@ class GatewaySettings:
("telemetry_backend", _TELEMETRY_BACKENDS), ("telemetry_backend", _TELEMETRY_BACKENDS),
("selector", _SELECTORS), ("selector", _SELECTORS),
("quota_full", _QUOTA_FULL), ("quota_full", _QUOTA_FULL),
("circuit_open", _CIRCUIT_OPEN),
): ):
value = getattr(self, field) value = getattr(self, field)
if value not in allowed: if value not in allowed:
@@ -211,7 +233,23 @@ class GatewaySettings:
剥而不是拒: 两条装配路对同一 DSN 应产出同一结果。但不静默——`from_env` 剥而不是拒: 两条装配路对同一 DSN 应产出同一结果。但不静默——`from_env`
那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方, 那条路在 `_load_pg_dsn` 就剥干净了,能走到这里的只有手工构造的调用方,
他有权知道库动了他给的值。 他有权知道库动了他给的值。
`telemetry_auto_migrate` 同理归一化而非报错: backend=none 时根本没有
recorder 消费它,True 是个自相矛盾却无害的状态。`from_env` 那条路的派生
已经给出 False,归一化是为了直接构造与 `dataclasses.replace` 也一致——
不变量挂在构造期,才不用每加一个装配工厂就多一处要同步。
`telemetry_text_cap` 的值域则是**报错**而非归一化: 0 与负数都不是"不截断"
的写法(不截断写 None),把它们悄悄改成 None 等于用默认值掩盖调用方的错误。
报错文本同时点出字段名与 env 键名,两条装配路的调用方各看得懂自己那套。
""" """
if self.telemetry_text_cap is not None and self.telemetry_text_cap <= 0:
raise ValueError(
f"telemetry_text_cap({_TEXT_CAP_KEY})必须 > 0: {self.telemetry_text_cap};"
"不截断请不设该键(None),0 只会让每条正文退化成一个省略标记"
)
if self.telemetry_backend == "none" and self.telemetry_auto_migrate:
object.__setattr__(self, "telemetry_auto_migrate", False)
if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path: if self.telemetry_backend == "sqlite" and not self.telemetry_sqlite_path:
raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path") raise ValueError("telemetry_backend=sqlite 时必须提供 telemetry_sqlite_path")
if self.telemetry_backend != "postgres": if self.telemetry_backend != "postgres":
@@ -289,6 +327,7 @@ class GatewaySettings:
backpressure=_load_backpressure(scope_u, env), backpressure=_load_backpressure(scope_u, env),
selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"), selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"),
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"), quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
**_load_pgw(env), **_load_pgw(env),
) )
@@ -434,6 +473,7 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
redis_url = env.get("REDIS_URL") or None redis_url = env.get("REDIS_URL") or None
if "redis" in (limiter_backend, breaker_backend) and redis_url is None: if "redis" in (limiter_backend, breaker_backend) and redis_url is None:
raise ValueError("缺关键配置: 限流/熔断后端取 redis 需设置 REDIS_URL") raise ValueError("缺关键配置: 限流/熔断后端取 redis 需设置 REDIS_URL")
auto_migrate = _load_schema_mode(env, telemetry_backend)
return { return {
"limiter_backend": limiter_backend, "limiter_backend": limiter_backend,
"breaker_backend": breaker_backend, "breaker_backend": breaker_backend,
@@ -444,6 +484,8 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
if telemetry_backend == "sqlite" if telemetry_backend == "sqlite"
else None, else None,
"telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None, "telemetry_pg_dsn": _load_pg_dsn(env) if telemetry_backend == "postgres" else None,
"telemetry_auto_migrate": auto_migrate,
"telemetry_text_cap": _load_text_cap(env),
"redis_url": redis_url, "redis_url": redis_url,
"pricing_path": env.get("PGW_PRICING_PATH") or None, "pricing_path": env.get("PGW_PRICING_PATH") or None,
"structured_max_retries": _load_structured_retries(env), "structured_max_retries": _load_structured_retries(env),
@@ -451,6 +493,56 @@ def _load_pgw(env: Mapping[str, str]) -> dict[str, object]:
} }
def _load_schema_mode(env: Mapping[str, str], telemetry_backend: str) -> bool:
"""把 `PGW_TELEMETRY_SCHEMA_MODE` 的三态解成 `telemetry_auto_migrate`(issue #13)。
三态: 键未设 → 按后端**不对称**派生;显式 auto/manual → 两侧都可覆盖。
不对称的理由是两个后端的风险量级不同: SQLite 是下游自己的本地文件(没有
DBA、没有迁移工具、没有第二个系统碰它),ALTER 是毫秒级元数据操作,要求
手工跑 SQL 是给零运维场景强加运维步骤;PG 是共享的生产表,ALTER 取
ACCESS EXCLUSIVE 锁会排在长事务后阻塞该表其后的所有查询,而遥测是业务
路径上的内联 await。
`_load_choice` 带 default,不能直接用来读这个键——default 会把"未设"
"设成默认值"抹平成同一种,三态就塌回两态,后端派生也就再没机会生效。故
先用 `_first` 探"设没设",确认设了才交给 `_load_choice` 做值域校验(错误
信息点出 env 键名这件事仍由它负责)。
Args:
env: 已合并的环境映射。
telemetry_backend: 已校验过值域的遥测后端名。
Returns:
recorder 是否获准给旧表自动 ALTER 补列;backend=none 时无人消费,
构造期守卫会再把它归一化为 False。
"""
if _first(env, _SCHEMA_MODE_KEY) is None:
return telemetry_backend == "sqlite"
return _load_choice(env, _SCHEMA_MODE_KEY, _SCHEMA_MODES, "auto") == "auto"
def _load_text_cap(env: Mapping[str, str]) -> int | None:
"""读 `PGW_TELEMETRY_TEXT_CAP`(issue #12);键未设即 None = 不截断。
与相邻的 `PGW_TELEMETRY_SCHEMA_MODE` 不同,这个键是**二态**而非三态:
"未设"本身就是最终答案(不截断),没有需要按后端派生的第二种缺省,故不必像
那边一样先探"设没设"再分两条路取值,读到什么解什么即可。
值域(> 0)刻意不在此处判: 构造期守卫那道同时覆盖直接构造与
`dataclasses.replace`,而报错文本已点出本键名,env 路的调用方不会看丢。
Args:
env: 已合并的环境映射。
Returns:
遥测正文的字符上限;键未设或为空串时返回 None(不截断)。
"""
found = _first(env, _TEXT_CAP_KEY)
if found is None:
return None
return int(_cast(found[1], "int", found[0]))
def _strip_dsn_driver(dsn: str) -> str: def _strip_dsn_driver(dsn: str) -> str:
"""剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。""" """剥 SQLAlchemy 风格的 `+driver` 后缀(asyncpg 不认);已干净的原样返回。"""
scheme, sep, rest = dsn.partition("://") scheme, sep, rest = dsn.partition("://")
+27 -79
View File
@@ -28,7 +28,6 @@ from loguru import logger
from polygateway.config import EmbeddingSettings from polygateway.config import EmbeddingSettings
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
CircuitOpenError,
GovernanceBackendError, GovernanceBackendError,
PolyGatewayError, PolyGatewayError,
RequestRejectedError, RequestRejectedError,
@@ -37,11 +36,11 @@ from polygateway.errors import (
SourceNotConfiguredError, SourceNotConfiguredError,
TransientError, TransientError,
) )
from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.sources import SourceCooldownMemo
from polygateway.types import ( from polygateway.types import (
ChatRequest, ChatRequest,
EmbeddingResponse, EmbeddingResponse,
@@ -102,8 +101,10 @@ class EmbeddingClient:
retry: RetryPolicy, retry: RetryPolicy,
backpressure: BackpressurePolicy, backpressure: BackpressurePolicy,
quota_full: str = "wait", quota_full: str = "wait",
circuit_open: str = "fail_fast",
telemetry: TelemetryRecorder | None = None, telemetry: TelemetryRecorder | None = None,
pricing: PricingTable | None = None, pricing: PricingTable | None = None,
text_cap: int | None = None,
batch_size: int, batch_size: int,
normalize: bool = False, normalize: bool = False,
expected_dim: int | None = None, expected_dim: int | None = None,
@@ -113,31 +114,41 @@ class EmbeddingClient:
) -> None: ) -> None:
if batch_size < 1: if batch_size < 1:
raise ValueError("batch_size 必须 ≥ 1") raise ValueError("batch_size 必须 ≥ 1")
if quota_full not in ("wait", "fail_fast"):
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
if expected_dim is not None and expected_dim < 1: if expected_dim is not None and expected_dim < 1:
raise ValueError("expected_dim 必须 ≥ 1") raise ValueError("expected_dim 必须 ≥ 1")
self._scope = scope self._scope = scope
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离, # embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G) # 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
self._sources = strip_unsupported_extra_body(list(sources), path="embedding") self._sources = strip_unsupported_extra_body(list(sources), path="embedding")
self._selector = selector
self._quota = QuotaGate(limiter, scope=self._scope) self._quota = QuotaGate(limiter, scope=self._scope)
self._breaker = BreakerGate(breaker, scope=self._scope) self._breaker = BreakerGate(breaker, scope=self._scope)
self._transport = transport self._transport = transport
self._retry = retry self._retry = retry
self._bp = backpressure self._emitter = (
self._quota_full = quota_full TelemetryEmitter(telemetry, pricing=pricing, text_cap=text_cap) if telemetry else None
self._emitter = TelemetryEmitter(telemetry, pricing=pricing) if telemetry else None )
self._telemetry = telemetry self._telemetry = telemetry
self._pricing = pricing self._pricing = pricing
self._batch_size = batch_size self._batch_size = batch_size
self._normalize = normalize self._normalize = normalize
self._expected_dim = expected_dim self._expected_dim = expected_dim
self._memo = SourceCooldownMemo(now=now)
self._now = now self._now = now
self._sleep = sleep self._sleep = sleep
self._rng = rng self._rng = rng
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
self._admission = SourceAdmission(
scope=self._scope,
sources=self._sources,
selector=selector,
quota=self._quota,
breaker=self._breaker,
backpressure=backpressure,
quota_full=quota_full,
circuit_open=circuit_open,
now=now,
sleep=sleep,
rng=rng,
)
self._closed = False self._closed = False
async def embed( async def embed(
@@ -204,9 +215,9 @@ class EmbeddingClient:
# 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算 # 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算
clock = StallClock(self._now) clock = StallClock(self._now)
while True: while True:
picked, gate_rejections = await self._pick_runnable(reasons) picked, gate_rejections = await self._admission.pick(reasons, {})
if picked is None: if picked is None:
await self._on_no_runnable(gate_rejections, reasons, clock) await self._admission.on_no_runnable(gate_rejections, reasons, clock)
continue continue
async with clock.attempting(): async with clock.attempting():
outcome = await self._attempt( outcome = await self._attempt(
@@ -225,62 +236,6 @@ class EmbeddingClient:
if not outcome.immediate: if not outcome.immediate:
await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng)) await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng))
async def _pick_runnable(
self, reasons: dict[str, str]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0
for cand in self._selector.order(self._sources, stats):
if self._memo.active(cand.name):
gate_rejections += 1
reasons[cand.name] = "cooldown"
continue
permit = await self._quota.try_acquire(cand)
if permit is None:
reasons.setdefault(cand.name, "rate_limited")
continue
entry = None
try:
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
finally:
if entry is None:
await self._settle_and_release(permit, 0)
if entry.allowed:
return (cand, permit, entry), gate_rejections
gate_rejections += 1
reasons[cand.name] = "circuit_open"
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
await self._settle_and_release(permit, 0)
return None, gate_rejections
async def _on_no_runnable(
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
) -> None:
if gate_rejections == len(self._sources):
names = tuple(s.name for s in self._sources)
raise CircuitOpenError(
scope=self._scope,
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
if self._quota_full == "fail_fast":
raise AllSourcesExhausted(
scope=self._scope,
reason="quota_exhausted",
retry_after_s=self._bp.poll_interval_s,
per_source_reasons=reasons,
)
stall = self._bp.stall_window_s
if clock.stalled_s() > stall and await self._quota.progress_age_s() > stall:
names = tuple(s.name for s in self._sources)
raise AllSourcesExhausted(
scope=self._scope,
reason="stalled",
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
async def _attempt( async def _attempt(
self, self,
batch: list[str], batch: list[str],
@@ -374,7 +329,7 @@ class EmbeddingClient:
) )
return _FailedBatch(exc, immediate=dead) return _FailedBatch(exc, immediate=dead)
finally: finally:
await self._settle_and_release(permit, actual) await settle_and_release(permit, actual)
# —— 辅助 —— # —— 辅助 ——
@@ -396,17 +351,6 @@ class EmbeddingClient:
except (GovernanceBackendError, SourceNotConfiguredError) as exc: except (GovernanceBackendError, SourceNotConfiguredError) as exc:
logger.warning("embedding 治理记账写回降级(不冒泡): {}", exc) logger.warning("embedding 治理记账写回降级(不冒泡): {}", exc)
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
try:
try:
await permit.settle(actual)
finally:
await permit.release()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("embedding permit 结算/释放失败(不掩盖主异常): {}", exc)
async def _emit( async def _emit(
self, self,
batch: list[str], batch: list[str],
@@ -556,10 +500,14 @@ class EmbeddingClient:
retry=gw.retry, retry=gw.retry,
backpressure=gw.backpressure, backpressure=gw.backpressure,
quota_full=gw.quota_full, quota_full=gw.quota_full,
circuit_open=gw.circuit_open,
telemetry=telemetry if telemetry is not None else _build_telemetry(gw), telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
pricing=PricingTable.from_file(gw.pricing_path) pricing=PricingTable.from_file(gw.pricing_path)
if gw.pricing_path is not None if gw.pricing_path is not None
else None, else None,
# embed 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
# 一半不受控(issue #12)
text_cap=gw.telemetry_text_cap,
batch_size=settings.batch_size, batch_size=settings.batch_size,
normalize=settings.normalize, normalize=settings.normalize,
expected_dim=settings.expected_dim, expected_dim=settings.expected_dim,
+13 -2
View File
@@ -116,9 +116,20 @@ class ResultInvalidError(PolyGatewayError):
class GatewayUnavailableError(PolyGatewayError): class GatewayUnavailableError(PolyGatewayError):
"""scope 级暂时不可用;业务侧 catch 本类做延期重投(CHS arq 模式) """scope 级暂时不可用: 库的**调用级**预算已经耗尽
`retry_after_s` 非可选(0 = 可立即重试),承 CHS ProviderUnavailableError。 **职责边界(issue #14)**: 调用级的重试、退避、换源、等待冷却全部在库内,
不需要下游再写一层——两边各写一份必然漂移(库调了退避曲线而下游不知道,
下游改了等待上限而库的遥测算不进去),漂移之后"这次调用到底等了多久、
试了几次"就没有单一事实源答得出来。本异常表示那份预算(重试预算或 stall
预算)已经用完。下游据此再投是**任务级重试**,与调用级重试语义不同,由
业务自行在库外包(ARCH §7.2 单层重试原则)。
熔断开路时是当场抛本类还是先等冷却过去,由 `{SCOPE}__CIRCUIT_OPEN`
决定(缺省 fail_fast;单源 scope 建议配 wait)。
`retry_after_s` 非可选,语义是"距离**确定**可再试的时刻还有多久";
`0` 表示不存在确定的等待时刻(可立即重试),承 CHS ProviderUnavailableError。
""" """
def __init__( def __init__(
+287
View File
@@ -0,0 +1,287 @@
"""SourceAdmission: 一次尝试的准入编排,三条治理循环(chat/embedding/ocr)共用一份。
**收敛缘由(issue #14)**: 本模块的两个方法此前在 `middleware/retry.py`、
`embedding.py`、`ocr.py` 各存一份逐字复制(后两份是第一份的子集)。准入语义
一直在演进——issue #8 改过 stall 口径、M2.5 加过 AIMD pacer、issue #14 要加
熔断等待档——每演进一次就要三处同步,漏一处即行为分叉。三份复制正是库铁律
痛斥的那种模式(遥测"三项目 4 处复制"的教训),只不过这次发生在库内部。
**职责边界**: 只管"挑出一个可跑的源""一个都挑不出来时怎么办";一次尝试
本身(transport 调用、记账写回、逐次遥测)仍归各循环的 `_attempt`。
**共享而非持有**: `QuotaGate`/`BreakerGate`/`AdaptivePacer`/`SourceSelector` 由
调用方构造后传入**同一实例**——三处 `_attempt` 仍要用它们做记账写回与
`pacer.leave()`。pacer 尤其不能各建一个: 它有在途计数,分裂成两个计数器会让
`admit`/`enter` 与 `leave` 记到不同账上。`SourceCooldownMemo` 只被准入消费,
由本类独占。
"""
from __future__ import annotations
import asyncio
import random
import time
import uuid
from typing import TYPE_CHECKING
from loguru import logger
from polygateway.errors import AllSourcesExhausted, CircuitOpenError
from polygateway.sources import SourceCooldownMemo
# 两个准入策略键共用的值域;校验只此一处,不在各客户端重复
_POLICIES = frozenset({"wait", "fail_fast"})
if TYPE_CHECKING:
from collections.abc import Callable
from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock
from polygateway.ports import GateDecision, Permit, SourceSelector
from polygateway.sources import AdaptivePacer
from polygateway.types import BackpressurePolicy, SourceConfig
async def settle_and_release(permit: Permit, actual: int) -> None:
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。
三条循环的 `_attempt` 与本模块的准入拒绝路径共用这一份(此前三处逐字复制,
仅 warning 文案不同)。
"""
try:
try:
await permit.settle(actual)
finally:
await permit.release()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("permit 结算/释放失败(不掩盖主异常): {}", exc)
def _demote_call_failures(
ordered: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float] | None,
) -> list[SourceConfig]:
"""调用内降权(设计 §3.3/§3.36): 失败 ≥2 次且存在可信替代才让位。
可信替代 = 某未失败候选 health ≥ 0.5 × 失败源 health——异构池里健康源
偶发失败不该被推向已知坏源(第三轮教训: 期望成功率 83% vs 10%)。
无健康视图(round_robin 等)保持无条件降权(冷启动保护)。
`attempt_fails` 为空时恒等返回原列表对象——embedding/ocr 不维护调用内
失败计数,故对它们这一步是零成本的空操作,无需在调用侧加分支。
"""
demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2]
if not demoted or len(demoted) == len(ordered):
return ordered
if health is None:
return _move_to_tail(ordered, demoted)
return _health_gated_reorder(ordered, demoted, attempt_fails, health)
def _move_to_tail(ordered: list[SourceConfig], demoted: list[SourceConfig]) -> list[SourceConfig]:
"""无健康视图: 无条件移尾(冷启动保护原语义)。"""
names = {d.name for d in demoted}
return [s for s in ordered if s.name not in names] + demoted
def _health_gated_reorder(
ordered: list[SourceConfig],
demoted: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""健康门槛降权: 无可信替代则原地重试;有则插到可信替代之后。"""
demoted = _credible_demotions(ordered, demoted, attempt_fails, health)
if not demoted:
return ordered
names = {d.name for d in demoted}
rest = [s for s in ordered if s.name not in names]
return _insert_after_credible(rest, demoted, health)
def _insert_after_credible(
rest: list[SourceConfig],
demoted: list[SourceConfig],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""插入位置(第四轮教训): 被降权源排在可信替代之后、不可信源之前——
可信替代被限流闸/熔断跳过时,下一候选是失败源本身而非垃圾源。"""
bar = 0.5 * max(health(d.name) for d in demoted)
credible = [s for s in rest if health(s.name) >= bar]
junk = [s for s in rest if health(s.name) < bar]
return credible + demoted + junk
def _credible_demotions(
ordered: list[SourceConfig],
demoted: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""健康门槛过滤: 仅当存在"健康分 ≥ 失败源一半"的未失败候选,让位才有意义。"""
alts = [o for o in ordered if attempt_fails.get(o.name, 0) < 2]
return [s for s in demoted if any(health(o.name) >= 0.5 * health(s.name) for o in alts)]
class SourceAdmission:
"""准入编排器(CHS `governance.py:107-285` 同款);时钟/睡眠/随机全部注入。"""
def __init__(
self,
*,
scope: str,
sources: list[SourceConfig],
selector: SourceSelector,
quota: QuotaGate,
breaker: BreakerGate,
backpressure: BackpressurePolicy,
quota_full: str,
circuit_open: str,
memo: SourceCooldownMemo | None = None,
pacer: AdaptivePacer | None = None,
health_view: Callable[[str], float] | None = None,
now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], object] = asyncio.sleep,
rng: Callable[[], float] = random.random,
) -> None:
for name, value in (("quota_full", quota_full), ("circuit_open", circuit_open)):
if value not in _POLICIES:
raise ValueError(f"{name} 必须是 wait|fail_fast: {value!r}")
self._scope = scope
self._sources = sources
self._selector = selector
self._quota = quota
self._breaker = breaker
self._bp = backpressure
self._quota_full = quota_full
self._circuit_open = circuit_open
self._memo = memo or SourceCooldownMemo(now=now)
self._pacer = pacer
self._health_view = health_view
self._now = now
self._sleep = sleep
self._rng = rng
# —— 选源与准入(CHS _pick_runnable 120-167)——
async def pick(
self, reasons: dict[str, str], attempt_fails: dict[str, int]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
"""挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。"""
stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0
ordered = _demote_call_failures(
self._selector.order(self._sources, stats), attempt_fails, self._health_view
)
for cand in ordered:
if self._memo.active(cand.name):
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
gate_rejections += 1
reasons[cand.name] = "cooldown"
continue
if self._pacer is not None and not self._pacer.admit(cand.name):
# AIMD 超限: 不计 gate_rejections → 走 quota-wait 排队,不误判熔断
reasons.setdefault(cand.name, "adaptive_paced")
continue
permit = await self._quota.try_acquire(cand)
if permit is None:
reasons.setdefault(cand.name, "rate_limited")
continue
entry = None
try:
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
finally:
# try_enter 未归还 entry(异常/取消)→ 释放已占 permit,不吞任何异常
if entry is None:
await settle_and_release(permit, 0)
if entry.allowed:
if self._pacer is not None:
self._pacer.enter(cand.name)
return (cand, permit, entry), gate_rejections
gate_rejections += 1
reasons[cand.name] = "circuit_open"
# 开路源本地记冷却,避免每轮白烧 RPM 探测(CHS governance.py:107)
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
await settle_and_release(permit, 0)
return None, gate_rejections
# —— 背压与 stall 判死(CHS governance.py:270-285)——
async def stalled(self, clock: StallClock) -> bool:
"""双条件 stall 判死(CHS governance.py:270-281): 本地累计等待与全局
无进展**同时**超窗才判死——本地 monotonic 与后端时钟刻意不混用。
本地一侧只计非生产性等待(issue #8,见 `StallClock`)。短路顺序有意为之:
本地未超窗就不问后端,省一次 Redis 往返。
"""
stall = self._bp.stall_window_s
return clock.stalled_s() > stall and await self._quota.progress_age_s() > stall
async def on_no_runnable(
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
) -> None:
"""一个源都挑不出来时的处置: **按拒绝原因分派**到各自的策略。
分派而非串行是硬要求(issue #14): 串行写法下 `circuit_open=wait` 不抛
之后会径直掉进配额分支,`quota_full=fail_fast` 的调用方于是收到一个
`reason=quota_exhausted` 的异常——而配额其实是满的,坏的是熔断门。
"""
names = tuple(s.name for s in self._sources)
if gate_rejections == len(self._sources):
# 全部因熔断类原因(门开路 / 本地冷却备忘)被拒
if self._circuit_open == "fail_fast":
raise CircuitOpenError(
scope=self._scope,
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
# wait: 保护作用完整保留(这一轮照样一个请求都不发),改变的只是
# 调用方当场死还是排队等——多源可换源故 fail-fast 对,单源无源可换
hint = await self._breaker.retry_after_s(names)
else:
# 至少一个源是被配额/AIMD 挡的,归 quota_full 管
if self._quota_full == "fail_fast":
raise AllSourcesExhausted(
scope=self._scope,
reason="quota_exhausted",
retry_after_s=self._bp.poll_interval_s,
per_source_reasons=reasons,
)
hint = 0.0
if await self.stalled(clock):
raise AllSourcesExhausted(
scope=self._scope,
reason="stalled",
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
nap = self._nap(hint, clock)
if hint > 0:
logger.info("熔断开路等待 {:.1f}s 后重试(scope={}, 原因={})", nap, self._scope, reasons)
await self._sleep(nap)
def _nap(self, hint: float, clock: StallClock) -> float:
"""本轮等待多久。**必须在 `stalled()` 判定之后调用**(预算可能已耗尽)。
`hint > 0`(熔断开路有确定的冷却截止)时睡到那个时刻,而不是按
`poll_interval` 空转——60 秒冷却用 10ms 轮询是 6000 次空转,内存后端
只是查字典,Redis 后端则是 6000 次往返 × 每个在途调用。抖动**上**加
而非缩放(既有 quota 路径是 `[0.5p, 1.0p]`): 对一个确定的截止时刻提前
醒来必然被再拒一次,白跑一趟。
两档都夹到剩余 stall 预算,故单次调用的最坏墙钟是 `stall_window_s`
加一个 poll 间隔,不随 `max_cooldown_s` 漂移。多加的那一格是因为
`stalled()` 判据是 `>` 而非 `>=`——恰好睡到窗口边界不判死,留这一格
让下一轮必定判死。`hint == 0` 时整个式子退化为既有的 jitter 轮询。
"""
jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
wait = hint + jitter if hint > 0 else jitter
# 下界取 jitter 而非 poll_interval: 既有 quota 轮询是 [0.5p, 1.0p],用
# poll_interval 兜底会把 rng→0 那半边抬上去。预算为负时(本地已超窗但
# 全局仍在出餐,故 stalled() 不判死)靠它退回正常轮询节奏,不忙循环。
return max(jitter, min(wait, budget))
+27 -170
View File
@@ -23,7 +23,6 @@ from loguru import logger
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
CircuitOpenError,
GovernanceBackendError, GovernanceBackendError,
PolyGatewayError, PolyGatewayError,
RequestRejectedError, RequestRejectedError,
@@ -32,10 +31,11 @@ from polygateway.errors import (
SourceNotConfiguredError, SourceNotConfiguredError,
TransientError, TransientError,
) )
from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate from polygateway.middleware.ratelimit import QuotaGate
from polygateway.ports import OutcomeAwareSelector from polygateway.ports import OutcomeAwareSelector
from polygateway.sources import AdaptivePacer, SourceCooldownMemo from polygateway.sources import AdaptivePacer
from polygateway.streaming import StreamLivenessTimeout from polygateway.streaming import StreamLivenessTimeout
from polygateway.types import LLMResponse from polygateway.types import LLMResponse
@@ -50,6 +50,7 @@ if TYPE_CHECKING:
SourceSelector, SourceSelector,
Transport, Transport,
) )
from polygateway.sources import SourceCooldownMemo
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
ChatRequest, ChatRequest,
@@ -134,70 +135,6 @@ class StallClock:
self._productive_s += self._now() - started self._productive_s += self._now() - started
def _demote_call_failures(
ordered: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float] | None,
) -> list[SourceConfig]:
"""调用内降权(设计 §3.3/§3.36): 失败 ≥2 次且存在可信替代才让位。
可信替代 = 某未失败候选 health ≥ 0.5 × 失败源 health——异构池里健康源
偶发失败不该被推向已知坏源(第三轮教训: 期望成功率 83% vs 10%)。
无健康视图(round_robin 等)保持无条件降权(冷启动保护)。
"""
demoted = [s for s in ordered if attempt_fails.get(s.name, 0) >= 2]
if not demoted or len(demoted) == len(ordered):
return ordered
if health is None:
return _move_to_tail(ordered, demoted)
return _health_gated_reorder(ordered, demoted, attempt_fails, health)
def _move_to_tail(ordered: list[SourceConfig], demoted: list[SourceConfig]) -> list[SourceConfig]:
"""无健康视图: 无条件移尾(冷启动保护原语义)。"""
names = {d.name for d in demoted}
return [s for s in ordered if s.name not in names] + demoted
def _health_gated_reorder(
ordered: list[SourceConfig],
demoted: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""健康门槛降权: 无可信替代则原地重试;有则插到可信替代之后。"""
demoted = _credible_demotions(ordered, demoted, attempt_fails, health)
if not demoted:
return ordered
names = {d.name for d in demoted}
rest = [s for s in ordered if s.name not in names]
return _insert_after_credible(rest, demoted, health)
def _insert_after_credible(
rest: list[SourceConfig],
demoted: list[SourceConfig],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""插入位置(第四轮教训): 被降权源排在可信替代之后、不可信源之前——
可信替代被限流闸/熔断跳过时,下一候选是失败源本身而非垃圾源。"""
bar = 0.5 * max(health(d.name) for d in demoted)
credible = [s for s in rest if health(s.name) >= bar]
junk = [s for s in rest if health(s.name) < bar]
return credible + demoted + junk
def _credible_demotions(
ordered: list[SourceConfig],
demoted: list[SourceConfig],
attempt_fails: dict[str, int],
health: Callable[[str], float],
) -> list[SourceConfig]:
"""健康门槛过滤: 仅当存在"健康分 ≥ 失败源一半"的未失败候选,让位才有意义。"""
alts = [o for o in ordered if attempt_fails.get(o.name, 0) < 2]
return [s for s in demoted if any(health(o.name) >= 0.5 * health(s.name) for o in alts)]
def _failure_reason(exc: PolyGatewayError) -> str: def _failure_reason(exc: PolyGatewayError) -> str:
"""失败原因归类(CHS governance.py:169 同款)。""" """失败原因归类(CHS governance.py:169 同款)。"""
if isinstance(exc, SourceDeadError): if isinstance(exc, SourceDeadError):
@@ -241,6 +178,7 @@ class RetryMW:
retry: RetryPolicy, retry: RetryPolicy,
backpressure: BackpressurePolicy, backpressure: BackpressurePolicy,
quota_full: str = "wait", quota_full: str = "wait",
circuit_open: str = "fail_fast",
cooldown_memo: SourceCooldownMemo | None = None, cooldown_memo: SourceCooldownMemo | None = None,
pacer: AdaptivePacer | None = None, pacer: AdaptivePacer | None = None,
emitter: object | None = None, emitter: object | None = None,
@@ -248,27 +186,39 @@ class RetryMW:
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random, rng: Callable[[], float] = random.random,
) -> None: ) -> None:
if quota_full not in ("wait", "fail_fast"):
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
self._scope = scope self._scope = scope
self._sources = list(sources) self._sources = list(sources)
self._selector = selector # 记账写回与 pacer 结算仍在 `_attempt` 内,故这三者由本类持有并与
# `SourceAdmission` **共享同一实例**(pacer 有在途计数,不可分裂)
self._quota = QuotaGate(limiter, scope=self._scope) self._quota = QuotaGate(limiter, scope=self._scope)
self._breaker = BreakerGate(gate, scope=self._scope) self._breaker = BreakerGate(gate, scope=self._scope)
self._transport = transport self._transport = transport
self._retry = retry self._retry = retry
self._bp = backpressure
self._quota_full = quota_full
self._memo = cooldown_memo or SourceCooldownMemo(now=now)
# M2.5: 选源器可选健康喂数端口,构造期 isinstance 判定一次(设计 §3.2) # M2.5: 选源器可选健康喂数端口,构造期 isinstance 判定一次(设计 §3.2)
self._outcome_sink = selector if isinstance(selector, OutcomeAwareSelector) else None self._outcome_sink = selector if isinstance(selector, OutcomeAwareSelector) else None
self._health_view = self._outcome_sink.health if self._outcome_sink else None
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算 # M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
self._pacer = pacer or AdaptivePacer(ceiling=64.0) self._pacer = pacer or AdaptivePacer(ceiling=64.0)
self._emitter = emitter self._emitter = emitter
self._now = now self._now = now
self._sleep = sleep self._sleep = sleep
self._rng = rng self._rng = rng
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
self._admission = SourceAdmission(
scope=self._scope,
sources=self._sources,
selector=selector,
quota=self._quota,
breaker=self._breaker,
backpressure=backpressure,
quota_full=quota_full,
circuit_open=circuit_open,
memo=cooldown_memo,
pacer=self._pacer,
health_view=self._outcome_sink.health if self._outcome_sink else None,
now=now,
sleep=sleep,
rng=rng,
)
async def __call__(self, request: ChatRequest) -> LLMResponse: async def __call__(self, request: ChatRequest) -> LLMResponse:
"""执行治理调用;scope 级失败按 §6.1 携结构化字段上抛。""" """执行治理调用;scope 级失败按 §6.1 携结构化字段上抛。"""
@@ -283,16 +233,16 @@ class RetryMW:
clock = StallClock(self._now) clock = StallClock(self._now)
while True: while True:
# 调用级时间上限(迭代 5): 429 免预算后的兜底,防饱和期无限循环 # 调用级时间上限(迭代 5): 429 免预算后的兜底,防饱和期无限循环
if await self._stalled(clock): if await self._admission.stalled(clock):
raise AllSourcesExhausted( raise AllSourcesExhausted(
scope=self._scope, scope=self._scope,
reason="stalled", reason="stalled",
retry_after_s=self._retry.backoff_base_s, retry_after_s=self._retry.backoff_base_s,
per_source_reasons=reasons, per_source_reasons=reasons,
) )
picked, gate_rejections = await self._pick_runnable(reasons, attempt_fails) picked, gate_rejections = await self._admission.pick(reasons, attempt_fails)
if picked is None: if picked is None:
await self._on_no_runnable(gate_rejections, reasons, clock) await self._admission.on_no_runnable(gate_rejections, reasons, clock)
continue continue
async with clock.attempting() as attempt: async with clock.attempting() as attempt:
outcome = await self._attempt(request, *picked, reasons, attempt_fails) outcome = await self._attempt(request, *picked, reasons, attempt_fails)
@@ -314,87 +264,6 @@ class RetryMW:
if not outcome.immediate: if not outcome.immediate:
await self._sleep(self._backoff_delay(max(fails, 1), outcome.exc)) await self._sleep(self._backoff_delay(max(fails, 1), outcome.exc))
# —— 选源与准入(CHS _pick_runnable 120-167)——
async def _pick_runnable(
self, reasons: dict[str, str], attempt_fails: dict[str, int]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0
ordered = _demote_call_failures(
self._selector.order(self._sources, stats), attempt_fails, self._health_view
)
for cand in ordered:
if self._memo.active(cand.name):
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
gate_rejections += 1
reasons[cand.name] = "cooldown"
continue
if not self._pacer.admit(cand.name):
# AIMD 超限: 不计 gate_rejections → 走 quota-wait 排队,不误判熔断
reasons.setdefault(cand.name, "adaptive_paced")
continue
permit = await self._quota.try_acquire(cand)
if permit is None:
reasons.setdefault(cand.name, "rate_limited")
continue
entry = None
try:
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
finally:
# try_enter 未归还 entry(异常/取消)→ 释放已占 permit,不吞任何异常
if entry is None:
await self._settle_and_release(permit, 0)
if entry.allowed:
self._pacer.enter(cand.name)
return (cand, permit, entry), gate_rejections
gate_rejections += 1
reasons[cand.name] = "circuit_open"
# 开路源本地记冷却,避免每轮白烧 RPM 探测(CHS governance.py:107)
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
await self._settle_and_release(permit, 0)
return None, gate_rejections
# —— 背压与 stall 判死(CHS governance.py:270-285)——
async def _stalled(self, clock: StallClock) -> bool:
"""双条件 stall 判死(CHS governance.py:270-281): 本地累计等待与全局
无进展**同时**超窗才判死——本地 monotonic 与后端时钟刻意不混用。
本地一侧只计非生产性等待(issue #8,见 `StallClock`)。短路顺序有意为之:
本地未超窗就不问后端,省一次 Redis 往返。
"""
stall = self._bp.stall_window_s
return clock.stalled_s() > stall and await self._quota.progress_age_s() > stall
async def _on_no_runnable(
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
) -> None:
if gate_rejections == len(self._sources):
names = tuple(s.name for s in self._sources)
raise CircuitOpenError(
scope=self._scope,
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
if self._quota_full == "fail_fast":
raise AllSourcesExhausted(
scope=self._scope,
reason="quota_exhausted",
retry_after_s=self._bp.poll_interval_s,
per_source_reasons=reasons,
)
if await self._stalled(clock):
names = tuple(s.name for s in self._sources)
raise AllSourcesExhausted(
scope=self._scope,
reason="stalled",
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
# jitter ∈ [0.5p, 1.0p] 防惊群(CHS governance.py:283-285)
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
# —— 单次尝试(CHS run 200-268)—— # —— 单次尝试(CHS run 200-268)——
async def _attempt( async def _attempt(
@@ -460,7 +329,7 @@ class RetryMW:
return _Failed(exc, immediate=dead) return _Failed(exc, immediate=dead)
finally: finally:
self._pacer.leave(source.name) self._pacer.leave(source.name)
await self._settle_and_release(permit, actual) await settle_and_release(permit, actual)
async def _on_rejected( async def _on_rejected(
self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision
@@ -523,18 +392,6 @@ class RetryMW:
reasoning_tokens=result.reasoning_tokens, reasoning_tokens=result.reasoning_tokens,
) )
async def _settle_and_release(self, permit: Permit, actual: int) -> None:
"""finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""
try:
try:
await permit.settle(actual)
finally:
await permit.release()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("permit 结算/释放失败(不掩盖主异常): {}", exc)
async def _emit( async def _emit(
self, self,
request: ChatRequest, request: ChatRequest,
+63 -5
View File
@@ -55,6 +55,44 @@ def _canonical_meta_json(meta: Mapping[str, Any]) -> str:
return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False) return json.dumps(dict(meta), sort_keys=True, ensure_ascii=False, allow_nan=False)
def _cap_text(text: str, cap: int | None) -> str:
"""超出 cap 时头部硬切并附省略标记 `…(略 N 字)`;cap 为 None 原样返回。"""
if cap is None or len(text) <= cap:
return text
return f"{text[:cap]}…(略 {len(text) - cap} 字)"
def _cap_part(part: Any, cap: int) -> Any:
"""多模态 part 的文本截断;非 `type == "text"` 的 part 原样返回同一对象。"""
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
return {**part, "text": _cap_text(part["text"], cap)}
return part
def _cap_messages(messages: list[dict[str, Any]], cap: int | None) -> list[dict[str, Any]]:
"""对每条消息的文本 content 与多模态 part 中 type == "text" 的 text 逐条施加 cap。
非字符串 content 原样放行(外部输入形状不可控,遥测路径不得因此抛错)。
**只产出新对象,严禁就地修改**: `digest_messages` 对 content 非 list 的消息是
原样透传**同一个 dict 对象**(`cache.py:43`),多模态里非 image_url 的 part 同理。
就地改它会一并污染调用方持有的 messages、后续重试尝试的请求体与缓存写入的 key,
且全程无任何报错。
"""
if cap is None:
return messages
capped: list[dict[str, Any]] = []
for msg in messages:
content = msg.get("content")
if isinstance(content, str):
capped.append({**msg, "content": _cap_text(content, cap)})
elif isinstance(content, list):
capped.append({**msg, "content": [_cap_part(part, cap) for part in content]})
else:
capped.append(msg)
return capped
@dataclass(frozen=True) @dataclass(frozen=True)
class _AttemptUsage: class _AttemptUsage:
"""一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。 """一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。
@@ -96,9 +134,25 @@ class _AttemptUsage:
class TelemetryEmitter: class TelemetryEmitter:
"""从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。""" """从请求与结果组装 24 字段并写入 recorder;一切写失败降级 warning。"""
def __init__(self, recorder: TelemetryRecorder, *, pricing: PricingTable | None = None) -> None: def __init__(
self,
recorder: TelemetryRecorder,
*,
pricing: PricingTable | None = None,
text_cap: int | None,
) -> None:
"""`text_cap` 无默认值是有意的: 它是关键行为参数,漏传即静默改变落库正文。
本类是库内部类,唯一构造者是三个公共 Client,必填能保证没有一处漏传。
同理,值域校验也放在这一处: 三个 Client 的 `text_cap` 全部汇流到这里,
`GatewaySettings` 那道只管 env 一条路,而直接构造 Client 是库承诺的另一
条公共装配路——`text_cap=0` 会让每条正文只剩一个省略标记(P5 不得静默)。
"""
if text_cap is not None and text_cap <= 0:
raise ValueError(f"text_cap 必须 > 0(不截断请传 None): {text_cap}")
self._recorder = recorder self._recorder = recorder
self._pricing = pricing self._pricing = pricing
self._text_cap = text_cap
async def emit_attempt( async def emit_attempt(
self, self,
@@ -241,8 +295,12 @@ class TelemetryEmitter:
) )
else: else:
cost = None cost = None
# messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12) # messages 落库前多模态摘要,与缓存 key 共用同一函数(VT R12);
messages_json = json.dumps(digest_messages(request.messages), ensure_ascii=False) # 截断只发生在摘要之后、序列化之前的遥测分支,缓存路径不经过它(issue #12)
messages_json = json.dumps(
_cap_messages(digest_messages(request.messages), self._text_cap),
ensure_ascii=False,
)
await self._recorder.record_llm_call( await self._recorder.record_llm_call(
call_id=call_id, call_id=call_id,
parent_call_id=request.parent_call_id, parent_call_id=request.parent_call_id,
@@ -251,8 +309,8 @@ class TelemetryEmitter:
provider=provider, provider=provider,
source_name=source_name, source_name=source_name,
messages=messages_json, messages=messages_json,
response=response_text, response=_cap_text(response_text, self._text_cap),
thinking=thinking, thinking=_cap_text(thinking, self._text_cap),
prompt_tokens=prompt_tokens, prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens, completion_tokens=completion_tokens,
usage_source=usage_source, usage_source=usage_source,
+25 -79
View File
@@ -24,7 +24,6 @@ from loguru import logger
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
CircuitOpenError,
GovernanceBackendError, GovernanceBackendError,
PolyGatewayError, PolyGatewayError,
RequestRejectedError, RequestRejectedError,
@@ -33,12 +32,12 @@ from polygateway.errors import (
SourceNotConfiguredError, SourceNotConfiguredError,
TransientError, TransientError,
) )
from polygateway.middleware.admission import SourceAdmission, settle_and_release
from polygateway.middleware.breaker import BreakerGate from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.ports import OutcomeAwareSelector from polygateway.ports import OutcomeAwareSelector
from polygateway.sources import SourceCooldownMemo
from polygateway.types import ( from polygateway.types import (
ChatRequest, ChatRequest,
LLMResponse, LLMResponse,
@@ -108,13 +107,13 @@ class OcrClient:
retry: RetryPolicy, retry: RetryPolicy,
backpressure: BackpressurePolicy, backpressure: BackpressurePolicy,
quota_full: str = "wait", quota_full: str = "wait",
circuit_open: str = "fail_fast",
telemetry: TelemetryRecorder | None = None, telemetry: TelemetryRecorder | None = None,
text_cap: int | None = None,
now: Callable[[], float] = time.monotonic, now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random, rng: Callable[[], float] = random.random,
) -> None: ) -> None:
if quota_full not in ("wait", "fail_fast"):
raise ValueError(f"quota_full 必须是 wait|fail_fast: {quota_full!r}")
self._scope = scope self._scope = scope
# MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则 # MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则
# 遥测会记录一个从未发出的采样参数(issue #4 决策 G) # 遥测会记录一个从未发出的采样参数(issue #4 决策 G)
@@ -125,14 +124,25 @@ class OcrClient:
self._breaker = BreakerGate(breaker, scope=self._scope) self._breaker = BreakerGate(breaker, scope=self._scope)
self._transport = transport self._transport = transport
self._retry = retry self._retry = retry
self._bp = backpressure self._emitter = TelemetryEmitter(telemetry, text_cap=text_cap) if telemetry else None
self._quota_full = quota_full
self._emitter = TelemetryEmitter(telemetry) if telemetry else None
self._telemetry = telemetry self._telemetry = telemetry
self._memo = SourceCooldownMemo(now=now)
self._now = now self._now = now
self._sleep = sleep self._sleep = sleep
self._rng = rng self._rng = rng
# 准入编排三条循环共用一份(issue #14);冷却备忘由它独占
self._admission = SourceAdmission(
scope=self._scope,
sources=self._sources,
selector=selector,
quota=self._quota,
breaker=self._breaker,
backpressure=backpressure,
quota_full=quota_full,
circuit_open=circuit_open,
now=now,
sleep=sleep,
rng=rng,
)
self._closed = False self._closed = False
# —— 公共端口(OcrTextPort / OcrLayoutPort)—— # —— 公共端口(OcrTextPort / OcrLayoutPort)——
@@ -233,9 +243,9 @@ class OcrClient:
# 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算 # 只计非生产性等待(issue #8): 真实尝试由重试预算治理,不重复烧 stall 预算
clock = StallClock(self._now) clock = StallClock(self._now)
while True: while True:
picked, gate_rejections = await self._pick_runnable(reasons) picked, gate_rejections = await self._admission.pick(reasons, {})
if picked is None: if picked is None:
await self._on_no_runnable(gate_rejections, reasons, clock) await self._admission.on_no_runnable(gate_rejections, reasons, clock)
continue continue
async with clock.attempting(): async with clock.attempting():
outcome = await self._attempt( outcome = await self._attempt(
@@ -254,62 +264,6 @@ class OcrClient:
if not outcome.immediate: if not outcome.immediate:
await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng)) await self._sleep(backoff_delay(self._retry, fails, outcome.exc, self._rng))
async def _pick_runnable(
self, reasons: dict[str, str]
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0
for cand in self._selector.order(self._sources, stats):
if self._memo.active(cand.name):
gate_rejections += 1
reasons[cand.name] = "cooldown"
continue
permit = await self._quota.try_acquire(cand)
if permit is None:
reasons.setdefault(cand.name, "rate_limited")
continue
entry = None
try:
entry = await self._breaker.try_enter(cand, uuid.uuid4().hex)
finally:
if entry is None:
await self._settle_and_release(permit)
if entry.allowed:
return (cand, permit, entry), gate_rejections
gate_rejections += 1
reasons[cand.name] = "circuit_open"
self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
await self._settle_and_release(permit)
return None, gate_rejections
async def _on_no_runnable(
self, gate_rejections: int, reasons: dict[str, str], clock: StallClock
) -> None:
if gate_rejections == len(self._sources):
names = tuple(s.name for s in self._sources)
raise CircuitOpenError(
scope=self._scope,
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
if self._quota_full == "fail_fast":
raise AllSourcesExhausted(
scope=self._scope,
reason="quota_exhausted",
retry_after_s=self._bp.poll_interval_s,
per_source_reasons=reasons,
)
stall = self._bp.stall_window_s
if clock.stalled_s() > stall and await self._quota.progress_age_s() > stall:
names = tuple(s.name for s in self._sources)
raise AllSourcesExhausted(
scope=self._scope,
reason="stalled",
retry_after_s=await self._breaker.retry_after_s(names),
per_source_reasons=reasons,
)
await self._sleep(self._bp.poll_interval_s * (0.5 + 0.5 * self._rng()))
async def _attempt( async def _attempt(
self, self,
kind: _OcrKind, kind: _OcrKind,
@@ -397,7 +351,7 @@ class OcrClient:
) )
return _FailedAttempt(exc, immediate=dead) return _FailedAttempt(exc, immediate=dead)
finally: finally:
await self._settle_and_release(permit) await settle_and_release(permit, 0)
async def _invoke( async def _invoke(
self, kind: _OcrKind, image: bytes, source: SourceConfig, call_id: str self, kind: _OcrKind, image: bytes, source: SourceConfig, call_id: str
@@ -434,18 +388,6 @@ class OcrClient:
except (GovernanceBackendError, SourceNotConfiguredError) as exc: except (GovernanceBackendError, SourceNotConfiguredError) as exc:
logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc) logger.warning("OCR 治理记账写回降级(不冒泡): {}", exc)
async def _settle_and_release(self, permit: Permit) -> None:
"""settle 恒 0: OCR 无 token 计费(设计 §5 差异①)。"""
try:
try:
await permit.settle(0)
finally:
await permit.release()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("OCR permit 结算/释放失败(不掩盖主异常): {}", exc)
async def _emit( async def _emit(
self, self,
kind: _OcrKind, kind: _OcrKind,
@@ -571,7 +513,11 @@ class OcrClient:
retry=gw.retry, retry=gw.retry,
backpressure=gw.backpressure, backpressure=gw.backpressure,
quota_full=gw.quota_full, quota_full=gw.quota_full,
circuit_open=gw.circuit_open,
telemetry=telemetry if telemetry is not None else _build_telemetry(gw), telemetry=telemetry if telemetry is not None else _build_telemetry(gw),
# OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
# 一半不受控(issue #12)
text_cap=gw.telemetry_text_cap,
) )
@classmethod @classmethod
+102 -105
View File
@@ -21,56 +21,17 @@ from typing import TYPE_CHECKING
from loguru import logger from loguru import logger
from polygateway.telemetry.schema import (
COLUMNS,
PG_BACKFILL,
PG_DDL,
insert_sql,
missing_columns_warning,
)
if TYPE_CHECKING: if TYPE_CHECKING:
import asyncpg import asyncpg
_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms DOUBLE PRECISION,
max_inter_token_ms DOUBLE PRECISION,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb
);
"""
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 sqlite.py 同款注释)
_BACKFILL = (
("cached_prompt_tokens", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER"),
("model_reported", "ALTER TABLE llm_calls ADD COLUMN model_reported TEXT"),
("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT"),
("reasoning_tokens", "ALTER TABLE llm_calls ADD COLUMN reasoning_tokens INTEGER"),
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
(
"tenant_id",
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ''",
),
(
"meta",
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb",
),
)
# 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析 # 探测表是否存在;不需要任何权限,且与 INSERT 走同一套 search_path 解析
_TABLE_EXISTS = "SELECT to_regclass('llm_calls')" _TABLE_EXISTS = "SELECT to_regclass('llm_calls')"
@@ -80,44 +41,22 @@ _EXISTING_COLUMNS = (
"WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped" "WHERE attrelid = to_regclass('llm_calls') AND attnum > 0 AND NOT attisdropped"
) )
_COLUMNS = (
"call_id",
"parent_call_id",
"session_id",
"model",
"provider",
"source_name",
"messages",
"response",
"thinking",
"prompt_tokens",
"completion_tokens",
"usage_source",
"latency_ms",
"ttft_ms",
"max_inter_token_ms",
"cache_hit",
"error",
"cost",
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
)
_INSERT = (
f"INSERT INTO llm_calls ({', '.join(_COLUMNS)}) "
f"VALUES ({', '.join(f'${i + 1}' for i in range(len(_COLUMNS)))}) "
"ON CONFLICT (call_id) DO NOTHING"
)
class PostgresRecorder: class PostgresRecorder:
"""TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。""" """TelemetryRecorder 端口的 Postgres 实现;asyncpg 原生异步,无线程桥接。"""
def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None) -> None: def __init__(self, dsn: str, *, pool: asyncpg.Pool | None = None, auto_migrate: bool) -> None:
"""记下装配参数(不连库);列与 INSERT 语句在首次准备期定型。
Args:
dsn: asyncpg 连接串(已剥驱动后缀)。
pool: 外部注入的池;注入方自己负责关闭。
auto_migrate: True 则给已存在的旧表自动补列;False(PG 侧的缺省档)
则一条 ALTER 都不发——`ALTER TABLE ADD COLUMN` 取 ACCESS EXCLUSIVE
锁,会排在长事务后阻塞该表其后所有查询,而遥测是业务路径上的内联
await。keyword-only **必填**: 缺省规则只写在 config 一处,不与本类
签名漂移(设计 D-c)。
"""
try: try:
import asyncpg # noqa: F401 - 仅探测 extra 是否安装 import asyncpg # noqa: F401 - 仅探测 extra 是否安装
except ImportError as exc: except ImportError as exc:
@@ -127,6 +66,10 @@ class PostgresRecorder:
self._dsn = dsn self._dsn = dsn
self._pool: asyncpg.Pool | None = pool self._pool: asyncpg.Pool | None = pool
self._external_pool = pool is not None self._external_pool = pool is not None
self._auto_migrate = auto_migrate
# 先按全量列定型: 准备期探测失败时保守沿用全量(今天的行为)
self._columns: tuple[str, ...] = COLUMNS
self._insert = insert_sql("postgres", COLUMNS)
self._schema_ready = False self._schema_ready = False
self._failed = False # 结构性降级标志: 置位后所有写入短路 self._failed = False # 结构性降级标志: 置位后所有写入短路
self._init_lock = asyncio.Lock() self._init_lock = asyncio.Lock()
@@ -169,7 +112,7 @@ class PostgresRecorder:
"""备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。""" """备好表并交回可用的池;瞬时失败只跳过本次,确定写不进去才判死。"""
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
writable = await self._prepare_table(conn) columns = await self._prepare_table(conn)
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as exc: except Exception as exc:
@@ -177,14 +120,20 @@ class PostgresRecorder:
# 只跳过本次记录,下次调用重新准备 # 只跳过本次记录,下次调用重新准备
logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc) logger.warning("Postgres 遥测建表探测失败(跳过本条,下次重试): {}", exc)
return None return None
if not writable: if columns is None:
self._failed = True self._failed = True
return None return None
# 写入列、语句与就绪标志必须**一起**生效: `_ensure_ready` 只看 `_schema_ready`
# 就绕开 `_init_lock` 直接返回池,先置就绪会开出"已就绪但语句还是旧的"的窗口
self._columns = columns
self._insert = insert_sql("postgres", columns)
self._schema_ready = True self._schema_ready = True
return pool return pool
async def _prepare_table(self, conn: object) -> bool: async def _prepare_table(self, conn: object) -> tuple[str, ...] | None:
"""备好 `llm_calls`;**表存在就绝不发 DDL**。返回 False 仅表示表确定不存在。 """备好 `llm_calls` 并返回本实例要写的列;**表存在就绝不发 DDL**。
返回 None 仅表示表确定不存在且建不出来(唯一允许判死的情形)。
`CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的 `CREATE TABLE IF NOT EXISTS` 不能无条件发: PostgreSQL 对 schema 的
CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测: CREATE 权限检查**早于** `IF NOT EXISTS` 的存在性判断(PG 16.14 实测:
@@ -197,33 +146,77 @@ class PostgresRecorder:
""" """
exists = await conn.fetchval(_TABLE_EXISTS) is not None # type: ignore[attr-defined] exists = await conn.fetchval(_TABLE_EXISTS) is not None # type: ignore[attr-defined]
if exists: if exists:
await self._backfill_columns(conn) # 旧表可能缺列;失败只逐行降级 return await self._resolve_columns(conn) # 旧表可能缺列
return True
try: try:
await conn.execute(_DDL) # type: ignore[attr-defined] await conn.execute(PG_DDL) # type: ignore[attr-defined]
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as exc: except Exception as exc:
logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc) logger.warning("Postgres 遥测建表失败(表不存在,记录无处可落): {}", exc)
return False return None
return True # 新建表列已齐全,无需再走补列 return COLUMNS # 新建表列已齐全,无需再走补列
async def _backfill_columns(self, conn: object) -> None: async def _resolve_columns(self, conn: object) -> tuple[str, ...]:
"""给已存在的旧表补新列(issue #3);**先探测再 ALTER,失败绝不置 `_failed`** """探测旧表现有列并定型写入列: auto 档先补齐,manual 档改为裁剪(issue #13)。
两条纪律各有实测理由: **先探测**的理由(两档共用): `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会
① 不置 `_failed`: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的 ownership **先取 ACCESS EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是
检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让 内联 await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖 拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
(SQLite 侧同款守卫,两侧必须对称)。
② 先探测: `ADD COLUMN IF NOT EXISTS` 即便列已存在,也会**先取 ACCESS 探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
EXCLUSIVE 锁**再判存在性(实测会被一个开着的读事务阻塞)。遥测是内联
await,让每个进程的首次写入都去抢共享审计表的排他锁,等于用记录基础设施
拖垮业务调用。探测走 ACCESS SHARE,稳态下一条 ALTER 都不会发。
""" """
try: try:
existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined] existing = {row["attname"] for row in await conn.fetch(_EXISTING_COLUMNS)} # type: ignore[attr-defined]
for column, statement in _BACKFILL: except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Postgres 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
return COLUMNS
if self._auto_migrate:
await self._backfill_columns(conn, existing)
return COLUMNS
return self._trim_columns(existing)
def _trim_columns(self, existing: set[str]) -> tuple[str, ...]:
"""manual 档: 按现有列裁剪写入列,并把缺列一次讲清楚。
裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法
INSERT,`insert_sql` 会 ValueError,而 `_prepare_schema` 里那次调用在 try
**之外**,异常会顺着 `record_llm_call` 一路冒给业务调用方(遥测绝不冒泡)
——回落必须发生在把空列集交给它之前。
"""
effective = tuple(column for column in COLUMNS if column in existing)
if not effective:
logger.warning(
"Postgres 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
"现有列: {}",
sorted(existing),
)
return COLUMNS
missing = [column for column in COLUMNS if column not in existing]
if missing:
# 单参数传入: 补列 SQL 里带 `'{}'::jsonb` 字面量,拼进 format 模板会被当占位符
logger.warning(
"{}",
missing_columns_warning("postgres", missing, alien_table="call_id" not in existing),
)
return effective
async def _backfill_columns(self, conn: object, existing: set[str]) -> None:
"""auto 档: 给已存在的旧表补新列(issue #3);**失败绝不置 `_failed`**。
不置 `_failed` 的实测理由: 应用账号只有 INSERT 权限时,`ALTER TABLE` 的
ownership 检查早于 `IF NOT EXISTS` 的存在性判断——列明明齐全也会失败。置位会让
整个 recorder 永久 no-op,与「补列失败只降级为逐行丢弃」的承诺相悖
(SQLite 侧同款守卫,两侧必须对称)。补列失败后写入沿用全量列(今天的行为):
auto 档承诺的是"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入
请显式选 manual。
"""
try:
for column, statement in PG_BACKFILL:
if column not in existing: if column not in existing:
await conn.execute(statement) # type: ignore[attr-defined] await conn.execute(statement) # type: ignore[attr-defined]
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -232,14 +225,18 @@ class PostgresRecorder:
logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc) logger.warning("Postgres 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None: async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。""" """写一行遥测;单条失败逐条 warning 丢弃(两级降级之二),绝不冒泡。
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
"""
pool = await self._ensure_ready() pool = await self._ensure_ready()
if pool is None: if pool is None:
return return
row = tuple(fields[col] for col in _COLUMNS) row = tuple(fields[col] for col in self._columns)
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
await conn.execute(_INSERT, *row) await conn.execute(self._insert, *row)
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as exc: except Exception as exc:
+300
View File
@@ -0,0 +1,300 @@
"""遥测表 `llm_calls` 的 schema 单一事实源: 列序、两端 DDL、补列语句与 INSERT 构造。
两个 recorder(`sqlite.py` / `postgres.py`)与公共函数 `telemetry_schema_sql` 共用本模块。
收敛的理由是**正确性**而非整洁: 打印给下游的 SQL 必须与库真正执行的 DDL 同源——常量在
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 24 个 INSERT 字段 +
`created_at` = 25;列数断言一律按物理列数写,两套口径混用是最易错处。
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
互不依赖(import-linter 契约执法)。
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
TABLE = "llm_calls"
# 支持的后端;`insert_sql` / `telemetry_schema_sql` 的取值域
_BACKENDS = ("sqlite", "postgres")
SQLITE_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms REAL,
max_inter_token_ms REAL,
cache_hit INTEGER NOT NULL DEFAULT 0,
error TEXT,
cost REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}'
);
"""
PG_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms DOUBLE PRECISION,
max_inter_token_ms DOUBLE PRECISION,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::jsonb
);
"""
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
SQLITE_BACKFILL = (
("cached_prompt_tokens", "INTEGER"),
("model_reported", "TEXT"),
("sampling", "TEXT"),
("reasoning_tokens", "INTEGER"),
# NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "TEXT NOT NULL DEFAULT '{}'"),
)
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
# 下游的那份"的列集合与列定义**无法分叉**——本模块存在的全部理由就是不许漂移。
_PG_BACKFILL_DECLS = (
("cached_prompt_tokens", "INTEGER"),
("model_reported", "TEXT"),
("sampling", "TEXT"),
("reasoning_tokens", "INTEGER"),
# 两个默认值都是非易失常量,PG 11+ 只改 catalog 不重写全表,故大表补列亦是秒级
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "JSONB NOT NULL DEFAULT '{}'::jsonb"),
)
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
# **库内执行的这份有意不带 `IF NOT EXISTS`**: PG 对它即便列已存在也会先取 ACCESS
# EXCLUSIVE 锁,而遥测是业务路径上的内联 await,故库侧一律"先探测后 ALTER"
# (postgres.py `_backfill_columns` 记有实测)。给人执行的那份见 `telemetry_schema_sql`。
PG_BACKFILL = tuple(
(column, f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}")
for column, decl in _PG_BACKFILL_DECLS
)
COLUMNS = (
"call_id",
"parent_call_id",
"session_id",
"model",
"provider",
"source_name",
"messages",
"response",
"thinking",
"prompt_tokens",
"completion_tokens",
"usage_source",
"latency_ms",
"ttft_ms",
"max_inter_token_ms",
"cache_hit",
"error",
"cost",
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
)
_COLUMN_SET = frozenset(COLUMNS)
def insert_sql(backend: str, columns: Sequence[str]) -> str:
"""按给定列构造 INSERT;列必须是 `COLUMNS` 的非空子集,否则 ValueError。
子集校验是**注入面的闸**: 列名来自数据库探测结果,不是常量,不校验就等于把外部
字符串拼进 SQL(占位符只保护值,保护不了列名)。空集同样来自探测结果,而
`INSERT INTO llm_calls () VALUES ()` 两端都语法非法——本函数自己拒,不把这个
不变量押在调用方身上。sqlite 用 `?`、postgres 用 `$n`,
两端的重复键处理都不绑定具体约束名(`INSERT OR IGNORE` / `ON CONFLICT`)。
**PG 的 `ON CONFLICT` 一律不带冲突目标,不得"顺手"补回 `(call_id)`**: PG 要求
分区表的唯一约束必须包含分区键,按 `created_at` 分区(issue #12 的保留期方案)后
主键变成 `(call_id, created_at)`,带目标的语句匹配不到任何约束,PG 直接拒收
("there is no unique or exclusion constraint matching the ON CONFLICT
specification"),而遥测写失败只逐行 warning——分区部署下会全线静默丢数据。
无目标版本在两种表形态上都合法,普通表上语义逐字等价(表上只有主键一个唯一约束)。
Args:
backend: `"sqlite"` 或 `"postgres"`。
columns: 要写入的列,顺序即占位符顺序(调用方须按同序取值)。
Returns:
完整的 INSERT 语句。
Raises:
ValueError: backend 不在取值域内,columns 为空,或含 `COLUMNS` 之外的列名。
"""
if backend not in _BACKENDS:
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
selected = tuple(columns)
if not selected:
raise ValueError("遥测 INSERT 至少需要一列: 空列集合会拼出语法非法的 SQL")
unknown = [column for column in selected if column not in _COLUMN_SET]
if unknown:
raise ValueError(f"列名不在遥测 schema 内(拒绝拼进 SQL): {unknown}")
names = ", ".join(selected)
if backend == "sqlite":
placeholders = ", ".join("?" for _ in selected)
return f"INSERT OR IGNORE INTO {TABLE} ({names}) VALUES ({placeholders})"
placeholders = ", ".join(f"${i + 1}" for i in range(len(selected)))
return f"INSERT INTO {TABLE} ({names}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
# 缺列告警要打印的补列语句: 库内执行的那份怎么写,打印给人的就怎么写(同源不许漂移)。
# SQLite 侧常量只有列定义,故在此按 TABLE 拼成整条 ALTER;PG 侧常量本就是整条语句。
_ALTER_BY_BACKEND = {
"sqlite": {
column: f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl}"
for column, decl in SQLITE_BACKFILL
},
"postgres": dict(PG_BACKFILL),
}
_BACKEND_LABELS = {"sqlite": "SQLite", "postgres": "Postgres"}
# PG 的 ALTER 取 ACCESS EXCLUSIVE 锁,执行时机得由 DBA 自己挑;SQLite 是下游本地文件,无此顾虑
_EXECUTION_NOTES = {"sqlite": "", "postgres": "(建议挑低峰,ALTER 取 ACCESS EXCLUSIVE 锁)"}
def missing_columns_warning(backend: str, missing: Sequence[str], *, alien_table: bool) -> str:
"""拼 manual 档的缺列告警: 逐列点名 + 讲清后果 + 给出可直接执行的 SQL。
只说"缺列"是不够的: 静默丢维度的后果是多租户账目全归空串且无任何报错,
看告警的人必须一眼看到丢的是哪几个维度、以及怎么补。
**住在本模块而不是两个 recorder 里**: 这条消息拼的是给人执行的 DDL,与库自己
执行的 ALTER 必须同源——本模块存在的全部理由就是不许这两者漂移。
Args:
backend: `"sqlite"` 或 `"postgres"`。
missing: 缺失的列名(按 `COLUMNS` 保序)。
alien_table: 连主键列 `call_id` 都没有——该表多半不是本库的 `llm_calls`。
Returns:
单条 warning 的完整文本(库只在准备期发一次,不逐行发)。
Raises:
ValueError: backend 不在取值域内。
"""
if backend not in _BACKENDS:
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
alters = _ALTER_BY_BACKEND[backend]
selected = tuple(missing)
statements = [f"{alters[column]};" for column in selected if column in alters]
unknown = [column for column in selected if column not in alters]
if unknown:
# 这些列本库从未经 ALTER 补过(建表即有),给不出单条 ALTER,指向完整脚本
statements.append(
f"-- 另缺 {', '.join(unknown)};完整建表脚本见 "
f'polygateway.telemetry_schema_sql("{backend}")'
)
label = _BACKEND_LABELS[backend]
head = (
f"{label} 遥测表 {TABLE} 缺主键列 call_id,很可能不是本库的遥测表"
"(库不做二次判定,仍照常尝试写入)"
if alien_table
else f"{label} 遥测表 {TABLE} 缺列,且 auto_migrate=False(库不发任何 DDL)"
)
return (
f"{head};以下维度不会被记录: {', '.join(selected)}"
f"补列请自行执行{_EXECUTION_NOTES[backend]}:\n" + "\n".join(statements)
)
def telemetry_schema_sql(backend: str) -> str:
"""返回可直接粘进迁移文件的完整脚本(建表 + 各补列语句 + 注释)。
给不愿意让库在自己的生产表上发 DDL 的下游用: 输出与库运行时执行的 DDL 同源,
照它建完表,库探测到的列就是齐的。
**补列语句与库内执行的那份是两套文本,不是一份**: 这份给人执行,必须可重复执行,
故 PG 变体带 `ADD COLUMN IF NOT EXISTS`(它会先取 ACCESS EXCLUSIVE 锁,但执行时机
由 DBA 自己挑,锁风险可控);库内那份不带,靠先探测后 ALTER 规避锁。SQLite 没有
`ADD COLUMN IF NOT EXISTS` 语法,只能以注释交代"仅当该列不存在时执行"
Args:
backend: `"sqlite"` 或 `"postgres"`。
Returns:
含注释的完整 SQL 脚本。
Raises:
ValueError: backend 不在取值域内。
"""
if backend not in _BACKENDS:
raise ValueError(f"未知遥测后端 {backend!r}: 只支持 {list(_BACKENDS)}")
if backend == "sqlite":
ddl = SQLITE_DDL
notes = (
f"-- 旧表补列(库升级后新增的列)。SQLite 无 ADD COLUMN IF NOT EXISTS 语法,\n"
f"-- 以下每条**仅当该列不存在时执行**(先 PRAGMA table_info({TABLE}) 对照)。"
)
alters = [
f"ALTER TABLE {TABLE} ADD COLUMN {column} {decl};" for column, decl in SQLITE_BACKFILL
]
else:
ddl = PG_DDL
notes = (
"-- 旧表补列(库升级后新增的列)。带 IF NOT EXISTS,整段可重复执行;\n"
"-- 注意它即便列已存在也会先取 ACCESS EXCLUSIVE 锁,请挑低峰执行。"
)
alters = [
f"ALTER TABLE {TABLE} ADD COLUMN IF NOT EXISTS {column} {decl};"
for column, decl in _PG_BACKFILL_DECLS
]
header = (
f"-- PolyGateway 遥测表 {TABLE}({backend})\n"
f'-- 由 polygateway.telemetry_schema_sql("{backend}") 生成,与库运行时执行的 DDL 同源。\n'
"-- 新建库执行整段;已有旧表则建表语句自动跳过,只需关注下方补列语句。"
)
return "\n".join([header, "", ddl.strip(), "", notes, *alters, ""])
+75 -86
View File
@@ -22,117 +22,102 @@ from pathlib import Path
from loguru import logger from loguru import logger
_DDL = """ from polygateway.telemetry.schema import (
CREATE TABLE IF NOT EXISTS llm_calls ( COLUMNS,
call_id TEXT PRIMARY KEY, SQLITE_BACKFILL,
parent_call_id TEXT, SQLITE_DDL,
session_id TEXT, insert_sql,
model TEXT NOT NULL, missing_columns_warning,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms REAL,
max_inter_token_ms REAL,
cache_hit INTEGER NOT NULL DEFAULT 0,
error TEXT,
cost REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}'
);
"""
# 新列必须排在 created_at 之后: 旧表只能经 ALTER 追加到末尾,新建库若把它们
# 插在前面,两条路径的物理列序会分叉(列序断言测试无合规修法)。
_BACKFILL_COLUMNS = (
("cached_prompt_tokens", "INTEGER"),
("model_reported", "TEXT"),
("sampling", "TEXT"),
("reasoning_tokens", "INTEGER"),
# NOT NULL 补列必须带非 NULL 常量默认值,否则 SQLite 直接拒绝该 ALTER
# ("Cannot add a NOT NULL column with default value NULL"),补列全盘失败。
("tenant_id", "TEXT NOT NULL DEFAULT ''"),
("meta", "TEXT NOT NULL DEFAULT '{}'"),
)
_COLUMNS = (
"call_id",
"parent_call_id",
"session_id",
"model",
"provider",
"source_name",
"messages",
"response",
"thinking",
"prompt_tokens",
"completion_tokens",
"usage_source",
"latency_ms",
"ttft_ms",
"max_inter_token_ms",
"cache_hit",
"error",
"cost",
"cached_prompt_tokens",
"model_reported",
"sampling",
"reasoning_tokens",
"tenant_id",
"meta",
)
_INSERT = (
f"INSERT OR IGNORE INTO llm_calls ({', '.join(_COLUMNS)}) "
f"VALUES ({', '.join('?' for _ in _COLUMNS)})"
) )
class SQLiteRecorder: class SQLiteRecorder:
"""TelemetryRecorder 端口的 SQLite 实现;初始化/写入失败全降级 warning。""" """TelemetryRecorder 端口的 SQLite 实现;初始化/写入失败全降级 warning。"""
def __init__(self, db_path: Path | str) -> None: def __init__(self, db_path: Path | str, *, auto_migrate: bool) -> None:
"""建连接与表,并按探测到的列定型本实例的 INSERT 语句。
Args:
db_path: 库文件路径;父目录不存在会自动创建。
auto_migrate: True 则给已存在的旧表自动补列(SQLite 侧的缺省档:
下游本地文件,无 DBA 无迁移工具);False 则一条 ALTER 都不发,
改为按现有列裁剪写入。keyword-only **必填**: 缺省规则只写在
config 一处,不与本类签名漂移(设计 D-c)。
"""
self._auto_migrate = auto_migrate
self._lock = threading.Lock() self._lock = threading.Lock()
self._conn: sqlite3.Connection | None = None self._conn: sqlite3.Connection | None = None
# 先按全量列定型: 连接失败/探测失败时保守沿用全量(今天的行为)
self._columns: tuple[str, ...] = COLUMNS
self._insert = insert_sql("sqlite", COLUMNS)
try: try:
path = Path(db_path) path = Path(db_path)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, check_same_thread=False, timeout=10.0) conn = sqlite3.connect(path, check_same_thread=False, timeout=10.0)
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000") conn.execute("PRAGMA busy_timeout=5000")
conn.execute(_DDL) conn.execute(SQLITE_DDL)
conn.commit() conn.commit()
self._conn = conn self._conn = conn
except (OSError, sqlite3.Error) as exc: except (OSError, sqlite3.Error) as exc:
logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc) logger.warning("SQLite 遥测初始化失败,后续记录降级为 no-op: {}", exc)
self._backfill_columns() self._prepare_columns()
def _backfill_columns(self) -> None: def _prepare_columns(self) -> None:
"""给已存在的旧表补新列(issue #3);独立 try,失败只降级为逐行丢弃 """探测现有列后定型写入: auto 档补齐缺列,manual 档改为裁剪写入(issue #13)。
必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None, 必须放在 `self._conn` 赋值**之后**并先判空: 初始化失败时连接为 None,
无守卫的补列会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。 无守卫的探测会抛 AttributeError 逃出 `__init__`,把"静默降级"变成崩溃。
补列失败也绝不清空 `self._conn`——那会让整个 recorder 永久 no-op, 探测失败保守沿用全量列(今天的行为): 猜不出真实列集合时,让写入照常尝试。
比逐行丢弃严重得多。
""" """
if self._conn is None: if self._conn is None:
return return
try: try:
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")} existing = {row[1] for row in self._conn.execute("PRAGMA table_info(llm_calls)")}
except sqlite3.Error as exc: except sqlite3.Error as exc:
logger.warning("SQLite 遥测列探测失败(写入将逐行降级): {}", exc) logger.warning("SQLite 遥测列探测失败(沿用全量列,写入将逐行降级): {}", exc)
return return
for column, decl in _BACKFILL_COLUMNS: if self._auto_migrate:
self._backfill_columns(existing)
return
self._adopt_existing_columns(existing)
def _adopt_existing_columns(self, existing: set[str]) -> None:
"""manual 档: 不发任何 DDL,按现有列裁剪 INSERT,并把缺列一次讲清楚。
裁剪是关掉 ALTER 的**前提**而非增强: 旧表缺列时仍发全量 INSERT,每一行
都会因未知列被拒 → 遥测彻底丢失,比自动 ALTER 更严重地违反"遥测必录"
探测结果与 `COLUMNS` 毫无交集时视同探测异常保守回落全量: 空列集拼不出合法
INSERT,`insert_sql` 会 ValueError,而遥测构造期抛异常就是把"初始化失败静默
降级"的铁律破成崩溃——回落必须发生在把空列集交给它之前。
"""
effective = tuple(column for column in COLUMNS if column in existing)
if not effective:
logger.warning(
"SQLite 遥测表 llm_calls 没有任何本库认识的列(沿用全量列,写入将逐行降级);"
"现有列: {}",
sorted(existing),
)
return
self._columns = effective
self._insert = insert_sql("sqlite", effective)
missing = [column for column in COLUMNS if column not in existing]
if missing:
# 单参数传入: 补列 SQL 里带 `'{}'` 字面量,拼进 format 模板会被当占位符
logger.warning(
"{}",
missing_columns_warning("sqlite", missing, alien_table="call_id" not in existing),
)
def _backfill_columns(self, existing: set[str]) -> None:
"""auto 档: 给已存在的旧表补新列(issue #3);逐列独立 try,失败只降级为逐行丢弃。
补列失败绝不清空 `self._conn`——那会让整个 recorder 永久 no-op,
比逐行丢弃严重得多。失败后写入沿用全量列(今天的行为): auto 档承诺的是
"把列补上",补不上就让缺列以逐行 warning 暴露;要降级写入请显式选 manual。
"""
assert self._conn is not None # 内部不变量: 调用方已判空
for column, decl in SQLITE_BACKFILL:
if column in existing: if column in existing:
continue continue
# 逐列独立 try: 一列撞上 duplicate 不得让后面的列漏补 # 逐列独立 try: 一列撞上 duplicate 不得让后面的列漏补
@@ -145,10 +130,14 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None: async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。""" """写一行遥测;字段集合即 24 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
"""
if self._conn is None: if self._conn is None:
return return
row = tuple(fields[col] for col in _COLUMNS) row = tuple(fields[col] for col in self._columns)
try: try:
await asyncio.to_thread(self._write, row) await asyncio.to_thread(self._write, row)
except (OSError, sqlite3.Error) as exc: except (OSError, sqlite3.Error) as exc:
@@ -157,7 +146,7 @@ class SQLiteRecorder:
def _write(self, row: tuple) -> None: def _write(self, row: tuple) -> None:
assert self._conn is not None # 内部不变量: 调用方已判空 assert self._conn is not None # 内部不变量: 调用方已判空
with self._lock: with self._lock:
self._conn.execute(_INSERT, row) self._conn.execute(self._insert, row)
self._conn.commit() self._conn.commit()
def close(self) -> None: def close(self) -> None:
+44
View File
@@ -291,6 +291,50 @@ class TestRetryAfter:
await _open_gate(gate, "s1") # s1 开路;s2 健康 await _open_gate(gate, "s1") # s1 开路;s2 健康
assert await gate.retry_after_s(("s1", "s2")) == 0.0 assert await gate.retry_after_s(("s1", "s2")) == 0.0
async def test_half_open_rejection_reports_no_certain_wait(self, gate_factory, clock):
"""探针在途时被拒 → 0.0(issue #14): 探针随时可能出结果,不存在确定时刻。
旧行为返回探针租约剩余,而租约长度是**死锁保护参数**(派生自
`2 × 最慢源 timeout`),与"这个源多久能恢复"没有因果关系。现场
`TIMEOUT_S=300` 时它是 600s,而冷却期只有 60s。
"""
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
assert probe.is_probe
blocked = await gate.try_enter("s1", "w2")
assert not blocked.allowed and blocked.state is GateState.HALF_OPEN
assert blocked.retry_after_s == 0.0
async def test_probe_grant_reports_no_certain_wait(self, gate_factory, clock):
"""准入被允许 → 恒 0.0(现在就能试);此前 redis 侧返回探针 TTL。"""
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
assert probe.allowed and probe.is_probe
assert probe.retry_after_s == 0.0
async def test_retry_after_zero_while_probe_in_flight(self, gate_factory, clock):
"""集合查询同口径: 探针在途的源不贡献等待时间。"""
gate = gate_factory(_CFG)
await _open_gate(gate)
clock.advance(_CFG.cooldown_s + 1)
assert (await gate.try_enter("s1", "w1")).is_probe
assert await gate.retry_after_s(("s1",)) == 0.0
async def test_fenced_write_in_half_open_reports_no_certain_wait(self, gate_factory, clock):
"""写回被 fencing 拒时的快照同口径;此前 redis 侧返回探针租约剩余。"""
gate = gate_factory(_CFG)
stale = await gate.try_enter("s1", "slow-worker") # epoch 0 的旧 entry
await _open_gate(gate) # 他人开路,epoch 推进
clock.advance(_CFG.cooldown_s + 1)
assert (await gate.try_enter("s1", "w1")).is_probe # 门此刻 HALF_OPEN
update = await gate.record_success(stale)
assert not update.applied and update.state is GateState.HALF_OPEN
assert update.retry_after_s == 0.0
class TestConsecutiveSuppression: class TestConsecutiveSuppression:
"""迭代 6: 窗口证据充足且健康时,连败是噪声,不开路(设计 §3.39)。""" """迭代 6: 窗口证据充足且健康时,连败是噪声,不开路(设计 §3.39)。"""
+6 -6
View File
@@ -119,7 +119,7 @@ class TestBreakerRecoveryFullChain:
class TestCancellationThroughStack: class TestCancellationThroughStack:
async def test_cancel_mid_request_releases_and_records(self, tmp_path): async def test_cancel_mid_request_releases_and_records(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db") recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
entered = asyncio.Event() entered = asyncio.Event()
async def hanging_handler(request): async def hanging_handler(request):
@@ -144,7 +144,7 @@ class TestCancellationThroughStack:
class TestTelemetryAcrossPaths: class TestTelemetryAcrossPaths:
async def test_success_cache_hit_and_failure_rows(self, tmp_path): async def test_success_cache_hit_and_failure_rows(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db") recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
client = _full_client(lambda req: _sse(), telemetry=recorder, cache=InMemoryCache()) client = _full_client(lambda req: _sse(), telemetry=recorder, cache=InMemoryCache())
await client.chat([{"role": "user", "content": "hi"}]) # 成功(尝试行) await client.chat([{"role": "user", "content": "hi"}]) # 成功(尝试行)
await client.chat([{"role": "user", "content": "hi"}]) # 缓存命中行 await client.chat([{"role": "user", "content": "hi"}]) # 缓存命中行
@@ -155,7 +155,7 @@ class TestTelemetryAcrossPaths:
assert hits == 1 and total == 2 assert hits == 1 and total == 2
async def test_transient_attempts_each_recorded(self, tmp_path): async def test_transient_attempts_each_recorded(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db") recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
calls = {"n": 0} calls = {"n": 0}
def flaky(request): def flaky(request):
@@ -193,7 +193,7 @@ class TestRejectionReasonIsQueryable:
) )
async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path): async def test_rejected_call_leaves_the_reason_in_telemetry(self, tmp_path):
recorder = SQLiteRecorder(tmp_path / "t.db") recorder = SQLiteRecorder(tmp_path / "t.db", auto_migrate=True)
client = _full_client( client = _full_client(
lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder lambda req: httpx.Response(400, content=self._BODY.encode()), telemetry=recorder
) )
@@ -257,7 +257,7 @@ class TestSamplingThroughStack:
return _sse() return _sse()
db = tmp_path / "t.db" db = tmp_path / "t.db"
recorder = SQLiteRecorder(db) recorder = SQLiteRecorder(db, auto_migrate=True)
client = _full_client(handler, telemetry=recorder) client = _full_client(handler, telemetry=recorder)
await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42}) await client.chat([{"role": "user", "content": "hi"}], overlay={"seed": 42})
recorder.close() recorder.close()
@@ -276,7 +276,7 @@ class TestSamplingThroughStack:
src = dataclasses.replace(_source(), extra_body={"temperature": 0}) src = dataclasses.replace(_source(), extra_body={"temperature": 0})
db = tmp_path / "t.db" db = tmp_path / "t.db"
recorder = SQLiteRecorder(db) recorder = SQLiteRecorder(db, auto_migrate=True)
client = GatewayClient( client = GatewayClient(
scope="llm", scope="llm",
sources=[src], sources=[src],
+597 -16
View File
@@ -14,12 +14,16 @@ import asyncio
import json import json
import os import os
import re import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from dotenv import dotenv_values from dotenv import dotenv_values
from polygateway.telemetry.postgres import PostgresRecorder from polygateway.telemetry.postgres import PostgresRecorder
from polygateway.telemetry.schema import COLUMNS, telemetry_schema_sql
_EXPECTED_COLUMNS = [ _EXPECTED_COLUMNS = [
"call_id", "call_id",
@@ -88,8 +92,13 @@ async def dsn():
async def _record_minimal( async def _record_minimal(
recorder: PostgresRecorder, call_id: str | None = None, **overrides recorder: PostgresRecorder, call_id: str | None = None, **overrides
) -> None: ) -> dict[str, object]:
fields = { """记一行最小遥测,并**返回实际提交的字段**供调用方逐列比对回读结果。
返回值不是顺手加的: 逐列断言若在测试里另抄一份期望值,抄错的那一列会以
"库写错列位"的形态误报,而漏抄的列则悄悄不被验证。
"""
fields: dict[str, object] = {
"call_id": call_id if call_id is not None else _cid("c1"), "call_id": call_id if call_id is not None else _cid("c1"),
"parent_call_id": None, "parent_call_id": None,
"session_id": "sess-1", "session_id": "sess-1",
@@ -118,6 +127,7 @@ async def _record_minimal(
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
return fields
async def _fetch(dsn: str, sql: str, *args): async def _fetch(dsn: str, sql: str, *args):
@@ -130,6 +140,17 @@ async def _fetch(dsn: str, sql: str, *args):
await conn.close() await conn.close()
async def _execute_script(dsn: str, sql: str) -> None:
"""整段执行多语句脚本(不带参数,走简单查询协议)——模拟下游把脚本贴进 psql。"""
import asyncpg
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(sql)
finally:
await conn.close()
_LEGACY_DDL = """ _LEGACY_DDL = """
CREATE TABLE {schema}.llm_calls ( CREATE TABLE {schema}.llm_calls (
call_id TEXT PRIMARY KEY, call_id TEXT PRIMARY KEY,
@@ -184,7 +205,7 @@ class TestObservabilityColumns:
"""issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。""" """issue #3: 两列写入可回读,且已存在的 18 列旧表会被自动补列。"""
async def test_values_round_trip(self, dsn): async def test_values_round_trip(self, dsn):
recorder = PostgresRecorder(dsn) recorder = PostgresRecorder(dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64) await _record_minimal(recorder, call_id=_cid("hit"), cached_prompt_tokens=64)
await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0) await _record_minimal(recorder, call_id=_cid("zero"), cached_prompt_tokens=0)
@@ -212,7 +233,7 @@ class TestObservabilityColumns:
async def test_legacy_table_is_upgraded_in_place(self, legacy_schema): async def test_legacy_table_is_upgraded_in_place(self, legacy_schema):
"""18 列旧表不补列的话,每行写入都会被逐行 warning 丢弃(遥测静默全失)。""" """18 列旧表不补列的话,每行写入都会被逐行 warning 丢弃(遥测静默全失)。"""
schema_dsn, schema = legacy_schema schema_dsn, schema = legacy_schema
recorder = PostgresRecorder(schema_dsn) recorder = PostgresRecorder(schema_dsn, auto_migrate=True)
try: try:
await _record_minimal( await _record_minimal(
recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real" recorder, call_id=_cid("legacy"), cached_prompt_tokens=7, model_reported="m-real"
@@ -237,7 +258,7 @@ class TestObservabilityColumns:
class TestSchema: class TestSchema:
async def test_schema_has_frozen_columns_in_order(self, dsn): async def test_schema_has_frozen_columns_in_order(self, dsn):
recorder = PostgresRecorder(dsn) recorder = PostgresRecorder(dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder) await _record_minimal(recorder)
rows = await _fetch( rows = await _fetch(
@@ -250,7 +271,7 @@ class TestSchema:
await recorder.aclose() await recorder.aclose()
async def test_call_id_idempotent(self, dsn): async def test_call_id_idempotent(self, dsn):
recorder = PostgresRecorder(dsn) recorder = PostgresRecorder(dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("dup")) await _record_minimal(recorder, call_id=_cid("dup"))
await _record_minimal(recorder, call_id=_cid("dup"), response="second") await _record_minimal(recorder, call_id=_cid("dup"), response="second")
@@ -262,7 +283,7 @@ class TestSchema:
await recorder.aclose() await recorder.aclose()
async def test_concurrent_writes_all_land(self, dsn): async def test_concurrent_writes_all_land(self, dsn):
recorder = PostgresRecorder(dsn) recorder = PostgresRecorder(dsn, auto_migrate=True)
try: try:
await asyncio.gather( await asyncio.gather(
*(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50)) *(_record_minimal(recorder, call_id=_cid(f"c{i}")) for i in range(50))
@@ -280,14 +301,14 @@ class TestSchema:
class TestDegradation: class TestDegradation:
async def test_unreachable_server_degrades_silently(self): async def test_unreachable_server_degrades_silently(self):
"""结构性失败(建池不通)→ warning 一次后永久降级,业务零感知。""" """结构性失败(建池不通)→ warning 一次后永久降级,业务零感知。"""
recorder = PostgresRecorder("postgresql://u:p@127.0.0.1:1/x") recorder = PostgresRecorder("postgresql://u:p@127.0.0.1:1/x", auto_migrate=True)
await _record_minimal(recorder) # 不抛 await _record_minimal(recorder) # 不抛
await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛 await _record_minimal(recorder, call_id=_cid("c2")) # 已降级短路,同样不抛
await recorder.aclose() await recorder.aclose()
async def test_row_failure_does_not_poison_later_rows(self, dsn): async def test_row_failure_does_not_poison_later_rows(self, dsn):
"""运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。""" """运行时单条写失败(NUL 字节文本被 PG 拒)→ 丢该行,后续行照常落库。"""
recorder = PostgresRecorder(dsn) recorder = PostgresRecorder(dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte") await _record_minimal(recorder, call_id=_cid("bad"), response="nul\x00byte")
await _record_minimal(recorder, call_id=_cid("good")) await _record_minimal(recorder, call_id=_cid("good"))
@@ -301,7 +322,7 @@ class TestDegradation:
await recorder.aclose() await recorder.aclose()
async def test_aclose_idempotent(self, dsn): async def test_aclose_idempotent(self, dsn):
recorder = PostgresRecorder(dsn) recorder = PostgresRecorder(dsn, auto_migrate=True)
await _record_minimal(recorder) await _record_minimal(recorder)
await recorder.aclose() await recorder.aclose()
await recorder.aclose() await recorder.aclose()
@@ -320,7 +341,7 @@ async def least_privilege_dsn(dsn):
""" """
import asyncpg import asyncpg
from polygateway.telemetry.postgres import _DDL from polygateway.telemetry.schema import PG_DDL
name = f"pgwtest_lp_{uuid4().hex[:8]}" name = f"pgwtest_lp_{uuid4().hex[:8]}"
admin = await asyncpg.connect(dsn, timeout=10) admin = await asyncpg.connect(dsn, timeout=10)
@@ -332,7 +353,7 @@ async def least_privilege_dsn(dsn):
await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'") await admin.execute(f"CREATE ROLE {name} LOGIN PASSWORD '{_PROBE_PASSWORD}'")
await admin.execute(f"CREATE SCHEMA {name}") await admin.execute(f"CREATE SCHEMA {name}")
await admin.execute(f"SET search_path = {name}") await admin.execute(f"SET search_path = {name}")
await admin.execute(_DDL) # 表由**别的账号**建好,与现场一致 await admin.execute(PG_DDL) # 表由**别的账号**建好,与现场一致
await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}") await admin.execute(f"GRANT USAGE ON SCHEMA {name} TO {name}")
await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}") await admin.execute(f"GRANT SELECT, INSERT ON {name}.llm_calls TO {name}")
# 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项 # 关键: 绝不 GRANT CREATE ON SCHEMA —— 缺的正是这一项
@@ -373,7 +394,7 @@ class TestLeastPrivilegeDeployment:
async def test_records_land_without_schema_create_privilege(self, least_privilege_dsn): async def test_records_land_without_schema_create_privilege(self, least_privilege_dsn):
"""修复前: 建表被拒 → _failed → 整个进程一条不落(下游 150 次调用全丢)。""" """修复前: 建表被拒 → _failed → 整个进程一条不落(下游 150 次调用全丢)。"""
low_dsn, schema = least_privilege_dsn low_dsn, schema = least_privilege_dsn
recorder = PostgresRecorder(low_dsn) recorder = PostgresRecorder(low_dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("lp1")) await _record_minimal(recorder, call_id=_cid("lp1"))
await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5) await _record_minimal(recorder, call_id=_cid("lp2"), cost=1.5)
@@ -428,6 +449,15 @@ _PRE_TENANT_INSERT = (
) )
# `_PRE_TENANT_DDL` 的物理列(23 个): 由 `_EXPECTED_COLUMNS` 去掉 issue #11 的两个新维度
# 派生而非另抄一份——两份常量必然漂移,而漂移的表现是"manual 档没补列"这条断言假绿。
# 去掉后的顺序与 DDL 逐字一致(tenant_id/meta 在 DDL 里本就排在末尾)。
_PRE_TENANT_COLUMNS = [c for c in _EXPECTED_COLUMNS if c not in ("tenant_id", "meta")]
# 回读要逐列比对的字段: 物理列去掉库从不显式写的 created_at,恰好 22 个
_PRE_TENANT_WRITTEN_COLUMNS = [c for c in _PRE_TENANT_COLUMNS if c != "created_at"]
def _search_path_dsn(dsn: str, schema: str) -> str: def _search_path_dsn(dsn: str, schema: str) -> str:
sep = "&" if "?" in dsn else "?" sep = "&" if "?" in dsn else "?"
return f"{dsn}{sep}options=-csearch_path%3D{schema}" return f"{dsn}{sep}options=-csearch_path%3D{schema}"
@@ -533,7 +563,7 @@ class TestCallerDimensionsAcceptance:
async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema): async def test_fresh_schema_round_trips_the_dimensions(self, fresh_schema):
"""新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。""" """新建库: 列齐全,且维度值原样读回——只验列存在会漏掉写错列位的错。"""
fresh_dsn, schema = fresh_schema fresh_dsn, schema = fresh_schema
recorder = PostgresRecorder(fresh_dsn) recorder = PostgresRecorder(fresh_dsn, auto_migrate=True)
try: try:
await _record_minimal( await _record_minimal(
recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}' recorder, call_id=_cid("dim"), tenant_id="tenant-a", meta='{"batch": "b7"}'
@@ -567,7 +597,7 @@ class TestCallerDimensionsAcceptance:
审计出来,历史欠账是可见、可量化、可补录的。 审计出来,历史欠账是可见、可量化、可补录的。
""" """
schema_dsn, schema = pre_tenant_schema schema_dsn, schema = pre_tenant_schema
recorder = PostgresRecorder(schema_dsn) recorder = PostgresRecorder(schema_dsn, auto_migrate=True)
try: try:
await _record_minimal( await _record_minimal(
recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}' recorder, call_id=_cid("new"), tenant_id="tenant-a", meta='{"k": 1}'
@@ -619,7 +649,7 @@ class TestCallerDimensionsAcceptance:
置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多), 置 `_failed` 会让整个进程从此一条遥测都不写(比逐行丢弃严重得多),
且一旦 DBA 补上列也不会自愈——必须等重启。 且一旦 DBA 补上列也不会自愈——必须等重启。
""" """
recorder = PostgresRecorder(least_privilege_pre_tenant_dsn) recorder = PostgresRecorder(least_privilege_pre_tenant_dsn, auto_migrate=True)
try: try:
await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛 await _record_minimal(recorder, call_id=_cid("lpp1")) # 不得抛
assert recorder._failed is False assert recorder._failed is False
@@ -628,3 +658,554 @@ class TestCallerDimensionsAcceptance:
assert any("写入失败" in m for m in captured_warnings) assert any("写入失败" in m for m in captured_warnings)
finally: finally:
await recorder.aclose() await recorder.aclose()
# issue #12 的目标表形态: 按 created_at 做 RANGE 分区(过期清理 DROP PARTITION 而非 DELETE)。
# PG 强制分区表的唯一约束必须包含分区键,故主键只能是 (call_id, created_at) ——
# 这正是带目标的 `ON CONFLICT (call_id)` 再也匹配不到约束的现场。
_PARTITIONED_DDL = """
CREATE TABLE {schema}.llm_calls (
call_id TEXT NOT NULL,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms DOUBLE PRECISION,
max_inter_token_ms DOUBLE PRECISION,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT,
cost DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{{}}'::jsonb,
PRIMARY KEY (call_id, created_at)
) PARTITION BY RANGE (created_at)
"""
_PARTITION_DDL = (
"CREATE TABLE {schema}.llm_calls_current PARTITION OF {schema}.llm_calls "
"FOR VALUES FROM ('{start}') TO ('{end}')"
)
def _current_month_bounds() -> tuple[str, str]:
"""当前月的 [月初, 下月初) 边界字面量;分区键落在区间外会因找不到分区而写失败。"""
now = datetime.now(UTC)
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = (start + timedelta(days=32)).replace(day=1)
fmt = "%Y-%m-%d %H:%M:%S%z"
return start.strftime(fmt), end.strftime(fmt)
@pytest.fixture
async def partitioned_schema(dsn):
"""自建临时 schema 里造一张按 created_at RANGE 分区的表 + 覆盖当前月的分区。
与 legacy_schema 同款隔离: 绝不碰共享的 public.llm_calls,teardown 只 DROP
自己建的 schema(CASCADE 连分区一并删)。
"""
import asyncpg
name = f"pgwtest_part_{uuid4().hex[:8]}"
start, end = _current_month_bounds()
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(_PARTITIONED_DDL.format(schema=name))
await conn.execute(_PARTITION_DDL.format(schema=name, start=start, end=end))
finally:
await conn.close()
yield _search_path_dsn(dsn, name), name
conn = await asyncpg.connect(dsn, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
class TestConflictTargetFreeInsert:
"""issue #13: INSERT 不绑定冲突目标,普通表与分区表两种形态都写得进去。"""
async def test_plain_table_still_dedupes_by_call_id(self, fresh_schema, captured_warnings):
"""普通表上语义不变: 重复 call_id 仍只落一行,且不是被拒后丢弃。
表上只有主键这一个唯一约束,故无目标的 DO NOTHING 与 `(call_id)` 逐字等价;
断言"无写入失败 warning"是为了区分"冲突被忽略""整条被 PG 拒收"
"""
fresh_dsn, _ = fresh_schema
recorder = PostgresRecorder(fresh_dsn, auto_migrate=True)
try:
await _record_minimal(recorder, call_id=_cid("nodup"))
await _record_minimal(recorder, call_id=_cid("nodup"), response="second")
assert [m for m in captured_warnings if "写入失败" in m] == []
rows = await _fetch(
fresh_dsn, "SELECT response FROM llm_calls WHERE call_id = $1", _cid("nodup")
)
assert [r["response"] for r in rows] == ["ok"] # 首行胜出,写入幂等
finally:
await recorder.aclose()
async def test_partitioned_table_accepts_writes(self, partitioned_schema, captured_warnings):
"""分区表上写入成功且能读回——改动前这里必红。
带目标的 `ON CONFLICT (call_id)` 在主键为 `(call_id, created_at)` 的表上
匹配不到任何约束,PG 报 "there is no unique or exclusion constraint matching
the ON CONFLICT specification";该错误被逐行降级吞成 warning,于是分区部署下
遥测全线写不进去却一声不吭,只能靠"读不回来"暴露。
"""
part_dsn, _ = partitioned_schema
recorder = PostgresRecorder(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] == []
rows = await _fetch(
part_dsn,
"SELECT call_id, tenant_id FROM llm_calls WHERE call_id = $1",
_cid("part"),
)
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(_cid("part"), "tenant-p")]
finally:
await recorder.aclose()
class TestManualSchemaModeAcceptance:
"""issue #13 manual 档的真实实例验收: 旧表原样不动,写入照常,缺列只作提示。
manual 档的承诺是"库一条 DDL 都不发"——单元测试只能验"没调用 execute",
真表上才验得了"表结构确实没变"。两条用例分别覆盖有权补列却不补(纪律)与
无权补列(现场),后者正是 auto 档会刷出 `补列失败` warning 的那张表。
"""
async def test_manual_leaves_the_stale_table_untouched(
self, pre_tenant_schema, captured_warnings
):
"""22 字段旧表 + manual: 列一个不加,行照常落库,缺的两维度静默不写。
与 `test_pre_tenant_table_gains_columns_and_old_rows_stay_auditable` 恰成对照:
同一张表、同一份负载,只有 `auto_migrate` 不同,列数就必须是 23 与 25 之别。
"""
schema_dsn, schema = pre_tenant_schema
recorder = PostgresRecorder(schema_dsn, auto_migrate=False)
try:
recorded = await _record_minimal(
recorder, call_id=_cid("man"), tenant_id="tenant-a", meta='{"k": 1}'
)
cols = await _fetch(
schema_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema,
)
# 表结构逐字不动: 既没多出 tenant_id/meta,也没被顺手改了列序
assert [r["column_name"] for r in cols] == _PRE_TENANT_COLUMNS
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
rows = await _fetch(
schema_dsn, f"SELECT {names} FROM llm_calls WHERE call_id = $1", _cid("man")
)
assert len(rows) == 1 # 裁剪后的 INSERT 真写进去了,不是被 PG 拒收
# 其余 22 列逐列与提交值相等: 少写两列最容易引发的错是剩下的值整体错位
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
assert [m for m in captured_warnings if "写入失败" in m] == []
assert [m for m in captured_warnings if "补列失败" in m] == []
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次讲清,不逐行刷屏
assert "以下维度不会被记录: tenant_id, meta" in notices[0]
finally:
await recorder.aclose()
async def test_manual_on_a_role_that_cannot_alter_emits_no_backfill_failure(
self, least_privilege_pre_tenant_dsn, captured_warnings
):
"""缺列旧表 + 只授 SELECT/INSERT 的角色 + manual: 补列失败的 warning 彻底消失。
auto 档在这张表上会刷出 `补列失败` 再刷 `写入失败`(见
`test_backfill_failure_degrades_per_row_not_wholesale`)——那是 issue #13 要
消灭的噪声。manual 档下 ALTER 压根不发,取而代之的是一条点名缺列并附可直接
执行的 ALTER 的提示,而遥测照常落库。
"""
recorder = PostgresRecorder(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}'
)
await _record_minimal(recorder, call_id=_cid("manlp2"), cost=2.5)
assert [m for m in captured_warnings if "补列失败" in m] == []
assert [m for m in captured_warnings if "写入失败" in m] == []
assert recorder._failed is False
notices = [m for m in captured_warnings if "auto_migrate=False" in m]
assert len(notices) == 1 # 准备期一次,第二行不再重复
assert "以下维度不会被记录: tenant_id, meta" in notices[0]
# 提示里的 SQL 必须可直接粘贴执行,而不是只报个列名
assert (
"ALTER TABLE llm_calls ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';" in notices[0]
)
assert (
"ALTER TABLE llm_calls ADD COLUMN meta JSONB NOT NULL DEFAULT '{}'::jsonb;"
in notices[0]
)
# 该角色无权 ALTER,表必然还是旧形态: 缺的两列确实没被写
cols = await _fetch(
least_privilege_pre_tenant_dsn,
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = current_schema() AND table_name = 'llm_calls' "
"ORDER BY ordinal_position",
)
assert [r["column_name"] for r in cols] == _PRE_TENANT_COLUMNS
names = ", ".join(_PRE_TENANT_WRITTEN_COLUMNS)
rows = await _fetch(
least_privilege_pre_tenant_dsn,
f"SELECT {names} FROM llm_calls WHERE call_id LIKE $1 ORDER BY call_id",
f"{_RUN_PREFIX}-manlp%",
)
assert [r["call_id"] for r in rows] == [_cid("manlp1"), _cid("manlp2")]
assert dict(rows[0]) == {c: recorded[c] for c in _PRE_TENANT_WRITTEN_COLUMNS}
assert rows[1]["cost"] == 2.5
finally:
await recorder.aclose()
_PHYSICAL_COLUMNS_SQL = (
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position"
)
class TestPublishedSchemaScript:
"""issue #13: README 叫下游执行的那份脚本,在真实实例上必须建得出、且可重复执行。
这份脚本是 `telemetry_schema_sql("postgres")` 的输出,manual 档下游拿它建表,
库随后靠列探测决定写哪些列——脚本与 `COLUMNS` 一旦漂移,表现是"照文档建完表,
库仍报缺列"。人工核对不构成回归保护: 改一次 README 或 DDL 就会悄悄失去它。
"""
async def test_script_builds_the_full_table_and_is_rerunnable(self, fresh_schema):
"""空 schema 里执行一遍建出全部物理列;再执行一遍不报错。
第二遍是 `ADD COLUMN IF NOT EXISTS` 的幂等性验收: 去掉 IF NOT EXISTS 后,
建表语句会被 `IF NOT EXISTS` 跳过而补列语句撞上 "column ... already exists",
整段脚本第二次执行即失败——而"可重复执行"正是这份脚本对下游的承诺。
"""
fresh_dsn, schema = fresh_schema
script = telemetry_schema_sql("postgres")
await _execute_script(fresh_dsn, script)
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
# 物理列 = 24 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
assert set(actual) == set(COLUMNS) | {"created_at"}
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
assert actual == _EXPECTED_COLUMNS
await _execute_script(fresh_dsn, script) # 可重复执行: 第二遍不得抛
rerun = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
assert rerun == actual # 且第二遍没有偷偷改动表结构
# ---------------------------------------------------------------------------
# issue #12 Task 4: README 的生产部署 DDL 模板,逐条在真实 PG 上执行
# ---------------------------------------------------------------------------
# 模板 SQL **只有一份**,在 README 里。测试从 README 解析出来跑,而不是在这里另抄
# 一份: 抄一份就是两份会各自漂移的东西,而"README 里的 SQL 能跑"这个承诺恰恰只在
# 同源时才成立(doctest / Rust doc tests / mdbook test 都是这个范式)。
_README = Path(__file__).resolve().parents[2] / "README.md"
# 锚点写成 HTML 注释,渲染时不可见,比按章节标题或代码块序号定位稳固得多。
_TEMPLATE_BLOCK = re.compile(r"<!-- pg-template:([a-z_]+) -->\s*\n```sql\n(.*?)\n```", re.DOTALL)
# 顺序即执行顺序;数量与名字都钉死——解析不到或多出一块必须当场红,
# 绝不能退化成空列表让这条测试变成永远绿的摆设。
_EXPECTED_TEMPLATE_BLOCKS = (
"roles",
"table",
"partition",
"grants",
"immutable",
"rls",
"index",
)
# README 里必须原样保留、由本测试做受控替换的标识符。README 那份是给下游照抄的,
# 故占位符是**合法可执行的具体值**而不是 `<schema>` 之类的尖括号洞。
_TEMPLATE_PLACEHOLDERS = (
"polygateway_owner",
"polygateway_app",
"polygateway_report",
"CHANGE_ME_APP",
"CHANGE_ME_REPORT",
"SCHEMA public",
"llm_calls_2026_01",
"'2026-01-01 00:00:00+00'",
"'2026-02-01 00:00:00+00'",
)
# 应用角色在生产里能发的唯一一类写语句(与库的 INSERT 同形,只列 NOT NULL 列)
_TEMPLATE_INSERT = (
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id) "
"VALUES ($1, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, $2)"
)
def _template_blocks() -> dict[str, str]:
"""从 README 解析带锚点的 SQL 块;顺序即文中出现顺序。"""
return dict(_TEMPLATE_BLOCK.findall(_README.read_text(encoding="utf-8")))
@dataclass(frozen=True)
class _TemplateEnv:
"""模板部署完成后的现场句柄:三个角色各自的连接串 + 当月分区名。"""
admin_dsn: str
app_dsn: str
report_dsn: str
schema: str
partition: str
seeded: tuple[str, str] # (tenant-a 的行, tenant-b 的行)
def _localize(sql: str, schema: str, roles: dict[str, str], month: datetime) -> str:
"""把 README 里给下游照抄的标识符换成本次运行专属的临时对象。
替换规则写在测试里而不是让 README 变得不可直接复制: README 里那份必须是
下游 `pip install` 后照抄就能用的,占位符因此都是合法 SQL 值。
"""
start = month.strftime("%Y-%m-%d %H:%M:%S%z")
end = (month + timedelta(days=32)).replace(day=1).strftime("%Y-%m-%d %H:%M:%S%z")
for placeholder, actual in (
# 长名在前: 三个角色名互不为前缀,但顺序稳定便于排查
("polygateway_owner", roles["owner"]),
("polygateway_report", roles["report"]),
("polygateway_app", roles["app"]),
("CHANGE_ME_APP", _PROBE_PASSWORD),
("CHANGE_ME_REPORT", _PROBE_PASSWORD),
("SCHEMA public", f"SCHEMA {schema}"),
("llm_calls_2026_01", f"llm_calls_{month:%Y_%m}"),
("'2026-01-01 00:00:00+00'", f"'{start}'"),
("'2026-02-01 00:00:00+00'", f"'{end}'"),
):
sql = sql.replace(placeholder, actual)
return sql
def _role_dsn(dsn: str, role: str, schema: str) -> str:
low = re.sub(r"//[^@/]+@", f"//{role}:{_PROBE_PASSWORD}@", dsn, count=1)
return _search_path_dsn(low, schema)
async def _drop_template_objects(dsn: str, schema: str, roles: dict[str, str]) -> None:
"""删净临时 schema 与三个角色(角色是**全局**对象,漏删会跨 run 残留)。"""
import asyncpg
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
for role in roles.values():
await admin.execute(f"DROP OWNED BY {role}")
await admin.execute(f"DROP ROLE IF EXISTS {role}")
finally:
await admin.close()
@pytest.fixture
async def production_template(dsn):
"""在临时 schema + 临时角色上跑完 README 的整套模板,产出可用的三条连接串。
隔离纪律(M4 事故教训)同 `least_privilege_dsn`: 共享的 `public.llm_calls`
一个字节都不碰,建的 schema / 角色 / 函数 / 分区在 teardown 里删净。
"""
import asyncpg
suffix = uuid4().hex[:8]
schema = f"pgwtpl_{suffix}"
roles = {
"owner": f"pgwtpl_owner_{suffix}",
"app": f"pgwtpl_app_{suffix}",
"report": f"pgwtpl_report_{suffix}",
}
month = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
blocks = _template_blocks()
# 解析不到就地红: 空 dict 会让下面的 for 一句不执行,测试变成"只验证了能连上库"
assert list(blocks) == list(_EXPECTED_TEMPLATE_BLOCKS), (
f"README 的模板锚点与预期不符: {list(blocks)}"
)
seeded = (_cid("tpl-a"), _cid("tpl-b"))
admin_dsn = _search_path_dsn(dsn, schema)
admin = await asyncpg.connect(dsn, timeout=10)
# 权限门放在建任何对象**之前**: `pytest.skip` 抛的是 BaseException,
# 若它在下面的清理块内触发,清理会去 DROP 从未建过的角色而把 skip 盖掉
can_create = await admin.fetchval(
"SELECT rolcreaterole OR rolsuper FROM pg_roles WHERE rolname = current_user"
)
if not can_create:
await admin.close()
pytest.skip("当前账号无权建临时角色,跳过生产模板用例")
try:
await admin.execute(f"CREATE SCHEMA {schema}")
await admin.execute(f"SET search_path = {schema}")
# README §2 写明的前置步骤: 先用库自带脚本建出普通表当模子
await admin.execute(telemetry_schema_sql("postgres"))
for name in _EXPECTED_TEMPLATE_BLOCKS:
await admin.execute(_localize(blocks[name], schema, roles, month))
# 种两个租户的行(超级用户绕过 RLS,属于布景不属于被测行为)
for call_id, tenant in zip(seeded, ("tenant-a", "tenant-b"), strict=True):
await admin.execute(_TEMPLATE_INSERT, call_id, tenant)
except BaseException:
# 模板 SQL 出错时也必须删净: 建到一半的 schema 会残留一张 llm_calls,
# 而 `TestSchema` 那条按 table_name 查 information_schema 的用例不带
# schema 过滤,会被残留物在**下一次运行**里以列数不符的形态误伤
await admin.close()
await _drop_template_objects(dsn, schema, roles)
raise
finally:
if not admin.is_closed():
await admin.close()
yield _TemplateEnv(
admin_dsn=admin_dsn,
app_dsn=_role_dsn(dsn, roles["app"], schema),
report_dsn=_role_dsn(dsn, roles["report"], schema),
schema=schema,
partition=f"llm_calls_{month:%Y_%m}",
seeded=seeded,
)
admin = await asyncpg.connect(dsn, timeout=10)
try:
await admin.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
for role in roles.values():
await admin.execute(f"DROP OWNED BY {role}")
await admin.execute(f"DROP ROLE IF EXISTS {role}")
finally:
await admin.close()
class TestProductionTemplate:
"""issue #12: README 的生产部署 DDL 模板必须逐条可执行,且行为与文中描述一致。
模板出错的代价全部落在下游身上(照抄就中招),而人工核对不构成回归保护——
改一次 README 就会悄悄失去它。故这里从 README **直接解析** SQL 来执行。
"""
def test_readme_exposes_exactly_the_expected_template_blocks(self):
"""先钉死解析本身: 锚点没了、改名了、块数变了,这条当场红。
没有它,`production_template` 里解析出空 dict 时下面每条用例都会以
"表不存在"之类的间接形态失败,真因(README 结构变了)要靠猜。
"""
blocks = _template_blocks()
assert list(blocks) == list(_EXPECTED_TEMPLATE_BLOCKS)
assert all(sql.strip() for sql in blocks.values())
joined = "\n".join(blocks.values())
for placeholder in _TEMPLATE_PLACEHOLDERS:
# 占位符没了 = 受控替换静默失效,测试会去打真实的 polygateway_* 角色
assert placeholder in joined, f"README 模板缺占位符 {placeholder!r}"
async def test_app_can_insert_but_cannot_mutate(self, production_template):
"""应用角色: INSERT 通过,UPDATE / DELETE 被权限层拒绝(不是被触发器拒)。
权限检查早于行级触发器,故这里拿到的必须是 InsufficientPrivilegeError——
若换成触发器的 RaiseError,说明 REVOKE 那一块没生效,而"不可变"就只剩
一层属主随手可关的兜底。
"""
import asyncpg
env = production_template
conn = await asyncpg.connect(env.app_dsn, timeout=10)
try:
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-app"), "tenant-a")
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", _cid("tpl-app"))
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute("UPDATE llm_calls SET response = 'x'")
finally:
await conn.close()
rows = await _fetch(
env.admin_dsn, "SELECT call_id FROM llm_calls WHERE call_id = $1", _cid("tpl-app")
)
assert [r["call_id"] for r in rows] == [_cid("tpl-app")] # 写入真落库了
async def test_report_can_read_but_cannot_write(self, production_template):
"""报表角色: 带租户上下文读得到自己的行,任何写入都被拒。"""
import asyncpg
env = production_template
conn = await asyncpg.connect(env.report_dsn, timeout=10)
try:
with pytest.raises(asyncpg.exceptions.InsufficientPrivilegeError):
await conn.execute(_TEMPLATE_INSERT, _cid("tpl-rpt"), "tenant-a")
async with conn.transaction():
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-a', true)")
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(env.seeded[0], "tenant-a")]
finally:
await conn.close()
async def test_reads_are_fail_closed_until_the_tenant_guc_is_set(self, production_template):
"""未设 `app.tenant_id` → 零行(fail-closed);设了 → 只看得到本租户。
两个断言缺一不可: 只验"设了能看到自己的"漏掉了 GUC 未设时全表泄露,
只验"未设是零行"则一条永远返回 false 的 policy 也能通过。
"""
import asyncpg
env = production_template
conn = await asyncpg.connect(env.app_dsn, timeout=10)
try:
async with conn.transaction():
assert await conn.fetch("SELECT call_id FROM llm_calls") == []
async with conn.transaction():
await conn.execute("SELECT set_config('app.tenant_id', 'tenant-b', true)")
rows = await conn.fetch("SELECT call_id, tenant_id FROM llm_calls")
assert [(r["call_id"], r["tenant_id"]) for r in rows] == [(env.seeded[1], "tenant-b")]
finally:
await conn.close()
async def test_rows_land_in_the_current_month_partition(self, production_template):
"""分区表写入成功,且行确实落进当月分区(不是落进某个兜底分区)。"""
env = production_template
rows = await _fetch(
env.admin_dsn,
"SELECT tableoid::regclass::text AS part FROM llm_calls WHERE call_id = $1",
env.seeded[0],
)
assert [r["part"].split(".")[-1] for r in rows] == [env.partition]
async def test_trigger_blocks_delete_while_drop_partition_still_works(
self, production_template
):
"""兜底触发器拦得住 DELETE(连超级用户也拦),却拦不住 DROP PARTITION。
这正是 README 说"清理只能走 DROP PARTITION 而不是 DELETE"的机械化依据:
既要对应用角色 REVOKE DELETE、又要能清理过期数据,分区是唯一不冲突的解。
"""
import asyncpg
env = production_template
conn = await asyncpg.connect(env.admin_dsn, timeout=10)
try:
with pytest.raises(asyncpg.exceptions.RaiseError) as exc:
await conn.execute("DELETE FROM llm_calls WHERE call_id = $1", env.seeded[0])
assert "不可变审计表" in str(exc.value)
await conn.execute(f"ALTER TABLE llm_calls DETACH PARTITION {env.partition}")
await conn.execute(f"DROP TABLE {env.partition}")
assert await conn.fetchval("SELECT count(*) FROM llm_calls") == 0
finally:
await conn.close()
@@ -327,3 +327,49 @@ async def test_variant_probe_rate_limited_releases_not_hangs(redis_client):
assert update.applied assert update.applied
nxt = await gate.try_enter("s1", "w2") nxt = await gate.try_enter("s1", "w2")
assert nxt.allowed and nxt.is_probe # 立即可再探,不等 probe_ttl assert nxt.allowed and nxt.is_probe # 立即可再探,不等 probe_ttl
# —— issue #14: retry_after_s = 距离**确定**可再试的时刻,HALF_OPEN 无确定时刻 ——
@pytestmark_slow
async def test_variant_half_open_rejection_reports_no_certain_wait(redis_client):
gate = _gate(redis_client)
await _open_gate(gate)
await asyncio.sleep(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
assert probe.is_probe
blocked = await gate.try_enter("s1", "w2")
assert not blocked.allowed and blocked.state is GateState.HALF_OPEN
assert blocked.retry_after_s == 0.0
@pytestmark_slow
async def test_variant_probe_grant_reports_no_certain_wait(redis_client):
gate = _gate(redis_client)
await _open_gate(gate)
await asyncio.sleep(_CFG.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
assert probe.allowed and probe.is_probe
assert probe.retry_after_s == 0.0
@pytestmark_slow
async def test_variant_retry_after_zero_while_probe_in_flight(redis_client):
gate = _gate(redis_client)
await _open_gate(gate)
await asyncio.sleep(_CFG.cooldown_s + 1)
assert (await gate.try_enter("s1", "w1")).is_probe
assert await gate.retry_after_s(("s1",)) == 0.0
@pytestmark_slow
async def test_variant_fenced_write_in_half_open_reports_no_certain_wait(redis_client):
gate = _gate(redis_client)
stale = await gate.try_enter("s1", "slow-worker") # epoch 0 的旧 entry
await _open_gate(gate) # 他人开路,epoch 推进
await asyncio.sleep(_CFG.cooldown_s + 1)
assert (await gate.try_enter("s1", "w1")).is_probe # 门此刻 HALF_OPEN
update = await gate.record_success(stale)
assert not update.applied and update.state is GateState.HALF_OPEN
assert update.retry_after_s == 0.0
+298
View File
@@ -0,0 +1,298 @@
"""`tools/telemetry_retention.py` 的 PostgreSQL 分支测试(issue #12 Task 3,真实 PG)。
DSN 走 .env `PGW_TELEMETRY_PG_DSN`,缺则 skip。
隔离纪律(M4 事故教训): `public.llm_calls` 是与真实批跑共享的表,而本测试跑的是
一个**会删数据的脚本**——一律在自建的临时 schema 里操作(DSN 挂 search_path),
teardown 只 `DROP SCHEMA ... CASCADE`;分批删除那例另行断言 `public.llm_calls`
的行数前后不变,把"search_path 没生效"这种最坏情况钉成红灯而不是静默删库。
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from dotenv import dotenv_values
from polygateway.telemetry.schema import PG_DDL
_ROOT = Path(__file__).resolve().parents[2]
_SCRIPT = _ROOT / "tools" / "telemetry_retention.py"
_INSERT = (
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id, created_at) "
"VALUES ($1, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, $2, $3)"
)
def _partitioned_ddl() -> str:
"""由库的真实 `PG_DDL` 派生一份 RANGE 分区版建表语句。
不另抄一份 DDL: 抄的那份与库的 schema 必然漂移,而漂移后本测试验的就不再是
"库建的表被做成分区后脚本认不认得"。两处改动都是分区表的**硬性要求**——
分区表上的唯一约束必须包含分区键,故 `call_id` 单列主键不再合法。
"""
body, count = re.subn(
r"call_id(\s+)TEXT PRIMARY KEY", r"call_id\1TEXT NOT NULL", PG_DDL, count=1
)
if count != 1:
raise AssertionError("PG_DDL 的 call_id 主键声明形态已变,分区版 DDL 需同步")
body = body.strip().rstrip(";").strip()
if not body.endswith(")"):
raise AssertionError("PG_DDL 结尾形态已变,分区版 DDL 需同步")
return (
f"{body[:-1].rstrip()},\n"
" PRIMARY KEY (call_id, created_at)\n"
") PARTITION BY RANGE (created_at)"
)
def _dsn_value() -> str | None:
merged = {**dotenv_values(".env"), **os.environ}
raw = merged.get("PGW_TELEMETRY_PG_DSN")
if not raw:
return None
scheme, sep, rest = raw.partition("://")
return f"{scheme.partition('+')[0]}{sep}{rest}"
def _search_path_dsn(dsn: str, schema: str) -> str:
sep = "&" if "?" in dsn else "?"
return f"{dsn}{sep}options=-csearch_path%3D{schema}"
def _stamp(delta: timedelta) -> datetime:
return datetime.now(UTC) + delta
def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(_SCRIPT), *args],
capture_output=True,
text=True,
cwd=_ROOT,
env=env,
timeout=120,
)
@pytest.fixture
async def dsn():
value = _dsn_value()
if value is None:
pytest.skip("PGW_TELEMETRY_PG_DSN 未配置")
# 隔离守卫: 该实例有 app/chs_prod 等在用库,只许打 polygateway 专用库
if not value.rstrip("/").endswith("/polygateway"):
pytest.fail(f"保留期脚本测试只允许连 polygateway 专用库,当前 DSN 库名不符: {value!r}")
return value
async def _make_schema(dsn_value: str, prefix: str, ddl: str, extra: tuple[str, ...] = ()) -> str:
import asyncpg
name = f"pgwret_{prefix}_{uuid4().hex[:8]}"
conn = await asyncpg.connect(dsn_value, timeout=10)
try:
await conn.execute(f"CREATE SCHEMA {name}")
await conn.execute(f"SET search_path = {name}")
await conn.execute(ddl)
for statement in extra:
await conn.execute(statement)
finally:
await conn.close()
return name
async def _drop_schema(dsn_value: str, name: str) -> None:
import asyncpg
conn = await asyncpg.connect(dsn_value, timeout=10)
try:
await conn.execute(f"DROP SCHEMA {name} CASCADE")
finally:
await conn.close()
async def _seed(schema_dsn: str, rows: list[tuple[str, str, datetime]]) -> None:
import asyncpg
conn = await asyncpg.connect(schema_dsn, timeout=10)
try:
await conn.executemany(_INSERT, rows)
finally:
await conn.close()
async def _call_ids(schema_dsn: str) -> list[str]:
import asyncpg
conn = await asyncpg.connect(schema_dsn, timeout=10)
try:
rows = await conn.fetch("SELECT call_id FROM llm_calls ORDER BY call_id")
finally:
await conn.close()
return [r["call_id"] for r in rows]
async def _public_count(dsn_value: str) -> int:
"""共享表的行数;本测试全程不得让它变动一行。"""
import asyncpg
conn = await asyncpg.connect(dsn_value, timeout=10)
try:
if await conn.fetchval("SELECT to_regclass('public.llm_calls')") is None:
return -1
return await conn.fetchval("SELECT COUNT(*) FROM public.llm_calls")
finally:
await conn.close()
@pytest.fixture
async def partitioned_schema(dsn):
"""临时 schema 内的**分区表**: 脚本必须认出它并让路给 DROP PARTITION。"""
name = await _make_schema(
dsn,
"part",
_partitioned_ddl(),
extra=(
"CREATE TABLE llm_calls_all PARTITION OF llm_calls "
"FOR VALUES FROM ('2000-01-01') TO ('2100-01-01')",
),
)
yield _search_path_dsn(dsn, name), name
await _drop_schema(dsn, name)
@pytest.fixture
async def plain_schema(dsn):
"""临时 schema 内的普通表: 存量场景,脚本的分批 DELETE 兜底路径。"""
name = await _make_schema(dsn, "plain", PG_DDL)
yield _search_path_dsn(dsn, name), name
await _drop_schema(dsn, name)
class TestPartitionedTarget:
async def test_partitioned_table_exits_three_without_deleting_anything(
self, partitioned_schema
):
schema_dsn, schema = partitioned_schema
await _seed(
schema_dsn,
[
("part-old-1", "", _stamp(timedelta(days=-30))),
("part-old-2", "acme", _stamp(timedelta(days=-20))),
],
)
# 带 --apply 跑: 危险的那条路径必须在真正删之前就被分区探测拦住
result = _run(
"--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7", "--apply"
)
assert result.returncode == 3, (result.stdout, result.stderr)
combined = result.stdout + result.stderr
assert "DROP PARTITION" in combined
assert "DETACH" in combined
assert await _call_ids(schema_dsn) == ["part-old-1", "part-old-2"]
# 脚本必须报出它解析到的**限定表名**: 这是"我删的到底是哪张表"的唯一凭据
assert f"{schema}.llm_calls" in result.stdout
class TestPlainTableBatches:
async def test_apply_deletes_only_expired_rows_in_batches(self, plain_schema, dsn):
schema_dsn, schema = plain_schema
before_public = await _public_count(dsn)
await _seed(
schema_dsn,
[
("old-1", "", _stamp(timedelta(days=-40))),
("old-2", "acme", _stamp(timedelta(days=-30))),
("old-3", "acme", _stamp(timedelta(days=-20))),
("old-4", "acme", _stamp(timedelta(days=-15))),
("old-5", "", _stamp(timedelta(days=-10))),
("fresh-1", "acme", _stamp(timedelta(days=-1))),
("fresh-2", "", _stamp(timedelta(hours=-1))),
],
)
result = _run(
"--backend",
"postgres",
"--dsn",
schema_dsn,
"--older-than-days",
"7",
"--apply",
"--batch-size",
"2",
)
assert result.returncode == 0, (result.stdout, result.stderr)
assert await _call_ids(schema_dsn) == ["fresh-1", "fresh-2"]
assert f"{schema}.llm_calls" in result.stdout
assert "将删除行数: 5" in result.stdout
assert "'acme': 3" in result.stdout
# 5 行 / 每批 2 行 = 3 批,每批各自提交;批次行必须真的出现三条
assert "批次 1" in result.stdout
assert "批次 3" in result.stdout
assert "批次 4" not in result.stdout
assert "已删除 5 行" in result.stdout
assert await _public_count(dsn) == before_public
async def test_dry_run_on_a_plain_table_deletes_nothing(self, plain_schema):
schema_dsn, _ = plain_schema
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
result = _run("--backend", "postgres", "--dsn", schema_dsn, "--older-than-days", "7")
assert result.returncode == 0, (result.stdout, result.stderr)
assert "将删除行数: 1" in result.stdout
assert "dry-run" in result.stdout
assert await _call_ids(schema_dsn) == ["old-1"]
class TestMissingAsyncpg:
async def test_missing_asyncpg_exits_two_without_touching_rows(self, plain_schema, tmp_path):
"""缺 asyncpg 必须明确报错退出(码 2),不静默降级——这是运维工具不是库路径。
用一个只 `raise ImportError` 的临时 `asyncpg.py` 挂进子进程的 PYTHONPATH 构造该
场景: 脚本跑在子进程里,monkeypatch 对它无效。DSN 用**真实可连**的临时 schema,
这样"没有导入守卫"的实现会走通并退出 0,而不是碰巧也退出 2 而假绿。
"""
schema_dsn, _ = plain_schema
await _seed(schema_dsn, [("old-1", "acme", _stamp(timedelta(days=-30)))])
stub = tmp_path / "stub"
stub.mkdir()
(stub / "asyncpg.py").write_text(
'raise ImportError("asyncpg 未安装(测试构造)")\n', encoding="utf-8"
)
env = {
**os.environ,
"PYTHONPATH": os.pathsep.join(
[str(stub), *([p] if (p := os.environ.get("PYTHONPATH")) else [])]
),
}
result = _run(
"--backend",
"postgres",
"--dsn",
schema_dsn,
"--older-than-days",
"7",
"--apply",
env=env,
)
assert result.returncode == 2, (result.stdout, result.stderr)
assert "asyncpg" in result.stderr
assert "pip install" in result.stderr
assert await _call_ids(schema_dsn) == ["old-1"]
+204 -6
View File
@@ -13,8 +13,10 @@ from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
CircuitOpenError,
GatewayUnavailableError, GatewayUnavailableError,
GovernanceBackendError, GovernanceBackendError,
SourceDeadError,
SourceNotConfiguredError, SourceNotConfiguredError,
TransientError, TransientError,
) )
@@ -62,6 +64,7 @@ def _mw(
sleep, sleep,
rng=lambda: 0.0, rng=lambda: 0.0,
quota_full="wait", quota_full="wait",
circuit_open="fail_fast",
gate=None, gate=None,
transport=None, transport=None,
emitter=None, emitter=None,
@@ -76,6 +79,7 @@ def _mw(
retry=RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0), retry=RetryPolicy(max_attempts=3, backoff_base_s=2.0, backoff_max_s=30.0),
backpressure=BackpressurePolicy(stall_window_s=_STALL, poll_interval_s=0.01), backpressure=BackpressurePolicy(stall_window_s=_STALL, poll_interval_s=0.01),
quota_full=quota_full, quota_full=quota_full,
circuit_open=circuit_open,
cooldown_memo=SourceCooldownMemo(now=clock), cooldown_memo=SourceCooldownMemo(now=clock),
emitter=emitter, emitter=emitter,
now=clock, now=clock,
@@ -321,9 +325,7 @@ class TestStallBudget:
async def advance(_n): async def advance(_n):
clock.advance(_STALL) clock.advance(_STALL)
mw = _mw( mw = _mw([src], limiter, [], clock=clock, sleep=BoundedSleep(advance), transport=transport)
[src], limiter, [], clock=clock, sleep=BoundedSleep(advance), transport=transport
)
with pytest.raises(AllSourcesExhausted) as ei: with pytest.raises(AllSourcesExhausted) as ei:
await mw(_REQ) await mw(_REQ)
assert ei.value.reason == "stalled" # 不是 retry_exhausted: 429 确实没烧重试预算 assert ei.value.reason == "stalled" # 不是 retry_exhausted: 429 确实没烧重试预算
@@ -541,9 +543,7 @@ class TestUnknownSourceIsAssemblyDefect:
""" """
src = make_source("s1") src = make_source("s1")
# 限流后端的源名单与治理循环拿到的源对不上 = 装配缺陷 # 限流后端的源名单与治理循环拿到的源对不上 = 装配缺陷
limiter = InMemoryLimiter( limiter = InMemoryLimiter(scope="llm", sources={"other": src}, global_limits=_NO_GLOBAL)
scope="llm", sources={"other": src}, global_limits=_NO_GLOBAL
)
gate = QuotaGate(limiter, scope="llm") gate = QuotaGate(limiter, scope="llm")
with pytest.raises(SourceNotConfiguredError) as ei: with pytest.raises(SourceNotConfiguredError) as ei:
await getattr(gate, method)(src) await getattr(gate, method)(src)
@@ -597,3 +597,201 @@ class TestGateFailuresReachCallersAsScopeLevel:
await QuotaGate(_Broken(), scope="LLM").progress_age_s() await QuotaGate(_Broken(), scope="LLM").progress_age_s()
assert ei.value.scope == "llm" assert ei.value.scope == "llm"
assert ei.value.reason == "governance_backend_down" assert ei.value.reason == "governance_backend_down"
class TestCircuitOpenPolicy:
"""issue #14: 熔断全拒时是当场判死还是等冷却过去。
缺省 fail_fast 即历史行为(TestStallQuadrants 等既有用例照旧覆盖);
本类钉的是 wait 档,以及两条策略互不串线。
"""
@staticmethod
async def _opened_gate(clock, cfg=_BREAKER):
gate = InMemoryGate(config=cfg, now=clock)
for _ in range(cfg.fail_threshold):
entry = await gate.try_enter("s1", "w")
await gate.record_failure(entry, "network_error", False)
return gate
@staticmethod
def _free_limiter(clock, src):
return InMemoryLimiter(
scope="llm",
sources={"s1": src},
global_limits=_NO_GLOBAL,
lease_ttl_s=10_000.0,
now=clock,
)
async def test_fail_fast_is_the_default(self):
"""缺省档逐字保持历史行为: 全源开路当场抛 CircuitOpenError。"""
clock = FakeClock()
src = make_source()
mw = _mw(
[src],
self._free_limiter(clock, src),
[],
clock=clock,
sleep=BoundedSleep(),
gate=await self._opened_gate(clock),
)
with pytest.raises(CircuitOpenError) as ei:
await mw(_REQ)
assert ei.value.reason == "circuit_open"
async def test_wait_sleeps_out_the_cooldown_instead_of_dying(self):
"""wait 档: 睡到冷却结束再来一轮,拿到探针后正常返回。
睡的是**冷却剩余**而不是 poll_interval——60 秒冷却用 10ms 轮询要空转
6000 次,memory 后端只是查字典,Redis 后端则是 6000 次往返 × 每个在途调用。
"""
clock = FakeClock()
src = make_source()
sleep = BoundedSleep()
async def advance(_n):
clock.advance(sleep.delays[-1])
sleep._side_effect = advance
mw = _mw(
[src],
self._free_limiter(clock, src),
[_ok()],
clock=clock,
sleep=sleep,
gate=await self._opened_gate(clock),
circuit_open="wait",
)
resp = await mw(_REQ)
assert resp.content == "ok"
# 一觉睡到冷却结束(jitter 上加,rng=0 → +0.5×poll),不是 poll 空转
assert sleep.delays[0] == pytest.approx(_BREAKER.cooldown_s + 0.005)
async def test_wait_does_not_leak_into_the_quota_branch(self):
"""两条策略互不串线: circuit_open=wait 配 quota_full=fail_fast 时,
熔断等待**不得**被当成配额耗尽上报——串线会让调用方拿到一个
reason=quota_exhausted 的异常,而配额其实是满的。"""
clock = FakeClock()
src = make_source()
sleep = BoundedSleep()
async def advance(_n):
clock.advance(sleep.delays[-1])
sleep._side_effect = advance
mw = _mw(
[src],
self._free_limiter(clock, src),
[_ok()],
clock=clock,
sleep=sleep,
gate=await self._opened_gate(clock),
quota_full="fail_fast",
circuit_open="wait",
)
assert (await mw(_REQ)).content == "ok"
async def test_wait_still_dies_when_cooldown_outlasts_the_stall_budget(self):
"""等待有可解释的上界: 冷却比 stall 预算还长时,在窗口耗尽处判死。
单次睡眠夹到剩余 stall 预算,故最坏墙钟 = stall_window + 一个 poll,
不随 max_cooldown_s 漂移。
"""
clock = FakeClock()
src = make_source()
long_cooldown = BreakerConfig(
fail_threshold=3, cooldown_s=1000.0, probe_ttl_s=2000.0, max_cooldown_s=1000.0
)
sleep = BoundedSleep()
async def advance(_n):
clock.advance(sleep.delays[-1])
sleep._side_effect = advance
mw = _mw(
[src],
self._free_limiter(clock, src),
[],
clock=clock,
sleep=sleep,
gate=await self._opened_gate(clock, long_cooldown),
circuit_open="wait",
)
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_REQ)
assert ei.value.reason == "stalled"
assert ei.value.per_source_reasons == {"s1": "circuit_open"}
assert sleep.delays[0] == pytest.approx(_STALL + 0.01) # 夹到预算 + 一个 poll
async def test_wait_loop_stays_cancellable(self):
"""取消穿透(铁律): 熔断等待中的取消不得被吞。"""
clock = FakeClock()
src = make_source()
mw = _mw(
[src],
self._free_limiter(clock, src),
[],
clock=clock,
sleep=asyncio.sleep,
gate=await self._opened_gate(clock),
circuit_open="wait",
)
task = asyncio.create_task(mw(_REQ))
await asyncio.sleep(0.03)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
async def test_wait_does_not_exempt_probes_from_the_retry_budget(self):
"""wait 档不豁免重试预算: 探针是**真实尝试**,失败照样烧 max_attempts。
故 force_open 的源(401/403/欠费一击即熔,不看任何阈值)在 wait 档下并
**不是**"等满 stall 窗口才死"——两个预算哪个先耗尽就以哪个的 reason
失败。这里 max_attempts=3 而冷却只累计 120s < stall_window=300s,故
先到的是重试预算。参数换成"冷却累计超过 stall 预算"则先到 stalled
(见 test_wait_still_dies_when_cooldown_outlasts_the_stall_budget)。
这与 issue #8 确立的划分一致: 划分依据是"谁消耗重试预算",探针发出了
真实请求,理应记在重试预算上而不是 stall 账上。
"""
clock = FakeClock()
src = make_source()
sleep = BoundedSleep()
async def advance(_n):
clock.advance(sleep.delays[-1])
sleep._side_effect = advance
mw = _mw(
[src],
self._free_limiter(clock, src),
[SourceDeadError("401"), SourceDeadError("401"), SourceDeadError("401")],
clock=clock,
sleep=sleep,
circuit_open="wait",
)
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_REQ)
assert ei.value.reason == "retry_exhausted"
assert clock.t - 1000.0 < _STALL # 远未等满 stall 窗口
async def test_half_open_rejection_does_not_blacklist_a_recovered_source(self):
"""issue #14 §1.3 回归: 探针成功后本进程立即可再选该源。
此前 HALF_OPEN 拒绝把探针租约(派生自 2 × timeout,现场 600s)写进冷却
备忘,而 `set_until` 取更晚者、不可回退——门恢复 CLOSED 之后本进程仍
跳过该源整整一个租约,单源下每次调用照旧判死。多源部署同样中招,只是
被别的源接住流量掩盖了。
"""
clock = FakeClock()
cfg = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=600.0)
gate = await self._opened_gate(clock, cfg)
memo = SourceCooldownMemo(now=clock)
clock.advance(cfg.cooldown_s + 1)
probe = await gate.try_enter("s1", "w1")
blocked = await gate.try_enter("s1", "w2") # 并发调用撞上在途探针
assert not blocked.allowed
memo.set_until("s1", clock() + blocked.retry_after_s) # 准入路径的写法
await gate.record_success(probe) # 探针成功 → 门恢复 CLOSED
assert not memo.active("s1")
+50 -1
View File
@@ -9,7 +9,8 @@ import pytest
from polygateway.backends.memory.cache import InMemoryCache from polygateway.backends.memory.cache import InMemoryCache
from polygateway.errors import ResultInvalidError, TransientError from polygateway.errors import ResultInvalidError, TransientError
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
from polygateway.types import ChatRequest, LLMResponse from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.types import ChatRequest, LLMResponse, SourceConfig
_MSGS = [{"role": "user", "content": "hi"}] _MSGS = [{"role": "user", "content": "hi"}]
@@ -327,3 +328,51 @@ class TestStructuredRehydration:
key = build_cache_key("m", _MSGS, "proj", None) key = build_cache_key("m", _MSGS, "proj", None)
raw = await backend.get(key) raw = await backend.get(key)
assert raw is not None and "structured_data" not in json.loads(raw) assert raw is not None and "structured_data" not in json.loads(raw)
class TestTelemetryCapDoesNotPoisonTheCacheKey:
"""红线之一(issue #12): 遥测截断绝不能改到缓存 key。
`digest_messages` 对 content 非 list 的消息**原样透传同一个 dict 对象**
(本文件上方公式测试依赖的也是这份对象),遥测拿到的与算 key 用的是同一份。
就地截断会让同一组 messages 在遥测前后算出两个不同的 key——全量 miss、
且没有任何报错。故这里测的是"截断没有就地改掉调用方的对象",不只是
"截断函数是纯的"
"""
class _Rows:
def __init__(self):
self.rows = []
async def record_llm_call(self, **fields):
self.rows.append(fields)
async def test_key_is_byte_identical_across_a_capped_emit(self):
messages = [
{"role": "user", "content": "合同正文" * 31},
{"role": "user", "content": [{"type": "text", "text": "标书正文" * 30}]},
]
before = build_cache_key("m", messages, "proj", None)
rec = self._Rows()
await TelemetryEmitter(rec, text_cap=8).emit_attempt(
request=ChatRequest(messages=messages),
source=SourceConfig(
name="s1",
provider="p",
base_url="https://gw.example/v1",
api_key="sk",
model="m",
timeout_s=10.0,
),
call_id="c",
latency_ms=1,
response=_resp(),
error=None,
)
# 截断确实发生了(否则本用例恒真)
logged = json.loads(rec.rows[0]["messages"])
assert "(略 116 字)" in logged[0]["content"]
assert "(略 112 字)" in logged[1]["content"][0]["text"]
assert build_cache_key("m", messages, "proj", None) == before
+91
View File
@@ -351,6 +351,97 @@ class TestFactories:
assert isinstance(client, GatewayClient) assert isinstance(client, GatewayClient)
class TestTelemetryTextCapWiring:
"""`PGW_TELEMETRY_TEXT_CAP` 必须走通全部三条 `from_settings` 装配路(issue #12)。
三条链路写的是**同一张** `llm_calls` 表:只接通 chat,embed 与 OCR 的行就
永远不受 cap 约束,同表内一半受控一半不受控——那正是本 issue 要消灭的状态。
"""
_CAP_ENV = dict(_ENV, PGW_TELEMETRY_TEXT_CAP="8")
_OCR_CAP_ENV = {
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
"OCR__MONKEY__1__API_KEY": "none",
"OCR__MONKEY__1__MODEL": "monkey-ocr",
"OCR__MONKEY__1__TIMEOUT_S": "120",
"LLM_MAX_RETRIES": "3",
"LLM_RETRY_BASE_DELAY": "2.0",
"LLM_RETRY_MAX_DELAY": "30.0",
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
"PGW_TELEMETRY_TEXT_CAP": "8",
}
def test_gateway_from_settings_wires_the_cap(self):
settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
client = GatewayClient.from_settings(settings, telemetry=_MemoryRecorder())
assert client._terminal._emitter._text_cap == 8
# 对照组: 不设该键时 emitter 拿到的必须是 None,否则 8 可能是硬编码来的
unset = GatewayClient.from_settings(
GatewaySettings.from_env("LLM", env=_ENV), telemetry=_MemoryRecorder()
)
assert unset._terminal._emitter._text_cap is None
def test_embedding_from_settings_wires_the_cap(self):
from polygateway.config import EmbeddingSettings
from polygateway.embedding import EmbeddingClient
gateway = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
client = EmbeddingClient.from_settings(
EmbeddingSettings(gateway=gateway, batch_size=2), telemetry=_MemoryRecorder()
)
assert client._emitter._text_cap == 8
unset = EmbeddingClient.from_settings(
EmbeddingSettings(gateway=GatewaySettings.from_env("LLM", env=_ENV), batch_size=2),
telemetry=_MemoryRecorder(),
)
assert unset._emitter._text_cap is None
def test_ocr_from_settings_wires_the_cap(self):
from polygateway.config import OcrSettings
from polygateway.ocr import OcrClient
settings = OcrSettings.from_env("OCR", env=dict(self._OCR_CAP_ENV))
client = OcrClient.from_settings(settings, telemetry=_MemoryRecorder())
assert client._emitter._text_cap == 8
no_cap = dict(self._OCR_CAP_ENV)
no_cap.pop("PGW_TELEMETRY_TEXT_CAP")
unset = OcrClient.from_settings(
OcrSettings.from_env("OCR", env=no_cap), telemetry=_MemoryRecorder()
)
assert unset._emitter._text_cap is None
async def test_capped_body_reaches_the_recorder_end_to_end(self, monkeypatch):
"""装配路通了还不够: 真跑一次 chat,落库的 messages 与 response 确已截断。
`from_settings` 自建 transport(没有 client_factory 入口),故在装配点
换掉该类以接上 MockTransport——洋葱其余各层仍是 `from_settings` 装的真件。
"""
recorder = _MemoryRecorder()
long_text = "甲乙丙丁戊己庚辛壬癸" # 10 字,cap=8 → 略 2 字
monkeypatch.setattr(
"polygateway.client.OpenAICompatTransport",
lambda **kwargs: OpenAICompatTransport(
client_factory=lambda source: httpx.AsyncClient(
transport=httpx.MockTransport(lambda request: _sse(content=long_text))
)
),
)
settings = GatewaySettings.from_env("LLM", env=self._CAP_ENV)
async with GatewayClient.from_settings(settings, telemetry=recorder) as client:
await client.chat([{"role": "user", "content": long_text}])
row = recorder.rows[-1]
assert json.loads(row["messages"])[0]["content"] == "甲乙丙丁戊己庚辛…(略 2 字)"
assert row["response"] == "甲乙丙丁戊己庚辛…(略 2 字)"
def test_non_positive_cap_rejected_on_the_direct_construction_path(self):
"""直接构造是库承诺的另一条公共装配路;cap=0 会让每条正文只剩省略标记。"""
with pytest.raises(ValueError, match="text_cap"):
_client(telemetry=_MemoryRecorder(), text_cap=0)
class TestSharedBackend: class TestSharedBackend:
async def test_two_clients_share_global_concurrency_gate(self): async def test_two_clients_share_global_concurrency_gate(self):
"""VT R5: 两个逻辑角色显式注入同一 limiter → 共享全局并发闸。""" """VT R5: 两个逻辑角色显式注入同一 limiter → 共享全局并发闸。"""
+111
View File
@@ -170,6 +170,18 @@ class TestResilienceKeys:
with pytest.raises(ValueError, match="probe"): with pytest.raises(ValueError, match="probe"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "45"})) GatewaySettings.from_env("LLM", env=_env(**{"LLM__BREAKER__PROBE_TTL_S": "45"}))
def test_circuit_open_defaults_to_fail_fast(self):
"""issue #14: 熔断拒绝的处置策略。
缺省**不跟随** quota_full 的 wait——把最坏墙钟从毫秒抬到 stall 窗口
"快速失败 → 长时间挂起"这个最危险的方向,不能强加给存量下游。
"""
assert GatewaySettings.from_env("LLM", env=_env()).circuit_open == "fail_fast"
waiting = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "wait"}))
assert waiting.circuit_open == "wait"
with pytest.raises(ValueError, match="CIRCUIT_OPEN"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__CIRCUIT_OPEN": "block"}))
def test_selector_and_quota_full(self): def test_selector_and_quota_full(self):
# M2.5: 缺省选源改 health_aware(生产级默认);显式配置者不变 # M2.5: 缺省选源改 health_aware(生产级默认);显式配置者不变
s = GatewaySettings.from_env("LLM", env=_env()) s = GatewaySettings.from_env("LLM", env=_env())
@@ -331,6 +343,88 @@ class TestAssemblyGuards:
assert GatewaySettings.from_env("LLM", env=env_ok).backpressure.stall_window_s == 60.0 assert GatewaySettings.from_env("LLM", env=env_ok).backpressure.stall_window_s == 60.0
class TestTelemetrySchemaMode:
"""PGW_TELEMETRY_SCHEMA_MODE 三态(issue #13 设计 §4.1)。
键未设时按后端**不对称**派生: SQLite 是下游自己的本地文件(没有 DBA、
没有迁移工具、没有第二个系统碰它),补列是毫秒级元数据操作,故默认 auto;
PG 是共享生产表,ALTER 取 ACCESS EXCLUSIVE 锁会阻塞该表其后的所有查询,
而遥测是业务路径上的内联 await,故默认 manual。显式设置两侧都可覆盖——
"可覆盖"正是三态相对两态多出来的那一态,派生本身盖不住它。
"""
def _sqlite_env(self, **overrides):
return _env(
PGW_TELEMETRY_BACKEND="sqlite",
PGW_TELEMETRY_SQLITE_PATH="logs/telemetry.db",
**overrides,
)
def _pg_env(self, **overrides):
return _env(
PGW_TELEMETRY_BACKEND="postgres",
PGW_TELEMETRY_PG_DSN="postgresql://u:p@h:5432/polygateway",
**overrides,
)
def test_unset_key_derives_auto_for_sqlite(self):
s = GatewaySettings.from_env("LLM", env=self._sqlite_env())
assert s.telemetry_auto_migrate is True
def test_unset_key_derives_manual_for_postgres(self):
s = GatewaySettings.from_env("LLM", env=self._pg_env())
assert s.telemetry_auto_migrate is False
def test_unset_key_derives_manual_for_none_backend(self):
"""backend=none 无 recorder 消费该字段,派生结果必须是 False 而非 sqlite 那档。"""
s = GatewaySettings.from_env("LLM", env=_env())
assert s.telemetry_auto_migrate is False
def test_explicit_manual_overrides_sqlite_default(self):
s = GatewaySettings.from_env(
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="manual")
)
assert s.telemetry_auto_migrate is False
def test_explicit_auto_overrides_postgres_default(self):
s = GatewaySettings.from_env("LLM", env=self._pg_env(PGW_TELEMETRY_SCHEMA_MODE="auto"))
assert s.telemetry_auto_migrate is True
def test_invalid_mode_rejected_naming_the_env_key(self):
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_SCHEMA_MODE"):
GatewaySettings.from_env(
"LLM", env=self._sqlite_env(PGW_TELEMETRY_SCHEMA_MODE="enabled")
)
class TestTelemetryTextCap:
"""`PGW_TELEMETRY_TEXT_CAP`(issue #12): 二态键,未设即不截断。
与 `PGW_TELEMETRY_SCHEMA_MODE` 的三态不同,这里"未设"本身就是最终答案
(不截断),没有需要按后端派生的第二种缺省,故不走 `_load_choice` 那套。
"""
def test_unset_key_means_no_truncation(self):
"""缺省不截断是人类决策: 截断后的遥测不再是审计证据、无法复现重放。"""
assert GatewaySettings.from_env("LLM", env=_env()).telemetry_text_cap is None
def test_positive_value_is_parsed_as_int(self):
s = GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2000"))
assert s.telemetry_text_cap == 2000
@pytest.mark.parametrize("raw", ["0", "-1"])
def test_non_positive_rejected(self, raw):
"""0 会把每条正文退化成一个省略标记,负数无意义;都不是"不截断"的写法。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP=raw))
def test_non_integer_rejected_naming_the_env_key(self):
"""报错须点出 env 键名: 这条路的调用方看得懂的是键名,不是字段名。"""
with pytest.raises(ValueError, match="PGW_TELEMETRY_TEXT_CAP"):
GatewaySettings.from_env("LLM", env=_env(PGW_TELEMETRY_TEXT_CAP="2k"))
class TestOcrSettings: class TestOcrSettings:
"""M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。""" """M3 OcrSettings(设计 §3.4): 复用 GatewaySettings,无 OCR 专用键。"""
@@ -507,6 +601,7 @@ class TestCrossFieldInvariants:
("telemetry_backend", "redis"), ("telemetry_backend", "redis"),
("selector", "random"), ("selector", "random"),
("quota_full", "block"), ("quota_full", "block"),
("circuit_open", "block"),
], ],
) )
def test_enum_field_rejects_value_outside_domain(self, field, bad_value): def test_enum_field_rejects_value_outside_domain(self, field, bad_value):
@@ -555,8 +650,24 @@ class TestCrossFieldInvariants:
with pytest.raises(ValueError, match="telemetry_pg_dsn"): with pytest.raises(ValueError, match="telemetry_pg_dsn"):
dataclasses.replace(base, telemetry_backend="postgres") dataclasses.replace(base, telemetry_backend="postgres")
def test_none_backend_forces_auto_migrate_off(self):
"""backend=none 时没有 recorder 消费该字段,True 是自相矛盾的状态(issue #13)。
env 路的派生已给出 False,但直接构造与 dataclasses.replace 这两条同等
官方的装配路仍能把 True 传进来——不变量归位到构造期,三条路才一致。
"""
base = self._base() # telemetry_backend="none"
replaced = dataclasses.replace(base, telemetry_auto_migrate=True)
assert replaced.telemetry_auto_migrate is False
# —— 标量域 —— # —— 标量域 ——
def test_non_positive_text_cap_rejected(self):
"""env 路只覆盖 from_env;直接构造与 replace 同样能把 0 传进来(issue #12)。"""
base = self._base()
with pytest.raises(ValueError, match="telemetry_text_cap"):
dataclasses.replace(base, telemetry_text_cap=0)
def test_negative_structured_retries_rejected(self): def test_negative_structured_retries_rejected(self):
base = self._base() base = self._base()
with pytest.raises(ValueError, match="structured_max_retries"): with pytest.raises(ValueError, match="structured_max_retries"):
+1 -1
View File
@@ -113,7 +113,7 @@ async def _recorded_cost(result, source):
source_name=source.name, source_name=source.name,
usage_source=result.usage_source, usage_source=result.usage_source,
) )
await TelemetryEmitter(recorder, pricing=_PRICING).emit_attempt( await TelemetryEmitter(recorder, pricing=_PRICING, text_cap=None).emit_attempt(
request=ChatRequest(messages=[{"role": "user", "content": "hi"}]), request=ChatRequest(messages=[{"role": "user", "content": "hi"}]),
source=source, source=source,
call_id="cid-1", call_id="cid-1",
+12
View File
@@ -25,3 +25,15 @@ def test_ocr_public_surface_exported():
): ):
assert hasattr(polygateway, name), name assert hasattr(polygateway, name), name
assert name in polygateway.__all__, name assert name in polygateway.__all__, name
def test_telemetry_schema_sql_exported():
"""issue #13: manual 档下游需要主动索取"库要求的最小 schema"的顶层入口。
同时钉住公共面**只增这一个名字**: `missing_columns_warning` recorder 内部
共用的文案构造函数,导出它等于多一份永久承诺(库承诺公共面只增不删)
"""
assert "telemetry_schema_sql" in polygateway.__all__
assert callable(polygateway.telemetry_schema_sql)
assert "missing_columns_warning" not in polygateway.__all__
assert not hasattr(polygateway, "missing_columns_warning")
+5 -5
View File
@@ -169,7 +169,7 @@ def _source(model="qwen-max"):
class TestEmitterCost: class TestEmitterCost:
async def test_success_row_costed(self): async def test_success_row_costed(self):
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE) emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_attempt( await emitter.emit_attempt(
request=_REQ, request=_REQ,
source=_source(), source=_source(),
@@ -182,13 +182,13 @@ class TestEmitterCost:
async def test_cache_hit_row_costs_zero(self): async def test_cache_hit_row_costs_zero(self):
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE) emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True)) await emitter.emit_cache_hit(request=_REQ, response=_resp(cache_hit=True))
assert rec.rows[0]["cost"] == 0.0 assert rec.rows[0]["cost"] == 0.0
async def test_failure_row_cost_none(self): async def test_failure_row_cost_none(self):
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE) emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_attempt( await emitter.emit_attempt(
request=_REQ, request=_REQ,
source=_source(), source=_source(),
@@ -201,7 +201,7 @@ class TestEmitterCost:
async def test_unknown_model_none_without_blocking(self): async def test_unknown_model_none_without_blocking(self):
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, pricing=_TABLE) emitter = TelemetryEmitter(rec, pricing=_TABLE, text_cap=None)
await emitter.emit_attempt( await emitter.emit_attempt(
request=_REQ, request=_REQ,
source=_source(model="mystery"), source=_source(model="mystery"),
@@ -215,7 +215,7 @@ class TestEmitterCost:
async def test_no_pricing_keeps_none(self): async def test_no_pricing_keeps_none(self):
"""未注入价格表 = M1 现状: cost 恒 None(回归)。""" """未注入价格表 = M1 现状: cost 恒 None(回归)。"""
rec = _MemoryRecorder() rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec) emitter = TelemetryEmitter(rec, text_cap=None)
await emitter.emit_attempt( await emitter.emit_attempt(
request=_REQ, request=_REQ,
source=_source(), source=_source(),
+273
View File
@@ -0,0 +1,273 @@
"""`tools/telemetry_retention.py` 的 SQLite 分支测试(issue #12 Task 3)。
一律经 `subprocess` 跑真实脚本 + 真实临时 SQLite 库文件: 脚本是独立运维工具
不被库 import, monkeypatch 或直接 import 私有函数测出来的"通过"与运维实际
执行的那条路径不是同一条(退出码argparse 行为stdout 全都测不到)
"""
from __future__ import annotations
import sqlite3
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from polygateway.telemetry.schema import SQLITE_DDL
_ROOT = Path(__file__).resolve().parents[2]
_SCRIPT = _ROOT / "tools" / "telemetry_retention.py"
_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
# 库写入 SQLite 的 created_at 是 UTC 的 'YYYY-MM-DD HH:MM:SS' 文本(schema 的
# DEFAULT (datetime('now'))),测试数据必须同款,否则字符串比较的口径就假了
_INSERT = (
"INSERT INTO llm_calls (call_id, model, provider, source_name, messages, response, "
"prompt_tokens, completion_tokens, usage_source, latency_ms, tenant_id, created_at) "
"VALUES (?, 'm', 'p', 's1', '[]', 'ok', 1, 2, 'measured', 10, ?, ?)"
)
def _stamp(delta: timedelta) -> str:
return (datetime.now(UTC) + delta).strftime(_TIME_FORMAT)
def _make_db(tmp_path: Path, rows: list[tuple[str, str, str]]) -> Path:
"""按库的真实 DDL 建临时库并灌入 (call_id, tenant_id, created_at) 三元组。"""
path = tmp_path / "telemetry.db"
conn = sqlite3.connect(path)
try:
conn.executescript(SQLITE_DDL)
conn.executemany(_INSERT, rows)
conn.commit()
finally:
conn.close()
return path
def _run(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(_SCRIPT), *args],
capture_output=True,
text=True,
cwd=_ROOT,
timeout=120,
)
def _rows(path: Path) -> list[str]:
conn = sqlite3.connect(path)
try:
return [r[0] for r in conn.execute("SELECT call_id FROM llm_calls ORDER BY call_id")]
finally:
conn.close()
def _aged_db(tmp_path: Path) -> Path:
return _make_db(
tmp_path,
[
("old-1", "", _stamp(timedelta(days=-30))),
("old-2", "acme", _stamp(timedelta(days=-20))),
("old-3", "acme", _stamp(timedelta(days=-10))),
("fresh-1", "acme", _stamp(timedelta(days=-1))),
("fresh-2", "", _stamp(timedelta(hours=-1))),
],
)
class TestSqliteDryRun:
def test_dry_run_deletes_nothing_and_reports_counts_range_and_tenants(self, tmp_path):
"""缺省(不带 --apply)是 dry-run: 一行不删,且报出足以判断"删的是不是我想删的"的三样。"""
path = _aged_db(tmp_path)
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
assert result.returncode == 0, result.stderr
assert _rows(path) == ["fresh-1", "fresh-2", "old-1", "old-2", "old-3"]
assert "将删除行数: 3" in result.stdout
assert "created_at 范围:" in result.stdout
assert "按 tenant_id 分布" in result.stdout
# 空串是"未归属"的哨兵而非 NULL,repr 让它在输出里不被误读成缺失
assert "'acme': 2" in result.stdout
assert "'': 1" in result.stdout
assert "dry-run" in result.stdout
def test_dry_run_reports_the_actual_created_at_window(self, tmp_path):
"""时间范围报的必须是**命中行**的窗口,不是全表的。"""
path = _aged_db(tmp_path)
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
conn = sqlite3.connect(path)
try:
low, high = conn.execute(
"SELECT MIN(created_at), MAX(created_at) FROM llm_calls WHERE call_id LIKE 'old-%'"
).fetchone()
finally:
conn.close()
assert f"{low} ~ {high}" in result.stdout
class TestSqliteApply:
def test_apply_removes_only_expired_rows(self, tmp_path):
path = _aged_db(tmp_path)
result = _run(
"--backend", "sqlite", "--path", str(path), "--older-than-days", "7", "--apply"
)
assert result.returncode == 0, result.stderr
assert _rows(path) == ["fresh-1", "fresh-2"]
assert "已删除 3 行" in result.stdout
def test_older_than_days_zero_deletes_everything_before_now(self, tmp_path):
"""N=0 的边界: 截止时刻即"此刻",此刻之前的全删、之后的(未来戳)留下。"""
path = _make_db(
tmp_path,
[
("past", "", _stamp(timedelta(seconds=-5))),
("future", "", _stamp(timedelta(hours=1))),
],
)
result = _run(
"--backend", "sqlite", "--path", str(path), "--older-than-days", "0", "--apply"
)
assert result.returncode == 0, result.stderr
assert _rows(path) == ["future"]
def test_vacuum_with_apply_rewrites_the_file(self, tmp_path):
path = _aged_db(tmp_path)
result = _run(
"--backend",
"sqlite",
"--path",
str(path),
"--older-than-days",
"7",
"--apply",
"--vacuum",
)
assert result.returncode == 0, result.stderr
assert "VACUUM" in result.stdout
assert _rows(path) == ["fresh-1", "fresh-2"]
def test_deleting_from_a_db_without_the_table_is_a_backend_failure(self, tmp_path):
"""连得上但没有 llm_calls: 属"目标不可用",退出码 2 且**不**静默当成 0 行。"""
path = tmp_path / "empty.db"
sqlite3.connect(path).close()
result = _run("--backend", "sqlite", "--path", str(path), "--older-than-days", "7")
assert result.returncode == 2
assert "llm_calls" in result.stderr
def test_missing_db_file_exits_two(self, tmp_path):
result = _run(
"--backend", "sqlite", "--path", str(tmp_path / "nope.db"), "--older-than-days", "7"
)
assert result.returncode == 2
assert "nope.db" in result.stderr
class TestUsageErrors:
"""参数层的一切错误都是退出码 1(argparse 默认的 2 已被本脚本改写,2 留给连接失败)。"""
def test_sqlite_with_dsn_exits_one(self, tmp_path):
result = _run(
"--backend",
"sqlite",
"--path",
str(tmp_path / "x.db"),
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
)
assert result.returncode == 1
assert "--dsn" in result.stderr
def test_sqlite_without_path_exits_one(self):
result = _run("--backend", "sqlite", "--older-than-days", "7")
assert result.returncode == 1
assert "--path" in result.stderr
def test_sqlite_with_batch_size_exits_one(self, tmp_path):
result = _run(
"--backend",
"sqlite",
"--path",
str(tmp_path / "x.db"),
"--older-than-days",
"7",
"--batch-size",
"10",
)
assert result.returncode == 1
assert "--batch-size" in result.stderr
def test_postgres_with_vacuum_exits_one(self):
result = _run(
"--backend",
"postgres",
"--dsn",
"postgresql://x/y",
"--older-than-days",
"7",
"--apply",
"--vacuum",
)
assert result.returncode == 1
assert "--vacuum" in result.stderr
def test_vacuum_without_apply_exits_one(self, tmp_path):
result = _run(
"--backend",
"sqlite",
"--path",
str(tmp_path / "x.db"),
"--older-than-days",
"7",
"--vacuum",
)
assert result.returncode == 1
assert "--apply" in result.stderr
def test_missing_older_than_days_exits_one(self, tmp_path):
result = _run("--backend", "sqlite", "--path", str(tmp_path / "x.db"))
assert result.returncode == 1
def test_negative_older_than_days_exits_one(self, tmp_path):
result = _run(
"--backend", "sqlite", "--path", str(tmp_path / "x.db"), "--older-than-days", "-1"
)
assert result.returncode == 1
assert "--older-than-days" in result.stderr
def test_unknown_backend_exits_one(self, tmp_path):
result = _run("--backend", "mysql", "--path", str(tmp_path / "x.db"))
assert result.returncode == 1
class TestHelp:
def test_help_names_the_maintenance_role_and_the_recommended_path(self):
"""帮助文本是运维唯一会读的文档,权限口径与"推荐不是 DELETE"必须在里面。"""
result = _run("--help")
assert result.returncode == 0
assert "维护角色" in result.stdout
assert "REVOKE" in result.stdout
assert "PARTITION" in result.stdout
+2 -2
View File
@@ -629,7 +629,7 @@ class TestDemotionInsertPosition:
async def test_demoted_lands_before_junk_sources(self): async def test_demoted_lands_before_junk_sources(self):
# a 失败 2 次;b 可信(0.9)但会被跳过时,第三候选应是 a 而非垃圾源 c # a 失败 2 次;b 可信(0.9)但会被跳过时,第三候选应是 a 而非垃圾源 c
from polygateway.middleware.retry import _demote_call_failures from polygateway.middleware.admission import _demote_call_failures
srcs = [_src("a"), _src("b"), _src("c")] srcs = [_src("a"), _src("b"), _src("c")]
health = {"a": 0.9, "b": 0.9, "c": 0.05}.__getitem__ health = {"a": 0.9, "b": 0.9, "c": 0.05}.__getitem__
@@ -637,7 +637,7 @@ class TestDemotionInsertPosition:
assert [s.name for s in out] == ["b", "a", "c"] assert [s.name for s in out] == ["b", "a", "c"]
async def test_health_blind_demotion_still_tail(self): async def test_health_blind_demotion_still_tail(self):
from polygateway.middleware.retry import _demote_call_failures from polygateway.middleware.admission import _demote_call_failures
srcs = [_src("a"), _src("b"), _src("c")] srcs = [_src("a"), _src("b"), _src("c")]
out = _demote_call_failures(srcs, {"a": 2}, None) out = _demote_call_failures(srcs, {"a": 2}, None)
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -254,7 +254,7 @@ def _resp(usage_source):
@pytest.mark.parametrize("emitted", _DOMAIN) @pytest.mark.parametrize("emitted", _DOMAIN)
async def test_emit_attempt_success_stays_in_domain(emitted): async def test_emit_attempt_success_stays_in_domain(emitted):
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
await TelemetryEmitter(recorder).emit_attempt( await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
request=_REQ, request=_REQ,
source=_src(), source=_src(),
call_id="cid", call_id="cid",
@@ -268,7 +268,7 @@ async def test_emit_attempt_success_stays_in_domain(emitted):
async def test_emit_attempt_failed_attempt_stays_in_domain(): async def test_emit_attempt_failed_attempt_stays_in_domain():
"""失败尝试无 response,`usage_source` 取 emitter 自己的字面量。""" """失败尝试无 response,`usage_source` 取 emitter 自己的字面量。"""
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
await TelemetryEmitter(recorder).emit_attempt( await TelemetryEmitter(recorder, text_cap=None).emit_attempt(
request=_REQ, request=_REQ,
source=_src(), source=_src(),
call_id="cid", call_id="cid",
@@ -282,14 +282,16 @@ async def test_emit_attempt_failed_attempt_stays_in_domain():
@pytest.mark.parametrize("emitted", _DOMAIN) @pytest.mark.parametrize("emitted", _DOMAIN)
async def test_emit_cache_hit_stays_in_domain(emitted): async def test_emit_cache_hit_stays_in_domain(emitted):
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
await TelemetryEmitter(recorder).emit_cache_hit(request=_REQ, response=_resp(emitted)) await TelemetryEmitter(recorder, text_cap=None).emit_cache_hit(
request=_REQ, response=_resp(emitted)
)
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
async def test_emit_terminal_failure_stays_in_domain(): async def test_emit_terminal_failure_stays_in_domain():
"""终态失败无具体源,`usage_source` 同样取 emitter 字面量。""" """终态失败无具体源,`usage_source` 同样取 emitter 字面量。"""
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
await TelemetryEmitter(recorder).emit_terminal_failure( await TelemetryEmitter(recorder, text_cap=None).emit_terminal_failure(
request=_REQ, call_id="cid", latency_ms=10, error="cancelled" request=_REQ, call_id="cid", latency_ms=10, error="cancelled"
) )
assert recorder.rows[0]["usage_source"] in USAGE_SOURCES assert recorder.rows[0]["usage_source"] in USAGE_SOURCES
+1 -1
View File
@@ -127,7 +127,7 @@ async def _worker_async(args: argparse.Namespace, worker_idx: int) -> None:
env = _merged_env() env = _merged_env()
run_id = args.run_id run_id = args.run_id
telemetry_path = _ROOT / f"data/soak/telemetry_{run_id}_{worker_idx}.db" telemetry_path = _ROOT / f"data/soak/telemetry_{run_id}_{worker_idx}.db"
recorder = SQLiteRecorder(telemetry_path) recorder = SQLiteRecorder(telemetry_path, auto_migrate=True)
if args.scenario == "P7": if args.scenario == "P7":
from polygateway.ocr import OcrClient from polygateway.ocr import OcrClient
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""遥测表 `llm_calls` 的保留期清理脚本(issue #12;独立运维工具,库本体不 import 它)。
**为什么是脚本而不是库能力**: 库对下游数据库只做 SELECT/INSERT 加可选建表,一切
改结构与删数据的操作交给下游(ARCHITECTURE D15)库若持有 DELETE 权限,就与生产
部署模板推荐的 `REVOKE UPDATE, DELETE ON llm_calls FROM app` 直接冲突
**默认 dry-run**: 本脚本会永久删除审计数据,故不带 `--apply` 时只统计不删,并把
行数`created_at` 窗口`tenant_id` 分布三样一并打出运维据此判断"删掉的是不是
我想删的",判断不了就不该按下 `--apply`。
**失败方向与库相反**: 这是运维工具,缺依赖/连不上/表不存在一律明确报错退出,绝不
静默降级成"删了 0 行"静默的 0 行会被当成"已清理干净"
用法见 `--help`
"""
from __future__ import annotations
import argparse
import asyncio
import sqlite3
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any, NoReturn
if TYPE_CHECKING:
from collections.abc import Sequence
TABLE = "llm_calls"
# 退出码是本脚本对调度器(cron/systemd)的公共契约,改动即破坏下游告警规则
EXIT_OK = 0
EXIT_USAGE = 1
EXIT_BACKEND = 2
EXIT_PARTITIONED = 3
# 库写 SQLite 的 created_at 是 UTC 文本(DEFAULT (datetime('now'))),故截止时刻
# 也必须是同格式文本——该格式定长且高位在前,字符串比较与时间序等价。
# PG 的 created_at 是 TIMESTAMPTZ,直接传 aware datetime,两端口径不可互换。
_SQLITE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
_EPILOG = """\
退出码:
0 正常完成( dry-run)
1 参数错误
2 连接/权限/目标表不可用(含缺少 asyncpg)
3 目标是 PostgreSQL 分区表 请改用 DETACH/DROP PARTITION,脚本不会 DELETE
权限: 请用**维护角色**(表属主)跑本脚本,不要用应用账号 生产部署模板已对应用
账号 REVOKE UPDATE, DELETE ON llm_calls(遥测表按不可变审计表对待)
推荐路径(本脚本是存量兜底,不是首选):
PostgreSQL llm_calls 建成按 created_at RANGE 分区表,过期靠
ALTER TABLE ... DETACH PARTITION + DROP TABLE O(1) 清理
SQLite 按天/按实验轮转库文件( runs/<date>.db),到期直接删文件
时间口径: 截止时刻 = 当前 UTC 时刻 - N ,删除 created_at < 截止时刻 的行;
--older-than-days 0 "删除此刻之前的全部行"
示例:
python tools/telemetry_retention.py --backend sqlite --path runs/telemetry.db \\
--older-than-days 90 # dry-run,只看会删什么
python tools/telemetry_retention.py --backend postgres --dsn "$DSN" \\
--older-than-days 90 --apply --batch-size 1000
"""
class _Parser(argparse.ArgumentParser):
"""把 argparse 的参数错误退出码从 2 改成 1。
2 在本脚本的契约里留给"连接/权限失败",两者混用会让调度器分不清"我写错了参数"
"数据库连不上"后者要告警重试,前者不该重试
"""
def error(self, message: str) -> NoReturn:
self.print_usage(sys.stderr)
print(f"{self.prog}: 参数错误: {message}", file=sys.stderr)
raise SystemExit(EXIT_USAGE)
def _build_parser() -> _Parser:
"""构造 CLI 解析器(参数契约见设计 §6.2)。"""
parser = _Parser(
prog="telemetry_retention.py",
description="按 created_at 清理 PolyGateway 遥测表 llm_calls 的过期行(默认 dry-run)。",
epilog=_EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--backend", required=True, choices=("sqlite", "postgres"))
parser.add_argument("--path", help="SQLite 库文件路径(--backend sqlite 必填)")
parser.add_argument("--dsn", help="PostgreSQL DSN(--backend postgres 必填)")
parser.add_argument(
"--older-than-days",
type=int,
required=True,
metavar="N",
help="删除 created_at 早于 N 天前的行;N >= 0",
)
parser.add_argument(
"--apply",
action="store_true",
help="真正执行删除;不给则只统计不删(默认)",
)
parser.add_argument(
"--batch-size",
type=int,
metavar="N",
help="仅 postgres: 每批删除的行数,每批一个事务(默认 1000)",
)
parser.add_argument(
"--vacuum",
action="store_true",
help="仅 sqlite: 删除后执行 VACUUM 回收文件空间;须与 --apply 同时给",
)
return parser
def _validate(parser: _Parser, args: argparse.Namespace) -> None:
"""校验参数组合;任何不合法组合以退出码 1 结束(P5: 不给默认值掩盖错误)。
**校验链的顺序就是错误消息的优先级**: 先两端通用,再按 backend 分支同时给出
多个错误参数时,报出的是链上最先命中的那条
"""
_validate_shared(parser, args)
if args.backend == "sqlite":
_validate_sqlite(parser, args)
return
_validate_postgres(parser, args)
def _validate_shared(parser: _Parser, args: argparse.Namespace) -> None:
"""两端通用的校验。
`--vacuum` `--apply` 的联动归在这里(而不是 SQLite 分支): 它是"别在只想看看的
时候重写整个库"这条安全约束,先于"这个参数属于哪个 backend"成立。
"""
if args.older_than_days < 0:
parser.error("--older-than-days 必须 >= 0")
if args.vacuum and not args.apply:
parser.error("--vacuum 会重写整个库文件,必须与 --apply 同时给")
def _validate_sqlite(parser: _Parser, args: argparse.Namespace) -> None:
"""SQLite 分支: 必须有 --path,且拒绝一切 postgres 专属参数(不静默忽略)。"""
if args.path is None:
parser.error("--backend sqlite 需要 --path")
if args.dsn is not None:
parser.error("--backend sqlite 不接受 --dsn")
if args.batch_size is not None:
parser.error("--batch-size 仅用于 --backend postgres")
def _validate_postgres(parser: _Parser, args: argparse.Namespace) -> None:
"""Postgres 分支: 必须有 --dsn,拒绝 sqlite 专属参数,并在此落 --batch-size 缺省值。"""
if args.dsn is None:
parser.error("--backend postgres 需要 --dsn")
if args.path is not None:
parser.error("--backend postgres 不接受 --path")
if args.vacuum:
parser.error("--vacuum 仅用于 --backend sqlite")
if args.batch_size is None:
args.batch_size = 1000
elif args.batch_size < 1:
parser.error("--batch-size 必须 >= 1")
def _print_stats(total: int, low: object, high: object, tenants: Sequence[tuple[str, int]]) -> None:
"""打印将删除行数、created_at 窗口与按 tenant_id 的分布。
tenant_id repr : 空串是"未归属"的哨兵(不是 NULL),裸打会与缺失混淆
"""
print(f"将删除行数: {total}")
print(f"created_at 范围: {low} ~ {high}" if total else "created_at 范围: (无匹配行)")
print("按 tenant_id 分布:")
if not tenants:
print(" (无匹配行)")
for tenant, count in tenants:
print(f" {tenant!r}: {count}")
# --------------------------------------------------------------------------- SQLite
def _run_sqlite(path: str, cutoff: str, apply_: bool, vacuum: bool) -> int:
"""SQLite 分支: 单条 DELETE(本地文件无长事务与锁膨胀问题),VACUUM 须显式要。"""
file = Path(path)
if not file.is_file():
print(f"SQLite 库文件不存在: {file}", file=sys.stderr)
return EXIT_BACKEND
try:
conn = sqlite3.connect(f"file:{file}?mode=rw", uri=True)
except sqlite3.Error as exc:
print(f"打开 SQLite 库失败: {file}: {exc}", file=sys.stderr)
return EXIT_BACKEND
try:
exists = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (TABLE,)
).fetchone()
if exists is None:
print(f"目标库里没有表 {TABLE}: {file}", file=sys.stderr)
return EXIT_BACKEND
print(f"目标表: {file}::{TABLE}")
total, low, high = conn.execute(
f"SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM {TABLE} WHERE created_at < ?",
(cutoff,),
).fetchone()
tenants = conn.execute(
f"SELECT tenant_id, COUNT(*) FROM {TABLE} WHERE created_at < ? "
"GROUP BY tenant_id ORDER BY COUNT(*) DESC, tenant_id",
(cutoff,),
).fetchall()
_print_stats(total, low, high, tenants)
if not apply_:
print("模式 dry-run: 未删除任何行。确认无误后加 --apply 才会真正删除。")
return EXIT_OK
cursor = conn.execute(f"DELETE FROM {TABLE} WHERE created_at < ?", (cutoff,))
conn.commit()
print(f"已删除 {cursor.rowcount} 行。")
if vacuum:
print("执行 VACUUM(重写整个库文件,需要与库等量的空闲磁盘)…")
conn.execute("VACUUM")
conn.commit()
print("VACUUM 完成。")
except sqlite3.Error as exc:
print(f"SQLite 操作失败: {exc}", file=sys.stderr)
return EXIT_BACKEND
finally:
conn.close()
return EXIT_OK
# --------------------------------------------------------------------------- PostgreSQL
def _quote(identifier: str) -> str:
"""把 catalog 取回的 schema/表名包成合法标识符(库名含大写或特殊字符时必需)。"""
escaped = identifier.replace('"', '""')
return f'"{escaped}"'
async def _run_postgres(dsn: str, cutoff: datetime, apply_: bool, batch_size: int) -> int:
"""PostgreSQL 分支: 分区表让路,普通表分批 DELETE(每批一个事务)。"""
try:
import asyncpg
except ImportError as exc:
print(
f"--backend postgres 需要 asyncpg,当前不可用({exc});"
"请 pip install 'polygateway[postgres]' 或 pip install asyncpg 后重试。",
file=sys.stderr,
)
return EXIT_BACKEND
try:
conn = await asyncpg.connect(dsn, timeout=10)
except (OSError, asyncpg.PostgresError) as exc:
print(f"连接 PostgreSQL 失败: {exc}", file=sys.stderr)
return EXIT_BACKEND
try:
return await _purge_postgres(conn, cutoff, apply_, batch_size)
except asyncpg.PostgresError as exc:
print(f"PostgreSQL 操作失败: {exc}", file=sys.stderr)
return EXIT_BACKEND
finally:
await conn.close()
async def _purge_postgres(conn: Any, cutoff: datetime, apply_: bool, batch_size: int) -> int:
"""已连上后的清理主体(conn 是 asyncpg.Connection,不 import 类型以免脚本硬依赖)。"""
# 先解析目标: to_regclass 走连接自己的 search_path,故必须把解析结果打出来——
# "我删的到底是哪张表"是这个脚本唯一不能猜的事(共享库里另有同名表的场景常见)。
target = await conn.fetchrow(
"SELECT n.nspname AS schema, c.relname AS name, "
"EXISTS (SELECT 1 FROM pg_partitioned_table p WHERE p.partrelid = c.oid) AS partitioned "
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.oid = to_regclass($1)",
TABLE,
)
if target is None:
print(f"目标库的 search_path 下找不到表 {TABLE}", file=sys.stderr)
return EXIT_BACKEND
schema, name = target["schema"], target["name"]
qualified = f"{_quote(schema)}.{_quote(name)}"
print(f"目标表: {schema}.{name}")
if target["partitioned"]:
print(
f"{schema}.{name} 是分区表: 本脚本拒绝对分区表执行 DELETE。\n"
"请改用 DETACH/DROP PARTITION —— ALTER TABLE ... DETACH PARTITION <子表> 后 "
"DROP TABLE <子表>(或交给 pg_partman 的 retention)。\n"
"那是 O(1) 的,而 DELETE 会全表扫描并留下等量膨胀。"
)
return EXIT_PARTITIONED
stats = await conn.fetchrow(
f"SELECT COUNT(*) AS total, MIN(created_at) AS low, MAX(created_at) AS high "
f"FROM {qualified} WHERE created_at < $1",
cutoff,
)
tenants = await conn.fetch(
f"SELECT tenant_id, COUNT(*) AS total FROM {qualified} WHERE created_at < $1 "
"GROUP BY tenant_id ORDER BY COUNT(*) DESC, tenant_id",
cutoff,
)
_print_stats(
stats["total"], stats["low"], stats["high"], [(r["tenant_id"], r["total"]) for r in tenants]
)
if not apply_:
print("模式 dry-run: 未删除任何行。确认无误后加 --apply 才会真正删除。")
return EXIT_OK
# 分批: 一条大 DELETE 会撑出长事务(阻塞 autovacuum、堆积 WAL、锁膨胀),
# 中断后还得整批回滚重来。每批独立提交,中断只影响未删批次。
deleted = 0
batches = 0
statement = (
f"DELETE FROM {qualified} WHERE ctid IN "
f"(SELECT ctid FROM {qualified} WHERE created_at < $1 ORDER BY created_at LIMIT $2)"
)
while True:
async with conn.transaction():
status = await conn.execute(statement, cutoff, batch_size)
count = int(status.rsplit(" ", 1)[-1])
if count == 0:
break
deleted += count
batches += 1
print(f" 批次 {batches}: 删除 {count} 行(已提交)")
print(f"已删除 {deleted} 行,共 {batches} 批。")
return EXIT_OK
# --------------------------------------------------------------------------- 入口
def main(argv: Sequence[str] | None = None) -> int:
"""解析参数并分派到对应后端;返回值即进程退出码。"""
parser = _build_parser()
args = parser.parse_args(argv)
_validate(parser, args)
cutoff = datetime.now(UTC) - timedelta(days=args.older_than_days)
print(f"后端: {args.backend}")
print(
f"截止时间(UTC): {cutoff.strftime(_SQLITE_TIME_FORMAT)}"
f"(--older-than-days {args.older_than_days};删除 created_at 早于该时刻的行)"
)
print(f"模式: {'apply(将真正删除)' if args.apply else 'dry-run(只统计,不删除)'}")
if args.backend == "sqlite":
return _run_sqlite(args.path, cutoff.strftime(_SQLITE_TIME_FORMAT), args.apply, args.vacuum)
return asyncio.run(_run_postgres(args.dsn, cutoff, args.apply, args.batch_size))
if __name__ == "__main__":
sys.exit(main())