# 设计文档:DWD 第一阶段重构 ## 概述 本次重构针对 `DwdLoadTask`(`tasks/dwd/dwd_load_task.py`),目标是: 1. 统一事实表增量窗口模式,消除水位线与窗口两套并行的范围过滤 2. 删除回补机制(`_insert_missing_by_pk`),简化事实表写入路径 3. 清理已确认的死代码、未使用常量和方法 4. 修复 `_build_column_mapping()` 的参数 bug 所有改动集中在 `dwd_load_task.py` 一个文件(加上删除 `base_dwd_task.py`、更新两个外部引用),属于低风险重构。重构后代码路径更清晰,为第二阶段架构重构(E/T/L → `process_segment` 钩子)做好准备。 ## 架构 ### 当前架构(重构前) ```mermaid graph TD A[DwdLoadTask.load] --> B{事实表?} B -->|是| C{use_window?} C -->|是| D["_merge_fact_increment(window_start, window_end)"] C -->|否| E["_get_fact_watermark() → watermark"] E --> F["_merge_fact_increment(watermark)"] D --> G["_insert_missing_by_pk(回补)"] F --> G B -->|否| H{scd_cols_present?} H -->|是| I[_merge_dim_scd2] H -->|否| J[_merge_dim_type1_upsert] ``` ### 目标架构(重构后) ```mermaid graph TD A[DwdLoadTask.load] --> B{事实表?} B -->|是| D["_merge_fact_increment(window_start, window_end)"] D --> R[返回计数] B -->|否| I[_merge_dim_scd2] ``` 关键变化: - 事实表路径:消除 `use_window` 分支判断,始终传 `context.window_start/window_end`;删除水位线获取和回补步骤 - 维度表路径:消除 `scd_cols_present` 条件判断,直接调用 `_merge_dim_scd2`(所有 17 张维度表都有 SCD2 列) ## 组件与接口 ### 变更组件清单 | 组件 | 变更类型 | 说明 | |------|----------|------| | `DwdLoadTask.load()` | 修改 | 删除 `use_window` 判断,始终传 `context.window_start/window_end` | | `DwdLoadTask._merge_fact_increment()` | 修改 | 删除 `watermark` 分支和回补调用;`window_start`/`window_end` 改为必填参数 | | `DwdLoadTask._merge_dim()` | 修改 | 删除 Type1 分支,直接调用 `_merge_dim_scd2` | | `DwdLoadTask._build_column_mapping()` | 修改 | 将 `ods_table` 和 `cur` 加入方法签名 | | `DwdLoadTask._get_fact_watermark()` | 删除 | 水位线机制不再需要 | | `DwdLoadTask._insert_missing_by_pk()` | 删除 | 回补机制不再需要 | | `DwdLoadTask._pick_order_column()` | 删除 | 从未被调用的死代码 | | `DwdLoadTask._merge_dim_type1_upsert()` | 删除 | Type1 分支永远不触发 | | `DwdLoadTask._upsert_scd2_row()` | 删除 | 已被批量方法替代 | | `DwdLoadTask._close_current_dim()` | 删除 | 已被 `_close_current_dim_bulk` 替代 | | `DwdLoadTask._insert_dim_row()` | 删除 | 已被 `_insert_dim_rows_bulk` 替代 | | `FACT_ORDER_CANDIDATES` 常量 | 删除 | 配合 `_pick_order_column` 删除 | | `FACT_MISSING_FILL_TABLES` 常量 | 删除 | 配合回补机制删除 | | `base_dwd_task.py` | 删除文件 | 死代码,从未被使用 | | `debug_dwd.py` | 修改 | 内联时间列候选列表,替代对 `FACT_ORDER_CANDIDATES` 的引用 | | `integrity_checker.py` | 修改 | 内联时间列候选列表,替代对 `FACT_ORDER_CANDIDATES` 的引用 | ### 接口变更详情 #### `_merge_fact_increment()` 签名变更 ```python # 重构前 def _merge_fact_increment( self, cur, dwd_table, ods_table, dwd_cols, ods_cols, dwd_types, ods_types, window_start: datetime | None = None, # 可选 window_end: datetime | None = None, # 可选 ) -> Dict[str, int]: # 重构后 def _merge_fact_increment( self, cur, dwd_table, ods_table, dwd_cols, ods_cols, dwd_types, ods_types, window_start: datetime, # 必填 window_end: datetime, # 必填 ) -> Dict[str, int]: ``` #### `_build_column_mapping()` 签名变更 ```python # 重构前(bug:引用了外部作用域的 ods_table 和 cur) def _build_column_mapping( self, dwd_table: str, pk_cols: Sequence[str], ods_cols: Sequence[str] ) -> Dict[str, tuple[str, str | None]]: # 重构后 def _build_column_mapping( self, cur, dwd_table: str, ods_table: str, pk_cols: Sequence[str], ods_cols: Sequence[str] ) -> Dict[str, tuple[str, str | None]]: ``` #### `_merge_dim()` 简化 ```python # 重构前 def _merge_dim(self, cur, dwd_table, ods_table, dwd_cols, ods_cols, now): pk_cols = self._get_primary_keys(cur, dwd_table) scd_cols_present = any(c.lower() in self.SCD_COLS for c in dwd_cols) if scd_cols_present: return self._merge_dim_scd2(...) return self._merge_dim_type1_upsert(...) # 重构后 def _merge_dim(self, cur, dwd_table, ods_table, dwd_cols, ods_cols, now): return self._merge_dim_scd2(cur, dwd_table, ods_table, dwd_cols, ods_cols, now) ``` #### `load()` 事实表分支简化 ```python # 重构前 use_window = bool( self.config.get("run.window_override.start") and self.config.get("run.window_override.end") ) fact_counts = self._merge_fact_increment( ..., window_start=context.window_start if use_window else None, window_end=context.window_end if use_window else None, ) # 重构后 fact_counts = self._merge_fact_increment( ..., window_start=context.window_start, window_end=context.window_end, ) ``` ### 外部引用处理 `FACT_ORDER_CANDIDATES` 被两个外部模块引用,删除后需要同步处理: 1. **`scripts/debug/debug_dwd.py`**:将候选列表内联为模块级常量 `_TIME_COLUMN_CANDIDATES` 2. **`quality/integrity_checker.py`**:将候选列表内联为模块级常量 `_TIME_COLUMN_CANDIDATES` 两处内联的列表内容与原 `FACT_ORDER_CANDIDATES` 相同:`["pay_time", "create_time", "update_time", "occur_time", "settle_time", "start_use_time", "fetched_at"]`。这些模块的用途是调试和完整性检查,与 DWD 装载的核心逻辑无关,内联不会引入耦合问题。 ## 数据模型 本次重构不涉及数据库 schema 变更。所有 DWD 表结构、ODS 表结构、`FACT_MAPPINGS` 和 `TABLE_MAP` 保持不变。 变更仅影响运行时行为: - 事实表增量 SQL 的 WHERE 条件从"水位线或窗口"统一为"窗口" - 回补 LEFT JOIN SQL 不再执行 - 维度表合并路径从"SCD2 或 Type1"统一为"SCD2" ## 正确性属性 *属性(Property)是一种在系统所有有效执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。* 基于验收标准的前置分析,以下属性覆盖了本次重构的核心正确性要求。静态代码结构检查(方法/常量/文件是否存在)通过单元测试覆盖,不作为属性列出。 ### Property 1: 事实表增量 SQL 始终使用窗口范围条件 *For any* 有效的事实表映射(`TABLE_MAP` 中 `dwd_` 前缀的表)和任意的 `window_start`/`window_end` 时间对(`window_start < window_end`),`_merge_fact_increment()` 生成的主增量 SQL 的 WHERE 子句 SHALL 包含 `fetched_at >= window_start AND fetched_at < window_end` 条件,且不包含单边水位线条件(`fetched_at > watermark`)。 **Validates: Requirements 1.1, 1.2** ### Property 2: 事实表增量不执行回补 SQL *For any* 有效的事实表映射和任意的窗口参数,`_merge_fact_increment()` 执行的 SQL 语句列表中 SHALL 不包含 LEFT JOIN 回补查询(即不包含 `LEFT JOIN ... WHERE ... IS NULL` 模式的 SQL)。 **Validates: Requirements 2.3** ### Property 3: 事实表主增量 SQL 结构等价性 *For any* 有效的事实表映射、列集合和窗口参数,重构后 `_merge_fact_increment()` 生成的主增量 `INSERT INTO ... SELECT ... ON CONFLICT` SQL 的结构(列列表、SELECT 表达式、ON CONFLICT 子句)SHALL 与重构前窗口模式(`use_window=True`)生成的 SQL 结构相同。 **Validates: Requirements 5.2, 5.5** ### Property 4: 维度表始终走 SCD2 路径 *For any* 有效的维度表映射(`TABLE_MAP` 中 `dim_` 前缀的表),调用 `_merge_dim()` SHALL 始终委托给 `_merge_dim_scd2()`,不经过任何条件分支判断。 **Validates: Requirements 3.8, 5.1** ## 错误处理 本次重构不引入新的错误处理逻辑,保留现有机制: | 场景 | 处理方式 | 变更 | |------|----------|------| | 单表事实增量 SQL 执行失败(含类型转换错误) | `load()` 中 `try/except` 捕获,回滚该表事务,记录错误日志,继续处理下一张表 | 无变更(需求 2.4:异常自然传播到 load() 层面) | | 维度表 SCD2 合并失败 | 同上 | 无变更 | | ODS 表缺少 `fetched_at` 列 | `_merge_fact_increment` 记录 error 日志并返回零计数 | 无变更 | | DWD 表无法获取列信息 | `load()` 记录 warning 并跳过该表 | 无变更 | 删除回补机制后,原本由回补兜底的"部分行丢失"场景不再存在——类型转换失败会导致整条 SQL 报错、整张表事务回滚,这是正确的行为(需求 2.4)。 ## 测试策略 ### 测试框架 - 单元测试:`pytest`,使用 `FakeDB`/`FakeCursor`(`tests/unit/task_test_utils.py`) - 属性测试:`hypothesis`,最少 100 次迭代 - 测试位置: - 单元测试:`apps/etl/pipelines/feiqiu/tests/unit/test_dwd_phase1_refactor.py` - 属性测试:`tests/test_dwd_phase1_properties.py`(monorepo 根目录) ### 双轨测试方法 **单元测试**覆盖: - 静态代码结构验证(死代码/常量/方法已删除) - `_build_column_mapping()` bug 修复后的调用正确性 - `_merge_dim()` 直接调用 SCD2 的行为 - 外部模块(`debug_dwd.py`、`integrity_checker.py`)导入不报错 - 表过滤功能(`dwd.only_tables`)回归 **属性测试**覆盖: - Property 1: 事实表 SQL 窗口条件(通过 FakeCursor 捕获 SQL,验证 WHERE 子句) - Property 2: 无回补 SQL(通过 FakeCursor 捕获所有执行的 SQL,验证无 LEFT JOIN) - Property 3: SQL 结构等价性(对比重构前后生成的 SQL) - Property 4: 维度表 SCD2 路径(通过 mock 验证调用链) ### 属性测试配置 - 库:`hypothesis` - 每个属性最少 100 次迭代 - 每个属性测试标注对应的设计属性编号 - 标注格式:`# Feature: dwd-phase1-refactor, Property N: {property_text}` ### 测试数据生成策略 使用 `hypothesis` 的 `@st.composite` 策略生成: - 随机的 `window_start`/`window_end` 时间对(确保 `start < end`) - 随机的列名集合(确保包含 `fetched_at` 和至少一个主键列) - 随机的 `FACT_MAPPINGS` 条目(列名 → 源列 + 可选类型转换) 使用 `FakeCursor` 捕获执行的 SQL 语句,而非实际连接数据库。