前言

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 分四步加入:

  1. 接受 numeric ID 或 Shopify GID,正規化後逐筆建立 source = backfill 的 SyncJob。
  2. Processor 執行時使用 offline session,透過 Admin GraphQL node 重新讀取 Order 或 Customer。
  3. 查詢同一 shop、target、resource ID 下 pending/running 的 backfill job,已有未完成工作就略過。
  4. 建立工作前要求該 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

參考資料

以上平台文件查核日期:2026-07-27。