Custom Checkout Extension Breaks Under Concurrent Flash Sale
A merchant built a custom module that adds a gift-with-purchase rule at checkout: if cart subtotal > $150, attach a free SKU. The logic runs in an observer on checkout_cart_save_after and writes the gift to the quote via a repository. During a flash sale, ~2,000 orders/hour hit the site. Symptoms: (1) some carts get two gift items, (2) the gift occasionally persists after the cart drops below $150, (3) a small number of orders fail with a quote save exception, (4) the module has no unit tests and was deployed by copying files to the server. The team wants to keep the feature but make it reliable.
1. Explain why duplicate gifts and stale gifts occur, referencing the observer timing and quote lifecycle.
Read the worked answer
checkout_cart_save_after fires after the quote is saved. Writing to the quote inside this observer triggers a second save, which re-fires the observer - a recursion/duplication hazard. Under concurrency, two requests (e.g., add-to-cart and a totals collection) can both read a cart below threshold, both decide to add the gift, and both persist it → two gift rows. That is a classic read-modify-write race with no locking.
Stale gifts: the observer only adds on save; it never removes when subtotal drops. If the cart later falls below $150 (item removed, coupon), no code path removes the gift, so it persists. Also, because the gift is written outside the totals collection flow, the subtotal used may be pre-discount or stale.
Fixes: move the logic into a totals collector (or a quote totals model) that runs during collectTotals, making it idempotent - compute desired gift set from current totals and reconcile (add/remove) rather than append. Never write to the quote inside a save_after observer. Guard with a per-quote lock or use the quote's item collection to detect an existing gift before adding.
2. The quote save exception appears under load. What is the likely cause and how do you make the write safe?
Read the worked answer
Likely a deadlock or duplicate-key/lock-wait on quote/quote_item when two concurrent requests save the same quote. Commerce serializes quote saves per quote, but a second writer (the observer's nested save) racing the primary save can hit lock wait or a constraint violation, surfacing as a save exception.
The nested save is the smell: save_after → repository save → another save. Each cart action now performs two writes to the same aggregate.
Make it safe by:
- Removing the nested save entirely - do the gift reconciliation inside
collectTotals, which is already part of the single quote save transaction. - Making the gift operation idempotent: query existing gift items by SKU + a marker (e.g.,
additional_optionsflag) before adding. - If a separate write is unavoidable, wrap in a transaction with
SELECT ... FOR UPDATEon the quote row, or serialize via a per-quote lock (Redis lock keyed on quote ID). - Add retry-with-backoff only for genuine deadlock error codes, not as a blanket catch.
Then add integration tests simulating concurrent add-to-cart to prove no duplicates or exceptions.
3. Address the deployment and testing gaps. What would you require before this feature can be trusted in production?
Read the worked answer
The file-copy deployment means no reproducibility, no rollback, and no audit - unacceptable for checkout-critical logic.
Requirements:
- Packaging: convert to a Composer package with
composer.json, versioned, deployed viacomposer requirein a build pipeline; no manual file copies. - Tests: unit tests for the reconciliation logic (above/below threshold, idempotency, removal) and an integration test for concurrent cart saves. Add a test that asserts the gift is removed when subtotal drops.
- Static analysis (PHPStan/Psalm) and coding-standard checks in CI.
- Observability: log gift add/remove decisions with quote ID; alert on quote save exception rate.
- Rollback plan: tagged release + DB migration plan if schema changes.
Do not ship until the totals-collector refactor and concurrency integration test pass; the current observer design cannot be made reliable by testing alone.