前言
批量特價功能會讀取一批 Shopify 商品的目前售價,保存 snapshot,再把每個 Variant 的 price 與 compareAtPrice 改成活動價格。活動結束後,系統還要安全還原。
原本功能在長駐 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_failed/restore_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 的完整路徑開始。
參考資料
- Shopify:productVariantsBulkUpdate
- Shopify:API limits
- Shopify:Bulk operations
- Shopify:Bulk import data
- Vercel:Function duration
- Vercel:Queues
以上平台文件查核日期:2026-07-25。
