diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index e0e28c4942..d28a7f9ceb 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -75,6 +75,8 @@
+
+
diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/ServiceControl.Persistence.EFCore.PostgreSql.csproj b/src/ServiceControl.Persistence.EFCore.PostgreSql/ServiceControl.Persistence.EFCore.PostgreSql.csproj
index f638c069d0..a20e270c74 100644
--- a/src/ServiceControl.Persistence.EFCore.PostgreSql/ServiceControl.Persistence.EFCore.PostgreSql.csproj
+++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/ServiceControl.Persistence.EFCore.PostgreSql.csproj
@@ -33,5 +33,8 @@
+
+
+
diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/ServiceControl.Persistence.EFCore.SqlServer.csproj b/src/ServiceControl.Persistence.EFCore.SqlServer/ServiceControl.Persistence.EFCore.SqlServer.csproj
index 862beed816..9346aa13f2 100644
--- a/src/ServiceControl.Persistence.EFCore.SqlServer/ServiceControl.Persistence.EFCore.SqlServer.csproj
+++ b/src/ServiceControl.Persistence.EFCore.SqlServer/ServiceControl.Persistence.EFCore.SqlServer.csproj
@@ -33,5 +33,9 @@
+
+
+
+
diff --git a/src/ServiceControl.Persistence.Tests.InMemory/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.InMemory/PersistenceTestsContext.cs
index 4e2f2e3d23..8e66c39775 100644
--- a/src/ServiceControl.Persistence.Tests.InMemory/PersistenceTestsContext.cs
+++ b/src/ServiceControl.Persistence.Tests.InMemory/PersistenceTestsContext.cs
@@ -8,6 +8,7 @@
public class PersistenceTestsContext : IPersistenceTestsContext
{
public PersistenceSettings PersistenceSettings { get; private set; }
+ public string GenerateFailedMessageRecordId(string messageId) => throw new System.NotImplementedException();
public Task Setup(IHostApplicationBuilder hostBuilder)
{
diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/.editorconfig b/src/ServiceControl.Persistence.Tests.PostgreSql/.editorconfig
new file mode 100644
index 0000000000..20102eaa39
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.PostgreSql/.editorconfig
@@ -0,0 +1,5 @@
+[*.cs]
+
+# Justification: ServiceControl app has no synchronization context
+dotnet_diagnostic.CA2007.severity = none
+
diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs
new file mode 100644
index 0000000000..091e910ece
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs
@@ -0,0 +1,38 @@
+// ReSharper disable once CheckNamespace
+namespace ServiceControl.Persistence.Tests;
+
+using System.Threading.Tasks;
+using EFCore.PostgreSql;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+public class PersistenceTestsContext : IPersistenceTestsContext
+{
+ public async Task Setup(IHostApplicationBuilder hostBuilder)
+ {
+ PersistenceSettings = new PostgreSqlPersisterSettings
+ {
+ ConnectionString = await PostgreSqlSharedContainer.GetConnectionStringAsync()
+ };
+
+ var persistence = new PostgreSqlPersistenceConfiguration().Create(PersistenceSettings);
+
+ persistence.AddPersistence(hostBuilder.Services);
+ persistence.AddInstaller(hostBuilder.Services);
+ }
+
+ public async Task PostSetup(IHost host)
+ {
+ using var scope = host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.EnsureCreatedAsync();
+ }
+
+ public Task TearDown() => Task.CompletedTask;
+
+ public void CompleteDatabaseOperation() { }
+
+ public PersistenceSettings PersistenceSettings { get; set; }
+
+ public string GenerateFailedMessageRecordId(string messageId) => messageId;
+}
diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlSharedContainer.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlSharedContainer.cs
new file mode 100644
index 0000000000..b3c5a9b223
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.PostgreSql/PostgreSqlSharedContainer.cs
@@ -0,0 +1,47 @@
+namespace ServiceControl.Persistence.Tests;
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Testcontainers.PostgreSql;
+
+static class PostgreSqlSharedContainer
+{
+ public static async Task GetConnectionStringAsync(CancellationToken ct = default)
+ {
+ var envConnStr = Environment.GetEnvironmentVariable("ServiceControl_Persistence_PostgreSql_ConnectionString");
+ if (!string.IsNullOrEmpty(envConnStr))
+ {
+ return envConnStr;
+ }
+
+ if (container != null)
+ {
+ return container.GetConnectionString();
+ }
+
+ await semaphore.WaitAsync(ct);
+ try
+ {
+ container ??= await StartContainerAsync(ct);
+ return container.GetConnectionString();
+ }
+ finally
+ {
+ semaphore.Release();
+ }
+ }
+
+ public static async Task Stop() => await (container?.DisposeAsync() ?? ValueTask.CompletedTask);
+
+ static async Task StartContainerAsync(CancellationToken ct)
+ {
+ var c = new PostgreSqlBuilder("postgres:16-alpine")
+ .Build();
+ await c.StartAsync(ct);
+ return c;
+ }
+
+ static PostgreSqlContainer container;
+ static readonly SemaphoreSlim semaphore = new(1, 1);
+}
diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj b/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj
new file mode 100644
index 0000000000..4394b112a4
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/StopSharedPostgreSql.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/StopSharedPostgreSql.cs
new file mode 100644
index 0000000000..d5b0e8515e
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.PostgreSql/StopSharedPostgreSql.cs
@@ -0,0 +1,12 @@
+namespace ServiceControl;
+
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Persistence.Tests;
+
+[SetUpFixture]
+public class StopSharedPostgreSql
+{
+ [OneTimeTearDown]
+ public async Task Teardown() => await PostgreSqlSharedContainer.Stop();
+}
diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/TestsFilter.cs b/src/ServiceControl.Persistence.Tests.PostgreSql/TestsFilter.cs
new file mode 100644
index 0000000000..c2971486f6
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.PostgreSql/TestsFilter.cs
@@ -0,0 +1 @@
+[assembly: IncludeInTestCategory("PostgreSqlPersistence")]
diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs
index 1bf7b44424..e8ba73939c 100644
--- a/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs
+++ b/src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs
@@ -53,6 +53,7 @@ public async Task PostSetup(IHost host)
public async Task TearDown() => await embeddedServer.DeleteDatabase(databaseName);
public PersistenceSettings PersistenceSettings { get; private set; }
+ public string GenerateFailedMessageRecordId(string messageId) => FailedMessageIdGenerator.MakeDocumentId(messageId);
public IDocumentStore DocumentStore { get; private set; }
diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/.editorconfig b/src/ServiceControl.Persistence.Tests.SqlServer/.editorconfig
new file mode 100644
index 0000000000..20102eaa39
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.SqlServer/.editorconfig
@@ -0,0 +1,5 @@
+[*.cs]
+
+# Justification: ServiceControl app has no synchronization context
+dotnet_diagnostic.CA2007.severity = none
+
diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs
new file mode 100644
index 0000000000..2d3a6b49d3
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs
@@ -0,0 +1,38 @@
+// ReSharper disable once CheckNamespace
+namespace ServiceControl.Persistence.Tests;
+
+using System.Threading.Tasks;
+using EFCore.SqlServer;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+public class PersistenceTestsContext : IPersistenceTestsContext
+{
+ public async Task Setup(IHostApplicationBuilder hostBuilder)
+ {
+ PersistenceSettings = new SqlServerPersisterSettings
+ {
+ ConnectionString = await SqlServerSharedContainer.GetConnectionStringAsync()
+ };
+
+ var persistence = new SqlServerPersistenceConfiguration().Create(PersistenceSettings);
+
+ persistence.AddPersistence(hostBuilder.Services);
+ persistence.AddInstaller(hostBuilder.Services);
+ }
+
+ public async Task PostSetup(IHost host)
+ {
+ using var scope = host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.EnsureCreatedAsync();
+ }
+
+ public Task TearDown() => Task.CompletedTask;
+
+ public void CompleteDatabaseOperation() { }
+
+ public PersistenceSettings PersistenceSettings { get; set; }
+
+ public string GenerateFailedMessageRecordId(string messageId) => messageId;
+}
\ No newline at end of file
diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj b/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj
new file mode 100644
index 0000000000..acbe985b53
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerSharedContainer.cs b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerSharedContainer.cs
new file mode 100644
index 0000000000..066e823329
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.SqlServer/SqlServerSharedContainer.cs
@@ -0,0 +1,47 @@
+namespace ServiceControl.Persistence.Tests;
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Testcontainers.MsSql;
+
+static class SqlServerSharedContainer
+{
+ public static async Task GetConnectionStringAsync(CancellationToken ct = default)
+ {
+ var envConnStr = Environment.GetEnvironmentVariable("ServiceControl_Persistence_SqlServer_ConnectionString");
+ if (!string.IsNullOrEmpty(envConnStr))
+ {
+ return envConnStr;
+ }
+
+ if (container != null)
+ {
+ return container.GetConnectionString();
+ }
+
+ await semaphore.WaitAsync(ct);
+ try
+ {
+ container ??= await StartContainerAsync(ct);
+ return container.GetConnectionString();
+ }
+ finally
+ {
+ semaphore.Release();
+ }
+ }
+
+ public static async Task Stop() => await (container?.DisposeAsync() ?? ValueTask.CompletedTask);
+
+ static async Task StartContainerAsync(CancellationToken ct)
+ {
+ var c = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest")
+ .Build();
+ await c.StartAsync(ct);
+ return c;
+ }
+
+ static MsSqlContainer container;
+ static readonly SemaphoreSlim semaphore = new(1, 1);
+}
diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/StopSharedSqlServer.cs b/src/ServiceControl.Persistence.Tests.SqlServer/StopSharedSqlServer.cs
new file mode 100644
index 0000000000..61b506cceb
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.SqlServer/StopSharedSqlServer.cs
@@ -0,0 +1,12 @@
+namespace ServiceControl;
+
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Persistence.Tests;
+
+[SetUpFixture]
+public class StopSharedSqlServer
+{
+ [OneTimeTearDown]
+ public async Task Teardown() => await SqlServerSharedContainer.Stop();
+}
diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/TestsFilter.cs b/src/ServiceControl.Persistence.Tests.SqlServer/TestsFilter.cs
new file mode 100644
index 0000000000..d37d903ffb
--- /dev/null
+++ b/src/ServiceControl.Persistence.Tests.SqlServer/TestsFilter.cs
@@ -0,0 +1 @@
+[assembly: IncludeInTestCategory("SqlServerPersistence")]
diff --git a/src/ServiceControl.Persistence.Tests/IPersistenceTestsContext.cs b/src/ServiceControl.Persistence.Tests/IPersistenceTestsContext.cs
index bebd0aec54..5009a117a8 100644
--- a/src/ServiceControl.Persistence.Tests/IPersistenceTestsContext.cs
+++ b/src/ServiceControl.Persistence.Tests/IPersistenceTestsContext.cs
@@ -14,4 +14,6 @@ public interface IPersistenceTestsContext
void CompleteDatabaseOperation();
PersistenceSettings PersistenceSettings { get; }
+
+ string GenerateFailedMessageRecordId(string messageId);
}
\ No newline at end of file
diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs
index a22a45e2fd..df02f36532 100644
--- a/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs
+++ b/src/ServiceControl.Persistence.Tests/Recoverability/EditHandlerAuditTests.cs
@@ -91,7 +91,7 @@ async Task CreateAndStoreFailedMessage(string failedMessageId = n
var failedMessage = new FailedMessage
{
UniqueMessageId = failedMessageId,
- Id = FailedMessageIdGenerator.MakeDocumentId(failedMessageId),
+ Id = PersistenceTestsContext.GenerateFailedMessageRecordId(failedMessageId),
Status = FailedMessageStatus.Unresolved,
ProcessingAttempts =
[
diff --git a/src/ServiceControl.Persistence.Tests/Recoverability/EditMessageTests.cs b/src/ServiceControl.Persistence.Tests/Recoverability/EditMessageTests.cs
index 74f62ef85f..7836a00e0f 100644
--- a/src/ServiceControl.Persistence.Tests/Recoverability/EditMessageTests.cs
+++ b/src/ServiceControl.Persistence.Tests/Recoverability/EditMessageTests.cs
@@ -248,7 +248,7 @@ async Task CreateAndStoreFailedMessage(string failedMessageId = n
var failedMessage = new FailedMessage
{
UniqueMessageId = failedMessageId,
- Id = FailedMessageIdGenerator.MakeDocumentId(failedMessageId),
+ Id = PersistenceTestsContext.GenerateFailedMessageRecordId(failedMessageId),
Status = status,
ProcessingAttempts =
[
diff --git a/src/ServiceControl.Persistence.Tests/RetryStateTests.cs b/src/ServiceControl.Persistence.Tests/RetryStateTests.cs
index 8f22784e67..bc87e50e34 100644
--- a/src/ServiceControl.Persistence.Tests/RetryStateTests.cs
+++ b/src/ServiceControl.Persistence.Tests/RetryStateTests.cs
@@ -239,7 +239,7 @@ public async Task When_a_selection_is_staged_each_message_is_audited_as_a_batch(
var messages = ids.Select(id => new FailedMessage
{
- Id = FailedMessageIdGenerator.MakeDocumentId(id),
+ Id = PersistenceTestsContext.GenerateFailedMessageRecordId(id),
UniqueMessageId = id,
Status = FailedMessageStatus.Unresolved,
ProcessingAttempts =
@@ -318,7 +318,7 @@ async Task CreateAFailedMessageAndMarkAsPartOfRetryBatch(RetryingManager retryMa
{
var messages = messageIds.Select(id => new FailedMessage
{
- Id = FailedMessageIdGenerator.MakeDocumentId(id),
+ Id = PersistenceTestsContext.GenerateFailedMessageRecordId(id),
UniqueMessageId = id,
FailureGroups =
[
diff --git a/src/ServiceControl.slnx b/src/ServiceControl.slnx
index 76d72c12f1..b59518a697 100644
--- a/src/ServiceControl.slnx
+++ b/src/ServiceControl.slnx
@@ -51,6 +51,8 @@
+
+
diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj
index 6c857f62d8..2223cb11af 100644
--- a/src/ServiceControl/ServiceControl.csproj
+++ b/src/ServiceControl/ServiceControl.csproj
@@ -60,7 +60,9 @@
+
+