バグ報告
単純な苦情だった。「チェックアウトできない — 在庫不足」。商品は管理画面では在庫ありと表示されていた。在庫数量も問題なさそうだった。しかし、注文を出そうとするたびに在庫検証エラーで失敗した。
在庫予約の仕組み
このシステムは2段階の在庫モデルを使っている。
- available_quantity — 手元にある総在庫
- reserved_quantity — アクティブな注文によって保持されている数量
- purchasable =
available_quantity - reserved_quantity
顧客が注文を出すと、reserved_quantityは即座に増加する。それが減るのは、注文がキャンセルされるか、履行される(発送される)ときだけだ。これは意図的な設計で、複数の顧客が同時にチェックアウトする際の売り越しを防ぐ。
チェックアウトの検証はシンプルだ。
const purchasable = stock.available_quantity - stock.reserved_quantity;
return purchasable >= quantity;
purchasableがゼロになると、物理的な在庫が倉庫に眠っていても、誰も買えなくなる。
根本原因:漏れた予約
データベースにクエリをかけると、問題は即座に明らかになった。
SELECT v.variant_name, s.available_quantity, s.reserved_quantity,
(s.available_quantity - s.reserved_quantity) AS purchasable
FROM inventory_stocks s
JOIN product_variants v ON v.id = s.variant_id
WHERE (s.available_quantity - s.reserved_quantity) <= 0;
いくつかのバリアントが、物理的な在庫があるにもかかわらずpurchasable = 0だった。予約数量が利用可能数量と一致するか、それを完全に超えていた。
どの注文がそれらの予約を保持していたのかを掘り下げると、犯人が明らかになった。ステータスpending、payment_status = 'awaiting_payment'の19件の注文、中には15日以上経過しているものもあった。これらは、顧客が決済ゲートウェイのページを開いたまま、決済を完了しなかった放棄済みのチェックアウトだった。
システムには、これらを期限切れにする仕組みがなかった。そのため、その在庫予約は永遠に保持され続けた。
なぜ在庫が解放されなかったのか
releaseStock()関数は存在しており、正しく呼び出されていたが、それは1箇所だけだった。つまり明示的な注文キャンセルフローだ。そのフローには、ユーザー(管理者または顧客)が能動的に注文をキャンセルする必要があった。
放棄された決済の場合:
- 顧客がブラウザタブを閉じた
- 決済ゲートウェイが静かにタイムアウトした
- Webhookは発火しなかった(決済なし=Webhookなし)
- 誰も「キャンセル」を押さなかった
- 注文は
pending + awaiting_paymentのまま永遠に残った - 予約された在庫:永久にロックされた
これは典型的な予約漏れだ。フェーズ2(解放)がユーザーの行動に依存しており、その行動が決して来ないかもしれない2段階コミットだ。
修正:2つのパート
パート1: 即座のデータ修復
既存の漏れた予約は、データベース上で直接修正する必要があった。
BEGIN;
-- Release reserved stock from abandoned orders
UPDATE inventory_stocks s
SET reserved_quantity = GREATEST(0, s.reserved_quantity - sub.qty_to_release)
FROM (
SELECT oi.variant_id, SUM(oi.quantity) AS qty_to_release
FROM sales_orders o
JOIN sales_order_items oi ON oi.order_id = o.id
WHERE o.status = 'pending'
AND o.payment_status = 'awaiting_payment'
AND o.created_at < NOW() - INTERVAL '24 hours'
AND o.deleted_at IS NULL
AND oi.item_type = 'product'
GROUP BY oi.variant_id
) sub
WHERE s.variant_id = sub.variant_id;
-- Cancel the abandoned orders
UPDATE sales_orders
SET
status = 'cancelled',
cancellation_reason = 'Payment not completed within 24 hours',
cancelled_at = NOW()
WHERE status = 'pending'
AND payment_status = 'awaiting_payment'
AND created_at < NOW() - INTERVAL '24 hours'
AND deleted_at IS NULL;
COMMIT;
GREATEST(0, ...)のガードに注目してほしい。これにより、データがすでに不整合な場合でもreserved_quantityが負になるのを防いでいる。
パート2: 恒久的な修正、自動失効スケジューラー
既存のプロジェクトには、配達完了注文を自動完了させるためのNestJSスケジューラーサービスがすでにあった。自動失効ジョブはそれと並べて追加された。
@Cron(CronExpression.EVERY_HOUR)
async autoExpireAbandonedPayments(): Promise<void> {
const cutoffDate = new Date();
cutoffDate.setHours(cutoffDate.getHours() - 24);
// payment_status isn't mapped on the TypeORM entity, so raw query is needed
const expiredOrders = await this.orderRepository.query(
`SELECT id, order_number FROM sales_orders
WHERE status = $1
AND payment_status = 'awaiting_payment'
AND created_at < $2
AND deleted_at IS NULL`,
[OrderStatusEnum.PENDING, cutoffDate],
);
for (const order of expiredOrders) {
const items = await this.orderItemRepository.find({
where: { order: { id: order.id } },
});
// Release reserved stock before cancelling
for (const item of items) {
if (item.item_type === CartItemTypeEnum.PRODUCT && item.variant_id) {
await this.inventoryStocksService.releaseStock(
item.variant_id,
item.quantity,
null, // system-initiated, no causer user
);
}
}
await this.orderRepository.update(order.id, {
status: OrderStatusEnum.CANCELLED,
cancellation_reason: 'Payment not completed within 24 hours',
cancelled_at: new Date(),
});
await this.orderTrackingService.createEvent(
order.id,
OrderEventTypeEnum.CANCELLED,
'Order auto-cancelled: payment not completed within 24 hours',
);
}
}
重要な落とし穴:マッピングされていないカラム
payment_statusカラムはデータベース内には存在していたが、TypeORMエンティティ上で@Column()としてマッピングされていなかった。TypeORMのfind()とfindOne()は、マッピングされたフィールドでしかフィルタリングできない。マッピングされていないカラムについては、repository.query()経由の生クエリが逃げ道になる。スケジューラーのクエリのためだけにエンティティを変更する必要はない。
注意すべきパターン
チェックアウト時にカウンターを増やすが、明示的なユーザー操作でしか減らさないシステムには、すべてこの脆弱性がある。修正は常に同じ形をしている。
- 予約漏れを特定する(有効期限を過ぎても在庫を保持している注文)
- 安全なガード(
GREATEST(0, ...))付きの一回限りの修正クエリを書く - 今後は同じロジックを自動的に実行するスケジュールジョブを追加する
- 注文ステータスを変更する前にリソースを解放すること。後ではなく
最後の点が重要だ。もし在庫解放がループの途中で失敗した場合、注文はpendingのままとなり、次の1時間ごとの実行がそれを再試行する。もし先にキャンセルしてから後で解放すると、その2つのステップの間で失敗が起きた場合、まだ在庫が予約されたままのキャンセル済み注文が残ってしまう。