You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Cart service has no atomicity guarantees — concurrent AddItem calls lose updates, and checkout's EmptyCart can silently discard items added during checkout #3824
Version: verified against commit 6c4b663a8a2c243f60f6b1a7d93e52467b3124f1 on main (2026-08-10).
Summary
ValkeyCartStore stores each user's cart as a single serialized blob under one Redis/Valkey hash field and mutates it with an unprotected read → modify → write sequence (no WATCH/MULTI, no Lua script, no per-field atomic ops, no optimistic-concurrency/version check). Because of this, concurrent writes to the same cart are not serialized in any way, which produces two distinct, reproducible failure modes:
Lost updates on concurrent AddItem — two "add to cart" calls for the same user that overlap in time silently clobber each other; one increment is lost and the user is undercharged/under-fulfilled with no error surfaced anywhere (not in the response, not in a span, not in a log).
Silent item loss during checkout — checkoutservice.PlaceOrder calls cartservice.GetCart and then, after building the order, calls cartservice.EmptyCart (src/checkout/main.go:520 and :528). If an AddItem for that same user lands in the window between those two calls (e.g. a second browser tab, a retried request, or the load generator), EmptyCartAsync unconditionally overwrites the hash field with an empty cart, silently discarding an item that was never included in the placed order and is never billed — a silent data/revenue-consistency bug with no telemetry signal indicating anything went wrong.
Both symptoms trace back to the same root cause in src/cart/src/cartstore/ValkeyCartStore.cs, so this is one subsystem-level defect, not two unrelated ones.
Reproduction steps
A. Lost update on concurrent AddItem
docker compose up (or run cartservice against Valkey directly).
Fire two concurrent AddItem gRPC calls for the same user_id/product_id (e.g. two grpcurl invocations launched in parallel, or two goroutines calling the generated client):
Expected: quantity 2. Actual (intermittently, under real network/scheduling jitter): quantity 1 — one of the two increments is lost because both requests read the same pre-update blob before either writes it back (ValkeyCartStore.cs:140-165).
B. Silent item loss racing with checkout
Add an item to a cart for user_id=checkout-race-user.
Call checkoutservice.PlaceOrder for that user, but pause/delay the request (or add a temporary sleep) between its GetCart call and its EmptyCart call (src/checkout/main.go:520-528) to simulate realistic latency under load.
During that window, call cartservice.AddItem again for the same user with a different product.
Inspect the placed order (it will not contain the item added in step 3 — it wasn't in the cart when GetCart ran) and then call GetCart again.
Expected: the item added in step 3 should either be in a fresh cart or surfaced as a conflict. Actual: the cart is empty — EmptyCartAsync (ValkeyCartStore.cs:178-194) does an unconditional HashSetAsync overwrite with no check against the state PlaceOrder originally read, so the item vanishes: not ordered, not billed, not in the cart, and nothing logs or traces this as an error.
Root cause
src/cart/src/cartstore/ValkeyCartStore.cs:
AddItemAsync (lines 127-176): db.HashGetAsync → deserialize → mutate in memory → db.HashSetAsync. Two concurrent calls both read the same starting state and the second write wins, discarding the first.
EmptyCartAsync (lines 178-194): unconditional HashSetAsync with an empty cart blob — never conditioned on the version/state that the caller (e.g. checkout) originally observed.
GetCartAsync (lines 196-227): plain read with no version/ETag returned to callers, so callers have no way to detect that the cart changed underneath them.
There is no WATCH/MULTI-EXEC transaction, no Lua script, and no per-item atomic field (e.g. HINCRBY on a productId field) anywhere in this file — the entire cart is treated as one opaque blob guarded only by a connection-level lock (_locker) that protects Redis connection setup, not cart data mutations.
Compounding this: cart mutation logic currently has zero effective test coverage. All three tests in src/cart/tests/CartServiceTests.cs (GetItem_NoAddItemBefore_EmptyCartReturned, AddItem_ItemExists_Updated, AddItem_New_Inserted) are marked [Fact(Skip = ...)] referencing PR #746 and have been disabled since that PR merged, so there is no regression safety net over AddItemAsync/EmptyCartAsync/GetCartAsync today, and no concurrency test exists for this path at all.
Impact
Data/revenue consistency: items can be silently dropped from an order during checkout under realistic concurrent load (double-tab, client retry, load generator) — the worst class of bug for a checkout flow, since it fails silently rather than loudly.
Correctness under concurrency: AddItem is not idempotent/atomic, so quantities are simply wrong under contention, with no error returned to the caller and no exception/log/span marking the loss.
Observability credibility: this is a project whose entire purpose is to demonstrate correct, observable microservice behavior. A silent, un-instrumented data-loss bug in the reference checkout flow undermines that goal — anyone using this demo to practice diagnosing production issues will find no trace, log, or metric pointing at the actual defect.
No regression protection: because the existing cart tests are skipped, this class of bug can be reintroduced or worsened by future changes without CI ever catching it.
Affected files/components
src/cart/src/cartstore/ValkeyCartStore.cs — core fix: replace blob read-modify-write with atomic operations (e.g. per-product hash fields with HashIncrementAsync, or a Lua script / WATCH+MULTI+EXEC transaction with retry-on-conflict for the whole-cart representation).
src/cart/src/cartstore/ICartStore.cs — may need a version/ETag-aware signature (e.g. optional expected-version parameter or a compare-and-swap style API) so callers like checkout can detect concurrent modification instead of blindly overwriting.
src/cart/src/services/CartService.cs — surface conflict/retry behavior (e.g. return a specific gRPC status) if a CAS-based store reports contention.
src/checkout/main.go (PlaceOrder, lines ~520-528) — must not unconditionally call EmptyCart after GetCart without accounting for concurrent modification; needs either a conditional/versioned empty-cart call or an explicit "remove exactly these items" operation instead of "clear everything."
src/cart/tests/CartServiceTests.cs — re-enable the disabled tests (fixing whatever made them skip in Avoid calling obsolete methods #746) and add concurrency-focused tests reproducing both failure modes above.
Documentation: any architecture/README notes describing the cart storage contract should note the atomicity guarantees once fixed.
Acceptance criteria
Two concurrent AddItem calls for the same user/product each register their full increment — no lost updates under concurrent load (verified by an automated concurrency test, e.g. N parallel AddItem calls resulting in the correct summed quantity).
EmptyCart (or whatever replaces the checkout-side cart-clearing call) cannot silently discard an item that was added after the state PlaceOrder/the caller last observed; the system either preserves the new item or surfaces an explicit, observable conflict (span exception, error status, or documented compensating behavior) — no silent loss.
The three currently-skipped tests in CartServiceTests.cs are re-enabled and passing, plus new tests cover the concurrent-AddItem and checkout/AddItem-race scenarios described above.
No behavioral regression to the existing single-writer cart flow (add/get/empty) used throughout the rest of the demo (frontend, load generator, checkout, recommendation).
High-level implementation plan
Redesign the Valkey cart schema in ValkeyCartStore.cs to make mutations atomic — most direct option: store each cart item as its own hash field (HINCRBY for quantity) instead of one serialized blob, eliminating the read-modify-write pattern for AddItem entirely; alternatively, keep the blob but wrap read+modify+write in a WATCH/MULTI/EXEC transaction with bounded retry on conflict, or a Lua script for atomicity.
Update EmptyCartAsync/checkout's clear-cart call so it can't blindly wipe state that has changed since it was last read — e.g. accept the expected item set/version and only clear what was actually ordered, or fail/report conflict rather than silently overwrite.
Update ICartStore/CartService gRPC surface as needed to propagate conflict signals, keeping backward-compatible behavior for the common non-concurrent path.
Update checkoutservice.PlaceOrder in src/checkout/main.go to use the new conflict-aware clear operation.
Re-enable and fix the three skipped tests in CartServiceTests.cs; add targeted concurrency tests for both reproduction scenarios above.
Verify manually with the reproduction steps in this issue, and note the fix in any relevant architecture docs describing cart storage guarantees.
Version: verified against commit
6c4b663a8a2c243f60f6b1a7d93e52467b3124f1onmain(2026-08-10).Summary
ValkeyCartStorestores each user's cart as a single serialized blob under one Redis/Valkey hash field and mutates it with an unprotected read → modify → write sequence (noWATCH/MULTI, no Lua script, no per-field atomic ops, no optimistic-concurrency/version check). Because of this, concurrent writes to the same cart are not serialized in any way, which produces two distinct, reproducible failure modes:AddItem— two "add to cart" calls for the same user that overlap in time silently clobber each other; one increment is lost and the user is undercharged/under-fulfilled with no error surfaced anywhere (not in the response, not in a span, not in a log).checkoutservice.PlaceOrdercallscartservice.GetCartand then, after building the order, callscartservice.EmptyCart(src/checkout/main.go:520and:528). If anAddItemfor that same user lands in the window between those two calls (e.g. a second browser tab, a retried request, or the load generator),EmptyCartAsyncunconditionally overwrites the hash field with an empty cart, silently discarding an item that was never included in the placed order and is never billed — a silent data/revenue-consistency bug with no telemetry signal indicating anything went wrong.Both symptoms trace back to the same root cause in
src/cart/src/cartstore/ValkeyCartStore.cs, so this is one subsystem-level defect, not two unrelated ones.Reproduction steps
A. Lost update on concurrent AddItem
docker compose up(or runcartserviceagainst Valkey directly).AddItemgRPC calls for the sameuser_id/product_id(e.g. twogrpcurlinvocations launched in parallel, or two goroutines calling the generated client):GetCartforrace-user.2. Actual (intermittently, under real network/scheduling jitter): quantity1— one of the two increments is lost because both requests read the same pre-update blob before either writes it back (ValkeyCartStore.cs:140-165).B. Silent item loss racing with checkout
user_id=checkout-race-user.checkoutservice.PlaceOrderfor that user, but pause/delay the request (or add a temporary sleep) between itsGetCartcall and itsEmptyCartcall (src/checkout/main.go:520-528) to simulate realistic latency under load.cartservice.AddItemagain for the same user with a different product.GetCartran) and then callGetCartagain.EmptyCartAsync(ValkeyCartStore.cs:178-194) does an unconditionalHashSetAsyncoverwrite with no check against the statePlaceOrderoriginally read, so the item vanishes: not ordered, not billed, not in the cart, and nothing logs or traces this as an error.Root cause
src/cart/src/cartstore/ValkeyCartStore.cs:AddItemAsync(lines 127-176):db.HashGetAsync→ deserialize → mutate in memory →db.HashSetAsync. Two concurrent calls both read the same starting state and the second write wins, discarding the first.EmptyCartAsync(lines 178-194): unconditionalHashSetAsyncwith an empty cart blob — never conditioned on the version/state that the caller (e.g. checkout) originally observed.GetCartAsync(lines 196-227): plain read with no version/ETag returned to callers, so callers have no way to detect that the cart changed underneath them.There is no
WATCH/MULTI-EXECtransaction, no Lua script, and no per-item atomic field (e.g.HINCRBYon aproductIdfield) anywhere in this file — the entire cart is treated as one opaque blob guarded only by a connection-level lock (_locker) that protects Redis connection setup, not cart data mutations.Compounding this: cart mutation logic currently has zero effective test coverage. All three tests in
src/cart/tests/CartServiceTests.cs(GetItem_NoAddItemBefore_EmptyCartReturned,AddItem_ItemExists_Updated,AddItem_New_Inserted) are marked[Fact(Skip = ...)]referencing PR #746 and have been disabled since that PR merged, so there is no regression safety net overAddItemAsync/EmptyCartAsync/GetCartAsynctoday, and no concurrency test exists for this path at all.Impact
AddItemis not idempotent/atomic, so quantities are simply wrong under contention, with no error returned to the caller and no exception/log/span marking the loss.Affected files/components
src/cart/src/cartstore/ValkeyCartStore.cs— core fix: replace blob read-modify-write with atomic operations (e.g. per-product hash fields withHashIncrementAsync, or a Lua script /WATCH+MULTI+EXECtransaction with retry-on-conflict for the whole-cart representation).src/cart/src/cartstore/ICartStore.cs— may need a version/ETag-aware signature (e.g. optional expected-version parameter or a compare-and-swap style API) so callers like checkout can detect concurrent modification instead of blindly overwriting.src/cart/src/services/CartService.cs— surface conflict/retry behavior (e.g. return a specific gRPC status) if a CAS-based store reports contention.src/checkout/main.go(PlaceOrder, lines ~520-528) — must not unconditionally callEmptyCartafterGetCartwithout accounting for concurrent modification; needs either a conditional/versioned empty-cart call or an explicit "remove exactly these items" operation instead of "clear everything."src/cart/tests/CartServiceTests.cs— re-enable the disabled tests (fixing whatever made them skip in Avoid calling obsolete methods #746) and add concurrency-focused tests reproducing both failure modes above.Acceptance criteria
AddItemcalls for the same user/product each register their full increment — no lost updates under concurrent load (verified by an automated concurrency test, e.g. N parallelAddItemcalls resulting in the correct summed quantity).EmptyCart(or whatever replaces the checkout-side cart-clearing call) cannot silently discard an item that was added after the statePlaceOrder/the caller last observed; the system either preserves the new item or surfaces an explicit, observable conflict (span exception, error status, or documented compensating behavior) — no silent loss.CartServiceTests.csare re-enabled and passing, plus new tests cover the concurrent-AddItem and checkout/AddItem-race scenarios described above.High-level implementation plan
ValkeyCartStore.csto make mutations atomic — most direct option: store each cart item as its own hash field (HINCRBYfor quantity) instead of one serialized blob, eliminating the read-modify-write pattern forAddItementirely; alternatively, keep the blob but wrap read+modify+write in aWATCH/MULTI/EXECtransaction with bounded retry on conflict, or a Lua script for atomicity.EmptyCartAsync/checkout's clear-cart call so it can't blindly wipe state that has changed since it was last read — e.g. accept the expected item set/version and only clear what was actually ordered, or fail/report conflict rather than silently overwrite.ICartStore/CartServicegRPC surface as needed to propagate conflict signals, keeping backward-compatible behavior for the common non-concurrent path.checkoutservice.PlaceOrderinsrc/checkout/main.goto use the new conflict-aware clear operation.CartServiceTests.cs; add targeted concurrency tests for both reproduction scenarios above.