From 8c9e1179bcb6bb21ab49f63ff3ba0c1ff5b7d047 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Thu, 30 Jul 2026 00:46:44 -0400 Subject: [PATCH] docs: design second round of settings validation consolidation The independent verifier found 15 more checks still living only in from_env: six enum domains, seven conditional-required pairs and two scalar ranges. More severe than round one because client.py has five asserts that claim config already validated the redis_url and telemetry paths, which is false on the from_settings path. Also records a normalisation gap the verifier missed: _load_pg_dsn strips the SQLAlchemy +asyncpg suffix, so a hand-built DSN reaches asyncpg unstripped. --- ...7-30-settings-invariants-round-2-design.md | 126 ++++++++++++++++++ .../designs/settings-invariants-round-2.md | 17 +++ research-wiki/graph/edges.json | 5 + research-wiki/index.md | 6 +- research-wiki/log.md | 2 + 5 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md create mode 100644 research-wiki/designs/settings-invariants-round-2.md diff --git a/research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md b/research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md new file mode 100644 index 0000000..13188ea --- /dev/null +++ b/research-wiki/designs/2026-07-30-settings-invariants-round-2-design.md @@ -0,0 +1,126 @@ +# GatewaySettings 装配校验补齐(第二轮) + +- **日期**: 2026-07-30;**状态**: 待人类审批(同为构造承诺收紧,强制人类门) +- **缘起**: [2026-07-29-settings-invariant-guards-design.md](2026-07-29-settings-invariant-guards-design.md) §9.1 —— 独立 verifier 在第一轮交付后发现,`from_env` 上还留着一批同族校验;本设计是那一轮的续作,**同一个 bug 类的剩余部分** +- **上游依据**: 第一轮设计 §2 已批准的方案 A(不变量归属于类,不归属于某个工厂);CLAUDE.md §4.3(assert 仅用于内部不变量)、§4.5(装配只有两条路) + +## 1. 待收拢的校验清单(逐条实测确认只在 `from_env` 生效) + +### A. 枚举合法域(6 条) + +| 字段 | 合法域 | 现居 | +|---|---|---| +| `limiter_backend` / `breaker_backend` | `{memory, redis}` | `_load_pgw`(经 `_load_choice`) | +| `cache_backend` | `{redis, memory, none}` | `_load_pgw` 内联 | +| `telemetry_backend` | `{sqlite, postgres, none}` | `_load_pgw` 内联 | +| `selector` | `_SELECTORS` | `from_env` 调 `_load_choice` | +| `quota_full` | `_QUOTA_FULL` | `from_env` 调 `_load_choice` | + +直接构造传 `selector="random"` 或 `cache_backend="rediss"` 一律放行,后果是装配时落进 `_build_*` 的 else 分支或静默不建后端。 + +### B. 条件必填(7 条,跨字段) + +| 条件 | 要求 | 违反后果 | +|---|---|---| +| `limiter_backend`/`breaker_backend`/`cache_backend` 取 `redis` | `redis_url` 非空 | **见 §2**,最严重 | +| `cache_backend != "none"` | `cache_namespace` 非空 | 缓存 key 失去租户隔离——踩"无缓存毒化"铁律 | +| `cache_backend != "none"` | `cache_ttl_s > 0` | `from_env` 明令禁止的"永不过期"从另一条路进来 | +| `telemetry_backend == "sqlite"` | `telemetry_sqlite_path` 非空 | 断言炸或写空路径 | +| `telemetry_backend == "postgres"` | `telemetry_pg_dsn` 非空 | 同上 | + +### C. 标量域(2 条) + +`structured_max_retries ≥ 0`;`scope` 非空(空 scope 会污染遥测与缓存命名空间)。 + +## 2. 为什么这批比第一轮更严重:`client.py` 的断言前提为假 + +`client.py` 有 5 处断言**明文声称这个前提已经成立**: + +```python +assert settings.redis_url is not None # 内部不变量: config 已校验 +``` + +位置:`client.py:262/282/302`(redis_url)、`:312`(pg_dsn)、`:316`(sqlite_path)。走 `from_settings` 时该注释是假的,verifier 实测: + +| 运行方式 | 结果 | +|---|---| +| 断言开启 | `AssertionError()` —— 裸断言,不点字段、不说原因 | +| `python -O` | 断言消失,退化为 redis 库的 `ValueError: Redis URL must specify one of the following schemes...` | + +后者正是 CLAUDE.md §4.3 禁止的"assert 承担生产校验"。 + +**但注意结论的方向**:这 5 处 assert 本身不是要修的东西——它们要的前提是对的,错的是没人保证这个前提。§4 给出处置。 + +## 3. 方案 + +沿用第一轮已批准的方案 A,不重新论证:全部收进 `GatewaySettings.__post_init__`,新增三个私有方法与既有四个并列。 + +| 方法 | 覆盖 | +|---|---| +| `_validate_backends` | A 类 6 条枚举 + B 类 redis_url 三条件 | +| `_validate_cache` | `cache_namespace` 非空、`cache_ttl_s > 0`(仅 `cache_backend != "none"` 时) | +| `_validate_telemetry` | sqlite path / postgres dsn 条件必填 + §5 的 DSN 形态 | + +标量两条(`structured_max_retries`、`scope`)并入 `_validate_sources` 改名后的 `_validate_identity`,与 `SourceConfig._validate_identity` 同名同职。 + +枚举合法域上提为模块级 frozenset 常量(`_LIMITER_BACKENDS` 等),`_load_pgw` 与 `__post_init__` 共用一份,消除现有的内联字面量重复。 + +**否决的替代**:在 `_build_limiter`/`_build_cache` 等工厂函数里逐个补显式检查。理由同第一轮 §2 方案 B——校验散落在消费点,每加一个后端就多一处要同步,且 `dataclasses.replace` 仍绕过。 + +## 4. 5 处 assert 的处置:**保留,不改** + +修好构造期校验后,`settings.redis_url is not None` 就真的成了内部不变量——CLAUDE.md §4.3 原文"assert 仅用于内部不变量"说的正是这种用法,同时它给类型检查器收窄了 `str | None`。此时删掉 assert 反而丢失类型信息,改成 `raise` 则是在防御一个已被构造期排除的情况(死代码)。 + +**要改的是注释**:`# 内部不变量: config 已校验` 应点明由谁保证,例如 `# 内部不变量: GatewaySettings._validate_backends 已保证`。前一轮的教训就是这类注释会随时间变成谎言。 + +## 5. Postgres DSN:校验而非规范化(本轮唯一的新决策) + +`_load_pg_dsn` 对 `from_env` 读到的 DSN 做了**规范化**:剥掉 SQLAlchemy 风格的 `+asyncpg` 驱动后缀(asyncpg 不认)。直接构造那条路不会剥,`postgresql+asyncpg://...` 会原样送进 asyncpg 然后在首次写遥测时才炸。 + +| 选项 | 权衡 | +|---|---| +| **A. 构造期校验,含 `+driver` 即报错(推荐)** | 显式优于隐式;frozen dataclass 里改字段要 `object.__setattr__`,是在用户背后动他给的值。报错消息直接告诉他去掉后缀即可 | +| B. 构造期规范化(`object.__setattr__` 剥后缀) | 与 `from_env` 行为完全对齐,调用方不用管;但 frozen 类在构造期悄悄改字段,后续 `replace`/相等性比较都会有惊喜 | + +推荐 A。代价是两条装配路对同一输入的**接受度**不同(`from_env` 接受带后缀的、直接构造不接受),但两者的**产出**一致——`GatewaySettings.telemetry_pg_dsn` 永远是干净 DSN。这个不对称是有意的:env 那条路要吃下三项目历史遗留的 SQLAlchemy DSN 写法(迁移兼容),代码构造那条路没有历史包袱。 + +## 6. 行为审计 + +| 现有行为 | 处置 | +|---|---| +| `from_env` 对上述 15 条的校验与报错 | **全部保留**,时机提前到 `cls(...)`;`_load_*` 内联检查删除,避免同一约束两处维护 | +| `_load_pg_dsn` 剥 `+driver` | **保留**,继续只在 env 路径生效(§5) | +| `_load_choice` 的 `default` 语义(键缺失时取默认) | **保留**,那是 env 解析职责,不是不变量 | +| 直接构造出上述任一非法组合 → 静默成功 | **有意替换**为构造期 `ValueError` | +| `client.py` 5 处 assert | **保留**,仅改注释(§4) | +| 异常类型 | 一律 `ValueError`,与第一轮及既有装配错误一致 | + +**有意放弃**:不校验 `pricing_path` 指向的文件是否存在(I/O 不属于配置校验,`PricingTable.from_file` 自会报错);不强制 `cache_backend == "none"` 时 namespace/ttl 必须为 None(多余字段无害)。 + +## 7. 非功能维度 + +与第一轮同构,不重复论证:`__post_init__` 纯同步计算无 I/O(不适用并发/取消/持久化);装配期属准入侧,报错不放行;`__post_init__` 不改字段故幂等。**性能**:新增约 10 次字符串比较,第一轮实测单次构造 1.45 µs 且库内无热路径构造 `GatewaySettings`,可忽略。 + +## 8. 测试策略 + +`tests/unit/test_config.py::TestCrossFieldInvariants` 扩充(不新建类,同族不变量归一处): + +| 用例组 | 断言 | +|---|---| +| 6 条枚举各一条非法值 | 抛 `ValueError`,消息含字段名与合法域 | +| redis_url 三条件(limiter/breaker/cache 各一) | 抛 `ValueError`,消息点明需要 `redis_url` | +| cache namespace 缺失 / ttl ≤ 0 | 抛 `ValueError` | +| telemetry sqlite path / pg dsn 缺失 | 抛 `ValueError` | +| `structured_max_retries=-1`、`scope=""` | 抛 `ValueError` | +| pg dsn 含 `+asyncpg` | 抛 `ValueError`,消息给出去后缀的写法 | +| 合法组合(每种 backend 组合各一) | 构造成功——收紧的是错的那些 | +| **回归护栏**:`GatewayClient.from_settings` 走 redis 三后端的合法配置 | 装配成功,证明 assert 前提真的被保证了 | + +TDD:先跑出红,预计 ≥14 条失败。要求同第一轮——每条实现改动都要有对应测试能杀死它。 + +版本:**1.0.2**(patch),CHANGELOG 同样单列"行为收紧"小节。 + +## 9. 待人类拍板 + +1. §5 的 DSN 处置:选 A(校验拒绝,推荐)还是 B(构造期剥后缀)? +2. §4 的 assert 保留 + 改注释,是否认同? diff --git a/research-wiki/designs/settings-invariants-round-2.md b/research-wiki/designs/settings-invariants-round-2.md new file mode 100644 index 0000000..4ba3114 --- /dev/null +++ b/research-wiki/designs/settings-invariants-round-2.md @@ -0,0 +1,17 @@ +--- +type: design +node_id: design:settings-invariants-round-2 +title: "GatewaySettings 装配校验补齐(第二轮)" +date: 2026-07-30 +--- + +# GatewaySettings 装配校验补齐(第二轮) + +全文见 [2026-07-30-settings-invariants-round-2-design.md](2026-07-30-settings-invariants-round-2-design.md)。第一轮见 [settings-invariant-guards](settings-invariant-guards.md)。 + +- **缘起**: 第一轮交付后独立 verifier 发现 `from_env` 上还留着 15 条同族校验(枚举合法域 6、条件必填 7、标量域 2),`from_settings` 与直接构造全部放行。 +- **严重性高于第一轮**: `client.py:262/282/302/312/316` 有 5 处 `assert ... # 内部不变量: config 已校验` 明文依赖这个前提;实测断言开启抛裸 `AssertionError`,`python -O` 下退化为 redis 库天书。 +- **方案**: 沿用第一轮已批准的方案 A,不重新论证;新增 `_validate_backends/_validate_cache/_validate_telemetry`,枚举合法域上提为模块级常量供 `_load_pgw` 与构造期共用。 +- **assert 处置**: **保留不改**——前提一旦由构造期保证,它就是 CLAUDE.md §4.3 认可的内部不变量用法且给类型检查器收窄 `str | None`;只改那句会变成谎言的注释,点明由哪个方法保证。 +- **本轮唯一新决策**: `_load_pg_dsn` 剥 `+asyncpg` 驱动后缀是**规范化**不是校验,直接构造那条路不会剥。选校验拒绝(显式)而非构造期 `object.__setattr__` 剥后缀(在用户背后改 frozen 字段)。两条路接受度不同是有意的:env 路要吃三项目历史遗留的 SQLAlchemy DSN 写法,代码构造路没有历史包袱。 +- **版本**: 1.0.2(patch),CHANGELOG 单列"行为收紧"小节。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 6a66dc0..2a9ed65 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -95,6 +95,11 @@ "id": "design:settings-invariant-guards", "label": "GatewaySettings 跨字段不变量守卫的生效范围", "type": "design" + }, + { + "id": "design:settings-invariants-round-2", + "label": "GatewaySettings 装配校验补齐(第二轮)", + "type": "design" } ], "links": [ diff --git a/research-wiki/index.md b/research-wiki/index.md index 3fa48ef..153618d 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,14 +1,16 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-07-30 03:44 UTC +> 自动生成,更新时间:2026-07-30 04:44 UTC -## design (12) +## design (14) - [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-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` - [2026-07-21-m3-ocr-design](designs/2026-07-21-m3-ocr-design.md) `design:2026-07-21-m3-ocr-design` - [2026-07-22-m4-migration-design](designs/2026-07-22-m4-migration-design.md) `design:2026-07-22-m4-migration-design` - [2026-07-29-settings-invariant-guards-design](designs/2026-07-29-settings-invariant-guards-design.md) `design:2026-07-29-settings-invariant-guards-design` +- [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design` +- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [M1 核心里程碑设计:公共签名冻结与治理栈落地](designs/m1-core-design.md) `design:m1-core-design` - [M2 分布式:Redis 治理后端+背压+Postgres 遥测+pricing+Embedding+压测 harness](designs/m2-distributed.md) `design:m2-distributed` diff --git a/research-wiki/log.md b/research-wiki/log.md index 4d92fd4..9797c82 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -48,3 +48,5 @@ - [2026-07-22 14:36 UTC] 重建索引: 34 篇页面 - [2026-07-30 03:44 UTC] 新增 design: GatewaySettings 跨字段不变量守卫的生效范围 (design:settings-invariant-guards) - [2026-07-30 03:44 UTC] 重建索引: 36 篇页面 +- [2026-07-30 04:44 UTC] 新增 design: GatewaySettings 装配校验补齐(第二轮) (design:settings-invariants-round-2) +- [2026-07-30 04:44 UTC] 重建索引: 38 篇页面