From cd5008df0c0973451a99c7ee2667921d1c5edaf6 Mon Sep 17 00:00:00 2001 From: Oleg Zhuk Date: Fri, 26 Jan 2024 13:02:25 +0200 Subject: [PATCH 01/11] VCST-122: Refactored Unique Number Generator feat: Refactored Unique Number Generator --- .../Readme.md | 29 ++-- .../SequenceEntityConfiguration.cs | 24 +++ .../Model/SequenceEntity.cs | 2 + .../Repositories/CoreDbContext.cs | 2 - .../SequenceUniqueNumberGeneratorService.cs | 153 +++--------------- 5 files changed, 58 insertions(+), 152 deletions(-) create mode 100644 src/VirtoCommerce.CoreModule.Data.PostgreSql/SequenceEntityConfiguration.cs diff --git a/src/VirtoCommerce.CoreModule.Data.PostgreSql/Readme.md b/src/VirtoCommerce.CoreModule.Data.PostgreSql/Readme.md index 25bfbdfd3..437dc81f9 100644 --- a/src/VirtoCommerce.CoreModule.Data.PostgreSql/Readme.md +++ b/src/VirtoCommerce.CoreModule.Data.PostgreSql/Readme.md @@ -1,24 +1,19 @@ +# Generate Migrations -## Package manager -Add-Migration Initial -Context VirtoCommerce.CoreModule.Data.Repositories.CoreDbContext -Verbose -OutputDir Migrations -Project VirtoCommerce.CoreModule.Data.PostgreSql -StartupProject VirtoCommerce.CoreModule.Data.PostgreSql -Debug - - - -### Entity Framework Core Commands -``` -dotnet tool install --global dotnet-ef --version 6.* +## Install CLI tools for Entity Framework Core +```cmd +dotnet tool install --global dotnet-ef --version 8.0.0 ``` -**Generate Migrations** +or update +```cmd +dotnet tool update --global dotnet-ef --version 8.0.0 ``` -dotnet ef migrations add Initial -- "{connection string}" -dotnet ef migrations add Update1 -- "{connection string}" -dotnet ef migrations add Update2 -- "{connection string}" -``` - -etc.. -**Apply Migrations** +## Add Migration +Select Data. folder and run following command for each provider: -`dotnet ef database update -- "{connection string}"` +```cmd +dotnet ef migrations add +``` diff --git a/src/VirtoCommerce.CoreModule.Data.PostgreSql/SequenceEntityConfiguration.cs b/src/VirtoCommerce.CoreModule.Data.PostgreSql/SequenceEntityConfiguration.cs new file mode 100644 index 000000000..8338aee9d --- /dev/null +++ b/src/VirtoCommerce.CoreModule.Data.PostgreSql/SequenceEntityConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using VirtoCommerce.CoreModule.Data.Model; + +namespace VirtoCommerce.CoreModule.Data.PostgreSql +{ + public class SequenceEntityConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + var converter = new ValueConverter( + v => BitConverter.ToInt64(v, 0), + v => BitConverter.GetBytes(v)); + + builder.Property(c => c.RowVersion) + .HasColumnType("xid") + .HasColumnName("xmin") + .HasConversion(converter) + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); + } + } +} diff --git a/src/VirtoCommerce.CoreModule.Data/Model/SequenceEntity.cs b/src/VirtoCommerce.CoreModule.Data/Model/SequenceEntity.cs index 9e66838df..a6b34ff80 100644 --- a/src/VirtoCommerce.CoreModule.Data/Model/SequenceEntity.cs +++ b/src/VirtoCommerce.CoreModule.Data/Model/SequenceEntity.cs @@ -16,5 +16,7 @@ public class SequenceEntity [Timestamp] public byte[] RowVersion { get; set; } + + //public DateTime? LastResetDate { get; set; } } } diff --git a/src/VirtoCommerce.CoreModule.Data/Repositories/CoreDbContext.cs b/src/VirtoCommerce.CoreModule.Data/Repositories/CoreDbContext.cs index 20af0e535..0de79721d 100644 --- a/src/VirtoCommerce.CoreModule.Data/Repositories/CoreDbContext.cs +++ b/src/VirtoCommerce.CoreModule.Data/Repositories/CoreDbContext.cs @@ -1,7 +1,5 @@ using System.Reflection; -using EntityFrameworkCore.Triggers; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; using VirtoCommerce.CoreModule.Data.Currency; using VirtoCommerce.CoreModule.Data.Model; using VirtoCommerce.CoreModule.Data.Package; diff --git a/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs b/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs index e50b9733f..40128e407 100644 --- a/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs +++ b/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs @@ -1,6 +1,7 @@ using System; -using System.Collections.Generic; using System.Linq; +using Microsoft.EntityFrameworkCore; +using Polly; using VirtoCommerce.CoreModule.Core.Common; using VirtoCommerce.CoreModule.Data.Model; using VirtoCommerce.CoreModule.Data.Repositories; @@ -9,15 +10,8 @@ namespace VirtoCommerce.CoreModule.Data.Services { public class SequenceUniqueNumberGeneratorService : IUniqueNumberGenerator { - - //How many sequence items will be stored in-memory - public const int SequenceReservationRange = 100; - public const int DefaultSequenceStartValue = 1; - + private readonly object _lock = new object(); private readonly Func _repositoryFactory; - private static readonly object _sequenceLock = new object(); - private static readonly InMemorySequenceList _inMemorySequences = new InMemorySequenceList(); - public SequenceUniqueNumberGeneratorService(Func repositoryFactory) { @@ -31,145 +25,38 @@ public SequenceUniqueNumberGeneratorService(Func repositoryFact /// public string GenerateNumber(string numberTemplate) { - lock (_sequenceLock) - { - _inMemorySequences[numberTemplate] = _inMemorySequences[numberTemplate] ?? new InMemorySequence(numberTemplate); - - if (_inMemorySequences[numberTemplate].IsEmpty || _inMemorySequences[numberTemplate].HasExpired) - { - const int maxTransactionRetries = 3; - - for (var retryCount = 0; retryCount < maxTransactionRetries; retryCount++) - { - try - { - InitCounters(numberTemplate, out var startCounter, out var endCounter); - _inMemorySequences[numberTemplate].Pregenerate(startCounter, endCounter, numberTemplate); - break; - } - catch (Exception) - { - // Catching base Exception as any db exception thrown as FaultException. - } - } - } - - return string.Format(_inMemorySequences[numberTemplate].Next()); - } - } + var retryPolicy = Policy.Handle().WaitAndRetry(retryCount: 10, _ => TimeSpan.FromMilliseconds(5)); - private void InitCounters(string objectType, out int startCounter, out int endCounter) - { - //Update Sequences in database - using (var repository = _repositoryFactory()) + lock (_lock) { - var sequence = repository.Sequences.SingleOrDefault(s => s.ObjectType == objectType); - var originalModifiedDate = sequence?.ModifiedDate; - - if (sequence != null) - { - sequence.ModifiedDate = DateTime.UtcNow; - } - else - { - sequence = new SequenceEntity { ObjectType = objectType, Value = DefaultSequenceStartValue, ModifiedDate = DateTime.UtcNow }; - repository.Add(sequence); - } - + var currentDate = DateTime.Now; // {0} - repository.UnitOfWork.Commit(); - //TODO will check it - //Refresh data to make sure we have latest value in case another transaction was locked - //repository.Refresh(repository.Sequences); - sequence = repository.Sequences.Single(s => s.ObjectType == objectType); - startCounter = sequence.Value; + int counter = 0; + retryPolicy.Execute(() => counter = RequestNextCounter(numberTemplate)); - //Sequence in database has expired? - if (originalModifiedDate.HasValue && originalModifiedDate.Value.Date < DateTime.UtcNow.Date) - { - startCounter = DefaultSequenceStartValue; - } - - try - { - endCounter = checked(startCounter + SequenceReservationRange); - } - catch (OverflowException) - { - //need to reset - startCounter = DefaultSequenceStartValue; - endCounter = SequenceReservationRange; - } - - sequence.Value = endCounter; - repository.UnitOfWork.Commit(); + return string.Format(numberTemplate, currentDate, counter); } } - private class InMemorySequence + protected virtual int RequestNextCounter(string numberTemplate) { - private readonly string _type; - private Stack _sequence = new Stack(); - private DateTime? _lastGenerationDateTime; - - public InMemorySequence(string type) - { - _type = type; - } - - public string ObjectType - { - get { return _type; } - } - - public bool HasExpired - { - get { return _lastGenerationDateTime.HasValue && _lastGenerationDateTime.Value.Date < DateTime.UtcNow.Date; } - } - - public bool IsEmpty - { - get { return _sequence.Count == 0; } - } + using var repository = _repositoryFactory(); + var sequence = repository.Sequences.SingleOrDefault(s => s.ObjectType == numberTemplate); - public string Next() + if (sequence != null) { - return _sequence.Pop(); + sequence.ModifiedDate = DateTime.UtcNow; + sequence.Value += 1; } - - public void Pregenerate(int startCount, int endCount, string numberTemplate) + else { - _lastGenerationDateTime = DateTime.UtcNow; - var generatedItems = new Stack(); - for (var index = startCount; index < endCount; index++) - { - generatedItems.Push(string.Format(numberTemplate, _lastGenerationDateTime.Value, index)); - } - - //This revereses the sequence - _sequence = new Stack(generatedItems); + sequence = new SequenceEntity { ObjectType = numberTemplate, Value = 1, ModifiedDate = DateTime.UtcNow }; + repository.Add(sequence); } - } - private class InMemorySequenceList : List - { - public InMemorySequence this[string type] - { - get - { - return this.FirstOrDefault(i => i.ObjectType.Equals(type, StringComparison.OrdinalIgnoreCase)); - } - set - { - var exitingItem = this[type]; + repository.UnitOfWork.Commit(); - if (exitingItem != null) - { - Remove(exitingItem); - } - Add(value); - } - } + return sequence.Value; } } } From 6c781204ae8e45ce73c42c480f53fcad483613d1 Mon Sep 17 00:00:00 2001 From: artem-dudarev Date: Fri, 26 Jan 2024 13:14:26 +0200 Subject: [PATCH 02/11] Update row version for PostgreSQL --- ...0240126111303_UpdateRowVersion.Designer.cs | 147 ++++++++++++++++++ .../20240126111303_UpdateRowVersion.cs | 50 ++++++ .../Migrations/CoreDbContextModelSnapshot.cs | 7 +- 3 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/20240126111303_UpdateRowVersion.Designer.cs create mode 100644 src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/20240126111303_UpdateRowVersion.cs diff --git a/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/20240126111303_UpdateRowVersion.Designer.cs b/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/20240126111303_UpdateRowVersion.Designer.cs new file mode 100644 index 000000000..50c098ab2 --- /dev/null +++ b/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/20240126111303_UpdateRowVersion.Designer.cs @@ -0,0 +1,147 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using VirtoCommerce.CoreModule.Data.Repositories; + +#nullable disable + +namespace VirtoCommerce.CoreModule.Data.PostgreSql.Migrations +{ + [DbContext(typeof(CoreDbContext))] + [Migration("20240126111303_UpdateRowVersion")] + partial class UpdateRowVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("VirtoCommerce.CoreModule.Data.Currency.CurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedBy") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFormatting") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExchangeRate") + .HasColumnType("Money"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("MidpointRounding") + .HasMaxLength(18) + .HasColumnType("character varying(18)"); + + b.Property("ModifiedBy") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ModifiedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RoundingType") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Symbol") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .HasDatabaseName("IX_Code"); + + b.ToTable("Currency", (string)null); + }); + + modelBuilder.Entity("VirtoCommerce.CoreModule.Data.Model.SequenceEntity", b => + { + b.Property("ObjectType") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ModifiedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("ObjectType"); + + b.ToTable("Sequence", (string)null); + }); + + modelBuilder.Entity("VirtoCommerce.CoreModule.Data.Package.PackageTypeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Height") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Length") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("MeasureUnit") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("Width") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("Id"); + + b.ToTable("PackageType", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/20240126111303_UpdateRowVersion.cs b/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/20240126111303_UpdateRowVersion.cs new file mode 100644 index 000000000..1810c2088 --- /dev/null +++ b/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/20240126111303_UpdateRowVersion.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VirtoCommerce.CoreModule.Data.PostgreSql.Migrations +{ + /// + public partial class UpdateRowVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "RowVersion", + table: "Sequence", + newName: "xmin"); + + migrationBuilder.AlterColumn( + name: "xmin", + table: "Sequence", + type: "xid", + rowVersion: true, + nullable: true, + oldClrType: typeof(byte[]), + oldType: "bytea", + oldRowVersion: true, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "xmin", + table: "Sequence", + newName: "RowVersion"); + + migrationBuilder.AlterColumn( + name: "RowVersion", + table: "Sequence", + type: "bytea", + rowVersion: true, + nullable: true, + oldClrType: typeof(uint), + oldType: "xid", + oldRowVersion: true, + oldNullable: true); + } + } +} diff --git a/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/CoreDbContextModelSnapshot.cs b/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/CoreDbContextModelSnapshot.cs index 234bd7571..838e6cd3f 100644 --- a/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/CoreDbContextModelSnapshot.cs +++ b/src/VirtoCommerce.CoreModule.Data.PostgreSql/Migrations/CoreDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("ProductVersion", "8.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -92,10 +92,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ModifiedDate") .HasColumnType("timestamp with time zone"); - b.Property("RowVersion") + b.Property("RowVersion") .IsConcurrencyToken() .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); + .HasColumnType("xid") + .HasColumnName("xmin"); b.Property("Value") .HasColumnType("integer"); From 0eb2e75aaa1a11e628f8380f5d8ea4f500480cb9 Mon Sep 17 00:00:00 2001 From: Oleg Zhuk Date: Fri, 26 Jan 2024 16:38:43 +0200 Subject: [PATCH 03/11] feat: Added ITenantUniqueNumberGenerator. feat: Added Integration and Unit tests. --- .../Common/ITenantUniqueNumberGenerator.cs | 27 +++ .../Common/ResetCounterType.cs | 11 + .../SequenceUniqueNumberGeneratorService.cs | 123 ++++++++++- .../UniqueNumberGeneratorTest.cs | 202 ++++++++++++++++++ .../VirtoCommerce.CoreModule.Tests.csproj | 1 + 5 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 src/VirtoCommerce.CoreModule.Core/Common/ITenantUniqueNumberGenerator.cs create mode 100644 src/VirtoCommerce.CoreModule.Core/Common/ResetCounterType.cs create mode 100644 tests/VirtoCommerce.CoreModule.Tests/UniqueNumberGeneratorTest.cs diff --git a/src/VirtoCommerce.CoreModule.Core/Common/ITenantUniqueNumberGenerator.cs b/src/VirtoCommerce.CoreModule.Core/Common/ITenantUniqueNumberGenerator.cs new file mode 100644 index 000000000..8b719d352 --- /dev/null +++ b/src/VirtoCommerce.CoreModule.Core/Common/ITenantUniqueNumberGenerator.cs @@ -0,0 +1,27 @@ +namespace VirtoCommerce.CoreModule.Core.Common +{ + /// + /// Represents options for unique number generation. + /// + public class UniqueNumberGeneratorOptions + { + public ResetCounterType ResetCounterType { get; set; } + public int StartCounterFrom { get; set; } = 1; + public int CounterIncrement { get; set; } = 1; + } + + /// + /// Represents interface for unique number generation for tenant. + /// + public interface ITenantUniqueNumberGenerator + { + /// + /// Generates unique number using given template with resetCounterType for tentantId. + /// + /// + /// + /// + /// + string GenerateNumber(string tentantId, string numberTemplate, UniqueNumberGeneratorOptions options); + } +} diff --git a/src/VirtoCommerce.CoreModule.Core/Common/ResetCounterType.cs b/src/VirtoCommerce.CoreModule.Core/Common/ResetCounterType.cs new file mode 100644 index 000000000..a36495c28 --- /dev/null +++ b/src/VirtoCommerce.CoreModule.Core/Common/ResetCounterType.cs @@ -0,0 +1,11 @@ +namespace VirtoCommerce.CoreModule.Core.Common +{ + public enum ResetCounterType + { + None = 0, + Daily = 1, + Weekly = 2, + Monthly = 3, + Yearly = 5, + } +} diff --git a/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs b/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs index 40128e407..7ec03abd1 100644 --- a/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs +++ b/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs @@ -2,17 +2,29 @@ using System.Linq; using Microsoft.EntityFrameworkCore; using Polly; +using Polly.Retry; using VirtoCommerce.CoreModule.Core.Common; using VirtoCommerce.CoreModule.Data.Model; using VirtoCommerce.CoreModule.Data.Repositories; namespace VirtoCommerce.CoreModule.Data.Services { - public class SequenceUniqueNumberGeneratorService : IUniqueNumberGenerator + /// + /// Represents implementation of unique number generator using database sequences. + /// Generates unique number using given template, e.g., GenerateNumber("Order{0:yyMMdd}-{1:D5}"); + /// {0} - date (the UTC time of number generation) + /// {1} - the sequence number + /// {2} - tenantId + /// + public class SequenceUniqueNumberGeneratorService : IUniqueNumberGenerator, ITenantUniqueNumberGenerator { private readonly object _lock = new object(); private readonly Func _repositoryFactory; + /// + /// Creates new instance of SequenceUniqueNumberGeneratorService. + /// + /// public SequenceUniqueNumberGeneratorService(Func repositoryFactory) { _repositoryFactory = repositoryFactory; @@ -25,32 +37,83 @@ public SequenceUniqueNumberGeneratorService(Func repositoryFact /// public string GenerateNumber(string numberTemplate) { - var retryPolicy = Policy.Handle().WaitAndRetry(retryCount: 10, _ => TimeSpan.FromMilliseconds(5)); + return GenerateNumber(string.Empty, numberTemplate, + new UniqueNumberGeneratorOptions + { + ResetCounterType = ResetCounterType.Daily, + CounterIncrement = 1, + StartCounterFrom = 1 + }); + } + + /// + /// Generates unique number using given template and options for tentantId. + /// + /// + /// + /// + /// + public string GenerateNumber(string tentantId, string numberTemplate, UniqueNumberGeneratorOptions options) + { + var retryPolicy = ConfigureRetryPolicy(); lock (_lock) { - var currentDate = DateTime.Now; // {0} + var currentDate = DateTime.Now; + var counter = 0; - int counter = 0; - retryPolicy.Execute(() => counter = RequestNextCounter(numberTemplate)); + retryPolicy.Execute(() => counter = RequestNextCounter(tentantId, numberTemplate, options)); - return string.Format(numberTemplate, currentDate, counter); + return string.Format(numberTemplate, currentDate, counter, tentantId); } } - protected virtual int RequestNextCounter(string numberTemplate) + /// + /// Configures retry policy for database operations. + /// + /// + protected virtual RetryPolicy ConfigureRetryPolicy() + { + return Policy.Handle() + .Or() + .WaitAndRetry(retryCount: 15, _ => TimeSpan.FromMilliseconds(5)); + } + + /// + /// Requests next counter for given number template. + /// + /// + /// + /// + /// + protected virtual int RequestNextCounter(string tenantId, string numberTemplate, UniqueNumberGeneratorOptions options) { using var repository = _repositoryFactory(); var sequence = repository.Sequences.SingleOrDefault(s => s.ObjectType == numberTemplate); if (sequence != null) { - sequence.ModifiedDate = DateTime.UtcNow; - sequence.Value += 1; + var lastResetDate = sequence.ModifiedDate ?? GetCurrentUtcDate(); + + if (ShouldResetCounter(lastResetDate, options.ResetCounterType)) + { + sequence.Value = options.StartCounterFrom; + } + else + { + sequence.Value += options.CounterIncrement; + } + + sequence.ModifiedDate = GetCurrentUtcDate(); } else { - sequence = new SequenceEntity { ObjectType = numberTemplate, Value = 1, ModifiedDate = DateTime.UtcNow }; + sequence = new SequenceEntity + { + ObjectType = numberTemplate, + Value = options.StartCounterFrom, + ModifiedDate = GetCurrentUtcDate() + }; repository.Add(sequence); } @@ -58,5 +121,45 @@ protected virtual int RequestNextCounter(string numberTemplate) return sequence.Value; } + + /// + /// Returns true if counter should be reset. + /// + /// + /// + /// + protected virtual bool ShouldResetCounter(DateTime lastResetDate, ResetCounterType resetCounterType) + { + var currentUtcDate = GetCurrentUtcDate(); + + switch (resetCounterType) + { + case ResetCounterType.Daily: + return currentUtcDate.Date > lastResetDate.Date; + case ResetCounterType.Weekly: + // Reset every Monday + int daysUntilTargetDay = ((int)DayOfWeek.Monday - (int)lastResetDate.DayOfWeek + 7) % 7; + var nextMondayDate = lastResetDate.Date.AddDays(daysUntilTargetDay); + return currentUtcDate >= nextMondayDate; + case ResetCounterType.Monthly: + // Reset on first day of the month + return currentUtcDate.Month > lastResetDate.Month || currentUtcDate.Year > lastResetDate.Year; + case ResetCounterType.Yearly: + // Reset on first day of the year + return currentUtcDate.Year > lastResetDate.Year; + case ResetCounterType.None: + default: + return false; + } + } + + /// + /// Returns current UTC date. Allows to override for testing purposes. + /// + /// + protected virtual DateTime GetCurrentUtcDate() + { + return DateTime.UtcNow; + } } } diff --git a/tests/VirtoCommerce.CoreModule.Tests/UniqueNumberGeneratorTest.cs b/tests/VirtoCommerce.CoreModule.Tests/UniqueNumberGeneratorTest.cs new file mode 100644 index 000000000..46088c8bc --- /dev/null +++ b/tests/VirtoCommerce.CoreModule.Tests/UniqueNumberGeneratorTest.cs @@ -0,0 +1,202 @@ +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Moq; +using VirtoCommerce.CoreModule.Core.Common; +using VirtoCommerce.CoreModule.Data.Model; +using VirtoCommerce.CoreModule.Data.Repositories; +using VirtoCommerce.CoreModule.Data.Services; +using VirtoCommerce.Platform.Core.Domain; +using Xunit; + +namespace VirtoCommerce.CoreModule.Tests +{ + public class CustomDateSequenceUniqueNumberGeneratorService : SequenceUniqueNumberGeneratorService + { + public DateTime CurrentUtcDate { get; set; } + + public CustomDateSequenceUniqueNumberGeneratorService(Func repositoryFactory, + DateTime currentUtcDate) : base(repositoryFactory) + { + CurrentUtcDate = currentUtcDate; + } + + protected override DateTime GetCurrentUtcDate() + { + return CurrentUtcDate; + } + } + + public class UniqueNumberGeneratorTest + { + [Fact] + [Trait("Category", "IntegrationTest")] + public void IntegrationTestWithSqlServer() + { + var connectionString = "Data Source=(local);Initial Catalog=VirtoCommerce3;Persist Security Info=True;User ID=virto;Password=virto;Connect Timeout=30;TrustServerCertificate=True;"; + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlServer(connectionString); + + var generator = new SequenceUniqueNumberGeneratorService(() => new CoreRepositoryImpl(new CoreDbContext(optionsBuilder.Options))); + + var number = generator.GenerateNumber("PO{0:yyMMdd}-{1:D5}"); + Assert.NotNull(number); + } + + [Theory] + [InlineData("{1}", "2")] + [InlineData("PO-{1:D5}", "PO-00002")] + [InlineData("CO{0:yyMMdd}-{1:D5}", "CO240126-00002")] + public void GenerateNumber_ShouldGenerateUniqueNumbersWithDifferentParameters(string template, string expected) + { + // Arrange + var sequenceEntity = new SequenceEntity { ObjectType = template, Value = 1, ModifiedDate = DateTime.UtcNow }; + + var repositoryFactoryMock = CreateRepositoryMock(sequenceEntity); + + var numberGeneratorService = new CustomDateSequenceUniqueNumberGeneratorService( + repositoryFactoryMock.Object, + new DateTime(2024, 01, 26, 0, 0, 0, DateTimeKind.Utc)); + + // Act + var generatedNumber = numberGeneratorService.GenerateNumber(template); + + // Assert + Assert.Equal(expected, generatedNumber); + } + + + + public static TheoryData ResetCounterCases = + new() + { + // Daily + { + new DateTime(2024, 01, 26, 23, 59, 58, DateTimeKind.Utc), + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + ResetCounterType.Daily, + "00778" + }, + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2024, 01, 27, 00, 00, 00, DateTimeKind.Utc), + ResetCounterType.Daily, + "00001" + }, + // Weekly + { + new DateTime(2024, 01, 26, 23, 59, 58, DateTimeKind.Utc), + new DateTime(2024, 01, 28, 23, 59, 59, DateTimeKind.Utc), + ResetCounterType.Weekly, + "00778" + }, + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2024, 01, 29, 00, 00, 00, DateTimeKind.Utc), + ResetCounterType.Weekly, + "00001" + }, + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2024, 01, 30, 00, 00, 00, DateTimeKind.Utc), + ResetCounterType.Weekly, + "00001" + }, + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2024, 05, 30, 00, 00, 00, DateTimeKind.Utc), + ResetCounterType.Weekly, + "00001" + }, + // Monthly + { + new DateTime(2024, 01, 26, 23, 59, 58, DateTimeKind.Utc), + new DateTime(2024, 01, 31, 23, 59, 59, DateTimeKind.Utc), + ResetCounterType.Monthly, + "00778" + }, + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2024, 02, 01, 00, 00, 00, DateTimeKind.Utc), + ResetCounterType.Monthly, + "00001" + }, + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2025, 01, 01, 23, 59, 59, DateTimeKind.Utc), + ResetCounterType.Monthly, + "00001" + }, + // Yearly + { + new DateTime(2024, 01, 26, 23, 59, 58, DateTimeKind.Utc), + new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc), + ResetCounterType.Yearly, + "00778" + }, + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2025, 02, 01, 00, 00, 00, DateTimeKind.Utc), + ResetCounterType.Yearly, + "00001" + }, + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2030, 01, 01, 23, 59, 59, DateTimeKind.Utc), + ResetCounterType.Yearly, + "00001" + }, + // Never + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2025, 02, 01, 00, 00, 00, DateTimeKind.Utc), + ResetCounterType.None, + "00778" + }, + { + new DateTime(2024, 01, 26, 23, 59, 59, DateTimeKind.Utc), + new DateTime(2030, 01, 01, 23, 59, 59, DateTimeKind.Utc), + ResetCounterType.None, + "00778" + }, + }; + + + [Theory] + [MemberData(nameof(ResetCounterCases))] + public void GenerateNumber_ResetCounter(DateTime lastResetDate, DateTime currentDate, ResetCounterType resetCounterType, string expected) + { + var tenantId = "TEST"; + var template = "{1:D5}"; + + // Arrange + var sequenceEntity = new SequenceEntity { ObjectType = template, Value = 777, ModifiedDate = lastResetDate }; + + var repositoryFactoryMock = CreateRepositoryMock(sequenceEntity); + + var numberGeneratorService = new CustomDateSequenceUniqueNumberGeneratorService( + repositoryFactoryMock.Object, + currentDate); + + // Act + var generatedNumber = numberGeneratorService.GenerateNumber(tenantId, template, + new UniqueNumberGeneratorOptions { ResetCounterType = resetCounterType }); + + // Assert + Assert.Equal(expected, generatedNumber); + } + + private static Mock> CreateRepositoryMock(SequenceEntity sequenceEntity) + { + var repositoryMock = new Mock(); + repositoryMock.Setup(r => r.Sequences).Returns((new SequenceEntity[] { sequenceEntity }).AsQueryable()); + repositoryMock.Setup(r => r.UnitOfWork).Returns((new Mock()).Object); + + + var repositoryFactoryMock = new Mock>(); + repositoryFactoryMock.Setup(f => f()).Returns(repositoryMock.Object); + return repositoryFactoryMock; + } + } +} diff --git a/tests/VirtoCommerce.CoreModule.Tests/VirtoCommerce.CoreModule.Tests.csproj b/tests/VirtoCommerce.CoreModule.Tests/VirtoCommerce.CoreModule.Tests.csproj index cef1210e9..cb04207c6 100644 --- a/tests/VirtoCommerce.CoreModule.Tests/VirtoCommerce.CoreModule.Tests.csproj +++ b/tests/VirtoCommerce.CoreModule.Tests/VirtoCommerce.CoreModule.Tests.csproj @@ -19,6 +19,7 @@ + \ No newline at end of file From ac360b87bcf919d8cf12af0f242fa3cce37692db Mon Sep 17 00:00:00 2001 From: Oleg Zhuk Date: Fri, 26 Jan 2024 16:54:51 +0200 Subject: [PATCH 04/11] Add SequenceNumberGeneratorOptions --- .../Common/NumberGeneratorOptions.cs | 14 ++++++++++++++ .../SequenceUniqueNumberGeneratorService.cs | 10 ++++++++-- src/VirtoCommerce.CoreModule.Web/Module.cs | 10 ++++++---- .../UniqueNumberGeneratorTest.cs | 7 ++++--- 4 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 src/VirtoCommerce.CoreModule.Core/Common/NumberGeneratorOptions.cs diff --git a/src/VirtoCommerce.CoreModule.Core/Common/NumberGeneratorOptions.cs b/src/VirtoCommerce.CoreModule.Core/Common/NumberGeneratorOptions.cs new file mode 100644 index 000000000..8b58b0373 --- /dev/null +++ b/src/VirtoCommerce.CoreModule.Core/Common/NumberGeneratorOptions.cs @@ -0,0 +1,14 @@ +namespace VirtoCommerce.CoreModule.Core.Common +{ + public class SequenceNumberGeneratorOptions + { + /// + /// Defines the number of retries to generate unique number if either DbUpdateConcurrencyException or InvalidOperationException is occured. + /// + public int RetryCount { get; set; } = 15; + /// + /// Defines the delay between retries in seconds. + /// + public int RetryDelay { get; set; } = 5; + } +} diff --git a/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs b/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs index 7ec03abd1..f071bc2e2 100644 --- a/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs +++ b/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Polly; using Polly.Retry; using VirtoCommerce.CoreModule.Core.Common; @@ -21,13 +22,18 @@ public class SequenceUniqueNumberGeneratorService : IUniqueNumberGenerator, ITen private readonly object _lock = new object(); private readonly Func _repositoryFactory; + private readonly SequenceNumberGeneratorOptions _options; + /// /// Creates new instance of SequenceUniqueNumberGeneratorService. /// /// - public SequenceUniqueNumberGeneratorService(Func repositoryFactory) + /// + public SequenceUniqueNumberGeneratorService(Func repositoryFactory, + IOptions options) { _repositoryFactory = repositoryFactory; + _options = options.Value; } /// @@ -76,7 +82,7 @@ protected virtual RetryPolicy ConfigureRetryPolicy() { return Policy.Handle() .Or() - .WaitAndRetry(retryCount: 15, _ => TimeSpan.FromMilliseconds(5)); + .WaitAndRetry(retryCount: _options.RetryCount, _ => TimeSpan.FromMilliseconds(_options.RetryDelay)); } /// diff --git a/src/VirtoCommerce.CoreModule.Web/Module.cs b/src/VirtoCommerce.CoreModule.Web/Module.cs index d14e00888..a7cb59c5f 100644 --- a/src/VirtoCommerce.CoreModule.Web/Module.cs +++ b/src/VirtoCommerce.CoreModule.Web/Module.cs @@ -72,6 +72,8 @@ public void Initialize(IServiceCollection serviceCollection) // Money rounding serviceCollection.AddTransient(); + + serviceCollection.AddOptions().Bind(Configuration.GetSection("VirtoCommerce:SequenceNumberGenerator")).ValidateDataAnnotations(); } public void PostInitialize(IApplicationBuilder appBuilder) @@ -103,15 +105,15 @@ public void Uninstall() // Method intentionally left empty. } - public async Task ExportAsync(Stream outStream, ExportImportOptions options, Action progressCallback, ICancellationToken cancellationToken) + public Task ExportAsync(Stream outStream, ExportImportOptions options, Action progressCallback, ICancellationToken cancellationToken) { - await _appBuilder.ApplicationServices.GetRequiredService().ExportAsync(outStream, options, progressCallback, cancellationToken); + return _appBuilder.ApplicationServices.GetRequiredService().ExportAsync(outStream, options, progressCallback, cancellationToken); } - public async Task ImportAsync(Stream inputStream, ExportImportOptions options, Action progressCallback, + public Task ImportAsync(Stream inputStream, ExportImportOptions options, Action progressCallback, ICancellationToken cancellationToken) { - await _appBuilder.ApplicationServices.GetRequiredService().ImportAsync(inputStream, options, progressCallback, cancellationToken); + return _appBuilder.ApplicationServices.GetRequiredService().ImportAsync(inputStream, options, progressCallback, cancellationToken); } } } diff --git a/tests/VirtoCommerce.CoreModule.Tests/UniqueNumberGeneratorTest.cs b/tests/VirtoCommerce.CoreModule.Tests/UniqueNumberGeneratorTest.cs index 46088c8bc..582fb23a7 100644 --- a/tests/VirtoCommerce.CoreModule.Tests/UniqueNumberGeneratorTest.cs +++ b/tests/VirtoCommerce.CoreModule.Tests/UniqueNumberGeneratorTest.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Moq; using VirtoCommerce.CoreModule.Core.Common; using VirtoCommerce.CoreModule.Data.Model; @@ -16,7 +17,7 @@ public class CustomDateSequenceUniqueNumberGeneratorService : SequenceUniqueNumb public DateTime CurrentUtcDate { get; set; } public CustomDateSequenceUniqueNumberGeneratorService(Func repositoryFactory, - DateTime currentUtcDate) : base(repositoryFactory) + DateTime currentUtcDate) : base(repositoryFactory, Options.Create(new SequenceNumberGeneratorOptions())) { CurrentUtcDate = currentUtcDate; } @@ -38,7 +39,8 @@ public void IntegrationTestWithSqlServer() var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseSqlServer(connectionString); - var generator = new SequenceUniqueNumberGeneratorService(() => new CoreRepositoryImpl(new CoreDbContext(optionsBuilder.Options))); + var generator = new SequenceUniqueNumberGeneratorService(() => new CoreRepositoryImpl(new CoreDbContext(optionsBuilder.Options)), + Options.Create(new SequenceNumberGeneratorOptions())); var number = generator.GenerateNumber("PO{0:yyMMdd}-{1:D5}"); Assert.NotNull(number); @@ -193,7 +195,6 @@ private static Mock> CreateRepositoryMock(SequenceEntity s repositoryMock.Setup(r => r.Sequences).Returns((new SequenceEntity[] { sequenceEntity }).AsQueryable()); repositoryMock.Setup(r => r.UnitOfWork).Returns((new Mock()).Object); - var repositoryFactoryMock = new Mock>(); repositoryFactoryMock.Setup(f => f()).Returns(repositoryMock.Object); return repositoryFactoryMock; From 3e5724b6827df65b7e20c271105119df3f783238 Mon Sep 17 00:00:00 2001 From: Oleg Zhuk Date: Fri, 26 Jan 2024 18:03:59 +0200 Subject: [PATCH 05/11] feats: Add support for counter options via template number --- .../Model/SequenceEntity.cs | 2 - .../SequenceUniqueNumberGeneratorService.cs | 79 ++++++++++++++++--- .../UniqueNumberGeneratorTest.cs | 14 ++-- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/VirtoCommerce.CoreModule.Data/Model/SequenceEntity.cs b/src/VirtoCommerce.CoreModule.Data/Model/SequenceEntity.cs index a6b34ff80..9e66838df 100644 --- a/src/VirtoCommerce.CoreModule.Data/Model/SequenceEntity.cs +++ b/src/VirtoCommerce.CoreModule.Data/Model/SequenceEntity.cs @@ -16,7 +16,5 @@ public class SequenceEntity [Timestamp] public byte[] RowVersion { get; set; } - - //public DateTime? LastResetDate { get; set; } } } diff --git a/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs b/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs index f071bc2e2..87f57e892 100644 --- a/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs +++ b/src/VirtoCommerce.CoreModule.Data/Services/SequenceUniqueNumberGeneratorService.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Polly; @@ -11,11 +12,26 @@ namespace VirtoCommerce.CoreModule.Data.Services { /// - /// Represents implementation of unique number generator using database sequences. - /// Generates unique number using given template, e.g., GenerateNumber("Order{0:yyMMdd}-{1:D5}"); + /// Represents unique number generator using database storage. + /// It generates an unique number using given template, e.g., "PO{0:yyMMdd}-{1:D5}" where + /// /// {0} - date (the UTC time of number generation) /// {1} - the sequence number /// {2} - tenantId + /// + /// Also, it supports counter options after @: + /// or @:: + /// + /// reset_counter_type - can be one of this value: None, Daily, Weekly, Monthly, Yearly. Default value: Daily + /// start_counter_from - positive integer value. Default value: 1 + /// counter_increment - positive integer value. Default value: 1 + /// + /// Examples: + /// PO{1:D5} + /// PO{0:yyMMdd}-{1:D5} + /// PO{0:yyMMdd}-{1:D5}@Daily + /// PO{0:yyMMdd}-{1:D5}@Weekly:1:10 + /// PO{0:yyMMdd}-{1:D5}@None:1:1 /// public class SequenceUniqueNumberGeneratorService : IUniqueNumberGenerator, ITenantUniqueNumberGenerator { @@ -32,24 +48,27 @@ public class SequenceUniqueNumberGeneratorService : IUniqueNumberGenerator, ITen public SequenceUniqueNumberGeneratorService(Func repositoryFactory, IOptions options) { + ArgumentNullException.ThrowIfNull(repositoryFactory); + ArgumentNullException.ThrowIfNull(options); + _repositoryFactory = repositoryFactory; _options = options.Value; } /// - /// Generates unique number using given template, e.g., GenerateNumber("Order{0:yyMMdd}-{1:D5}"); + /// Generates unique number using given template. /// /// The number template. Pass the format to be used in string.Format function. Passable parameters: 0 - date (the UTC time of number generation); 1 - the sequence number. /// - public string GenerateNumber(string numberTemplate) + public virtual string GenerateNumber(string numberTemplate) { - return GenerateNumber(string.Empty, numberTemplate, - new UniqueNumberGeneratorOptions - { - ResetCounterType = ResetCounterType.Daily, - CounterIncrement = 1, - StartCounterFrom = 1 - }); + ArgumentNullException.ThrowIfNull(numberTemplate); + + + string resolvedNumberTemplate; + var templateOptions = ResolveTemplateOptionsFromTemplate(numberTemplate, out resolvedNumberTemplate); + + return GenerateNumber(string.Empty, resolvedNumberTemplate, templateOptions); } /// @@ -59,8 +78,12 @@ public string GenerateNumber(string numberTemplate) /// /// /// - public string GenerateNumber(string tentantId, string numberTemplate, UniqueNumberGeneratorOptions options) + public virtual string GenerateNumber(string tentantId, string numberTemplate, UniqueNumberGeneratorOptions options) { + ArgumentNullException.ThrowIfNull(tentantId); + ArgumentNullException.ThrowIfNull(numberTemplate); + ArgumentNullException.ThrowIfNull(options); + var retryPolicy = ConfigureRetryPolicy(); lock (_lock) @@ -74,6 +97,34 @@ public string GenerateNumber(string tentantId, string numberTemplate, UniqueNumb } } + protected virtual UniqueNumberGeneratorOptions ResolveTemplateOptionsFromTemplate(string numberTemplate, out string resolvedNumberTemplate) + { + var match = Regex.Match(numberTemplate, @"(?