Skip to content

Commit 78ddbed

Browse files
Assign the order number under a unique index instead of a read-max race (#778)
1 parent 3c93838 commit 78ddbed

11 files changed

Lines changed: 285 additions & 9 deletions

File tree

‎src/Business/Grand.Business.Checkout/Services/Orders/OrderService.cs‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Grand.Domain.Shipping;
99
using Grand.Infrastructure.Extensions;
1010
using Grand.Mediator;
11+
using Grand.SharedKernel;
1112

1213
namespace Grand.Business.Checkout.Services.Orders;
1314

@@ -38,6 +39,11 @@ IMediator mediator
3839

3940
#region Fields
4041

42+
/// <summary>
43+
/// How many times an order insert is retried when another checkout took the same order number
44+
/// </summary>
45+
private const int OrderNumberAttempts = 5;
46+
4147
private readonly IRepository<Order> _orderRepository;
4248
private readonly IRepository<OrderNote> _orderNoteRepository;
4349
private readonly IMediator _mediator;
@@ -210,11 +216,23 @@ public virtual async Task InsertOrder(Order order)
210216
{
211217
ArgumentNullException.ThrowIfNull(order);
212218

213-
var orderExists = _orderRepository.Table.OrderByDescending(x => x.OrderNumber).Select(x => x.OrderNumber)
214-
.FirstOrDefault();
215-
order.OrderNumber = orderExists != 0 ? orderExists + 1 : 1;
216-
217-
await _orderRepository.InsertAsync(order);
219+
//the order number is taken from the collection itself, so two concurrent checkouts can read the same
220+
//value - the unique index on OrderNumber rejects the loser and the number is read again
221+
for (var attempt = 1;; attempt++)
222+
{
223+
var lastOrderNumber = await _orderRepository.FirstOrDefaultAsync(
224+
_orderRepository.Table.OrderByDescending(x => x.OrderNumber).Select(x => x.OrderNumber));
225+
order.OrderNumber = lastOrderNumber + 1;
226+
227+
try
228+
{
229+
await _orderRepository.InsertAsync(order);
230+
break;
231+
}
232+
catch (DuplicateKeyGrandException) when (attempt < OrderNumberAttempts)
233+
{
234+
}
235+
}
218236

219237
//event notification
220238
await _mediator.EntityInserted(order);

‎src/Core/Grand.Data/LiteDb/LiteDBRepository.cs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Grand.Domain;
2+
using Grand.SharedKernel;
23
using Grand.SharedKernel.Attributes;
34
using LiteDB;
45
using System.Linq.Expressions;
@@ -107,7 +108,16 @@ public virtual T Insert(T entity)
107108
{
108109
entity.CreatedOnUtc = _auditInfoProvider.GetCurrentDateTime();
109110
entity.CreatedBy = _auditInfoProvider.GetCurrentUser();
110-
Collection.Insert(entity);
111+
try
112+
{
113+
Collection.Insert(entity);
114+
}
115+
catch (LiteException ex) when (ex.ErrorCode == LiteException.INDEX_DUPLICATE_KEY)
116+
{
117+
throw new DuplicateKeyGrandException(
118+
$"Insert into {typeof(T).Name} rejected - it violates a unique index", ex);
119+
}
120+
111121
return entity;
112122
}
113123

‎src/Core/Grand.Data/Mongo/MongoRepository.cs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Grand.Domain;
2+
using Grand.SharedKernel;
23
using MongoDB.Driver;
34
using MongoDB.Driver.Linq;
45
using System.Linq.Expressions;
@@ -98,7 +99,15 @@ public virtual T Insert(T entity)
9899
{
99100
entity.CreatedOnUtc = _auditInfoProvider.GetCurrentDateTime();
100101
entity.CreatedBy = _auditInfoProvider.GetCurrentUser();
101-
Collection.InsertOne(entity);
102+
try
103+
{
104+
Collection.InsertOne(entity);
105+
}
106+
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
107+
{
108+
throw DuplicateKey(ex);
109+
}
110+
102111
return entity;
103112
}
104113

@@ -110,10 +119,28 @@ public virtual async Task<T> InsertAsync(T entity)
110119
{
111120
entity.CreatedOnUtc = _auditInfoProvider.GetCurrentDateTime();
112121
entity.CreatedBy = _auditInfoProvider.GetCurrentUser();
113-
await Collection.InsertOneAsync(entity);
122+
try
123+
{
124+
await Collection.InsertOneAsync(entity);
125+
}
126+
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
127+
{
128+
throw DuplicateKey(ex);
129+
}
130+
114131
return entity;
115132
}
116133

134+
/// <summary>
135+
/// Translates a driver duplicate key error into a store independent exception
136+
/// </summary>
137+
/// <param name="exception">Driver exception</param>
138+
private static DuplicateKeyGrandException DuplicateKey(MongoWriteException exception)
139+
{
140+
return new DuplicateKeyGrandException(
141+
$"Insert into {typeof(T).Name} rejected - it violates a unique index", exception);
142+
}
143+
117144
/// <summary>
118145
/// Update entity
119146
/// </summary>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace Grand.SharedKernel;
2+
3+
/// <summary>
4+
/// Represents a write rejected by the store because it violates a unique index
5+
/// </summary>
6+
/// <remarks>
7+
/// Thrown by the repository implementations so that callers can react to a unique key collision
8+
/// without referencing a database driver.
9+
/// </remarks>
10+
[Serializable]
11+
public class DuplicateKeyGrandException : GrandException
12+
{
13+
/// <summary>
14+
/// Initializes a new instance of the DuplicateKeyGrandException class.
15+
/// </summary>
16+
public DuplicateKeyGrandException()
17+
{
18+
}
19+
20+
/// <summary>
21+
/// Initializes a new instance of the DuplicateKeyGrandException class with a specified error message.
22+
/// </summary>
23+
/// <param name="message">The message that describes the error.</param>
24+
public DuplicateKeyGrandException(string message)
25+
: base(message)
26+
{
27+
}
28+
29+
/// <summary>
30+
/// Initializes a new instance of the DuplicateKeyGrandException class with a specified error message
31+
/// and the driver exception that caused it.
32+
/// </summary>
33+
/// <param name="message">The message that describes the error.</param>
34+
/// <param name="innerException">The exception that is the cause of the current exception.</param>
35+
public DuplicateKeyGrandException(string message, Exception innerException)
36+
: base(message, innerException)
37+
{
38+
}
39+
}

‎src/Core/Grand.SharedKernel/GrandException.cs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,15 @@ public GrandException(string message)
2121
: base(message)
2222
{
2323
}
24+
25+
/// <summary>
26+
/// Initializes a new instance of the Exception class with a specified error message and the exception
27+
/// that caused it.
28+
/// </summary>
29+
/// <param name="message">The message that describes the error.</param>
30+
/// <param name="innerException">The exception that is the cause of the current exception.</param>
31+
public GrandException(string message, Exception innerException)
32+
: base(message, innerException)
33+
{
34+
}
2435
}

‎src/Modules/Grand.Module.Installer/Services/InstallationService.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -865,8 +865,9 @@ await dbContext.CreateIndex(_orderRepository,
865865
"CustomerId_1_CreatedOnUtc_-1");
866866
await dbContext.CreateIndex(_orderRepository, OrderBuilder<Order>.Create().Descending(x => x.CreatedOnUtc),
867867
"CreatedOnUtc_-1");
868+
//unique - the order number is assigned from the collection itself, the index is what makes it atomic
868869
await dbContext.CreateIndex(_orderRepository, OrderBuilder<Order>.Create().Descending(x => x.OrderNumber),
869-
"OrderNumber");
870+
"OrderNumber", true);
870871
await dbContext.CreateIndex(_orderRepository, OrderBuilder<Order>.Create().Ascending(x => x.Code), "OrderCode");
871872
await dbContext.CreateIndex(_orderRepository, OrderBuilder<Order>.Create().Ascending("OrderItems.ProductId"),
872873
"OrderItemProductId");
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Grand.Data;
2+
using Grand.Domain.Orders;
3+
using Grand.Infrastructure.Migrations;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace Grand.Module.Migration.Migrations._2._4;
8+
9+
public class MigrationUniqueOrderNumberIndex : IMigration
10+
{
11+
private const string IndexName = "OrderNumber";
12+
13+
public int Priority => 1;
14+
public DbVersion Version => new(2, 4);
15+
public Guid Identity => new("F4D2A7C1-5E93-4B08-9A6D-3C71E852B4F0");
16+
public string Name => "Make the index on Order.OrderNumber unique";
17+
18+
/// <summary>
19+
/// Upgrade process
20+
/// </summary>
21+
/// <param name="serviceProvider"></param>
22+
/// <returns></returns>
23+
public bool UpgradeProcess(IServiceProvider serviceProvider)
24+
{
25+
var repository = serviceProvider.GetRequiredService<IRepository<Order>>();
26+
var databaseContext = serviceProvider.GetRequiredService<IDatabaseContext>();
27+
var logService = serviceProvider.GetRequiredService<ILogger<MigrationUniqueOrderNumberIndex>>();
28+
29+
try
30+
{
31+
var duplicates = repository.Table.Select(x => x.OrderNumber).ToList()
32+
.GroupBy(x => x)
33+
.Where(x => x.Count() > 1)
34+
.Select(x => x.Key)
35+
.ToList();
36+
37+
if (duplicates.Count != 0)
38+
{
39+
//the upgrade must not stop because of data that is already inconsistent - report and move on,
40+
//the index can be created by hand once the numbers are corrected
41+
logService.LogError(
42+
"The unique index on Order.OrderNumber was not created - the following order numbers are used by more than one order: {OrderNumbers}. Correct them and create the index manually",
43+
string.Join(", ", duplicates));
44+
return true;
45+
}
46+
47+
//the non-unique index of the same name is dropped first - the driver rejects a redefinition
48+
databaseContext.DeleteIndex(repository, IndexName).GetAwaiter().GetResult();
49+
databaseContext.CreateIndex(repository,
50+
OrderBuilder<Order>.Create().Descending(x => x.OrderNumber), IndexName, true)
51+
.GetAwaiter().GetResult();
52+
}
53+
catch (Exception ex)
54+
{
55+
logService.LogError(ex, "UpgradeProcess - UniqueOrderNumberIndex (2.4)");
56+
}
57+
58+
return true;
59+
}
60+
}

‎src/Tests/Grand.Business.Checkout.Tests/Services/Orders/OrderServiceTests.cs‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Grand.Domain.Orders;
44
using Grand.Infrastructure.Events;
55
using Grand.Mediator;
6+
using Grand.SharedKernel;
67
using Microsoft.VisualStudio.TestTools.UnitTesting;
78
using Moq;
89

@@ -25,6 +26,76 @@ public void Init()
2526
_service = new OrderService(_orderRepositoryMock.Object, _orderNoteRepositoryMock.Object, _mediatorMock.Object);
2627
}
2728

29+
[TestMethod]
30+
public async Task InsertOrder_AssignsNextOrderNumber()
31+
{
32+
_orderRepositoryMock.Setup(c => c.Table).Returns(new List<Order>().AsQueryable());
33+
_orderRepositoryMock.Setup(c => c.FirstOrDefaultAsync(It.IsAny<IQueryable<int>>(), It.IsAny<CancellationToken>()))
34+
.ReturnsAsync(41);
35+
36+
var order = new Order();
37+
await _service.InsertOrder(order);
38+
39+
Assert.AreEqual(42, order.OrderNumber);
40+
_orderRepositoryMock.Verify(c => c.InsertAsync(It.IsAny<Order>()), Times.Once);
41+
_mediatorMock.Verify(c => c.Publish(It.IsAny<EntityInserted<Order>>(), default), Times.Once);
42+
}
43+
44+
[TestMethod]
45+
public async Task InsertOrder_NoOrdersYet_AssignsFirstOrderNumber()
46+
{
47+
_orderRepositoryMock.Setup(c => c.Table).Returns(new List<Order>().AsQueryable());
48+
_orderRepositoryMock.Setup(c => c.FirstOrDefaultAsync(It.IsAny<IQueryable<int>>(), It.IsAny<CancellationToken>()))
49+
.ReturnsAsync(0);
50+
51+
var order = new Order();
52+
await _service.InsertOrder(order);
53+
54+
Assert.AreEqual(1, order.OrderNumber);
55+
}
56+
57+
[TestMethod]
58+
public async Task InsertOrder_OrderNumberTakenByAnotherCheckout_ReadsItAgainAndRetries()
59+
{
60+
_orderRepositoryMock.Setup(c => c.Table).Returns(new List<Order>().AsQueryable());
61+
_orderRepositoryMock.SetupSequence(c => c.FirstOrDefaultAsync(It.IsAny<IQueryable<int>>(), It.IsAny<CancellationToken>()))
62+
.ReturnsAsync(41)
63+
.ReturnsAsync(42);
64+
65+
var order = new Order();
66+
_orderRepositoryMock.SetupSequence(c => c.InsertAsync(It.IsAny<Order>()))
67+
.Throws(new DuplicateKeyGrandException())
68+
.ReturnsAsync(order);
69+
70+
await _service.InsertOrder(order);
71+
72+
Assert.AreEqual(43, order.OrderNumber);
73+
_orderRepositoryMock.Verify(c => c.InsertAsync(It.IsAny<Order>()), Times.Exactly(2));
74+
_mediatorMock.Verify(c => c.Publish(It.IsAny<EntityInserted<Order>>(), default), Times.Once);
75+
}
76+
77+
[TestMethod]
78+
public async Task InsertOrder_OrderNumberTakenOnEveryAttempt_ThrowsAndDoesNotNotify()
79+
{
80+
_orderRepositoryMock.Setup(c => c.Table).Returns(new List<Order>().AsQueryable());
81+
_orderRepositoryMock.Setup(c => c.FirstOrDefaultAsync(It.IsAny<IQueryable<int>>(), It.IsAny<CancellationToken>()))
82+
.ReturnsAsync(41);
83+
_orderRepositoryMock.Setup(c => c.InsertAsync(It.IsAny<Order>()))
84+
.Throws(new DuplicateKeyGrandException());
85+
86+
await Assert.ThrowsExactlyAsync<DuplicateKeyGrandException>(async () =>
87+
await _service.InsertOrder(new Order()));
88+
89+
_orderRepositoryMock.Verify(c => c.InsertAsync(It.IsAny<Order>()), Times.Exactly(5));
90+
_mediatorMock.Verify(c => c.Publish(It.IsAny<EntityInserted<Order>>(), default), Times.Never);
91+
}
92+
93+
[TestMethod]
94+
public async Task InsertOrder_NullArguments_ThrowException()
95+
{
96+
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await _service.InsertOrder(null));
97+
}
98+
2899
[TestMethod]
29100
public async Task UpdateOrder_InvokeExpectedMethods()
30101
{

‎src/Tests/Grand.Data.Tests/LiteDb/LiteDbRepositoryMock.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Grand.Data.LiteDb;
22
using Grand.Domain;
33
using LiteDB;
4+
using System.Linq.Expressions;
45

56
namespace Grand.Data.Tests.LiteDb;
67

@@ -12,4 +13,9 @@ public class LiteDBRepositoryMock<T> : LiteDBRepository<T>, IRepository<T> where
1213
Database.DropCollection(typeof(T).Name);
1314
Collection = Database.GetCollection<T>(typeof(T).Name);
1415
}
16+
17+
public void EnsureUniqueIndex<K>(Expression<Func<T, K>> keySelector)
18+
{
19+
Collection.EnsureIndex(keySelector, true);
20+
}
1521
}

‎src/Tests/Grand.Data.Tests/LiteDb/LiteDbRepositoryTests.cs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Grand.Domain.Common;
2+
using Grand.SharedKernel;
23
using Microsoft.VisualStudio.TestTools.UnitTesting;
34

45
namespace Grand.Data.Tests.LiteDb;
@@ -41,6 +42,19 @@ public async Task InsertAsync_LiteRepository_Success()
4142
Assert.AreEqual("user", p.CreatedBy);
4243
}
4344

45+
[TestMethod]
46+
public async Task InsertAsync_UniqueIndexViolated_ThrowsDuplicateKeyGrandException()
47+
{
48+
var myRepository = new LiteDBRepositoryMock<SampleCollection>();
49+
myRepository.EnsureUniqueIndex(x => x.Count);
50+
await myRepository.InsertAsync(new SampleCollection { Id = "1", Count = 7 });
51+
52+
await Assert.ThrowsExactlyAsync<DuplicateKeyGrandException>(async () =>
53+
await myRepository.InsertAsync(new SampleCollection { Id = "2", Count = 7 }));
54+
55+
Assert.AreEqual(1, myRepository.Table.Count());
56+
}
57+
4458
[TestMethod]
4559
public async Task GetById_LiteRepository_Success()
4660
{

0 commit comments

Comments
 (0)