From 10491a49b4bb60ab965b11d421a9283f0abfb543 Mon Sep 17 00:00:00 2001 From: John Simons Date: Fri, 7 Aug 2026 16:22:07 +1000 Subject: [PATCH 1/2] Implement the EF licensing data store RavenDB stored throughput as an incremental time series keyed by a case-insensitive document id. The relational equivalent is a row per endpoint per day, with lowercased name columns to keep endpoint identity and sanitized name matching case-insensitive on PostgreSQL. Recording adds to the day's total via ExecuteUpdate so concurrent writers queue on the row rather than overwriting each other, and the four singleton documents share the Settings table. --- ...7053832_AddLicensingThroughput.Designer.cs | 866 ++++++++++++++++++ .../20260807053832_AddLicensingThroughput.cs | 74 ++ ...SqlServiceControlDbContextModelSnapshot.cs | 92 ++ ...7053830_AddLicensingThroughput.Designer.cs | 694 ++++++++++++++ .../20260807053830_AddLicensingThroughput.cs | 73 ++ ...verServiceControlDbContextModelSnapshot.cs | 75 ++ .../DbContexts/ServiceControlDbContext.cs | 4 + .../Entities/LicensingEndpointEntity.cs | 22 + .../LicensingEndpointThroughputEntity.cs | 14 + .../EntityConfigurations/ColumnLengths.cs | 6 + .../LicensingEndpointConfiguration.cs | 22 + ...icensingEndpointThroughputConfiguration.cs | 20 + .../Implementation/LicensingDataStore.cs | 377 +++++++- .../Infrastructure/SettingKeys.cs | 4 + .../PersistenceTestsContext.cs | 2 +- ...ontrol.Persistence.Tests.PostgreSql.csproj | 4 - .../PersistenceTestsContext.cs | 2 +- ...Control.Persistence.Tests.SqlServer.csproj | 4 - .../EFCore/LicensingDataStoreEFTests.cs | 214 +++++ 19 files changed, 2540 insertions(+), 29 deletions(-) create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260807053832_AddLicensingThroughput.Designer.cs create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260807053832_AddLicensingThroughput.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260807053830_AddLicensingThroughput.Designer.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260807053830_AddLicensingThroughput.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Entities/LicensingEndpointEntity.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Entities/LicensingEndpointThroughputEntity.cs create mode 100644 src/ServiceControl.Persistence.EFCore/EntityConfigurations/LicensingEndpointConfiguration.cs create mode 100644 src/ServiceControl.Persistence.EFCore/EntityConfigurations/LicensingEndpointThroughputConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Tests/EFCore/LicensingDataStoreEFTests.cs 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..b064513411 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs @@ -1,23 +1,362 @@ +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 + // A report covers the last 14 months, so older throughput is never read. + const int ReportedMonths = 14; + 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(-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.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(); + } +} From 605dffbec8588c1f8cd4b684f9bed444e1db0b9b Mon Sep 17 00:00:00 2001 From: John Simons Date: Mon, 10 Aug 2026 13:11:00 +1000 Subject: [PATCH 2/2] Centralize throughput reporting window constant --- .../ThroughputReporting.cs | 10 ++++++++++ .../Implementation/LicensingDataStore.cs | 4 +--- .../Throughput/LicensingDataStore.cs | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 src/Particular.LicensingComponent.Persistence/ThroughputReporting.cs 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/Implementation/LicensingDataStore.cs b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs index b064513411..3405c3d627 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/LicensingDataStore.cs @@ -10,8 +10,6 @@ namespace ServiceControl.Persistence.EFCore.Implementation; class LicensingDataStore(IServiceScopeFactory scopeFactory, TimeProvider timeProvider) : DataStoreBase(scopeFactory), ILicensingDataStore { - // A report covers the last 14 months, so older throughput is never read. - const int ReportedMonths = 14; const int MaxRecordAttempts = 5; static readonly string PlatformEndpointIndicator = EndpointIndicator.PlatformEndpoint.ToString(); @@ -130,7 +128,7 @@ public Task>> GetEndpointThrough } var normalizedNames = requestedByNormalizedName.Keys.ToList(); - var from = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime).AddMonths(-ReportedMonths); + var from = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime).AddMonths(-ThroughputReporting.ReportedMonths); var rows = await context.LicensingEndpoints .AsNoTracking() 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));