# OdsTaskSpec 参数优化方案

> 状态：方案 1-4 已完成 ✅ · 方案 5 中长期待办
> 完成日期：2026-02-17
> 范围：`tasks/ods/ods_tasks.py` 中的 `OdsTaskSpec` 数据类及 `BaseOdsTask.execute()` 相关逻辑

---

## 背景

当前 `OdsTaskSpec` 有 8 个关键配置参数。经逐一审查，其中 3 个存在设计问题，1 个有轻微隐式耦合。这些问题不影响当前功能正确性，但会增加维护成本、降低可读性、埋下未来改动时的陷阱。

---

## 方案 1：清理 OdsTaskSpec 无效/冗余配置 ✅ 已完成（2026-02-17）

### 实施内容

在 `ods_tasks.py` 中完成了以下改造：

- 新增 `SnapshotMode` 枚举（NONE / FULL_TABLE / WINDOW），替代 `snapshot_full_table` + `snapshot_window_columns` 的组合字段
- 从 OdsTaskSpec 中删除 7 个无效/冗余字段：
  - `conflict_columns_override` — 运行时不生效，仅声明性标注
  - `include_site_column` — 全部 False，硬编码移除
  - `include_page_no` — 全部 False，硬编码移除
  - `include_page_size` — 全部 False，硬编码移除
  - `snapshot_full_table` — 被 `SnapshotMode` 替代
  - `snapshot_window_columns` — 被 `SnapshotMode` + `snapshot_time_column` 替代
  - `enable_content_hash_dedup` — 被 `skip_unchanged` 替代
- 新增字段：`skip_unchanged: bool = True`、`snapshot_mode: SnapshotMode`、`snapshot_time_column: str | None`
- `__post_init__` 校验：WINDOW 必须有 `snapshot_time_column`，FULL_TABLE/NONE 不能有
- 23 个 ODS_TASK_SPECS 声明全部迁移到新字段

### 与原提案的对应关系

- 原"问题三（`conflict_columns_override` 默认值设反）"→ 直接删除该字段（PK 唯一来源为 DDL）
- 原"无问题的参数"中 `include_site_column`、`snapshot_full_table`、`snapshot_window_columns` → 重新评估后删除或替代
- 原"问题二方案 A（显式去重开关）"→ 以 `skip_unchanged=True` 实现，默认开启

---

## 方案 2：默认开启 skip_unchanged + hash 算法改为 payload + is_delete ✅ 已完成（2026-02-17）

### 实施内容

- `skip_unchanged` 默认值为 `True`（原 `enable_content_hash_dedup` 默认 False，22/23 任务关闭）
- `_compute_content_hash` 改为基于原始 payload JSON + is_delete 计算 SHA-256
  - 输入：`json.dumps(payload, sort_keys=True, separators=(',',':'), ensure_ascii=False)` + `|` + `is_delete`
  - 删除 `_sanitize_record_for_hash` 方法（不再需要字段排除逻辑）
- 所有 23 个任务默认开启去重，内容不变则跳过写入

### 与原提案的对应关系

- 原"问题二（`include_fetched_at` 副作用隐蔽）"→ 通过改用 payload + is_delete 算 hash，彻底消除了 `include_fetched_at` 对去重的隐式影响
- 原"问题二方案 A（显式去重开关）"→ 以 `skip_unchanged` 实现，去重行为透明可控

---

## 方案 3：DDL 迁移 — 添加"取最新版本"复合索引 ✅ 已完成（2026-02-17）

### 实施内容

- 迁移脚本：`db/etl_feiqiu/migrations/2026-02-17__add_ods_latest_version_indexes.sql`
- 为全部 23 张 ODS 表创建 `(业务主键, fetched_at DESC)` 复合索引
- 使用 `CREATE INDEX CONCURRENTLY IF NOT EXISTS`，保证幂等且不锁表
- 索引命名规范：`idx_ods_{table_name}_latest`
- 同步更新 `docs/database/ddl/etl_feiqiu__ods.sql` 中的索引定义

### 用途

支持 `DISTINCT ON (id) ORDER BY id, fetched_at DESC` 的高效查询模式，供 DWD 层取最新版本使用。

---

## 方案 4：软删除改为"插入删除版本行" ✅ 已完成（2026-02-17）

### 实施内容

- `_mark_missing_as_deleted` 从 `UPDATE ... SET is_delete=1` 改为 INSERT 一条 `is_delete=1` 的新版本行
- 历史版本行完全不变，保持 ODS 追加写入的语义一致性
- 幂等性：若最新版本已是 `is_delete=1`，跳过插入
- 接口变更：`window_columns`/`full_table` 参数改为 `snapshot_mode`/`snapshot_time_column`
- 软删除双路径覆盖：
  - 路径 A（API 返回 is_delete）：通过 is_delete 参与 hash 计算，自动产生新版本行
  - 路径 B（快照空缺）：`_mark_missing_as_deleted` 读取最新版本 → 构造删除版本 → INSERT

---

## 方案 5：冷数据归档 📋 中长期待办

### 问题描述

随着 ODS 层版本行持续累积（尤其是方案 2 切换 hash 算法后首次运行会产生全量新版本），历史数据量将持续增长。需要冷数据归档策略来控制 ODS 表体积。

### 待规划内容

- 归档策略：按 `fetched_at` 时间窗口，将超过 N 天的历史版本行迁移到归档表或外部存储
- 归档粒度：保留每个业务 ID 的最新 K 个版本，其余归档
- 归档触发：定时任务 or 手动触发
- 回溯支持：归档数据仍可按需查询（冷查询路径）

### 优先级

低优先级，待 ODS 数据量达到性能瓶颈时再启动。当前 23 张表的数据量尚在可控范围内。

---

## 原提案中未纳入本次改造的问题

### 问题一：`requires_window` 语义模糊

原提案建议将 `requires_window` 拆分为 `api_supports_time_filter` + `data_model_type`。本次改造未涉及此项，`requires_window` 保持原样。可作为后续独立优化项。

### 轻微问题：`include_record_index` 与 `conflict_columns_override` 隐式耦合

原提案建议在 `__post_init__` 中加校验。由于 `conflict_columns_override` 已在方案 1 中被删除，此耦合问题已自然消解。

---

## 回归验证

本次改造（方案 1-4）配套了完整的属性测试和单元测试：

- 属性测试文件：`tests/unit/test_ods_dedup_properties.py`（8 个 Property，覆盖 SnapshotMode 校验、hash 确定性/区分性、skip_unchanged、记录数闭合、软删除正确性/幂等性/不修改历史）
- 单元测试：适配 `test_ods_tasks.py` 和 `test_debug_ods_properties.py` 到新接口
- 测试框架：pytest + hypothesis
