M8: clarify Alembic schema migration plan

This commit is contained in:
2026-08-22 22:02:14 +02:00
parent d7f04aee8c
commit 43c2ddce1a
+57 -4
View File
@@ -176,6 +176,35 @@ Source 负责连接和健康状态,channel 负责稳定的测量身份,bindi
所有 migration 必须在空库和带历史数据的升级副本上通过,迁移前后逐表对账,不 drop/truncate 任何
业务数据。SQLite batch migration 后显式验证 FK、索引和唯一约束。
### M8 Alembic revision 计划与仓库级不变量
M8 的 schema 变更沿现有单一 `alembic_app` 链按下表串行落地;不得合并成一次不可审计的大迁移,
也不得创建并行 head
| Task / revision | `down_revision` | Schema 变更 |
| --- | --- | --- |
| T01 / `20260822_15_meter_sources` | `20260625_14_meter_uuid` | 新建 `meter_source``meter_source_channel``meter_source_binding`;给 `energy_cost_period` 增加 nullable `source_binding_id` |
| T03 / `20260822_16_dsmr_source_adoption` | `20260822_15_meter_sources` | `dsmr_reading.source_id` 政名为 `telegram_id`,新增 non-null `meter_source_id`,唯一键改为 `(meter_source_id, recorded_at)`,并执行 DSMR/source/binding/cost 历史回填 |
| T08 / `20260822_17_warmtelink_readings` | `20260822_16_dsmr_source_adoption` | 新建 `warmtelink_reading` 标量历史表 |
| T12 / `20260822_18_contract_scopes` | `20260822_17_warmtelink_readings` | 给 `energy_contract` 增加 non-null `scope`,旧行回填 `electricity` |
| T14 / `20260822_19_meter_cost_periods` | `20260822_18_contract_scopes` | 新建 `meter_cost_period` 审计账本 |
每张新增 revision 的任务还必须同时遵守以下仓库级契约:
1. 新模型模块在首次引入时显式 import 到 `alembic_app/env.py`,保证 `Base.metadata` 完整;后续只在
已注册模块中新增模型时无需重复改 env。
2. 同一任务把 `scripts/app_db_adopt.py::APP_BASELINE_REVISION` 更新为该任务的新 head。现有
fail-closed 启动校验和多组部署/模型回归测试都要求该常量与唯一 Alembic head 完全相等;不得把
常量临时留在旧 revision 等后续任务补救。
3. `scripts/run_migrations.py` 继续只调用 app DB adoption/upgrade,不新增第二条迁移链;每个任务验证
旧 head→新 head、空库→head 和重复运行幂等。
4. 实现、review 和 T20 收尾只使用 pytest `tmp_path``/tmp` 下明确命名的临时 SQLite 库以及合成的
历史 fixture。不得挂载、复制、打开或修改运行中 production 的 DB 路径、容器或 volume;所谓
“历史升级副本”在本轮指结构和边界场景等价的隔离合成副本,不含真实生产数据。
5. 每次升级对账至少记录 migration 前后业务表行数、Alembic revision、孤儿 FK、关键唯一键/索引;
任何不一致必须在同一事务中失败并回滚。downgrade 仅用于临时测试库的 schema 可逆性验证,绝不
对生产或用户备份执行。
### DSMR 回填算法
1. 读取旧 runtime config,创建一个 `dsmr_mqtt` source 和稳定 electricity channel;无旧 config 时也
@@ -262,7 +291,9 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- `create app/models/meter_source.py`
- `modify app/models/__init__.py`
- `modify app/models/energy.py`
- `modify alembic_app/env.py`
- `create alembic_app/versions/20260822_15_meter_sources.py`
- `modify scripts/app_db_adopt.py`
- `create tests/test_meter_sources.py`
- `modify tests/test_energy_models.py`
@@ -273,9 +304,12 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
半开区间相交,数据库 FK 使用 `RESTRICT`relationship 不配置 delete cascade。
3.`EnergyCostPeriod` 增加 nullable `source_binding_id` FK 和 relationship;迁移此时只加列,
不回填 DSMR 历史。
4. migration 在 SQLite batch 模式创建表、索引和约束;downgrade 只能回退 schema,不删除任何
4. `alembic_app/env.py` 注册新模型模块,并把 `APP_BASELINE_REVISION` 同步到 revision 15
不改变 `scripts/run_migrations.py` 的单库行为。
5. migration 在 SQLite batch 模式创建表、索引和约束;downgrade 只能回退 schema,不删除任何
外部数据库文件。
5. 测试 Alembic 空库升级、模型默认值、唯一约束、FK RESTRICT 和无 delete cascade。
6. 测试 Alembic 空库升级、revision 14→15、重复运行、baseline=head、模型默认值、唯一约束、
FK RESTRICT 和无 delete cascade;数据库只使用 `tmp_path` 临时文件。
**Out of scope / 不要碰**
- 不迁移 `dsmr_reading`,不读取旧 config,不实现 API、worker 或 source profile。
@@ -284,11 +318,12 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
**Acceptance criteria**
- [ ] 三张新表、UUID/唯一键/FK/索引与 §5 一致,`energy_cost_period` 新列可空。
- [ ] 删除被 channel/binding/成本引用的行会被数据库拒绝,不级联丢历史。
- [ ] 空库与从 revision 14 升级都到达 head,测试覆盖约束。
- [ ] 空库与从 revision 14 升级都到达 head`APP_BASELINE_REVISION` 等于唯一 head测试覆盖约束。
- [ ] `pytest``ruff check .` 全绿。
**Reviewer checklist**
- migration 是否完全无 drop/truncate/用户文件操作;SQLite 重建后 FK 是否仍启用。
- `alembic_app/env.py` 是否已注册新模型,baseline 常量是否与 revision 15 完全一致。
- 时间窗是否统一为 `[started_at, ended_at)`,没有把 source 生命周期混进 Meter epoch。
### M8-T02 — Source profile registry 与 binding service
@@ -335,6 +370,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
**Files**
- `modify app/models/energy.py`
- `create alembic_app/versions/20260822_16_dsmr_source_adoption.py`
- `modify scripts/app_db_adopt.py`
- `create tests/test_dsmr_source_migration.py`
- `modify tests/test_energy_models.py`
@@ -347,6 +383,8 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
binding 保持 nullablemigration 注释和测试必须明确这是历史边界,而非当前 binding。
4. 每一阶段在 migration 内核对 source/readings/cost 行数与 orphan FK;不一致立即抛错回滚。
5. 用 revision 14 的历史 fixture 覆盖单 Meter、多次换表、跨界成本、无读数 Meter 和无旧 config。
6.`APP_BASELINE_REVISION` 同步到 revision 16;所有升级 fixture 仅在 `tmp_path` 中构造,不读取
真实 app DB 或 volume。
**Out of scope / 不要碰**
- 不删除旧 DSMR `app_config` 行,不改变 MQTT subscription,不改 API 响应。
@@ -356,10 +394,12 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- [ ] 迁移前后 DSMR 与成本行数不减少,orphan FK 为 0,正常可解析周期有 binding。
- [ ] 两个 source 可在同一 timestamp 各存一条 DSMR reading;同 source 重复 timestamp 被拒绝。
- [ ] `telegram_id` 不参与幂等唯一键,旧 telegram id 完整保留。
- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 16 head,升级重复运行幂等。
- [ ] 历史升级 fixture、空库升级、`pytest``ruff check .` 全绿。
**Reviewer checklist**
- 对账是否在 migration 中真实执行,而不只是测试断言;失败能否原子回滚。
- baseline 常量是否随 revision 16 同步,fixture 是否完全隔离于真实生产路径。
- 是否存在“把所有历史强绑当前 Meter/source”的静默错误或任何 destructive cleanup。
### M8-T04 — DSMR runtime 改为多 Source 配置 [structural]
@@ -528,6 +568,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
**Files**
- `modify app/models/meter_source.py`
- `create alembic_app/versions/20260822_17_warmtelink_readings.py`
- `modify scripts/app_db_adopt.py`
- `create tests/test_warmtelink_models.py`
**Steps**
@@ -538,6 +579,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
3. FK 使用 `RESTRICT`,不配置 orphan/delete cascade;选择足以容纳长期累计量与 0.001 精度的
Numeric precision/scale,并测试 Decimal round-trip。
4. migration 覆盖空库/升级、唯一键、FK 和索引;downgrade 不触碰任何外部文件。
5.`APP_BASELINE_REVISION` 同步到 revision 17;所有 migration 测试只使用 `tmp_path` 临时库。
**Out of scope / 不要碰**
- 不实现接纳、sampling、worker、API 或成本。
@@ -547,11 +589,13 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- [ ] schema 与 §5 一致,Decimal 无 float 转换,source/channel 删除受限。
- [ ] 同 channel/timestamp 幂等隔离,不同 channel 同时刻允许。
- [ ] 模型和 migration 中不存在 raw telegram/equipment identifier 列。
- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 17 head。
- [ ] `pytest``ruff check .` 全绿。
**Reviewer checklist**
- Numeric precision 是否覆盖合理长期累计值;timezone 与唯一键是否使用设备时间。
- migration 是否数据安全、约束命名稳定且可在 SQLite 正确执行。
- baseline 常量是否随 revision 17 同步,测试是否未打开任何真实数据库。
### M8-T09 — WarmteLink 质量接纳、发现与分钟采样 [structural]
@@ -677,6 +721,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- `modify app/schemas/energy_contract.py`
- `modify app/api/routes/api/energy_contracts.py`
- `create alembic_app/versions/20260822_18_contract_scopes.py`
- `modify scripts/app_db_adopt.py`
- `modify tests/test_api_energy_contracts.py`
- `modify tests/test_energy_models.py`
- `modify openapi/openapi.json`
@@ -692,6 +737,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
list 默认兼容 electricity 行为,新增可选 scope filter。
4. migration 对账合同/version/cost 行数,不改 versions/pricing values,不删除任何合同。
5. 覆盖两 scope 同时 active、同 scope 互斥、kind/scope mismatch、旧 payload 与升级 fixture;重导契约。
6.`APP_BASELINE_REVISION` 同步到 revision 18;升级 fixture 仅使用隔离临时库。
**Out of scope / 不要碰**
- 不新增真实 thermal profile/费率,不改 electricity 成本公式。
@@ -701,10 +747,12 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- [ ] 旧合同全部回填 electricity 且行数/version FK 不变。
- [ ] service 对预置的 electricity/thermal rows 可同时保持 active;每 scope 最多一份 active。
- [ ] 旧 manual/tibber API 请求仍成功并得到 electricity scope。
- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 18 head。
- [ ] `pytest``ruff check .`、OpenAPI/codegen 同步闸门全绿。
**Reviewer checklist**
- 并发/事务失败是否可能留下同 scope 双 active 或误停用另一 scope。
- baseline 常量是否随 revision 18 同步,历史对账是否在临时副本内执行。
- migration 是否在 active 旧数据上安全,API 默认值是否真正向后兼容。
### M8-T13 — District-heating 定价 Profile
@@ -752,6 +800,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
**Files**
- `modify app/models/energy.py`
- `create alembic_app/versions/20260822_19_meter_cost_periods.py`
- `modify scripts/app_db_adopt.py`
- `create tests/test_meter_cost_models.py`
**Steps**
@@ -763,6 +812,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
3. quantity/cost 使用定点 NumericORM 不经 floatJSON 中的金额/数量统一序列化为十进制字符串。
4. migration 只创建新表,不迁移/删除 `energy_cost_period`;测试空库/升级、Decimal round-trip、
唯一键、nullable degraded 和 FK RESTRICT。
5.`APP_BASELINE_REVISION` 同步到 revision 19;升级/降级测试只使用 `tmp_path` 临时库。
**Out of scope / 不要碰**
- 不计算或回填热力成本,不泛化/删除现有 electricity 表。
@@ -772,10 +822,12 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
- [ ] 表结构可表达正常和降级 period,`(commodity, period_start)` 幂等。
- [ ] Decimal 与 JSON snapshot 可精确 round-trip,无 binary float。
- [ ] 现有 electricity 账本及行数完全不受 migration 影响。
- [ ] `APP_BASELINE_REVISION` 等于唯一 revision 19 head。
- [ ] `pytest``ruff check .` 全绿。
**Reviewer checklist**
- 降级 nullable 不能放松正常写入的 service invariantFK 删除策略必须保护审计。
- baseline 常量是否随 revision 19 同步,测试路径是否与生产 DB/volume 完全隔离。
- period timezone/半开区间、唯一键和 Numeric scale 是否足以重算。
### M8-T15 — Thermal 15 分钟成本引擎与调度 [structural]
@@ -1022,7 +1074,8 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
overlay 合并、device path、非 root、COPY source 存在。
3. 写运行文档:识别稳定 by-id、查 GID、启停 overlay、创建 source、discover/bind、质量含义、
reconnect/权限排障、backup/migration、合同录入、HA toggle 与安全回滚。明确不删除旧 config/data。
4.备份数据库副本演练 Alembic 14→head 并对账;新空库迁移到 head。
4. `/tmp` 下构造的隔离合成历史数据库演练 Alembic 14→head 并对账;新空库迁移到 head。
不挂载、复制或打开运行中 production 的数据库、容器或 volume。
5. 跑全量后端/前端/OpenAPI/codegen 闸门和真实 `docker build`;启动迁移后的 app,确认关键路由
不 500。
6. 按 §12 人工 walkthrough 走完整链;通过后把 Roadmap/设计索引/M8 状态与 T01T20 Status