From ff8b95cbb8f1e2ca84d552493a14227e5b151345 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Fri, 31 Jul 2026 15:28:27 +0800 Subject: [PATCH 01/10] add test coverage on IArchiveMessages interface --- ...ontrol.Persistence.Tests.PostgreSql.csproj | 1 + ...Control.Persistence.Tests.SqlServer.csproj | 1 + .../Recoverability/ArchiveMessagesTests.cs | 177 ++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 src/ServiceControl.Persistence.Tests/Recoverability/ArchiveMessagesTests.cs diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj b/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj index a8cd23fb3c..ee2d6291bd 100644 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj @@ -40,6 +40,7 @@ + diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj b/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj index af49f36dcd..7cdd5a6320 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj +++ b/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj @@ -40,6 +40,7 @@ + diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/ArchiveMessagesTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/ArchiveMessagesTests.cs new file mode 100644 index 0000000000..786de8335c --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Recoverability/ArchiveMessagesTests.cs @@ -0,0 +1,177 @@ +namespace ServiceControl.Persistence.Tests.Recoverability; + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using ServiceControl.Infrastructure.DomainEvents; +using ServiceControl.Persistence.Recoverability; +using ServiceControl.Recoverability; + +/// +/// Covers the in-memory state-management members of +/// (StartArchiving/StartUnarchiving, IsArchiveInProgressFor, +/// IsOperationInProgressFor, DismissArchiveOperation, GetArchivalOperations) +/// that are not exercised by the archive/unarchive loop tests. These members drive the +/// API controllers and the group fetcher and have no dedicated coverage otherwise. +/// +[TestFixture] +class ArchiveMessagesTests : PersistenceTestBase +{ + readonly CapturingDomainEvents events = new(); + + public ArchiveMessagesTests() => + RegisterServices = services => services.AddSingleton(events); + + [Test] + public async Task IsArchiveInProgressFor_is_false_when_no_operation_started() + { + Assert.That(ArchiveMessages.IsArchiveInProgressFor("group-1"), Is.False); + await Task.CompletedTask; + } + + [Test] + public async Task StartArchiving_makes_IsArchiveInProgressFor_true() + { + await ArchiveMessages.StartArchiving("group-1", ArchiveType.FailureGroup); + + Assert.That(ArchiveMessages.IsArchiveInProgressFor("group-1"), Is.True); + } + + [Test] + public async Task StartArchiving_emits_ArchiveOperationStarting() + { + await ArchiveMessages.StartArchiving("group-1", ArchiveType.FailureGroup); + + var starting = events.Raised.OfType().Single(); + using (Assert.EnterMultipleScope()) + { + Assert.That(starting.RequestId, Is.EqualTo("group-1")); + Assert.That(starting.ArchiveType, Is.EqualTo(ArchiveType.FailureGroup)); + } + } + + [Test] + public async Task StartArchiving_registers_operation_in_GetArchivalOperations() + { + await ArchiveMessages.StartArchiving("group-1", ArchiveType.FailureGroup); + + var op = ArchiveMessages.GetArchivalOperations().Single(); + using (Assert.EnterMultipleScope()) + { + Assert.That(op.RequestId, Is.EqualTo("group-1")); + Assert.That(op.ArchiveType, Is.EqualTo(ArchiveType.FailureGroup)); + Assert.That(op.GroupName, Is.EqualTo("Undefined")); + Assert.That(op.NeedsAcknowledgement(), Is.False, "an in-progress op does not need acknowledgement"); + } + } + + [Test] + public async Task StartArchiving_twice_for_same_group_keeps_a_single_operation() + { + await ArchiveMessages.StartArchiving("group-1", ArchiveType.FailureGroup); + await ArchiveMessages.StartArchiving("group-1", ArchiveType.FailureGroup); + + Assert.That(ArchiveMessages.GetArchivalOperations().Count(op => op.RequestId == "group-1"), Is.EqualTo(1)); + } + + [Test] + public async Task StartArchiving_for_different_groups_registers_each_independently() + { + await ArchiveMessages.StartArchiving("group-1", ArchiveType.FailureGroup); + await ArchiveMessages.StartArchiving("group-2", ArchiveType.FailureGroup); + + var ids = ArchiveMessages.GetArchivalOperations().Select(op => op.RequestId).ToArray(); + Assert.That(ids, Is.EquivalentTo(new[] { "group-1", "group-2" })); + using (Assert.EnterMultipleScope()) + { + Assert.That(ArchiveMessages.IsArchiveInProgressFor("group-1"), Is.True); + Assert.That(ArchiveMessages.IsArchiveInProgressFor("group-2"), Is.True); + } + } + + [Test] + public async Task DismissArchiveOperation_removes_the_operation() + { + await ArchiveMessages.StartArchiving("group-1", ArchiveType.FailureGroup); + + ArchiveMessages.DismissArchiveOperation("group-1", ArchiveType.FailureGroup); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ArchiveMessages.IsArchiveInProgressFor("group-1"), Is.False); + Assert.That(ArchiveMessages.GetArchivalOperations(), Is.Empty); + } + } + + [Test] + public void DismissArchiveOperation_does_not_throw_when_no_operation_exists() + { + Assert.DoesNotThrow(() => ArchiveMessages.DismissArchiveOperation("group-unknown", ArchiveType.FailureGroup)); + } + + [Test] + public async Task StartUnarchiving_does_not_register_an_archive_operation() + { + await ArchiveMessages.StartUnarchiving("group-1", ArchiveType.FailureGroup); + + // GetArchivalOperations / IsArchiveInProgressFor track archive ops only, not unarchive ops. + using (Assert.EnterMultipleScope()) + { + Assert.That(ArchiveMessages.GetArchivalOperations(), Is.Empty); + Assert.That(ArchiveMessages.IsArchiveInProgressFor("group-1"), Is.False); + } + } + + [Test] + public async Task StartUnarchiving_emits_UnarchiveOperationStarting() + { + await ArchiveMessages.StartUnarchiving("group-1", ArchiveType.FailureGroup); + + var starting = events.Raised.OfType().Single(); + using (Assert.EnterMultipleScope()) + { + Assert.That(starting.RequestId, Is.EqualTo("group-1")); + Assert.That(starting.ArchiveType, Is.EqualTo(ArchiveType.FailureGroup)); + } + } + + [Test] + public async Task IsOperationInProgressFor_is_true_after_StartUnarchiving() + { + await ArchiveMessages.StartUnarchiving("group-1", ArchiveType.FailureGroup); + + Assert.That(ArchiveMessages.IsOperationInProgressFor("group-1", ArchiveType.FailureGroup), Is.True); + } + + [Test] + public async Task IsOperationInProgressFor_is_false_when_no_operation_started() + { + Assert.That(ArchiveMessages.IsOperationInProgressFor("group-1", ArchiveType.FailureGroup), Is.False); + await Task.CompletedTask; + } + + [Test] + public async Task DismissArchiveOperation_only_removes_the_targeted_group() + { + await ArchiveMessages.StartArchiving("group-1", ArchiveType.FailureGroup); + await ArchiveMessages.StartArchiving("group-2", ArchiveType.FailureGroup); + + ArchiveMessages.DismissArchiveOperation("group-1", ArchiveType.FailureGroup); + + var remaining = ArchiveMessages.GetArchivalOperations().Single(); + Assert.That(remaining.RequestId, Is.EqualTo("group-2")); + } + + sealed class CapturingDomainEvents : IDomainEvents + { + public System.Collections.Generic.List Raised { get; } = []; + + public Task Raise(T domainEvent, CancellationToken cancellationToken = default) where T : IDomainEvent + { + Raised.Add(domainEvent); + return Task.CompletedTask; + } + } +} \ No newline at end of file From 8bbf2f705e9c15ce23669529dc9dfad8b1dedefb Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Mon, 3 Aug 2026 13:17:45 +0800 Subject: [PATCH 02/10] First pass at archive implementation --- ...803050237_AddArchiveOperations.Designer.cs | 506 ++++++++++++++++++ .../20260803050237_AddArchiveOperations.cs | 56 ++ ...803050232_AddArchiveOperations.Designer.cs | 409 ++++++++++++++ .../20260803050232_AddArchiveOperations.cs | 56 ++ .../Abstractions/BasePersistence.cs | 2 + .../DbContexts/ServiceControlDbContext.cs | 2 + .../Entities/ArchiveOperationEntity.cs | 59 ++ .../ArchiveOperationConfiguration.cs | 39 ++ .../FailedMessageLifecycleDataStore.cs | 139 ++++- .../Implementation/MessageArchiver.cs | 431 ++++++++++++++- .../Recoverability/ArchiveQueryHelper.cs | 72 +++ .../Recoverability/EFCoreArchivingManager.cs | 91 ++++ .../EFCoreUnarchivingManager.cs | 81 +++ ...ontrol.Persistence.Tests.PostgreSql.csproj | 1 - ...Control.Persistence.Tests.SqlServer.csproj | 1 - 15 files changed, 1913 insertions(+), 32 deletions(-) create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.Designer.cs create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.Designer.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs create mode 100644 src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Recoverability/ArchiveQueryHelper.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreArchivingManager.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreUnarchivingManager.cs diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.Designer.cs new file mode 100644 index 0000000000..46c8c8b2e0 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.Designer.cs @@ -0,0 +1,506 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ServiceControl.Persistence.EFCore.PostgreSql; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations +{ + [DbContext(typeof(PostgreSqlServiceControlDbContext))] + [Migration("20260803050237_AddArchiveOperations")] + partial class AddArchiveOperations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.ArchiveOperationEntity", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("id"); + + b.Property("ArchiveType") + .HasColumnType("integer") + .HasColumnName("archive_type"); + + b.Property("CurrentBatch") + .HasColumnType("integer") + .HasColumnName("current_batch"); + + b.Property("GroupName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("group_name"); + + b.Property("InitiatedById") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("initiated_by_id"); + + b.Property("InitiatedByName") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("initiated_by_name"); + + b.Property("IsArchive") + .HasColumnType("boolean") + .HasColumnName("is_archive"); + + b.Property("NumberOfBatches") + .HasColumnType("integer") + .HasColumnName("number_of_batches"); + + b.Property("NumberOfMessagesProcessed") + .HasColumnType("integer") + .HasColumnName("number_of_messages_processed"); + + b.Property("OperationId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("operation_id"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("request_id"); + + b.Property("Started") + .HasColumnType("timestamp with time zone") + .HasColumnName("started"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("integer") + .HasColumnName("total_number_of_messages"); + + b.HasKey("Id") + .HasName("pk_archive_operations"); + + b.HasIndex("Started") + .HasDatabaseName("ix_archive_operations_started"); + + b.HasIndex("RequestId", "ArchiveType", "IsArchive") + .IsUnique() + .HasDatabaseName("ix_archive_operations_request_id_archive_type_is_archive"); + + b.ToTable("ArchiveOperations", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("name"); + + b.Property("TrackInstances") + .HasColumnType("boolean") + .HasColumnName("track_instances"); + + b.HasKey("Name") + .HasName("pk_endpoint_settings"); + + b.ToTable("endpoint_settings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EventLogItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("category"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("event_type"); + + b.Property("RaisedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("raised_at"); + + b.PrimitiveCollection>("RelatedTo") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("related_to"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("pk_event_log_items"); + + b.HasIndex("RaisedAt", "Id") + .IsDescending() + .HasDatabaseName("ix_event_log_items_raised_at_id"); + + b.ToTable("EventLogItems", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedErrorImportEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uuid") + .HasColumnName("unique_message_id"); + + b.Property("Body") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("body"); + + b.Property("BodyStoredExternally") + .HasColumnType("boolean") + .HasColumnName("body_stored_externally"); + + b.Property("ExceptionInfo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("exception_info"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("headers_json"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("message_id"); + + b.HasKey("UniqueMessageId") + .HasName("pk_failed_error_imports"); + + b.HasIndex("FailedAt") + .HasDatabaseName("ix_failed_error_imports_failed_at"); + + b.ToTable("failed_error_imports", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uuid") + .HasColumnName("unique_message_id"); + + b.Property("BodyContentType") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("body_content_type"); + + b.Property("BodySize") + .HasColumnType("integer") + .HasColumnName("body_size"); + + b.Property("BodyStoredExternally") + .HasColumnType("boolean") + .HasColumnName("body_stored_externally"); + + b.Property("BodyText") + .HasColumnType("text") + .HasColumnName("body_text"); + + b.Property("ConversationId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("conversation_id"); + + b.Property("ExceptionMessage") + .HasColumnType("text") + .HasColumnName("exception_message"); + + b.Property("ExceptionType") + .HasColumnType("text") + .HasColumnName("exception_type"); + + b.Property("FailingEndpointAddress") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("failing_endpoint_address"); + + b.Property("FirstTimeOfFailure") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_time_of_failure"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("headers_json"); + + b.Property("IsSystemMessage") + .HasColumnType("boolean") + .HasColumnName("is_system_message"); + + b.Property("LastAttemptedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempted_at"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastTimeOfFailure") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_time_of_failure"); + + b.Property("MessageId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("message_id"); + + b.Property("MessageType") + .HasColumnType("text") + .HasColumnName("message_type"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("integer") + .HasColumnName("number_of_processing_attempts"); + + b.Property("QueueAddress") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("queue_address"); + + b.Property("ReceivingEndpointHost") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("receiving_endpoint_host"); + + b.Property("ReceivingEndpointHostId") + .HasColumnType("uuid") + .HasColumnName("receiving_endpoint_host_id"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("receiving_endpoint_name"); + + b.Property("SendingEndpointHost") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("sending_endpoint_host"); + + b.Property("SendingEndpointHostId") + .HasColumnType("uuid") + .HasColumnName("sending_endpoint_host_id"); + + b.Property("SendingEndpointName") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("sending_endpoint_name"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("StatusChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("status_changed_at"); + + b.Property("TimeSent") + .HasColumnType("timestamp with time zone") + .HasColumnName("time_sent"); + + b.HasKey("UniqueMessageId") + .HasName("pk_failed_messages"); + + b.HasIndex("ConversationId") + .HasDatabaseName("ix_failed_messages_conversation_id"); + + b.HasIndex("FailingEndpointAddress") + .HasDatabaseName("ix_failed_messages_failing_endpoint_address"); + + b.HasIndex("QueueAddress") + .HasDatabaseName("ix_failed_messages_queue_address"); + + b.HasIndex("ReceivingEndpointName") + .HasDatabaseName("ix_failed_messages_receiving_endpoint_name"); + + b.HasIndex("StatusChangedAt") + .HasDatabaseName("ix_failed_messages_status_changed_at") + .HasFilter("status IN (2, 4)"); + + b.HasIndex("TimeSent") + .HasDatabaseName("ix_failed_messages_time_sent"); + + b.HasIndex("Status", "LastModified") + .HasDatabaseName("ix_failed_messages_status_last_modified"); + + b.ToTable("failed_messages", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => + { + b.Property("FailedMessageUniqueId") + .HasColumnType("uuid") + .HasColumnName("failed_message_unique_id"); + + b.Property("GroupId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("group_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("type"); + + b.HasKey("FailedMessageUniqueId", "GroupId") + .HasName("pk_failed_message_groups"); + + b.HasIndex("GroupId") + .HasDatabaseName("ix_failed_message_groups_group_id"); + + b.ToTable("failed_message_groups", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageRetryEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uuid") + .HasColumnName("unique_message_id"); + + b.Property("RetryId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("retry_id"); + + b.HasKey("UniqueMessageId") + .HasName("pk_failed_message_retries"); + + b.ToTable("failed_message_retries", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("host"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("Monitored") + .HasColumnType("boolean") + .HasColumnName("monitored"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_known_endpoints"); + + b.ToTable("known_endpoints", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => + { + b.Property("MessageType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("message_type"); + + b.Property("TransportAddress") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("transport_address"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("endpoint"); + + b.HasKey("MessageType", "TransportAddress") + .HasName("pk_subscriptions"); + + b.ToTable("subscriptions", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("TrialEndDate") + .HasColumnType("date") + .HasColumnName("trial_end_date"); + + b.HasKey("Id") + .HasName("pk_trial_metadata"); + + b.ToTable("trial_metadata", (string)null); + + b.HasData( + new + { + Id = 1 + }); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => + { + b.HasOne("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", null) + .WithMany() + .HasForeignKey("FailedMessageUniqueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_failed_message_groups_failed_messages_failed_message_unique"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.cs new file mode 100644 index 0000000000..14602642a2 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations +{ + /// + public partial class AddArchiveOperations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ArchiveOperations", + columns: table => new + { + id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + request_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + group_name = table.Column(type: "text", nullable: false), + archive_type = table.Column(type: "integer", nullable: false), + is_archive = table.Column(type: "boolean", nullable: false), + total_number_of_messages = table.Column(type: "integer", nullable: false), + number_of_messages_processed = table.Column(type: "integer", nullable: false), + number_of_batches = table.Column(type: "integer", nullable: false), + current_batch = table.Column(type: "integer", nullable: false), + started = table.Column(type: "timestamp with time zone", nullable: false), + initiated_by_id = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + initiated_by_name = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + operation_id = table.Column(type: "character varying(450)", maxLength: 450, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_archive_operations", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_archive_operations_request_id_archive_type_is_archive", + table: "ArchiveOperations", + columns: new[] { "request_id", "archive_type", "is_archive" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_archive_operations_started", + table: "ArchiveOperations", + column: "started"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArchiveOperations"); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.Designer.cs new file mode 100644 index 0000000000..0fa087f1ad --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.Designer.cs @@ -0,0 +1,409 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ServiceControl.Persistence.EFCore.SqlServer; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations +{ + [DbContext(typeof(SqlServerServiceControlDbContext))] + [Migration("20260803050232_AddArchiveOperations")] + partial class AddArchiveOperations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.ArchiveOperationEntity", b => + { + b.Property("Id") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ArchiveType") + .HasColumnType("int"); + + b.Property("CurrentBatch") + .HasColumnType("int"); + + b.Property("GroupName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InitiatedById") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("InitiatedByName") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("IsArchive") + .HasColumnType("bit"); + + b.Property("NumberOfBatches") + .HasColumnType("int"); + + b.Property("NumberOfMessagesProcessed") + .HasColumnType("int"); + + b.Property("OperationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Started") + .HasColumnType("datetime2"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Started"); + + b.HasIndex("RequestId", "ArchiveType", "IsArchive") + .IsUnique(); + + b.ToTable("ArchiveOperations", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TrackInstances") + .HasColumnType("bit"); + + b.HasKey("Name"); + + b.ToTable("EndpointSettings"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EventLogItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("RaisedAt") + .HasColumnType("datetime2"); + + b.PrimitiveCollection("RelatedTo") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RaisedAt", "Id") + .IsDescending(); + + b.ToTable("EventLogItems", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedErrorImportEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("Body") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("BodyStoredExternally") + .HasColumnType("bit"); + + b.Property("ExceptionInfo") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FailedAt") + .HasColumnType("datetime2"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("UniqueMessageId"); + + b.HasIndex("FailedAt"); + + b.ToTable("FailedErrorImports"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("BodyContentType") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("BodySize") + .HasColumnType("int"); + + b.Property("BodyStoredExternally") + .HasColumnType("bit"); + + b.Property("BodyText") + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExceptionMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("ExceptionType") + .HasColumnType("nvarchar(max)"); + + b.Property("FailingEndpointAddress") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("FirstTimeOfFailure") + .HasColumnType("datetime2"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsSystemMessage") + .HasColumnType("bit"); + + b.Property("LastAttemptedAt") + .HasColumnType("datetime2"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastTimeOfFailure") + .HasColumnType("datetime2"); + + b.Property("MessageId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("MessageType") + .HasColumnType("nvarchar(max)"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("int"); + + b.Property("QueueAddress") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ReceivingEndpointHost") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ReceivingEndpointHostId") + .HasColumnType("uniqueidentifier"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("SendingEndpointHost") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("SendingEndpointHostId") + .HasColumnType("uniqueidentifier"); + + b.Property("SendingEndpointName") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StatusChangedAt") + .HasColumnType("datetime2"); + + b.Property("TimeSent") + .HasColumnType("datetime2"); + + b.HasKey("UniqueMessageId"); + + b.HasIndex("ConversationId"); + + b.HasIndex("FailingEndpointAddress"); + + b.HasIndex("QueueAddress"); + + b.HasIndex("ReceivingEndpointName"); + + b.HasIndex("StatusChangedAt") + .HasFilter("[Status] IN (2, 4)"); + + b.HasIndex("TimeSent"); + + b.HasIndex("Status", "LastModified"); + + b.ToTable("FailedMessages"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => + { + b.Property("FailedMessageUniqueId") + .HasColumnType("uniqueidentifier"); + + b.Property("GroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.HasKey("FailedMessageUniqueId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("FailedMessageGroups"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageRetryEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("RetryId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("UniqueMessageId"); + + b.ToTable("FailedMessageRetries"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("HostId") + .HasColumnType("uniqueidentifier"); + + b.Property("Monitored") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.ToTable("KnownEndpoints"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => + { + b.Property("MessageType") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TransportAddress") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("MessageType", "TransportAddress"); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialMetadata"); + + b.HasData( + new + { + Id = 1 + }); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => + { + b.HasOne("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", null) + .WithMany() + .HasForeignKey("FailedMessageUniqueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.cs new file mode 100644 index 0000000000..f4d8113457 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations +{ + /// + public partial class AddArchiveOperations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ArchiveOperations", + columns: table => new + { + Id = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + RequestId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + GroupName = table.Column(type: "nvarchar(max)", nullable: false), + ArchiveType = table.Column(type: "int", nullable: false), + IsArchive = table.Column(type: "bit", nullable: false), + TotalNumberOfMessages = table.Column(type: "int", nullable: false), + NumberOfMessagesProcessed = table.Column(type: "int", nullable: false), + NumberOfBatches = table.Column(type: "int", nullable: false), + CurrentBatch = table.Column(type: "int", nullable: false), + Started = table.Column(type: "datetime2", nullable: false), + InitiatedById = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: true), + InitiatedByName = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: true), + OperationId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ArchiveOperations", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_RequestId_ArchiveType_IsArchive", + table: "ArchiveOperations", + columns: new[] { "RequestId", "ArchiveType", "IsArchive" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ArchiveOperations_Started", + table: "ArchiveOperations", + column: "Started"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ArchiveOperations"); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs index 34b80a3644..dc3a5bd1ea 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs @@ -13,6 +13,7 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; using ServiceControl.Persistence.MessageRedirects; using ServiceControl.Persistence.Recoverability; using ServiceControl.Persistence.UnitOfWork; +using ServiceControl.Recoverability; public abstract class BasePersistence { @@ -33,6 +34,7 @@ protected static void RegisterDataStores(IServiceCollection services, EFPersiste services.AddHostedService(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs b/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs index 8973e49541..a53e3c8e42 100644 --- a/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs +++ b/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs @@ -20,6 +20,7 @@ public abstract class ServiceControlDbContext(DbContextOptions options) : DbCont public DbSet TrialMetadata { get; set; } public DbSet Subscriptions { get; set; } public DbSet EventLogItems { get; set; } + public DbSet ArchiveOperations { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.EnableDetailedErrors(); @@ -42,6 +43,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new SubscriptionConfiguration()); modelBuilder.ApplyConfiguration(new TrialMetadataConfiguration()); modelBuilder.ApplyConfiguration(new EventLogItemConfiguration()); + modelBuilder.ApplyConfiguration(new ArchiveOperationConfiguration()); } public abstract bool IsDuplicateKeyException(DbUpdateException exception); diff --git a/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs new file mode 100644 index 0000000000..879d9ec8f8 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs @@ -0,0 +1,59 @@ +namespace ServiceControl.Persistence.EFCore.Entities; + +using ServiceControl.Recoverability; + +/// +/// Persisted record of an in-progress archive or unarchive operation, enabling resume-after-crash. +/// A single row per (RequestId, ArchiveType, IsArchive) combination; the unique index prevents +/// duplicate concurrent operations. +/// +public class ArchiveOperationEntity +{ + /// Deterministic string PK (e.g. ArchiveOperations/2/{groupId}). + public string Id { get; set; } = null!; + + /// The group id (or other request id) being archived/unarchived. + public string RequestId { get; set; } = null!; + + /// Display name of the group, captured at operation start. + public string GroupName { get; set; } = null!; + + /// The type of archive operation (FailureGroup, SingleMessage, etc.). + public ArchiveType ArchiveType { get; set; } + + /// Distinguishes archive (true) from unarchive (false). + public bool IsArchive { get; set; } + + /// Total number of messages in the group at operation start. + public int TotalNumberOfMessages { get; set; } + + /// Number of messages processed so far (resume checkpoint). + public int NumberOfMessagesProcessed { get; set; } + + /// Total number of batches planned. + public int NumberOfBatches { get; set; } + + /// Current batch number (resume checkpoint, 0-based). + public int CurrentBatch { get; set; } + + /// When the operation started (UTC). + public DateTime Started { get; set; } + + /// Audit attribution: the id of the user who initiated the operation. + public string? InitiatedById { get; set; } + + /// Audit attribution: the name of the user who initiated the operation. + public string? InitiatedByName { get; set; } + + /// Audit attribution: the operation id used to correlate per-message audit entries. + public string? OperationId { get; set; } + + /// + /// Builds a deterministic primary key for the operation row. + /// + public static string MakeId(string requestId, ArchiveType archiveType, bool isArchive) + { + var prefix = isArchive ? "ArchiveOperations" : "UnarchiveOperations"; + return $"{prefix}/{(int)archiveType}/{requestId}"; + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs new file mode 100644 index 0000000000..d17e07a63c --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs @@ -0,0 +1,39 @@ +namespace ServiceControl.Persistence.EFCore.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class ArchiveOperationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ArchiveOperations"); + + builder.HasKey(e => e.Id); + builder.Property(e => e.Id) + .HasMaxLength(200) + .ValueGeneratedNever(); + + builder.Property(e => e.RequestId).HasMaxLength(64).IsRequired(); + builder.Property(e => e.GroupName).IsRequired(); + builder.Property(e => e.ArchiveType).IsRequired(); + builder.Property(e => e.IsArchive).IsRequired(); + builder.Property(e => e.TotalNumberOfMessages).IsRequired(); + builder.Property(e => e.NumberOfMessagesProcessed).IsRequired(); + builder.Property(e => e.NumberOfBatches).IsRequired(); + builder.Property(e => e.CurrentBatch).IsRequired(); + builder.Property(e => e.Started).IsRequired(); + + builder.Property(e => e.InitiatedById).HasMaxLength(ColumnLengths.ShortTextLength); + builder.Property(e => e.InitiatedByName).HasMaxLength(ColumnLengths.ShortTextLength); + builder.Property(e => e.OperationId).HasMaxLength(ColumnLengths.ShortTextLength); + + // Enforce one operation per (RequestId, ArchiveType, IsArchive) at a time + builder.HasIndex(e => new { e.RequestId, e.ArchiveType, e.IsArchive }) + .IsUnique(); + + // Non-unique index for cleanup / diagnostics queries + builder.HasIndex(e => e.Started); + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageLifecycleDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageLifecycleDataStore.cs index 07f3b724d7..9c51049517 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageLifecycleDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageLifecycleDataStore.cs @@ -1,24 +1,141 @@ namespace ServiceControl.Persistence.EFCore.Implementation; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using ServiceControl.MessageFailures; /// /// Every operation here has to update both StatusChangedAt and LastModified. /// public class FailedMessageLifecycleDataStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), IFailedMessageLifecycleDataStore { - public Task MarkAsArchived(string failedMessageId) => - throw new NotImplementedException(); + public async Task MarkAsArchived(string failedMessageId) + { + await ExecuteWithDbContext(async dbContext => + { + if (!Guid.TryParse(failedMessageId, out var uniqueMessageId)) + { + return; + } - public Task MarkAsResolved(string failedMessageId) => - throw new NotImplementedException(); + var now = DateTime.UtcNow; - public Task UnArchiveMessages(IEnumerable failedMessageIds) => - throw new NotImplementedException(); + await dbContext.FailedMessages + .Where(fm => fm.UniqueMessageId == uniqueMessageId) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, FailedMessageStatus.Archived) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now)); + }); + } - public Task UnArchiveMessagesByRange(DateTime from, DateTime to) => - throw new NotImplementedException(); + public async Task MarkAsResolved(string failedMessageId) + { + return await ExecuteWithDbContext(async dbContext => + { + if (!Guid.TryParse(failedMessageId, out var uniqueMessageId)) + { + return false; + } - public Task RevertRetry(string messageUniqueId) => - throw new NotImplementedException(); -} + var now = DateTime.UtcNow; + + var affected = await dbContext.FailedMessages + .Where(fm => fm.UniqueMessageId == uniqueMessageId && fm.Status == FailedMessageStatus.Unresolved) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, FailedMessageStatus.Resolved) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now)); + + return affected > 0; + }); + } + + public async Task UnArchiveMessages(IEnumerable failedMessageIds) + { + var ids = failedMessageIds + .Select(id => Guid.TryParse(id, out var guid) ? guid : Guid.Empty) + .Where(guid => guid != Guid.Empty) + .ToList(); + + if (ids.Count == 0) + { + return []; + } + + return await ExecuteWithDbContext(async dbContext => + { + var now = DateTime.UtcNow; + + // Query which messages will actually be unarchived (must be Archived status) + var unarchivableIds = await dbContext.FailedMessages + .Where(fm => ids.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Archived) + .Select(fm => fm.UniqueMessageId) + .ToListAsync(); + + if (unarchivableIds.Count == 0) + { + return []; + } + + await dbContext.FailedMessages + .Where(fm => unarchivableIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Archived) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, FailedMessageStatus.Unresolved) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now)); + + return unarchivableIds.Select(id => id.ToString()).ToArray(); + }); + } + + public async Task UnArchiveMessagesByRange(DateTime from, DateTime to) + { + return await ExecuteWithDbContext(async dbContext => + { + var now = DateTime.UtcNow; + + // Query which messages will be unarchived (must be Archived and within the date range) + var unarchivableIds = await dbContext.FailedMessages + .Where(fm => fm.Status == FailedMessageStatus.Archived + && fm.LastTimeOfFailure >= from + && fm.LastTimeOfFailure <= to) + .Select(fm => fm.UniqueMessageId) + .ToListAsync(); + + if (unarchivableIds.Count == 0) + { + return []; + } + + await dbContext.FailedMessages + .Where(fm => unarchivableIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Archived) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, FailedMessageStatus.Unresolved) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now)); + + return unarchivableIds.Select(id => id.ToString()).ToArray(); + }); + } + + public async Task RevertRetry(string messageUniqueId) + { + await ExecuteWithDbContext(async dbContext => + { + if (!Guid.TryParse(messageUniqueId, out var uniqueMessageId)) + { + return; + } + + var now = DateTime.UtcNow; + + await dbContext.FailedMessages + .Where(fm => fm.UniqueMessageId == uniqueMessageId && fm.Status == FailedMessageStatus.RetryIssued) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, FailedMessageStatus.Unresolved) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now)); + }); + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs index e56575df4d..000f7e19a6 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs @@ -1,34 +1,427 @@ namespace ServiceControl.Persistence.EFCore.Implementation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using ServiceControl.Infrastructure.Auth; +using ServiceControl.Infrastructure.DomainEvents; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.EFCore.DbContexts; +using ServiceControl.Persistence.EFCore.Entities; using ServiceControl.Persistence.Recoverability; using ServiceControl.Recoverability; public class MessageArchiver : IArchiveMessages { - // must set StatusChangedAt + LastModified - public Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) => - throw new NotImplementedException(); + public MessageArchiver( + IServiceScopeFactory scopeFactory, + OperationsManager operationsManager, + IDomainEvents domainEvents, + IMessageActionAuditLog auditLog, + ILogger logger + ) + { + this.scopeFactory = scopeFactory; + this.operationsManager = operationsManager; + this.auditLog = auditLog; + this.logger = logger; + this.domainEvents = domainEvents; - // must set StatusChangedAt + LastModified - public Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) => - throw new NotImplementedException(); + archivingManager = new EFCoreArchivingManager(domainEvents, operationsManager); + unarchivingManager = new EFCoreUnarchivingManager(domainEvents, operationsManager); + } - public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) => - throw new NotImplementedException(); + public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) + { + logger.LogInformation("Archiving of {GroupId} started", groupId); - public bool IsArchiveInProgressFor(string groupId) => - throw new NotImplementedException(); + ArchiveOperationEntity? operationEntity; + AuditUser auditUser; + string? auditOperationId; - public void DismissArchiveOperation(string groupId, ArchiveType archiveType) => - throw new NotImplementedException(); + // ── Load-or-create operation row ── + var operationId_str = ArchiveOperationEntity.MakeId(groupId, ArchiveType.FailureGroup, isArchive: true); - public Task StartArchiving(string groupId, ArchiveType archiveType) => - throw new NotImplementedException(); + using (var scope = scopeFactory.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); - public Task StartUnarchiving(string groupId, ArchiveType archiveType) => - throw new NotImplementedException(); + operationEntity = await dbContext.ArchiveOperations.FindAsync(operationId_str); - public IEnumerable GetArchivalOperations() => - throw new NotImplementedException(); -} + if (operationEntity != null) + { + // Resume scenario: operation already exists from a previous (possibly crashed) run + logger.LogInformation("Resuming archive operation for group {GroupId} at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity.CurrentBatch, operationEntity.NumberOfBatches); + } + else + { + // New operation: get group details + var (count, groupName) = await ArchiveQueryHelper.GetGroupDetailsForArchive(dbContext, groupId); + + if (count == 0) + { + logger.LogWarning("No messages to archive in group {GroupId}", groupId); + return; + } + + operationEntity = new ArchiveOperationEntity + { + Id = operationId_str, + RequestId = groupId, + GroupName = groupName, + ArchiveType = ArchiveType.FailureGroup, + IsArchive = true, + TotalNumberOfMessages = count, + NumberOfMessagesProcessed = 0, + NumberOfBatches = (int)Math.Ceiling(count / (float)batchSize), + CurrentBatch = 0, + Started = DateTime.UtcNow, + InitiatedById = initiatedBy?.Id, + InitiatedByName = initiatedBy?.Name, + OperationId = operationId + }; + + dbContext.ArchiveOperations.Add(operationEntity); + + try + { + await dbContext.SaveChangesAsync(); + logger.LogInformation("Group {GroupId} has been split into {NumberOfBatches} batches", groupId, operationEntity.NumberOfBatches); + } + catch (DbUpdateException ex) when (dbContext.IsDuplicateKeyException(ex)) + { + // Another handler beat us to it — load the existing operation + operationEntity = await dbContext.ArchiveOperations.FindAsync(operationId_str); + logger.LogInformation("Archive operation for group {GroupId} already in progress, resuming at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity!.CurrentBatch, operationEntity.NumberOfBatches); + } + } + + // Capture audit attribution from the persisted entity + auditUser = new AuditUser(operationEntity.InitiatedById ?? AuditUser.AnonymousValue, operationEntity.InitiatedByName ?? AuditUser.AnonymousValue); + auditOperationId = operationEntity.OperationId; + } + + // ── Start in-memory tracking ── + await archivingManager.StartArchiving(operationEntity!); + + // ── Batch loop ── + var lastProcessedId = Guid.Empty; + + // If resuming, try to reconstruct lastProcessedId from the number of already-processed messages. + // We can't know the exact IDs that were already processed, so we start from the beginning. + // The re-asserted Status == Unresolved filter ensures already-archived messages are skipped. + // This is safe because already-archived messages have Status = Archived and won't match the filter. + + while (operationEntity!.CurrentBatch < operationEntity!.NumberOfBatches) + { + using var batchScope = scopeFactory.CreateAsyncScope(); + var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); + + var batchIds = await ArchiveQueryHelper.GetNextBatchOfMessageIds( + batchDbContext, groupId, FailedMessageStatus.Unresolved, lastProcessedId, batchSize); + + if (batchIds.Count == 0) + { + // All messages already archived or group changed + logger.LogWarning("Attempting to archive a batch ({CurrentBatch}/{NumberOfBatches}) which appears to already have been archived", operationEntity!.CurrentBatch, operationEntity!.NumberOfBatches); + } + else + { + logger.LogInformation("Archiving {MessageCount} messages from group {GroupId} starting", batchIds.Count, groupId); + + var now = DateTime.UtcNow; + + // Bulk status change with re-asserted status filter + var affectedCount = await batchDbContext.FailedMessages + .Where(fm => batchIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Unresolved) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, FailedMessageStatus.Archived) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now)); + + lastProcessedId = batchIds[^1]; + + await archivingManager.BatchArchived(groupId, ArchiveType.FailureGroup, affectedCount); + + // Update persisted operation entity + var persistedEntity = await batchDbContext.ArchiveOperations.FindAsync(operationId_str); + if (persistedEntity != null) + { + persistedEntity.CurrentBatch++; + persistedEntity.NumberOfMessagesProcessed += affectedCount; + await batchDbContext.SaveChangesAsync(); + operationEntity = persistedEntity; + } + + // Raise batch domain event + var messageIds = batchIds.Select(id => id.ToString()).ToArray(); + await domainEvents.Raise(new FailedMessageGroupBatchArchived + { + FailedMessagesIds = messageIds + }); + + // Per-message audit + AuditArchivedMessages(MessageActionKind.Archive, Permissions.ErrorRecoverabilityGroupsArchive, auditUser, auditOperationId, messageIds); + + logger.LogInformation("Archiving of {MessageCount} messages from group {GroupId} completed", batchIds.Count, groupId); + } + + // If batch was empty, still increment to avoid infinite loop + if (batchIds.Count == 0) + { + var persistedEntity = await batchDbContext.ArchiveOperations.FindAsync(operationId_str); + if (persistedEntity != null) + { + persistedEntity.CurrentBatch++; + await batchDbContext.SaveChangesAsync(); + operationEntity = persistedEntity; + } + } + } + + // ── Finalize ── + logger.LogInformation("Archiving of group {GroupId} is complete", groupId); + await archivingManager.ArchiveOperationFinalizing(groupId, ArchiveType.FailureGroup); + + // No wait-for-index step — SQL is immediately consistent + + await archivingManager.ArchiveOperationCompleted(groupId, ArchiveType.FailureGroup); + + // Delete the operation row + using (var finalizeScope = scopeFactory.CreateAsyncScope()) + { + var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); + var entity = await finalizeDbContext.ArchiveOperations.FindAsync(operationId_str); + if (entity != null) + { + finalizeDbContext.ArchiveOperations.Remove(entity); + await finalizeDbContext.SaveChangesAsync(); + } + } + + await domainEvents.Raise(new FailedMessageGroupArchived + { + GroupId = groupId, + GroupName = operationEntity!.GroupName, + MessagesCount = operationEntity!.TotalNumberOfMessages + }); + + logger.LogInformation("Archiving of group {GroupId} completed", groupId); + } + + public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) + { + logger.LogInformation("Unarchiving of {GroupId} started", groupId); + + ArchiveOperationEntity? operationEntity; + AuditUser auditUser; + string? auditOperationId; + + // ── Load-or-create operation row ── + var operationId_str = ArchiveOperationEntity.MakeId(groupId, ArchiveType.FailureGroup, isArchive: false); + + using (var scope = scopeFactory.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + operationEntity = await dbContext.ArchiveOperations.FindAsync(operationId_str); + + if (operationEntity != null) + { + // Resume scenario + logger.LogInformation("Resuming unarchive operation for group {GroupId} at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity.CurrentBatch, operationEntity.NumberOfBatches); + } + else + { + // New operation: get group details + var (count, groupName) = await ArchiveQueryHelper.GetGroupDetailsForUnarchive(dbContext, groupId); + + if (count == 0) + { + logger.LogWarning("No messages to unarchive in group {GroupId}", groupId); + return; + } + + operationEntity = new ArchiveOperationEntity + { + Id = operationId_str, + RequestId = groupId, + GroupName = groupName, + ArchiveType = ArchiveType.FailureGroup, + IsArchive = false, + TotalNumberOfMessages = count, + NumberOfMessagesProcessed = 0, + NumberOfBatches = (int)Math.Ceiling(count / (float)batchSize), + CurrentBatch = 0, + Started = DateTime.UtcNow, + InitiatedById = initiatedBy?.Id, + InitiatedByName = initiatedBy?.Name, + OperationId = operationId + }; + + dbContext.ArchiveOperations.Add(operationEntity); + + try + { + await dbContext.SaveChangesAsync(); + logger.LogInformation("Group {GroupId} has been split into {NumberOfBatches} batches", groupId, operationEntity.NumberOfBatches); + } + catch (DbUpdateException ex) when (dbContext.IsDuplicateKeyException(ex)) + { + // Another handler beat us to it — load the existing operation + operationEntity = await dbContext.ArchiveOperations.FindAsync(operationId_str); + logger.LogInformation("Unarchive operation for group {GroupId} already in progress, resuming at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity!.CurrentBatch, operationEntity.NumberOfBatches); + } + } + + // Capture audit attribution from the persisted entity + auditUser = new AuditUser(operationEntity.InitiatedById ?? AuditUser.AnonymousValue, operationEntity.InitiatedByName ?? AuditUser.AnonymousValue); + auditOperationId = operationEntity.OperationId; + } + + // ── Start in-memory tracking ── + await unarchivingManager.StartUnarchiving(operationEntity!); + + // ── Batch loop ── + var lastProcessedId = Guid.Empty; + + while (operationEntity!.CurrentBatch < operationEntity!.NumberOfBatches) + { + using var batchScope = scopeFactory.CreateAsyncScope(); + var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); + + var batchIds = await ArchiveQueryHelper.GetNextBatchOfMessageIds( + batchDbContext, groupId, FailedMessageStatus.Archived, lastProcessedId, batchSize); + + if (batchIds.Count == 0) + { + logger.LogWarning("Attempting to unarchive a batch ({CurrentBatch}/{NumberOfBatches}) which appears to already have been unarchived", operationEntity!.CurrentBatch, operationEntity!.NumberOfBatches); + } + else + { + logger.LogInformation("Unarchiving {MessageCount} messages from group {GroupId} starting", batchIds.Count, groupId); + + var now = DateTime.UtcNow; + + // Bulk status change with re-asserted status filter + var affectedCount = await batchDbContext.FailedMessages + .Where(fm => batchIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Archived) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, FailedMessageStatus.Unresolved) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now)); + + lastProcessedId = batchIds[^1]; + + await unarchivingManager.BatchUnarchived(groupId, ArchiveType.FailureGroup, affectedCount); + + // Update persisted operation entity + var persistedEntity = await batchDbContext.ArchiveOperations.FindAsync(operationId_str); + if (persistedEntity != null) + { + persistedEntity.CurrentBatch++; + persistedEntity.NumberOfMessagesProcessed += affectedCount; + await batchDbContext.SaveChangesAsync(); + operationEntity = persistedEntity; + } + + // Raise batch domain event + var messageIds = batchIds.Select(id => id.ToString()).ToArray(); + await domainEvents.Raise(new FailedMessageGroupBatchUnarchived + { + FailedMessagesIds = messageIds + }); + + // Per-message audit + AuditArchivedMessages(MessageActionKind.Unarchive, Permissions.ErrorRecoverabilityGroupsUnarchive, auditUser, auditOperationId, messageIds); + + logger.LogInformation("Unarchiving of {MessageCount} messages from group {GroupId} completed", batchIds.Count, groupId); + } + + // If batch was empty, still increment to avoid infinite loop + if (batchIds.Count == 0) + { + var persistedEntity = await batchDbContext.ArchiveOperations.FindAsync(operationId_str); + if (persistedEntity != null) + { + persistedEntity.CurrentBatch++; + await batchDbContext.SaveChangesAsync(); + operationEntity = persistedEntity; + } + } + } + + // ── Finalize ── + logger.LogInformation("Unarchiving of group {GroupId} is complete", groupId); + await unarchivingManager.UnarchiveOperationFinalizing(groupId, ArchiveType.FailureGroup); + + // No wait-for-index step — SQL is immediately consistent + + await unarchivingManager.UnarchiveOperationCompleted(groupId, ArchiveType.FailureGroup); + + // Delete the operation row + using (var finalizeScope = scopeFactory.CreateAsyncScope()) + { + var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); + var entity = await finalizeDbContext.ArchiveOperations.FindAsync(operationId_str); + if (entity != null) + { + finalizeDbContext.ArchiveOperations.Remove(entity); + await finalizeDbContext.SaveChangesAsync(); + } + } + + await domainEvents.Raise(new FailedMessageGroupUnarchived + { + GroupId = groupId, + GroupName = operationEntity!.GroupName, + MessagesCount = operationEntity!.TotalNumberOfMessages + }); + + logger.LogInformation("Unarchiving of group {GroupId} completed", groupId); + } + + /// + /// Emits one per-message audit entry for each message in a batch, correlated to the initiating + /// operation. Skipped when no OperationId was captured (e.g. legacy in-flight operations). + /// + void AuditArchivedMessages(MessageActionKind kind, string permission, AuditUser user, string? operationId, string[] messageIds) + { + if (string.IsNullOrEmpty(operationId)) + { + return; + } + + foreach (var messageId in messageIds) + { + auditLog.MessageAction(user, kind, permission, MessageActionScope.Group, messageId, operationId); + } + } + + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) + => operationsManager.IsOperationInProgressFor(groupId, archiveType); + + public bool IsArchiveInProgressFor(string groupId) + => archivingManager.IsArchiveInProgressFor(groupId); + + public void DismissArchiveOperation(string groupId, ArchiveType archiveType) + => archivingManager.DismissArchiveOperation(groupId, archiveType); + + public Task StartArchiving(string groupId, ArchiveType archiveType) + => archivingManager.StartArchiving(groupId, archiveType); + + public Task StartUnarchiving(string groupId, ArchiveType archiveType) + => unarchivingManager.StartUnarchiving(groupId, archiveType); + + public IEnumerable GetArchivalOperations() + => archivingManager.GetArchivalOperations(); + + readonly IServiceScopeFactory scopeFactory; + readonly OperationsManager operationsManager; + readonly IDomainEvents domainEvents; + readonly IMessageActionAuditLog auditLog; + readonly EFCoreArchivingManager archivingManager; + readonly EFCoreUnarchivingManager unarchivingManager; + readonly ILogger logger; + const int batchSize = 1000; +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Recoverability/ArchiveQueryHelper.cs b/src/ServiceControl.Persistence.EFCore/Recoverability/ArchiveQueryHelper.cs new file mode 100644 index 0000000000..ce35555ebd --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Recoverability/ArchiveQueryHelper.cs @@ -0,0 +1,72 @@ +namespace ServiceControl.Persistence.EFCore.Implementation; + +using Microsoft.EntityFrameworkCore; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.EFCore.DbContexts; + +/// +/// Focused query helpers for the archive/unarchive flows: group details (count + name) +/// and keyset-paginated batch selection of message IDs by group + status. +/// +static class ArchiveQueryHelper +{ + /// + /// Returns the count of unresolved messages in a group and the group's title. + /// Used when starting an archive operation. + /// + public static async Task<(int count, string groupName)> GetGroupDetailsForArchive( + ServiceControlDbContext dbContext, string groupId, CancellationToken cancellationToken = default) + { + return await GetGroupDetails(dbContext, groupId, FailedMessageStatus.Unresolved, cancellationToken); + } + + /// + /// Returns the count of archived messages in a group and the group's title. + /// Used when starting an unarchive operation. + /// + public static async Task<(int count, string groupName)> GetGroupDetailsForUnarchive( + ServiceControlDbContext dbContext, string groupId, CancellationToken cancellationToken = default) + { + return await GetGroupDetails(dbContext, groupId, FailedMessageStatus.Archived, cancellationToken); + } + + static async Task<(int count, string groupName)> GetGroupDetails( + ServiceControlDbContext dbContext, string groupId, FailedMessageStatus status, CancellationToken cancellationToken) + { + var query = from fmg in dbContext.FailedMessageGroups + join fm in dbContext.FailedMessages on fmg.FailedMessageUniqueId equals fm.UniqueMessageId + where fmg.GroupId == groupId && fm.Status == status + select new { fmg.Title, fm.UniqueMessageId }; + + var count = await query.CountAsync(cancellationToken); + var groupName = await dbContext.FailedMessageGroups + .Where(fmg => fmg.GroupId == groupId) + .Select(fmg => fmg.Title) + .FirstOrDefaultAsync(cancellationToken) ?? "Undefined"; + + return (count, groupName); + } + + /// + /// Keyset-paginated query: selects the next batch of message IDs in a group + /// with the given status, ordered by UniqueMessageId, starting after lastProcessedId. + /// + public static async Task> GetNextBatchOfMessageIds( + ServiceControlDbContext dbContext, + string groupId, + FailedMessageStatus status, + Guid lastProcessedId, + int batchSize, + CancellationToken cancellationToken = default) + { + var query = from fmg in dbContext.FailedMessageGroups + join fm in dbContext.FailedMessages on fmg.FailedMessageUniqueId equals fm.UniqueMessageId + where fmg.GroupId == groupId + && fm.Status == status + && fm.UniqueMessageId > lastProcessedId + orderby fm.UniqueMessageId + select fm.UniqueMessageId; + + return await query.Take(batchSize).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreArchivingManager.cs b/src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreArchivingManager.cs new file mode 100644 index 0000000000..7d9e29f268 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreArchivingManager.cs @@ -0,0 +1,91 @@ +namespace ServiceControl.Persistence.EFCore.Implementation; + +using ServiceControl.Infrastructure.DomainEvents; +using ServiceControl.Persistence.EFCore.Entities; +using ServiceControl.Recoverability; + +/// +/// EFCore equivalent of the RavenDB . Wraps the shared +/// singleton to manage in-memory archive progress state. +/// +class EFCoreArchivingManager(IDomainEvents domainEvents, OperationsManager operationsManager) +{ + InMemoryArchive GetOrCreate(ArchiveType archiveType, string requestId) + { + var id = InMemoryArchive.MakeId(requestId, archiveType); + if (!operationsManager.ArchiveOperations.TryGetValue(id, out var summary)) + { + summary = new InMemoryArchive(requestId, archiveType, domainEvents); + operationsManager.ArchiveOperations[id] = summary; + } + + return summary; + } + + public Task StartArchiving(ArchiveOperationEntity operation) + { + var summary = GetOrCreate(operation.ArchiveType, operation.RequestId); + + summary.TotalNumberOfMessages = operation.TotalNumberOfMessages; + summary.NumberOfMessagesArchived = operation.NumberOfMessagesProcessed; + summary.Started = operation.Started; + summary.GroupName = operation.GroupName; + summary.NumberOfBatches = operation.NumberOfBatches; + summary.CurrentBatch = operation.CurrentBatch; + + return summary.Start(); + } + + public Task StartArchiving(string requestId, ArchiveType archiveType) + { + var summary = GetOrCreate(archiveType, requestId); + + summary.TotalNumberOfMessages = 0; + summary.NumberOfMessagesArchived = 0; + summary.Started = DateTime.UtcNow; + summary.GroupName = "Undefined"; + summary.NumberOfBatches = 0; + summary.CurrentBatch = 0; + + return summary.Start(); + } + + public InMemoryArchive? GetStatusForArchiveOperation(string requestId, ArchiveType archiveType) + { + operationsManager.ArchiveOperations.TryGetValue(InMemoryArchive.MakeId(requestId, archiveType), out var summary); + return summary; + } + + public Task BatchArchived(string requestId, ArchiveType archiveType, int numberOfMessagesArchivedInBatch) + { + var summary = GetOrCreate(archiveType, requestId); + return summary.BatchArchived(numberOfMessagesArchivedInBatch); + } + + public Task ArchiveOperationFinalizing(string requestId, ArchiveType archiveType) + { + var summary = GetOrCreate(archiveType, requestId); + return summary.FinalizeArchive(); + } + + public Task ArchiveOperationCompleted(string requestId, ArchiveType archiveType) + { + var summary = GetOrCreate(archiveType, requestId); + return summary.Complete(); + } + + public bool IsArchiveInProgressFor(string requestId) + { + return operationsManager.ArchiveOperations.Keys.Any(key => key.EndsWith($"/{requestId}")); + } + + public IEnumerable GetArchivalOperations() + { + return operationsManager.ArchiveOperations.Values; + } + + public void DismissArchiveOperation(string requestId, ArchiveType archiveType) + { + operationsManager.ArchiveOperations.Remove(InMemoryArchive.MakeId(requestId, archiveType)); + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreUnarchivingManager.cs b/src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreUnarchivingManager.cs new file mode 100644 index 0000000000..396d54508c --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreUnarchivingManager.cs @@ -0,0 +1,81 @@ +namespace ServiceControl.Persistence.EFCore.Implementation; + +using ServiceControl.Infrastructure.DomainEvents; +using ServiceControl.Persistence.EFCore.Entities; +using ServiceControl.Recoverability; + +/// +/// EFCore equivalent of the RavenDB . Wraps the shared +/// singleton to manage in-memory unarchive progress state. +/// +class EFCoreUnarchivingManager(IDomainEvents domainEvents, OperationsManager operationsManager) +{ + InMemoryUnarchive GetOrCreate(ArchiveType archiveType, string requestId) + { + var id = InMemoryUnarchive.MakeId(requestId, archiveType); + if (!operationsManager.UnarchiveOperations.TryGetValue(id, out var summary)) + { + summary = new InMemoryUnarchive(requestId, archiveType, domainEvents); + operationsManager.UnarchiveOperations[id] = summary; + } + + return summary; + } + + public Task StartUnarchiving(ArchiveOperationEntity operation) + { + var summary = GetOrCreate(operation.ArchiveType, operation.RequestId); + + summary.TotalNumberOfMessages = operation.TotalNumberOfMessages; + summary.NumberOfMessagesUnarchived = operation.NumberOfMessagesProcessed; + summary.Started = operation.Started; + summary.GroupName = operation.GroupName; + summary.NumberOfBatches = operation.NumberOfBatches; + summary.CurrentBatch = operation.CurrentBatch; + + return summary.Start(); + } + + public Task StartUnarchiving(string requestId, ArchiveType archiveType) + { + var summary = GetOrCreate(archiveType, requestId); + + summary.TotalNumberOfMessages = 0; + summary.NumberOfMessagesUnarchived = 0; + summary.Started = DateTime.UtcNow; + summary.GroupName = "Undefined"; + summary.NumberOfBatches = 0; + summary.CurrentBatch = 0; + + return summary.Start(); + } + + public InMemoryUnarchive? GetStatusForUnarchiveOperation(string requestId, ArchiveType archiveType) + { + operationsManager.UnarchiveOperations.TryGetValue(InMemoryUnarchive.MakeId(requestId, archiveType), out var summary); + return summary; + } + + public Task BatchUnarchived(string requestId, ArchiveType archiveType, int numberOfMessagesUnarchivedInBatch) + { + var summary = GetOrCreate(archiveType, requestId); + return summary.BatchUnarchived(numberOfMessagesUnarchivedInBatch); + } + + public Task UnarchiveOperationFinalizing(string requestId, ArchiveType archiveType) + { + var summary = GetOrCreate(archiveType, requestId); + return summary.FinalizeUnarchive(); + } + + public Task UnarchiveOperationCompleted(string requestId, ArchiveType archiveType) + { + var summary = GetOrCreate(archiveType, requestId); + return summary.Complete(); + } + + public void DismissUnarchiveOperation(string requestId, ArchiveType archiveType) + { + operationsManager.UnarchiveOperations.Remove(InMemoryUnarchive.MakeId(requestId, archiveType)); + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj b/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj index ee2d6291bd..a8cd23fb3c 100644 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj @@ -40,7 +40,6 @@ - diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj b/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj index 7cdd5a6320..af49f36dcd 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj +++ b/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj @@ -40,7 +40,6 @@ - From e3e51820f660e56d8c4d6989b80a19536b485d46 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Mon, 3 Aug 2026 13:54:23 +0800 Subject: [PATCH 03/10] Remove raven-style key, better looping on batches --- ...03054523_AddArchiveOperations.Designer.cs} | 30 +-- ...=> 20260803054523_AddArchiveOperations.cs} | 11 +- ...03054521_AddArchiveOperations.Designer.cs} | 24 +- ...=> 20260803054521_AddArchiveOperations.cs} | 11 +- .../Entities/ArchiveOperationEntity.cs | 16 +- .../ArchiveOperationConfiguration.cs | 11 +- .../Implementation/MessageArchiver.cs | 210 ++++++++---------- .../Recoverability/ArchiveQueryHelper.cs | 53 +++++ .../Recoverability/EFCoreArchivingManager.cs | 0 .../EFCoreUnarchivingManager.cs | 0 .../Recoverability/ArchiveQueryHelper.cs | 72 ------ 11 files changed, 177 insertions(+), 261 deletions(-) rename src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/{20260803050237_AddArchiveOperations.Designer.cs => 20260803054523_AddArchiveOperations.Designer.cs} (97%) rename src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/{20260803050237_AddArchiveOperations.cs => 20260803054523_AddArchiveOperations.cs} (85%) rename src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/{20260803050232_AddArchiveOperations.Designer.cs => 20260803054521_AddArchiveOperations.Designer.cs} (97%) rename src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/{20260803050232_AddArchiveOperations.cs => 20260803054521_AddArchiveOperations.cs} (85%) create mode 100644 src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs rename src/ServiceControl.Persistence.EFCore/{ => Implementation}/Recoverability/EFCoreArchivingManager.cs (100%) rename src/ServiceControl.Persistence.EFCore/{ => Implementation}/Recoverability/EFCoreUnarchivingManager.cs (100%) delete mode 100644 src/ServiceControl.Persistence.EFCore/Recoverability/ArchiveQueryHelper.cs diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.Designer.cs similarity index 97% rename from src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.Designer.cs rename to src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.Designer.cs index 46c8c8b2e0..8b9aaf4737 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.Designer.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.Designer.cs @@ -13,7 +13,7 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations { [DbContext(typeof(PostgreSqlServiceControlDbContext))] - [Migration("20260803050237_AddArchiveOperations")] + [Migration("20260803054523_AddArchiveOperations")] partial class AddArchiveOperations { /// @@ -28,15 +28,19 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.ArchiveOperationEntity", b => { - b.Property("Id") - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("id"); + b.Property("RequestId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("request_id"); b.Property("ArchiveType") .HasColumnType("integer") .HasColumnName("archive_type"); + b.Property("IsArchive") + .HasColumnType("boolean") + .HasColumnName("is_archive"); + b.Property("CurrentBatch") .HasColumnType("integer") .HasColumnName("current_batch"); @@ -56,10 +60,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(450)") .HasColumnName("initiated_by_name"); - b.Property("IsArchive") - .HasColumnType("boolean") - .HasColumnName("is_archive"); - b.Property("NumberOfBatches") .HasColumnType("integer") .HasColumnName("number_of_batches"); @@ -73,12 +73,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("character varying(450)") .HasColumnName("operation_id"); - b.Property("RequestId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("request_id"); - b.Property("Started") .HasColumnType("timestamp with time zone") .HasColumnName("started"); @@ -87,16 +81,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("total_number_of_messages"); - b.HasKey("Id") + b.HasKey("RequestId", "ArchiveType", "IsArchive") .HasName("pk_archive_operations"); b.HasIndex("Started") .HasDatabaseName("ix_archive_operations_started"); - b.HasIndex("RequestId", "ArchiveType", "IsArchive") - .IsUnique() - .HasDatabaseName("ix_archive_operations_request_id_archive_type_is_archive"); - b.ToTable("ArchiveOperations", (string)null); }); diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.cs similarity index 85% rename from src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.cs rename to src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.cs index 14602642a2..4df323715c 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803050237_AddArchiveOperations.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.cs @@ -15,11 +15,10 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "ArchiveOperations", columns: table => new { - id = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), request_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - group_name = table.Column(type: "text", nullable: false), archive_type = table.Column(type: "integer", nullable: false), is_archive = table.Column(type: "boolean", nullable: false), + group_name = table.Column(type: "text", nullable: false), total_number_of_messages = table.Column(type: "integer", nullable: false), number_of_messages_processed = table.Column(type: "integer", nullable: false), number_of_batches = table.Column(type: "integer", nullable: false), @@ -31,15 +30,9 @@ protected override void Up(MigrationBuilder migrationBuilder) }, constraints: table => { - table.PrimaryKey("pk_archive_operations", x => x.id); + table.PrimaryKey("pk_archive_operations", x => new { x.request_id, x.archive_type, x.is_archive }); }); - migrationBuilder.CreateIndex( - name: "ix_archive_operations_request_id_archive_type_is_archive", - table: "ArchiveOperations", - columns: new[] { "request_id", "archive_type", "is_archive" }, - unique: true); - migrationBuilder.CreateIndex( name: "ix_archive_operations_started", table: "ArchiveOperations", diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.Designer.cs similarity index 97% rename from src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.Designer.cs rename to src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.Designer.cs index 0fa087f1ad..9e54562cbf 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.Designer.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.Designer.cs @@ -12,7 +12,7 @@ namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations { [DbContext(typeof(SqlServerServiceControlDbContext))] - [Migration("20260803050232_AddArchiveOperations")] + [Migration("20260803054521_AddArchiveOperations")] partial class AddArchiveOperations { /// @@ -27,13 +27,16 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.ArchiveOperationEntity", b => { - b.Property("Id") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); + b.Property("RequestId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); b.Property("ArchiveType") .HasColumnType("int"); + b.Property("IsArchive") + .HasColumnType("bit"); + b.Property("CurrentBatch") .HasColumnType("int"); @@ -49,9 +52,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(450) .HasColumnType("nvarchar(450)"); - b.Property("IsArchive") - .HasColumnType("bit"); - b.Property("NumberOfBatches") .HasColumnType("int"); @@ -62,24 +62,16 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(450) .HasColumnType("nvarchar(450)"); - b.Property("RequestId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - b.Property("Started") .HasColumnType("datetime2"); b.Property("TotalNumberOfMessages") .HasColumnType("int"); - b.HasKey("Id"); + b.HasKey("RequestId", "ArchiveType", "IsArchive"); b.HasIndex("Started"); - b.HasIndex("RequestId", "ArchiveType", "IsArchive") - .IsUnique(); - b.ToTable("ArchiveOperations", (string)null); }); diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.cs similarity index 85% rename from src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.cs rename to src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.cs index f4d8113457..dd324c430d 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803050232_AddArchiveOperations.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.cs @@ -15,11 +15,10 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "ArchiveOperations", columns: table => new { - Id = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), RequestId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - GroupName = table.Column(type: "nvarchar(max)", nullable: false), ArchiveType = table.Column(type: "int", nullable: false), IsArchive = table.Column(type: "bit", nullable: false), + GroupName = table.Column(type: "nvarchar(max)", nullable: false), TotalNumberOfMessages = table.Column(type: "int", nullable: false), NumberOfMessagesProcessed = table.Column(type: "int", nullable: false), NumberOfBatches = table.Column(type: "int", nullable: false), @@ -31,15 +30,9 @@ protected override void Up(MigrationBuilder migrationBuilder) }, constraints: table => { - table.PrimaryKey("PK_ArchiveOperations", x => x.Id); + table.PrimaryKey("PK_ArchiveOperations", x => new { x.RequestId, x.ArchiveType, x.IsArchive }); }); - migrationBuilder.CreateIndex( - name: "IX_ArchiveOperations_RequestId_ArchiveType_IsArchive", - table: "ArchiveOperations", - columns: new[] { "RequestId", "ArchiveType", "IsArchive" }, - unique: true); - migrationBuilder.CreateIndex( name: "IX_ArchiveOperations_Started", table: "ArchiveOperations", diff --git a/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs index 879d9ec8f8..6cc0768ad7 100644 --- a/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs +++ b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs @@ -4,14 +4,11 @@ namespace ServiceControl.Persistence.EFCore.Entities; /// /// Persisted record of an in-progress archive or unarchive operation, enabling resume-after-crash. -/// A single row per (RequestId, ArchiveType, IsArchive) combination; the unique index prevents -/// duplicate concurrent operations. +/// The composite primary key (RequestId, ArchiveType, IsArchive) enforces one operation per +/// group/type/direction at a time — no separate unique index is needed. /// public class ArchiveOperationEntity { - /// Deterministic string PK (e.g. ArchiveOperations/2/{groupId}). - public string Id { get; set; } = null!; - /// The group id (or other request id) being archived/unarchived. public string RequestId { get; set; } = null!; @@ -47,13 +44,4 @@ public class ArchiveOperationEntity /// Audit attribution: the operation id used to correlate per-message audit entries. public string? OperationId { get; set; } - - /// - /// Builds a deterministic primary key for the operation row. - /// - public static string MakeId(string requestId, ArchiveType archiveType, bool isArchive) - { - var prefix = isArchive ? "ArchiveOperations" : "UnarchiveOperations"; - return $"{prefix}/{(int)archiveType}/{requestId}"; - } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs index d17e07a63c..2a1b23f173 100644 --- a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs @@ -10,10 +10,9 @@ public void Configure(EntityTypeBuilder builder) { builder.ToTable("ArchiveOperations"); - builder.HasKey(e => e.Id); - builder.Property(e => e.Id) - .HasMaxLength(200) - .ValueGeneratedNever(); + // Composite primary key — the natural key that distinguishes one operation from another. + // This also serves as the uniqueness constraint: one operation per (RequestId, ArchiveType, IsArchive). + builder.HasKey(e => new { e.RequestId, e.ArchiveType, e.IsArchive }); builder.Property(e => e.RequestId).HasMaxLength(64).IsRequired(); builder.Property(e => e.GroupName).IsRequired(); @@ -29,10 +28,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.InitiatedByName).HasMaxLength(ColumnLengths.ShortTextLength); builder.Property(e => e.OperationId).HasMaxLength(ColumnLengths.ShortTextLength); - // Enforce one operation per (RequestId, ArchiveType, IsArchive) at a time - builder.HasIndex(e => new { e.RequestId, e.ArchiveType, e.IsArchive }) - .IsUnique(); - // Non-unique index for cleanup / diagnostics queries builder.HasIndex(e => e.Started); } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs index 000f7e19a6..e81ff7af8b 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs @@ -40,13 +40,12 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul string? auditOperationId; // ── Load-or-create operation row ── - var operationId_str = ArchiveOperationEntity.MakeId(groupId, ArchiveType.FailureGroup, isArchive: true); - - using (var scope = scopeFactory.CreateAsyncScope()) + await using (var scope = scopeFactory.CreateAsyncScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); - operationEntity = await dbContext.ArchiveOperations.FindAsync(operationId_str); + operationEntity = await dbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, true); if (operationEntity != null) { @@ -56,7 +55,7 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul else { // New operation: get group details - var (count, groupName) = await ArchiveQueryHelper.GetGroupDetailsForArchive(dbContext, groupId); + var (count, groupName) = await ArchiveQueryHelper.GetGroupDetails(dbContext, groupId, FailedMessageStatus.Unresolved); if (count == 0) { @@ -66,7 +65,6 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul operationEntity = new ArchiveOperationEntity { - Id = operationId_str, RequestId = groupId, GroupName = groupName, ArchiveType = ArchiveType.FailureGroup, @@ -91,7 +89,8 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul catch (DbUpdateException ex) when (dbContext.IsDuplicateKeyException(ex)) { // Another handler beat us to it — load the existing operation - operationEntity = await dbContext.ArchiveOperations.FindAsync(operationId_str); + operationEntity = await dbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, true); logger.LogInformation("Archive operation for group {GroupId} already in progress, resuming at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity!.CurrentBatch, operationEntity.NumberOfBatches); } } @@ -105,77 +104,66 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul await archivingManager.StartArchiving(operationEntity!); // ── Batch loop ── - var lastProcessedId = Guid.Empty; - - // If resuming, try to reconstruct lastProcessedId from the number of already-processed messages. - // We can't know the exact IDs that were already processed, so we start from the beginning. - // The re-asserted Status == Unresolved filter ensures already-archived messages are skipped. - // This is safe because already-archived messages have Status = Archived and won't match the filter. - - while (operationEntity!.CurrentBatch < operationEntity!.NumberOfBatches) + // Each iteration queries the first batchSize messages that still have Status = Unresolved. + // Archiving them sets Status = Archived, so the next query naturally skips them — the + // status change IS the cursor. No lastProcessedId needed. On resume after a crash, the + // loop simply starts again; already-archived messages don't match the status filter. + // The loop terminates when a query returns fewer than batchSize messages (the last + // partial batch) or zero (nothing left). + + while (true) { using var batchScope = scopeFactory.CreateAsyncScope(); var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); var batchIds = await ArchiveQueryHelper.GetNextBatchOfMessageIds( - batchDbContext, groupId, FailedMessageStatus.Unresolved, lastProcessedId, batchSize); + batchDbContext, groupId, FailedMessageStatus.Unresolved, batchSize); if (batchIds.Count == 0) { - // All messages already archived or group changed - logger.LogWarning("Attempting to archive a batch ({CurrentBatch}/{NumberOfBatches}) which appears to already have been archived", operationEntity!.CurrentBatch, operationEntity!.NumberOfBatches); + break; // No more unresolved messages in the group } - else - { - logger.LogInformation("Archiving {MessageCount} messages from group {GroupId} starting", batchIds.Count, groupId); - var now = DateTime.UtcNow; + logger.LogInformation("Archiving {MessageCount} messages from group {GroupId} starting", batchIds.Count, groupId); - // Bulk status change with re-asserted status filter - var affectedCount = await batchDbContext.FailedMessages - .Where(fm => batchIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Unresolved) - .ExecuteUpdateAsync(s => s - .SetProperty(fm => fm.Status, FailedMessageStatus.Archived) - .SetProperty(fm => fm.StatusChangedAt, now) - .SetProperty(fm => fm.LastModified, now)); + var now = DateTime.UtcNow; - lastProcessedId = batchIds[^1]; + // Bulk status change with re-asserted status filter + var affectedCount = await batchDbContext.FailedMessages + .Where(fm => batchIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Unresolved) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, FailedMessageStatus.Archived) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now)); - await archivingManager.BatchArchived(groupId, ArchiveType.FailureGroup, affectedCount); + await archivingManager.BatchArchived(groupId, ArchiveType.FailureGroup, affectedCount); - // Update persisted operation entity - var persistedEntity = await batchDbContext.ArchiveOperations.FindAsync(operationId_str); - if (persistedEntity != null) - { - persistedEntity.CurrentBatch++; - persistedEntity.NumberOfMessagesProcessed += affectedCount; - await batchDbContext.SaveChangesAsync(); - operationEntity = persistedEntity; - } + // Update persisted operation entity for progress tracking + var persistedEntity = await batchDbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, true); + if (persistedEntity != null) + { + persistedEntity.CurrentBatch++; + persistedEntity.NumberOfMessagesProcessed += affectedCount; + await batchDbContext.SaveChangesAsync(); + operationEntity = persistedEntity; + } - // Raise batch domain event - var messageIds = batchIds.Select(id => id.ToString()).ToArray(); - await domainEvents.Raise(new FailedMessageGroupBatchArchived - { - FailedMessagesIds = messageIds - }); + // Raise batch domain event + var messageIds = batchIds.Select(id => id.ToString()).ToArray(); + await domainEvents.Raise(new FailedMessageGroupBatchArchived + { + FailedMessagesIds = messageIds + }); - // Per-message audit - AuditArchivedMessages(MessageActionKind.Archive, Permissions.ErrorRecoverabilityGroupsArchive, auditUser, auditOperationId, messageIds); + // Per-message audit + AuditArchivedMessages(MessageActionKind.Archive, Permissions.ErrorRecoverabilityGroupsArchive, auditUser, auditOperationId, messageIds); - logger.LogInformation("Archiving of {MessageCount} messages from group {GroupId} completed", batchIds.Count, groupId); - } + logger.LogInformation("Archiving of {MessageCount} messages from group {GroupId} completed", batchIds.Count, groupId); - // If batch was empty, still increment to avoid infinite loop - if (batchIds.Count == 0) + if (batchIds.Count < batchSize) { - var persistedEntity = await batchDbContext.ArchiveOperations.FindAsync(operationId_str); - if (persistedEntity != null) - { - persistedEntity.CurrentBatch++; - await batchDbContext.SaveChangesAsync(); - operationEntity = persistedEntity; - } + break; // Last partial batch — no more messages after these } } @@ -191,7 +179,8 @@ await domainEvents.Raise(new FailedMessageGroupBatchArchived using (var finalizeScope = scopeFactory.CreateAsyncScope()) { var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); - var entity = await finalizeDbContext.ArchiveOperations.FindAsync(operationId_str); + var entity = await finalizeDbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, true); if (entity != null) { finalizeDbContext.ArchiveOperations.Remove(entity); @@ -218,13 +207,12 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n string? auditOperationId; // ── Load-or-create operation row ── - var operationId_str = ArchiveOperationEntity.MakeId(groupId, ArchiveType.FailureGroup, isArchive: false); - using (var scope = scopeFactory.CreateAsyncScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); - operationEntity = await dbContext.ArchiveOperations.FindAsync(operationId_str); + operationEntity = await dbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, false); if (operationEntity != null) { @@ -234,7 +222,7 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n else { // New operation: get group details - var (count, groupName) = await ArchiveQueryHelper.GetGroupDetailsForUnarchive(dbContext, groupId); + var (count, groupName) = await ArchiveQueryHelper.GetGroupDetails(dbContext, groupId, FailedMessageStatus.Archived, (CancellationToken)default); if (count == 0) { @@ -244,7 +232,6 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n operationEntity = new ArchiveOperationEntity { - Id = operationId_str, RequestId = groupId, GroupName = groupName, ArchiveType = ArchiveType.FailureGroup, @@ -269,7 +256,8 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n catch (DbUpdateException ex) when (dbContext.IsDuplicateKeyException(ex)) { // Another handler beat us to it — load the existing operation - operationEntity = await dbContext.ArchiveOperations.FindAsync(operationId_str); + operationEntity = await dbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, false); logger.LogInformation("Unarchive operation for group {GroupId} already in progress, resuming at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity!.CurrentBatch, operationEntity.NumberOfBatches); } } @@ -283,71 +271,66 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n await unarchivingManager.StartUnarchiving(operationEntity!); // ── Batch loop ── - var lastProcessedId = Guid.Empty; - - while (operationEntity!.CurrentBatch < operationEntity!.NumberOfBatches) + // Each iteration queries the first batchSize messages that still have Status = Archived. + // Unarchiving them sets Status = Unresolved, so the next query naturally skips them — the + // status change IS the cursor. No lastProcessedId needed. On resume after a crash, the + // loop simply starts again; already-unarchived messages don't match the status filter. + // The loop terminates when a query returns fewer than batchSize messages (the last + // partial batch) or zero (nothing left). + + while (true) { using var batchScope = scopeFactory.CreateAsyncScope(); var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); var batchIds = await ArchiveQueryHelper.GetNextBatchOfMessageIds( - batchDbContext, groupId, FailedMessageStatus.Archived, lastProcessedId, batchSize); + batchDbContext, groupId, FailedMessageStatus.Archived, batchSize); if (batchIds.Count == 0) { - logger.LogWarning("Attempting to unarchive a batch ({CurrentBatch}/{NumberOfBatches}) which appears to already have been unarchived", operationEntity!.CurrentBatch, operationEntity!.NumberOfBatches); + break; // No more archived messages in the group } - else - { - logger.LogInformation("Unarchiving {MessageCount} messages from group {GroupId} starting", batchIds.Count, groupId); - var now = DateTime.UtcNow; + logger.LogInformation("Unarchiving {MessageCount} messages from group {GroupId} starting", batchIds.Count, groupId); - // Bulk status change with re-asserted status filter - var affectedCount = await batchDbContext.FailedMessages - .Where(fm => batchIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Archived) - .ExecuteUpdateAsync(s => s - .SetProperty(fm => fm.Status, FailedMessageStatus.Unresolved) - .SetProperty(fm => fm.StatusChangedAt, now) - .SetProperty(fm => fm.LastModified, now)); + var now = DateTime.UtcNow; - lastProcessedId = batchIds[^1]; + // Bulk status change with re-asserted status filter + var affectedCount = await batchDbContext.FailedMessages + .Where(fm => batchIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Archived) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, FailedMessageStatus.Unresolved) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now)); - await unarchivingManager.BatchUnarchived(groupId, ArchiveType.FailureGroup, affectedCount); + await unarchivingManager.BatchUnarchived(groupId, ArchiveType.FailureGroup, affectedCount); - // Update persisted operation entity - var persistedEntity = await batchDbContext.ArchiveOperations.FindAsync(operationId_str); - if (persistedEntity != null) - { - persistedEntity.CurrentBatch++; - persistedEntity.NumberOfMessagesProcessed += affectedCount; - await batchDbContext.SaveChangesAsync(); - operationEntity = persistedEntity; - } + // Update persisted operation entity for progress tracking + var persistedEntity = await batchDbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, false); + if (persistedEntity != null) + { + persistedEntity.CurrentBatch++; + persistedEntity.NumberOfMessagesProcessed += affectedCount; + await batchDbContext.SaveChangesAsync(); + operationEntity = persistedEntity; + } - // Raise batch domain event - var messageIds = batchIds.Select(id => id.ToString()).ToArray(); - await domainEvents.Raise(new FailedMessageGroupBatchUnarchived - { - FailedMessagesIds = messageIds - }); + // Raise batch domain event + var messageIds = batchIds.Select(id => id.ToString()).ToArray(); + await domainEvents.Raise(new FailedMessageGroupBatchUnarchived + { + FailedMessagesIds = messageIds + }); - // Per-message audit - AuditArchivedMessages(MessageActionKind.Unarchive, Permissions.ErrorRecoverabilityGroupsUnarchive, auditUser, auditOperationId, messageIds); + // Per-message audit + AuditArchivedMessages(MessageActionKind.Unarchive, Permissions.ErrorRecoverabilityGroupsUnarchive, auditUser, auditOperationId, messageIds); - logger.LogInformation("Unarchiving of {MessageCount} messages from group {GroupId} completed", batchIds.Count, groupId); - } + logger.LogInformation("Unarchiving of {MessageCount} messages from group {GroupId} completed", batchIds.Count, groupId); - // If batch was empty, still increment to avoid infinite loop - if (batchIds.Count == 0) + if (batchIds.Count < batchSize) { - var persistedEntity = await batchDbContext.ArchiveOperations.FindAsync(operationId_str); - if (persistedEntity != null) - { - persistedEntity.CurrentBatch++; - await batchDbContext.SaveChangesAsync(); - operationEntity = persistedEntity; - } + break; // Last partial batch — no more messages after these } } @@ -363,7 +346,8 @@ await domainEvents.Raise(new FailedMessageGroupBatchUnarchived using (var finalizeScope = scopeFactory.CreateAsyncScope()) { var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); - var entity = await finalizeDbContext.ArchiveOperations.FindAsync(operationId_str); + var entity = await finalizeDbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, false); if (entity != null) { finalizeDbContext.ArchiveOperations.Remove(entity); diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs new file mode 100644 index 0000000000..511c733e98 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs @@ -0,0 +1,53 @@ +namespace ServiceControl.Persistence.EFCore.Implementation; + +using Microsoft.EntityFrameworkCore; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.EFCore.DbContexts; + +/// +/// Focused query helpers for the archive/unarchive flows: group details (count + name) +/// and keyset-paginated batch selection of message IDs by group + status. +/// +static class ArchiveQueryHelper +{ + internal static async Task<(int count, string groupName)> GetGroupDetails( + ServiceControlDbContext dbContext, string groupId, FailedMessageStatus status, CancellationToken cancellationToken = default) + { + var query = + from fmg in dbContext.FailedMessageGroups + join fm in dbContext.FailedMessages on fmg.FailedMessageUniqueId equals fm.UniqueMessageId + where fmg.GroupId == groupId && fm.Status == status + select new { fmg.Title, fm.UniqueMessageId }; + + var count = await query.CountAsync(cancellationToken); + var groupName = await dbContext.FailedMessageGroups + .Where(fmg => fmg.GroupId == groupId) + .Select(fmg => fmg.Title) + .FirstOrDefaultAsync(cancellationToken) ?? "Undefined"; + + return (count, groupName); + } + + /// + /// Selects the next batch of message IDs in a group with the given status, ordered by + /// UniqueMessageId. No cursor is needed — once a batch is archived (or unarchived), the + /// status change excludes those messages from the next query, so each call naturally + /// returns the next unprocessed batch. + /// + public static async Task> GetNextBatchOfMessageIds( + ServiceControlDbContext dbContext, + string groupId, + FailedMessageStatus status, + int batchSize, + CancellationToken cancellationToken = default) + { + var query = from fmg in dbContext.FailedMessageGroups + join fm in dbContext.FailedMessages on fmg.FailedMessageUniqueId equals fm.UniqueMessageId + where fmg.GroupId == groupId + && fm.Status == status + orderby fm.UniqueMessageId + select fm.UniqueMessageId; + + return await query.Take(batchSize).ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreArchivingManager.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/EFCoreArchivingManager.cs similarity index 100% rename from src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreArchivingManager.cs rename to src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/EFCoreArchivingManager.cs diff --git a/src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreUnarchivingManager.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/EFCoreUnarchivingManager.cs similarity index 100% rename from src/ServiceControl.Persistence.EFCore/Recoverability/EFCoreUnarchivingManager.cs rename to src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/EFCoreUnarchivingManager.cs diff --git a/src/ServiceControl.Persistence.EFCore/Recoverability/ArchiveQueryHelper.cs b/src/ServiceControl.Persistence.EFCore/Recoverability/ArchiveQueryHelper.cs deleted file mode 100644 index ce35555ebd..0000000000 --- a/src/ServiceControl.Persistence.EFCore/Recoverability/ArchiveQueryHelper.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace ServiceControl.Persistence.EFCore.Implementation; - -using Microsoft.EntityFrameworkCore; -using ServiceControl.MessageFailures; -using ServiceControl.Persistence.EFCore.DbContexts; - -/// -/// Focused query helpers for the archive/unarchive flows: group details (count + name) -/// and keyset-paginated batch selection of message IDs by group + status. -/// -static class ArchiveQueryHelper -{ - /// - /// Returns the count of unresolved messages in a group and the group's title. - /// Used when starting an archive operation. - /// - public static async Task<(int count, string groupName)> GetGroupDetailsForArchive( - ServiceControlDbContext dbContext, string groupId, CancellationToken cancellationToken = default) - { - return await GetGroupDetails(dbContext, groupId, FailedMessageStatus.Unresolved, cancellationToken); - } - - /// - /// Returns the count of archived messages in a group and the group's title. - /// Used when starting an unarchive operation. - /// - public static async Task<(int count, string groupName)> GetGroupDetailsForUnarchive( - ServiceControlDbContext dbContext, string groupId, CancellationToken cancellationToken = default) - { - return await GetGroupDetails(dbContext, groupId, FailedMessageStatus.Archived, cancellationToken); - } - - static async Task<(int count, string groupName)> GetGroupDetails( - ServiceControlDbContext dbContext, string groupId, FailedMessageStatus status, CancellationToken cancellationToken) - { - var query = from fmg in dbContext.FailedMessageGroups - join fm in dbContext.FailedMessages on fmg.FailedMessageUniqueId equals fm.UniqueMessageId - where fmg.GroupId == groupId && fm.Status == status - select new { fmg.Title, fm.UniqueMessageId }; - - var count = await query.CountAsync(cancellationToken); - var groupName = await dbContext.FailedMessageGroups - .Where(fmg => fmg.GroupId == groupId) - .Select(fmg => fmg.Title) - .FirstOrDefaultAsync(cancellationToken) ?? "Undefined"; - - return (count, groupName); - } - - /// - /// Keyset-paginated query: selects the next batch of message IDs in a group - /// with the given status, ordered by UniqueMessageId, starting after lastProcessedId. - /// - public static async Task> GetNextBatchOfMessageIds( - ServiceControlDbContext dbContext, - string groupId, - FailedMessageStatus status, - Guid lastProcessedId, - int batchSize, - CancellationToken cancellationToken = default) - { - var query = from fmg in dbContext.FailedMessageGroups - join fm in dbContext.FailedMessages on fmg.FailedMessageUniqueId equals fm.UniqueMessageId - where fmg.GroupId == groupId - && fm.Status == status - && fm.UniqueMessageId > lastProcessedId - orderby fm.UniqueMessageId - select fm.UniqueMessageId; - - return await query.Take(batchSize).ToListAsync(cancellationToken); - } -} \ No newline at end of file From cf3a1fc87cdc44fa1e5d48b6dd112c6062cb8d24 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Mon, 3 Aug 2026 15:28:03 +0800 Subject: [PATCH 04/10] regenerate migration after rebase --- ... => 20260804010839_AddArchive.Designer.cs} | 218 ++++++++++++++++-- ...ations.cs => 20260804010839_AddArchive.cs} | 2 +- ...SqlServiceControlDbContextModelSnapshot.cs | 64 +++++ ... => 20260804010848_AddArchive.Designer.cs} | 172 +++++++++++++- ...ations.cs => 20260804010848_AddArchive.cs} | 2 +- ...verServiceControlDbContextModelSnapshot.cs | 50 ++++ .../Implementation/MessageArchiver.cs | 2 +- .../Recoverability/ArchiveQueryHelper.cs | 3 +- 8 files changed, 484 insertions(+), 29 deletions(-) rename src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/{20260803054523_AddArchiveOperations.Designer.cs => 20260804010839_AddArchive.Designer.cs} (71%) rename src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/{20260803054523_AddArchiveOperations.cs => 20260804010839_AddArchive.cs} (97%) rename src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/{20260803054521_AddArchiveOperations.Designer.cs => 20260804010848_AddArchive.Designer.cs} (72%) rename src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/{20260803054521_AddArchiveOperations.cs => 20260804010848_AddArchive.cs} (97%) diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.Designer.cs similarity index 71% rename from src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.Designer.cs rename to src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.Designer.cs index 8b9aaf4737..ea784832f7 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.Designer.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.Designer.cs @@ -13,8 +13,8 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations { [DbContext(typeof(PostgreSqlServiceControlDbContext))] - [Migration("20260803054523_AddArchiveOperations")] - partial class AddArchiveOperations + [Migration("20260804010839_AddArchive")] + partial class AddArchive { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -90,6 +90,60 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("ArchiveOperations", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text") + .HasColumnName("category"); + + b.Property("CustomCheckId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("custom_check_id"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("OriginatingEndpointHost") + .IsRequired() + .HasColumnType("text") + .HasColumnName("originating_endpoint_host"); + + b.Property("OriginatingEndpointHostId") + .HasColumnType("uuid") + .HasColumnName("originating_endpoint_host_id"); + + b.Property("OriginatingEndpointName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("originating_endpoint_name"); + + b.Property("ReportedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reported_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_custom_checks"); + + b.HasIndex("ReportedAt") + .HasDatabaseName("ix_custom_checks_reported_at"); + + b.HasIndex("Status", "ReportedAt") + .HasDatabaseName("ix_custom_checks_status_reported_at"); + + b.ToTable("custom_checks", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b => { b.Property("Name") @@ -279,11 +333,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("number_of_processing_attempts"); - b.Property("QueueAddress") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("queue_address"); - b.Property("ReceivingEndpointHost") .HasMaxLength(450) .HasColumnType("character varying(450)") @@ -333,9 +382,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("FailingEndpointAddress") .HasDatabaseName("ix_failed_messages_failing_endpoint_address"); - b.HasIndex("QueueAddress") - .HasDatabaseName("ix_failed_messages_queue_address"); - b.HasIndex("ReceivingEndpointName") .HasDatabaseName("ix_failed_messages_receiving_endpoint_name"); @@ -380,6 +426,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("GroupId") .HasDatabaseName("ix_failed_message_groups_group_id"); + b.HasIndex("Type", "GroupId") + .HasDatabaseName("ix_failed_message_groups_type_group_id"); + b.ToTable("failed_message_groups", (string)null); }); @@ -389,17 +438,41 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("unique_message_id"); - b.Property("RetryId") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("retry_id"); + b.Property("RetryBatchId") + .HasColumnType("uuid") + .HasColumnName("retry_batch_id"); + + b.Property("StageAttempts") + .HasColumnType("integer") + .HasColumnName("stage_attempts"); b.HasKey("UniqueMessageId") .HasName("pk_failed_message_retries"); + b.HasIndex("RetryBatchId") + .HasDatabaseName("ix_failed_message_retries_retry_batch_id"); + b.ToTable("failed_message_retries", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.GroupCommentEntity", b => + { + b.Property("GroupId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("group_id"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text") + .HasColumnName("comment"); + + b.HasKey("GroupId") + .HasName("pk_group_comments"); + + b.ToTable("group_comments", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b => { b.Property("Id") @@ -432,6 +505,123 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("known_endpoints", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.MessageRedirectEntity", b => + { + b.Property("FromPhysicalAddress") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("from_physical_address"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("ToPhysicalAddress") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("to_physical_address"); + + b.HasKey("FromPhysicalAddress") + .HasName("pk_message_redirects"); + + b.ToTable("message_redirects", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.RetryBatchEntity", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Classifier") + .HasColumnType("text") + .HasColumnName("classifier"); + + b.Property("Context") + .HasColumnType("text") + .HasColumnName("context"); + + b.Property("InitialBatchSize") + .HasColumnType("integer") + .HasColumnName("initial_batch_size"); + + b.Property("InitiatedById") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("initiated_by_id"); + + b.Property("InitiatedByName") + .HasColumnType("text") + .HasColumnName("initiated_by_name"); + + b.Property("Last") + .HasColumnType("timestamp with time zone") + .HasColumnName("last"); + + b.Property("OperationId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("operation_id"); + + b.Property("Originator") + .HasColumnType("text") + .HasColumnName("originator"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("request_id"); + + b.Property("RetrySessionId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("retry_session_id"); + + b.Property("RetryType") + .HasColumnType("integer") + .HasColumnName("retry_type"); + + b.Property("StagingId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("staging_id"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_retry_batches"); + + b.HasIndex("Status", "RetrySessionId") + .HasDatabaseName("ix_retry_batches_status_retry_session_id"); + + b.ToTable("retry_batches", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.RetryBatchNowForwardingEntity", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.Property("RetryBatchId") + .HasColumnType("uuid") + .HasColumnName("retry_batch_id"); + + b.HasKey("Id") + .HasName("pk_retry_batch_now_forwarding"); + + b.ToTable("retry_batch_now_forwarding", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => { b.Property("MessageType") diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.cs similarity index 97% rename from src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.cs rename to src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.cs index 4df323715c..9bf8a36c94 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803054523_AddArchiveOperations.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.cs @@ -6,7 +6,7 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations { /// - public partial class AddArchiveOperations : Migration + public partial class AddArchive : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs index 8184cac7ba..6005ba1baf 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs @@ -23,6 +23,70 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.ArchiveOperationEntity", b => + { + b.Property("RequestId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("request_id"); + + b.Property("ArchiveType") + .HasColumnType("integer") + .HasColumnName("archive_type"); + + b.Property("IsArchive") + .HasColumnType("boolean") + .HasColumnName("is_archive"); + + b.Property("CurrentBatch") + .HasColumnType("integer") + .HasColumnName("current_batch"); + + b.Property("GroupName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("group_name"); + + b.Property("InitiatedById") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("initiated_by_id"); + + b.Property("InitiatedByName") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("initiated_by_name"); + + b.Property("NumberOfBatches") + .HasColumnType("integer") + .HasColumnName("number_of_batches"); + + b.Property("NumberOfMessagesProcessed") + .HasColumnType("integer") + .HasColumnName("number_of_messages_processed"); + + b.Property("OperationId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("operation_id"); + + b.Property("Started") + .HasColumnType("timestamp with time zone") + .HasColumnName("started"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("integer") + .HasColumnName("total_number_of_messages"); + + b.HasKey("RequestId", "ArchiveType", "IsArchive") + .HasName("pk_archive_operations"); + + b.HasIndex("Started") + .HasDatabaseName("ix_archive_operations_started"); + + b.ToTable("ArchiveOperations", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.Designer.cs similarity index 72% rename from src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.Designer.cs rename to src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.Designer.cs index 9e54562cbf..d18c8f29fd 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.Designer.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.Designer.cs @@ -12,8 +12,8 @@ namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations { [DbContext(typeof(SqlServerServiceControlDbContext))] - [Migration("20260803054521_AddArchiveOperations")] - partial class AddArchiveOperations + [Migration("20260804010848_AddArchive")] + partial class AddArchive { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -75,6 +75,48 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("ArchiveOperations", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CustomCheckId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginatingEndpointHost") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OriginatingEndpointHostId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginatingEndpointName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReportedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ReportedAt"); + + b.HasIndex("Status", "ReportedAt"); + + b.ToTable("CustomChecks"); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b => { b.Property("Name") @@ -225,10 +267,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("NumberOfProcessingAttempts") .HasColumnType("int"); - b.Property("QueueAddress") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - b.Property("ReceivingEndpointHost") .HasMaxLength(450) .HasColumnType("nvarchar(450)"); @@ -266,8 +304,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("FailingEndpointAddress"); - b.HasIndex("QueueAddress"); - b.HasIndex("ReceivingEndpointName"); b.HasIndex("StatusChangedAt") @@ -302,6 +338,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("GroupId"); + b.HasIndex("Type", "GroupId"); + b.ToTable("FailedMessageGroups"); }); @@ -310,15 +348,34 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("UniqueMessageId") .HasColumnType("uniqueidentifier"); - b.Property("RetryId") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); + b.Property("RetryBatchId") + .HasColumnType("uniqueidentifier"); + + b.Property("StageAttempts") + .HasColumnType("int"); b.HasKey("UniqueMessageId"); + b.HasIndex("RetryBatchId"); + b.ToTable("FailedMessageRetries"); }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.GroupCommentEntity", b => + { + b.Property("GroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("GroupId"); + + b.ToTable("GroupComments"); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b => { b.Property("Id") @@ -345,6 +402,99 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("KnownEndpoints"); }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.MessageRedirectEntity", b => + { + b.Property("FromPhysicalAddress") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("ToPhysicalAddress") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("FromPhysicalAddress"); + + b.ToTable("MessageRedirects"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.RetryBatchEntity", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Classifier") + .HasColumnType("nvarchar(max)"); + + b.Property("Context") + .HasColumnType("nvarchar(max)"); + + b.Property("InitialBatchSize") + .HasColumnType("int"); + + b.Property("InitiatedById") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("InitiatedByName") + .HasColumnType("nvarchar(max)"); + + b.Property("Last") + .HasColumnType("datetime2"); + + b.Property("OperationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Originator") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("RetrySessionId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("RetryType") + .HasColumnType("int"); + + b.Property("StagingId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Status", "RetrySessionId"); + + b.ToTable("RetryBatches"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.RetryBatchNowForwardingEntity", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("RetryBatchId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.ToTable("RetryBatchNowForwarding"); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => { b.Property("MessageType") diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.cs similarity index 97% rename from src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.cs rename to src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.cs index dd324c430d..2a1203e34f 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803054521_AddArchiveOperations.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.cs @@ -6,7 +6,7 @@ namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations { /// - public partial class AddArchiveOperations : Migration + public partial class AddArchive : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs index e2c68d1c5b..acb0513589 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs @@ -22,6 +22,56 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.ArchiveOperationEntity", b => + { + b.Property("RequestId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ArchiveType") + .HasColumnType("int"); + + b.Property("IsArchive") + .HasColumnType("bit"); + + b.Property("CurrentBatch") + .HasColumnType("int"); + + b.Property("GroupName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("InitiatedById") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("InitiatedByName") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("NumberOfBatches") + .HasColumnType("int"); + + b.Property("NumberOfMessagesProcessed") + .HasColumnType("int"); + + b.Property("OperationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Started") + .HasColumnType("datetime2"); + + b.Property("TotalNumberOfMessages") + .HasColumnType("int"); + + b.HasKey("RequestId", "ArchiveType", "IsArchive"); + + b.HasIndex("Started"); + + b.ToTable("ArchiveOperations", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs index e81ff7af8b..4c6dab4640 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs @@ -222,7 +222,7 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n else { // New operation: get group details - var (count, groupName) = await ArchiveQueryHelper.GetGroupDetails(dbContext, groupId, FailedMessageStatus.Archived, (CancellationToken)default); + var (count, groupName) = await ArchiveQueryHelper.GetGroupDetails(dbContext, groupId, FailedMessageStatus.Archived); if (count == 0) { diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs index 511c733e98..67c19b699d 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs @@ -15,7 +15,8 @@ static class ArchiveQueryHelper { var query = from fmg in dbContext.FailedMessageGroups - join fm in dbContext.FailedMessages on fmg.FailedMessageUniqueId equals fm.UniqueMessageId + join fm in dbContext.FailedMessages + on fmg.FailedMessageUniqueId equals fm.UniqueMessageId where fmg.GroupId == groupId && fm.Status == status select new { fmg.Title, fm.UniqueMessageId }; From 443a12d2a640ff77a646c15f1b61704c8ae55976 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Tue, 4 Aug 2026 13:32:22 +0800 Subject: [PATCH 05/10] Move code around for readability, change boolean to enum --- ...20260803010136_AddCustomChecks.Designer.cs | 486 ------------------ .../Migrations/20260804010839_AddArchive.cs | 49 -- ...0804052012_AddArchiveEntities.Designer.cs} | 14 +- ...s => 20260804052012_AddArchiveEntities.cs} | 32 +- ...SqlServiceControlDbContextModelSnapshot.cs | 10 +- ...20260803010143_AddCustomChecks.Designer.cs | 393 -------------- .../20260803010143_AddCustomChecks.cs | 51 -- ...0804052028_AddArchiveEntities.Designer.cs} | 12 +- ...s => 20260804052028_AddArchiveEntities.cs} | 38 +- ...verServiceControlDbContextModelSnapshot.cs | 8 +- .../Abstractions/BasePersistence.cs | 1 + .../Entities/ArchiveOperationEntity.cs | 4 +- .../Entities/ArchiveOperationType.cs | 7 + .../ArchiveOperationConfiguration.cs | 8 +- .../Implementation/MessageArchiver.cs | 411 --------------- .../Recoverability/ArchiveQueryHelper.cs | 54 -- .../Recoverability/MessageArchiver.cs | 348 +++++++++++++ 17 files changed, 449 insertions(+), 1477 deletions(-) delete mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803010136_AddCustomChecks.Designer.cs delete mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.cs rename src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/{20260804010839_AddArchive.Designer.cs => 20260804052012_AddArchiveEntities.Designer.cs} (98%) rename src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/{20260803010136_AddCustomChecks.cs => 20260804052012_AddArchiveEntities.cs} (51%) delete mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803010143_AddCustomChecks.Designer.cs delete mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803010143_AddCustomChecks.cs rename src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/{20260804010848_AddArchive.Designer.cs => 20260804052028_AddArchiveEntities.Designer.cs} (98%) rename src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/{20260804010848_AddArchive.cs => 20260804052028_AddArchiveEntities.cs} (53%) create mode 100644 src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationType.cs delete mode 100644 src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs delete mode 100644 src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803010136_AddCustomChecks.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803010136_AddCustomChecks.Designer.cs deleted file mode 100644 index 5b84e3f762..0000000000 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803010136_AddCustomChecks.Designer.cs +++ /dev/null @@ -1,486 +0,0 @@ -// -using System; -using System.Collections.Generic; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using ServiceControl.Persistence.EFCore.PostgreSql; - -#nullable disable - -namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations -{ - [DbContext(typeof(PostgreSqlServiceControlDbContext))] - [Migration("20260803010136_AddCustomChecks")] - partial class AddCustomChecks - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.10") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("Category") - .IsRequired() - .HasColumnType("text") - .HasColumnName("category"); - - b.Property("CustomCheckId") - .IsRequired() - .HasColumnType("text") - .HasColumnName("custom_check_id"); - - b.Property("FailureReason") - .HasColumnType("text") - .HasColumnName("failure_reason"); - - b.Property("OriginatingEndpointHost") - .IsRequired() - .HasColumnType("text") - .HasColumnName("originating_endpoint_host"); - - b.Property("OriginatingEndpointHostId") - .HasColumnType("uuid") - .HasColumnName("originating_endpoint_host_id"); - - b.Property("OriginatingEndpointName") - .IsRequired() - .HasColumnType("text") - .HasColumnName("originating_endpoint_name"); - - b.Property("ReportedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("reported_at"); - - b.Property("Status") - .HasColumnType("integer") - .HasColumnName("status"); - - b.HasKey("Id") - .HasName("pk_custom_checks"); - - b.HasIndex("ReportedAt") - .HasDatabaseName("ix_custom_checks_reported_at"); - - b.HasIndex("Status", "ReportedAt") - .HasDatabaseName("ix_custom_checks_status_reported_at"); - - b.ToTable("custom_checks", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b => - { - b.Property("Name") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("name"); - - b.Property("TrackInstances") - .HasColumnType("boolean") - .HasColumnName("track_instances"); - - b.HasKey("Name") - .HasName("pk_endpoint_settings"); - - b.ToTable("endpoint_settings", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EventLogItemEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("category"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text") - .HasColumnName("description"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("event_type"); - - b.Property("RaisedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("raised_at"); - - b.PrimitiveCollection>("RelatedTo") - .IsRequired() - .HasColumnType("text[]") - .HasColumnName("related_to"); - - b.Property("Severity") - .HasColumnType("integer") - .HasColumnName("severity"); - - b.HasKey("Id") - .HasName("pk_event_log_items"); - - b.HasIndex("RaisedAt", "Id") - .IsDescending() - .HasDatabaseName("ix_event_log_items_raised_at_id"); - - b.ToTable("EventLogItems", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedErrorImportEntity", b => - { - b.Property("UniqueMessageId") - .HasColumnType("uuid") - .HasColumnName("unique_message_id"); - - b.Property("Body") - .IsRequired() - .HasColumnType("bytea") - .HasColumnName("body"); - - b.Property("BodyStoredExternally") - .HasColumnType("boolean") - .HasColumnName("body_stored_externally"); - - b.Property("ExceptionInfo") - .IsRequired() - .HasColumnType("text") - .HasColumnName("exception_info"); - - b.Property("FailedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("failed_at"); - - b.Property("HeadersJson") - .IsRequired() - .HasColumnType("text") - .HasColumnName("headers_json"); - - b.Property("MessageId") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("message_id"); - - b.HasKey("UniqueMessageId") - .HasName("pk_failed_error_imports"); - - b.HasIndex("FailedAt") - .HasDatabaseName("ix_failed_error_imports_failed_at"); - - b.ToTable("failed_error_imports", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", b => - { - b.Property("UniqueMessageId") - .HasColumnType("uuid") - .HasColumnName("unique_message_id"); - - b.Property("BodyContentType") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("body_content_type"); - - b.Property("BodySize") - .HasColumnType("integer") - .HasColumnName("body_size"); - - b.Property("BodyStoredExternally") - .HasColumnType("boolean") - .HasColumnName("body_stored_externally"); - - b.Property("BodyText") - .HasColumnType("text") - .HasColumnName("body_text"); - - b.Property("ConversationId") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("conversation_id"); - - b.Property("ExceptionMessage") - .HasColumnType("text") - .HasColumnName("exception_message"); - - b.Property("ExceptionType") - .HasColumnType("text") - .HasColumnName("exception_type"); - - b.Property("FailingEndpointAddress") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("failing_endpoint_address"); - - b.Property("FirstTimeOfFailure") - .HasColumnType("timestamp with time zone") - .HasColumnName("first_time_of_failure"); - - b.Property("HeadersJson") - .IsRequired() - .HasColumnType("text") - .HasColumnName("headers_json"); - - b.Property("IsSystemMessage") - .HasColumnType("boolean") - .HasColumnName("is_system_message"); - - b.Property("LastAttemptedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_attempted_at"); - - b.Property("LastModified") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_modified"); - - b.Property("LastTimeOfFailure") - .HasColumnType("timestamp with time zone") - .HasColumnName("last_time_of_failure"); - - b.Property("MessageId") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("message_id"); - - b.Property("MessageType") - .HasColumnType("text") - .HasColumnName("message_type"); - - b.Property("NumberOfProcessingAttempts") - .HasColumnType("integer") - .HasColumnName("number_of_processing_attempts"); - - b.Property("QueueAddress") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("queue_address"); - - b.Property("ReceivingEndpointHost") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("receiving_endpoint_host"); - - b.Property("ReceivingEndpointHostId") - .HasColumnType("uuid") - .HasColumnName("receiving_endpoint_host_id"); - - b.Property("ReceivingEndpointName") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("receiving_endpoint_name"); - - b.Property("SendingEndpointHost") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("sending_endpoint_host"); - - b.Property("SendingEndpointHostId") - .HasColumnType("uuid") - .HasColumnName("sending_endpoint_host_id"); - - b.Property("SendingEndpointName") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("sending_endpoint_name"); - - b.Property("Status") - .HasColumnType("integer") - .HasColumnName("status"); - - b.Property("StatusChangedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("status_changed_at"); - - b.Property("TimeSent") - .HasColumnType("timestamp with time zone") - .HasColumnName("time_sent"); - - b.HasKey("UniqueMessageId") - .HasName("pk_failed_messages"); - - b.HasIndex("ConversationId") - .HasDatabaseName("ix_failed_messages_conversation_id"); - - b.HasIndex("FailingEndpointAddress") - .HasDatabaseName("ix_failed_messages_failing_endpoint_address"); - - b.HasIndex("QueueAddress") - .HasDatabaseName("ix_failed_messages_queue_address"); - - b.HasIndex("ReceivingEndpointName") - .HasDatabaseName("ix_failed_messages_receiving_endpoint_name"); - - b.HasIndex("StatusChangedAt") - .HasDatabaseName("ix_failed_messages_status_changed_at") - .HasFilter("status IN (2, 4)"); - - b.HasIndex("TimeSent") - .HasDatabaseName("ix_failed_messages_time_sent"); - - b.HasIndex("Status", "LastModified") - .HasDatabaseName("ix_failed_messages_status_last_modified"); - - b.ToTable("failed_messages", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => - { - b.Property("FailedMessageUniqueId") - .HasColumnType("uuid") - .HasColumnName("failed_message_unique_id"); - - b.Property("GroupId") - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasColumnName("group_id"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text") - .HasColumnName("title"); - - b.Property("Type") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)") - .HasColumnName("type"); - - b.HasKey("FailedMessageUniqueId", "GroupId") - .HasName("pk_failed_message_groups"); - - b.HasIndex("GroupId") - .HasDatabaseName("ix_failed_message_groups_group_id"); - - b.ToTable("failed_message_groups", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageRetryEntity", b => - { - b.Property("UniqueMessageId") - .HasColumnType("uuid") - .HasColumnName("unique_message_id"); - - b.Property("RetryId") - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("retry_id"); - - b.HasKey("UniqueMessageId") - .HasName("pk_failed_message_retries"); - - b.ToTable("failed_message_retries", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b => - { - b.Property("Id") - .HasColumnType("uuid") - .HasColumnName("id"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("host"); - - b.Property("HostId") - .HasColumnType("uuid") - .HasColumnName("host_id"); - - b.Property("Monitored") - .HasColumnType("boolean") - .HasColumnName("monitored"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("name"); - - b.HasKey("Id") - .HasName("pk_known_endpoints"); - - b.ToTable("known_endpoints", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => - { - b.Property("MessageType") - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("message_type"); - - b.Property("TransportAddress") - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasColumnName("transport_address"); - - b.Property("Endpoint") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("character varying(450)") - .HasColumnName("endpoint"); - - b.HasKey("MessageType", "TransportAddress") - .HasName("pk_subscriptions"); - - b.ToTable("subscriptions", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("TrialEndDate") - .HasColumnType("date") - .HasColumnName("trial_end_date"); - - b.HasKey("Id") - .HasName("pk_trial_metadata"); - - b.ToTable("trial_metadata", (string)null); - - b.HasData( - new - { - Id = 1 - }); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => - { - b.HasOne("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", null) - .WithMany() - .HasForeignKey("FailedMessageUniqueId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired() - .HasConstraintName("fk_failed_message_groups_failed_messages_failed_message_unique"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.cs deleted file mode 100644 index 9bf8a36c94..0000000000 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations -{ - /// - public partial class AddArchive : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ArchiveOperations", - columns: table => new - { - request_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), - archive_type = table.Column(type: "integer", nullable: false), - is_archive = table.Column(type: "boolean", nullable: false), - group_name = table.Column(type: "text", nullable: false), - total_number_of_messages = table.Column(type: "integer", nullable: false), - number_of_messages_processed = table.Column(type: "integer", nullable: false), - number_of_batches = table.Column(type: "integer", nullable: false), - current_batch = table.Column(type: "integer", nullable: false), - started = table.Column(type: "timestamp with time zone", nullable: false), - initiated_by_id = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), - initiated_by_name = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), - operation_id = table.Column(type: "character varying(450)", maxLength: 450, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("pk_archive_operations", x => new { x.request_id, x.archive_type, x.is_archive }); - }); - - migrationBuilder.CreateIndex( - name: "ix_archive_operations_started", - table: "ArchiveOperations", - column: "started"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ArchiveOperations"); - } - } -} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804052012_AddArchiveEntities.Designer.cs similarity index 98% rename from src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.Designer.cs rename to src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804052012_AddArchiveEntities.Designer.cs index ea784832f7..eb43ef1626 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804010839_AddArchive.Designer.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804052012_AddArchiveEntities.Designer.cs @@ -13,8 +13,8 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations { [DbContext(typeof(PostgreSqlServiceControlDbContext))] - [Migration("20260804010839_AddArchive")] - partial class AddArchive + [Migration("20260804052012_AddArchiveEntities")] + partial class AddArchiveEntities { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -37,9 +37,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("archive_type"); - b.Property("IsArchive") - .HasColumnType("boolean") - .HasColumnName("is_archive"); + b.Property("OperationType") + .HasColumnType("integer") + .HasColumnName("operation_type"); b.Property("CurrentBatch") .HasColumnType("integer") @@ -81,13 +81,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("total_number_of_messages"); - b.HasKey("RequestId", "ArchiveType", "IsArchive") + b.HasKey("RequestId", "ArchiveType", "OperationType") .HasName("pk_archive_operations"); b.HasIndex("Started") .HasDatabaseName("ix_archive_operations_started"); - b.ToTable("ArchiveOperations", (string)null); + b.ToTable("archive_operations", (string)null); }); modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803010136_AddCustomChecks.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804052012_AddArchiveEntities.cs similarity index 51% rename from src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803010136_AddCustomChecks.cs rename to src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804052012_AddArchiveEntities.cs index c939f66397..fc11707147 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260803010136_AddCustomChecks.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260804052012_AddArchiveEntities.cs @@ -6,11 +6,33 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations { /// - public partial class AddCustomChecks : Migration + public partial class AddArchiveEntities : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.CreateTable( + name: "archive_operations", + columns: table => new + { + request_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + archive_type = table.Column(type: "integer", nullable: false), + operation_type = table.Column(type: "integer", nullable: false), + group_name = table.Column(type: "text", nullable: false), + total_number_of_messages = table.Column(type: "integer", nullable: false), + number_of_messages_processed = table.Column(type: "integer", nullable: false), + number_of_batches = table.Column(type: "integer", nullable: false), + current_batch = table.Column(type: "integer", nullable: false), + started = table.Column(type: "timestamp with time zone", nullable: false), + initiated_by_id = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + initiated_by_name = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + operation_id = table.Column(type: "character varying(450)", maxLength: 450, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_archive_operations", x => new { x.request_id, x.archive_type, x.operation_type }); + }); + migrationBuilder.CreateTable( name: "custom_checks", columns: table => new @@ -30,6 +52,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("pk_custom_checks", x => x.id); }); + migrationBuilder.CreateIndex( + name: "ix_archive_operations_started", + table: "archive_operations", + column: "started"); + migrationBuilder.CreateIndex( name: "ix_custom_checks_reported_at", table: "custom_checks", @@ -44,6 +71,9 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "archive_operations"); + migrationBuilder.DropTable( name: "custom_checks"); } diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs index 6005ba1baf..adee429be3 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs @@ -34,9 +34,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("archive_type"); - b.Property("IsArchive") - .HasColumnType("boolean") - .HasColumnName("is_archive"); + b.Property("OperationType") + .HasColumnType("integer") + .HasColumnName("operation_type"); b.Property("CurrentBatch") .HasColumnType("integer") @@ -78,13 +78,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("total_number_of_messages"); - b.HasKey("RequestId", "ArchiveType", "IsArchive") + b.HasKey("RequestId", "ArchiveType", "OperationType") .HasName("pk_archive_operations"); b.HasIndex("Started") .HasDatabaseName("ix_archive_operations_started"); - b.ToTable("ArchiveOperations", (string)null); + b.ToTable("archive_operations", (string)null); }); modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803010143_AddCustomChecks.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803010143_AddCustomChecks.Designer.cs deleted file mode 100644 index c923ad9b0f..0000000000 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803010143_AddCustomChecks.Designer.cs +++ /dev/null @@ -1,393 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using ServiceControl.Persistence.EFCore.SqlServer; - -#nullable disable - -namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations -{ - [DbContext(typeof(SqlServerServiceControlDbContext))] - [Migration("20260803010143_AddCustomChecks")] - partial class AddCustomChecks - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.10") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => - { - b.Property("Id") - .HasColumnType("uniqueidentifier"); - - b.Property("Category") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("CustomCheckId") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("FailureReason") - .HasColumnType("nvarchar(max)"); - - b.Property("OriginatingEndpointHost") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("OriginatingEndpointHostId") - .HasColumnType("uniqueidentifier"); - - b.Property("OriginatingEndpointName") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ReportedAt") - .HasColumnType("datetime2"); - - b.Property("Status") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("ReportedAt"); - - b.HasIndex("Status", "ReportedAt"); - - b.ToTable("CustomChecks"); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b => - { - b.Property("Name") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("TrackInstances") - .HasColumnType("bit"); - - b.HasKey("Name"); - - b.ToTable("EndpointSettings"); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EventLogItemEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Category") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("Description") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("EventType") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("RaisedAt") - .HasColumnType("datetime2"); - - b.PrimitiveCollection("RelatedTo") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Severity") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("RaisedAt", "Id") - .IsDescending(); - - b.ToTable("EventLogItems", (string)null); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedErrorImportEntity", b => - { - b.Property("UniqueMessageId") - .HasColumnType("uniqueidentifier"); - - b.Property("Body") - .IsRequired() - .HasColumnType("varbinary(max)"); - - b.Property("BodyStoredExternally") - .HasColumnType("bit"); - - b.Property("ExceptionInfo") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("FailedAt") - .HasColumnType("datetime2"); - - b.Property("HeadersJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("MessageId") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.HasKey("UniqueMessageId"); - - b.HasIndex("FailedAt"); - - b.ToTable("FailedErrorImports"); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", b => - { - b.Property("UniqueMessageId") - .HasColumnType("uniqueidentifier"); - - b.Property("BodyContentType") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("BodySize") - .HasColumnType("int"); - - b.Property("BodyStoredExternally") - .HasColumnType("bit"); - - b.Property("BodyText") - .HasColumnType("nvarchar(max)"); - - b.Property("ConversationId") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("ExceptionMessage") - .HasColumnType("nvarchar(max)"); - - b.Property("ExceptionType") - .HasColumnType("nvarchar(max)"); - - b.Property("FailingEndpointAddress") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("FirstTimeOfFailure") - .HasColumnType("datetime2"); - - b.Property("HeadersJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("IsSystemMessage") - .HasColumnType("bit"); - - b.Property("LastAttemptedAt") - .HasColumnType("datetime2"); - - b.Property("LastModified") - .HasColumnType("datetime2"); - - b.Property("LastTimeOfFailure") - .HasColumnType("datetime2"); - - b.Property("MessageId") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("MessageType") - .HasColumnType("nvarchar(max)"); - - b.Property("NumberOfProcessingAttempts") - .HasColumnType("int"); - - b.Property("QueueAddress") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("ReceivingEndpointHost") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("ReceivingEndpointHostId") - .HasColumnType("uniqueidentifier"); - - b.Property("ReceivingEndpointName") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("SendingEndpointHost") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("SendingEndpointHostId") - .HasColumnType("uniqueidentifier"); - - b.Property("SendingEndpointName") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("Status") - .HasColumnType("int"); - - b.Property("StatusChangedAt") - .HasColumnType("datetime2"); - - b.Property("TimeSent") - .HasColumnType("datetime2"); - - b.HasKey("UniqueMessageId"); - - b.HasIndex("ConversationId"); - - b.HasIndex("FailingEndpointAddress"); - - b.HasIndex("QueueAddress"); - - b.HasIndex("ReceivingEndpointName"); - - b.HasIndex("StatusChangedAt") - .HasFilter("[Status] IN (2, 4)"); - - b.HasIndex("TimeSent"); - - b.HasIndex("Status", "LastModified"); - - b.ToTable("FailedMessages"); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => - { - b.Property("FailedMessageUniqueId") - .HasColumnType("uniqueidentifier"); - - b.Property("GroupId") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Title") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Type") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("FailedMessageUniqueId", "GroupId"); - - b.HasIndex("GroupId"); - - b.ToTable("FailedMessageGroups"); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageRetryEntity", b => - { - b.Property("UniqueMessageId") - .HasColumnType("uniqueidentifier"); - - b.Property("RetryId") - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.HasKey("UniqueMessageId"); - - b.ToTable("FailedMessageRetries"); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b => - { - b.Property("Id") - .HasColumnType("uniqueidentifier"); - - b.Property("Host") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.Property("HostId") - .HasColumnType("uniqueidentifier"); - - b.Property("Monitored") - .HasColumnType("bit"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.ToTable("KnownEndpoints"); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => - { - b.Property("MessageType") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("TransportAddress") - .HasMaxLength(200) - .HasColumnType("nvarchar(200)"); - - b.Property("Endpoint") - .IsRequired() - .HasMaxLength(450) - .HasColumnType("nvarchar(450)"); - - b.HasKey("MessageType", "TransportAddress"); - - b.ToTable("Subscriptions"); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("TrialEndDate") - .HasColumnType("date"); - - b.HasKey("Id"); - - b.ToTable("TrialMetadata"); - - b.HasData( - new - { - Id = 1 - }); - }); - - modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => - { - b.HasOne("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", null) - .WithMany() - .HasForeignKey("FailedMessageUniqueId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803010143_AddCustomChecks.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803010143_AddCustomChecks.cs deleted file mode 100644 index d74e29218a..0000000000 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260803010143_AddCustomChecks.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations -{ - /// - public partial class AddCustomChecks : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "CustomChecks", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - CustomCheckId = table.Column(type: "nvarchar(max)", nullable: false), - Category = table.Column(type: "nvarchar(max)", nullable: false), - Status = table.Column(type: "int", nullable: false), - ReportedAt = table.Column(type: "datetime2", nullable: false), - FailureReason = table.Column(type: "nvarchar(max)", nullable: true), - OriginatingEndpointName = table.Column(type: "nvarchar(max)", nullable: false), - OriginatingEndpointHostId = table.Column(type: "uniqueidentifier", nullable: false), - OriginatingEndpointHost = table.Column(type: "nvarchar(max)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CustomChecks", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_CustomChecks_ReportedAt", - table: "CustomChecks", - column: "ReportedAt"); - - migrationBuilder.CreateIndex( - name: "IX_CustomChecks_Status_ReportedAt", - table: "CustomChecks", - columns: new[] { "Status", "ReportedAt" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "CustomChecks"); - } - } -} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804052028_AddArchiveEntities.Designer.cs similarity index 98% rename from src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.Designer.cs rename to src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804052028_AddArchiveEntities.Designer.cs index d18c8f29fd..7e6a395cdf 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.Designer.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804052028_AddArchiveEntities.Designer.cs @@ -12,8 +12,8 @@ namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations { [DbContext(typeof(SqlServerServiceControlDbContext))] - [Migration("20260804010848_AddArchive")] - partial class AddArchive + [Migration("20260804052028_AddArchiveEntities")] + partial class AddArchiveEntities { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -34,8 +34,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ArchiveType") .HasColumnType("int"); - b.Property("IsArchive") - .HasColumnType("bit"); + b.Property("OperationType") + .HasColumnType("int"); b.Property("CurrentBatch") .HasColumnType("int"); @@ -68,11 +68,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("TotalNumberOfMessages") .HasColumnType("int"); - b.HasKey("RequestId", "ArchiveType", "IsArchive"); + b.HasKey("RequestId", "ArchiveType", "OperationType"); b.HasIndex("Started"); - b.ToTable("ArchiveOperations", (string)null); + b.ToTable("ArchiveOperations"); }); modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804052028_AddArchiveEntities.cs similarity index 53% rename from src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.cs rename to src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804052028_AddArchiveEntities.cs index 2a1203e34f..b9b4044806 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804010848_AddArchive.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260804052028_AddArchiveEntities.cs @@ -6,7 +6,7 @@ namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations { /// - public partial class AddArchive : Migration + public partial class AddArchiveEntities : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -17,7 +17,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { RequestId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), ArchiveType = table.Column(type: "int", nullable: false), - IsArchive = table.Column(type: "bit", nullable: false), + OperationType = table.Column(type: "int", nullable: false), GroupName = table.Column(type: "nvarchar(max)", nullable: false), TotalNumberOfMessages = table.Column(type: "int", nullable: false), NumberOfMessagesProcessed = table.Column(type: "int", nullable: false), @@ -30,13 +30,42 @@ protected override void Up(MigrationBuilder migrationBuilder) }, constraints: table => { - table.PrimaryKey("PK_ArchiveOperations", x => new { x.RequestId, x.ArchiveType, x.IsArchive }); + table.PrimaryKey("PK_ArchiveOperations", x => new { x.RequestId, x.ArchiveType, x.OperationType }); + }); + + migrationBuilder.CreateTable( + name: "CustomChecks", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + CustomCheckId = table.Column(type: "nvarchar(max)", nullable: false), + Category = table.Column(type: "nvarchar(max)", nullable: false), + Status = table.Column(type: "int", nullable: false), + ReportedAt = table.Column(type: "datetime2", nullable: false), + FailureReason = table.Column(type: "nvarchar(max)", nullable: true), + OriginatingEndpointName = table.Column(type: "nvarchar(max)", nullable: false), + OriginatingEndpointHostId = table.Column(type: "uniqueidentifier", nullable: false), + OriginatingEndpointHost = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CustomChecks", x => x.Id); }); migrationBuilder.CreateIndex( name: "IX_ArchiveOperations_Started", table: "ArchiveOperations", column: "Started"); + + migrationBuilder.CreateIndex( + name: "IX_CustomChecks_ReportedAt", + table: "CustomChecks", + column: "ReportedAt"); + + migrationBuilder.CreateIndex( + name: "IX_CustomChecks_Status_ReportedAt", + table: "CustomChecks", + columns: new[] { "Status", "ReportedAt" }); } /// @@ -44,6 +73,9 @@ protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( name: "ArchiveOperations"); + + migrationBuilder.DropTable( + name: "CustomChecks"); } } } diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs index acb0513589..833fdf1a08 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs @@ -31,8 +31,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ArchiveType") .HasColumnType("int"); - b.Property("IsArchive") - .HasColumnType("bit"); + b.Property("OperationType") + .HasColumnType("int"); b.Property("CurrentBatch") .HasColumnType("int"); @@ -65,11 +65,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TotalNumberOfMessages") .HasColumnType("int"); - b.HasKey("RequestId", "ArchiveType", "IsArchive"); + b.HasKey("RequestId", "ArchiveType", "OperationType"); b.HasIndex("Started"); - b.ToTable("ArchiveOperations", (string)null); + b.ToTable("ArchiveOperations"); }); modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.CustomCheckEntity", b => diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs index dc3a5bd1ea..1ecac1215b 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; +using Implementation.Recoverability; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; diff --git a/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs index 6cc0768ad7..beb7c1a338 100644 --- a/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs +++ b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs @@ -18,8 +18,8 @@ public class ArchiveOperationEntity /// The type of archive operation (FailureGroup, SingleMessage, etc.). public ArchiveType ArchiveType { get; set; } - /// Distinguishes archive (true) from unarchive (false). - public bool IsArchive { get; set; } + /// Distinguishes archive from unarchive. + public ArchiveOperationType OperationType { get; set; } /// Total number of messages in the group at operation start. public int TotalNumberOfMessages { get; set; } diff --git a/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationType.cs b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationType.cs new file mode 100644 index 0000000000..0cb7984ac8 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationType.cs @@ -0,0 +1,7 @@ +namespace ServiceControl.Persistence.EFCore.Entities; + +public enum ArchiveOperationType +{ + Archive, + UnArchive +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs index 2a1b23f173..a38d4e3867 100644 --- a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ArchiveOperationConfiguration.cs @@ -8,16 +8,14 @@ class ArchiveOperationConfiguration : IEntityTypeConfiguration builder) { - builder.ToTable("ArchiveOperations"); - // Composite primary key — the natural key that distinguishes one operation from another. - // This also serves as the uniqueness constraint: one operation per (RequestId, ArchiveType, IsArchive). - builder.HasKey(e => new { e.RequestId, e.ArchiveType, e.IsArchive }); + // This also serves as the uniqueness constraint: one operation per (RequestId, ArchiveType, OperationType). + builder.HasKey(e => new { e.RequestId, e.ArchiveType, e.OperationType }); builder.Property(e => e.RequestId).HasMaxLength(64).IsRequired(); builder.Property(e => e.GroupName).IsRequired(); builder.Property(e => e.ArchiveType).IsRequired(); - builder.Property(e => e.IsArchive).IsRequired(); + builder.Property(e => e.OperationType).IsRequired(); builder.Property(e => e.TotalNumberOfMessages).IsRequired(); builder.Property(e => e.NumberOfMessagesProcessed).IsRequired(); builder.Property(e => e.NumberOfBatches).IsRequired(); diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs b/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs deleted file mode 100644 index 4c6dab4640..0000000000 --- a/src/ServiceControl.Persistence.EFCore/Implementation/MessageArchiver.cs +++ /dev/null @@ -1,411 +0,0 @@ -namespace ServiceControl.Persistence.EFCore.Implementation; - -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ServiceControl.Infrastructure.Auth; -using ServiceControl.Infrastructure.DomainEvents; -using ServiceControl.MessageFailures; -using ServiceControl.Persistence.EFCore.DbContexts; -using ServiceControl.Persistence.EFCore.Entities; -using ServiceControl.Persistence.Recoverability; -using ServiceControl.Recoverability; - -public class MessageArchiver : IArchiveMessages -{ - public MessageArchiver( - IServiceScopeFactory scopeFactory, - OperationsManager operationsManager, - IDomainEvents domainEvents, - IMessageActionAuditLog auditLog, - ILogger logger - ) - { - this.scopeFactory = scopeFactory; - this.operationsManager = operationsManager; - this.auditLog = auditLog; - this.logger = logger; - this.domainEvents = domainEvents; - - archivingManager = new EFCoreArchivingManager(domainEvents, operationsManager); - unarchivingManager = new EFCoreUnarchivingManager(domainEvents, operationsManager); - } - - public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) - { - logger.LogInformation("Archiving of {GroupId} started", groupId); - - ArchiveOperationEntity? operationEntity; - AuditUser auditUser; - string? auditOperationId; - - // ── Load-or-create operation row ── - await using (var scope = scopeFactory.CreateAsyncScope()) - { - var dbContext = scope.ServiceProvider.GetRequiredService(); - - operationEntity = await dbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, true); - - if (operationEntity != null) - { - // Resume scenario: operation already exists from a previous (possibly crashed) run - logger.LogInformation("Resuming archive operation for group {GroupId} at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity.CurrentBatch, operationEntity.NumberOfBatches); - } - else - { - // New operation: get group details - var (count, groupName) = await ArchiveQueryHelper.GetGroupDetails(dbContext, groupId, FailedMessageStatus.Unresolved); - - if (count == 0) - { - logger.LogWarning("No messages to archive in group {GroupId}", groupId); - return; - } - - operationEntity = new ArchiveOperationEntity - { - RequestId = groupId, - GroupName = groupName, - ArchiveType = ArchiveType.FailureGroup, - IsArchive = true, - TotalNumberOfMessages = count, - NumberOfMessagesProcessed = 0, - NumberOfBatches = (int)Math.Ceiling(count / (float)batchSize), - CurrentBatch = 0, - Started = DateTime.UtcNow, - InitiatedById = initiatedBy?.Id, - InitiatedByName = initiatedBy?.Name, - OperationId = operationId - }; - - dbContext.ArchiveOperations.Add(operationEntity); - - try - { - await dbContext.SaveChangesAsync(); - logger.LogInformation("Group {GroupId} has been split into {NumberOfBatches} batches", groupId, operationEntity.NumberOfBatches); - } - catch (DbUpdateException ex) when (dbContext.IsDuplicateKeyException(ex)) - { - // Another handler beat us to it — load the existing operation - operationEntity = await dbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, true); - logger.LogInformation("Archive operation for group {GroupId} already in progress, resuming at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity!.CurrentBatch, operationEntity.NumberOfBatches); - } - } - - // Capture audit attribution from the persisted entity - auditUser = new AuditUser(operationEntity.InitiatedById ?? AuditUser.AnonymousValue, operationEntity.InitiatedByName ?? AuditUser.AnonymousValue); - auditOperationId = operationEntity.OperationId; - } - - // ── Start in-memory tracking ── - await archivingManager.StartArchiving(operationEntity!); - - // ── Batch loop ── - // Each iteration queries the first batchSize messages that still have Status = Unresolved. - // Archiving them sets Status = Archived, so the next query naturally skips them — the - // status change IS the cursor. No lastProcessedId needed. On resume after a crash, the - // loop simply starts again; already-archived messages don't match the status filter. - // The loop terminates when a query returns fewer than batchSize messages (the last - // partial batch) or zero (nothing left). - - while (true) - { - using var batchScope = scopeFactory.CreateAsyncScope(); - var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); - - var batchIds = await ArchiveQueryHelper.GetNextBatchOfMessageIds( - batchDbContext, groupId, FailedMessageStatus.Unresolved, batchSize); - - if (batchIds.Count == 0) - { - break; // No more unresolved messages in the group - } - - logger.LogInformation("Archiving {MessageCount} messages from group {GroupId} starting", batchIds.Count, groupId); - - var now = DateTime.UtcNow; - - // Bulk status change with re-asserted status filter - var affectedCount = await batchDbContext.FailedMessages - .Where(fm => batchIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Unresolved) - .ExecuteUpdateAsync(s => s - .SetProperty(fm => fm.Status, FailedMessageStatus.Archived) - .SetProperty(fm => fm.StatusChangedAt, now) - .SetProperty(fm => fm.LastModified, now)); - - await archivingManager.BatchArchived(groupId, ArchiveType.FailureGroup, affectedCount); - - // Update persisted operation entity for progress tracking - var persistedEntity = await batchDbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, true); - if (persistedEntity != null) - { - persistedEntity.CurrentBatch++; - persistedEntity.NumberOfMessagesProcessed += affectedCount; - await batchDbContext.SaveChangesAsync(); - operationEntity = persistedEntity; - } - - // Raise batch domain event - var messageIds = batchIds.Select(id => id.ToString()).ToArray(); - await domainEvents.Raise(new FailedMessageGroupBatchArchived - { - FailedMessagesIds = messageIds - }); - - // Per-message audit - AuditArchivedMessages(MessageActionKind.Archive, Permissions.ErrorRecoverabilityGroupsArchive, auditUser, auditOperationId, messageIds); - - logger.LogInformation("Archiving of {MessageCount} messages from group {GroupId} completed", batchIds.Count, groupId); - - if (batchIds.Count < batchSize) - { - break; // Last partial batch — no more messages after these - } - } - - // ── Finalize ── - logger.LogInformation("Archiving of group {GroupId} is complete", groupId); - await archivingManager.ArchiveOperationFinalizing(groupId, ArchiveType.FailureGroup); - - // No wait-for-index step — SQL is immediately consistent - - await archivingManager.ArchiveOperationCompleted(groupId, ArchiveType.FailureGroup); - - // Delete the operation row - using (var finalizeScope = scopeFactory.CreateAsyncScope()) - { - var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); - var entity = await finalizeDbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, true); - if (entity != null) - { - finalizeDbContext.ArchiveOperations.Remove(entity); - await finalizeDbContext.SaveChangesAsync(); - } - } - - await domainEvents.Raise(new FailedMessageGroupArchived - { - GroupId = groupId, - GroupName = operationEntity!.GroupName, - MessagesCount = operationEntity!.TotalNumberOfMessages - }); - - logger.LogInformation("Archiving of group {GroupId} completed", groupId); - } - - public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) - { - logger.LogInformation("Unarchiving of {GroupId} started", groupId); - - ArchiveOperationEntity? operationEntity; - AuditUser auditUser; - string? auditOperationId; - - // ── Load-or-create operation row ── - using (var scope = scopeFactory.CreateAsyncScope()) - { - var dbContext = scope.ServiceProvider.GetRequiredService(); - - operationEntity = await dbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, false); - - if (operationEntity != null) - { - // Resume scenario - logger.LogInformation("Resuming unarchive operation for group {GroupId} at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity.CurrentBatch, operationEntity.NumberOfBatches); - } - else - { - // New operation: get group details - var (count, groupName) = await ArchiveQueryHelper.GetGroupDetails(dbContext, groupId, FailedMessageStatus.Archived); - - if (count == 0) - { - logger.LogWarning("No messages to unarchive in group {GroupId}", groupId); - return; - } - - operationEntity = new ArchiveOperationEntity - { - RequestId = groupId, - GroupName = groupName, - ArchiveType = ArchiveType.FailureGroup, - IsArchive = false, - TotalNumberOfMessages = count, - NumberOfMessagesProcessed = 0, - NumberOfBatches = (int)Math.Ceiling(count / (float)batchSize), - CurrentBatch = 0, - Started = DateTime.UtcNow, - InitiatedById = initiatedBy?.Id, - InitiatedByName = initiatedBy?.Name, - OperationId = operationId - }; - - dbContext.ArchiveOperations.Add(operationEntity); - - try - { - await dbContext.SaveChangesAsync(); - logger.LogInformation("Group {GroupId} has been split into {NumberOfBatches} batches", groupId, operationEntity.NumberOfBatches); - } - catch (DbUpdateException ex) when (dbContext.IsDuplicateKeyException(ex)) - { - // Another handler beat us to it — load the existing operation - operationEntity = await dbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, false); - logger.LogInformation("Unarchive operation for group {GroupId} already in progress, resuming at batch {CurrentBatch}/{NumberOfBatches}", groupId, operationEntity!.CurrentBatch, operationEntity.NumberOfBatches); - } - } - - // Capture audit attribution from the persisted entity - auditUser = new AuditUser(operationEntity.InitiatedById ?? AuditUser.AnonymousValue, operationEntity.InitiatedByName ?? AuditUser.AnonymousValue); - auditOperationId = operationEntity.OperationId; - } - - // ── Start in-memory tracking ── - await unarchivingManager.StartUnarchiving(operationEntity!); - - // ── Batch loop ── - // Each iteration queries the first batchSize messages that still have Status = Archived. - // Unarchiving them sets Status = Unresolved, so the next query naturally skips them — the - // status change IS the cursor. No lastProcessedId needed. On resume after a crash, the - // loop simply starts again; already-unarchived messages don't match the status filter. - // The loop terminates when a query returns fewer than batchSize messages (the last - // partial batch) or zero (nothing left). - - while (true) - { - using var batchScope = scopeFactory.CreateAsyncScope(); - var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); - - var batchIds = await ArchiveQueryHelper.GetNextBatchOfMessageIds( - batchDbContext, groupId, FailedMessageStatus.Archived, batchSize); - - if (batchIds.Count == 0) - { - break; // No more archived messages in the group - } - - logger.LogInformation("Unarchiving {MessageCount} messages from group {GroupId} starting", batchIds.Count, groupId); - - var now = DateTime.UtcNow; - - // Bulk status change with re-asserted status filter - var affectedCount = await batchDbContext.FailedMessages - .Where(fm => batchIds.Contains(fm.UniqueMessageId) && fm.Status == FailedMessageStatus.Archived) - .ExecuteUpdateAsync(s => s - .SetProperty(fm => fm.Status, FailedMessageStatus.Unresolved) - .SetProperty(fm => fm.StatusChangedAt, now) - .SetProperty(fm => fm.LastModified, now)); - - await unarchivingManager.BatchUnarchived(groupId, ArchiveType.FailureGroup, affectedCount); - - // Update persisted operation entity for progress tracking - var persistedEntity = await batchDbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, false); - if (persistedEntity != null) - { - persistedEntity.CurrentBatch++; - persistedEntity.NumberOfMessagesProcessed += affectedCount; - await batchDbContext.SaveChangesAsync(); - operationEntity = persistedEntity; - } - - // Raise batch domain event - var messageIds = batchIds.Select(id => id.ToString()).ToArray(); - await domainEvents.Raise(new FailedMessageGroupBatchUnarchived - { - FailedMessagesIds = messageIds - }); - - // Per-message audit - AuditArchivedMessages(MessageActionKind.Unarchive, Permissions.ErrorRecoverabilityGroupsUnarchive, auditUser, auditOperationId, messageIds); - - logger.LogInformation("Unarchiving of {MessageCount} messages from group {GroupId} completed", batchIds.Count, groupId); - - if (batchIds.Count < batchSize) - { - break; // Last partial batch — no more messages after these - } - } - - // ── Finalize ── - logger.LogInformation("Unarchiving of group {GroupId} is complete", groupId); - await unarchivingManager.UnarchiveOperationFinalizing(groupId, ArchiveType.FailureGroup); - - // No wait-for-index step — SQL is immediately consistent - - await unarchivingManager.UnarchiveOperationCompleted(groupId, ArchiveType.FailureGroup); - - // Delete the operation row - using (var finalizeScope = scopeFactory.CreateAsyncScope()) - { - var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); - var entity = await finalizeDbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, false); - if (entity != null) - { - finalizeDbContext.ArchiveOperations.Remove(entity); - await finalizeDbContext.SaveChangesAsync(); - } - } - - await domainEvents.Raise(new FailedMessageGroupUnarchived - { - GroupId = groupId, - GroupName = operationEntity!.GroupName, - MessagesCount = operationEntity!.TotalNumberOfMessages - }); - - logger.LogInformation("Unarchiving of group {GroupId} completed", groupId); - } - - /// - /// Emits one per-message audit entry for each message in a batch, correlated to the initiating - /// operation. Skipped when no OperationId was captured (e.g. legacy in-flight operations). - /// - void AuditArchivedMessages(MessageActionKind kind, string permission, AuditUser user, string? operationId, string[] messageIds) - { - if (string.IsNullOrEmpty(operationId)) - { - return; - } - - foreach (var messageId in messageIds) - { - auditLog.MessageAction(user, kind, permission, MessageActionScope.Group, messageId, operationId); - } - } - - public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) - => operationsManager.IsOperationInProgressFor(groupId, archiveType); - - public bool IsArchiveInProgressFor(string groupId) - => archivingManager.IsArchiveInProgressFor(groupId); - - public void DismissArchiveOperation(string groupId, ArchiveType archiveType) - => archivingManager.DismissArchiveOperation(groupId, archiveType); - - public Task StartArchiving(string groupId, ArchiveType archiveType) - => archivingManager.StartArchiving(groupId, archiveType); - - public Task StartUnarchiving(string groupId, ArchiveType archiveType) - => unarchivingManager.StartUnarchiving(groupId, archiveType); - - public IEnumerable GetArchivalOperations() - => archivingManager.GetArchivalOperations(); - - readonly IServiceScopeFactory scopeFactory; - readonly OperationsManager operationsManager; - readonly IDomainEvents domainEvents; - readonly IMessageActionAuditLog auditLog; - readonly EFCoreArchivingManager archivingManager; - readonly EFCoreUnarchivingManager unarchivingManager; - readonly ILogger logger; - const int batchSize = 1000; -} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs deleted file mode 100644 index 67c19b699d..0000000000 --- a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/ArchiveQueryHelper.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace ServiceControl.Persistence.EFCore.Implementation; - -using Microsoft.EntityFrameworkCore; -using ServiceControl.MessageFailures; -using ServiceControl.Persistence.EFCore.DbContexts; - -/// -/// Focused query helpers for the archive/unarchive flows: group details (count + name) -/// and keyset-paginated batch selection of message IDs by group + status. -/// -static class ArchiveQueryHelper -{ - internal static async Task<(int count, string groupName)> GetGroupDetails( - ServiceControlDbContext dbContext, string groupId, FailedMessageStatus status, CancellationToken cancellationToken = default) - { - var query = - from fmg in dbContext.FailedMessageGroups - join fm in dbContext.FailedMessages - on fmg.FailedMessageUniqueId equals fm.UniqueMessageId - where fmg.GroupId == groupId && fm.Status == status - select new { fmg.Title, fm.UniqueMessageId }; - - var count = await query.CountAsync(cancellationToken); - var groupName = await dbContext.FailedMessageGroups - .Where(fmg => fmg.GroupId == groupId) - .Select(fmg => fmg.Title) - .FirstOrDefaultAsync(cancellationToken) ?? "Undefined"; - - return (count, groupName); - } - - /// - /// Selects the next batch of message IDs in a group with the given status, ordered by - /// UniqueMessageId. No cursor is needed — once a batch is archived (or unarchived), the - /// status change excludes those messages from the next query, so each call naturally - /// returns the next unprocessed batch. - /// - public static async Task> GetNextBatchOfMessageIds( - ServiceControlDbContext dbContext, - string groupId, - FailedMessageStatus status, - int batchSize, - CancellationToken cancellationToken = default) - { - var query = from fmg in dbContext.FailedMessageGroups - join fm in dbContext.FailedMessages on fmg.FailedMessageUniqueId equals fm.UniqueMessageId - where fmg.GroupId == groupId - && fm.Status == status - orderby fm.UniqueMessageId - select fm.UniqueMessageId; - - return await query.Take(batchSize).ToListAsync(cancellationToken); - } -} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs new file mode 100644 index 0000000000..ea8cc95ecd --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs @@ -0,0 +1,348 @@ +namespace ServiceControl.Persistence.EFCore.Implementation.Recoverability; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ServiceControl.Infrastructure.Auth; +using ServiceControl.Infrastructure.DomainEvents; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.EFCore.DbContexts; +using ServiceControl.Persistence.EFCore.Entities; +using ServiceControl.Persistence.Recoverability; +using ServiceControl.Recoverability; + +public class MessageArchiver : IArchiveMessages +{ + public MessageArchiver( + IServiceScopeFactory scopeFactory, + OperationsManager operationsManager, + IDomainEvents domainEvents, + IMessageActionAuditLog auditLog, + ILogger logger + ) + { + this.scopeFactory = scopeFactory; + this.operationsManager = operationsManager; + this.auditLog = auditLog; + this.logger = logger; + this.domainEvents = domainEvents; + + archivingManager = new EFCoreArchivingManager(domainEvents, operationsManager); + unarchivingManager = new EFCoreUnarchivingManager(domainEvents, operationsManager); + } + + public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) + { + logger.LogInformation("Archiving of {GroupId} started", groupId); + + ArchiveOperationEntity? operationEntity; + AuditUser auditUser; + string? auditOperationId; + + await using (var scope = scopeFactory.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + operationEntity = await GetOrCreateOperation(dbContext, groupId, ArchiveOperationType.UnArchive, initiatedBy, operationId); + if (operationEntity == null) + { + return; + } + + // Capture audit attribution from the persisted entity + auditUser = new AuditUser(operationEntity.InitiatedById ?? AuditUser.AnonymousValue, operationEntity.InitiatedByName ?? AuditUser.AnonymousValue); + auditOperationId = operationEntity.OperationId; + } + + // ── Start in-memory tracking ── + await archivingManager.StartArchiving(operationEntity); + + // ── Batch loop ── + string[] batchIds; + do + { + logger.LogInformation("Archiving messages from group {GroupId} starting", groupId); + + await using var batchScope = scopeFactory.CreateAsyncScope(); + var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); + + batchIds = await UpdateGroupStatusAsync(batchDbContext, groupId, FailedMessageStatus.Unresolved, FailedMessageStatus.Archived, batchSize); + await archivingManager.BatchArchived(groupId, ArchiveType.FailureGroup, batchIds.Length); + + // Update persisted operation entity for progress tracking + var persistedEntity = await batchDbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, true); + if (persistedEntity != null) + { + persistedEntity.CurrentBatch++; + persistedEntity.NumberOfMessagesProcessed += batchIds.Length; + await batchDbContext.SaveChangesAsync(); + operationEntity = persistedEntity; + } + + // Raise batch domain event + await domainEvents.Raise(new FailedMessageGroupBatchArchived { FailedMessagesIds = batchIds }); + + // Per-message audit + AuditArchivedMessages(MessageActionKind.Archive, Permissions.ErrorRecoverabilityGroupsArchive, auditUser, auditOperationId, batchIds); + + logger.LogInformation("Archiving of {MessageCount} messages from group {GroupId} completed", batchIds.Length, groupId); + } while (batchIds.Length >= batchSize); + + // ── Finalize ── + logger.LogInformation("Archiving of group {GroupId} is complete", groupId); + await archivingManager.ArchiveOperationFinalizing(groupId, ArchiveType.FailureGroup); + await archivingManager.ArchiveOperationCompleted(groupId, ArchiveType.FailureGroup); + + // Delete the operation row + await using (var finalizeScope = scopeFactory.CreateAsyncScope()) + { + var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); + var entity = await finalizeDbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, true); + if (entity != null) + { + finalizeDbContext.ArchiveOperations.Remove(entity); + await finalizeDbContext.SaveChangesAsync(); + } + } + + await domainEvents.Raise(new FailedMessageGroupArchived + { + GroupId = groupId, + GroupName = operationEntity.GroupName, + MessagesCount = operationEntity.TotalNumberOfMessages + }); + + logger.LogInformation("Archiving of group {GroupId} completed", groupId); + } + + public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = null, string? operationId = null) + { + logger.LogInformation("Unarchiving of {GroupId} started", groupId); + + ArchiveOperationEntity? operationEntity; + AuditUser auditUser; + string? auditOperationId; + + // ── Load-or-create operation row ── + using (var scope = scopeFactory.CreateAsyncScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + operationEntity = await GetOrCreateOperation(dbContext, groupId, ArchiveOperationType.UnArchive, initiatedBy, operationId); + if (operationEntity == null) + { + return; + } + + // Capture audit attribution from the persisted entity + auditUser = new AuditUser(operationEntity.InitiatedById ?? AuditUser.AnonymousValue, operationEntity.InitiatedByName ?? AuditUser.AnonymousValue); + auditOperationId = operationEntity.OperationId; + } + + await unarchivingManager.StartUnarchiving(operationEntity!); + string[] batchIds; + do + { + await using var batchScope = scopeFactory.CreateAsyncScope(); + var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); + + logger.LogInformation("Unarchiving messages from group {GroupId} starting", groupId); + batchIds = await UpdateGroupStatusAsync(batchDbContext, groupId, FailedMessageStatus.Archived, FailedMessageStatus.Unresolved, batchSize); + + await unarchivingManager.BatchUnarchived(groupId, ArchiveType.FailureGroup, batchIds.Length); + + // Update persisted operation entity for progress tracking + var persistedEntity = await batchDbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, false); + if (persistedEntity != null) + { + persistedEntity.CurrentBatch++; + persistedEntity.NumberOfMessagesProcessed += batchIds.Length; + await batchDbContext.SaveChangesAsync(); + operationEntity = persistedEntity; + } + + // Raise batch domain event + await domainEvents.Raise(new FailedMessageGroupBatchUnarchived { FailedMessagesIds = batchIds }); + + // Per-message audit + AuditArchivedMessages(MessageActionKind.Unarchive, Permissions.ErrorRecoverabilityGroupsUnarchive, auditUser, auditOperationId, batchIds); + + logger.LogInformation("Unarchiving of {MessageCount} messages from group {GroupId} completed", batchIds.Length, groupId); + } while (batchIds.Length >= batchSize); + + // ── Finalize ── + logger.LogInformation("Unarchiving of group {GroupId} is complete", groupId); + await unarchivingManager.UnarchiveOperationFinalizing(groupId, ArchiveType.FailureGroup); + await unarchivingManager.UnarchiveOperationCompleted(groupId, ArchiveType.FailureGroup); + + // Delete the operation row + await using (var finalizeScope = scopeFactory.CreateAsyncScope()) + { + var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); + var entity = await finalizeDbContext.ArchiveOperations + .FindAsync(groupId, ArchiveType.FailureGroup, false); + if (entity != null) + { + finalizeDbContext.ArchiveOperations.Remove(entity); + await finalizeDbContext.SaveChangesAsync(); + } + } + + await domainEvents.Raise(new FailedMessageGroupUnarchived + { + GroupId = groupId, + GroupName = operationEntity!.GroupName, + MessagesCount = operationEntity!.TotalNumberOfMessages + }); + + logger.LogInformation("Unarchiving of group {GroupId} completed", groupId); + } + + async Task GetOrCreateOperation(ServiceControlDbContext dbContext, string groupId, ArchiveOperationType operation, AuditUser? initiatedBy, string? operationId) + { + ArchiveOperationEntity? operationEntity = await dbContext.ArchiveOperations.FindAsync(groupId, ArchiveType.FailureGroup, false); + + if (operationEntity != null) + { + logger.LogInformation("Resuming {OperationType} operation for group {GroupId} at batch {CurrentBatch}/{NumberOfBatches}", operation.ToString(), groupId, operationEntity.CurrentBatch, operationEntity.NumberOfBatches); + } + else + { + var (count, groupName) = await GetGroupDetails(dbContext, groupId, FailedMessageStatus.Archived); + if (count == 0) + { + logger.LogWarning("No messages to {OperationType} in group {GroupId}", operation.ToString(), groupId); + return operationEntity; + } + + operationEntity = new ArchiveOperationEntity + { + RequestId = groupId, + GroupName = groupName, + ArchiveType = ArchiveType.FailureGroup, + OperationType = operation, + TotalNumberOfMessages = count, + NumberOfMessagesProcessed = 0, + NumberOfBatches = (int)Math.Ceiling(count / (float)batchSize), + CurrentBatch = 0, + Started = DateTime.UtcNow, + InitiatedById = initiatedBy?.Id, + InitiatedByName = initiatedBy?.Name, + OperationId = operationId + }; + + dbContext.ArchiveOperations.Add(operationEntity); + + try + { + await dbContext.SaveChangesAsync(); + logger.LogInformation("Group {GroupId} has been split into {NumberOfBatches} batches", groupId, operationEntity.NumberOfBatches); + } + catch (DbUpdateException ex) when (dbContext.IsDuplicateKeyException(ex)) + { + //Concurrency issue and process has already started it, nothing to do until restart. + return null; + } + } + + return operationEntity; + } + + /// + /// Emits one per-message audit entry for each message in a batch, correlated to the initiating + /// operation. Skipped when no OperationId was captured (e.g. legacy in-flight operations). + /// + void AuditArchivedMessages(MessageActionKind kind, string permission, AuditUser user, string? operationId, string[] messageIds) + { + if (string.IsNullOrEmpty(operationId)) + { + return; + } + + foreach (var messageId in messageIds) + { + auditLog.MessageAction(user, kind, permission, MessageActionScope.Group, messageId, operationId); + } + } + + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) + => operationsManager.IsOperationInProgressFor(groupId, archiveType); + + public bool IsArchiveInProgressFor(string groupId) + => archivingManager.IsArchiveInProgressFor(groupId); + + public void DismissArchiveOperation(string groupId, ArchiveType archiveType) + => archivingManager.DismissArchiveOperation(groupId, archiveType); + + public Task StartArchiving(string groupId, ArchiveType archiveType) + => archivingManager.StartArchiving(groupId, archiveType); + + public Task StartUnarchiving(string groupId, ArchiveType archiveType) + => unarchivingManager.StartUnarchiving(groupId, archiveType); + + public IEnumerable GetArchivalOperations() + => archivingManager.GetArchivalOperations(); + + async Task UpdateGroupStatusAsync(ServiceControlDbContext dbContext, string groupId, FailedMessageStatus fromStatus, FailedMessageStatus toStatus, int batchSize, CancellationToken cancellationToken = default) + { + var batchIds = await GetNextBatch(dbContext, groupId, fromStatus, batchSize) + .Select(x => x.UniqueMessageId) + .ToListAsync(cancellationToken); + + if (batchIds.Count > 0) + { + var now = DateTime.UtcNow; + + // Bulk status change with re-asserted status filter + await dbContext.FailedMessages + .Where(fm => batchIds.Contains(fm.UniqueMessageId) && fm.Status == fromStatus) + .ExecuteUpdateAsync(s => s + .SetProperty(fm => fm.Status, toStatus) + .SetProperty(fm => fm.StatusChangedAt, now) + .SetProperty(fm => fm.LastModified, now), cancellationToken); + } + + return batchIds.Select(id => id.ToString()).ToArray(); + } + + static async Task<(int count, string groupName)> GetGroupDetails( + ServiceControlDbContext dbContext, string groupId, FailedMessageStatus status, CancellationToken cancellationToken = default) + { + var query = + from fmg in dbContext.FailedMessageGroups + join fm in dbContext.FailedMessages + on fmg.FailedMessageUniqueId equals fm.UniqueMessageId + where fmg.GroupId == groupId && fm.Status == status + select new { fmg.Title, fm.UniqueMessageId }; + + var count = await query.CountAsync(cancellationToken); + var groupName = await dbContext.FailedMessageGroups + .Where(fmg => fmg.GroupId == groupId) + .Select(fmg => fmg.Title) + .FirstOrDefaultAsync(cancellationToken) ?? "Undefined"; + + return (count, groupName); + } + + static IQueryable GetGroup(ServiceControlDbContext dbContext, string groupId, FailedMessageStatus status) => + from fmg in dbContext.FailedMessageGroups + join fm in dbContext.FailedMessages on fmg.FailedMessageUniqueId equals fm.UniqueMessageId + where fmg.GroupId == groupId && fm.Status == status + orderby fm.UniqueMessageId + select fm; + + static IQueryable GetNextBatch(ServiceControlDbContext dbContext, string groupId, FailedMessageStatus status, int batchSize) => + GetGroup(dbContext, groupId, status).Take(batchSize); + + readonly IServiceScopeFactory scopeFactory; + readonly OperationsManager operationsManager; + readonly IDomainEvents domainEvents; + readonly IMessageActionAuditLog auditLog; + readonly EFCoreArchivingManager archivingManager; + readonly EFCoreUnarchivingManager unarchivingManager; + readonly ILogger logger; + const int batchSize = 1000; +} \ No newline at end of file From bed83a109c7df009191d77cbe0444fc0a99e019a Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Tue, 4 Aug 2026 13:44:22 +0800 Subject: [PATCH 06/10] Prune unused code --- .../Recoverability/EFCoreUnarchivingManager.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/EFCoreUnarchivingManager.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/EFCoreUnarchivingManager.cs index 396d54508c..f9ec6a0996 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/EFCoreUnarchivingManager.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/EFCoreUnarchivingManager.cs @@ -50,12 +50,6 @@ public Task StartUnarchiving(string requestId, ArchiveType archiveType) return summary.Start(); } - public InMemoryUnarchive? GetStatusForUnarchiveOperation(string requestId, ArchiveType archiveType) - { - operationsManager.UnarchiveOperations.TryGetValue(InMemoryUnarchive.MakeId(requestId, archiveType), out var summary); - return summary; - } - public Task BatchUnarchived(string requestId, ArchiveType archiveType, int numberOfMessagesUnarchivedInBatch) { var summary = GetOrCreate(archiveType, requestId); @@ -73,9 +67,4 @@ public Task UnarchiveOperationCompleted(string requestId, ArchiveType archiveTyp var summary = GetOrCreate(archiveType, requestId); return summary.Complete(); } - - public void DismissUnarchiveOperation(string requestId, ArchiveType archiveType) - { - operationsManager.UnarchiveOperations.Remove(InMemoryUnarchive.MakeId(requestId, archiveType)); - } } \ No newline at end of file From d6163ec57795861c5c7964a3acb034352cbde8d4 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Tue, 4 Aug 2026 13:45:42 +0800 Subject: [PATCH 07/10] Reorganise code --- .../Recoverability/MessageArchiver.cs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs index ea8cc95ecd..b4000b1893 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs @@ -201,6 +201,24 @@ await domainEvents.Raise(new FailedMessageGroupUnarchived logger.LogInformation("Unarchiving of group {GroupId} completed", groupId); } + public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) + => operationsManager.IsOperationInProgressFor(groupId, archiveType); + + public bool IsArchiveInProgressFor(string groupId) + => archivingManager.IsArchiveInProgressFor(groupId); + + public void DismissArchiveOperation(string groupId, ArchiveType archiveType) + => archivingManager.DismissArchiveOperation(groupId, archiveType); + + public Task StartArchiving(string groupId, ArchiveType archiveType) + => archivingManager.StartArchiving(groupId, archiveType); + + public Task StartUnarchiving(string groupId, ArchiveType archiveType) + => unarchivingManager.StartUnarchiving(groupId, archiveType); + + public IEnumerable GetArchivalOperations() + => archivingManager.GetArchivalOperations(); + async Task GetOrCreateOperation(ServiceControlDbContext dbContext, string groupId, ArchiveOperationType operation, AuditUser? initiatedBy, string? operationId) { ArchiveOperationEntity? operationEntity = await dbContext.ArchiveOperations.FindAsync(groupId, ArchiveType.FailureGroup, false); @@ -268,24 +286,6 @@ void AuditArchivedMessages(MessageActionKind kind, string permission, AuditUser } } - public bool IsOperationInProgressFor(string groupId, ArchiveType archiveType) - => operationsManager.IsOperationInProgressFor(groupId, archiveType); - - public bool IsArchiveInProgressFor(string groupId) - => archivingManager.IsArchiveInProgressFor(groupId); - - public void DismissArchiveOperation(string groupId, ArchiveType archiveType) - => archivingManager.DismissArchiveOperation(groupId, archiveType); - - public Task StartArchiving(string groupId, ArchiveType archiveType) - => archivingManager.StartArchiving(groupId, archiveType); - - public Task StartUnarchiving(string groupId, ArchiveType archiveType) - => unarchivingManager.StartUnarchiving(groupId, archiveType); - - public IEnumerable GetArchivalOperations() - => archivingManager.GetArchivalOperations(); - async Task UpdateGroupStatusAsync(ServiceControlDbContext dbContext, string groupId, FailedMessageStatus fromStatus, FailedMessageStatus toStatus, int batchSize, CancellationToken cancellationToken = default) { var batchIds = await GetNextBatch(dbContext, groupId, fromStatus, batchSize) From b678f9b34e9a89c7941c9741cbcbacf34cbfc01c Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Wed, 5 Aug 2026 13:13:31 +0800 Subject: [PATCH 08/10] Minor changes from review --- .../Abstractions/BasePersistence.cs | 2 +- .../Entities/ArchiveOperationEntity.cs | 4 ++-- .../Implementation/Recoverability/MessageArchiver.cs | 3 --- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs index 1ecac1215b..5b5603731b 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs @@ -1,6 +1,5 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; -using Implementation.Recoverability; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; @@ -9,6 +8,7 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; using ServiceControl.Operations.BodyStorage; using ServiceControl.Persistence.EFCore.Implementation; using ServiceControl.Persistence.EFCore.Implementation.BodyStorage; +using ServiceControl.Persistence.EFCore.Implementation.Recoverability; using ServiceControl.Persistence.EFCore.Implementation.UnitOfWork; using ServiceControl.Persistence.EFCore.Infrastructure; using ServiceControl.Persistence.MessageRedirects; diff --git a/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs index beb7c1a338..ab5a972706 100644 --- a/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs +++ b/src/ServiceControl.Persistence.EFCore/Entities/ArchiveOperationEntity.cs @@ -10,10 +10,10 @@ namespace ServiceControl.Persistence.EFCore.Entities; public class ArchiveOperationEntity { /// The group id (or other request id) being archived/unarchived. - public string RequestId { get; set; } = null!; + public required string RequestId { get; set; } /// Display name of the group, captured at operation start. - public string GroupName { get; set; } = null!; + public required string GroupName { get; set; } /// The type of archive operation (FailureGroup, SingleMessage, etc.). public ArchiveType ArchiveType { get; set; } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs index b4000b1893..fdde84022f 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs @@ -56,12 +56,10 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul // ── Start in-memory tracking ── await archivingManager.StartArchiving(operationEntity); - // ── Batch loop ── string[] batchIds; do { - logger.LogInformation("Archiving messages from group {GroupId} starting", groupId); await using var batchScope = scopeFactory.CreateAsyncScope(); var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); @@ -148,7 +146,6 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n await using var batchScope = scopeFactory.CreateAsyncScope(); var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); - logger.LogInformation("Unarchiving messages from group {GroupId} starting", groupId); batchIds = await UpdateGroupStatusAsync(batchDbContext, groupId, FailedMessageStatus.Archived, FailedMessageStatus.Unresolved, batchSize); await unarchivingManager.BatchUnarchived(groupId, ArchiveType.FailureGroup, batchIds.Length); From 2631de78344ee41b317eaae33a83836c20ca1d64 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Wed, 5 Aug 2026 15:19:26 +0800 Subject: [PATCH 09/10] make sure timestamps are tested for sweeper compatibility --- .../Recoverability/MessageArchiver.cs | 7 +++++-- .../EFCore/RetentionSweepTests.cs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs index fdde84022f..16ee5949c3 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs @@ -18,12 +18,14 @@ public MessageArchiver( OperationsManager operationsManager, IDomainEvents domainEvents, IMessageActionAuditLog auditLog, + TimeProvider timeProvider, ILogger logger ) { this.scopeFactory = scopeFactory; this.operationsManager = operationsManager; this.auditLog = auditLog; + this.timeProvider = timeProvider; this.logger = logger; this.domainEvents = domainEvents; @@ -243,7 +245,7 @@ public IEnumerable GetArchivalOperations() NumberOfMessagesProcessed = 0, NumberOfBatches = (int)Math.Ceiling(count / (float)batchSize), CurrentBatch = 0, - Started = DateTime.UtcNow, + Started = timeProvider.GetUtcNow().DateTime, InitiatedById = initiatedBy?.Id, InitiatedByName = initiatedBy?.Name, OperationId = operationId @@ -291,7 +293,7 @@ async Task UpdateGroupStatusAsync(ServiceControlDbContext dbContext, s if (batchIds.Count > 0) { - var now = DateTime.UtcNow; + var now = timeProvider.GetUtcNow().DateTime; // Bulk status change with re-asserted status filter await dbContext.FailedMessages @@ -338,6 +340,7 @@ static IQueryable GetNextBatch(ServiceControlDbContext dbCo readonly OperationsManager operationsManager; readonly IDomainEvents domainEvents; readonly IMessageActionAuditLog auditLog; + readonly TimeProvider timeProvider; readonly EFCoreArchivingManager archivingManager; readonly EFCoreUnarchivingManager unarchivingManager; readonly ILogger logger; diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs index 5919b446b6..fe1c81834d 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs @@ -147,6 +147,25 @@ public async Task Keeps_comments_of_groups_that_still_have_messages() Assert.That(await FindGroupComment(groupId), Is.Not.Null); } + [Test] + public async Task Archived_messages_are_swept_after_the_archiver_updates_the_timestamp() + { + var groupId = Guid.NewGuid().ToString(); + var messageId = await SeedFailedMessage(FailedMessageStatus.Unresolved, Now.AddDays(-40)); + await Store(new FailedMessageGroupEntity { FailedMessageUniqueId = messageId, GroupId = groupId, Title = "t", Type = "Message Type" }); + + await ArchiveMessages.ArchiveAllInGroup(groupId); + + var archived = await FindFailedMessage(messageId); + Assert.That(archived, Is.Not.Null); + Assert.That(archived!.Status, Is.EqualTo(FailedMessageStatus.Archived)); + Assert.That(archived.StatusChangedAt, Is.EqualTo(Now), "the archiver should stamp the current fake time"); + + await RunRetentionSweep(); + + Assert.That(await FindFailedMessage(messageId), Is.Null); + } + async Task SeedGroup(Guid uniqueMessageId) { var groupId = Guid.NewGuid().ToString(); From 5cdaf59f0ea42e55efce9949b12228da7766cdc2 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Wed, 5 Aug 2026 16:03:09 +0800 Subject: [PATCH 10/10] Realign key for finds, make sure retention test works --- .../Recoverability/MessageArchiver.cs | 77 ++++++++----------- .../EFCore/RetentionSweepTests.cs | 4 + 2 files changed, 38 insertions(+), 43 deletions(-) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs index 16ee5949c3..30f9b7f902 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/Recoverability/MessageArchiver.cs @@ -45,7 +45,7 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul { var dbContext = scope.ServiceProvider.GetRequiredService(); - operationEntity = await GetOrCreateOperation(dbContext, groupId, ArchiveOperationType.UnArchive, initiatedBy, operationId); + operationEntity = await GetOrCreateOperation(dbContext, groupId, ArchiveOperationType.Archive, initiatedBy, operationId); if (operationEntity == null) { return; @@ -62,23 +62,19 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul string[] batchIds; do { - await using var batchScope = scopeFactory.CreateAsyncScope(); var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); + operationEntity = await batchDbContext.ArchiveOperations.FindAsync(groupId, ArchiveType.FailureGroup, ArchiveOperationType.Archive) + ?? throw new InvalidOperationException($"No in progress Archive Operation found for {groupId}"); + batchIds = await UpdateGroupStatusAsync(batchDbContext, groupId, FailedMessageStatus.Unresolved, FailedMessageStatus.Archived, batchSize); await archivingManager.BatchArchived(groupId, ArchiveType.FailureGroup, batchIds.Length); - // Update persisted operation entity for progress tracking - var persistedEntity = await batchDbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, true); - if (persistedEntity != null) - { - persistedEntity.CurrentBatch++; - persistedEntity.NumberOfMessagesProcessed += batchIds.Length; - await batchDbContext.SaveChangesAsync(); - operationEntity = persistedEntity; - } + // Update progress tracking + operationEntity.CurrentBatch++; + operationEntity.NumberOfMessagesProcessed += batchIds.Length; + await batchDbContext.SaveChangesAsync(); // Raise batch domain event await domainEvents.Raise(new FailedMessageGroupBatchArchived { FailedMessagesIds = batchIds }); @@ -98,13 +94,12 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul await using (var finalizeScope = scopeFactory.CreateAsyncScope()) { var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); - var entity = await finalizeDbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, true); - if (entity != null) - { - finalizeDbContext.ArchiveOperations.Remove(entity); - await finalizeDbContext.SaveChangesAsync(); - } + await finalizeDbContext.ArchiveOperations + .Where(o => + o.ArchiveType == operationEntity.ArchiveType + && o.RequestId == operationEntity.RequestId + && o.OperationType == operationEntity.OperationType) + .ExecuteDeleteAsync(); } await domainEvents.Raise(new FailedMessageGroupArchived @@ -141,27 +136,23 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n auditOperationId = operationEntity.OperationId; } - await unarchivingManager.StartUnarchiving(operationEntity!); + await unarchivingManager.StartUnarchiving(operationEntity); string[] batchIds; do { await using var batchScope = scopeFactory.CreateAsyncScope(); var batchDbContext = batchScope.ServiceProvider.GetRequiredService(); + operationEntity = await batchDbContext.ArchiveOperations.FindAsync(groupId, ArchiveType.FailureGroup, ArchiveOperationType.UnArchive) + ?? throw new InvalidOperationException($"No in progress Unarchive Operation found for {groupId}"); batchIds = await UpdateGroupStatusAsync(batchDbContext, groupId, FailedMessageStatus.Archived, FailedMessageStatus.Unresolved, batchSize); await unarchivingManager.BatchUnarchived(groupId, ArchiveType.FailureGroup, batchIds.Length); - // Update persisted operation entity for progress tracking - var persistedEntity = await batchDbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, false); - if (persistedEntity != null) - { - persistedEntity.CurrentBatch++; - persistedEntity.NumberOfMessagesProcessed += batchIds.Length; - await batchDbContext.SaveChangesAsync(); - operationEntity = persistedEntity; - } + // Update progress tracking + operationEntity.CurrentBatch++; + operationEntity.NumberOfMessagesProcessed += batchIds.Length; + await batchDbContext.SaveChangesAsync(); // Raise batch domain event await domainEvents.Raise(new FailedMessageGroupBatchUnarchived { FailedMessagesIds = batchIds }); @@ -181,20 +172,19 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n await using (var finalizeScope = scopeFactory.CreateAsyncScope()) { var finalizeDbContext = finalizeScope.ServiceProvider.GetRequiredService(); - var entity = await finalizeDbContext.ArchiveOperations - .FindAsync(groupId, ArchiveType.FailureGroup, false); - if (entity != null) - { - finalizeDbContext.ArchiveOperations.Remove(entity); - await finalizeDbContext.SaveChangesAsync(); - } + await finalizeDbContext.ArchiveOperations + .Where(o => + o.ArchiveType == operationEntity.ArchiveType + && o.RequestId == operationEntity.RequestId + && o.OperationType == operationEntity.OperationType) + .ExecuteDeleteAsync(); } await domainEvents.Raise(new FailedMessageGroupUnarchived { GroupId = groupId, - GroupName = operationEntity!.GroupName, - MessagesCount = operationEntity!.TotalNumberOfMessages + GroupName = operationEntity.GroupName, + MessagesCount = operationEntity.TotalNumberOfMessages }); logger.LogInformation("Unarchiving of group {GroupId} completed", groupId); @@ -220,7 +210,7 @@ public IEnumerable GetArchivalOperations() async Task GetOrCreateOperation(ServiceControlDbContext dbContext, string groupId, ArchiveOperationType operation, AuditUser? initiatedBy, string? operationId) { - ArchiveOperationEntity? operationEntity = await dbContext.ArchiveOperations.FindAsync(groupId, ArchiveType.FailureGroup, false); + ArchiveOperationEntity? operationEntity = await dbContext.ArchiveOperations.FindAsync(groupId, ArchiveType.FailureGroup, operation); if (operationEntity != null) { @@ -228,7 +218,8 @@ public IEnumerable GetArchivalOperations() } else { - var (count, groupName) = await GetGroupDetails(dbContext, groupId, FailedMessageStatus.Archived); + var targetStatus = operation == ArchiveOperationType.Archive ? FailedMessageStatus.Unresolved : FailedMessageStatus.Archived; + var (count, groupName) = await GetGroupDetails(dbContext, groupId, targetStatus); if (count == 0) { logger.LogWarning("No messages to {OperationType} in group {GroupId}", operation.ToString(), groupId); @@ -245,7 +236,7 @@ public IEnumerable GetArchivalOperations() NumberOfMessagesProcessed = 0, NumberOfBatches = (int)Math.Ceiling(count / (float)batchSize), CurrentBatch = 0, - Started = timeProvider.GetUtcNow().DateTime, + Started = timeProvider.GetUtcNow().UtcDateTime, InitiatedById = initiatedBy?.Id, InitiatedByName = initiatedBy?.Name, OperationId = operationId @@ -293,7 +284,7 @@ async Task UpdateGroupStatusAsync(ServiceControlDbContext dbContext, s if (batchIds.Count > 0) { - var now = timeProvider.GetUtcNow().DateTime; + var now = timeProvider.GetUtcNow().UtcDateTime; // Bulk status change with re-asserted status filter await dbContext.FailedMessages diff --git a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs index fe1c81834d..35aa3d930a 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/RetentionSweepTests.cs @@ -161,6 +161,10 @@ public async Task Archived_messages_are_swept_after_the_archiver_updates_the_tim Assert.That(archived!.Status, Is.EqualTo(FailedMessageStatus.Archived)); Assert.That(archived.StatusChangedAt, Is.EqualTo(Now), "the archiver should stamp the current fake time"); + // The archiver reset the timestamp to Now, so the message is back inside the retention window. + // Only after the clock advances past the retention period can the sweeper remove it. + AdvanceClock(TimeSpan.FromDays(31)); + await RunRetentionSweep(); Assert.That(await FindFailedMessage(messageId), Is.Null);