前言
Webhook 只能處理 App 開始接收事件之後的異動。商家第一次啟用 Shopify 與 Ragic 同步時,既有訂單仍要補進去;若把歷史資料一次匯出再直接 POST,很容易重複建立、漏掉中途失敗的頁面,或使用已經過期的 Mapping。
上一篇先拆開了 Webhook 接收與可重試 Job Processor。這篇聚焦 Backfill,並刻意把三種「不重複」分開:Shopify 分頁不漏資料、工作佇列不重複排同一資源、Ragic 不重複建立 record。
原本的做法與問題症狀
第一個做法是讓商家貼一批訂單 ID,對每個 ID 建立 backfill job。這個 MVP 可控制範圍,也方便先跑少量資料;但如果輸入內容有重複 ID、已有 pending job,或根本沒有 active Mapping,工作一開始就可能變成無效或重複。
另一個風險是把 enqueue 時的簡短 payload 當成歷史訂單內容。Backfill 可能排隊數分鐘甚至更久,應在真正執行時重新向 Shopify 讀取 resource,才能套用當下可取得的來源資料。
至於「同步所有歷史訂單」,單靠貼 ID 並不能證明完整性。完整掃描需要 cursor pagination、頁面 checkpoint、查詢範圍與結束條件;這些不能被一個 Set 或 Ragic unique key 取代。
問題是怎麼推敲出來的?
來源 Git 歷史把 Backfill 分四步加入:
- 接受 numeric ID 或 Shopify GID,正規化後逐筆建立
source = backfill的 SyncJob。 - Processor 執行時使用 offline session,透過 Admin GraphQL
node重新讀取 Order 或 Customer。 - 查詢同一 shop、target、resource ID 下 pending/running 的 backfill job,已有未完成工作就略過。
- 建立工作前要求該 target 存在 active Mapping,避免工作排完才發現沒有 destination contract。
測試還特別保留一個重要語意:既有 success 或 error job 不會永遠阻擋重跑。商家修正 Mapping 或外部問題後,可以再次補送同一資源;真正寫入 Ragic 時再由 unique key 決定 create 或 update。
最後採用的架構
目前已實作的 Backfill 是受控批次:
商家輸入 resource IDs
→ 正規化與 target 驗證
→ 檢查 active Mapping
→ 跳過同批重複 ID
→ 跳過既有 pending / running job
→ 建立 backfill SyncJobs
Worker 執行每一筆 job
→ 重新 fetch Shopify resource
→ 套用 active Mapping
→ Ragic unique-key lookup
→ create / update
→ SyncLog
完整歷史掃描應在這個入口前再加一層 discovery:
Orders connection + stable sort
→ pageInfo / endCursor
→ 持久化 scan checkpoint
→ 每頁拆成 resource jobs
→ 直到 hasNextPage = false
來源專案尚未實作這層 cursor scanner,因此本文不把目前功能描述成「一鍵完整回補所有歷史訂單」。
關鍵實作
Job dedup 只針對未完成工作:
const unfinished = await findJobs({
shop,
target,
source: 'backfill',
status: ['pending', 'running'],
shopifyResourceId: resourceIds,
});
if (!unfinished.has(resourceId)) {
await enqueueSyncJob({ shop, target, source: 'backfill', shopifyResourceId: resourceId });
}
Processor 不信任 enqueue 時的資料快照,而是重新 fetch:
const source = await fetchShopifyResource({
shop,
target,
resourceId: job.shopifyResourceId,
});
const dryRun = await buildDryRunFromActiveMapping({ shop, target, source });
await upsertRagicRecordFromDryRun({ shop, dryRun });
這裡沒有公開正式商店、訂單編號、Ragic URL、Field ID 或顧客資料。測試 ID 也應使用明顯的假值。
實際驗證
來源測試已覆蓋:
- numeric ID 與正確類型 GID 可正規化,錯誤 resource type 會略過。
- 同一次輸入中重複 ID 只建立一筆工作。
- 同 shop、target、resource ID 已有 pending 或 running backfill job 時不再建立。
- 其他 shop、其他 target,以及已 success 的舊工作不會誤擋這次批次。
- 沒有 active Mapping 時拒絕 enqueue。
- Processor 會以 offline session 讀取 Shopify resource,再交給共用 Mapping 與 Ragic upsert。
- Backfill 不依賴 Webhook 自動同步開關,執行結果仍留下
source = backfill的 SyncLog。
這些驗證證明受控 ID 批次的行為,沒有證明所有歷史訂單都被列舉完成。
仍然存在的限制
目前最大的限制就是 discovery。Shopify GraphQL connection 使用 cursor pagination;完整 Backfill 需要固定查詢條件與排序、保存 end cursor,並定義掃描期間新訂單或更新資料如何處理。只把最後一個 ID 記在記憶體,process restart 後仍會漏頁。
Shopify App 預設只能存取最近 60 天的訂單;若要讀取更早的歷史訂單,還要申請 read_all_orders,並搭配訂單讀寫 scope。這是權限與審核邊界,不能用 pagination 技巧繞過。
第二,pending/running 查詢與 create 不是原子操作。多個 request 同時 enqueue 相同 ID 時仍可能競爭;正式版應加資料庫唯一約束、transaction 或其他可證明的 idempotency gate。
第三,Ragic upsert 依賴 Mapping 的 unique key。若商家更換 Field ID、修改格式或遠端已有髒資料,lookup 仍可能命中錯誤 record。大批回補前應先做小批 Dry Run、統計 create/update/warning 數量,再逐步放大。
第四,歷史訂單與會員可能包含 protected customer data。Query 只應取 Mapping 真正需要的欄位,工作資料與 SyncLog 必須有 retention 與權限控制。
資料量很大時,可以評估 Shopify Bulk Operations 取得歷史 snapshot;但 bulk query 只改變資料發現方式,後續 job idempotency、Ragic rate limit、checkpoint 與失敗續跑仍要自行設計。
可以延伸到哪些情境?
同一模式也適合 ERP 首次導入、資料遷移、漏送修補與 schema 修復後重播。設計時把三個問題分開:
- Shopify pagination:來源是否列舉完整。
- Job dedup:同一資源是否被同時排入多次。
- Ragic upsert:遠端是否建立重複 record。
下一篇會處理 Backfill 最容易遇到的後續問題:Ragic 欄位改了怎麼辦?用 Schema Diff 修復既有 Mapping
參考資料
- Shopify:Pagination with GraphQL
- Shopify Admin GraphQL:orders query
- Shopify Admin GraphQL:node query
- Shopify:Bulk operations
- Shopify:Access scopes
- Shopify:Work with protected customer data
- Ragic:Finding the field ID for a field
- Ragic:Modifying an Entry
以上平台文件查核日期:2026-07-27。
