From a962bd81e8e9adb0bc4a3175c14ba69aad1387e3 Mon Sep 17 00:00:00 2001 From: John Simons Date: Wed, 29 Jul 2026 15:51:13 +1000 Subject: [PATCH] Add EF Core-backed subscription storage for ServiceControl Introduces a new persistence mechanism for NServiceBus subscriptions using Entity Framework Core. This enables ServiceControl to track and retrieve subscriber information from a relational database. --- ...0260729053645_AddSubscriptions.Designer.cs | 383 ++++++++++++++++++ .../20260729053645_AddSubscriptions.cs | 34 ++ ...SqlServiceControlDbContextModelSnapshot.cs | 24 ++ ...0260729053622_AddSubscriptions.Designer.cs | 312 ++++++++++++++ .../20260729053622_AddSubscriptions.cs | 34 ++ ...verServiceControlDbContextModelSnapshot.cs | 20 + .../EFPersistenceConfigurationBase.cs | 2 + .../Abstractions/EFPersisterSettings.cs | 7 + .../DbContexts/ServiceControlDbContext.cs | 2 + .../Entities/SubscriptionEntity.cs | 8 + .../EntityConfigurations/ColumnLengths.cs | 4 + .../SubscriptionConfiguration.cs | 16 + .../Implementation/SubscriptionStorage.cs | 120 +++++- .../EFCore/SubscriptionStorageTests.cs | 258 ++++++++++++ 14 files changed, 1215 insertions(+), 9 deletions(-) create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260729053645_AddSubscriptions.Designer.cs create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260729053645_AddSubscriptions.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260729053622_AddSubscriptions.Designer.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260729053622_AddSubscriptions.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Entities/SubscriptionEntity.cs create mode 100644 src/ServiceControl.Persistence.EFCore/EntityConfigurations/SubscriptionConfiguration.cs create mode 100644 src/ServiceControl.Persistence.Tests/EFCore/SubscriptionStorageTests.cs diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260729053645_AddSubscriptions.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260729053645_AddSubscriptions.Designer.cs new file mode 100644 index 0000000000..4feb704b4c --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260729053645_AddSubscriptions.Designer.cs @@ -0,0 +1,383 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ServiceControl.Persistence.EFCore.PostgreSql; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations +{ + [DbContext(typeof(PostgreSqlServiceControlDbContext))] + [Migration("20260729053645_AddSubscriptions")] + partial class AddSubscriptions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("name"); + + b.Property("TrackInstances") + .HasColumnType("boolean") + .HasColumnName("track_instances"); + + b.HasKey("Name") + .HasName("pk_endpoint_settings"); + + b.ToTable("endpoint_settings", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedErrorImportEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uuid") + .HasColumnName("unique_message_id"); + + b.Property("Body") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("body"); + + b.Property("BodyStoredExternally") + .HasColumnType("boolean") + .HasColumnName("body_stored_externally"); + + b.Property("ExceptionInfo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("exception_info"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("headers_json"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("message_id"); + + b.HasKey("UniqueMessageId") + .HasName("pk_failed_error_imports"); + + b.HasIndex("FailedAt") + .HasDatabaseName("ix_failed_error_imports_failed_at"); + + b.ToTable("failed_error_imports", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uuid") + .HasColumnName("unique_message_id"); + + b.Property("BodyContentType") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("body_content_type"); + + b.Property("BodySize") + .HasColumnType("integer") + .HasColumnName("body_size"); + + b.Property("BodyStoredExternally") + .HasColumnType("boolean") + .HasColumnName("body_stored_externally"); + + b.Property("BodyText") + .HasColumnType("text") + .HasColumnName("body_text"); + + b.Property("ConversationId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("conversation_id"); + + b.Property("ExceptionMessage") + .HasColumnType("text") + .HasColumnName("exception_message"); + + b.Property("ExceptionType") + .HasColumnType("text") + .HasColumnName("exception_type"); + + b.Property("FailingEndpointAddress") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("failing_endpoint_address"); + + b.Property("FirstTimeOfFailure") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_time_of_failure"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("headers_json"); + + b.Property("IsSystemMessage") + .HasColumnType("boolean") + .HasColumnName("is_system_message"); + + b.Property("LastAttemptedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempted_at"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_modified"); + + b.Property("LastTimeOfFailure") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_time_of_failure"); + + b.Property("MessageId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("message_id"); + + b.Property("MessageType") + .HasColumnType("text") + .HasColumnName("message_type"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("integer") + .HasColumnName("number_of_processing_attempts"); + + b.Property("QueueAddress") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("queue_address"); + + b.Property("ReceivingEndpointHost") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("receiving_endpoint_host"); + + b.Property("ReceivingEndpointHostId") + .HasColumnType("uuid") + .HasColumnName("receiving_endpoint_host_id"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("receiving_endpoint_name"); + + b.Property("SendingEndpointHost") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("sending_endpoint_host"); + + b.Property("SendingEndpointHostId") + .HasColumnType("uuid") + .HasColumnName("sending_endpoint_host_id"); + + b.Property("SendingEndpointName") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("sending_endpoint_name"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("StatusChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("status_changed_at"); + + b.Property("TimeSent") + .HasColumnType("timestamp with time zone") + .HasColumnName("time_sent"); + + b.HasKey("UniqueMessageId") + .HasName("pk_failed_messages"); + + b.HasIndex("ConversationId") + .HasDatabaseName("ix_failed_messages_conversation_id"); + + b.HasIndex("FailingEndpointAddress") + .HasDatabaseName("ix_failed_messages_failing_endpoint_address"); + + b.HasIndex("QueueAddress") + .HasDatabaseName("ix_failed_messages_queue_address"); + + b.HasIndex("ReceivingEndpointName") + .HasDatabaseName("ix_failed_messages_receiving_endpoint_name"); + + b.HasIndex("StatusChangedAt") + .HasDatabaseName("ix_failed_messages_status_changed_at") + .HasFilter("status IN (2, 4)"); + + b.HasIndex("TimeSent") + .HasDatabaseName("ix_failed_messages_time_sent"); + + b.HasIndex("Status", "LastModified") + .HasDatabaseName("ix_failed_messages_status_last_modified"); + + b.ToTable("failed_messages", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => + { + b.Property("FailedMessageUniqueId") + .HasColumnType("uuid") + .HasColumnName("failed_message_unique_id"); + + b.Property("GroupId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("group_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("type"); + + b.HasKey("FailedMessageUniqueId", "GroupId") + .HasName("pk_failed_message_groups"); + + b.HasIndex("GroupId") + .HasDatabaseName("ix_failed_message_groups_group_id"); + + b.ToTable("failed_message_groups", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageRetryEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uuid") + .HasColumnName("unique_message_id"); + + b.Property("RetryId") + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("retry_id"); + + b.HasKey("UniqueMessageId") + .HasName("pk_failed_message_retries"); + + b.ToTable("failed_message_retries", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("host"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("Monitored") + .HasColumnType("boolean") + .HasColumnName("monitored"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_known_endpoints"); + + b.ToTable("known_endpoints", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => + { + b.Property("MessageType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("message_type"); + + b.Property("TransportAddress") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("transport_address"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("endpoint"); + + b.HasKey("MessageType", "TransportAddress") + .HasName("pk_subscriptions"); + + b.ToTable("subscriptions", (string)null); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("TrialEndDate") + .HasColumnType("date") + .HasColumnName("trial_end_date"); + + b.HasKey("Id") + .HasName("pk_trial_metadata"); + + b.ToTable("trial_metadata", (string)null); + + b.HasData( + new + { + Id = 1 + }); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => + { + b.HasOne("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", null) + .WithMany() + .HasForeignKey("FailedMessageUniqueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_failed_message_groups_failed_messages_failed_message_unique"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260729053645_AddSubscriptions.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260729053645_AddSubscriptions.cs new file mode 100644 index 0000000000..18de802cd8 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260729053645_AddSubscriptions.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations +{ + /// + public partial class AddSubscriptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "subscriptions", + columns: table => new + { + message_type = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + transport_address = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + endpoint = table.Column(type: "character varying(450)", maxLength: 450, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_subscriptions", x => new { x.message_type, x.transport_address }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "subscriptions"); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs index 5887803530..770e5515d5 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs @@ -316,6 +316,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("known_endpoints", (string)null); }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => + { + b.Property("MessageType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("message_type"); + + b.Property("TransportAddress") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("transport_address"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)") + .HasColumnName("endpoint"); + + b.HasKey("MessageType", "TransportAddress") + .HasName("pk_subscriptions"); + + b.ToTable("subscriptions", (string)null); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260729053622_AddSubscriptions.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260729053622_AddSubscriptions.Designer.cs new file mode 100644 index 0000000000..2f268a7915 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260729053622_AddSubscriptions.Designer.cs @@ -0,0 +1,312 @@ +// +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("20260729053622_AddSubscriptions")] + partial class AddSubscriptions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.EndpointSettingsEntity", b => + { + b.Property("Name") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TrackInstances") + .HasColumnType("bit"); + + b.HasKey("Name"); + + b.ToTable("EndpointSettings"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedErrorImportEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("Body") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("BodyStoredExternally") + .HasColumnType("bit"); + + b.Property("ExceptionInfo") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FailedAt") + .HasColumnType("datetime2"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("UniqueMessageId"); + + b.HasIndex("FailedAt"); + + b.ToTable("FailedErrorImports"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("BodyContentType") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("BodySize") + .HasColumnType("int"); + + b.Property("BodyStoredExternally") + .HasColumnType("bit"); + + b.Property("BodyText") + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExceptionMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("ExceptionType") + .HasColumnType("nvarchar(max)"); + + b.Property("FailingEndpointAddress") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("FirstTimeOfFailure") + .HasColumnType("datetime2"); + + b.Property("HeadersJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsSystemMessage") + .HasColumnType("bit"); + + b.Property("LastAttemptedAt") + .HasColumnType("datetime2"); + + b.Property("LastModified") + .HasColumnType("datetime2"); + + b.Property("LastTimeOfFailure") + .HasColumnType("datetime2"); + + b.Property("MessageId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("MessageType") + .HasColumnType("nvarchar(max)"); + + b.Property("NumberOfProcessingAttempts") + .HasColumnType("int"); + + b.Property("QueueAddress") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ReceivingEndpointHost") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ReceivingEndpointHostId") + .HasColumnType("uniqueidentifier"); + + b.Property("ReceivingEndpointName") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("SendingEndpointHost") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("SendingEndpointHostId") + .HasColumnType("uniqueidentifier"); + + b.Property("SendingEndpointName") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StatusChangedAt") + .HasColumnType("datetime2"); + + b.Property("TimeSent") + .HasColumnType("datetime2"); + + b.HasKey("UniqueMessageId"); + + b.HasIndex("ConversationId"); + + b.HasIndex("FailingEndpointAddress"); + + b.HasIndex("QueueAddress"); + + b.HasIndex("ReceivingEndpointName"); + + b.HasIndex("StatusChangedAt") + .HasFilter("[Status] IN (2, 4)"); + + b.HasIndex("TimeSent"); + + b.HasIndex("Status", "LastModified"); + + b.ToTable("FailedMessages"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => + { + b.Property("FailedMessageUniqueId") + .HasColumnType("uniqueidentifier"); + + b.Property("GroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.HasKey("FailedMessageUniqueId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("FailedMessageGroups"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageRetryEntity", b => + { + b.Property("UniqueMessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("RetryId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("UniqueMessageId"); + + b.ToTable("FailedMessageRetries"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.KnownEndpointEntity", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("HostId") + .HasColumnType("uniqueidentifier"); + + b.Property("Monitored") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.ToTable("KnownEndpoints"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => + { + b.Property("MessageType") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TransportAddress") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("MessageType", "TransportAddress"); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("TrialEndDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.ToTable("TrialMetadata"); + + b.HasData( + new + { + Id = 1 + }); + }); + + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.FailedMessageGroupEntity", b => + { + b.HasOne("ServiceControl.Persistence.EFCore.Entities.FailedMessageEntity", null) + .WithMany() + .HasForeignKey("FailedMessageUniqueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260729053622_AddSubscriptions.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260729053622_AddSubscriptions.cs new file mode 100644 index 0000000000..9cb0ecdda4 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260729053622_AddSubscriptions.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations +{ + /// + public partial class AddSubscriptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Subscriptions", + columns: table => new + { + MessageType = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + TransportAddress = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Endpoint = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Subscriptions", x => new { x.MessageType, x.TransportAddress }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Subscriptions"); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs index e7082075b4..2539cbd49d 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs @@ -253,6 +253,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("KnownEndpoints"); }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.SubscriptionEntity", b => + { + b.Property("MessageType") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TransportAddress") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("MessageType", "TransportAddress"); + + b.ToTable("Subscriptions"); + }); + modelBuilder.Entity("ServiceControl.Persistence.EFCore.Entities.TrialMetadataEntity", b => { b.Property("Id") diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs index b14cf2b24d..4d752ad296 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs @@ -26,6 +26,7 @@ public abstract class EFPersistenceConfigurationBase : IPersistenceConfiguration const string MaxBodySizeToStoreKey = "MaxBodySizeToStore"; const string ErrorRetentionPeriodKey = "ErrorRetentionPeriod"; const string EnableFullTextSearchOnBodiesKey = "EnableFullTextSearchOnBodies"; + const string SubscriptionCacheDurationKey = "SubscriptionCacheDuration"; public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace) { @@ -36,6 +37,7 @@ public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootName settings.CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, EFPersisterSettings.DefaultCommandTimeout); settings.ErrorRetentionPeriod = GetRequiredSetting(settingsRootNamespace, ErrorRetentionPeriodKey); settings.EnableFullTextSearchOnBodies = SettingsReader.Read(settingsRootNamespace, EnableFullTextSearchOnBodiesKey, true); + settings.SubscriptionCacheDuration = SettingsReader.Read(settingsRootNamespace, SubscriptionCacheDurationKey, EFPersisterSettings.DefaultSubscriptionCacheDuration); return settings; } diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersisterSettings.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersisterSettings.cs index 1311e327e2..5eed5da073 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersisterSettings.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersisterSettings.cs @@ -6,6 +6,8 @@ public abstract class EFPersisterSettings : PersistenceSettings public const int DefaultCommandTimeout = 30; + public static readonly TimeSpan DefaultSubscriptionCacheDuration = TimeSpan.FromSeconds(60); + public required string ConnectionString { get; set; } public int CommandTimeout { get; set; } = DefaultCommandTimeout; public TimeSpan ErrorRetentionPeriod { get; set; } @@ -14,4 +16,9 @@ public abstract class EFPersisterSettings : PersistenceSettings public int MaxRetryDelayInSeconds { get; set; } = 30; public bool EnableSensitiveDataLogging { get; set; } public bool EnableRetryOnFailure { get; set; } = true; + + /// + /// How long subscriber lookups are cached for. disables caching. + /// + public TimeSpan SubscriptionCacheDuration { get; set; } = DefaultSubscriptionCacheDuration; } diff --git a/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs b/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs index 045b0f1414..fa10d2e455 100644 --- a/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs +++ b/src/ServiceControl.Persistence.EFCore/DbContexts/ServiceControlDbContext.cs @@ -13,6 +13,7 @@ public abstract class ServiceControlDbContext(DbContextOptions options) : DbCont public DbSet FailedMessageRetries { get; set; } public DbSet FailedErrorImports { get; set; } public DbSet TrialMetadata { get; set; } + public DbSet Subscriptions { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.EnableDetailedErrors(); @@ -27,6 +28,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new FailedMessageGroupConfiguration()); modelBuilder.ApplyConfiguration(new FailedMessageRetryConfiguration()); modelBuilder.ApplyConfiguration(new KnownEndpointConfiguration()); + modelBuilder.ApplyConfiguration(new SubscriptionConfiguration()); modelBuilder.ApplyConfiguration(new TrialMetadataConfiguration()); } diff --git a/src/ServiceControl.Persistence.EFCore/Entities/SubscriptionEntity.cs b/src/ServiceControl.Persistence.EFCore/Entities/SubscriptionEntity.cs new file mode 100644 index 0000000000..ac9db9c398 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Entities/SubscriptionEntity.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.EFCore.Entities; + +public class SubscriptionEntity +{ + public required string MessageType { get; set; } + public required string TransportAddress { get; set; } + public required string Endpoint { get; set; } +} diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs index 140d945469..69c8244b27 100644 --- a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/ColumnLengths.cs @@ -5,4 +5,8 @@ static class ColumnLengths // Indexed and short-by-nature values get a length so that SQL Server can index them, // nvarchar(max) columns cannot be index key columns. public const int ShortTextLength = 450; + + // The subscriptions key spans two columns and SQL Server caps a clustered index key at 900 bytes, + // so both have to stay well under ShortTextLength. Matches NServiceBus.Persistence.Sql. + public const int SubscriptionKeyLength = 200; } diff --git a/src/ServiceControl.Persistence.EFCore/EntityConfigurations/SubscriptionConfiguration.cs b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/SubscriptionConfiguration.cs new file mode 100644 index 0000000000..01a58a3c5f --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/EntityConfigurations/SubscriptionConfiguration.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.EFCore.EntityConfigurations; + +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +class SubscriptionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => new { e.MessageType, e.TransportAddress }); + builder.Property(e => e.MessageType).IsRequired().HasMaxLength(ColumnLengths.SubscriptionKeyLength); + builder.Property(e => e.TransportAddress).IsRequired().HasMaxLength(ColumnLengths.SubscriptionKeyLength); + builder.Property(e => e.Endpoint).IsRequired().HasMaxLength(ColumnLengths.ShortTextLength); + } +} diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/SubscriptionStorage.cs b/src/ServiceControl.Persistence.EFCore/Implementation/SubscriptionStorage.cs index 6b4d8c6e93..e910c4d8c0 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/SubscriptionStorage.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/SubscriptionStorage.cs @@ -1,20 +1,122 @@ namespace ServiceControl.Persistence.EFCore.Implementation; +using System.Collections.Concurrent; +using Abstractions; +using Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NServiceBus; using NServiceBus.Extensibility; +using NServiceBus.Settings; using NServiceBus.Unicast.Subscriptions; using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; +using ServiceControl.Infrastructure; -public class SubscriptionStorage : IServiceControlSubscriptionStorage +public class SubscriptionStorage : DataStoreBase, IServiceControlSubscriptionStorage { - public Task Initialize() => - throw new NotImplementedException(); + public SubscriptionStorage(IServiceScopeFactory scopeFactory, IReadOnlySettings settings, ReceiveAddresses receiveAddresses, EFPersisterSettings persisterSettings, TimeProvider timeProvider) + : this(scopeFactory, settings.EndpointName(), receiveAddresses.MainReceiveAddress, settings.GetAvailableTypes().Implementing().Select(e => new MessageType(e)).ToArray(), persisterSettings.SubscriptionCacheDuration, timeProvider) + { + } - public Task Subscribe(Subscriber subscriber, MessageType messageType, ContextBag context, CancellationToken cancellationToken = default) => - throw new NotImplementedException(); + public SubscriptionStorage(IServiceScopeFactory scopeFactory, string endpointName, string localAddress, MessageType[] locallyHandledEventTypes, TimeSpan cacheDuration, TimeProvider timeProvider) + : base(scopeFactory) + { + localEndpointName = endpointName; + localSubscriber = new Subscriber(localAddress, endpointName); + locallyHandledTypeNames = locallyHandledEventTypes.Select(eventType => eventType.TypeName).ToHashSet(StringComparer.Ordinal); + this.cacheDuration = cacheDuration; + this.timeProvider = timeProvider; + } - public Task Unsubscribe(Subscriber subscriber, MessageType messageType, ContextBag context, CancellationToken cancellationToken = default) => - throw new NotImplementedException(); + // Subscriptions are read on demand, so there is nothing to prime at startup. + public Task Initialize() => Task.CompletedTask; - public Task> GetSubscriberAddressesForMessage(IEnumerable messageTypes, ContextBag context, CancellationToken cancellationToken = default) => - throw new NotImplementedException(); + public async Task Subscribe(Subscriber subscriber, MessageType messageType, ContextBag context, CancellationToken cancellationToken = default) + { + if (subscriber.Endpoint == localEndpointName) + { + return; + } + + var typeName = messageType.TypeName; + var transportAddress = subscriber.TransportAddress; + //When the subscriber is running V6 and UseLegacyMessageDrivenSubscriptionMode is enabled at the subscriber the 'subscriber.Endpoint' value is null + var endpoint = subscriber.Endpoint ?? transportAddress.Split('@').First(); + + await ExecuteWithDbContext(dbContext => dbContext.UpsertAsync( + [typeName, transportAddress], + () => new SubscriptionEntity { MessageType = typeName, TransportAddress = transportAddress, Endpoint = endpoint }, + entity => entity.Endpoint = endpoint, + cancellationToken)); + + InvalidateCache(typeName); + } + + public async Task Unsubscribe(Subscriber subscriber, MessageType messageType, ContextBag context, CancellationToken cancellationToken = default) + { + var typeName = messageType.TypeName; + var transportAddress = subscriber.TransportAddress; + + await ExecuteWithDbContext(dbContext => dbContext.Subscriptions + .Where(subscription => subscription.MessageType == typeName && subscription.TransportAddress == transportAddress) + .ExecuteDeleteAsync(cancellationToken)); + + InvalidateCache(typeName); + } + + public async Task> GetSubscriberAddressesForMessage(IEnumerable messageTypes, ContextBag context, CancellationToken cancellationToken = default) + { + var typeNames = messageTypes.Select(messageType => messageType.TypeName).ToArray(); + var key = string.Join(",", typeNames); + + if (cache.TryGetValue(key, out var cached) && timeProvider.GetUtcNow() - cached.Stored < cacheDuration) + { + return cached.Subscribers; + } + + var subscribers = await LoadSubscribers(typeNames, cancellationToken); + cache[key] = new CacheItem(timeProvider.GetUtcNow(), typeNames, subscribers); + + return subscribers; + } + + async Task LoadSubscribers(string[] typeNames, CancellationToken cancellationToken) + { + var stored = await ExecuteWithDbContext(dbContext => dbContext.Subscriptions + .AsNoTracking() + .Where(subscription => typeNames.Contains(subscription.MessageType)) + .Select(subscription => new { subscription.TransportAddress, subscription.Endpoint }) + .Distinct() + .ToArrayAsync(cancellationToken)); + + var subscribers = stored.Select(subscription => new Subscriber(subscription.TransportAddress, subscription.Endpoint)); + + if (locallyHandledTypeNames.Overlaps(typeNames)) + { + subscribers = subscribers.Append(localSubscriber); + } + + return subscribers.ToArray(); + } + + void InvalidateCache(string typeName) + { + foreach (var entry in cache) + { + if (entry.Value.TypeNames.Contains(typeName, StringComparer.Ordinal)) + { + cache.TryRemove(entry.Key, out _); + } + } + } + + readonly string localEndpointName; + readonly Subscriber localSubscriber; + readonly HashSet locallyHandledTypeNames; + readonly TimeSpan cacheDuration; + readonly TimeProvider timeProvider; + readonly ConcurrentDictionary cache = new(); + + record CacheItem(DateTimeOffset Stored, string[] TypeNames, Subscriber[] Subscribers); } diff --git a/src/ServiceControl.Persistence.Tests/EFCore/SubscriptionStorageTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/SubscriptionStorageTests.cs new file mode 100644 index 0000000000..b94715c9b1 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/SubscriptionStorageTests.cs @@ -0,0 +1,258 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NServiceBus.Extensibility; +using NServiceBus.Unicast.Subscriptions; +using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.DbContexts; +using ServiceControl.Persistence.EFCore.Entities; +using ServiceControl.Persistence.EFCore.Implementation; + +class SubscriptionStorageTests : PersistenceTestBase +{ + const string LocalEndpointName = "Particular.ServiceControl"; + const string LocalAddress = "Particular.ServiceControl@local"; + + static readonly MessageType SampleEventV1 = new(typeof(SampleEvent).FullName, new Version(1, 0, 0)); + static readonly MessageType SampleEventV2 = new(typeof(SampleEvent).FullName, new Version(2, 0, 0)); + static readonly MessageType OtherEvent = new(typeof(AnotherSampleEvent).FullName, new Version(1, 0, 0)); + + [Test] + public async Task Subscribe_stores_the_subscriber() + { + var storage = CreateStorage(); + + await Subscribe(storage, new Subscriber("SalesAddress", "Sales"), SampleEventV1); + + var subscribers = await GetSubscribers(storage, SampleEventV1); + + Assert.That(subscribers, Has.Count.EqualTo(1)); + using (Assert.EnterMultipleScope()) + { + Assert.That(subscribers[0].TransportAddress, Is.EqualTo("SalesAddress")); + Assert.That(subscribers[0].Endpoint, Is.EqualTo("Sales")); + } + } + + [Test] + public async Task Subscribing_twice_stores_a_single_row() + { + var storage = CreateStorage(); + var subscriber = new Subscriber("SalesAddress", "Sales"); + + await Subscribe(storage, subscriber, SampleEventV1); + await Subscribe(storage, subscriber, SampleEventV1); + + Assert.That(await GetStoredSubscriptions(), Has.Count.EqualTo(1)); + } + + [Test] + public async Task Resubscribing_updates_the_endpoint_name() + { + var storage = CreateStorage(); + + await Subscribe(storage, new Subscriber("SalesAddress", "Sales"), SampleEventV1); + await Subscribe(storage, new Subscriber("SalesAddress", "Sales.Renamed"), SampleEventV1); + + var stored = await GetStoredSubscriptions(); + + Assert.That(stored, Has.Count.EqualTo(1)); + Assert.That(stored[0].Endpoint, Is.EqualTo("Sales.Renamed")); + } + + [Test] + public async Task Unsubscribe_removes_only_the_matching_subscription() + { + var storage = CreateStorage(); + var sales = new Subscriber("SalesAddress", "Sales"); + var shipping = new Subscriber("ShippingAddress", "Shipping"); + + await Subscribe(storage, sales, SampleEventV1); + await Subscribe(storage, shipping, SampleEventV1); + await Subscribe(storage, sales, OtherEvent); + + await storage.Unsubscribe(sales, SampleEventV1, new ContextBag(), CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That((await GetSubscribers(storage, SampleEventV1)).Select(s => s.TransportAddress), Is.EqualTo(new[] { "ShippingAddress" })); + Assert.That((await GetSubscribers(storage, OtherEvent)).Select(s => s.TransportAddress), Is.EqualTo(new[] { "SalesAddress" })); + } + } + + [Test] + public async Task Unsubscribing_something_that_was_never_subscribed_is_a_no_op() + { + var storage = CreateStorage(); + + await storage.Unsubscribe(new Subscriber("SalesAddress", "Sales"), SampleEventV1, new ContextBag(), CancellationToken.None); + + Assert.That(await GetStoredSubscriptions(), Is.Empty); + } + + [Test] + public async Task Should_return_subscriptions_for_other_versions_of_the_same_message_type() + { + var storage = CreateStorage(); + + await Subscribe(storage, new Subscriber("V1SubscriberAddress", "V1Subscriber"), SampleEventV1); + + var subscribers = await GetSubscribers(storage, SampleEventV2); + + Assert.That(subscribers, Has.Count.EqualTo(1)); + Assert.That(subscribers[0].TransportAddress, Is.EqualTo("V1SubscriberAddress")); + } + + [Test] + public async Task Subscriber_is_returned_once_when_subscribed_to_several_types_in_the_hierarchy() + { + var storage = CreateStorage(); + var sales = new Subscriber("SalesAddress", "Sales"); + + await Subscribe(storage, sales, SampleEventV1); + await Subscribe(storage, sales, OtherEvent); + + var subscribers = await GetSubscribers(storage, SampleEventV1, OtherEvent); + + Assert.That(subscribers, Has.Count.EqualTo(1)); + } + + [Test] + public async Task Endpoint_is_derived_from_the_transport_address_when_the_subscriber_does_not_supply_one() + { + var storage = CreateStorage(); + + await Subscribe(storage, new Subscriber("Sales@MachineName", null), SampleEventV1); + + var subscribers = await GetSubscribers(storage, SampleEventV1); + + Assert.That(subscribers[0].Endpoint, Is.EqualTo("Sales")); + } + + [Test] + public async Task Subscriptions_from_the_local_endpoint_are_ignored() + { + var storage = CreateStorage(); + + await Subscribe(storage, new Subscriber(LocalAddress, LocalEndpointName), SampleEventV1); + + Assert.That(await GetStoredSubscriptions(), Is.Empty); + } + + [Test] + public async Task Locally_handled_event_types_resolve_to_the_local_address() + { + var storage = CreateStorage(locallyHandledEventTypes: [SampleEventV1]); + + var subscribers = await GetSubscribers(storage, SampleEventV2); + + Assert.That(subscribers, Has.Count.EqualTo(1)); + using (Assert.EnterMultipleScope()) + { + Assert.That(subscribers[0].TransportAddress, Is.EqualTo(LocalAddress)); + Assert.That(subscribers[0].Endpoint, Is.EqualTo(LocalEndpointName)); + } + } + + [Test] + public async Task Locally_handled_event_types_do_not_leak_into_other_message_types() + { + var storage = CreateStorage(locallyHandledEventTypes: [SampleEventV1]); + + Assert.That(await GetSubscribers(storage, OtherEvent), Is.Empty); + } + + [Test] + public async Task Subscriber_lookups_are_served_from_the_cache_until_it_expires() + { + var storage = CreateStorage(cacheDuration: TimeSpan.FromSeconds(60)); + + Assert.That(await GetSubscribers(storage, SampleEventV1), Is.Empty); + + await StoreSubscriptionDirectly(SampleEventV1.TypeName, "SalesAddress", "Sales"); + + Assert.That(await GetSubscribers(storage, SampleEventV1), Is.Empty, "the cached, empty result should still be served"); + + PersistenceTestsContext.FakeTime.Advance(TimeSpan.FromSeconds(60)); + + Assert.That(await GetSubscribers(storage, SampleEventV1), Has.Count.EqualTo(1)); + } + + [Test] + public async Task Subscribing_evicts_the_cached_lookup_for_that_message_type() + { + var storage = CreateStorage(cacheDuration: TimeSpan.FromSeconds(60)); + + Assert.That(await GetSubscribers(storage, SampleEventV1), Is.Empty); + Assert.That(await GetSubscribers(storage, OtherEvent), Is.Empty); + + await Subscribe(storage, new Subscriber("SalesAddress", "Sales"), SampleEventV1); + + using (Assert.EnterMultipleScope()) + { + Assert.That(await GetSubscribers(storage, SampleEventV1), Has.Count.EqualTo(1)); + Assert.That(await GetSubscribers(storage, OtherEvent), Is.Empty); + } + } + + [Test] + public async Task Unsubscribing_evicts_the_cached_lookup_for_that_message_type() + { + var storage = CreateStorage(cacheDuration: TimeSpan.FromSeconds(60)); + var sales = new Subscriber("SalesAddress", "Sales"); + + await Subscribe(storage, sales, SampleEventV1); + Assert.That(await GetSubscribers(storage, SampleEventV1), Has.Count.EqualTo(1)); + + await storage.Unsubscribe(sales, SampleEventV1, new ContextBag(), CancellationToken.None); + + Assert.That(await GetSubscribers(storage, SampleEventV1), Is.Empty); + } + + SubscriptionStorage CreateStorage(TimeSpan cacheDuration = default, MessageType[] locallyHandledEventTypes = null) => + new(ServiceProvider.GetRequiredService(), + LocalEndpointName, + LocalAddress, + locallyHandledEventTypes ?? [], + cacheDuration, + PersistenceTestsContext.FakeTime); + + static Task Subscribe(SubscriptionStorage storage, Subscriber subscriber, MessageType messageType) => + storage.Subscribe(subscriber, messageType, new ContextBag(), CancellationToken.None); + + static async Task> GetSubscribers(SubscriptionStorage storage, params MessageType[] messageTypes) + { + var subscribers = await storage.GetSubscriberAddressesForMessage(messageTypes, new ContextBag(), CancellationToken.None); + + return subscribers.ToList(); + } + + async Task> GetStoredSubscriptions() + { + await using var scope = ServiceProvider.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + return await dbContext.Subscriptions.AsNoTracking().ToListAsync(); + } + + async Task StoreSubscriptionDirectly(string messageType, string transportAddress, string endpoint) + { + await using var scope = ServiceProvider.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Subscriptions.Add(new SubscriptionEntity { MessageType = messageType, TransportAddress = transportAddress, Endpoint = endpoint }); + + await dbContext.SaveChangesAsync(); + } + + class SampleEvent; + + class AnotherSampleEvent; +}