diff --git a/src/Particular.LicensingComponent.Persistence/ThroughputReporting.cs b/src/Particular.LicensingComponent.Persistence/ThroughputReporting.cs new file mode 100644 index 0000000000..d51da2c2ff --- /dev/null +++ b/src/Particular.LicensingComponent.Persistence/ThroughputReporting.cs @@ -0,0 +1,10 @@ +namespace Particular.LicensingComponent.Persistence; + +public static class ThroughputReporting +{ + /// + /// How far back a throughput report reaches. Throughput reads are filtered to this window, so it + /// has to be the same wherever is implemented. + /// + public const int ReportedMonths = 14; +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260807053832_AddLicensingThroughput.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260807053832_AddLicensingThroughput.Designer.cs new file mode 100644 index 0000000000..d2995c6c11 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260807053832_AddLicensingThroughput.Designer.cs @@ -0,0 +1,866 @@ +// +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("20260807053832_AddLicensingThroughput")] + partial class AddLicensingThroughput + { + /// + 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("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("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("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("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/20260807053832_AddLicensingThroughput.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260807053832_AddLicensingThroughput.cs new file mode 100644 index 0000000000..8a5004759f --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260807053832_AddLicensingThroughput.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations +{ + /// + public partial class AddLicensingThroughput : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "licensing_endpoints", + columns: table => new + { + normalized_name = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + throughput_source = table.Column(type: "integer", nullable: false), + name = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + sanitized_name = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + normalized_sanitized_name = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + user_indicator = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + scope = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + endpoint_indicators = table.Column>(type: "text[]", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_licensing_endpoints", x => new { x.normalized_name, x.throughput_source }); + }); + + migrationBuilder.CreateTable( + name: "licensing_endpoint_throughput", + columns: table => new + { + normalized_name = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + throughput_source = table.Column(type: "integer", nullable: false), + date_utc = table.Column(type: "date", nullable: false), + message_count = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_licensing_endpoint_throughput", x => new { x.normalized_name, x.throughput_source, x.date_utc }); + table.ForeignKey( + name: "fk_licensing_endpoint_throughput_licensing_endpoints_normalize", + columns: x => new { x.normalized_name, x.throughput_source }, + principalTable: "licensing_endpoints", + principalColumns: new[] { "normalized_name", "throughput_source" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_licensing_endpoint_throughput_date_utc", + table: "licensing_endpoint_throughput", + column: "date_utc"); + + migrationBuilder.CreateIndex( + name: "ix_licensing_endpoints_normalized_sanitized_name", + table: "licensing_endpoints", + column: "normalized_sanitized_name"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "licensing_endpoint_throughput"); + + migrationBuilder.DropTable( + name: "licensing_endpoints"); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs index bf18730550..8fc60dc49f 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs @@ -551,6 +551,88 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -765,6 +847,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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.SqlServer/Migrations/20260807053830_AddLicensingThroughput.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260807053830_AddLicensingThroughput.Designer.cs new file mode 100644 index 0000000000..ea0b7881a8 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260807053830_AddLicensingThroughput.Designer.cs @@ -0,0 +1,694 @@ +// +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("20260807053830_AddLicensingThroughput")] + partial class AddLicensingThroughput + { + /// + 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("RequestId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ArchiveType") + .HasColumnType("int"); + + b.Property("OperationType") + .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("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", "OperationType"); + + b.HasIndex("Started"); + + b.ToTable("ArchiveOperations"); + }); + + 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("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("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.HistoricRetryOperationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionTime") + .HasColumnType("datetime2"); + + b.Property("Failed") + .HasColumnType("bit"); + + b.Property("NumberOfMessagesProcessed") + .HasColumnType("int"); + + b.Property("Originator") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("RetryType") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CompletionTime", "Id") + .IsDescending(); + + b.ToTable("HistoricRetryOperations"); + }); + + 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.LicensingEndpointEntity", b => + { + b.Property("NormalizedName") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("ThroughputSource") + .HasColumnType("int"); + + b.PrimitiveCollection("EndpointIndicators") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("NormalizedSanitizedName") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("SanitizedName") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Scope") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("UserIndicator") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("NormalizedName", "ThroughputSource"); + + b.HasIndex("NormalizedSanitizedName"); + + b.ToTable("LicensingEndpoints"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointThroughputEntity", b => + { + b.Property("NormalizedName") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("ThroughputSource") + .HasColumnType("int"); + + b.Property("DateUtc") + .HasColumnType("date"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.HasKey("NormalizedName", "ThroughputSource", "DateUtc"); + + b.HasIndex("DateUtc"); + + b.ToTable("LicensingEndpointThroughput"); + }); + + 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.SettingEntity", b => + { + b.Property("Key") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + 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.UnacknowledgedRetryOperationEntity", b => + { + b.Property("RequestId") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("RetryType") + .HasColumnType("int"); + + b.Property("Classifier") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("CompletionTime") + .HasColumnType("datetime2"); + + b.Property("Failed") + .HasColumnType("bit"); + + b.Property("Last") + .HasColumnType("datetime2"); + + b.Property("NumberOfMessagesProcessed") + .HasColumnType("int"); + + b.Property("Originator") + .HasColumnType("nvarchar(max)"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.HasKey("RequestId", "RetryType"); + + b.ToTable("UnacknowledgedRetryOperations"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => + { + b.HasOne("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", null) + .WithMany() + .HasForeignKey("FailedMessageUniqueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointThroughputEntity", b => + { + b.HasOne("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointEntity", null) + .WithMany() + .HasForeignKey("NormalizedName", "ThroughputSource") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260807053830_AddLicensingThroughput.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260807053830_AddLicensingThroughput.cs new file mode 100644 index 0000000000..6ce091ffe3 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260807053830_AddLicensingThroughput.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations +{ + /// + public partial class AddLicensingThroughput : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "LicensingEndpoints", + columns: table => new + { + NormalizedName = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), + ThroughputSource = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), + SanitizedName = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + NormalizedSanitizedName = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + UserIndicator = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + Scope = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: true), + EndpointIndicators = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LicensingEndpoints", x => new { x.NormalizedName, x.ThroughputSource }); + }); + + migrationBuilder.CreateTable( + name: "LicensingEndpointThroughput", + columns: table => new + { + NormalizedName = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), + ThroughputSource = table.Column(type: "int", nullable: false), + DateUtc = table.Column(type: "date", nullable: false), + MessageCount = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LicensingEndpointThroughput", x => new { x.NormalizedName, x.ThroughputSource, x.DateUtc }); + table.ForeignKey( + name: "FK_LicensingEndpointThroughput_LicensingEndpoints_NormalizedName_ThroughputSource", + columns: x => new { x.NormalizedName, x.ThroughputSource }, + principalTable: "LicensingEndpoints", + principalColumns: new[] { "NormalizedName", "ThroughputSource" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_LicensingEndpoints_NormalizedSanitizedName", + table: "LicensingEndpoints", + column: "NormalizedSanitizedName"); + + migrationBuilder.CreateIndex( + name: "IX_LicensingEndpointThroughput_DateUtc", + table: "LicensingEndpointThroughput", + column: "DateUtc"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LicensingEndpointThroughput"); + + migrationBuilder.DropTable( + name: "LicensingEndpoints"); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs index 3a8d7dba15..8d83b415d3 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs @@ -438,6 +438,72 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("KnownEndpoints"); }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointEntity", b => + { + b.Property("NormalizedName") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("ThroughputSource") + .HasColumnType("int"); + + b.PrimitiveCollection("EndpointIndicators") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("NormalizedSanitizedName") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("SanitizedName") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Scope") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("UserIndicator") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("NormalizedName", "ThroughputSource"); + + b.HasIndex("NormalizedSanitizedName"); + + b.ToTable("LicensingEndpoints"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointThroughputEntity", b => + { + b.Property("NormalizedName") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("ThroughputSource") + .HasColumnType("int"); + + b.Property("DateUtc") + .HasColumnType("date"); + + b.Property("MessageCount") + .HasColumnType("bigint"); + + b.HasKey("NormalizedName", "ThroughputSource", "DateUtc"); + + b.HasIndex("DateUtc"); + + b.ToTable("LicensingEndpointThroughput"); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.MessageRedirectEntity", b => { b.Property("FromPhysicalAddress") @@ -610,6 +676,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointThroughputEntity", b => + { + b.HasOne("ServiceControl.Persistence.EFCore.Entities.LicensingEndpointEntity", null) + .WithMany() + .HasForeignKey("NormalizedName", "ThroughputSource") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs b/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs index 15d13674c4..fe962b00a9 100644 --- a/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs +++ b/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs @@ -23,6 +23,8 @@ public abstract class ServiceControlDbContext(DbContextOptions options) : DbCont public DbSet HistoricRetryOperations { get; set; } public DbSet UnacknowledgedRetryOperations { get; set; } public DbSet ArchiveOperations { get; set; } + public DbSet LicensingEndpoints { get; set; } + public DbSet LicensingEndpointThroughput { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.EnableDetailedErrors(); @@ -48,6 +50,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new HistoricRetryOperationConfiguration()); modelBuilder.ApplyConfiguration(new UnacknowledgedRetryOperationConfiguration()); modelBuilder.ApplyConfiguration(new ArchiveOperationConfiguration()); + modelBuilder.ApplyConfiguration(new LicensingEndpointConfiguration()); + modelBuilder.ApplyConfiguration(new LicensingEndpointThroughputConfiguration()); } public abstract bool IsDuplicateKeyException(DbUpdateException exception); diff --git a/src/ServiceControl.Persistence.EFCore/Entities/LicensingEndpointEntity.cs b/src/ServiceControl.Persistence.EFCore/Entities/LicensingEndpointEntity.cs new file mode 100644 index 0000000000..f1da07ed0c --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Entities/LicensingEndpointEntity.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.Persistence.EFCore.Entities; + +using Particular.LicensingComponent.Contracts; + +public class LicensingEndpointEntity +{ + public required string NormalizedName { get; set; } + + public ThroughputSource ThroughputSource { get; set; } + + public required string Name { get; set; } + + public required string SanitizedName { get; set; } + + public required string NormalizedSanitizedName { get; set; } + + public string UserIndicator { get; set; } = string.Empty; + + public string? Scope { get; set; } + + public List EndpointIndicators { get; set; } = []; +} diff --git a/src/ServiceControl.Persistence.EFCore/Entities/LicensingEndpointThroughputEntity.cs b/src/ServiceControl.Persistence.EFCore/Entities/LicensingEndpointThroughputEntity.cs new file mode 100644 index 0000000000..02dcbeff29 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Entities/LicensingEndpointThroughputEntity.cs @@ -0,0 +1,14 @@ +namespace ServiceControl.Persistence.EFCore.Entities; + +using Particular.LicensingComponent.Contracts; + +public class LicensingEndpointThroughputEntity +{ + public required string NormalizedName { get; set; } + + public ThroughputSource ThroughputSource { get; set; } + + public DateOnly DateUtc { get; set; } + + public long MessageCount { get; set; } +} diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs index d66b0cddda..bf40c72369 100644 --- a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs @@ -17,4 +17,10 @@ static class ColumnLengths // put it over SQL Server's 900 byte limit. 800 + 4 fits, with room for the queue addresses that // ByQueueAddress retries use as their request id. public const int RetryRequestIdLength = 400; + + // Applies to both the licensing endpoint name and the normalized name computed from it, which + // therefore has to hold anything the name can. The key is this column plus the ThroughputSource + // int, and the throughput key adds a date on top, so ShortTextLength would exceed SQL Server's + // 900 byte index key limit. + public const int LicensingEndpointNameLength = 300; } diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/LicensingEndpointConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/LicensingEndpointConfiguration.cs new file mode 100644 index 0000000000..05a1659b2d --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/LicensingEndpointConfiguration.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.Persistence.EFCore.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class LicensingEndpointConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.NormalizedName, e.ThroughputSource }); + builder.Property(e => e.NormalizedName).HasMaxLength(ColumnLengths.LicensingEndpointNameLength); + builder.Property(e => e.Name).IsRequired().HasMaxLength(ColumnLengths.LicensingEndpointNameLength); + builder.Property(e => e.SanitizedName).IsRequired().HasMaxLength(ColumnLengths.ShortTextLength); + builder.Property(e => e.NormalizedSanitizedName).IsRequired().HasMaxLength(ColumnLengths.ShortTextLength); + builder.Property(e => e.UserIndicator).IsRequired().HasMaxLength(ColumnLengths.ShortTextLength); + builder.Property(e => e.Scope).HasMaxLength(ColumnLengths.ShortTextLength); + builder.Property(e => e.EndpointIndicators).IsRequired(); + + builder.HasIndex(e => e.NormalizedSanitizedName); + } +} diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/LicensingEndpointThroughputConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/LicensingEndpointThroughputConfiguration.cs new file mode 100644 index 0000000000..e64a277617 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/LicensingEndpointThroughputConfiguration.cs @@ -0,0 +1,20 @@ +namespace ServiceControl.Persistence.EFCore.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class LicensingEndpointThroughputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.NormalizedName, e.ThroughputSource, e.DateUtc }); + builder.Property(e => e.NormalizedName).HasMaxLength(ColumnLengths.LicensingEndpointNameLength); + builder.HasOne() + .WithMany() + .HasForeignKey(e => new { e.NormalizedName, e.ThroughputSource }) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(e => e.DateUtc); + } +} diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs index acf8fe9102..3405c3d627 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs @@ -1,23 +1,360 @@ +namespace ServiceControl.Persistence.EFCore.Implementation; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Particular.LicensingComponent.Contracts; using Particular.LicensingComponent.Persistence; +using ServiceControl.Persistence.EFCore.DbContexts; +using ServiceControl.Persistence.EFCore.Entities; +using ServiceControl.Persistence.EFCore.Infrastructure; -class LicensingDataStore : ILicensingDataStore +class LicensingDataStore(IServiceScopeFactory scopeFactory, TimeProvider timeProvider) : DataStoreBase(scopeFactory), ILicensingDataStore { - public Task> GetAllEndpoints(bool includePlatformEndpoints, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task GetAuditServiceMetadata(CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task GetBrokerMetadata(CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task GetEndpoint(EndpointIdentifier id, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task> GetEndpoints(IList endpointIds, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task>> GetEndpointThroughputByQueueName(IList queueNames, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task GetLicensedEndpointDetails(CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task> GetReportMasks(CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task IsThereThroughputForLastXDays(int days, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task IsThereThroughputForLastXDaysForSource(int days, ThroughputSource throughputSource, bool includeToday, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSource, IList throughput, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task SaveBrokerMetadata(BrokerMetadata brokerMetadata, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task SaveEndpoint(Particular.LicensingComponent.Contracts.Endpoint endpoint, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task SaveReportMasks(List reportMasks, CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task UpdateUserIndicatorOnEndpoints(List userIndicatorUpdates, CancellationToken cancellationToken) => throw new NotImplementedException(); -} \ No newline at end of file + const int MaxRecordAttempts = 5; + + static readonly string PlatformEndpointIndicator = EndpointIndicator.PlatformEndpoint.ToString(); + static readonly AuditServiceMetadata DefaultAuditServiceMetadata = new([], []); + static readonly BrokerMetadata DefaultBrokerMetadata = new(null, []); + + public Task> GetAllEndpoints(bool includePlatformEndpoints, CancellationToken cancellationToken) => + ExecuteWithDbContext(async context => + { + var query = context.LicensingEndpoints.AsNoTracking(); + + if (!includePlatformEndpoints) + { + query = query.Where(endpoint => !endpoint.EndpointIndicators.Contains(PlatformEndpointIndicator)); + } + + var rows = await query.ToListAsync(cancellationToken); + + return rows.Select(ToEndpoint); + }); + + public Task GetEndpoint(EndpointIdentifier id, CancellationToken cancellationToken = default) => + ExecuteWithDbContext(async context => + { + var normalizedName = Normalize(id.Name); + + var row = await context.LicensingEndpoints + .AsNoTracking() + .Where(endpoint => endpoint.NormalizedName == normalizedName && endpoint.ThroughputSource == id.ThroughputSource) + .Select(endpoint => new + { + Endpoint = endpoint, + LastCollectedDate = context.LicensingEndpointThroughput + .Where(row => row.NormalizedName == endpoint.NormalizedName && row.ThroughputSource == endpoint.ThroughputSource) + .Max(row => row.DateUtc) + }) + .SingleOrDefaultAsync(cancellationToken); + + return row is null ? null : ToEndpoint(row.Endpoint, row.LastCollectedDate); + }); + + public Task> GetEndpoints(IList endpointIds, CancellationToken cancellationToken) => + ExecuteWithDbContext(async context => + { + var normalizedNames = endpointIds.Select(id => Normalize(id.Name)).Distinct().ToList(); + + // Every requested endpoint in one round trip, then matched up in memory, because the ids + // can span several sources of the same name. + var rows = await context.LicensingEndpoints + .AsNoTracking() + .Where(endpoint => normalizedNames.Contains(endpoint.NormalizedName)) + .Select(endpoint => new + { + Endpoint = endpoint, + LastCollectedDate = context.LicensingEndpointThroughput + .Where(row => row.NormalizedName == endpoint.NormalizedName && row.ThroughputSource == endpoint.ThroughputSource) + .Max(row => row.DateUtc) + }) + .ToListAsync(cancellationToken); + + var found = rows.ToDictionary(row => (row.Endpoint.NormalizedName, row.Endpoint.ThroughputSource)); + + return endpointIds + .Select(id => (id, found.TryGetValue((Normalize(id.Name), id.ThroughputSource), out var row) + ? ToEndpoint(row.Endpoint, row.LastCollectedDate) + : null)) + .ToList() + .AsEnumerable(); + }); + + public Task SaveEndpoint(Endpoint endpoint, CancellationToken cancellationToken) => + ExecuteWithDbContext(context => + { + var normalizedName = Normalize(endpoint.Id.Name); + var sanitizedName = endpoint.SanitizedName ?? string.Empty; + var indicators = endpoint.EndpointIndicators?.ToList() ?? []; + + return context.UpsertAsync( + [normalizedName, endpoint.Id.ThroughputSource], + () => new LicensingEndpointEntity + { + NormalizedName = normalizedName, + ThroughputSource = endpoint.Id.ThroughputSource, + Name = endpoint.Id.Name, + SanitizedName = sanitizedName, + NormalizedSanitizedName = Normalize(sanitizedName), + UserIndicator = endpoint.UserIndicator ?? string.Empty, + Scope = endpoint.Scope, + EndpointIndicators = indicators + }, + row => + { + row.Name = endpoint.Id.Name; + row.SanitizedName = sanitizedName; + row.NormalizedSanitizedName = Normalize(sanitizedName); + row.UserIndicator = endpoint.UserIndicator ?? string.Empty; + row.Scope = endpoint.Scope; + row.EndpointIndicators = indicators; + }, + cancellationToken); + }); + + public Task>> GetEndpointThroughputByQueueName(IList queueNames, CancellationToken cancellationToken) => + ExecuteWithDbContext(async context => + { + var results = queueNames.ToDictionary(queueName => queueName, _ => Enumerable.Empty()); + + var requestedByNormalizedName = new Dictionary(); + foreach (var queueName in queueNames) + { + var normalizedName = Normalize(queueName); + if (!requestedByNormalizedName.ContainsKey(normalizedName)) + { + requestedByNormalizedName[normalizedName] = queueName; + } + } + + var normalizedNames = requestedByNormalizedName.Keys.ToList(); + var from = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime).AddMonths(-ThroughputReporting.ReportedMonths); + + var rows = await context.LicensingEndpoints + .AsNoTracking() + .Where(endpoint => normalizedNames.Contains(endpoint.NormalizedSanitizedName)) + .Join(context.LicensingEndpointThroughput.Where(throughput => throughput.DateUtc >= from), + endpoint => new { endpoint.NormalizedName, endpoint.ThroughputSource }, + throughput => new { throughput.NormalizedName, throughput.ThroughputSource }, + (endpoint, throughput) => new + { + endpoint.NormalizedSanitizedName, + endpoint.ThroughputSource, + throughput.DateUtc, + throughput.MessageCount + }) + .ToListAsync(cancellationToken); + + foreach (var endpointRows in rows.GroupBy(row => (row.NormalizedSanitizedName, row.ThroughputSource))) + { + var throughputData = new ThroughputData(endpointRows + .OrderBy(row => row.DateUtc) + .Select(row => new EndpointDailyThroughput(row.DateUtc, row.MessageCount))) + { + ThroughputSource = endpointRows.Key.ThroughputSource + }; + + var queueName = requestedByNormalizedName[endpointRows.Key.NormalizedSanitizedName]; + results[queueName] = results[queueName].Append(throughputData); + } + + return (IDictionary>)results; + }); + + public async Task RecordEndpointThroughput(string endpointName, ThroughputSource throughputSource, IList throughput, CancellationToken cancellationToken) + { + if (throughput.Count == 0) + { + return; + } + + // Only the first recording of a day can lose a race, and once its row exists every later + // writer takes the update path, so a couple of attempts is enough. + for (var attempt = 1; attempt <= MaxRecordAttempts; attempt++) + { + var recorded = await ExecuteWithDbContext(context => + TryRecordEndpointThroughput(context, endpointName, throughputSource, throughput, cancellationToken)); + + if (recorded) + { + return; + } + } + + throw new InvalidOperationException( + $"Could not record throughput for {endpointName} from {throughputSource} after {MaxRecordAttempts} attempts because of concurrent updates."); + } + + static async Task TryRecordEndpointThroughput(ServiceControlDbContext context, string endpointName, ThroughputSource throughputSource, IList throughput, CancellationToken cancellationToken) + { + var normalizedName = Normalize(endpointName); + + // Ordered so that concurrent calls covering overlapping days take the row locks in the same + // order and cannot deadlock against each other. + var dailyThroughput = throughput.OrderBy(entry => entry.DateUTC).ToList(); + + var strategy = context.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + + var endpointExists = await context.LicensingEndpoints + .AnyAsync(endpoint => endpoint.NormalizedName == normalizedName && endpoint.ThroughputSource == throughputSource, cancellationToken); + + if (!endpointExists) + { + throw new InvalidOperationException($"Endpoint {endpointName} from {throughputSource} does not exist "); + } + + foreach (var (date, messageCount) in dailyThroughput) + { + // Recording adds to the day's total, because a source can report the same day + // repeatedly. The addition happens in the database, so concurrent writers queue up on + // the row instead of overwriting each other's totals. + var updated = await context.LicensingEndpointThroughput + .Where(row => row.NormalizedName == normalizedName && row.ThroughputSource == throughputSource && row.DateUtc == date) + .ExecuteUpdateAsync(row => row.SetProperty(p => p.MessageCount, p => p.MessageCount + messageCount), cancellationToken); + + if (updated > 0) + { + continue; + } + + context.LicensingEndpointThroughput.Add(new LicensingEndpointThroughputEntity + { + NormalizedName = normalizedName, + ThroughputSource = throughputSource, + DateUtc = date, + MessageCount = messageCount + }); + + try + { + await context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (context.IsDuplicateKeyException(exception)) + { + // Another writer created the day's row first, so this call has to add to it + // instead. The failed insert leaves the transaction unusable, hence the retry. + return false; + } + } + + await transaction.CommitAsync(cancellationToken); + return true; + }); + } + + public Task UpdateUserIndicatorOnEndpoints(List userIndicatorUpdates, CancellationToken cancellationToken) => + ExecuteWithDbContext(async context => + { + var updates = userIndicatorUpdates.ToDictionary(update => Normalize(update.Name), update => update.UserIndicator); + var names = updates.Keys.ToList(); + + var rows = await context.LicensingEndpoints + .Where(endpoint => names.Contains(endpoint.NormalizedName) || names.Contains(endpoint.NormalizedSanitizedName)) + .ToListAsync(cancellationToken); + + var indicatorBySanitizedName = new Dictionary(); + + foreach (var row in rows) + { + if (updates.TryGetValue(row.NormalizedSanitizedName, out var valueFromSanitizedName)) + { + row.UserIndicator = valueFromSanitizedName; + } + else if (updates.TryGetValue(row.NormalizedName, out var valueFromName)) + { + row.UserIndicator = valueFromName; + indicatorBySanitizedName[row.NormalizedSanitizedName] = valueFromName; + } + } + + if (indicatorBySanitizedName.Count > 0) + { + var sanitizedNames = indicatorBySanitizedName.Keys.ToList(); + var alreadyLoaded = rows.Select(row => (row.NormalizedName, row.ThroughputSource)).ToHashSet(); + + var siblings = await context.LicensingEndpoints + .Where(endpoint => sanitizedNames.Contains(endpoint.NormalizedSanitizedName)) + .ToListAsync(cancellationToken); + + foreach (var sibling in siblings.Where(row => !alreadyLoaded.Contains((row.NormalizedName, row.ThroughputSource)))) + { + if (indicatorBySanitizedName.TryGetValue(sibling.NormalizedSanitizedName, out var indicator)) + { + sibling.UserIndicator = indicator; + } + } + } + + await context.SaveChangesAsync(cancellationToken); + }); + + public Task IsThereThroughputForLastXDays(int days, CancellationToken cancellationToken) => + IsThereThroughput(days, throughputSource: null, includeToday: false, cancellationToken); + + public Task IsThereThroughputForLastXDaysForSource(int days, ThroughputSource throughputSource, bool includeToday, CancellationToken cancellationToken) => + IsThereThroughput(days, throughputSource, includeToday, cancellationToken); + + Task IsThereThroughput(int days, ThroughputSource? throughputSource, bool includeToday, CancellationToken cancellationToken) => + ExecuteWithDbContext(context => + { + var today = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime); + var from = today.AddDays(-days); + var to = includeToday ? today : today.AddDays(-1); + + var query = context.LicensingEndpointThroughput + .AsNoTracking() + .Where(row => row.DateUtc >= from && row.DateUtc <= to); + + if (throughputSource is not null) + { + query = query.Where(row => row.ThroughputSource == throughputSource); + } + + return query.AnyAsync(cancellationToken); + }); + + public Task GetBrokerMetadata(CancellationToken cancellationToken) => + ExecuteWithDbContext(async context => + await context.GetSetting(SettingKeys.BrokerMetadata, cancellationToken) ?? DefaultBrokerMetadata); + + public Task SaveBrokerMetadata(BrokerMetadata brokerMetadata, CancellationToken cancellationToken) => + ExecuteWithDbContext(context => context.StoreSetting(SettingKeys.BrokerMetadata, brokerMetadata, cancellationToken)); + + public Task GetAuditServiceMetadata(CancellationToken cancellationToken = default) => + ExecuteWithDbContext(async context => + await context.GetSetting(SettingKeys.AuditServiceMetadata, cancellationToken) ?? DefaultAuditServiceMetadata); + + public Task SaveAuditServiceMetadata(AuditServiceMetadata auditServiceMetadata, CancellationToken cancellationToken) => + ExecuteWithDbContext(context => context.StoreSetting(SettingKeys.AuditServiceMetadata, auditServiceMetadata, cancellationToken)); + + public Task> GetReportMasks(CancellationToken cancellationToken) => + ExecuteWithDbContext(async context => + await context.GetSetting>(SettingKeys.ReportMasks, cancellationToken) ?? []); + + public Task SaveReportMasks(List reportMasks, CancellationToken cancellationToken) => + ExecuteWithDbContext(context => context.StoreSetting(SettingKeys.ReportMasks, reportMasks, cancellationToken)); + + public Task GetLicensedEndpointDetails(CancellationToken cancellationToken) => + ExecuteWithDbContext(context => context.GetSetting(SettingKeys.LicensedEndpointDetails, cancellationToken)); + + public Task SaveLicensedEndpointDetails(LicensedEndpointDetails result, CancellationToken cancellationToken) => + ExecuteWithDbContext(context => context.StoreSetting(SettingKeys.LicensedEndpointDetails, result, cancellationToken)); + + static Endpoint ToEndpoint(LicensingEndpointEntity row) => ToEndpoint(row, lastCollectedDate: null); + + static Endpoint ToEndpoint(LicensingEndpointEntity row, DateOnly? lastCollectedDate) => + new(new EndpointIdentifier(row.Name, row.ThroughputSource)) + { + SanitizedName = row.SanitizedName, + EndpointIndicators = [.. row.EndpointIndicators], + UserIndicator = row.UserIndicator, + Scope = row.Scope, + LastCollectedDate = lastCollectedDate ?? default + }; + + static string Normalize(string name) => name.ToLowerInvariant(); +} diff --git a/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs b/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs index 0986c35ac0..bdafd60226 100644 --- a/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs +++ b/src/ServiceControl.Persistence.EFCore/Infrastructure/SettingKeys.cs @@ -4,4 +4,8 @@ namespace ServiceControl.Persistence.EFCore.Infrastructure; static class SettingKeys { public const string TrialEndDate = "TrialEndDate"; + public const string BrokerMetadata = "BrokerMetadata"; + public const string AuditServiceMetadata = "AuditServiceMetadata"; + public const string ReportMasks = "ReportMasks"; + public const string LicensedEndpointDetails = "LicensedEndpointDetails"; } diff --git a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs index 26b666cdcb..aecffa14d7 100644 --- a/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Throughput/LicensingDataStore.cs @@ -128,7 +128,7 @@ public async Task>> GetEndpointT var store = await storeProvider.GetDocumentStore(cancellationToken); using IAsyncDocumentSession session = store.OpenAsyncSession(databaseConfiguration.Name); - var from = DateTime.UtcNow.AddMonths(-14); + var from = DateTime.UtcNow.AddMonths(-ThroughputReporting.ReportedMonths); var query = session.Query() .Where(document => document.SanitizedName.In(queueNames)) .Include(builder => builder.IncludeTimeSeries(ThroughputTimeSeriesName, from)); diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs index 721f8d1cfd..b99a86b59b 100644 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs @@ -22,7 +22,7 @@ public partial class PersistenceTestsContext : IPersistenceTestsContext string databaseName; string bodyStoragePath; - public FakeTimeProvider FakeTime { get; } = new(); + public FakeTimeProvider FakeTime { get; } = new(DateTimeOffset.UtcNow); public async Task Setup(IHostApplicationBuilder hostBuilder) { 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 492db2ec1c..e585bb53be 100644 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj @@ -34,10 +34,6 @@ - - - - diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs index 9f3e59df8d..412ff6364a 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs @@ -21,7 +21,7 @@ public partial class PersistenceTestsContext : IPersistenceTestsContext string databaseName; string bodyStoragePath; - public FakeTimeProvider FakeTime { get; } = new(); + public FakeTimeProvider FakeTime { get; } = new(DateTimeOffset.UtcNow); public async Task Setup(IHostApplicationBuilder hostBuilder) { 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 2e762eb089..2ed196baf4 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj +++ b/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj @@ -34,10 +34,6 @@ - - - - diff --git a/src/ServiceControl.Persistence.Tests/EFCore/LicensingDataStoreEFTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/LicensingDataStoreEFTests.cs new file mode 100644 index 0000000000..fb266503a1 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/LicensingDataStoreEFTests.cs @@ -0,0 +1,214 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.LicensingComponent.Contracts; + +class LicensingDataStoreEFTests : PersistenceTestBase +{ + static DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow); + + [Test] + public async Task Recording_the_same_day_twice_adds_to_that_day() + { + await SaveEndpoint("Endpoint", ThroughputSource.Monitoring); + + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Monitoring, Today, 30, default); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Monitoring, Today, 12, default); + + var throughput = await GetThroughput("Endpoint"); + + Assert.That(throughput[Today], Is.EqualTo(42)); + } + + // Monitoring reports the same day from several instances, so the additions have to survive + // landing at the same moment rather than one overwriting the other. + [Test] + public async Task Concurrent_recordings_of_the_same_day_all_count() + { + const int writers = 10; + await SaveEndpoint("Endpoint", ThroughputSource.Monitoring); + + var recordings = Enumerable.Range(0, writers) + .Select(_ => LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Monitoring, Today, 5, default)); + + await Task.WhenAll(recordings); + + var throughput = await GetThroughput("Endpoint"); + + Assert.That(throughput[Today], Is.EqualTo(writers * 5)); + } + + [Test] + public async Task Endpoints_are_matched_regardless_of_name_casing() + { + await SaveEndpoint("SalesEndpoint", ThroughputSource.Broker); + + await LicensingDataStore.RecordEndpointThroughput("salesendpoint", ThroughputSource.Broker, Today, 7, default); + + var endpoint = await LicensingDataStore.GetEndpoint("SALESENDPOINT", ThroughputSource.Broker, default); + + Assert.That(endpoint, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(endpoint.Id.Name, Is.EqualTo("SalesEndpoint"), "the reported casing is what is stored"); + Assert.That(endpoint.LastCollectedDate, Is.EqualTo(Today)); + } + } + + [Test] + public async Task Last_collected_date_is_the_newest_recorded_day() + { + await SaveEndpoint("Endpoint", ThroughputSource.Audit); + + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today.AddDays(-5), 10, default); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today.AddDays(-1), 10, default); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today.AddDays(-3), 10, default); + + var endpoint = await LicensingDataStore.GetEndpoint("Endpoint", ThroughputSource.Audit, default); + + Assert.That(endpoint, Is.Not.Null); + Assert.That(endpoint.LastCollectedDate, Is.EqualTo(Today.AddDays(-1))); + } + + [Test] + public async Task Last_collected_date_is_unset_when_nothing_was_recorded() + { + await SaveEndpoint("Endpoint", ThroughputSource.Audit); + + var endpoint = await LicensingDataStore.GetEndpoint("Endpoint", ThroughputSource.Audit, default); + + Assert.That(endpoint, Is.Not.Null); + Assert.That(endpoint.LastCollectedDate, Is.EqualTo(default(DateOnly))); + } + + [Test] + public async Task Endpoints_are_returned_for_every_requested_id_including_the_unknown_ones() + { + await SaveEndpoint("Endpoint", ThroughputSource.Audit); + await SaveEndpoint("Endpoint", ThroughputSource.Broker); + + var requested = new[] + { + new EndpointIdentifier("Endpoint", ThroughputSource.Audit), + new EndpointIdentifier("Endpoint", ThroughputSource.Broker), + new EndpointIdentifier("Endpoint", ThroughputSource.Monitoring), + new EndpointIdentifier("Unknown", ThroughputSource.Audit) + }; + + var results = (await LicensingDataStore.GetEndpoints(requested, default)).ToList(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results, Has.Count.EqualTo(requested.Length)); + Assert.That(results[0].Endpoint, Is.Not.Null); + Assert.That(results[1].Endpoint, Is.Not.Null); + Assert.That(results[2].Endpoint, Is.Null, "same name, but no endpoint for that source"); + Assert.That(results[3].Endpoint, Is.Null); + } + } + + [Test] + public async Task Platform_endpoints_are_excluded_when_asked_for() + { + await LicensingDataStore.SaveEndpoint( + new Endpoint("Platform", ThroughputSource.Broker) + { + SanitizedName = "Platform", + EndpointIndicators = [EndpointIndicator.PlatformEndpoint.ToString()] + }, default); + await SaveEndpoint("Regular", ThroughputSource.Broker); + + var withPlatform = await LicensingDataStore.GetAllEndpoints(true, default); + var withoutPlatform = await LicensingDataStore.GetAllEndpoints(false, default); + + using (Assert.EnterMultipleScope()) + { + Assert.That(withPlatform.Count(), Is.EqualTo(2)); + Assert.That(withoutPlatform.Single().Id.Name, Is.EqualTo("Regular")); + } + } + + [Test] + public async Task Endpoint_indicators_survive_a_round_trip() + { + string[] indicators = [EndpointIndicator.KnownEndpoint.ToString(), EndpointIndicator.DelayBinding.ToString()]; + + await LicensingDataStore.SaveEndpoint( + new Endpoint("Endpoint", ThroughputSource.Broker) + { + SanitizedName = "Endpoint", + EndpointIndicators = indicators, + Scope = "vhost", + UserIndicator = UserIndicator.NServiceBusEndpoint.ToString() + }, default); + + var endpoint = await LicensingDataStore.GetEndpoint("Endpoint", ThroughputSource.Broker, default); + + Assert.That(endpoint, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(endpoint.EndpointIndicators, Is.EquivalentTo(indicators)); + Assert.That(endpoint.Scope, Is.EqualTo("vhost")); + Assert.That(endpoint.UserIndicator, Is.EqualTo(UserIndicator.NServiceBusEndpoint.ToString())); + } + } + + // A report covers the last 14 months, and older rows are left in place rather than filtered out + // by the retention sweep, so the read has to do the filtering. + [Test] + public async Task Throughput_older_than_the_reported_window_is_not_returned() + { + await SaveEndpoint("Endpoint", ThroughputSource.Broker); + + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Broker, Today.AddMonths(-15), 99, default); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Broker, Today.AddDays(-1), 5, default); + + var throughput = await GetThroughput("Endpoint"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(throughput, Has.Count.EqualTo(1)); + Assert.That(throughput[Today.AddDays(-1)], Is.EqualTo(5)); + } + } + + [Test] + public async Task Recording_throughput_for_an_unknown_endpoint_throws() + { + Assert.That(async () => await LicensingDataStore.RecordEndpointThroughput("Unknown", ThroughputSource.Broker, Today, 1, default), + Throws.InstanceOf()); + } + + [Test] + public async Task Saving_an_endpoint_again_replaces_it_and_keeps_its_throughput() + { + await SaveEndpoint("Endpoint", ThroughputSource.Broker); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Broker, Today, 11, default); + + await LicensingDataStore.SaveEndpoint( + new Endpoint("Endpoint", ThroughputSource.Broker) { SanitizedName = "Endpoint", Scope = "updated" }, default); + + var endpoint = await LicensingDataStore.GetEndpoint("Endpoint", ThroughputSource.Broker, default); + var throughput = await GetThroughput("Endpoint"); + + Assert.That(endpoint, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(endpoint.Scope, Is.EqualTo("updated")); + Assert.That(throughput[Today], Is.EqualTo(11)); + } + } + + Task SaveEndpoint(string name, ThroughputSource source) => + LicensingDataStore.SaveEndpoint(new Endpoint(name, source) { SanitizedName = name }, default); + + async Task GetThroughput(string queueName) + { + var throughput = await LicensingDataStore.GetEndpointThroughputByQueueName([queueName], default); + + return throughput[queueName].Single(); + } +}