Files
2meet-data-optimizer/docs/adr-004-search-index-mode-for-hp-wc.md
T
wpdev fab07655a2 docs(2meet-data-optimizer): 1.2.0 release notes
- Bump to 1.2.0; CHANGELOG / readme entries for PR-066–080, with the post-upgrade ops commands
- ADR-004 Release 2.2 design (scope, drift check / fix / router / resync) and CONTEXT.md drift paragraph
- Verify: composer test, integration, phpcs 0
- PR: PR-086
2026-09-27 09:52:22 +08:00

121 lines
10 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.
# ADR-004:HivePress / WooCommerce 站採「搜尋索引模式」
- **狀態**:已接受(2026-09-26)
- **日期**:2026-09-26
- **相關**:dev36 整合測試報告(`notes/tmdo-integration-test-dev36-2026-09-26.md`)、ADR-001、ADR-003、backlog PR-052〜PR-063(Release 2.1)
## 背景
2026-09-26 在 dev36(HivePress 1.7.31 + Marketplace + 10 個擴充、WooCommerce 11.1.2、Redis、10 萬筆 listing)做整合與壓力測試,確認:
1. **HivePress model 與 WC data store 用空 key 讀 meta**(`get_post_meta( $id )`),1.0.x 的 Hook Bus 對空 key 放行、只讀原生。所以 aeav_only 或 cleanup 之後,HP 的 verified / rating、WC 的價格與顧客資料讀成空值(dev20 / dev21 盤點:WC 商品 0/20 可購買)。
2. **WC 以直接 SQL 更新 `_stock`、`total_sales`**,扁平表跟不上。
3. **shadow_read 會遺失寫入**:WP 的 `update_metadata()` 經 `get_metadata_raw` 讀舊值,攔截器從扁平表回答,新舊相同就不寫原生。
4. **Router 在 OR、LIKE、多 post type、跨 group、具名 orderby 上結果錯誤**,HivePress 的 `_sort` 沒被改寫,前台搜尋幾乎沒有變快。
5. **陣列值被存成 `"Array"`**。
帶 key 的單筆讀取本來就沒有速度優勢:`update_meta_cache()` 一次載入全部 key,之後都是記憶體讀取。扁平表真正的價值在**搜尋與篩選**(`BENCHMARK-SCALE.md` 結論 1、2)。
## 決策(使用者 2026-09-26)
1. **HP / WC 站採「搜尋索引模式」**:原生 meta 是唯一真實資料來源,扁平表是它的搜尋索引。模式最高到 `shadow_read`;`aeav_only` 與 cleanup 由 Promotion Guard 擋下。
2. **移除 WC 扁平化**(Release 2.2:core 的 `post/wc_product` 群組、WC AddOn 的 user `hot` 群組與 hot-zone 欄位)。
3. **既有 aeav_only 站(dev20 / 21 / 22)先唯讀盤點**,處置由使用者決定(`notes/ops-aeav-sites-audit-2026-09-26.md`)。
4. **分兩版**:2.1 = 止血 + 資料正確性 + Router 與 HP 排序;2.2 = 範圍收斂 + 漂移偵測 + CLI 與診斷。
## 設計(Release 2.1)
### 寫入:扁平表跟隨原生
- **dual_write / shadow_read** 下,pre-filter(`update_/add_/delete_{type}_metadata`)只做路由判斷後回 `null`,讓 WordPress 自己寫原生。
- 扁平表在 `added_ / updated_ / deleted_{type}_meta` action(priority 1)裡**從原生重新同步**:直接 SQL 讀原生第一筆 row(`ORDER BY meta_id LIMIT 1`,不經 filter),扁平值 = `to_db( maybe_unserialize( v ) )`,原生沒有 row 時設為 NULL(`TMDO_Hook_Bus::sync_from_native()`)。
- `delete_metadata( …, $delete_all )`:`deleted_` action 拿到的 object id 是 0,所以在 `delete_{type}_meta` 先記下 object ids。
- `update_metadata_by_mid()` 改 key:新舊兩個 key 都重新同步。
- 原生沒有值時只把既有列的欄位設為 NULL,**不新增列**:刪除實體時 adapter 先刪扁平列,WordPress 才逐筆刪 meta。
- **aeav_only** 保留原本的 pre-filter short-circuit(非 HP / WC 站仍可使用)。
### 讀取:shadow_read = 讀原生、抽樣比對扁平
`intercept_get` 在 shadow_read 回 `$check`(讓 WordPress 讀原生),並以 `tmdo_shadow_compare_sample_rate`(預設 0.1)抽樣比對扁平值、記錄差異。查詢加速不受影響,因為 Router 仍由 `reads_from_flat()`(shadow_read 以上)控制。
### Promotion Guard
`TMDO_Promotion_Guard`,由 `TMDO_Mode_Manager::set()`、`TMDO_Post_Migration::cleanup()` 與 `TMDO_Phase_Cleanup::execute()` 呼叫:
- HivePress 啟用時,post / user / term / comment 不得升到 aeav_only 或執行 cleanup;WooCommerce 啟用時,post / user 不得。
- 回傳 `WP_Error( 'promotion_guarded' )`;特殊情況用 filter `tmdo_promotion_guard_allow` 放行。
- 已經在 aeav_only 的站**讀取時不強制降級**(降級會讀到空的原生),只在後台顯示 notice。處置用 `wp tmdo bridge-restore-native <entity> --demote`。
### 回寫原生(bridge-restore-native)
依模式決定策略:aeav_only 預設 `overwrite`(寫入只到扁平表、原生已過期),其他模式預設 `only_missing`(原生可能較新,例如 WC 直接 SQL 寫庫存)。相同的值不重寫;扁平值等於欄位 default 且原生沒有 row 時不寫(不捏造預設值)。
### 序列化值
`text` / `textarea` 遇到陣列或物件時 `maybe_serialize()`,讀出時 `TMDO_Safe_Unserialize`;`text`(VARCHAR(255))超長存 NULL 並記 `text_overflow`。既有的 `"Array"` 由 `wp tmdo repair-array-literals` 從原生修復。
### Router:只在語意與原生相同時路由
`TMDO_Post_Query_Router`(`pre_get_posts` priority 1000,排在外掛於預設 priority 加的條件之後):
- **全有或全無**:任何一個已註冊 key 的 clause 不能路由,整個查詢交回 WordPress。不能路由的情況:
- top-level OR
- 含已註冊 key 的巢狀 group(排序形狀除外)
- 多個 post type
- 白名單以外的 compare
- type 與欄位語意不同:無 type(CHAR)的數值範圍比較、decimal 欄位的 CHAR 比較、text 欄位用 NUMERIC
- **LIKE** 包成 `%esc_like%`。
- **每個 group 各自 LEFT JOIN**;扁平表一文章一列(UNIQUE `post_id`),不加 GROUP BY。
- 狀態 / 日期副本路由遇到 NOT EXISTS 時停用:INNER JOIN 會漏掉沒有扁平列的文章。
- **排序**(`wpdo_post_sort`):
- **HivePress `_sort` 形狀**:OR 裡剛好是同 key、同 type 的 NOT EXISTS + EXISTS,其中一個具名,並且是唯一的 orderby。這個 group 從 meta_query 移除,改成 LEFT JOIN 扁平表並 `ORDER BY col, ID DESC`;ASC 時 NULL 排最前,與原生相同。
- **具名 clause** 作為唯一的 orderby。
- **`meta_key` + `meta_value_num`**(字串欄位用 `meta_value`),加上 `col IS NOT NULL`(原生是 INNER JOIN)。
- 欄位排序不等於 `CAST( meta_value AS type )` 時退回原生。
- **flat-first**:依扁平欄位排序、且 routed 條件已要求扁平列存在(同一 group、非 NOT EXISTS)、沒有 `post__in` / `author` 等高選擇性條件時,改寫成 `SELECT STRAIGHT_JOIN … FROM flat INNER JOIN wp_posts`,ORDER BY 的 `wp_posts.ID` 改讀等值的 `post_id`,排序只在一張表完成。依日期排序的搜尋不套用(由 posts 驅動較快)。filter `tmdo_router_flat_first` 可關閉。
- **維持 WordPress 的 split query**:`posts_request` 移除 `SQL_CALC_FOUND_ROWS`(總數改由快取的 COUNT 回答)會讓 WordPress 以為 request 被改過而撈整列。`split_the_query` 對 routed 查詢恢復 ID-first,`posts_request_ids` 再移除一次 `SQL_CALC_FOUND_ROWS`。
HivePress AddOn 0.2.0 在 post router 啟用時不再綁定舊的 hot-zone `optimize_query` / `optimize_search`。
## 設計(Release 2.2)
### 範圍收斂
- core 不再註冊 `post/wc_product`;WooCommerce AddOn 0.2.0 不再註冊 product / order / subscription 的 zone 欄位與 user `hot` 群組。WooCommerce 的 meta 只存在原生。既有表以 `bridge-restore-native post --group=wc_product --retire` 封存。
### 漂移偵測與修復(`TMDO_Drift_Check`)
- `wp tmdo drift-check <entity>` 以值比對扁平與原生(第一筆原生 row → 安全 unserialize → `Type_Caster::to_db`),逐 key 回報 miss / diff / extra,結果寫入 `wpdo_drift_status`。
- `--fix` 從原生重建漂移列,刪除已刪實體與非本 group post type 的列,不寫原生。
- **aeav_only 不適用**:那時扁平才是真實資料,原生本來就過期。`--fix` 拒絕、每日抽樣略過、Router 不因漂移退回原生。
- **Router**:上次檢查有漂移的 group 不路由,整個查詢維持原生;從未檢查的 group 照常路由(決策 2026-09-27:升級後不變慢)。
- **每日抽樣**:`wpdo_daily_health_check`,每 group 500 筆。
- **Hook Bus 關閉期間的寫入**:entity 從 disabled 進入 dual_write / shadow_read、或 Hook Bus flag 由關轉開時,60 秒後 `tmdo_drift_resync` 執行全量 `--fix`。不用 `post_modified_gmt` 增量,因為 WooCommerce 直接 SQL 寫 meta 不會更新它。
- **Auto-promoter** 以 `clean_since()`(2 天內乾淨的檢查)取代 shadow diff 數量;Promotion Guard 仍擋 HP / WC 站。
### 查詢
- 純排序搜尋(HivePress `_sort`、沒有路由篩選)在該 post type 每篇都有扁平列時改由扁平表驅動;Router 在 `wp_insert_post` 替新文章建空列,覆蓋率快取 15 分鐘。
## 後果
**好處**
- 資料正確性回到 WordPress 原生語意:HP / WC 的空 key 讀取、直接 SQL 寫入都不再造成資料不一致或遺失。
- 查詢加速保留,而且擴及 HivePress 前台的篩選與排序(實測見 `BENCHMARK-SCALE.md`「前台與寫入鏈實測」)。
- 任何時候都可以降回 disabled,原生資料完整。
**代價與限制**
- **寫入開銷**:dual_write / shadow_read 每次 meta 寫入多一次原生讀取與一次扁平寫入。dev36 實測 HP listing 整條寫入鏈 +29%、WC product +127%(後者在 2.2 移除 WC 扁平化後消失)。
- **沒有讀取加速**:帶 key 的單筆讀取仍走原生(WordPress meta cache)。
- **漂移**:繞過 meta API 的寫入(直接 SQL、Hook Bus 關閉期間的匯入)會讓扁平表過期,Router 會回傳過期的篩選結果。2.2 起由 `drift-check`(每日抽樣、Router 避開漂移中的 group)與重新啟用時的全量 `--fix` 處理;兩次抽樣之間的漂移仍可能短暫回傳過期結果,大量匯入後可手動執行 `drift-check <entity> --fix`。
- **decimal 欄位搭配 NUMERIC / DECIMAL**:原生先 CAST 成整數(`DECIMAL` 預設 (10,0)),扁平表精確比較,因此帶小數的值在區間邊界上可能不同。dev36 資料沒有這種值。
- **text 欄位超過 255 字元**存 NULL,這類值的篩選結果會與原生不同(有 `text_overflow` 記錄)。
- HivePress 搜尋 p50 為 321–866 ms(原生 600–1,713 ms),尚未達到 R-2.1 的 300 ms 目標(驗收:`notes/tmdo-acceptance-2.1-2026-09-26.md`)。價格篩選頁的 SQL 只剩約 75 ms、其餘為頁面渲染;純排序(如 `rating__desc`)必須 LEFT JOIN 保留沒有值的文章,無法 flat-first。
## 不在範圍內
- aeav_only 的空 key 讀取合併(B02):HP / WC 站不再使用 aeav_only,是否實作依其他站需求決定。
- Hot zone 的移除(ADR-003 的後續步驟)。