Files
DashBoard/openspec/changes/archive/2026-03-03-fix-silent-data-loss-reject-history/design.md
egg a275c30c0e feat(reject-history): fix silent data loss by propagating partial failure metadata to frontend
Chunk failures in BatchQueryEngine were silently discarded — `has_partial_failure` was tracked
in Redis but never surfaced to the API response or frontend. Users could see incomplete data
without any warning. This commit closes the gap end-to-end:

Backend:
- Track failed chunk time ranges (`failed_ranges`) in batch engine progress metadata
- Add single retry for transient Oracle errors (timeout, connection) in `_execute_single_chunk`
- Read `get_batch_progress()` after merge but before `redis_clear_batch()` cleanup
- Inject `has_partial_failure`, `failed_chunk_count`, `failed_ranges` into API response meta
- Persist partial failure flag to independent Redis key with TTL aligned to data storage layer
- Add shared container-resolution policy module with wildcard/expansion guardrails
- Refactor reason filter from single-value to multi-select (`reason` → `reasons`)

Frontend:
- Add client-side date range validation (730-day limit) before API submission
- Display amber warning banner on partial failure with specific failed date ranges
- Support generic fallback message for container-mode queries without date ranges
- Update FilterPanel to support multi-select reason chips

Specs & tests:
- Create batch-query-resilience spec; update reject-history-api and reject-history-page specs
- Add 7 new tests for retry, memory guard, failed ranges, partial failure propagation, TTL
- Cross-service regression verified (hold, resource, job, msd — 411 tests pass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:00:07 +08:00

6.1 KiB
Raw Blame History

Context

報廢歷史查詢使用 BatchQueryEngine 將長日期範圍拆成 10 天 chunks 平行查詢 Oracle。每個 chunk 有記憶體上限256 MB和 timeout300s防護。當 chunk 失敗時,has_partial_failure 旗標寫入 Redis HSETkey: batch:reject:{hash}:meta),但此資訊在三個斷點被丟失

  1. reject_dataset_cache.pyexecute_primary_query() 未讀取 batch progress metadata
  2. API route 直接 jsonify({"success": True, **result}),在 partial chunk failure 路徑下仍回 HTTP 200 + success: true,不區分完整與不完整結果
  3. 前端 App.vue 沒有任何 partial failure 處理邏輯

另一個問題:redis_clear_batch()execute_primary_query() 的清理階段會刪除 metadata key所以讀取必須在清理之前。

前端的 730 天日期上限驗證只在後端 _validate_range() 做,前端缺乏即時回饋。

Goals / Non-Goals

Goals:

  • has_partial_failure 從 Redis metadata 傳遞到 API response meta 欄位
  • 追蹤失敗 chunk 的時間範圍,讓前端可顯示具體的缺漏區間
  • 前端顯示 amber warning banner告知使用者資料可能不完整
  • 前端加入日期範圍即時驗證,避免無效 API 請求
  • 對 transient errorOracle timeout、連線失敗加入單次重試減少不必要的 partial failure
  • 持久化 partial failure 旗標到獨立 Redis key讓 cache-hit 路徑也能還原警告狀態

Non-Goals:

  • 不改變現有 chunk 分片策略或記憶體上限數值
  • 不實作前端的自動重查/重試機制
  • 不修改 EVENT_FETCHER_ALLOW_PARTIAL_RESULTS 的行為(預設已是安全的 false
  • 不加入 progress bar / 即時進度追蹤 UI

Decisions

D1: 在 redis_clear_batch 之前讀取 metadata

決定: 在 execute_primary_query() 中,merge_chunks() 之後、redis_clear_batch() 之前,呼叫 get_batch_progress("reject", engine_hash) 讀取 partial failure 狀態。

理由: redis_clear_batch 會刪除包含 metadata 的 key之後就讀不到了。此時 chunk 資料已合併完成,是最後可讀取 metadata 的時機點。

D2: 用獨立 Redis key 持久化 partial failure flagTTL 對齊實際資料層

決定: 在 _store_query_result() 之後,將 partial failure 資訊存到 reject_dataset:{query_id}:partial_failure Redis HSET。TTL 必須與資料實際存活的層一致:若資料 spill 到 parquet spool_REJECT_ENGINE_SPOOL_TTL_SECONDS = 21600spartial failure flag 的 TTL 也要用 21600s若資料存在 L1/L2_CACHE_TTL = 900sflag TTL 用 900s。實作方式_store_partial_failure_flag() 接受 ttl 參數,由呼叫端根據 should_spill 判斷傳入 _REJECT_ENGINE_SPOOL_TTL_SECONDS_CACHE_TTL。Cache-hit 路徑透過 _load_partial_failure_flag(query_id) 還原。

替代方案 A: 將 flag 嵌入 DataFrame 的 attrs 或另外 pickle。 為何不採用: DataFrame attrs 在 parquet 序列化時會丟失pickle 增加反序列化風險。

替代方案 B: 固定 TTL=900s。 為何不採用: 大查詢 spill 到 parquet spool21600s TTL資料還能讀 6 小時,但 partial failure flag 15 分鐘就過期,造成「資料讀得到但警告消失」。

D3: 在 _update_progress 中追蹤 failed_ranges僅 time-range chunk

決定: 擴充 _update_progress() 接受 failed_ranges: Optional[List[Dict]] 參數,以 JSON 字串存入 Redis HSET。Sequential 和 parallel path 均從失敗的 chunk descriptor 提取 chunk_start / chunk_end僅當 chunk descriptor 包含 chunk_start/chunk_end 時才記錄(即 decompose_by_time_range 產生的 time-range chunk

container-id 分塊的情境: reject 的 container 模式使用 decompose_by_ids()chunk 結構為 {"ids": [...]} 不含日期範圍。此時 failed_ranges 為空 list前端透過 failed_chunk_count > 0 顯示 generic 警告訊息「N 個查詢批次的資料擷取失敗」),不含日期區間。

理由: chunk descriptor 的結構由 decompose 函式決定engine 層不應假設所有 chunk 都有時間範圍。

D4: Memory guard 失敗不重試

決定: _execute_single_chunk() 加入 max_retries=1,但只對 _is_retryable_error() 回傳 true 的 exception 重試。Memory guard記憶體超限和 Redis store 失敗直接 return False不重試。

理由: Memory guard 代表該時段資料量確實過大重試結果相同Oracle timeout 和連線錯誤則可能是暫態問題。

D5: 前端 warning banner 使用既有 amber 色系

決定: 新增 .warning-banner CSS class使用 background: #fffbeb; color: #b45309,與既有 .resolution-warn 的 amber 色系一致。放在 .error-banner 之後。

替代方案: 使用 toast/notification 元件。 為何不採用: 此專案無 toast 系統amber banner 與 red error-banner 模式統一。

D6: 前端日期驗證函式放在共用 filters module

決定: 在 frontend/src/core/reject-history-filters.js 新增 validateDateRange(),複用 resource-history/App.vue:231-248 的驗證模式。

理由: reject-history-filters.js 已是此頁面的 filter 工具模組validateDateRange 屬於 filter 驗證邏輯。

Risks / Trade-offs

  • [中] 重試邏輯影響所有 execute_plan 呼叫端_execute_single_chunk() 是 shared function被 reject / hold / resource / job / msd 五個服務共用。重試邏輯為加法行為(新增 retry loop 包在既有 try/except 外),成功路徑不變。→ 需要對其他 4 個服務執行 smoke test既有測試通過即可。若需更保守可加入 max_retries 參數讓呼叫端控制(預設 1但目前判斷統一重試對所有服務都是正面效果。
  • [低] 重試增加 Oracle 負擔 — 單次重試最多增加 1 倍的失敗查詢量。→ 透過 _is_retryable_error() 嚴格過濾,只重試 transient error且 parallel path 最多 3 worker影響可控。
  • [低] failed_ranges JSON 大小 — 理論上 73 chunks730/10全部失敗會產生 73 筆 rangeJSON < 5 KB。→ 遠低於 Redis HSET 欄位限制。