From 851d226f27ddad7b9d74f8c2305de10a3679e290 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Thu, 30 Jul 2026 13:04:26 +0800 Subject: [PATCH 1/6] Don't use migrations for all tests, add test runner parallelism --- .../NUnitSettings.cs | 4 ++++ .../PersistenceTestsContext.cs | 2 +- .../NUnitSettings.cs | 4 ++++ .../PersistenceTestsContext.cs | 2 +- .../EFCore/DbMigrationsTests.cs | 19 +++++++++++++++++++ .../EFCore/EFCoreExtensionMethodTests.cs | 2 +- 6 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests.PostgreSql/NUnitSettings.cs create mode 100644 src/ServiceControl.Persistence.Tests.SqlServer/NUnitSettings.cs create mode 100644 src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/NUnitSettings.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/NUnitSettings.cs new file mode 100644 index 0000000000..8478866bcd --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/NUnitSettings.cs @@ -0,0 +1,4 @@ +using NUnit.Framework; + +[assembly: Parallelizable(ParallelScope.All)] +[assembly: LevelOfParallelism(4)] \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs index 721f8d1cfd..a3ef1178c7 100644 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs @@ -55,7 +55,7 @@ public async Task PostSetup(IHost host) using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.MigrateAsync(); + await db.Database.EnsureCreatedAsync(); } public async Task TearDown() diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/NUnitSettings.cs b/src/ServiceControl.Persistence.Tests.SqlServer/NUnitSettings.cs new file mode 100644 index 0000000000..8478866bcd --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.SqlServer/NUnitSettings.cs @@ -0,0 +1,4 @@ +using NUnit.Framework; + +[assembly: Parallelizable(ParallelScope.All)] +[assembly: LevelOfParallelism(4)] \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs index 9f3e59df8d..03ee0393b1 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs @@ -54,7 +54,7 @@ public async Task PostSetup(IHost host) using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.MigrateAsync(); + await db.Database.EnsureCreatedAsync(); } public async Task TearDown() diff --git a/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs new file mode 100644 index 0000000000..4065858bcd --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs @@ -0,0 +1,19 @@ +namespace ServiceControl.Persistence.Tests; + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; + +public class DbMigrationsTests +{ + [Test] + public async Task ApplyMigrations_runs_successfully() + { + var ctx = new PersistenceTestsContext(); + var hostBuilder = new HostApplicationBuilder(); + await ctx.Setup(hostBuilder); + var host = hostBuilder.Build(); + await host.Services.GetRequiredService().ApplyMigrations(); + } +} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests/EFCore/EFCoreExtensionMethodTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/EFCoreExtensionMethodTests.cs index 697fe0ed1d..034ee9e51d 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/EFCoreExtensionMethodTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/EFCoreExtensionMethodTests.cs @@ -1,4 +1,4 @@ -namespace DefaultNamespace; +namespace ServiceControl.Persistence.Tests; using System; using System.Threading; From 523762110f407aba02773de04190667aa9dabc32 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Thu, 30 Jul 2026 14:36:32 +0800 Subject: [PATCH 2/6] Realign model with migrations, fix parallelism issue in tests --- ...054548_NullableEndpointAddress.Designer.cs | 358 ++++++++++++++++++ .../20260730054548_NullableEndpointAddress.cs | 40 ++ ...SqlServiceControlDbContextModelSnapshot.cs | 1 - ...054425_NullableEndpointAddress.Designer.cs | 291 ++++++++++++++ .../20260730054425_NullableEndpointAddress.cs | 40 ++ ...verServiceControlDbContextModelSnapshot.cs | 1 - .../Entities/FailedMessageEntity.cs | 2 +- .../RecordedFailedProcessingAttempt.cs | 2 +- .../EFCore/AzureBlobBodyStorageTests.cs | 35 +- .../EFCore/DbMigrationsTests.cs | 3 +- .../EFCore/S3BodyStorageTests.cs | 21 +- 11 files changed, 770 insertions(+), 24 deletions(-) create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260730054548_NullableEndpointAddress.Designer.cs create mode 100644 src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260730054548_NullableEndpointAddress.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260730054425_NullableEndpointAddress.Designer.cs create mode 100644 src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260730054425_NullableEndpointAddress.cs diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260730054548_NullableEndpointAddress.Designer.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260730054548_NullableEndpointAddress.Designer.cs new file mode 100644 index 0000000000..4ccefeca3f --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260730054548_NullableEndpointAddress.Designer.cs @@ -0,0 +1,358 @@ +// +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("20260730054548_NullableEndpointAddress")] + partial class NullableEndpointAddress + { + /// + 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") + .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.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/20260730054548_NullableEndpointAddress.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260730054548_NullableEndpointAddress.cs new file mode 100644 index 0000000000..31a2bf2c1a --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/20260730054548_NullableEndpointAddress.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.PostgreSql.Migrations +{ + /// + public partial class NullableEndpointAddress : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "failing_endpoint_address", + table: "failed_messages", + type: "character varying(450)", + maxLength: 450, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(450)", + oldMaxLength: 450); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "failing_endpoint_address", + table: "failed_messages", + type: "character varying(450)", + maxLength: 450, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "character varying(450)", + oldMaxLength: 450, + oldNullable: true); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs index 5887803530..809203245f 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/Migrations/PostgreSqlServiceControlDbContextModelSnapshot.cs @@ -120,7 +120,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("exception_type"); b.Property("FailingEndpointAddress") - .IsRequired() .HasMaxLength(450) .HasColumnType("character varying(450)") .HasColumnName("failing_endpoint_address"); diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260730054425_NullableEndpointAddress.Designer.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260730054425_NullableEndpointAddress.Designer.cs new file mode 100644 index 0000000000..b51a5f0ab8 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260730054425_NullableEndpointAddress.Designer.cs @@ -0,0 +1,291 @@ +// +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("20260730054425_NullableEndpointAddress")] + partial class NullableEndpointAddress + { + /// + 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") + .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.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/20260730054425_NullableEndpointAddress.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260730054425_NullableEndpointAddress.cs new file mode 100644 index 0000000000..9e848d7562 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/20260730054425_NullableEndpointAddress.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ServiceControl.Persistence.EFCore.SqlServer.Migrations +{ + /// + public partial class NullableEndpointAddress : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "FailingEndpointAddress", + table: "FailedMessages", + type: "nvarchar(450)", + maxLength: 450, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(450)", + oldMaxLength: 450); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "FailingEndpointAddress", + table: "FailedMessages", + type: "nvarchar(450)", + maxLength: 450, + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "nvarchar(450)", + oldMaxLength: 450, + oldNullable: true); + } + } +} diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs index e7082075b4..ac667e1322 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/Migrations/SqlServerServiceControlDbContextModelSnapshot.cs @@ -100,7 +100,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("nvarchar(max)"); b.Property("FailingEndpointAddress") - .IsRequired() .HasMaxLength(450) .HasColumnType("nvarchar(450)"); diff --git a/src/ServiceControl.Persistence.EFCore/Entities/FailedMessageEntity.cs b/src/ServiceControl.Persistence.EFCore/Entities/FailedMessageEntity.cs index 14bdb2f27f..85e360594f 100644 --- a/src/ServiceControl.Persistence.EFCore/Entities/FailedMessageEntity.cs +++ b/src/ServiceControl.Persistence.EFCore/Entities/FailedMessageEntity.cs @@ -58,5 +58,5 @@ public class FailedMessageEntity public string? BodyContentType { get; set; } - public required string FailingEndpointAddress { get; set; } + public string? FailingEndpointAddress { get; set; } } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/RecordedFailedProcessingAttempt.cs b/src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/RecordedFailedProcessingAttempt.cs index b9bdf84228..6120ef0dc3 100644 --- a/src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/RecordedFailedProcessingAttempt.cs +++ b/src/ServiceControl.Persistence.EFCore/Implementation/UnitOfWork/RecordedFailedProcessingAttempt.cs @@ -29,5 +29,5 @@ sealed class RecordedFailedProcessingAttempt public bool BodyStoredExternally { get; init; } public int BodySize { get; init; } public string? BodyContentType { get; init; } - public required string FailingEndpointAddress { get; set; } + public string? FailingEndpointAddress { get; set; } } diff --git a/src/ServiceControl.Persistence.Tests/EFCore/AzureBlobBodyStorageTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/AzureBlobBodyStorageTests.cs index 66f06d1881..78e191833f 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/AzureBlobBodyStorageTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/AzureBlobBodyStorageTests.cs @@ -14,7 +14,6 @@ namespace ServiceControl.Persistence.Tests; class AzureBlobBodyStorageTests { AzuriteContainer azurite; - AzureBlobBodyStoragePersistence store; [OneTimeSetUp] public async Task StartAzurite() @@ -38,8 +37,7 @@ public async Task StopAzurite() } } - [SetUp] - public async Task CreateContainer() + public async Task CreateContainer() { var settings = new AzureBlobBodyStorageSettings { @@ -49,12 +47,13 @@ public async Task CreateContainer() }; await new AzureBlobBodyStorageInstaller(settings).Provision(); - store = new AzureBlobBodyStoragePersistence(settings); + return new AzureBlobBodyStoragePersistence(settings); } [Test] public async Task Round_trips_a_small_uncompressed_body() { + var store = await CreateContainer(); var bodyId = Guid.NewGuid().ToString(); var body = Encoding.UTF8.GetBytes("hello world"); @@ -63,7 +62,7 @@ public async Task Round_trips_a_small_uncompressed_body() var result = await store.ReadBody(bodyId); Assert.That(result, Is.Not.Null); - using (result.Stream) + await using (result.Stream) { Assert.That(ReadAll(result.Stream), Is.EqualTo(body)); } @@ -78,6 +77,7 @@ public async Task Round_trips_a_small_uncompressed_body() [Test] public async Task Round_trips_a_large_body_over_the_compression_threshold() { + var store = await CreateContainer(); var bodyId = Guid.NewGuid().ToString(); var body = Encoding.UTF8.GetBytes(new string('a', 100_000)); @@ -86,7 +86,7 @@ public async Task Round_trips_a_large_body_over_the_compression_threshold() var result = await store.ReadBody(bodyId); Assert.That(result, Is.Not.Null); - using (result.Stream) + await using (result.Stream) { Assert.That(ReadAll(result.Stream), Is.EqualTo(body)); } @@ -95,14 +95,18 @@ public async Task Round_trips_a_large_body_over_the_compression_threshold() } [Test] - public async Task Returns_null_for_a_missing_body() => + public async Task Returns_null_for_a_missing_body() + { + var store = await CreateContainer(); Assert.That(await store.ReadBody(Guid.NewGuid().ToString()), Is.Null); + } [Test] public async Task Delete_removes_the_body() { + var store = await CreateContainer(); var bodyId = Guid.NewGuid().ToString(); - await store.WriteBody(bodyId, Encoding.UTF8.GetBytes("payload"), "text/plain"); + await store.WriteBody(bodyId, "payload"u8.ToArray(), "text/plain"); await store.DeleteBody(bodyId); @@ -110,24 +114,29 @@ public async Task Delete_removes_the_body() } [Test] - public void Delete_of_a_missing_body_does_not_throw() => + public async Task Delete_of_a_missing_body_does_not_throw() + { + var store = await CreateContainer(); Assert.DoesNotThrowAsync(() => store.DeleteBody(Guid.NewGuid().ToString())); + } [Test] public async Task Rewriting_an_existing_body_keeps_the_first_write() { + var store = await CreateContainer(); var bodyId = Guid.NewGuid().ToString(); - var original = Encoding.UTF8.GetBytes("original"); + var original = "original"u8.ToArray(); await store.WriteBody(bodyId, original, "text/plain"); - await store.WriteBody(bodyId, Encoding.UTF8.GetBytes("different"), "text/plain"); + await store.WriteBody(bodyId, "different"u8.ToArray(), "text/plain"); var result = await store.ReadBody(bodyId); Assert.That(result, Is.Not.Null); - using (result.Stream) + await using (result.Stream) { - Assert.That(ReadAll(result.Stream), Is.EqualTo(original), "bodies are immutable, so the first write wins"); + var data = ReadAll(result.Stream); + Assert.That(data, Is.EqualTo(original), "bodies are immutable, so the first write wins"); } } diff --git a/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs index 4065858bcd..36e306ddf8 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs @@ -14,6 +14,7 @@ public async Task ApplyMigrations_runs_successfully() var hostBuilder = new HostApplicationBuilder(); await ctx.Setup(hostBuilder); var host = hostBuilder.Build(); - await host.Services.GetRequiredService().ApplyMigrations(); + var scope = host.Services.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService().ApplyMigrations(); } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests/EFCore/S3BodyStorageTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/S3BodyStorageTests.cs index f2a0679f90..d658ba0f6d 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/S3BodyStorageTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/S3BodyStorageTests.cs @@ -14,7 +14,6 @@ namespace ServiceControl.Persistence.Tests; class S3BodyStorageTests { LocalStackContainer localStack; - S3BodyStoragePersistence store; [OneTimeSetUp] public async Task StartLocalStack() @@ -33,8 +32,7 @@ public async Task StopLocalStack() } } - [SetUp] - public async Task CreateBucket() + public async Task CreateBucket() { var settings = new S3BodyStorageSettings { @@ -46,12 +44,13 @@ public async Task CreateBucket() }; await new S3BodyStorageInstaller(settings).Provision(); - store = new S3BodyStoragePersistence(settings); + return new S3BodyStoragePersistence(settings); } [Test] public async Task Round_trips_a_small_uncompressed_body() { + var store = await CreateBucket(); var bodyId = Guid.NewGuid().ToString(); var body = Encoding.UTF8.GetBytes("hello world"); @@ -75,6 +74,7 @@ public async Task Round_trips_a_small_uncompressed_body() [Test] public async Task Round_trips_a_large_body_over_the_compression_threshold() { + var store = await CreateBucket(); var bodyId = Guid.NewGuid().ToString(); var body = Encoding.UTF8.GetBytes(new string('a', 100_000)); @@ -92,12 +92,16 @@ public async Task Round_trips_a_large_body_over_the_compression_threshold() } [Test] - public async Task Returns_null_for_a_missing_body() => + public async Task Returns_null_for_a_missing_body() + { + var store = await CreateBucket(); Assert.That(await store.ReadBody(Guid.NewGuid().ToString()), Is.Null); + } [Test] public async Task Delete_removes_the_body() { + var store = await CreateBucket(); var bodyId = Guid.NewGuid().ToString(); await store.WriteBody(bodyId, Encoding.UTF8.GetBytes("payload"), "text/plain"); @@ -107,12 +111,17 @@ public async Task Delete_removes_the_body() } [Test] - public void Delete_of_a_missing_body_does_not_throw() => + public async Task Delete_of_a_missing_body_does_not_throw() + { + var store = await CreateBucket(); Assert.DoesNotThrowAsync(() => store.DeleteBody(Guid.NewGuid().ToString())); + } [Test] + [Parallelizable(ParallelScope.None)] public async Task Rewriting_an_existing_body_keeps_the_first_write() { + var store = await CreateBucket(); var bodyId = Guid.NewGuid().ToString(); var original = Encoding.UTF8.GetBytes("original"); From 17e658a8ad0878c7eb83747a97eca1d3cde5699f Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Fri, 31 Jul 2026 09:52:57 +0800 Subject: [PATCH 3/6] Parallelise tests for ravenDB --- .../EmbeddedLifecycleTests.cs | 1 + .../LicenseTest.cs | 1 + .../NUnitSettings.cs | 4 ++++ .../PersistenceTestsContext.cs | 2 +- .../NUnitSettings.cs | 4 ++++ .../PersistenceTestsContext.cs | 10 +++++++++- .../SharedEmbeddedServer.cs | 5 +++++ .../PersistenceTestsContext.cs | 2 +- .../EFCore/DbMigrationsTests.cs | 20 ------------------- 9 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/NUnitSettings.cs delete mode 100644 src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/EmbeddedLifecycleTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/EmbeddedLifecycleTests.cs index e6fe27aa0f..e21dd2b2f9 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/EmbeddedLifecycleTests.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/EmbeddedLifecycleTests.cs @@ -9,6 +9,7 @@ using TestHelper; [TestFixture] + [NonParallelizable] class EmbeddedLifecycleTests : PersistenceTestFixture { string logPath; diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/LicenseTest.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/LicenseTest.cs index d419b38af2..62d456afa0 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/LicenseTest.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/LicenseTest.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using NUnit.Framework; + [NonParallelizable] class LicenseTest : PersistenceTestFixture { [Test] diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs new file mode 100644 index 0000000000..c8ef6eca38 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs @@ -0,0 +1,4 @@ +using NUnit.Framework; + +[assembly: Parallelizable(ParallelScope.Fixtures)] +[assembly: LevelOfParallelism(7)] diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs index a3ef1178c7..721f8d1cfd 100644 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs @@ -55,7 +55,7 @@ public async Task PostSetup(IHost host) using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.EnsureCreatedAsync(); + await db.Database.MigrateAsync(); } public async Task TearDown() diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/NUnitSettings.cs b/src/ServiceControl.Persistence.Tests.RavenDB/NUnitSettings.cs new file mode 100644 index 0000000000..ba80ffbc87 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/NUnitSettings.cs @@ -0,0 +1,4 @@ +using NUnit.Framework; + +[assembly: Parallelizable(ParallelScope.All)] +[assembly: LevelOfParallelism(4)] diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs index ebd1f288ab..bf77772c9e 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs @@ -52,7 +52,15 @@ public async Task PostSetup(IHost host) await CompleteDatabaseOperation(); } - public async Task TearDown() => await embeddedServer.DeleteDatabase(databaseName); + public async Task TearDown() + { + if (embeddedServer == null) + { + return; + } + + await embeddedServer.DeleteDatabase(databaseName); + } public PersistenceSettings PersistenceSettings { get; private set; } public string GenerateFailedMessageRecordId(string messageId) => FailedMessageIdGenerator.MakeDocumentId(messageId); diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs b/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs index 670dab2bfb..ae6d389dcb 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs @@ -25,6 +25,11 @@ public static async Task GetInstance(CancellationToken cancell try { + if (embeddedDatabase != null) + { + return embeddedDatabase; + } + var dbPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Tests", "PrimaryData"); var logPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Logs", "Primary"); var logsMode = "Operations"; diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs index 03ee0393b1..9f3e59df8d 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs +++ b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs @@ -54,7 +54,7 @@ public async Task PostSetup(IHost host) using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.EnsureCreatedAsync(); + await db.Database.MigrateAsync(); } public async Task TearDown() diff --git a/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs deleted file mode 100644 index 36e306ddf8..0000000000 --- a/src/ServiceControl.Persistence.Tests/EFCore/DbMigrationsTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ServiceControl.Persistence.Tests; - -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using NUnit.Framework; - -public class DbMigrationsTests -{ - [Test] - public async Task ApplyMigrations_runs_successfully() - { - var ctx = new PersistenceTestsContext(); - var hostBuilder = new HostApplicationBuilder(); - await ctx.Setup(hostBuilder); - var host = hostBuilder.Build(); - var scope = host.Services.CreateAsyncScope(); - await scope.ServiceProvider.GetRequiredService().ApplyMigrations(); - } -} \ No newline at end of file From d8d0eb6abaf0b56ee0000fc190b063fad2b4040c Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Fri, 31 Jul 2026 10:40:25 +0800 Subject: [PATCH 4/6] Fix flaky port selection on build server --- .../NUnitSettings.cs | 2 +- .../EFCore/BodyStoragePersistenceTests.cs | 1 + src/TestHelper/PortUtility.cs | 30 ++++++++++++------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs index c8ef6eca38..41ff98ae36 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs @@ -1,4 +1,4 @@ using NUnit.Framework; [assembly: Parallelizable(ParallelScope.Fixtures)] -[assembly: LevelOfParallelism(7)] +[assembly: LevelOfParallelism(4)] diff --git a/src/ServiceControl.Persistence.Tests/EFCore/BodyStoragePersistenceTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/BodyStoragePersistenceTests.cs index 2c90b22912..357289ddff 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/BodyStoragePersistenceTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/BodyStoragePersistenceTests.cs @@ -10,6 +10,7 @@ namespace ServiceControl.Persistence.Tests; using ServiceControl.Persistence.EFCore.Infrastructure; [TestFixture] +[FixtureLifeCycle(LifeCycle.InstancePerTestCase)] class BodyStoragePersistenceTests { const string FileSystem = nameof(FileSystem); diff --git a/src/TestHelper/PortUtility.cs b/src/TestHelper/PortUtility.cs index 7fc4648031..03d2d086d7 100644 --- a/src/TestHelper/PortUtility.cs +++ b/src/TestHelper/PortUtility.cs @@ -1,26 +1,36 @@ namespace TestHelper { - using System.Linq; - using System.Net.NetworkInformation; + using System; + using System.Net; + using System.Net.Sockets; public static class PortUtility { public static int FindAvailablePort(int startPort) { - var activeTcpListeners = IPGlobalProperties - .GetIPGlobalProperties() - .GetActiveTcpListeners(); + const int searchRange = 1024; - for (var port = startPort; port < startPort + 1024; port++) + // Multiple test hosts can start embedded RavenDB instances at the same time in CI. + // Offset the initial probe per process to reduce cross-process collisions. + var processOffset = Environment.ProcessId % searchRange; + + for (var attempt = 0; attempt < searchRange; attempt++) { - var portCopy = port; - if (activeTcpListeners.All(endPoint => endPoint.Port != portCopy)) + var port = startPort + ((processOffset + attempt) % searchRange); + + try + { + using var listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + catch (SocketException) { - return port; + // Port is not currently available, try the next one. } } - return startPort; + return startPort + processOffset; } } } From 3711a5dd69c3b87f89a5edf6711985dcd4634522 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Fri, 31 Jul 2026 10:50:19 +0800 Subject: [PATCH 5/6] longer test timeout --- .../EndpointSettingsStoreTests.cs | 4 ++-- .../TrialLicenseDataProviderTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ServiceControl.Persistence.Tests/EndpointSettingsStoreTests.cs b/src/ServiceControl.Persistence.Tests/EndpointSettingsStoreTests.cs index faa723c482..635cd1ee58 100644 --- a/src/ServiceControl.Persistence.Tests/EndpointSettingsStoreTests.cs +++ b/src/ServiceControl.Persistence.Tests/EndpointSettingsStoreTests.cs @@ -9,7 +9,7 @@ namespace ServiceControl.Persistence.Tests; class EndpointSettingsStoreTests : PersistenceTestBase { - [Test, CancelAfter(10_000)] + [Test, CancelAfter(15_000)] public async Task UpdateEndpointSettings_stores_and_updates_existing_setting(CancellationToken cancellationToken) { await EndpointSettingsStore.UpdateEndpointSettings(new EndpointSettings { Name = "Sales", TrackInstances = false }, cancellationToken); @@ -25,7 +25,7 @@ public async Task UpdateEndpointSettings_stores_and_updates_existing_setting(Can } } - [Test, CancelAfter(10_000)] + [Test, CancelAfter(15_000)] public async Task Delete_removes_only_target_setting(CancellationToken cancellationToken) { await EndpointSettingsStore.UpdateEndpointSettings(new EndpointSettings { Name = "Sales", TrackInstances = false }, cancellationToken); diff --git a/src/ServiceControl.Persistence.Tests/TrialLicenseDataProviderTests.cs b/src/ServiceControl.Persistence.Tests/TrialLicenseDataProviderTests.cs index d43cfb998b..807e018b8f 100644 --- a/src/ServiceControl.Persistence.Tests/TrialLicenseDataProviderTests.cs +++ b/src/ServiceControl.Persistence.Tests/TrialLicenseDataProviderTests.cs @@ -9,7 +9,7 @@ namespace ServiceControl.Persistence.Tests; class TrialLicenseDataProviderTests : PersistenceTestBase { - [Test, CancelAfter(10_000)] + [Test, CancelAfter(15_000)] public async Task GetTrialEndDate_returns_null_by_default(CancellationToken cancellationToken) { var trialLicenseDataProvider = ServiceProvider.GetRequiredService(); @@ -19,7 +19,7 @@ public async Task GetTrialEndDate_returns_null_by_default(CancellationToken canc Assert.That(trialEndDate, Is.Null); } - [Test, CancelAfter(10_000)] + [Test, CancelAfter(15_000)] public async Task StoreTrialEndDate_persists_value(CancellationToken cancellationToken) { var trialLicenseDataProvider = ServiceProvider.GetRequiredService(); From 81fef2cd5eda42b37941590ec7d28c3718dc71c3 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Fri, 31 Jul 2026 11:03:11 +0800 Subject: [PATCH 6/6] back out audit parallelisation --- .../NUnitSettings.cs | 4 --- src/TestHelper/PortUtility.cs | 30 +++++++------------ 2 files changed, 10 insertions(+), 24 deletions(-) delete mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs deleted file mode 100644 index 41ff98ae36..0000000000 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/NUnitSettings.cs +++ /dev/null @@ -1,4 +0,0 @@ -using NUnit.Framework; - -[assembly: Parallelizable(ParallelScope.Fixtures)] -[assembly: LevelOfParallelism(4)] diff --git a/src/TestHelper/PortUtility.cs b/src/TestHelper/PortUtility.cs index 03d2d086d7..7fc4648031 100644 --- a/src/TestHelper/PortUtility.cs +++ b/src/TestHelper/PortUtility.cs @@ -1,36 +1,26 @@ namespace TestHelper { - using System; - using System.Net; - using System.Net.Sockets; + using System.Linq; + using System.Net.NetworkInformation; public static class PortUtility { public static int FindAvailablePort(int startPort) { - const int searchRange = 1024; + var activeTcpListeners = IPGlobalProperties + .GetIPGlobalProperties() + .GetActiveTcpListeners(); - // Multiple test hosts can start embedded RavenDB instances at the same time in CI. - // Offset the initial probe per process to reduce cross-process collisions. - var processOffset = Environment.ProcessId % searchRange; - - for (var attempt = 0; attempt < searchRange; attempt++) + for (var port = startPort; port < startPort + 1024; port++) { - var port = startPort + ((processOffset + attempt) % searchRange); - - try - { - using var listener = new TcpListener(IPAddress.Loopback, port); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } - catch (SocketException) + var portCopy = port; + if (activeTcpListeners.All(endPoint => endPoint.Port != portCopy)) { - // Port is not currently available, try the next one. + return port; } } - return startPort + processOffset; + return startPort; } } }