前言

批量特價功能會讀取一批 Shopify 商品的目前售價,保存 snapshot,再把每個 Variant 的 pricecompareAtPrice 改成活動價格。活動結束後,系統還要安全還原。

原本功能在長駐 Web Server 上以單一 React Router action 同步完成。搬到 Vercel 後,route 可以設定較長的執行時間,但這只把天花板抬高,沒有把工作變成可續跑的 job。

上一篇用 Web、App Version、Database 與 Store Config 拆開發布邊界。這篇聚焦在 Web 與 Database 之間:大量 Shopify API 呼叫如何面對 timeout、429、部分成功與重送。

原本的做法與問題症狀

建立特價活動時,現行 action 依序做完所有事情:

展開 Collection products
      ↓
逐商品讀取 variants
      ↓
建立 Campaign + Snapshots + Leases
      ↓
依 Product 分組更新價格
      ↓
標記 Campaign applied

查詢每頁最多讀 250 筆,寫入則對每個 Product 呼叫一次 productVariantsBulkUpdate。如果選到很多商品,request 會包含多輪 Shopify GraphQL、價格比對與資料庫寫入。

問題不是「最後一個 API 回傳 error」而已。假設前五個 Product 已成功,第六個遇到 network timeout,Shopify 不會替 App rollback 前五組。Browser 只看到 request 失敗,但遠端商品與本地 Campaign 已進入部分完成狀態。

把 route 的 maxDuration 設長可以降低小批次逾時,卻無法處理 process crash、deployment replacement、反覆 429,或超過 runtime ceiling 的工作。

問題是怎麼推敲出來的?

先看 Shopify mutation 的 transaction 邊界。productVariantsBulkUpdate 一次只更新單一 Product 的多個 Variants。來源程式使用 allowPartialUpdates: false,代表同一個 Product group 有 invalid update 時,該次 mutation 的所有 Variant 都不寫入。

但 App 會對多個 Product 序列呼叫 mutation。原子性只到單一 Product,不會跨越整個 Campaign。

因此來源專案加上 onGroupSuccess checkpoint:每個 Product group 成功後,立刻把對應 snapshots 標成 applied;下一組失敗時,Campaign 進入 apply_failed,已完成的 snapshot 不會被假裝成未執行。

接著再看競態。Snapshot 建立後、mutation 執行前,商品價格可能被其他人修改。程式會重新讀 current price,只有仍符合 snapshot 的 expected original value 才更新;還原時也只在目前價格仍等於 applied value 時寫回,否則標成 conflict。

這些保護讓同步流程可以描述部分進度,卻仍沒有 job cursor、attempt 或獨立 worker,所以不能宣稱已完成 durable queue。

最後採用的架構

本次遷移先採有限同步路徑,而不是假裝大型 job 已解決:

  • 預設最多 50 個 Products、250 個 Variants。
  • 建立與還原 route 使用案例當時設定的 300 秒 maxDuration
  • Snapshot 與 Lease 在寫入 Shopify 前持久化。
  • 每個 Product group 成功後立即 checkpoint。
  • 寫入前驗證 expected state。
  • 失敗保留 apply_failedrestore_failed,讓使用者可看見並處理。

真正可擴充的下一版應把 request 與 execution 拆開:

HTTP request
  → transaction 建立 Campaign、Snapshots、Job
  → durable Queue / Workflow
  → claim 一個 Step
  → 驗證 expected state
  → 更新一小批 Product
  → transaction checkpoint
  → retry、reconcile 或下一批

Request 只負責可靠建立工作,不等待全部商品完成。Worker 每次處理可控制的批次,進度與錯誤都存在 Database,而不是依賴 Function memory。

關鍵實作

現行依 Product 分組的 mutation 保留了正確的最小邊界:

for (const group of groupVariantUpdatesByProduct(updates)) {
  await admin.graphql(PRODUCT_VARIANTS_BULK_UPDATE, {
    variables: {
      productId: group.productId,
      variants: group.variants,
      allowPartialUpdates: false,
    },
  });

  await markGroupApplied(group.variantIds);
}

Durable 版本則需要明確的 step identity:

type PriceUpdateStep = {
  jobId: string;
  stepKey: string;
  productId: string;
  attempt: number;
  expectedBefore: PriceState[];
  expectedAfter: PriceState[];
  status: 'pending' | 'running' | 'succeeded' | 'conflicted' | 'failed';
};

Queue 常是 at-least-once delivery,同一個 Step 可能被送達兩次。Consumer 必須先判斷:

  • 目前仍是 expectedBefore:可以執行。
  • 目前已是 expectedAfter:視為已完成,補 checkpoint。
  • 兩者都不是:停止並標成 conflict。

對更大的 catalog,可以評估 Shopify Bulk Operations:bulk query 非同步匯出大量資料,bulk mutation 可處理大規模寫入。不過它仍需要 operation ID、status polling/webhook、JSONL result、partial data 與 reconciliation,並不是呼叫一次就自然擁有應用層冪等性。

實際驗證

來源測試已覆蓋:

  • Selected Product 的所有 Variant pagination。
  • Collection products pagination。
  • 超過 Product/Variant 上限時,在建立 snapshot 前拒絕。
  • 同一 Product 的 Variants 合併成一次 mutation。
  • 第一個 Product 成功、第二個 network timeout 時,只 checkpoint 第一組。
  • Snapshot 後價格被修改時,在寫入前停止。
  • 還原時區分可還原、已還原與人工修改衝突。
  • 每組成功後更新 snapshot 狀態,讓 retry 不必猜測進度。

這些是 unit/route-level 證據。Repository 沒有公開的真實 Vercel timeout、Shopify 429、process crash 或大型商店壓測結果,所以文章不把安全上限寫成經 production 容量測試證明的數字。

仍然存在的限制

Vercel 在 2026 年已提供更長的 Function duration,但超過設定上限仍會終止 invocation;提高 maxDuration 不是 durability。waitUntil 也受 Function lifecycle 約束,適合短尾端工作,不適合拿來替代需要保證完成的批量改價。

Vercel Queues 目前提供 durable delivery、retry 與 visibility timeout,但採 at-least-once 語意,仍要求 consumer 冪等。Vercel Workflow 提供較高階的 durable steps;是否採 Queue、Workflow 或獨立 worker,要依團隊的觀測、回復與平台成熟度決定。

Shopify Bulk Operations 也有自己的併發、結果保存與失敗狀態。它可以減少同步 pagination 與一般 query cost,卻不能取代本地 Campaign、Snapshot、Lease 與 conflict policy。

最後,直接修改 Variant price 會影響所有銷售面與後續人工調價;它和 Discount Function 是不同機制。即使 job 完全 durable,還原時仍必須尊重活動期間發生的人工變更。

可以延伸到哪些情境?

「先持久化工作,再非同步執行小步驟」也適用於:

  • 大量商品標籤或 metafield 更新。
  • 歷史訂單 backfill。
  • 外部 ERP/Ragic 同步。
  • 大量圖片處理。
  • Webhook 後續工作。

判斷是否該離開同步 request 的訊號,不只看平均執行時間;只要工作可能部分成功、需要重試、需要顯示進度,或不能接受 deployment 中斷後遺失,就已經接近 durable job。

下一批會進入 Shopify 與外部資料系統的同步架構,先從訂單 Webhook 到資料 upsert 的完整路徑開始。

參考資料

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