[governance] 无关源同步触发退费宽表全量重建:空集语义反了 + 缺相关源白名单 #88

Closed
opened 2026-09-14 14:51:31 +08:00 by tanzk · 1 comment
Owner

背景

生产实测:同步「设备套餐」(device_combo) 会触发 refund_wide 退费宽表全量重建(66,451 行 / 约 50s)。

2026-09-14 14:44:26 [device_combo] 文件下载成功
2026-09-14 14:45:35 [device_combo] 导入完成: 新增 3307 / 更新 78234 / 失败 0
2026-09-14 14:46:28 post-sync 钩子完成: wide={'built': 66451, 'mode': 'full', 'deleted': 165}
                                  ↑ refund_wide 全量重建(无谓开销)

device_combo 与退费宽表毫无关系:wide_table_builder.py / matcher.py 中零引用 device_combo。
宽表纯属被 post-sync 钩子"顺带"跑起来。

影响范围

  • 每个与宽表无关的源同步(device_combo / cs_record / bms_order / SCRM 系列等)都会全量重建退费宽表
  • 单次浪费约 50s CPU/IO,期间 DB 连接与 IO 被占用(影响看板响应)
  • 定时同步 8:00 全量兜底等多源任务会把该开销叠加多次
  • 无数据正确性影响(重建结果与重建前一致,只是白跑)

根因判断

三处缺陷(backend/app/services/bms_sync.py + backend/app/api/v1/uploads.py):

# 位置 问题
1 bms_sync.py use_incremental = len(all_affected) > 0 and ... 空集语义搞反:all_affected 为空 = "本次导入未影响宽表任何一行",正确动作是跳过,却降级为全量重建
2 bms_sync.py any_success = any(r.get("success") ...) post-sync 只看"有没有任一源成功",不判断该源是否与宽表相关
3 uploads.py _run_post_import_tasks() build_refund_wide(db) 无条件全量,data_type 仅用于打日志

铁证:同一钩子内的 cash_flow_wide 就没被触发

同一段代码里 cash_flow_wide 有相关源白名单(_cfw_relevant),实测生产:

14:35:47 cash_flow_wide 重建完成          ← refund 同步,相关源 ✓
14:46:28 post-sync 完成: wide=full(66451)  ← device_combo 同步,refund_wide 无白名单 → 全量 ✗
                                            cash_flow_wide 无日志 = 已正确跳过 ✓

同一轮同步、同一钩子,cash_flow_wide 被白名单挡住、refund_wide 没有 → 行为不一致,确证缺陷。

处理动作 / 设计要求

按建议 A + B 修复(保守 + 正确):

  • A. 修正空集语义:all_affected 为空且本次无任何宽表相关源导入时 → 跳过宽表重建(记录 mode='skipped' 与原因),不再降级全量
  • B. 补相关源白名单:定义 REFUND_WIDE_SOURCES = (refund, ticket, wallet, external_complaint)(即宽表实际消费的全部上游源,与 _collect_affected_from_imports 的分支一一对应),在 bms_sync.py post-sync 与 uploads.py _run_post_import_tasks 两处都按白名单判定,与 cash_flow_wide 的 _cfw_relevant 写法对齐
  • 判定逻辑分层(关键:不得因"跳过"而漏采):
    • 无关源导入 + matcher 无新增关系 → 跳过(mode='skipped')
    • 有相关源导入或 matcher 有新增关系 → 维持原增量/全量判定
    • 即:相关源导入过数据时,绝不跳过(保留既有兜底,避免"导入但窗口过滤不到"的漏采风险)
  • 补充日志字段便于后续审计(mode / skipped_reason / relevant_sources)
  • 新增/补充单元测试锁定该行为

不做(显式取舍):

  • ❌ 不跳过 match_all:本次仅确认宽表为无谓开销;matcher 耗时与无关源的关系未量化,不在本单扩大范围(如后续需要另开单)
  • ❌ 不改 _collect_affected_from_imports 的时间窗口过滤逻辑:属另一类潜在漏采问题(导入旧窗口数据不被收集),需独立评估,不在本单
  • ❌ 不改 device_combo 每次全量 upsert(78234 更新):属该源自身的幂等/去重议题,与本单无关,另开单

验收标准 / 关闭条件

  1. 单测通过:新增用例覆盖「无关源同步 + matcher 无新增 → 不调用 build_refund_wide」与「相关源导入 → 仍重建」
  2. 生产实测:同步 device_combo 后,后端日志中不再出现 refund_wide 全量重建,出现 mode='skipped'(或等价的跳过日志)
  3. 生产实测:同步 refund(相关源)后,宽表仍正常重建(增量或全量),数据无缺失(对比重建前后行数与 stats 接口)
  4. 无回归:pytest 全量通过;ruff 全绿
  5. 两处通道一致:bms_sync.py 与 uploads.py 均按白名单判定(代码可见 + 测试覆盖)

关联

  • 相关规范:docs/standards/数据同步与加工说明.md(同步链路)
  • 相关记忆:wide-table-full-rebuild-matcher-bug-20260905(同类"永远全量"根因)、
    wide-table-purpose-audit-20260908(宽表定位与真实消费方)
  • 关联工单:#41(cash_flow_wide 引入,其白名单写法即本次对齐范本)

同单扩展(2026-09-14 晚,用户拍板不另开单)

用户判断: 这与本单是同一个目标——「宽表触发条件过宽」——只是同一缺陷模式在
cash_flow_wide 上的另一个实例。按 issue-workflow.md §一.3「一单一目标」,
合并后仍有唯一可验收目标(无关源不得触发任何宽表全量重建),故并入本单。

  • 统一 cash_flow_wide 判据到 CASH_FLOW_WIDE_RELEVANT_SOURCES 白名单
    (原先内联元组且不校验写入行数:相关源 success 但 0 行写入仍全量重建 ~12s)
  • ★补漏既有 bug:cash_flow_wide 白名单缺 external_complaint
    —— builder 第 111/153/188/224 行确实读 external_complaints 填
    has_external_complaint,旧判据漏了它 → 外部投诉同步后该列陈旧(该刷不刷)
  • 上传通道 uploads.py 同步改用 is_cash_flow_wide_relevant()
  • 抽取共用筛选 _filter_relevant_sources(),消除重复代码

不做(显式取舍):两张宽表白名单刻意不同(职责不同),已加测试锁死防止被误合并。

## 背景 生产实测:**同步「设备套餐」(`device_combo`) 会触发 `refund_wide` 退费宽表全量重建**(66,451 行 / 约 50s)。 ``` 2026-09-14 14:44:26 [device_combo] 文件下载成功 2026-09-14 14:45:35 [device_combo] 导入完成: 新增 3307 / 更新 78234 / 失败 0 2026-09-14 14:46:28 post-sync 钩子完成: wide={'built': 66451, 'mode': 'full', 'deleted': 165} ↑ refund_wide 全量重建(无谓开销) ``` `device_combo` 与退费宽表**毫无关系**:`wide_table_builder.py` / `matcher.py` 中零引用 `device_combo`。 宽表纯属被 post-sync 钩子"顺带"跑起来。 ## 影响范围 - 每个与宽表无关的源同步(`device_combo` / `cs_record` / `bms_order` / SCRM 系列等)都会全量重建退费宽表 - 单次浪费约 50s CPU/IO,期间 DB 连接与 IO 被占用(影响看板响应) - 定时同步 8:00 全量兜底等多源任务会把该开销叠加多次 - **无数据正确性影响**(重建结果与重建前一致,只是白跑) ## 根因判断 三处缺陷(`backend/app/services/bms_sync.py` + `backend/app/api/v1/uploads.py`): | # | 位置 | 问题 | |---|---|---| | 1 | `bms_sync.py` `use_incremental = len(all_affected) > 0 and ...` | **空集语义搞反**:`all_affected` 为空 = "本次导入未影响宽表任何一行",正确动作是**跳过**,却降级为**全量重建** | | 2 | `bms_sync.py` `any_success = any(r.get("success") ...)` | post-sync 只看"有没有任一源成功",**不判断该源是否与宽表相关** | | 3 | `uploads.py` `_run_post_import_tasks()` | `build_refund_wide(db)` **无条件全量**,`data_type` 仅用于打日志 | ### 铁证:同一钩子内的 `cash_flow_wide` 就没被触发 同一段代码里 `cash_flow_wide` 有相关源白名单(`_cfw_relevant`),实测生产: ``` 14:35:47 cash_flow_wide 重建完成 ← refund 同步,相关源 ✓ 14:46:28 post-sync 完成: wide=full(66451) ← device_combo 同步,refund_wide 无白名单 → 全量 ✗ cash_flow_wide 无日志 = 已正确跳过 ✓ ``` **同一轮同步、同一钩子,`cash_flow_wide` 被白名单挡住、`refund_wide` 没有 → 行为不一致,确证缺陷。** ## 处理动作 / 设计要求 按建议 **A + B** 修复(保守 + 正确): - [x] **A. 修正空集语义**:`all_affected` 为空且**本次无任何宽表相关源导入**时 → **跳过**宽表重建(记录 `mode='skipped'` 与原因),不再降级全量 - [x] **B. 补相关源白名单**:定义 `REFUND_WIDE_SOURCES = (refund, ticket, wallet, external_complaint)`(即宽表实际消费的全部上游源,与 `_collect_affected_from_imports` 的分支一一对应),在 `bms_sync.py` post-sync 与 `uploads.py` `_run_post_import_tasks` **两处**都按白名单判定,与 `cash_flow_wide` 的 `_cfw_relevant` 写法对齐 - [x] 判定逻辑**分层**(关键:不得因"跳过"而漏采): - 无关源导入 + matcher 无新增关系 → **跳过**(`mode='skipped'`) - 有相关源导入或 matcher 有新增关系 → 维持原增量/全量判定 - 即:**相关源导入过数据时,绝不跳过**(保留既有兜底,避免"导入但窗口过滤不到"的漏采风险) - [x] 补充日志字段便于后续审计(`mode` / `skipped_reason` / `relevant_sources`) - [x] 新增/补充单元测试锁定该行为 **不做**(显式取舍): - ❌ **不跳过 `match_all`**:本次仅确认宽表为无谓开销;matcher 耗时与无关源的关系未量化,不在本单扩大范围(如后续需要另开单) - ❌ **不改 `_collect_affected_from_imports` 的时间窗口过滤逻辑**:属另一类潜在漏采问题(导入旧窗口数据不被收集),需独立评估,不在本单 - ❌ **不改 `device_combo` 每次全量 upsert(78234 更新)**:属该源自身的幂等/去重议题,与本单无关,另开单 ## 验收标准 / 关闭条件 1. **单测通过**:新增用例覆盖「无关源同步 + matcher 无新增 → 不调用 `build_refund_wide`」与「相关源导入 → 仍重建」 2. **生产实测**:同步 `device_combo` 后,后端日志中**不再出现** `refund_wide` 全量重建,出现 `mode='skipped'`(或等价的跳过日志) 3. **生产实测**:同步 `refund`(相关源)后,宽表**仍正常重建**(增量或全量),数据无缺失(对比重建前后行数与 `stats` 接口) 4. **无回归**:`pytest` 全量通过;`ruff` 全绿 5. **两处通道一致**:`bms_sync.py` 与 `uploads.py` 均按白名单判定(代码可见 + 测试覆盖) ## 关联 - 相关规范:`docs/standards/数据同步与加工说明.md`(同步链路) - 相关记忆:`wide-table-full-rebuild-matcher-bug-20260905`(同类"永远全量"根因)、 `wide-table-purpose-audit-20260908`(宽表定位与真实消费方) - 关联工单:#41(cash_flow_wide 引入,其白名单写法即本次对齐范本) --- ## 同单扩展(2026-09-14 晚,用户拍板不另开单) 用户判断: 这与本单是**同一个目标**——「宽表触发条件过宽」——只是同一缺陷模式在 `cash_flow_wide` 上的另一个实例。按 `issue-workflow.md` §一.3「一单一目标」, 合并后仍有唯一可验收目标(**无关源不得触发任何宽表全量重建**),故并入本单。 - [x] 统一 `cash_flow_wide` 判据到 `CASH_FLOW_WIDE_RELEVANT_SOURCES` 白名单 (原先内联元组且**不校验写入行数**:相关源 success 但 0 行写入仍全量重建 ~12s) - [x] ★**补漏既有 bug**:`cash_flow_wide` 白名单缺 `external_complaint` —— builder 第 111/153/188/224 行确实读 `external_complaints` 填 `has_external_complaint`,旧判据漏了它 → 外部投诉同步后该列陈旧(该刷不刷) - [x] 上传通道 `uploads.py` 同步改用 `is_cash_flow_wide_relevant()` - [x] 抽取共用筛选 `_filter_relevant_sources()`,消除重复代码 **不做**(显式取舍):两张宽表白名单**刻意不同**(职责不同),已加测试锁死防止被误合并。
Author
Owner

关闭凭证核验 (2026-09-14)

提交: 927e42c(阶段一:空集语义 + refund_wide 白名单)· b496779(阶段二:cash_flow_wide 判据统一 + 补漏)
生产版本: git-20260914150934
测试: backend/tests/test_refund_wide_trigger_scope.py(36 项)

逐条对应正文「验收标准 / 关闭条件」:

# 关闭条件 可复现核验方式 结论
1 单测覆盖「无关源 + matcher 无新增 → 不重建」与「相关源 → 仍重建」 pytest tests/test_refund_wide_trigger_scope.py -q → 36 passed;含 test_upload_device_combo_does_not_rebuild_wide / test_upload_refund_does_rebuild_wide ✅
2 生产实测:同步 device_combo 后不再全量重建,出现 skipped 见下方【实测①】mode='skipped' + 两条跳过日志 ✅
3 生产实测:同步 refund 后宽表仍正常重建,数据无缺失 见下方【实测②】refund_wide mode='incremental' built=6;cash_flow_wide total=61035 ✅
4 无回归:pytest 全量通过;ruff 全绿 pytest → 1221 passed / 42 skipped;ruff check app/ tests/ alembic/ → All checks passed ✅
5 两处通道一致(bms_sync + uploads 均按白名单) 代码:bms_sync.py cash_flow_wide_relevant_sources() / uploads.py is_cash_flow_wide_relevant();测试:test_upload_cash_flow_relevant_sources_rebuild(6 源参数化) ✅

★ 实测①:无关源(设备套餐)→ 两张宽表都跳过

生产容器内真实触发 sync_all(data_types=['device_combo']):

[device_combo] 81693 行 → inserted=42 updated=81509   ← 同步本身正常
[post-sync] 本次同步无退费宽表相关源写入(refund/ticket/wallet/external_complaint
            均未写数据)且匹配无新增, 跳过 refund_wide 重建
[post-sync] 本次同步无现金流水宽表相关源写入(refund/wallet/shanmao/manual/E/
            external_complaint), 跳过 cash_flow_wide 重建
post-sync 钩子完成: wide={'mode': 'skipped', 'skip_reason':
                            'no_relevant_source_written', 'built': 0}

修复前同一动作(14:44 那轮):wide={'built': 66451, 'mode': 'full'} ≈ 50s 白跑。
修复后:built=0, mode='skipped',两张宽表均不重建。✅

★ 实测②:相关源(退费单)→ 两张宽表都重建(防漏刷)

[refund] inserted=7 updated=299
refund_wide 构建完成: built=6, skipped=0, deleted=0, target=6   ← 增量
[post-sync] cash_flow_wide 重建完成(relevant=refund): {'total': 61035, ...}
post-sync 钩子完成: wide={'mode': 'incremental', 'affected_count': 6,
                            'relevant_sources': ['refund']}

即「跳过」只对无关源生效,相关源一律照常重建 → 不存在因本次修复而漏刷。✅

关于关闭条件外的一项说明

正文「不做」列表中的三项目前确实未做(未跳过 match_all、未改
_collect_affected_from_imports 窗口过滤、未改 device_combo 全量 upsert),
与正文声明一致,非降级验收。

边界说明(诚实记录)

  • 测试用例:36 passed ✅
  • 全量测试:1221 passed / 42 skipped ✅
  • ruff:All checks passed ✅

结论:5 条关闭条件全部满足,予以关闭。

## 关闭凭证核验 (2026-09-14) **提交**: `927e42c`(阶段一:空集语义 + refund_wide 白名单)· `b496779`(阶段二:cash_flow_wide 判据统一 + 补漏) **生产版本**: `git-20260914150934` **测试**: `backend/tests/test_refund_wide_trigger_scope.py`(36 项) 逐条对应正文「验收标准 / 关闭条件」: | # | 关闭条件 | 可复现核验方式 | 结论 | |---|---|---|---| | 1 | 单测覆盖「无关源 + matcher 无新增 → 不重建」与「相关源 → 仍重建」 | `pytest tests/test_refund_wide_trigger_scope.py -q` → **36 passed**;含 `test_upload_device_combo_does_not_rebuild_wide` / `test_upload_refund_does_rebuild_wide` | ✅ | | 2 | 生产实测:同步 device_combo 后不再全量重建,出现 skipped | 见下方【实测①】`mode='skipped'` + 两条跳过日志 | ✅ | | 3 | 生产实测:同步 refund 后宽表仍正常重建,数据无缺失 | 见下方【实测②】`refund_wide mode='incremental' built=6`;`cash_flow_wide total=61035` | ✅ | | 4 | 无回归:pytest 全量通过;ruff 全绿 | `pytest` → **1221 passed / 42 skipped**;`ruff check app/ tests/ alembic/` → All checks passed | ✅ | | 5 | 两处通道一致(bms_sync + uploads 均按白名单) | 代码:`bms_sync.py` `cash_flow_wide_relevant_sources()` / `uploads.py` `is_cash_flow_wide_relevant()`;测试:`test_upload_cash_flow_relevant_sources_rebuild`(6 源参数化) | ✅ | ### ★ 实测①:无关源(设备套餐)→ 两张宽表**都跳过** 生产容器内真实触发 `sync_all(data_types=['device_combo'])`: ``` [device_combo] 81693 行 → inserted=42 updated=81509 ← 同步本身正常 [post-sync] 本次同步无退费宽表相关源写入(refund/ticket/wallet/external_complaint 均未写数据)且匹配无新增, 跳过 refund_wide 重建 [post-sync] 本次同步无现金流水宽表相关源写入(refund/wallet/shanmao/manual/E/ external_complaint), 跳过 cash_flow_wide 重建 post-sync 钩子完成: wide={'mode': 'skipped', 'skip_reason': 'no_relevant_source_written', 'built': 0} ``` **修复前**同一动作(14:44 那轮):`wide={'built': 66451, 'mode': 'full'}` ≈ 50s 白跑。 **修复后**:`built=0, mode='skipped'`,两张宽表均不重建。✅ ### ★ 实测②:相关源(退费单)→ 两张宽表**都重建**(防漏刷) ``` [refund] inserted=7 updated=299 refund_wide 构建完成: built=6, skipped=0, deleted=0, target=6 ← 增量 [post-sync] cash_flow_wide 重建完成(relevant=refund): {'total': 61035, ...} post-sync 钩子完成: wide={'mode': 'incremental', 'affected_count': 6, 'relevant_sources': ['refund']} ``` 即「跳过」只对无关源生效,**相关源一律照常重建** → 不存在因本次修复而漏刷。✅ ### 关于关闭条件外的一项说明 正文「不做」列表中的三项目前**确实未做**(未跳过 `match_all`、未改 `_collect_affected_from_imports` 窗口过滤、未改 `device_combo` 全量 upsert), 与正文声明一致,非降级验收。 ### 边界说明(诚实记录) - 测试用例:**36 passed** ✅ - 全量测试:**1221 passed / 42 skipped** ✅ - ruff:**All checks passed** ✅ **结论:5 条关闭条件全部满足,予以关闭。**
tanzk closed this issue 2026-09-14 15:13:49 +08:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tanzk/csctalon#88