diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/EntityConfigurations/FailedMessageConfiguration.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/EntityConfigurations/FailedMessageConfiguration.cs new file mode 100644 index 0000000000..cf1f2d7cb9 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/EntityConfigurations/FailedMessageConfiguration.cs @@ -0,0 +1,20 @@ +namespace ServiceControl.Persistence.EFCore.EntityConfigurations; + +using Entities; +using MessageFailures; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class FailedMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Drives the retention sweep. + // The index is restricted to the statuses the sweep deletes (Resolved and Archived) + builder.HasIndex(e => e.StatusChangedAt) + .HasFilter($"status IN ({(int)FailedMessageStatus.Resolved}, {(int)FailedMessageStatus.Archived})"); + + builder.HasIndex(e => new { e.Status, e.LastModified, e.UniqueMessageId }) + .IncludeProperties(nameof(FailedMessageEntity.FirstTimeOfFailure), nameof(FailedMessageEntity.LastTimeOfFailure)); + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/EntityConfigurations/FailedMessageGroupConfiguration.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/EntityConfigurations/FailedMessageGroupConfiguration.cs new file mode 100644 index 0000000000..4a293bc309 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/EntityConfigurations/FailedMessageGroupConfiguration.cs @@ -0,0 +1,17 @@ +namespace ServiceControl.Persistence.EFCore.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class FailedMessageGroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Both the join column (FailedMessageUniqueId) and the per-group title lookup's column + // (Title) have to be INCLUDEs. Without Title the lookup heap-fetches, and without + // FailedMessageUniqueId step 1 of the aggregate cannot join index-only + builder.HasIndex(e => new { e.Type, e.GroupId }) + .IncludeProperties(nameof(FailedMessageGroupEntity.FailedMessageUniqueId), nameof(FailedMessageGroupEntity.Title)); + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260915050538_AddAdditionalIndexCoverage.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260915050538_AddAdditionalIndexCoverage.Designer.cs new file mode 100644 index 0000000000..4dd20ce058 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260915050538_AddAdditionalIndexCoverage.Designer.cs @@ -0,0 +1,925 @@ +// +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("20260915050538_AddAdditionalIndexCoverage")] + partial class AddAdditionalIndexCoverage + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + 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("OperationType") + .HasColumnType("integer") + .HasColumnName("operation_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("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", "OperationType") + .HasName("pk_archive_operations"); + + b.HasIndex("Started") + .HasDatabaseName("ix_archive_operations_started"); + + b.ToTable("archive_operations", (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") + .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("event_log_items", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.ExternalIntegrationDispatchRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DispatchContextJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("dispatch_context_json"); + + b.Property("DispatchContextTypeName") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("dispatch_context_type_name"); + + b.HasKey("Id") + .HasName("pk_external_integration_dispatch_requests"); + + b.ToTable("external_integration_dispatch_requests", (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.FailedMessageEditEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uuid") + .HasColumnName("unique_message_id"); + + b.Property("EditId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("edit_id"); + + b.HasKey("UniqueMessageId") + .HasName("pk_failed_message_edits"); + + b.HasIndex("EditId") + .HasDatabaseName("ix_failed_message_edits_edit_id"); + + b.ToTable("failed_message_edits", (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") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .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", "LastTimeOfFailure") + .HasDatabaseName("ix_failed_messages_status_last_time_of_failure"); + + b.HasIndex("Status", "LastModified", "UniqueMessageId") + .HasDatabaseName("ix_failed_messages_status_last_modified_unique_message_id"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Status", "LastModified", "UniqueMessageId"), new[] { "FirstTimeOfFailure", "LastTimeOfFailure" }); + + b.HasIndex("Status", "MessageType", "UniqueMessageId") + .HasDatabaseName("ix_failed_messages_status_message_type_unique_message_id"); + + 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"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Type", "GroupId"), new[] { "FailedMessageUniqueId", "Title" }); + + 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("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.HistoricRetryOperationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletionTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("completion_time"); + + b.Property("Failed") + .HasColumnType("boolean") + .HasColumnName("failed"); + + b.Property("NumberOfMessagesProcessed") + .HasColumnType("integer") + .HasColumnName("number_of_messages_processed"); + + b.Property("Originator") + .HasColumnType("text") + .HasColumnName("originator"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("request_id"); + + b.Property("RetryType") + .HasColumnType("integer") + .HasColumnName("retry_type"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.HasKey("Id") + .HasName("pk_historic_retry_operations"); + + b.HasIndex("CompletionTime", "Id") + .IsDescending() + .HasDatabaseName("ix_historic_retry_operations_completion_time_id"); + + b.ToTable("historic_retry_operations", (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.LicensingEndpointEntity", b => + { + b.Property("NormalizedName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("normalized_name"); + + b.Property("ThroughputSource") + .HasColumnType("integer") + .HasColumnName("throughput_source"); + + b.PrimitiveCollection>("EndpointIndicators") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("endpoint_indicators"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NormalizedSanitizedName") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("normalized_sanitized_name"); + + b.Property("SanitizedName") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("sanitized_name"); + + b.Property("Scope") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("scope"); + + b.Property("UserIndicator") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("user_indicator"); + + b.HasKey("NormalizedName", "ThroughputSource") + .HasName("pk_licensing_endpoints"); + + b.HasIndex("NormalizedSanitizedName") + .HasDatabaseName("ix_licensing_endpoints_normalized_sanitized_name"); + + b.ToTable("licensing_endpoints", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointThroughputEntity", b => + { + b.Property("NormalizedName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("normalized_name"); + + b.Property("ThroughputSource") + .HasColumnType("integer") + .HasColumnName("throughput_source"); + + b.Property("DateUtc") + .HasColumnType("date") + .HasColumnName("date_utc"); + + b.Property("MessageCount") + .HasColumnType("bigint") + .HasColumnName("message_count"); + + b.HasKey("NormalizedName", "ThroughputSource", "DateUtc") + .HasName("pk_licensing_endpoint_throughput"); + + b.HasIndex("DateUtc") + .HasDatabaseName("ix_licensing_endpoint_throughput_date_utc"); + + b.ToTable("licensing_endpoint_throughput", (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.SettingEntity", b => + { + b.Property("Key") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Key") + .HasName("pk_settings"); + + b.ToTable("settings", (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.UnacknowledgedRetryOperationEntity", b => + { + b.Property("RequestId") + .HasMaxLength(400) + .HasColumnType("character varying(400)") + .HasColumnName("request_id"); + + b.Property("RetryType") + .HasColumnType("integer") + .HasColumnName("retry_type"); + + b.Property("Classifier") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("classifier"); + + b.Property("CompletionTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("completion_time"); + + b.Property("Failed") + .HasColumnType("boolean") + .HasColumnName("failed"); + + b.Property("Last") + .HasColumnType("timestamp with time zone") + .HasColumnName("last"); + + b.Property("NumberOfMessagesProcessed") + .HasColumnType("integer") + .HasColumnName("number_of_messages_processed"); + + b.Property("Originator") + .HasColumnType("text") + .HasColumnName("originator"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.HasKey("RequestId", "RetryType") + .HasName("pk_unacknowledged_retry_operations"); + + b.ToTable("unacknowledged_retry_operations", (string)null); + }); + + 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"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointThroughputEntity", b => + { + b.HasOne("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointEntity", null) + .WithMany() + .HasForeignKey("NormalizedName", "ThroughputSource") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_licensing_endpoint_throughput_licensing_endpoints_normalize"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260915050538_AddAdditionalIndexCoverage.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260915050538_AddAdditionalIndexCoverage.cs new file mode 100644 index 0000000000..9f38511303 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260915050538_AddAdditionalIndexCoverage.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations +{ + /// + public partial class AddAdditionalIndexCoverage : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_failed_messages_status_last_modified", + table: "failed_messages"); + + migrationBuilder.DropIndex( + name: "ix_failed_message_groups_type_group_id", + table: "failed_message_groups"); + + migrationBuilder.CreateIndex( + name: "ix_failed_messages_status_last_modified_unique_message_id", + table: "failed_messages", + columns: new[] { "status", "last_modified", "unique_message_id" }) + .Annotation("Npgsql:IndexInclude", new[] { "first_time_of_failure", "last_time_of_failure" }); + + migrationBuilder.CreateIndex( + name: "ix_failed_message_groups_type_group_id", + table: "failed_message_groups", + columns: new[] { "type", "group_id" }) + .Annotation("Npgsql:IndexInclude", new[] { "failed_message_unique_id", "title" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_failed_messages_status_last_modified_unique_message_id", + table: "failed_messages"); + + migrationBuilder.DropIndex( + name: "ix_failed_message_groups_type_group_id", + table: "failed_message_groups"); + + migrationBuilder.CreateIndex( + name: "ix_failed_messages_status_last_modified", + table: "failed_messages", + columns: new[] { "status", "last_modified" }) + .Annotation("Npgsql:IndexInclude", new[] { "first_time_of_failure", "last_time_of_failure" }); + + migrationBuilder.CreateIndex( + name: "ix_failed_message_groups_type_group_id", + table: "failed_message_groups", + columns: new[] { "type", "group_id" }) + .Annotation("Npgsql:IndexInclude", new[] { "title" }); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs index b32d5ae3fa..06b1a9b639 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs @@ -438,14 +438,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TimeSent") .HasDatabaseName("ix_failed_messages_time_sent"); - b.HasIndex("Status", "LastModified") - .HasDatabaseName("ix_failed_messages_status_last_modified"); - - NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Status", "LastModified"), new[] { "FirstTimeOfFailure", "LastTimeOfFailure" }); - b.HasIndex("Status", "LastTimeOfFailure") .HasDatabaseName("ix_failed_messages_status_last_time_of_failure"); + b.HasIndex("Status", "LastModified", "UniqueMessageId") + .HasDatabaseName("ix_failed_messages_status_last_modified_unique_message_id"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Status", "LastModified", "UniqueMessageId"), new[] { "FirstTimeOfFailure", "LastTimeOfFailure" }); + b.HasIndex("Status", "MessageType", "UniqueMessageId") .HasDatabaseName("ix_failed_messages_status_message_type_unique_message_id"); @@ -483,7 +483,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Type", "GroupId") .HasDatabaseName("ix_failed_message_groups_type_group_id"); - NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Type", "GroupId"), new[] { "Title" }); + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Type", "GroupId"), new[] { "FailedMessageUniqueId", "Title" }); b.ToTable("failed_message_groups", (string)null); }); diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlServiceControlDbContext.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlServiceControlDbContext.cs index e7d001a2cf..21ca3b0b4c 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlServiceControlDbContext.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/PostgreSqlServiceControlDbContext.cs @@ -1,10 +1,9 @@ namespace ServiceControl.Persistence.EFCore.PostgreSql; +using EntityConfigurations; using Microsoft.EntityFrameworkCore; using Npgsql; -using ServiceControl.MessageFailures; using ServiceControl.Persistence.EFCore.DbContexts; -using ServiceControl.Persistence.EFCore.Entities; public class PostgreSqlServiceControlDbContext(DbContextOptions options) : ServiceControlDbContext(options) { @@ -19,22 +18,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - - modelBuilder.Entity() - .HasIndex(e => e.StatusChangedAt) - .HasFilter($"status IN ({(int)FailedMessageStatus.Resolved}, {(int)FailedMessageStatus.Archived})"); - - // Widen the group-aggregate indexes with covering INCLUDE columns so the - // /api/recoverability/groups/ aggregate is index-only (no key lookups / sequential scan with a - // residual status predicate). The IncludeProperties API is - // provider-specific, so the widening is applied here rather than in the shared configuration. - modelBuilder.Entity() - .HasIndex(e => new { e.Status, e.LastModified }) - .IncludeProperties(nameof(FailedMessageEntity.FirstTimeOfFailure), nameof(FailedMessageEntity.LastTimeOfFailure)); - - modelBuilder.Entity() - .HasIndex(e => new { e.Type, e.GroupId }) - .IncludeProperties(nameof(FailedMessageGroupEntity.Title)); + modelBuilder.ApplyConfiguration(new FailedMessageConfiguration()); + modelBuilder.ApplyConfiguration(new FailedMessageGroupConfiguration()); } public override bool IsDuplicateKeyException(DbUpdateException exception) diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/EntityConfigurations/FailedMessageConfiguration.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/EntityConfigurations/FailedMessageConfiguration.cs new file mode 100644 index 0000000000..e7ecd53a74 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/EntityConfigurations/FailedMessageConfiguration.cs @@ -0,0 +1,23 @@ +namespace ServiceControl.Persistence.EFCore.EntityConfigurations; + +using Entities; +using MessageFailures; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class FailedMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Drives the retention sweep. + // The index is restricted to the statuses the sweep deletes (Resolved and Archived) + builder.HasIndex(e => e.StatusChangedAt) + .HasFilter($"[Status] IN ({(int)FailedMessageStatus.Resolved}, {(int)FailedMessageStatus.Archived})"); + + // Widen the group-aggregate indexes with covering INCLUDE columns so the + // /api/recoverability/groups/ aggregate is index-only (no key lookups / clustered scan with a + // residual Status predicate). + builder.HasIndex(e => new { e.Status, e.LastModified }) + .IncludeProperties(nameof(FailedMessageEntity.FirstTimeOfFailure), nameof(FailedMessageEntity.LastTimeOfFailure)); + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/EntityConfigurations/FailedMessageGroupConfiguration.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/EntityConfigurations/FailedMessageGroupConfiguration.cs new file mode 100644 index 0000000000..f0977c2140 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/EntityConfigurations/FailedMessageGroupConfiguration.cs @@ -0,0 +1,12 @@ +namespace ServiceControl.Persistence.EFCore.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class FailedMessageGroupConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) => + builder.HasIndex(e => new { e.Type, e.GroupId }) + .IncludeProperties(nameof(FailedMessageGroupEntity.Title)); +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerServiceControlDbContext.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerServiceControlDbContext.cs index 1c415c20ff..90ce6a48e8 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerServiceControlDbContext.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerServiceControlDbContext.cs @@ -1,6 +1,7 @@ namespace ServiceControl.Persistence.EFCore.SqlServer; using System; +using EntityConfigurations; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using ServiceControl.MessageFailures; @@ -20,24 +21,8 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - - // Drives the retention sweep. The index is restricted to the statuses the sweep deletes - // (Resolved and Archived) by a provider specific filter, applied in the provider DbContext. - modelBuilder.Entity() - .HasIndex(e => e.StatusChangedAt) - .HasFilter($"[Status] IN ({(int)FailedMessageStatus.Resolved}, {(int)FailedMessageStatus.Archived})"); - - // Widen the group-aggregate indexes with covering INCLUDE columns so the - // /api/recoverability/groups/ aggregate is index-only (no key lookups / clustered scan with a - // residual Status predicate). The IncludeProperties API is - // provider-specific, so the widening is applied here rather than in the shared configuration. - modelBuilder.Entity() - .HasIndex(e => new { e.Status, e.LastModified }) - .IncludeProperties(nameof(FailedMessageEntity.FirstTimeOfFailure), nameof(FailedMessageEntity.LastTimeOfFailure)); - - modelBuilder.Entity() - .HasIndex(e => new { e.Type, e.GroupId }) - .IncludeProperties(nameof(FailedMessageGroupEntity.Title)); + modelBuilder.ApplyConfiguration(new FailedMessageConfiguration()); + modelBuilder.ApplyConfiguration(new FailedMessageGroupConfiguration()); } public override bool IsDuplicateKeyException(DbUpdateException exception) diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageConfiguration.cs index d143d4973b..1bff26175c 100644 --- a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageConfiguration.cs +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageConfiguration.cs @@ -20,10 +20,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.LastAttemptedAt).IsRequired(); builder.Property(e => e.MessageId).HasMaxLength(ColumnLengths.ShortTextLength); - // 450 and not nvarchar(max): the column has to be indexable to serve sort=message_type, and - // SQL Server rejects nvarchar(max) as an index key column. Type names are short-by-nature - // (the enricher stores the first comma token of EnclosedMessageTypes); ingestion enforces - // the cap so the write path can never fail on a longer value. builder.Property(e => e.MessageType).HasMaxLength(ColumnLengths.ShortTextLength); builder.Property(e => e.ConversationId).HasMaxLength(ColumnLengths.ShortTextLength); builder.Property(e => e.SendingEndpointName).HasMaxLength(ColumnLengths.ShortTextLength); @@ -38,12 +34,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.BodyStoredExternally).IsRequired(); builder.Property(e => e.BodySize).IsRequired(); - // Drives the group aggregate's MIN/MAX(FirstTimeOfFailure, LastTimeOfFailure) over the - // unresolved set. The FirstTimeOfFailure/LastTimeOfFailure INCLUDE columns are added in the - // provider DbContexts (the IncludeProperties API is provider-specific and is not available in - // this shared project). - builder.HasIndex(e => new { e.Status, e.LastModified }); - // Serves the failed-messages page sorted by time_of_failure (ServicePulse default sort). // Keyed (Status, LastTimeOfFailure) so the page query streams instead of scanning the // clustered table. diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs index 6b47d7c755..750de7c3a5 100644 --- a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/FailedMessageGroupConfiguration.cs @@ -17,8 +17,6 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(e => e.GroupId); // Drives the per-classifier group aggregate, which filters on Type and groups by GroupId. - // The Title INCLUDE column is added in the provider DbContexts (the IncludeProperties API - // is provider-specific and is not available in this shared project). builder.HasIndex(e => new { e.Type, e.GroupId }); builder.HasOne() diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs index ad5d305d74..861487a218 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/GroupsDataStore.cs @@ -127,44 +127,15 @@ static async Task AttachComments(ServiceControlDbContext dbContext, IList - /// Two-step group aggregate: - /// 1. Narrow aggregate on (GroupId, Type) — avoids hashing nvarchar(max) Title per joined row. - /// Order by MAX(LastTimeOfFailure) desc, take MaxGroups (200), materialise. - /// 2. Fetch Title per output group (≤200 index seeks) from the same filtered group rows. - /// Title is functionally dependent on GroupId, so the result is identical to grouping by Title. - /// - static async Task> GetGroupViews(IQueryable groups, IQueryable messages, CancellationToken cancellationToken) - { - var summaries = await groups - .AggregateGroupSummaries(messages) + // One aggregate statement: Count/First/Last come from the joined message rows and the Title + // lookup per output group is bounded by FailureGroupQueries.MaxGroups. See + // FailureGroupQueries.AggregateGroups for the provider index shapes this relies on — on + // PostgreSQL the classifier and messages indexes must carry the join column or the whole + // aggregate degrades to sequential scans of both large tables. + static async Task> GetGroupViews(IQueryable groups, IQueryable messages, CancellationToken cancellationToken) => + await groups + .AggregateGroups(messages) .OrderByDescending(summary => summary.Last) .Take(FailureGroupQueries.MaxGroups) .ToListAsync(cancellationToken); - - if (summaries.Count == 0) - { - return []; - } - - var groupIds = summaries.Select(summary => summary.Id).ToArray(); - - var titles = await groups - .Where(group => groupIds.Contains(group.GroupId)) - .Select(group => new { group.GroupId, group.Title }) - .Distinct() - .ToDictionaryAsync(group => group.GroupId, group => group.Title, cancellationToken); - - return summaries - .Select(summary => new FailureGroupView - { - Id = summary.Id, - Title = titles.GetValueOrDefault(summary.Id) ?? string.Empty, - Type = summary.Type, - Count = summary.Count, - First = summary.First, - Last = summary.Last - }) - .ToList(); - } } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/EFRecoverabilityIngestionUnitOfWork.cs b/src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/EFRecoverabilityIngestionUnitOfWork.cs index 6c5c4de880..43a0e7c927 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/EFRecoverabilityIngestionUnitOfWork.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/EFRecoverabilityIngestionUnitOfWork.cs @@ -1,12 +1,11 @@ namespace ServiceControl.Persistence.EFCore.Implementation.UnitOfWork; -using System.Text.Json; +using EntityConfigurations; using NServiceBus; using NServiceBus.Transport; using ServiceControl.MessageFailures; using ServiceControl.Operations; using ServiceControl.Persistence.EFCore.Abstractions; -using ServiceControl.Persistence.EFCore.EntityConfigurations; using ServiceControl.Persistence.EFCore.Infrastructure; using ServiceControl.Persistence.Infrastructure; using ServiceControl.Persistence.UnitOfWork; diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs index e08f1dc240..a7f10c6276 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/FailureGroupQueries.cs @@ -8,54 +8,29 @@ static class FailureGroupQueries public const int MaxGroups = 200; /// - /// Aggregate with Title in the group key. Used by - /// where a single group is fetched and the nvarchar(max) Title cost is negligible. + /// The group aggregate: membership rows joined to their message, grouped by (GroupId, Type), + /// with Count/First/Last per group. /// 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) - }; - - /// - /// Narrow aggregate on (GroupId, Type) only — the first step of the two-step group query. - /// Title is functionally dependent on GroupId (GroupId = DeterministicGuid.MakeId(classifier.Name, - /// classification)), so grouping without it is semantically equivalent. Keeping nvarchar(max) Title - /// out of the group key avoids per-row LOB hashing/sorting and the large memory grant it demands. - /// - public static IQueryable AggregateGroupSummaries(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.Type } - into aggregate - select new GroupSummary - { - Id = aggregate.Key.GroupId, - Type = aggregate.Key.Type, - Count = aggregate.Count(), - First = aggregate.Min(message => message.FirstTimeOfFailure), - Last = aggregate.Max(message => message.LastTimeOfFailure) - }; -} - -/// -/// Intermediate projection for step 1 of the two-step group aggregate. Title is fetched -/// separately in step 2 to keep nvarchar(max) out of the aggregate hash/sort. -/// -sealed class GroupSummary -{ - public required string Id { get; set; } - public required string Type { get; set; } - public int Count { get; set; } - public DateTime First { get; set; } - public DateTime Last { get; set; } -} + groups + .Join(messages, + failureGroup => failureGroup.FailedMessageUniqueId, message => message.UniqueMessageId, + (failureGroup, message) => new { failureGroup, message }) + .GroupBy(t => new { t.failureGroup.GroupId, t.failureGroup.Type }, t => new + { + t.failureGroup.Title, + t.message.FirstTimeOfFailure, + t.message.LastTimeOfFailure, + }) + .Select(aggregate => new FailureGroupView + { + Id = aggregate.Key.GroupId, + // GroupId is DeterministicGuid.MakeId(classifier.Name, classification), so Title is + // functionally dependent on the group key: the first row's Title is the group's Title. + Title = aggregate.First().Title, + Type = aggregate.Key.Type, + Count = aggregate.Count(), + First = aggregate.Min(message => message.FirstTimeOfFailure), + Last = aggregate.Max(message => message.LastTimeOfFailure) + }); +} \ No newline at end of file