From 8dd812c9d800d5fa15ec9247d162cabaa50f9088 Mon Sep 17 00:00:00 2001 From: Drobor Date: Mon, 12 Jan 2026 01:13:10 +0300 Subject: [PATCH 1/4] remove parentId from OutboxEntity --- .../OutboxEntityConfiguration.cs | 8 +- .../DataAccess/Entities/OutboxEntity.cs | 5 +- .../DataAccess/Services/OutboxDataAccess.cs | 116 +++++++++-------- GenericOutbox/OutboxCreatorContext.cs | 27 ++-- .../OutboxDispatcherHostedService.cs | 2 +- .../20260111215916_RemoveParent.Designer.cs | 107 ++++++++++++++++ .../Migrations/20260111215916_RemoveParent.cs | 49 ++++++++ .../IntegrationTestsDbContextModelSnapshot.cs | 17 +-- .../20260111215716_RemoveParent.Designer.cs | 118 ++++++++++++++++++ .../Migrations/20260111215716_RemoveParent.cs | 49 ++++++++ .../PersonServiceDbContextModelSnapshot.cs | 17 +-- dotnet-tools.json | 13 ++ 12 files changed, 418 insertions(+), 110 deletions(-) create mode 100644 Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.Designer.cs create mode 100644 Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.cs create mode 100644 Tests/PersonService/Migrations/20260111215716_RemoveParent.Designer.cs create mode 100644 Tests/PersonService/Migrations/20260111215716_RemoveParent.cs create mode 100644 dotnet-tools.json diff --git a/GenericOutbox/DataAccess/Configuration/OutboxEntityConfiguration.cs b/GenericOutbox/DataAccess/Configuration/OutboxEntityConfiguration.cs index 57150b2..b211740 100644 --- a/GenericOutbox/DataAccess/Configuration/OutboxEntityConfiguration.cs +++ b/GenericOutbox/DataAccess/Configuration/OutboxEntityConfiguration.cs @@ -9,12 +9,6 @@ public class OutboxEntityConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(x => x.Id); - //builder.HasIndex(x => x.ParentId).IsUnique(); todo: nullability problem - - builder.HasOne(x => x.Parent) - .WithOne() - .HasForeignKey(x => x.ParentId) - .IsRequired(false); builder.HasIndex(x => x.Version); builder.HasIndex(x => x.HandlerLock); @@ -30,4 +24,4 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(x => x.Id); builder.HasIndex(x => x.ScopeId); } -} \ No newline at end of file +} diff --git a/GenericOutbox/DataAccess/Entities/OutboxEntity.cs b/GenericOutbox/DataAccess/Entities/OutboxEntity.cs index 5a456c2..4fb86c0 100644 --- a/GenericOutbox/DataAccess/Entities/OutboxEntity.cs +++ b/GenericOutbox/DataAccess/Entities/OutboxEntity.cs @@ -8,7 +8,6 @@ public class OutboxEntity public int Id { get; set; } public Guid? Lock { get; set; } public Guid? HandlerLock { get; set; } - public int? ParentId { get; set; } public Guid ScopeId { get; set; } public string Action { get; set; } public byte[] Payload { get; set; } @@ -21,7 +20,5 @@ public class OutboxEntity public string? Metadata { get; set; } - public virtual OutboxEntity Parent { get; set; } - public string PayloadString => Encoding.UTF8.GetString(Payload); -} \ No newline at end of file +} diff --git a/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs b/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs index 2928746..70f4cec 100644 --- a/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs +++ b/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs @@ -24,11 +24,13 @@ public async Task CommitExecutionResult(OutboxEntity outboxEntity, ExecutionResu await dbContext .Set() .Where(x => x.Id == outboxEntity.Id) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.RetriesCount, r => r.RetriesCount + 1) - .SetProperty(r => r.Status, executionResult.Status) - .SetProperty(r => r.RetryTimeoutUtc, executionResult.RetryTimeoutUtc) - .SetProperty(r => r.LastUpdatedUtc, now) - .SetProperty(r => r.HandlerLock, (Guid?)null) + .ExecuteUpdateAsync( + s => s + .SetProperty(r => r.RetriesCount, r => r.RetriesCount + 1) + .SetProperty(r => r.Status, executionResult.Status) + .SetProperty(r => r.RetryTimeoutUtc, executionResult.RetryTimeoutUtc) + .SetProperty(r => r.LastUpdatedUtc, now) + .SetProperty(r => r.HandlerLock, (Guid?)null) ); } @@ -70,29 +72,30 @@ private async Task ReserveLockedOutboxRecords(Guid handlerLockId, int maxCo return await dbContext .Set() - .Where(r => - r.HandlerLock == null - && r.Status == OutboxRecordStatus.ReadyToExecute - && dbContext - .Set() - .Where(x => x.Lock != null - && !dbContext.Set().Any(y => - y.Lock == x.Lock && !s_unlockedStatuses.Contains(y.Status)) - // Note: Using explicit subquery instead of x.Parent.Status navigation property - // because EF Core 10 ExecuteUpdateAsync cannot properly translate queries with navigation properties - && (x.ParentId == null || dbContext.Set().Any(p => - p.Id == x.ParentId && p.Status == OutboxRecordStatus.Completed)) - && x.Status == OutboxRecordStatus.ReadyToExecute - && x.Version == outboxOptions.Version - && x.HandlerLock == null) - .GroupBy(x => x.Lock) - .Select(x => x.OrderBy(x => x.Id).FirstOrDefault().Id) - .OrderBy(x => x) - .Take(maxCount) - .Contains(r.Id)) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, OutboxRecordStatus.InProgress) - .SetProperty(r => r.HandlerLock, handlerLockId) - .SetProperty(r => r.LastUpdatedUtc, now) + .Where( + r => + r.HandlerLock == null + && r.Status == OutboxRecordStatus.ReadyToExecute + && dbContext + .Set() + .Where( + x => x.Lock != null + && !dbContext + .Set() + .Any(y => y.Lock == x.Lock && !s_unlockedStatuses.Contains(y.Status)) + && x.Status == OutboxRecordStatus.ReadyToExecute + && x.Version == outboxOptions.Version + && x.HandlerLock == null) + .GroupBy(x => x.Lock) + .Select(x => x.OrderBy(x => x.Id).FirstOrDefault().Id) + .OrderBy(x => x) + .Take(maxCount) + .Contains(r.Id)) + .ExecuteUpdateAsync( + s => s + .SetProperty(r => r.Status, OutboxRecordStatus.InProgress) + .SetProperty(r => r.HandlerLock, handlerLockId) + .SetProperty(r => r.LastUpdatedUtc, now) ); } @@ -119,9 +122,11 @@ private async Task ReserveLockedRetryOutboxRecords(Guid handlerLockId, int .OrderBy(x => x) .Take(maxCount) .Contains(r.Id)) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, OutboxRecordStatus.InProgress) - .SetProperty(r => r.HandlerLock, handlerLockId) - .SetProperty(r => r.LastUpdatedUtc, now) + .ExecuteUpdateAsync( + s => s + .SetProperty(r => r.Status, OutboxRecordStatus.InProgress) + .SetProperty(r => r.HandlerLock, handlerLockId) + .SetProperty(r => r.LastUpdatedUtc, now) ); } @@ -131,16 +136,19 @@ private async Task ReserveNonLockedOutboxRecords(Guid handlerLockId, int ma return await dbContext .Set() - .Where(x => - x.RetryTimeoutUtc < now - && x.Version == outboxOptions.Version - && x.HandlerLock == null - && x.Lock == null) + .Where( + x => + x.RetryTimeoutUtc < now + && x.Version == outboxOptions.Version + && x.HandlerLock == null + && x.Lock == null) .OrderBy(x => x.Id) .Take(maxCount) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, OutboxRecordStatus.InProgress) - .SetProperty(r => r.HandlerLock, handlerLockId) - .SetProperty(r => r.LastUpdatedUtc, now) + .ExecuteUpdateAsync( + s => s + .SetProperty(r => r.Status, OutboxRecordStatus.InProgress) + .SetProperty(r => r.HandlerLock, handlerLockId) + .SetProperty(r => r.LastUpdatedUtc, now) ); } @@ -151,19 +159,18 @@ private async Task ReserveNonLockedRetryOutboxRecords(Guid handlerLockId, i return await dbContext .Set() .Where( - // Note: Using explicit subquery instead of x.Parent.Status navigation property - // because EF Core 10 ExecuteUpdateAsync cannot properly translate queries with navigation properties - x => (x.ParentId == null || dbContext.Set() - .Any(p => p.Id == x.ParentId && p.Status == OutboxRecordStatus.Completed)) - && x.Status == OutboxRecordStatus.ReadyToExecute - && x.Version == outboxOptions.Version - && x.HandlerLock == null - && x.Lock == null) + x => + x.Status == OutboxRecordStatus.ReadyToExecute + && x.Version == outboxOptions.Version + && x.HandlerLock == null + && x.Lock == null) .OrderBy(x => x.Id) .Take(maxCount) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.Status, OutboxRecordStatus.InProgress) - .SetProperty(r => r.HandlerLock, handlerLockId) - .SetProperty(r => r.LastUpdatedUtc, now) + .ExecuteUpdateAsync( + s => s + .SetProperty(r => r.Status, OutboxRecordStatus.InProgress) + .SetProperty(r => r.HandlerLock, handlerLockId) + .SetProperty(r => r.LastUpdatedUtc, now) ); } @@ -183,9 +190,10 @@ private async Task ReserveStuckInProgressRecords(Guid handlerLockId, int ma && r.LastUpdatedUtc < stuckCutoff) .OrderBy(x => x.Id) .Take(maxCount) - .ExecuteUpdateAsync(s => s.SetProperty(r => r.HandlerLock, handlerLockId) - .SetProperty(r => r.LastUpdatedUtc, now) + .ExecuteUpdateAsync( + s => s + .SetProperty(r => r.HandlerLock, handlerLockId) + .SetProperty(r => r.LastUpdatedUtc, now) ); - ; } -} \ No newline at end of file +} diff --git a/GenericOutbox/OutboxCreatorContext.cs b/GenericOutbox/OutboxCreatorContext.cs index 757e7f1..d137191 100644 --- a/GenericOutbox/OutboxCreatorContext.cs +++ b/GenericOutbox/OutboxCreatorContext.cs @@ -13,8 +13,7 @@ class OutboxCreatorContext : IOutboxCreatorContext where TDbContext private readonly LockClearer _lockClearer; - private int? _previousStepId; - private Guid? _lock; + private Guid _lock; private readonly Guid _scopeId; private readonly string _metadata; @@ -23,29 +22,28 @@ public OutboxCreatorContext(TDbContext dbContext, ISerializer serializer, Outbox _dbContext = dbContext; _serializer = serializer; _outboxOptions = outboxOptions; - + var metadataProviderService = serviceProvider.GetService(typeof(IOutboxMetadataProvider)); if (metadataProviderService != null) { _metadata = ((IOutboxMetadataProvider)metadataProviderService).GetMetadata(); } - _lockClearer = new LockClearer(this); + _lock = Guid.NewGuid(); + _lockClearer = new LockClearer(this, _lock); _scopeId = Guid.NewGuid(); } public void CreateOutboxRecord(string action, T model) { - var newRecord = CreateOutboxRecordInternal(action, model); + CreateOutboxRecordInternal(action, model); _dbContext.SaveChanges(); - _previousStepId = newRecord.Id; } public async Task CreateOutboxRecordAsync(string action, T model) { - var newRecord = CreateOutboxRecordInternal(action, model); + CreateOutboxRecordInternal(action, model); await _dbContext.SaveChangesAsync(); - _previousStepId = newRecord.Id; } public IDisposable Lock(string entityName, T entityId) @@ -57,6 +55,9 @@ public IDisposable Lock(string entityName, T entityId) public IDisposable Lock(Guid lockGuid) { + if (_lockClearer.IsLocked) + throw new InvalidOperationException("Lock is already taken."); + _lock = lockGuid; return _lockClearer; } @@ -70,7 +71,6 @@ private OutboxEntity CreateOutboxRecordInternal(string action, T model) Action = action, ScopeId = _scopeId, Payload = _serializer.Serialize(model), - ParentId = _previousStepId, Version = _outboxOptions.Version, Lock = _lock, LastUpdatedUtc = utcNow, @@ -86,15 +86,18 @@ private OutboxEntity CreateOutboxRecordInternal(string action, T model) class LockClearer : IDisposable { private readonly OutboxCreatorContext _outboxCreatorContext; + private readonly Guid _defaultLock; + public bool IsLocked => _defaultLock != _outboxCreatorContext._lock; - public LockClearer(OutboxCreatorContext outboxCreatorContext) + public LockClearer(OutboxCreatorContext outboxCreatorContext, Guid defaultLock) { _outboxCreatorContext = outboxCreatorContext; + _defaultLock = defaultLock; } public void Dispose() { - _outboxCreatorContext._lock = null; + _outboxCreatorContext._lock = _defaultLock; } } -} \ No newline at end of file +} diff --git a/GenericOutbox/OutboxDispatcherHostedService.cs b/GenericOutbox/OutboxDispatcherHostedService.cs index 19d3bb0..c53b9db 100644 --- a/GenericOutbox/OutboxDispatcherHostedService.cs +++ b/GenericOutbox/OutboxDispatcherHostedService.cs @@ -179,4 +179,4 @@ private static ExecutionResult GetRetryStrategyResolution(ILogger +using System; +using IntegrationTests; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IntegrationTests.Migrations +{ + [DbContext(typeof(IntegrationTestsDbContext))] + [Migration("20260111215916_RemoveParent")] + partial class RemoveParent + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("GenericOutbox.DataAccess.Entities.OutboxDataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Data") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScopeId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.ToTable("OutboxDataEntity"); + }); + + modelBuilder.Entity("GenericOutbox.DataAccess.Entities.OutboxEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HandlerLock") + .HasColumnType("TEXT"); + + b.Property("LastUpdatedUtc") + .HasColumnType("TEXT"); + + b.Property("Lock") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("RetriesCount") + .HasColumnType("INTEGER"); + + b.Property("RetryTimeoutUtc") + .HasColumnType("TEXT"); + + b.Property("ScopeId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HandlerLock"); + + b.HasIndex("Lock"); + + b.HasIndex("ScopeId"); + + b.HasIndex("Version"); + + b.ToTable("OutboxEntity"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.cs b/Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.cs new file mode 100644 index 0000000..a86e6a3 --- /dev/null +++ b/Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IntegrationTests.Migrations +{ + /// + public partial class RemoveParent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_OutboxEntity_OutboxEntity_ParentId", + table: "OutboxEntity"); + + migrationBuilder.DropIndex( + name: "IX_OutboxEntity_ParentId", + table: "OutboxEntity"); + + migrationBuilder.DropColumn( + name: "ParentId", + table: "OutboxEntity"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ParentId", + table: "OutboxEntity", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxEntity_ParentId", + table: "OutboxEntity", + column: "ParentId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_OutboxEntity_OutboxEntity_ParentId", + table: "OutboxEntity", + column: "ParentId", + principalTable: "OutboxEntity", + principalColumn: "Id"); + } + } +} diff --git a/Tests/IntegrationTests/Migrations/IntegrationTestsDbContextModelSnapshot.cs b/Tests/IntegrationTests/Migrations/IntegrationTestsDbContextModelSnapshot.cs index d2ff1a0..6be505e 100644 --- a/Tests/IntegrationTests/Migrations/IntegrationTestsDbContextModelSnapshot.cs +++ b/Tests/IntegrationTests/Migrations/IntegrationTestsDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class IntegrationTestsDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); modelBuilder.Entity("GenericOutbox.DataAccess.Entities.OutboxDataEntity", b => { @@ -66,9 +66,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Metadata") .HasColumnType("TEXT"); - b.Property("ParentId") - .HasColumnType("INTEGER"); - b.Property("Payload") .IsRequired() .HasColumnType("BLOB"); @@ -95,24 +92,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Lock"); - b.HasIndex("ParentId") - .IsUnique(); - b.HasIndex("ScopeId"); b.HasIndex("Version"); b.ToTable("OutboxEntity"); }); - - modelBuilder.Entity("GenericOutbox.DataAccess.Entities.OutboxEntity", b => - { - b.HasOne("GenericOutbox.DataAccess.Entities.OutboxEntity", "Parent") - .WithOne() - .HasForeignKey("GenericOutbox.DataAccess.Entities.OutboxEntity", "ParentId"); - - b.Navigation("Parent"); - }); #pragma warning restore 612, 618 } } diff --git a/Tests/PersonService/Migrations/20260111215716_RemoveParent.Designer.cs b/Tests/PersonService/Migrations/20260111215716_RemoveParent.Designer.cs new file mode 100644 index 0000000..1e78c13 --- /dev/null +++ b/Tests/PersonService/Migrations/20260111215716_RemoveParent.Designer.cs @@ -0,0 +1,118 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PersonService.DataAccess; + +#nullable disable + +namespace PersonService.Migrations +{ + [DbContext(typeof(PersonServiceDbContext))] + [Migration("20260111215716_RemoveParent")] + partial class RemoveParent + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("GenericOutbox.DataAccess.Entities.OutboxDataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Data") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ScopeId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.ToTable("OutboxDataEntity"); + }); + + modelBuilder.Entity("GenericOutbox.DataAccess.Entities.OutboxEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Action") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HandlerLock") + .HasColumnType("TEXT"); + + b.Property("LastUpdatedUtc") + .HasColumnType("TEXT"); + + b.Property("Lock") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("RetriesCount") + .HasColumnType("INTEGER"); + + b.Property("RetryTimeoutUtc") + .HasColumnType("TEXT"); + + b.Property("ScopeId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("HandlerLock"); + + b.HasIndex("Lock"); + + b.HasIndex("ScopeId"); + + b.HasIndex("Version"); + + b.ToTable("OutboxEntity"); + }); + + modelBuilder.Entity("PersonService.DataAccess.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Persons"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Tests/PersonService/Migrations/20260111215716_RemoveParent.cs b/Tests/PersonService/Migrations/20260111215716_RemoveParent.cs new file mode 100644 index 0000000..e054afc --- /dev/null +++ b/Tests/PersonService/Migrations/20260111215716_RemoveParent.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PersonService.Migrations +{ + /// + public partial class RemoveParent : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_OutboxEntity_OutboxEntity_ParentId", + table: "OutboxEntity"); + + migrationBuilder.DropIndex( + name: "IX_OutboxEntity_ParentId", + table: "OutboxEntity"); + + migrationBuilder.DropColumn( + name: "ParentId", + table: "OutboxEntity"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ParentId", + table: "OutboxEntity", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxEntity_ParentId", + table: "OutboxEntity", + column: "ParentId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_OutboxEntity_OutboxEntity_ParentId", + table: "OutboxEntity", + column: "ParentId", + principalTable: "OutboxEntity", + principalColumn: "Id"); + } + } +} diff --git a/Tests/PersonService/Migrations/PersonServiceDbContextModelSnapshot.cs b/Tests/PersonService/Migrations/PersonServiceDbContextModelSnapshot.cs index 4834e38..871eb20 100644 --- a/Tests/PersonService/Migrations/PersonServiceDbContextModelSnapshot.cs +++ b/Tests/PersonService/Migrations/PersonServiceDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class PersonServiceDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); modelBuilder.Entity("GenericOutbox.DataAccess.Entities.OutboxDataEntity", b => { @@ -66,9 +66,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Metadata") .HasColumnType("TEXT"); - b.Property("ParentId") - .HasColumnType("INTEGER"); - b.Property("Payload") .IsRequired() .HasColumnType("BLOB"); @@ -95,9 +92,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Lock"); - b.HasIndex("ParentId") - .IsUnique(); - b.HasIndex("ScopeId"); b.HasIndex("Version"); @@ -115,15 +109,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Persons"); }); - - modelBuilder.Entity("GenericOutbox.DataAccess.Entities.OutboxEntity", b => - { - b.HasOne("GenericOutbox.DataAccess.Entities.OutboxEntity", "Parent") - .WithOne() - .HasForeignKey("GenericOutbox.DataAccess.Entities.OutboxEntity", "ParentId"); - - b.Navigation("Parent"); - }); #pragma warning restore 612, 618 } } diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..fbb7b76 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.1", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file From 0fb6c43e1e20b2bd81ac20078b9f419c082b9ce7 Mon Sep 17 00:00:00 2001 From: Drobor Date: Mon, 12 Jan 2026 01:29:23 +0300 Subject: [PATCH 2/4] Made lock non-nullable --- .../DataAccess/Entities/OutboxEntity.cs | 2 +- .../DataAccess/Services/OutboxDataAccess.cs | 58 ++----------------- ...> 20260111221535_RemoveParent.Designer.cs} | 4 +- ...rent.cs => 20260111221535_RemoveParent.cs} | 21 ++++++- .../IntegrationTestsDbContextModelSnapshot.cs | 2 +- ...> 20260111221643_RemoveParent.Designer.cs} | 4 +- ...rent.cs => 20260111221643_RemoveParent.cs} | 21 ++++++- .../PersonServiceDbContextModelSnapshot.cs | 2 +- 8 files changed, 53 insertions(+), 61 deletions(-) rename Tests/IntegrationTests/Migrations/{20260111215916_RemoveParent.Designer.cs => 20260111221535_RemoveParent.Designer.cs} (97%) rename Tests/IntegrationTests/Migrations/{20260111215916_RemoveParent.cs => 20260111221535_RemoveParent.cs} (67%) rename Tests/PersonService/Migrations/{20260111215716_RemoveParent.Designer.cs => 20260111221643_RemoveParent.Designer.cs} (97%) rename Tests/PersonService/Migrations/{20260111215716_RemoveParent.cs => 20260111221643_RemoveParent.cs} (67%) diff --git a/GenericOutbox/DataAccess/Entities/OutboxEntity.cs b/GenericOutbox/DataAccess/Entities/OutboxEntity.cs index 4fb86c0..ea687c2 100644 --- a/GenericOutbox/DataAccess/Entities/OutboxEntity.cs +++ b/GenericOutbox/DataAccess/Entities/OutboxEntity.cs @@ -6,7 +6,7 @@ namespace GenericOutbox.DataAccess.Entities; public class OutboxEntity { public int Id { get; set; } - public Guid? Lock { get; set; } + public Guid Lock { get; set; } public Guid? HandlerLock { get; set; } public Guid ScopeId { get; set; } public string Action { get; set; } diff --git a/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs b/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs index 70f4cec..b13da42 100644 --- a/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs +++ b/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs @@ -40,17 +40,15 @@ public async Task GetOutboxRecords(int maxCount) var recordsToUpdateCount = _rollingOutboxQueryType switch { - 0 => await ReserveNonLockedRetryOutboxRecords(lockId, maxCount), - 1 => await ReserveNonLockedOutboxRecords(lockId, maxCount), - 2 => await ReserveLockedRetryOutboxRecords(lockId, maxCount), - 3 => await ReserveLockedOutboxRecords(lockId, maxCount), + 0 => await ReserveRetryOutboxRecords(lockId, maxCount), + 1 => await ReserveOutboxRecords(lockId, maxCount), _ => await ReserveStuckInProgressRecords(lockId, maxCount) }; - var stuckInProgress = _rollingOutboxQueryType == 4; + var stuckInProgress = _rollingOutboxQueryType == 2; if (recordsToUpdateCount < maxCount) - _rollingOutboxQueryType = (_rollingOutboxQueryType + 1) % 5; + _rollingOutboxQueryType = (_rollingOutboxQueryType + 1) % 3; if (recordsToUpdateCount == 0) return Array.Empty(); @@ -66,7 +64,7 @@ public async Task GetOutboxRecords(int maxCount) .ToArray(); } - private async Task ReserveLockedOutboxRecords(Guid handlerLockId, int maxCount) + private async Task ReserveOutboxRecords(Guid handlerLockId, int maxCount) { var now = DateTime.UtcNow; @@ -99,7 +97,7 @@ private async Task ReserveLockedOutboxRecords(Guid handlerLockId, int maxCo ); } - private async Task ReserveLockedRetryOutboxRecords(Guid handlerLockId, int maxCount) + private async Task ReserveRetryOutboxRecords(Guid handlerLockId, int maxCount) { var now = DateTime.UtcNow; @@ -130,50 +128,6 @@ private async Task ReserveLockedRetryOutboxRecords(Guid handlerLockId, int ); } - private async Task ReserveNonLockedOutboxRecords(Guid handlerLockId, int maxCount) - { - var now = DateTime.UtcNow; - - return await dbContext - .Set() - .Where( - x => - x.RetryTimeoutUtc < now - && x.Version == outboxOptions.Version - && x.HandlerLock == null - && x.Lock == null) - .OrderBy(x => x.Id) - .Take(maxCount) - .ExecuteUpdateAsync( - s => s - .SetProperty(r => r.Status, OutboxRecordStatus.InProgress) - .SetProperty(r => r.HandlerLock, handlerLockId) - .SetProperty(r => r.LastUpdatedUtc, now) - ); - } - - private async Task ReserveNonLockedRetryOutboxRecords(Guid handlerLockId, int maxCount) - { - var now = DateTime.UtcNow; - - return await dbContext - .Set() - .Where( - x => - x.Status == OutboxRecordStatus.ReadyToExecute - && x.Version == outboxOptions.Version - && x.HandlerLock == null - && x.Lock == null) - .OrderBy(x => x.Id) - .Take(maxCount) - .ExecuteUpdateAsync( - s => s - .SetProperty(r => r.Status, OutboxRecordStatus.InProgress) - .SetProperty(r => r.HandlerLock, handlerLockId) - .SetProperty(r => r.LastUpdatedUtc, now) - ); - } - private async Task ReserveStuckInProgressRecords(Guid handlerLockId, int maxCount) { if (outboxOptions.InProgressRecordTimeout == null) diff --git a/Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.Designer.cs b/Tests/IntegrationTests/Migrations/20260111221535_RemoveParent.Designer.cs similarity index 97% rename from Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.Designer.cs rename to Tests/IntegrationTests/Migrations/20260111221535_RemoveParent.Designer.cs index 871ef42..0457d5b 100644 --- a/Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.Designer.cs +++ b/Tests/IntegrationTests/Migrations/20260111221535_RemoveParent.Designer.cs @@ -11,7 +11,7 @@ namespace IntegrationTests.Migrations { [DbContext(typeof(IntegrationTestsDbContext))] - [Migration("20260111215916_RemoveParent")] + [Migration("20260111221535_RemoveParent")] partial class RemoveParent { /// @@ -63,7 +63,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("LastUpdatedUtc") .HasColumnType("TEXT"); - b.Property("Lock") + b.Property("Lock") .HasColumnType("TEXT"); b.Property("Metadata") diff --git a/Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.cs b/Tests/IntegrationTests/Migrations/20260111221535_RemoveParent.cs similarity index 67% rename from Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.cs rename to Tests/IntegrationTests/Migrations/20260111221535_RemoveParent.cs index a86e6a3..92595ad 100644 --- a/Tests/IntegrationTests/Migrations/20260111215916_RemoveParent.cs +++ b/Tests/IntegrationTests/Migrations/20260111221535_RemoveParent.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using System; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -21,11 +22,29 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.DropColumn( name: "ParentId", table: "OutboxEntity"); + + migrationBuilder.AlterColumn( + name: "Lock", + table: "OutboxEntity", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); } /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.AlterColumn( + name: "Lock", + table: "OutboxEntity", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + migrationBuilder.AddColumn( name: "ParentId", table: "OutboxEntity", diff --git a/Tests/IntegrationTests/Migrations/IntegrationTestsDbContextModelSnapshot.cs b/Tests/IntegrationTests/Migrations/IntegrationTestsDbContextModelSnapshot.cs index 6be505e..9ae0785 100644 --- a/Tests/IntegrationTests/Migrations/IntegrationTestsDbContextModelSnapshot.cs +++ b/Tests/IntegrationTests/Migrations/IntegrationTestsDbContextModelSnapshot.cs @@ -60,7 +60,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastUpdatedUtc") .HasColumnType("TEXT"); - b.Property("Lock") + b.Property("Lock") .HasColumnType("TEXT"); b.Property("Metadata") diff --git a/Tests/PersonService/Migrations/20260111215716_RemoveParent.Designer.cs b/Tests/PersonService/Migrations/20260111221643_RemoveParent.Designer.cs similarity index 97% rename from Tests/PersonService/Migrations/20260111215716_RemoveParent.Designer.cs rename to Tests/PersonService/Migrations/20260111221643_RemoveParent.Designer.cs index 1e78c13..041ae4c 100644 --- a/Tests/PersonService/Migrations/20260111215716_RemoveParent.Designer.cs +++ b/Tests/PersonService/Migrations/20260111221643_RemoveParent.Designer.cs @@ -11,7 +11,7 @@ namespace PersonService.Migrations { [DbContext(typeof(PersonServiceDbContext))] - [Migration("20260111215716_RemoveParent")] + [Migration("20260111221643_RemoveParent")] partial class RemoveParent { /// @@ -63,7 +63,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("LastUpdatedUtc") .HasColumnType("TEXT"); - b.Property("Lock") + b.Property("Lock") .HasColumnType("TEXT"); b.Property("Metadata") diff --git a/Tests/PersonService/Migrations/20260111215716_RemoveParent.cs b/Tests/PersonService/Migrations/20260111221643_RemoveParent.cs similarity index 67% rename from Tests/PersonService/Migrations/20260111215716_RemoveParent.cs rename to Tests/PersonService/Migrations/20260111221643_RemoveParent.cs index e054afc..70ad2ee 100644 --- a/Tests/PersonService/Migrations/20260111215716_RemoveParent.cs +++ b/Tests/PersonService/Migrations/20260111221643_RemoveParent.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using System; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -21,11 +22,29 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.DropColumn( name: "ParentId", table: "OutboxEntity"); + + migrationBuilder.AlterColumn( + name: "Lock", + table: "OutboxEntity", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); } /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.AlterColumn( + name: "Lock", + table: "OutboxEntity", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + migrationBuilder.AddColumn( name: "ParentId", table: "OutboxEntity", diff --git a/Tests/PersonService/Migrations/PersonServiceDbContextModelSnapshot.cs b/Tests/PersonService/Migrations/PersonServiceDbContextModelSnapshot.cs index 871eb20..e13e948 100644 --- a/Tests/PersonService/Migrations/PersonServiceDbContextModelSnapshot.cs +++ b/Tests/PersonService/Migrations/PersonServiceDbContextModelSnapshot.cs @@ -60,7 +60,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastUpdatedUtc") .HasColumnType("TEXT"); - b.Property("Lock") + b.Property("Lock") .HasColumnType("TEXT"); b.Property("Metadata") From 46bf797d22bb32a329e7ffd188a40283d42e358e Mon Sep 17 00:00:00 2001 From: Drobor Date: Mon, 12 Jan 2026 01:31:53 +0300 Subject: [PATCH 3/4] removed excessive null checks --- GenericOutbox/DataAccess/Services/OutboxDataAccess.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs b/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs index b13da42..8e6e83c 100644 --- a/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs +++ b/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs @@ -77,8 +77,7 @@ private async Task ReserveOutboxRecords(Guid handlerLockId, int maxCount) && dbContext .Set() .Where( - x => x.Lock != null - && !dbContext + x => !dbContext .Set() .Any(y => y.Lock == x.Lock && !s_unlockedStatuses.Contains(y.Status)) && x.Status == OutboxRecordStatus.ReadyToExecute @@ -110,8 +109,7 @@ private async Task ReserveRetryOutboxRecords(Guid handlerLockId, int maxCou && dbContext .Set() .Where( - x => x.Lock != null - && !dbContext.Set().Any(y => y.Lock == x.Lock && !s_unlockedStatuses.Contains(y.Status)) + x => !dbContext.Set().Any(y => y.Lock == x.Lock && !s_unlockedStatuses.Contains(y.Status)) && x.RetryTimeoutUtc < now && x.Version == outboxOptions.Version && x.HandlerLock == null) From 9be6b5fd520605e9f70f25576fba9032d6dbabbc Mon Sep 17 00:00:00 2001 From: Drobor Date: Mon, 12 Jan 2026 01:33:41 +0300 Subject: [PATCH 4/4] removed obsolete comment --- GenericOutbox/DataAccess/Services/OutboxDataAccess.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs b/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs index 8e6e83c..4e1ddf3 100644 --- a/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs +++ b/GenericOutbox/DataAccess/Services/OutboxDataAccess.cs @@ -15,7 +15,7 @@ public class OutboxDataAccess(TDbContext dbContext, OutboxOptions ou OutboxRecordStatus.Completed }; - private int _rollingOutboxQueryType = 0; //0 = non-locked, 1 = locked + private int _rollingOutboxQueryType = 0; public async Task CommitExecutionResult(OutboxEntity outboxEntity, ExecutionResult executionResult) {