diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260731043221_AddFailureGroupClassifierIndex.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260731043221_AddFailureGroupClassifierIndex.Designer.cs new file mode 100644 index 0000000000..5ed94cb171 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260731043221_AddFailureGroupClassifierIndex.Designer.cs @@ -0,0 +1,378 @@ +// +using System; +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("20260731043221_AddFailureGroupClassifierIndex")] + partial class AddFailureGroupClassifierIndex + { + /// + 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.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.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") + .IsRequired() + .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("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("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.HasIndex("Type", "GroupId") + .HasDatabaseName("ix_failed_message_groups_type_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/20260731043221_AddFailureGroupClassifierIndex.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260731043221_AddFailureGroupClassifierIndex.cs new file mode 100644 index 0000000000..f61b3ea87e --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260731043221_AddFailureGroupClassifierIndex.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations +{ + /// + public partial class AddFailureGroupClassifierIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "ix_failed_message_groups_type_group_id", + table: "failed_message_groups", + columns: new[] { "type", "group_id" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_failed_message_groups_type_group_id", + table: "failed_message_groups"); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs index cca72b3640..1623ec1da1 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs @@ -305,6 +305,9 @@ protected override void BuildModel(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); }); diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260731043129_AddFailureGroupClassifierIndex.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260731043129_AddFailureGroupClassifierIndex.Designer.cs new file mode 100644 index 0000000000..7418ae7263 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260731043129_AddFailureGroupClassifierIndex.Designer.cs @@ -0,0 +1,308 @@ +// +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("20260731043129_AddFailureGroupClassifierIndex")] + partial class AddFailureGroupClassifierIndex + { + /// + 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.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.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") + .IsRequired() + .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("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("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.HasIndex("Type", "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/20260731043129_AddFailureGroupClassifierIndex.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260731043129_AddFailureGroupClassifierIndex.cs new file mode 100644 index 0000000000..eac954e32f --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260731043129_AddFailureGroupClassifierIndex.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations +{ + /// + public partial class AddFailureGroupClassifierIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_FailedMessageGroups_Type_GroupId", + table: "FailedMessageGroups", + columns: new[] { "Type", "GroupId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_FailedMessageGroups_Type_GroupId", + table: "FailedMessageGroups"); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs index 829753e136..42cb2b31bd 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs @@ -243,6 +243,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("GroupId"); + b.HasIndex("Type", "GroupId"); + b.ToTable("FailedMessageGroups"); }); diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs index 591081cb80..535913c3ef 100644 --- a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs @@ -17,6 +17,9 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(e => e.GroupId); + // Drives the per-classifier group aggregate, which filters on Type and groups by GroupId. + builder.HasIndex(e => new { e.Type, e.GroupId }); + builder.HasOne() .WithMany() .HasForeignKey(e => e.FailedMessageUniqueId) diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs index c0cb7ee2fa..9060da9cf9 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryDataStore.cs @@ -13,14 +13,12 @@ namespace ServiceControl.Persistence.EFCore.Implementation; public class FailedMessageQueryDataStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), IFailedMessageQueryDataStore { public Task>> GetFailedMessages(string? status, string? modified, string? queueAddress, PagingInfo pagingInfo, SortInfo sortInfo) => - ExecuteWithDbContext(dbContext => Page( - dbContext.FailedMessages - .AsNoTracking() - .FilterByStatus(status) - .FilterByLastModifiedRange(modified) - .FilterByQueueAddress(queueAddress), - pagingInfo, - sortInfo)); + ExecuteWithDbContext(dbContext => dbContext.FailedMessages + .AsNoTracking() + .FilterByStatus(status) + .FilterByLastModifiedRange(modified) + .FilterByQueueAddress(queueAddress) + .ToPagedResult(pagingInfo, sortInfo)); public Task GetFailedMessagesStats(string? status, string? modified, string? queueAddress) => ExecuteWithDbContext(dbContext => dbContext.FailedMessages @@ -31,14 +29,12 @@ public Task GetFailedMessagesStats(string? status, string? modif .ToQueryStatsInfo()); public Task>> GetFailedMessagesByEndpoint(string? status, string endpointName, string? modified, PagingInfo pagingInfo, SortInfo sortInfo) => - ExecuteWithDbContext(dbContext => Page( - dbContext.FailedMessages - .AsNoTracking() - .Where(message => message.ReceivingEndpointName == endpointName) - .FilterByStatus(status) - .FilterByLastModifiedRange(modified), - pagingInfo, - sortInfo)); + ExecuteWithDbContext(dbContext => dbContext.FailedMessages + .AsNoTracking() + .Where(message => message.ReceivingEndpointName == endpointName) + .FilterByStatus(status) + .FilterByLastModifiedRange(modified) + .ToPagedResult(pagingInfo, sortInfo)); public Task> GetFailedMessagesSummary() => ExecuteWithDbContext(async dbContext => @@ -110,20 +106,6 @@ public Task GetFailedMessagesByIds(Guid[] ids) => return [.. entities.Select(entity => entity.ToFailedMessage(groups.GetValueOrDefault(entity.UniqueMessageId, [])))]; }); - static async Task>> Page(IQueryable query, PagingInfo pagingInfo, SortInfo sortInfo) - { - var stats = await query.ToQueryStatsInfo(); - - var entities = await query - .Sort(sortInfo) - .Page(pagingInfo) - .ToListAsync(); - - IList results = [.. entities.Select(entity => entity.ToFailedMessageView())]; - - return new QueryResult>(results, stats); - } - static async Task> CountBy(ServiceControlDbContext dbContext, Expression> selector) => await dbContext.FailedMessages .AsNoTracking() diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs new file mode 100644 index 0000000000..9c6fe90250 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Implementation/FailedMessageQueryResults.cs @@ -0,0 +1,24 @@ +namespace ServiceControl.Persistence.EFCore.Implementation; + +using Microsoft.EntityFrameworkCore; +using ServiceControl.MessageFailures.Api; +using ServiceControl.Persistence.EFCore.Entities; +using ServiceControl.Persistence.EFCore.Infrastructure; +using ServiceControl.Persistence.Infrastructure; + +static class FailedMessageQueryResults +{ + public static async Task>> ToPagedResult(this IQueryable source, PagingInfo pagingInfo, SortInfo sortInfo) + { + var stats = await source.ToQueryStatsInfo(); + + var entities = await source + .Sort(sortInfo) + .Page(pagingInfo) + .ToListAsync(); + + IList results = [.. entities.Select(entity => entity.ToFailedMessageView())]; + + return new QueryResult>(results, stats); + } +} diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs index 0ef4ea6a5c..56760ece42 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs @@ -1,35 +1,100 @@ namespace ServiceControl.Persistence.EFCore.Implementation; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ServiceControl.MessageFailures; using ServiceControl.MessageFailures.Api; +using ServiceControl.Persistence.EFCore.DbContexts; +using ServiceControl.Persistence.EFCore.Entities; +using ServiceControl.Persistence.EFCore.Infrastructure; using ServiceControl.Persistence.Infrastructure; using ServiceControl.Recoverability; -public class GroupsDataStore : IGroupsDataStore +public class GroupsDataStore(IServiceScopeFactory scopeFactory) : DataStoreBase(scopeFactory), IGroupsDataStore { public Task> GetFailureGroupsByClassifier(string classifier, string classifierFilter) => - throw new NotImplementedException(); + ExecuteWithDbContext(dbContext => + { + var groups = ByClassifier(dbContext, classifier); + + if (!string.IsNullOrWhiteSpace(classifierFilter)) + { + groups = groups.Where(group => group.Title == classifierFilter); + } + + return MostRecent(groups.AggregateGroups(WithStatus(dbContext, FailedMessageStatus.Unresolved))); + }); public Task> GetArchivedFailureGroupsByClassifier(string classifier) => - throw new NotImplementedException(); + ExecuteWithDbContext(dbContext => MostRecent( + ByClassifier(dbContext, classifier).AggregateGroups(WithStatus(dbContext, FailedMessageStatus.Archived)))); + // Implemented once retry batches are persisted, together with IRetryDocumentDataStore. public Task GetCurrentForwardingBatch() => throw new NotImplementedException(); public Task>> GetGroup(string groupId, string status, string modified) => - throw new NotImplementedException(); + ExecuteWithDbContext(async dbContext => + { + var groups = await ById(dbContext, groupId, FailedMessageStatus.Unresolved, status, modified).ToListAsync(); + + return new QueryResult>(groups, groups.ToQueryStatsInfo()); + }); public Task> GetFailureGroupView(string groupId, string status, string modified) => - throw new NotImplementedException(); + ExecuteWithDbContext(async dbContext => + { + var groups = await ById(dbContext, groupId, FailedMessageStatus.Archived, status, modified).ToListAsync(); + + // A missing group is reported as a null result, the same as the RavenDB persister does. + return new QueryResult(groups.FirstOrDefault()!, groups.ToQueryStatsInfo()); + }); public Task>> GetGroupErrors(string groupId, string status, string modified, SortInfo sortInfo, PagingInfo pagingInfo) => - throw new NotImplementedException(); + ExecuteWithDbContext(dbContext => InGroup(dbContext, groupId, status, modified).ToPagedResult(pagingInfo, sortInfo)); public Task GetGroupErrorsCount(string groupId, string status, string modified) => - throw new NotImplementedException(); + ExecuteWithDbContext(dbContext => InGroup(dbContext, groupId, status, modified).ToQueryStatsInfo()); public Task EditComment(string groupId, string comment) => throw new NotImplementedException(); public Task DeleteComment(string groupId) => throw new NotImplementedException(); + + static IQueryable ByClassifier(ServiceControlDbContext dbContext, string classifier) => + dbContext.FailedMessageGroups + .AsNoTracking() + .Where(group => group.Type == classifier); + + /// + /// The status a group is read at, before the caller's own status and modified filters narrow it + /// further. RavenDB reads open groups out of an unresolved-only index and archived groups out of + /// an archived-only one, which is what stands in for here. + /// + static IQueryable ById(ServiceControlDbContext dbContext, string groupId, FailedMessageStatus baseline, string status, string modified) => + dbContext.FailedMessageGroups + .AsNoTracking() + .Where(group => group.GroupId == groupId) + .AggregateGroups(WithStatus(dbContext, baseline) + .FilterByStatus(status) + .FilterByLastModifiedRange(modified)); + + static IQueryable WithStatus(ServiceControlDbContext dbContext, FailedMessageStatus status) => + dbContext.FailedMessages + .AsNoTracking() + .Where(message => message.Status == status); + + static IQueryable InGroup(ServiceControlDbContext dbContext, string groupId, string status, string modified) => + dbContext.FailedMessages + .AsNoTracking() + .Where(message => dbContext.FailedMessageGroups.Any(group => group.GroupId == groupId && group.FailedMessageUniqueId == message.UniqueMessageId)) + .FilterByStatus(status) + .FilterByLastModifiedRange(modified); + + static async Task> MostRecent(IQueryable groups) => + await groups + .OrderByDescending(group => group.Last) + .Take(FailureGroupQueries.MaxGroups) + .ToListAsync(); } diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs new file mode 100644 index 0000000000..0767a7d4b2 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs @@ -0,0 +1,32 @@ +namespace ServiceControl.Persistence.EFCore.Infrastructure; + +using ServiceControl.Persistence.EFCore.Entities; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +static class FailureGroupQueries +{ + public const int MaxGroups = 200; + + public static IQueryable AggregateGroups(this IQueryable groups, IQueryable messages) => + from failureGroup in groups + join message in messages on failureGroup.FailedMessageUniqueId equals message.UniqueMessageId + group message by new { failureGroup.GroupId, failureGroup.Title, failureGroup.Type } + into aggregate + select new FailureGroupView + { + Id = aggregate.Key.GroupId, + Title = aggregate.Key.Title, + Type = aggregate.Key.Type, + Count = aggregate.Count(), + First = aggregate.Min(message => message.FirstTimeOfFailure), + Last = aggregate.Max(message => message.LastTimeOfFailure) + }; + + public static QueryStatsInfo ToQueryStatsInfo(this IReadOnlyCollection groups) + { + var latest = groups.Count == 0 ? DateTime.MinValue : groups.Max(group => group.Last); + + return new QueryStatsInfo($"{groups.Count}-{latest.Ticks}", groups.Count, false); + } +} diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs new file mode 100644 index 0000000000..4f8654f193 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/Recoverability/GroupsDataStoreTests.cs @@ -0,0 +1,254 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.Infrastructure; +using ServiceControl.Recoverability; + +class GroupsDataStoreTests : PersistenceTestBase +{ + const string Classifier = "Exception Type and Stack Trace"; + const string OtherClassifier = "Message Type"; + + static readonly DateTime Noon = new(2026, 7, 22, 12, 0, 0, DateTimeKind.Utc); + + [Test] + public async Task Aggregates_the_messages_of_a_group() + { + var group = NewGroup("OrderPlaced"); + + await Insert( + InGroup(group, failedAt: Noon), + InGroup(group, failedAt: Noon.AddHours(2))); + + var view = (await GroupsStore.GetFailureGroupsByClassifier(Classifier, null)).Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(view.Id, Is.EqualTo(group.Id)); + Assert.That(view.Title, Is.EqualTo("OrderPlaced")); + Assert.That(view.Type, Is.EqualTo(Classifier)); + Assert.That(view.Count, Is.EqualTo(2)); + Assert.That(view.First, Is.EqualTo(Noon)); + Assert.That(view.Last, Is.EqualTo(Noon.AddHours(2))); + } + } + + [Test] + public async Task Returns_only_the_groups_of_the_requested_classifier() + { + var requested = NewGroup("OrderPlaced"); + var other = NewGroup("OrderPlaced", OtherClassifier); + + await Insert(InGroup(requested), InGroup(other)); + + var groups = await GroupsStore.GetFailureGroupsByClassifier(Classifier, null); + + Assert.That(groups.Select(group => group.Id), Is.EqualTo(new[] { requested.Id })); + } + + [Test] + public async Task Narrows_the_groups_to_the_classifier_filter() + { + var matching = NewGroup("OrderPlaced"); + var other = NewGroup("OrderCancelled"); + + await Insert(InGroup(matching), InGroup(other)); + + var groups = await GroupsStore.GetFailureGroupsByClassifier(Classifier, "OrderPlaced"); + + Assert.That(groups.Select(group => group.Id), Is.EqualTo(new[] { matching.Id })); + } + + [Test] + public async Task Counts_only_unresolved_messages() + { + var group = NewGroup("OrderPlaced"); + + await Insert( + InGroup(group).ToFailedMessage(), + InGroup(group).ToFailedMessage(FailedMessageStatus.Archived), + InGroup(group).ToFailedMessage(FailedMessageStatus.Resolved)); + + var view = (await GroupsStore.GetFailureGroupsByClassifier(Classifier, null)).Single(); + + Assert.That(view.Count, Is.EqualTo(1)); + } + + [Test] + public async Task Returns_archived_groups_separately() + { + var group = NewGroup("OrderPlaced"); + + await Insert( + InGroup(group).ToFailedMessage(), + InGroup(group).ToFailedMessage(FailedMessageStatus.Archived)); + + var view = (await GroupsStore.GetArchivedFailureGroupsByClassifier(Classifier)).Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(view.Id, Is.EqualTo(group.Id)); + Assert.That(view.Count, Is.EqualTo(1)); + } + } + + [Test] + public async Task Orders_groups_by_the_most_recent_failure() + { + var oldest = NewGroup("Oldest"); + var newest = NewGroup("Newest"); + var middle = NewGroup("Middle"); + + await Insert( + InGroup(oldest, failedAt: Noon), + InGroup(newest, failedAt: Noon.AddHours(4)), + InGroup(middle, failedAt: Noon.AddHours(2))); + + var groups = await GroupsStore.GetFailureGroupsByClassifier(Classifier, null); + + Assert.That(groups.Select(group => group.Title), Is.EqualTo(new[] { "Newest", "Middle", "Oldest" })); + } + + [Test] + public async Task Returns_a_single_group_by_id() + { + var requested = NewGroup("OrderPlaced"); + + await Insert(InGroup(requested), InGroup(NewGroup("OrderCancelled"))); + + var result = await GroupsStore.GetGroup(requested.Id, null, null); + + var view = result.Results.Single(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(view.Id, Is.EqualTo(requested.Id)); + Assert.That(view.Count, Is.EqualTo(1)); + } + } + + [Test] + public async Task Returns_no_group_for_an_unknown_id() + { + await Insert(InGroup(NewGroup("OrderPlaced"))); + + var result = await GroupsStore.GetGroup(Guid.NewGuid().ToString(), null, null); + + Assert.That(result.Results, Is.Empty); + } + + [Test] + public async Task Returns_an_archived_group_view_by_id() + { + var group = NewGroup("OrderPlaced"); + + await Insert(InGroup(group).ToFailedMessage(FailedMessageStatus.Archived)); + + var result = await GroupsStore.GetFailureGroupView(group.Id, null, null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Results.Id, Is.EqualTo(group.Id)); + Assert.That(result.Results.Count, Is.EqualTo(1)); + } + } + + [Test] + public async Task Returns_no_archived_group_view_for_an_unknown_id() + { + await Insert(InGroup(NewGroup("OrderPlaced")).ToFailedMessage(FailedMessageStatus.Archived)); + + var result = await GroupsStore.GetFailureGroupView(Guid.NewGuid().ToString(), null, null); + + Assert.That(result.Results, Is.Null); + } + + [Test] + public async Task Returns_the_errors_of_a_group() + { + var group = NewGroup("OrderPlaced"); + var inGroup = InGroup(group); + var outsideGroup = InGroup(NewGroup("OrderCancelled")); + + await Insert(inGroup, outsideGroup); + + var result = await GroupsStore.GetGroupErrors(group.Id, null, null, new SortInfo(), new PagingInfo()); + + Assert.That(result.Results.Select(view => view.Id), Is.EqualTo(new[] { inGroup.UniqueMessageIdString })); + } + + [Test] + public async Task Pages_the_errors_of_a_group() + { + var group = NewGroup("OrderPlaced"); + var newest = InGroup(group, sentAt: Noon.AddHours(2)); + var oldest = InGroup(group, sentAt: Noon); + + await Insert(newest, oldest); + + var result = await GroupsStore.GetGroupErrors(group.Id, null, null, new SortInfo("time_sent", "desc"), new PagingInfo(page: 1, pageSize: 1)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Results.Select(view => view.Id), Is.EqualTo(new[] { newest.UniqueMessageIdString })); + Assert.That(result.QueryStats.TotalCount, Is.EqualTo(2)); + } + } + + [Test] + public async Task Returns_the_errors_of_a_group_by_status() + { + var group = NewGroup("OrderPlaced"); + var unresolved = InGroup(group); + var archived = InGroup(group); + + await Insert(unresolved.ToFailedMessage(), archived.ToFailedMessage(FailedMessageStatus.Archived)); + + var result = await GroupsStore.GetGroupErrors(group.Id, "archived", null, new SortInfo(), new PagingInfo()); + + Assert.That(result.Results.Select(view => view.Id), Is.EqualTo(new[] { archived.UniqueMessageIdString })); + } + + [Test] + public async Task Counts_the_errors_of_a_group() + { + var group = NewGroup("OrderPlaced"); + + await Insert(InGroup(group), InGroup(group), InGroup(NewGroup("OrderCancelled"))); + + var stats = await GroupsStore.GetGroupErrorsCount(group.Id, null, null); + + Assert.That(stats.TotalCount, Is.EqualTo(2)); + } + + static FailedMessage.FailureGroup NewGroup(string title, string type = Classifier) => + new() { Id = Guid.NewGuid().ToString(), Title = title, Type = type }; + + static IngestedFailure InGroup(FailedMessage.FailureGroup group, DateTime? failedAt = null, DateTime? sentAt = null) => + new() + { + Groups = [group], + AttemptedAt = failedAt ?? Noon, + TimeOfFailure = failedAt ?? Noon, + TimeSent = sentAt ?? Noon.AddMinutes(-1) + }; + + Task Insert(params IngestedFailure[] failures) => + Insert([.. failures.Select(failure => failure.ToFailedMessage())]); + + async Task Insert(params FailedMessage[] messages) + { + foreach (var message in messages) + { + message.Id = PersistenceTestsContext.GenerateFailedMessageRecordId(message.UniqueMessageId); + } + + await PersistenceTestsContext.InsertFailedMessages(messages); + await CompleteDatabaseOperation(); + } +}