Skip to content

Commit 060ee20

Browse files
fix(cart): make cart mutations atomic and stop checkout from wiping concurrent adds
Update ValkeyCartStore to perform cart mutations via Redis optimistic concurrency transactions, avoiding lost updates during concurrent AddItem requests. Update checkout service to only remove ordered items from the cart instead of unconditionally wiping it. Update cart-unit-tests CI workflow to start valkey via docker compose. Signed-off-by: bhuvan-somisetty <somisettybhuvan5@gmail.com>
1 parent 7e780f1 commit 060ee20

5 files changed

Lines changed: 207 additions & 81 deletions

File tree

.github/workflows/checks.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,34 @@ jobs:
145145
"${WEAVER_IMAGE}" \
146146
registry check -r source
147147
148+
cart-unit-tests:
149+
name: Cart unit tests
150+
runs-on: ubuntu-latest
151+
steps:
152+
- name: Checkout code
153+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
154+
with:
155+
persist-credentials: false
156+
- name: Start Valkey
157+
run: |
158+
VALKEY_IMAGE=$(grep -oP '(?<=^VALKEY_IMAGE=)\S*' .env)
159+
docker run -d --rm --name valkey -p 6379:6379 "${VALKEY_IMAGE}"
160+
for i in {1..30}; do
161+
if docker exec valkey valkey-cli ping 2>/dev/null | grep -q PONG; then
162+
break
163+
fi
164+
sleep 1
165+
done
166+
- name: Set up .NET
167+
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
168+
with:
169+
dotnet-version: '10.0.x'
170+
- name: Run cart unit tests
171+
working-directory: src/cart
172+
env:
173+
VALKEY_ADDR: localhost:6379
174+
run: dotnet test tests/cart.tests.csproj
175+
148176
check-react-native-changes:
149177
name: Check React Native changes
150178
runs-on: ubuntu-latest
@@ -190,6 +218,7 @@ jobs:
190218
sanity,
191219
checklicense,
192220
weaver-check,
221+
cart-unit-tests,
193222
react-native-build,
194223
codeql-analysis
195224
]

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ the release.
1919
v1.46.0/v0.71.0 release line, picking up otelgrpc recording `error.type`
2020
on RPC duration metrics for failed calls
2121
([#3901](https://github.com/open-telemetry/opentelemetry-demo/issues/3901))
22+
* [cart] Make cart mutations atomic using Redis optimistic concurrency
23+
transactions, and update checkout to only remove ordered items
24+
([#3825](https://github.com/open-telemetry/opentelemetry-demo/pull/3825))
2225
* [collector] Add a data redaction/deletion example: delete, hash, and partially
2326
mask sensitive attributes with the transform processor, plus a documented
2427
redaction-processor fallback

src/cart/src/cartstore/ValkeyCartStore.cs

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public class ValkeyCartStore : ICartStore
1818
private readonly ILogger _logger;
1919
private const string CartFieldName = "cart";
2020
private const int RedisRetryNumber = 30;
21+
private const int CartMutationMaxAttempts = 30;
2122

2223
private volatile ConnectionMultiplexer _redis;
2324
private volatile bool _isRedisConnectionOpened;
@@ -132,38 +133,28 @@ public async Task AddItemAsync(string userId, string productId, int quantity)
132133

133134
try
134135
{
135-
EnsureRedisConnected();
136-
137-
var db = _redis.GetDatabase();
138-
139-
// Access the cart from the cache
140-
var value = await db.HashGetAsync(userId, CartFieldName);
141-
142-
Oteldemo.Cart cart;
143-
if (value.IsNull)
136+
await MutateCartAsync(userId, cart =>
144137
{
145-
cart = new Oteldemo.Cart
146-
{
147-
UserId = userId
148-
};
149-
cart.Items.Add(new Oteldemo.CartItem { ProductId = productId, Quantity = quantity });
150-
}
151-
else
152-
{
153-
cart = Oteldemo.Cart.Parser.ParseFrom((byte[])value);
154138
var existingItem = cart.Items.SingleOrDefault(i => i.ProductId == productId);
155139
if (existingItem == null)
156140
{
157-
cart.Items.Add(new Oteldemo.CartItem { ProductId = productId, Quantity = quantity });
141+
if (quantity > 0)
142+
{
143+
cart.Items.Add(new Oteldemo.CartItem { ProductId = productId, Quantity = quantity });
144+
}
145+
return;
158146
}
159-
else
147+
148+
existingItem.Quantity += quantity;
149+
if (existingItem.Quantity <= 0)
160150
{
161-
existingItem.Quantity += quantity;
151+
cart.Items.Remove(existingItem);
162152
}
163-
}
164-
165-
await db.HashSetAsync(userId, new[]{ new HashEntry(CartFieldName, cart.ToByteArray()) });
166-
await db.KeyExpireAsync(userId, TimeSpan.FromMinutes(60));
153+
});
154+
}
155+
catch (RpcException)
156+
{
157+
throw;
167158
}
168159
catch (Exception ex)
169160
{
@@ -175,6 +166,42 @@ public async Task AddItemAsync(string userId, string productId, int quantity)
175166
}
176167
}
177168

169+
private async Task MutateCartAsync(string userId, Action<Oteldemo.Cart> mutate)
170+
{
171+
EnsureRedisConnected();
172+
173+
var db = _redis.GetDatabase();
174+
175+
for (var attempt = 0; attempt < CartMutationMaxAttempts; attempt++)
176+
{
177+
var existingValue = await db.HashGetAsync(userId, CartFieldName);
178+
179+
var cart = existingValue.IsNull
180+
? new Oteldemo.Cart { UserId = userId }
181+
: Oteldemo.Cart.Parser.ParseFrom((byte[])existingValue);
182+
183+
mutate(cart);
184+
185+
var transaction = db.CreateTransaction();
186+
transaction.AddCondition(existingValue.IsNull
187+
? Condition.HashNotExists(userId, CartFieldName)
188+
: Condition.HashEqual(userId, CartFieldName, existingValue));
189+
190+
_ = transaction.HashSetAsync(userId, new[] { new HashEntry(CartFieldName, cart.ToByteArray()) });
191+
_ = transaction.KeyExpireAsync(userId, TimeSpan.FromMinutes(60));
192+
193+
if (await transaction.ExecuteAsync())
194+
{
195+
return;
196+
}
197+
198+
await Task.Delay(Random.Shared.Next(2, 10) * (attempt + 1));
199+
}
200+
201+
throw new RpcException(new Status(StatusCode.Aborted,
202+
$"Couldn't update cart for user {userId} after {CartMutationMaxAttempts} attempts due to concurrent modifications."));
203+
}
204+
178205
public async Task EmptyCartAsync(string userId)
179206
{
180207
Log.EmptyCartAsync(_logger, userId);

src/cart/tests/CartServiceTests.cs

Lines changed: 108 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
// Copyright The OpenTelemetry Authors
22
// SPDX-License-Identifier: Apache-2.0
33
using System;
4+
using System.Collections.Generic;
5+
using System.Linq;
46
using System.Threading.Tasks;
57
using Grpc.Net.Client;
68
using Oteldemo;
9+
using Microsoft.AspNetCore.Builder;
10+
using Microsoft.AspNetCore.Hosting;
711
using Microsoft.AspNetCore.TestHost;
12+
using Microsoft.Extensions.DependencyInjection;
813
using Microsoft.Extensions.Hosting;
14+
using Microsoft.Extensions.Logging.Abstractions;
15+
using OpenFeature;
916
using Xunit;
17+
using cart.cartstore;
1018
using static Oteldemo.CartService;
1119

1220
namespace cart.tests;
@@ -17,59 +25,68 @@ public class CartServiceTests
1725

1826
public CartServiceTests()
1927
{
28+
var valkeyAddress = Environment.GetEnvironmentVariable("VALKEY_ADDR") ?? "localhost:6379";
29+
2030
_host = new HostBuilder().ConfigureWebHost(webBuilder =>
2131
{
2232
webBuilder
23-
// .UseStartup<Startup>()
24-
.UseTestServer();
33+
.UseTestServer()
34+
.ConfigureServices(services =>
35+
{
36+
services.AddGrpc();
37+
services.AddSingleton<ICartStore>(_ =>
38+
{
39+
var store = new ValkeyCartStore(NullLogger<ValkeyCartStore>.Instance, valkeyAddress);
40+
store.Initialize();
41+
return store;
42+
});
43+
services.AddSingleton(sp =>
44+
new cart.services.CartService(
45+
sp.GetRequiredService<ICartStore>(),
46+
new ValkeyCartStore(NullLogger<ValkeyCartStore>.Instance, "badhost:1234"),
47+
Api.Instance.GetClient()));
48+
})
49+
.Configure(app =>
50+
{
51+
app.UseRouting();
52+
app.UseEndpoints(endpoints => endpoints.MapGrpcService<cart.services.CartService>());
53+
});
2554
});
2655
}
2756

28-
[Fact(Skip = "See https://github.com/open-telemetry/opentelemetry-demo/pull/746#discussion_r1107931240")]
29-
public async Task GetItem_NoAddItemBefore_EmptyCartReturned()
57+
private async Task<(IHost server, CartServiceClient client)> StartAsync()
3058
{
31-
// Setup test server and client
32-
using var server = await _host.StartAsync();
59+
var server = await _host.StartAsync();
3360
var httpClient = server.GetTestClient();
34-
35-
string userId = Guid.NewGuid().ToString();
36-
37-
// Create a GRPC communication channel between the client and the server
3861
var channel = GrpcChannel.ForAddress(httpClient.BaseAddress, new GrpcChannelOptions
3962
{
4063
HttpClient = httpClient
4164
});
65+
return (server, new CartServiceClient(channel));
66+
}
4267

43-
var cartClient = new CartServiceClient(channel);
68+
[Fact]
69+
public async Task GetItem_NoAddItemBefore_EmptyCartReturned()
70+
{
71+
var (server, client) = await StartAsync();
72+
using var _ = server;
4473

45-
var request = new GetCartRequest
46-
{
47-
UserId = userId,
48-
};
74+
string userId = Guid.NewGuid().ToString();
4975

50-
var cart = await cartClient.GetCartAsync(request);
76+
var cart = await client.GetCartAsync(new GetCartRequest { UserId = userId });
5177
Assert.NotNull(cart);
5278

5379
// All grpc objects implement IEquitable, so we can compare equality with by-value semantics
5480
Assert.Equal(new Cart(), cart);
5581
}
5682

57-
[Fact(Skip = "See https://github.com/open-telemetry/opentelemetry-demo/pull/746#discussion_r1107931240")]
83+
[Fact]
5884
public async Task AddItem_ItemExists_Updated()
5985
{
60-
// Setup test server and client
61-
using var server = await _host.StartAsync();
62-
var httpClient = server.GetTestClient();
86+
var (server, client) = await StartAsync();
87+
using var _ = server;
6388

6489
string userId = Guid.NewGuid().ToString();
65-
66-
// Create a GRPC communication channel between the client and the server
67-
var channel = GrpcChannel.ForAddress(httpClient.BaseAddress, new GrpcChannelOptions
68-
{
69-
HttpClient = httpClient
70-
});
71-
72-
var client = new CartServiceClient(channel);
7390
var request = new AddItemRequest
7491
{
7592
UserId = userId,
@@ -86,11 +103,7 @@ public async Task AddItem_ItemExists_Updated()
86103
// Second add of existing product - quantity should be updated
87104
await client.AddItemAsync(request);
88105

89-
var getCartRequest = new GetCartRequest
90-
{
91-
UserId = userId
92-
};
93-
var cart = await client.GetCartAsync(getCartRequest);
106+
var cart = await client.GetCartAsync(new GetCartRequest { UserId = userId });
94107
Assert.NotNull(cart);
95108
Assert.Equal(userId, cart.UserId);
96109
Assert.Single(cart.Items);
@@ -100,24 +113,13 @@ public async Task AddItem_ItemExists_Updated()
100113
await client.EmptyCartAsync(new EmptyCartRequest { UserId = userId });
101114
}
102115

103-
[Fact(Skip = "See https://github.com/open-telemetry/opentelemetry-demo/pull/746#discussion_r1107931240")]
116+
[Fact]
104117
public async Task AddItem_New_Inserted()
105118
{
106-
// Setup test server and client
107-
using var server = await _host.StartAsync();
108-
var httpClient = server.GetTestClient();
119+
var (server, client) = await StartAsync();
120+
using var _ = server;
109121

110122
string userId = Guid.NewGuid().ToString();
111-
112-
// Create a GRPC communication channel between the client and the server
113-
var channel = GrpcChannel.ForAddress(httpClient.BaseAddress, new GrpcChannelOptions
114-
{
115-
HttpClient = httpClient
116-
});
117-
118-
// Create a proxy object to work with the server
119-
var client = new CartServiceClient(channel);
120-
121123
var request = new AddItemRequest
122124
{
123125
UserId = userId,
@@ -130,10 +132,7 @@ public async Task AddItem_New_Inserted()
130132

131133
await client.AddItemAsync(request);
132134

133-
var getCartRequest = new GetCartRequest
134-
{
135-
UserId = userId
136-
};
135+
var getCartRequest = new GetCartRequest { UserId = userId };
137136
var cart = await client.GetCartAsync(getCartRequest);
138137
Assert.NotNull(cart);
139138
Assert.Equal(userId, cart.UserId);
@@ -143,4 +142,62 @@ public async Task AddItem_New_Inserted()
143142
cart = await client.GetCartAsync(getCartRequest);
144143
Assert.Empty(cart.Items);
145144
}
145+
146+
[Fact]
147+
public async Task AddItem_ConcurrentCallsSameProduct_NoLostUpdates()
148+
{
149+
var (server, client) = await StartAsync();
150+
using var _ = server;
151+
152+
string userId = Guid.NewGuid().ToString();
153+
const int concurrentCalls = 20;
154+
155+
var tasks = Enumerable.Range(0, concurrentCalls).Select(_ => client.AddItemAsync(new AddItemRequest
156+
{
157+
UserId = userId,
158+
Item = new CartItem { ProductId = "race-product", Quantity = 1 }
159+
}).ResponseAsync);
160+
161+
await Task.WhenAll(tasks);
162+
163+
var cart = await client.GetCartAsync(new GetCartRequest { UserId = userId });
164+
Assert.Single(cart.Items);
165+
Assert.Equal(concurrentCalls, cart.Items[0].Quantity);
166+
167+
await client.EmptyCartAsync(new EmptyCartRequest { UserId = userId });
168+
}
169+
170+
[Fact]
171+
public async Task AddItem_NegativeDeltaDuringCheckoutRace_OnlyRemovesOrderedQuantity()
172+
{
173+
var (server, client) = await StartAsync();
174+
using var _ = server;
175+
176+
string userId = Guid.NewGuid().ToString();
177+
178+
await client.AddItemAsync(new AddItemRequest
179+
{
180+
UserId = userId,
181+
Item = new CartItem { ProductId = "ordered-product", Quantity = 2 }
182+
});
183+
184+
await client.AddItemAsync(new AddItemRequest
185+
{
186+
UserId = userId,
187+
Item = new CartItem { ProductId = "concurrently-added-product", Quantity = 1 }
188+
});
189+
190+
await client.AddItemAsync(new AddItemRequest
191+
{
192+
UserId = userId,
193+
Item = new CartItem { ProductId = "ordered-product", Quantity = -2 }
194+
});
195+
196+
var cart = await client.GetCartAsync(new GetCartRequest { UserId = userId });
197+
var remaining = Assert.Single(cart.Items);
198+
Assert.Equal("concurrently-added-product", remaining.ProductId);
199+
Assert.Equal(1, remaining.Quantity);
200+
201+
await client.EmptyCartAsync(new EmptyCartRequest { UserId = userId });
202+
}
146203
}

0 commit comments

Comments
 (0)