Files
DashBoard/openspec/changes/archive/2026-03-02-unified-batch-query-redis-cache/design.md

167 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## Context
目前 6 個服務各自處理大查詢,缺乏統一保護:
| 服務 | 查詢類型 | 現有保護 | 缺口 |
|------|---------|---------|------|
| reject-history | 日期 + 工單/Lot/GD 展開 | L1+L2 快取、`read_sql_df_slow` | 無記憶體守衛、`limit=999999999`、缺分塊查詢 |
| hold-history | 日期 | L1+L2 快取、`read_sql_df_slow` | 無記憶體守衛、缺時間分塊 |
| resource-history | 日期 + 設備 ID | L1+L2 快取、1000 筆分批 | 無記憶體守衛 |
| mid-section-defect | 日期 → 偵測 → 族譜 → 上游 | Redis 快取、EventFetcher 分批 | 無偵測數量上限 |
| job-query | 日期 + 設備 ID | 1000 筆分批、`read_sql_df_slow` | **無結果快取**、缺時間分塊 |
| query-tool | 多種 resolver → container ID | 輸入筆數限制、resolve route 短 TTL 快取、EventFetcher 快取 | 多數查詢仍走 `read_sql_df`55s timeout、缺統一分塊編排 |
參考實作:
- `EventFetcher`batch 1000 + ThreadPoolExecutor(2) + `read_sql_df_slow_iter` streaming + Redis 快取 — **已是最佳實作**
- `LineageEngine`batch 1000 + depth limit 20 — **族譜專用引擎**
目標:建立 `BatchQueryEngine` 共用模組,任何服務接入即獲得完整保護。
## Goals / Non-Goals
**Goals:**
- 統一 parquet-in-Redis 存取為共用模組(消除 3 處重複)
- 提供時間範圍分解(長日期 → ~31 天月份區間)
- 提供 ID 批次分解(工單/Lot/GD 展開後的大量 container ID → 1000 筆一批)
- 記憶體守衛:每個 chunk 結果檢查 memory_usage超過閾值中止
- 結果筆數限制:可配置上限,超過時截斷並標記
- 受控並行預設循序、可選並行、semaphore 感知
- Redis 分塊快取 + 部分命中
- 統一使用 `read_sql_df_slow`300 秒 dedicated connection
- 定義 query_hash 與 chunk 邊界語意,避免跨服務行為不一致
- 定義 chunk cache 與服務 L1/L2 dataset cache 互動規則
**Non-Goals:**
- 不修改 SQL 語句本身
- 不引入新的外部依賴
- 不改變前端 API 介面(前端無感知)
- 不替換 EventFetcher / LineageEngine它們已各自最佳化引擎提供可選接入點
- 不改變 trace_job_service 的 RQ 非同步架構
## Decisions
### Decision 1: 提取 `redis_df_store.py` 共用模組
**選擇**:從 reject/hold/resource_dataset_cache 提取相同的 `_redis_store_df` / `_redis_load_df``src/mes_dashboard/core/redis_df_store.py`
**替代方案**(A) 保持各自複製 → 已有 3 處重複,維護困難。
**理由**parquet-in-Redis 是 DataFrame 序列化工具與快取策略TTL、LRU屬不同層次。
### Decision 2: `BatchQueryEngine` 作為工具類而非基底類別
**選擇**:提供獨立函式(`decompose_by_time_range``decompose_by_ids``execute_plan``merge_chunks`),各服務按需調用。
**替代方案**(A) 抽象基底類別 `BaseDatasetCache` → 三個 dataset cache 差異大SQL、policy filter、衍生計算強制繼承會過度耦合。
**理由**:工具類模式讓服務保持現有結構,僅在主查詢路徑決定是否啟用分解。閾值以下的查詢完全不經過引擎。
### Decision 3: 預設循序、可選並行、semaphore 感知
**選擇**`execute_plan(parallel=1)` 預設循序。實際並行上限 = `min(requested, semaphore_available - 1)`
**替代方案**(A) 預設並行 → 可能耗盡 semaphore(B) 完全不並行 → 失去速度。
**理由**Oracle 連線稀缺Production 預設 `DB_SLOW_MAX_CONCURRENT=5`Development 常見為 3。reject_dataset_cache 查詢最重可設 parallel=2其他預設循序最安全。
### Decision 4: 記憶體守衛 + 結果筆數限制
**選擇**:每個 chunk 查詢後檢查 `df.memory_usage(deep=True).sum()`,超過 `BATCH_CHUNK_MAX_MEMORY_MB`(預設 256MB時中止該 chunk 並標記失敗。同時提供 `max_rows_per_chunk` 參數,在 SQL 中加入 `FETCH FIRST N ROWS ONLY`
**替代方案**(A) 無限制 → 現狀OOM 風險高;(B) 全域限制 → 不夠靈活。
**理由**chunk 級別的記憶體守衛是最後一道防線。分解後每個 chunk 的日期/ID 範圍已大幅縮小,記憶體超限通常代表異常資料,應中止而非繼續。
### Decision 5: 分塊快取 + 部分命中
**選擇**Redis 鍵 `batch:{prefix}:{hash}:chunk:{idx}`,每個 chunk 獨立 SETEX。
**替代方案**(A) 只快取最終結果 → 無法部分命中。
**理由**:使用者常見操作是「先查 1-6 月,再查 1-8 月」。分塊快取讓前 6 個月直接複用,只查 7-8 月。
### Decision 6: 引擎路徑統一使用 slow-query 路徑(且不佔用主 pool
**選擇**:所有經過引擎的查詢統一使用 slow-query 路徑300s timeout, semaphore 控制);未經引擎的既有短查詢路徑保持原狀。
慢查詢執行策略採兩層:
1. 主路徑:使用既有獨立 `SLOW POOL`(小容量)做 checkout/checkin。
2. fallback當 SLOW POOL 不可用時,降級為 slow direct connection。
**替代方案**
(A) 引擎路徑混用 `read_sql_df`(主 pool, 55s timeout→ 長查詢高超時風險且會壓縮一般 API 吞吐。
(B) 慢查詢直接共用主 pool → 高峰時造成 pool 爭用與整體延遲放大。
**理由**:經過引擎的查詢本身就是「已知可能很慢」的查詢。慢查詢與主 pool 隔離可避免互相影響SLOW POOL 讓連線重用與隔離同時成立fallback direct connection 保障可用性。
### Decision 7: 部分失敗處理
**選擇**:某個 chunk 失敗時記錄錯誤、繼續剩餘 chunk。`merge_chunks()` 回傳成功部分metadata 標記 `has_partial_failure=True`
**替代方案**(A) 全部回滾 → 已成功的 chunk 浪費。
**理由**歷史報表場景下部分結果比完全失敗更有價值。metadata 標記讓服務可決定是否警告使用者。
### Decision 8: Chunk Cache 與服務 L1/L2 Dataset Cache 互動
**選擇**:先讀 chunk cacheRedis組裝結果組裝後回填既有 service dataset cacheL1 process + L2 Redis以維持現有 `/view` 路徑與 `query_id` 行為。
**替代方案**(A) 只使用 chunk cache不回填 service cache → 現有 view/query_id 流程失效或重複查詢。
**理由**:需要兼容既有 two-phase dataset APIprimary query + cached viewchunk cache 是引擎層優化,不應破壞服務層介面。
### Decision 9: query_hash 規格
**選擇**query_hash 使用 canonical JSONsorted keys、穩定 list 順序、字串正規化)後 SHA-256 前 16 碼hash 僅包含會影響原始資料集合的參數(不含純前端呈現參數)。
**替代方案**(A) 每服務自由實作 hash → 跨服務不可預測且難除錯。
**理由**chunk key、progress key、merge key 需可重現,否則無法保證 cache 命中與部分重用。
### Decision 10: 時間分解邊界語意
**選擇**:採閉區間 chunk `[chunk_start, chunk_end]`;下一段從 `chunk_end + 1 day` 開始;最後一段可小於 grain_days輸入日期以服務既有時區/日界線為準,不在引擎層重新解釋時區。
**替代方案**(A) 半開區間或依月份動態切割但不定義邊界 → 容易重疊或漏資料。
**理由**邊界語意固定後merge 去重、統計一致性與測試可驗證性都會提升。
### Decision 11: 大結果採 Parquet 落地Redis 僅保留 metadata/熱快取
**選擇**:對長查詢(尤其 reject-history引入 spill-to-disk
1. chunk 查詢與 chunk cache 保持現行Redis短 TTL
2. merge 後若結果超過門檻rows / memory / serialized size寫入 Parquet 至本機 spool 目錄
3. Redis 僅保存 metadataquery_id, file_path, row_count, schema_hash, created_at, expires_at
4. `/view`/`/export` 優先透過 metadata 讀取 parquetmetadata 不存在時回退現行 cache 行為
5. 背景清理器定期移除過期 parquet 與孤兒 metadata
**替代方案**
(A) Redis 全量承載所有結果(現況)→ 記憶體壓力高,易引發 lock timeout/OOM 連鎖
(B) 直接落 DB例如 SQLite→ 寫入鎖衝突與維運複雜度高(目前已有 `database is locked` 觀察)
**理由**Redis 是記憶體快取不適合長時間承載大結果Parquet 落地可把大結果轉移到磁碟,降低 worker/Redis 記憶體峰值。
## Risks / Trade-offs
**[Redis 記憶體增長]** → 分塊快取增加 key 數量365 天 ≈ 12 個 chunk key
→ 緩解TTL 自動過期900schunk 結果經 parquet 壓縮(通常 10:1 壓縮比)。
**[Semaphore 爭用]** → 並行 chunk 消耗更多 permit。
→ 緩解:感知可用數量,不足時自動降級循序。預設 parallel=1。
**[時間分解後的資料一致性]** → 不同月份 chunk 在不同時間點查詢。
→ 緩解:歷史報表資料更新頻率低(日級),短窗口內變動極低。可接受。
**[遷移風險]** → 先修改 3 個 dataset cache再擴展至其他服務整體範圍仍大。
→ 緩解:閾值控制(短查詢不經過引擎)+ P0/P1/P2/P3 分階段導入 + 每階段獨立驗證。
**[磁碟 I/O 與容量壓力]** → Parquet 落地會增加磁碟讀寫,若清理策略失效可能累積大量檔案。
→ 緩解:設定 spool 容量上限、TTL 清理、啟動時 orphan 掃描、超限時回退到「不落地僅回應摘要」保護模式。
**[Stale metadata / orphan file]** → Redis metadata 與實體檔案可能不一致。
→ 緩解:讀取前校驗檔案存在與 schema hash不一致時自動失效 metadata 並記錄告警。
## Open Questions
1. `mid_section_defect_service` 的 4 階段管線(偵測 → 族譜 → 上游歷史 → 歸因)中,哪些階段適合接入引擎?偵測查詢可日期分解,但族譜/上游已透過 EventFetcher 處理。
2. `query_tool_service` 有 15+ 種查詢類型是否全部接入還是只處理最易超時的split_merge_history、equipment_period