From f5d03e7843de30cf8282689f0579a6d2e9c8e84f Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Fri, 21 Aug 2026 09:51:28 +0800 Subject: [PATCH 1/2] Create aspire harness for testing error instance SQL migration --- .../AppHost/AppHost.cs | 43 ++++++ .../AppHost/AppHost.csproj | 22 +++ .../AppHost/BuilderExtensions.cs | 83 +++++++++++ .../AppHost/MigrationMode.cs | 8 ++ .../AppHost/Properties/launchSettings.json | 29 ++++ .../AppHost/TargetPersistence.cs | 9 ++ .../AppHost/appsettings.json | 9 ++ .../FailingEndpoint/FailingEndpoint.csproj | 18 +++ .../FailingEndpoint/Program.cs | 97 +++++++++++++ .../Properties/launchSettings.json | 14 ++ tools/TestErrorInstanceMigration/README.md | 63 +++++++++ .../Extensions.cs | 129 ++++++++++++++++++ .../ParticularPlatformConfig.cs | 7 + ...lacingErrorInstance.ServiceDefaults.csproj | 20 +++ .../ReplacingErrorInstance.slnx | 5 + .../aspire.config.json | 5 + 16 files changed, 561 insertions(+) create mode 100644 tools/TestErrorInstanceMigration/AppHost/AppHost.cs create mode 100644 tools/TestErrorInstanceMigration/AppHost/AppHost.csproj create mode 100644 tools/TestErrorInstanceMigration/AppHost/BuilderExtensions.cs create mode 100644 tools/TestErrorInstanceMigration/AppHost/MigrationMode.cs create mode 100644 tools/TestErrorInstanceMigration/AppHost/Properties/launchSettings.json create mode 100644 tools/TestErrorInstanceMigration/AppHost/TargetPersistence.cs create mode 100644 tools/TestErrorInstanceMigration/AppHost/appsettings.json create mode 100644 tools/TestErrorInstanceMigration/FailingEndpoint/FailingEndpoint.csproj create mode 100644 tools/TestErrorInstanceMigration/FailingEndpoint/Program.cs create mode 100644 tools/TestErrorInstanceMigration/FailingEndpoint/Properties/launchSettings.json create mode 100644 tools/TestErrorInstanceMigration/README.md create mode 100644 tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/Extensions.cs create mode 100644 tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ParticularPlatformConfig.cs create mode 100644 tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ReplacingErrorInstance.ServiceDefaults.csproj create mode 100644 tools/TestErrorInstanceMigration/ReplacingErrorInstance.slnx create mode 100644 tools/TestErrorInstanceMigration/aspire.config.json diff --git a/tools/TestErrorInstanceMigration/AppHost/AppHost.cs b/tools/TestErrorInstanceMigration/AppHost/AppHost.cs new file mode 100644 index 0000000000..976ac0017e --- /dev/null +++ b/tools/TestErrorInstanceMigration/AppHost/AppHost.cs @@ -0,0 +1,43 @@ +using AppHost; +using Particular.Aspire.Hosting.ServicePlatform.Platform; + +var mode = MigrationMode.PostMigrationMode; +var enableIngestion = true; +var targetPersistence = TargetPersistence.SqlServer; +var imageTag = "latest"; + +Console.WriteLine($"Migration mode: {mode}; target persistence: {targetPersistence}; ServiceControl image tag: {imageTag}"); + +var builder = DistributedApplication.CreateBuilder(args); + +// Azure Service Bus transport — the connection string is supplied as a secret parameter. +var asbConnectionString = builder.AddParameter("asb-connection-string", secret: true); +var transport = builder.AddConnectionString("transport", ReferenceExpression.Create($"{asbConnectionString}")); + +var platform = builder + .AddParticularPlatform("particular") + .WithTransportAzureServiceBus(transport); + +var raven = platform.AddPersistenceRavenDb("migration-ravendb") + .WithVolume("migration-raven-config", "/etc/ravendb") + .WithVolume("migration-raven-data", "/var/lib/ravendb/data"); +var targetDatabase = builder.AddTargetDatabase(targetPersistence); + +var errorInstance = + mode switch + { + MigrationMode.PreMigration => platform.AddRavenErrorInstance(raven, imageTag, enableIngestion), + MigrationMode.PostMigrationMode => platform.AddSqlErrorInstance(raven, targetPersistence, targetDatabase, imageTag), + _ => throw new Exception() + }; + +platform.AddServicePulse("servicepulse", errorInstance!); + +builder.AddProject("failing-endpoint") + .WithParticularPlatform(platform); + +await builder.Build().RunAsync(); +return; + + + diff --git a/tools/TestErrorInstanceMigration/AppHost/AppHost.csproj b/tools/TestErrorInstanceMigration/AppHost/AppHost.csproj new file mode 100644 index 0000000000..09312093de --- /dev/null +++ b/tools/TestErrorInstanceMigration/AppHost/AppHost.csproj @@ -0,0 +1,22 @@ + + + Exe + net10.0 + enable + enable + true + 5881c0e9-3e87-4447-85ed-6346bf54dc61 + + + + + + + + + + + + + + diff --git a/tools/TestErrorInstanceMigration/AppHost/BuilderExtensions.cs b/tools/TestErrorInstanceMigration/AppHost/BuilderExtensions.cs new file mode 100644 index 0000000000..b312e6250e --- /dev/null +++ b/tools/TestErrorInstanceMigration/AppHost/BuilderExtensions.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.Hosting; +using Particular.Aspire.Hosting.ServicePlatform.Platform; + +namespace AppHost; + +public static class BuilderExtensions +{ + public static IResourceBuilder AddTargetDatabase(this IDistributedApplicationBuilder builder, TargetPersistence persistence) + { + if (persistence == TargetPersistence.SqlServer) + { + var password = builder.AddParameter("sql-password", secret: true); + var server = builder.AddSqlServer("sqlserver", password) + .WithDataVolume("migration-sql-data"); + return server.AddDatabase("servicecontrol-sql", "ServiceControl"); + } + + var postgresPassword = builder.AddParameter("postgres-password", secret: true); + var postgres = builder.AddPostgres("postgres", password: postgresPassword) + .WithDataVolume("migration-postgres-data"); + return postgres.AddDatabase("servicecontrol-postgres", "servicecontrol"); + } + + public static IResourceBuilder AddRavenErrorInstance( + this IResourceBuilder platform, + IResourceBuilder raven, + string imageTag, + bool ingestErrors) + { + + var instance = platform + .AddServiceControlErrorInstance("old-error-ravendb", raven) + .WithErrorQueueName(ParticularPlatformConfig.ErrorQueue); + + ApplyImage(instance, "particular/servicecontrol", imageTag); + + if (!ingestErrors) + { + // Keep the old API available for retry/archive operations while leaving newly failed + // and deterministically re-failed messages in the queue for the replacement instance. + instance.WithEnvironment("SERVICECONTROL_INGESTERRORMESSAGES", "false"); + } + + return instance; + } + + public static IResourceBuilder AddSqlErrorInstance( + this IResourceBuilder platform, + IResourceBuilder raven, + TargetPersistence targetPersistence, + IResourceBuilder sqlDb, + string imageTag) + { + var instance = platform + .AddServiceControlErrorInstance("error-sql", raven) + .WithEnvironment("SERVICECONTROL_PERSISTENCETYPE", PersistenceTypeName(targetPersistence)) + .WithEnvironment("SERVICECONTROL_DATABASE_CONNECTIONSTRING", sqlDb.Resource.ConnectionStringExpression) + .WithEnvironment("SERVICECONTROL_MESSAGEBODY_STORAGETYPE", "FileSystem") + .WithEnvironment("SERVICECONTROL_MESSAGEBODY_FILESYSTEM_STORAGEPATH", "/var/lib/servicecontrol/message-bodies") + .WithVolume("migration-target-message-bodies", "/var/lib/servicecontrol/message-bodies") + .WithErrorQueueName(ParticularPlatformConfig.ErrorQueue); + + ApplyImage(instance, "particular/servicecontrol", imageTag); + + return instance; + } + + static string PersistenceTypeName(TargetPersistence persistence) => persistence switch + { + TargetPersistence.SqlServer => "SQLServer", + TargetPersistence.PostgreSql => "PostgreSQL", + _ => throw new ArgumentOutOfRangeException(nameof(persistence)) + }; + + static void ApplyImage(this IResourceBuilder resource, string image, string tag) where T : ContainerResource + { + // Non-latest tags are CI images, matching the existing GHCR test harness convention. + if (!string.Equals(tag, "latest", StringComparison.OrdinalIgnoreCase)) + { + resource.WithImage($"ghcr.io/{image}", tag); + } + } +} \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/AppHost/MigrationMode.cs b/tools/TestErrorInstanceMigration/AppHost/MigrationMode.cs new file mode 100644 index 0000000000..1a4b1ffd7f --- /dev/null +++ b/tools/TestErrorInstanceMigration/AppHost/MigrationMode.cs @@ -0,0 +1,8 @@ +namespace AppHost; + +public enum MigrationMode +{ + PreMigration, + SideBySide, + PostMigrationMode +} \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/AppHost/Properties/launchSettings.json b/tools/TestErrorInstanceMigration/AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..d8a1451e49 --- /dev/null +++ b/tools/TestErrorInstanceMigration/AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://error-migration.dev.localhost:17290;http://error-migration.dev.localhost:15170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21118", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22281" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://error-migration.dev.localhost:15170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19233", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20173" + } + } + } +} \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/AppHost/TargetPersistence.cs b/tools/TestErrorInstanceMigration/AppHost/TargetPersistence.cs new file mode 100644 index 0000000000..345bdf3b85 --- /dev/null +++ b/tools/TestErrorInstanceMigration/AppHost/TargetPersistence.cs @@ -0,0 +1,9 @@ +namespace AppHost; + +public enum TargetPersistence +{ + SqlServer, + Sql = SqlServer, + PostgreSql, + Postgres = PostgreSql +} \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/AppHost/appsettings.json b/tools/TestErrorInstanceMigration/AppHost/appsettings.json new file mode 100644 index 0000000000..27bbd50072 --- /dev/null +++ b/tools/TestErrorInstanceMigration/AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/FailingEndpoint/FailingEndpoint.csproj b/tools/TestErrorInstanceMigration/FailingEndpoint/FailingEndpoint.csproj new file mode 100644 index 0000000000..b92b7b2f37 --- /dev/null +++ b/tools/TestErrorInstanceMigration/FailingEndpoint/FailingEndpoint.csproj @@ -0,0 +1,18 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/FailingEndpoint/Program.cs b/tools/TestErrorInstanceMigration/FailingEndpoint/Program.cs new file mode 100644 index 0000000000..8f614ff13d --- /dev/null +++ b/tools/TestErrorInstanceMigration/FailingEndpoint/Program.cs @@ -0,0 +1,97 @@ +using NServiceBus; +using NServiceBus.Heartbeat; + +const string AuditQueue = "audit"; + +// WithParticularPlatform injects the ASB connection string as ConnectionStrings__transport. +var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__transport") + ?? throw new InvalidOperationException("Azure Service Bus connection string was not supplied by the AppHost."); + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +var endpointConfiguration = new EndpointConfiguration("MigrationTest.FailingEndpoint"); +var transport = new AzureServiceBusTransport(connectionString, TopicTopology.Default); +endpointConfiguration.UseTransport(transport); +endpointConfiguration.SendFailedMessagesTo(ParticularPlatformConfig.ErrorQueue); +endpointConfiguration.AuditProcessedMessagesTo(AuditQueue); +endpointConfiguration.EnableInstallers(); +endpointConfiguration.UseSerialization(); +endpointConfiguration.Recoverability() + .Immediate(retries => retries.NumberOfRetries(0)) + .Delayed(retries => retries.NumberOfRetries(0)); + +// Send heartbeats so ServicePulse discovers the endpoint and shows it as active. +endpointConfiguration.SendHeartbeatTo( + serviceControlQueue: ParticularPlatformConfig.ServiceControlQueue, + frequency: TimeSpan.FromSeconds(10)); + +builder.Services.AddNServiceBusEndpoint(endpointConfiguration); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.MapPost("/errors", async (IMessageSession messageSession, int? count) => +{ + var numberOfErrors = Math.Clamp(count ?? 1, 1, 100); + var ids = new Guid[numberOfErrors]; + + for (var i = 0; i < numberOfErrors; i++) + { + ids[i] = Guid.NewGuid(); + await messageSession.SendLocal(new FailDeterministically + { + ErrorId = ids[i], + RandomPayload = Convert.ToHexString(Guid.NewGuid().ToByteArray()) + }); + } + + return Results.Accepted(value: new { count = numberOfErrors, ids }); +}); + +app.MapGet("/", () => Results.Ok(new +{ + usage = "POST /errors?count=1", + note = "Each generated message always fails, including when retried from ServicePulse." +})); + +// Generate one deterministic failure on startup so the error queue is non-empty immediately. +app.Lifetime.ApplicationStarted.Register(() => +{ + _ = Task.Run(async () => + { + try + { + using var scope = app.Services.CreateScope(); + var messageSession = scope.ServiceProvider.GetRequiredService(); + await messageSession.SendLocal(new FailDeterministically + { + ErrorId = Guid.NewGuid(), + RandomPayload = Convert.ToHexString(Guid.NewGuid().ToByteArray()) + }); + Console.WriteLine("Startup failure message sent."); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to send startup failure message: {ex}"); + } + }); +}); + +await app.RunAsync(); + +public sealed class FailDeterministically : IMessage +{ + public Guid ErrorId { get; set; } + public string RandomPayload { get; set; } = string.Empty; +} + +public sealed class FailDeterministicallyHandler : IHandleMessages +{ + public Task Handle(FailDeterministically message, IMessageHandlerContext context) => + throw new SimulatedDeterministicFailure($"Error {message.ErrorId} is expected to fail on every attempt."); +} + +public sealed class SimulatedDeterministicFailure(string message) : Exception(message); \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/FailingEndpoint/Properties/launchSettings.json b/tools/TestErrorInstanceMigration/FailingEndpoint/Properties/launchSettings.json new file mode 100644 index 0000000000..3229a96d8e --- /dev/null +++ b/tools/TestErrorInstanceMigration/FailingEndpoint/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5187", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/tools/TestErrorInstanceMigration/README.md b/tools/TestErrorInstanceMigration/README.md new file mode 100644 index 0000000000..f1afe8507c --- /dev/null +++ b/tools/TestErrorInstanceMigration/README.md @@ -0,0 +1,63 @@ +# Replacing a ServiceControl Error instance with Aspire + +This Aspire solution exercises the [Error instance replacement process](https://docs.particular.net/servicecontrol/migrations/replacing-error-instances/). + +## Prerequisites + +- .NET 10 SDK +- Aspire CLI +- A container runtime +- An Azure Service Bus connection string (supplied via the `asb-connection-string` parameter) +- A Particular Platform license in a standard license location or `PARTICULARSOFTWARE_LICENSE` + +If `--tag` is omitted, published `latest` ServiceControl images are used. A non-`latest` tag selects the corresponding `ghcr.io/particular/servicecontrol*` CI images, which is useful when testing the SQL Server and PostgreSQL persisters from a branch build. + +## Run the migration stages + +Run commands from this directory and keep `--persistence` and `--tag` unchanged between the last two stages. + +```bash +aspire run --project AppHost/AppHost.csproj -- \ + --mode PreMigration \ + --persistence SqlServer +``` + +Open the `deterministic-failing-endpoint` URL in the Aspire dashboard and generate failures: + +```bash +curl -X POST 'http://localhost:/errors?count=10' +``` + +The payloads and IDs are random, but their failure is deterministic: the handler always throws, including after a ServicePulse retry. + +Next, restart in side-by-side mode: + +```bash +aspire run --project AppHost/AppHost.csproj -- \ + --mode SideBySide \ + --persistence SqlServer +``` + +The old RavenDB Error instance remains available through ServicePulse but no longer ingests from the error queue. Retry or archive its remaining failures. Retried messages fail again and are ingested by `new-error`. + +Finally, restart without the old Error instance: + +```bash +aspire run --project AppHost/AppHost.csproj -- \ + --mode PostMigrationMode \ + --persistence SqlServer +``` + +ServicePulse now points to `new-error`. The shared RavenDB-backed Audit instance remains registered as its remote instance. + +Use `PostgreSql` (or `Postgres`) instead of `SqlServer` to test PostgreSQL. `Sql` is also accepted as an alias for `SqlServer`. + +## Persistent state + +Named volumes preserve: + +- the original RavenDB Error and shared Audit data; +- SQL Server or PostgreSQL target data; and +- target Error message bodies. + +The transport is Azure Service Bus. The connection string is supplied as a secret Aspire parameter (`asb-connection-string`), which can be set via user secrets, environment variable, or the Aspire run prompt. diff --git a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/Extensions.cs b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000000..97f95afa5c --- /dev/null +++ b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/Extensions.cs @@ -0,0 +1,129 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddMeter("NServiceBus.*") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddSource("NServiceBus.*") + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ParticularPlatformConfig.cs b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ParticularPlatformConfig.cs new file mode 100644 index 0000000000..c2d0c913b0 --- /dev/null +++ b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ParticularPlatformConfig.cs @@ -0,0 +1,7 @@ +namespace Microsoft.Extensions.Hosting; + +public static class ParticularPlatformConfig +{ + public const string ErrorQueue = "error2"; + public const string ServiceControlQueue = "Particular.ServiceControl"; +} \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ReplacingErrorInstance.ServiceDefaults.csproj b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ReplacingErrorInstance.ServiceDefaults.csproj new file mode 100644 index 0000000000..0a48696d6d --- /dev/null +++ b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ReplacingErrorInstance.ServiceDefaults.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.slnx b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.slnx new file mode 100644 index 0000000000..d26f3a35d7 --- /dev/null +++ b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/tools/TestErrorInstanceMigration/aspire.config.json b/tools/TestErrorInstanceMigration/aspire.config.json new file mode 100644 index 0000000000..97ef3ac47d --- /dev/null +++ b/tools/TestErrorInstanceMigration/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "AppHost/AppHost.csproj" + } +} \ No newline at end of file From df2e4145060f40c8effb3d5d67e39758b5aff954 Mon Sep 17 00:00:00 2001 From: Rhys Bevilaqua Date: Fri, 21 Aug 2026 15:16:04 +0800 Subject: [PATCH 2/2] Tweak migrator --- .../AppHost/AppHost.cs | 14 ++++-- .../AppHost/AppHost.csproj | 2 +- .../AppHost/MigrationMode.cs | 6 +-- .../FailingEndpoint/FailDeterministically.cs | 5 ++ .../FailDeterministicallyHandler.cs | 5 ++ .../FailingEndpoint/FailingEndpoint.csproj | 2 +- .../FailingEndpoint/Program.cs | 49 ++----------------- .../SimulatedDeterministicFailure.cs | 1 + .../ReplacingErrorInstance.slnx | 2 +- .../Extensions.cs | 0 .../ParticularPlatformConfig.cs | 0 .../ServiceDefaults.csproj} | 1 + 12 files changed, 31 insertions(+), 56 deletions(-) create mode 100644 tools/TestErrorInstanceMigration/FailingEndpoint/FailDeterministically.cs create mode 100644 tools/TestErrorInstanceMigration/FailingEndpoint/FailDeterministicallyHandler.cs create mode 100644 tools/TestErrorInstanceMigration/FailingEndpoint/SimulatedDeterministicFailure.cs rename tools/TestErrorInstanceMigration/{ReplacingErrorInstance.ServiceDefaults => ServiceDefaults}/Extensions.cs (100%) rename tools/TestErrorInstanceMigration/{ReplacingErrorInstance.ServiceDefaults => ServiceDefaults}/ParticularPlatformConfig.cs (100%) rename tools/TestErrorInstanceMigration/{ReplacingErrorInstance.ServiceDefaults/ReplacingErrorInstance.ServiceDefaults.csproj => ServiceDefaults/ServiceDefaults.csproj} (92%) diff --git a/tools/TestErrorInstanceMigration/AppHost/AppHost.cs b/tools/TestErrorInstanceMigration/AppHost/AppHost.cs index 976ac0017e..dd2ea4d737 100644 --- a/tools/TestErrorInstanceMigration/AppHost/AppHost.cs +++ b/tools/TestErrorInstanceMigration/AppHost/AppHost.cs @@ -1,9 +1,9 @@ using AppHost; using Particular.Aspire.Hosting.ServicePlatform.Platform; -var mode = MigrationMode.PostMigrationMode; -var enableIngestion = true; +var mode = MigrationMode.Step1PreMigration; var targetPersistence = TargetPersistence.SqlServer; +var enableIngestion = mode != MigrationMode.Step2RetryMessages; var imageTag = "latest"; Console.WriteLine($"Migration mode: {mode}; target persistence: {targetPersistence}; ServiceControl image tag: {imageTag}"); @@ -26,14 +26,20 @@ var errorInstance = mode switch { - MigrationMode.PreMigration => platform.AddRavenErrorInstance(raven, imageTag, enableIngestion), - MigrationMode.PostMigrationMode => platform.AddSqlErrorInstance(raven, targetPersistence, targetDatabase, imageTag), + MigrationMode.Step1PreMigration or MigrationMode.Step2RetryMessages => platform.AddRavenErrorInstance(raven, imageTag, enableIngestion), + MigrationMode.Step3PostMigration => platform.AddSqlErrorInstance(raven, targetPersistence, targetDatabase, imageTag), _ => throw new Exception() }; platform.AddServicePulse("servicepulse", errorInstance!); builder.AddProject("failing-endpoint") + .WithEnvironment("CREATE_FAILURES", (mode == MigrationMode.PreMigration).ToString()) + .WithUrlForEndpoint("http", url => + { + url.DisplayText = "Create Errors"; + url.Url += "/createerrors"; + }) .WithParticularPlatform(platform); await builder.Build().RunAsync(); diff --git a/tools/TestErrorInstanceMigration/AppHost/AppHost.csproj b/tools/TestErrorInstanceMigration/AppHost/AppHost.csproj index 09312093de..04a9a67460 100644 --- a/tools/TestErrorInstanceMigration/AppHost/AppHost.csproj +++ b/tools/TestErrorInstanceMigration/AppHost/AppHost.csproj @@ -17,6 +17,6 @@ - + diff --git a/tools/TestErrorInstanceMigration/AppHost/MigrationMode.cs b/tools/TestErrorInstanceMigration/AppHost/MigrationMode.cs index 1a4b1ffd7f..806f3a9bc1 100644 --- a/tools/TestErrorInstanceMigration/AppHost/MigrationMode.cs +++ b/tools/TestErrorInstanceMigration/AppHost/MigrationMode.cs @@ -2,7 +2,7 @@ namespace AppHost; public enum MigrationMode { - PreMigration, - SideBySide, - PostMigrationMode + Step1PreMigration, + Step2RetryMessages, + Step3PostMigration } \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/FailingEndpoint/FailDeterministically.cs b/tools/TestErrorInstanceMigration/FailingEndpoint/FailDeterministically.cs new file mode 100644 index 0000000000..9bd35e4f8e --- /dev/null +++ b/tools/TestErrorInstanceMigration/FailingEndpoint/FailDeterministically.cs @@ -0,0 +1,5 @@ +public sealed class FailDeterministically : IMessage +{ + public Guid ErrorId { get; set; } + public string RandomPayload { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/FailingEndpoint/FailDeterministicallyHandler.cs b/tools/TestErrorInstanceMigration/FailingEndpoint/FailDeterministicallyHandler.cs new file mode 100644 index 0000000000..052423c17a --- /dev/null +++ b/tools/TestErrorInstanceMigration/FailingEndpoint/FailDeterministicallyHandler.cs @@ -0,0 +1,5 @@ +public sealed class FailDeterministicallyHandler : IHandleMessages +{ + public Task Handle(FailDeterministically message, IMessageHandlerContext context) => + throw new SimulatedDeterministicFailure($"Error {message.ErrorId} is expected to fail on every attempt."); +} \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/FailingEndpoint/FailingEndpoint.csproj b/tools/TestErrorInstanceMigration/FailingEndpoint/FailingEndpoint.csproj index b92b7b2f37..5932842bce 100644 --- a/tools/TestErrorInstanceMigration/FailingEndpoint/FailingEndpoint.csproj +++ b/tools/TestErrorInstanceMigration/FailingEndpoint/FailingEndpoint.csproj @@ -13,6 +13,6 @@ - + \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/FailingEndpoint/Program.cs b/tools/TestErrorInstanceMigration/FailingEndpoint/Program.cs index 8f614ff13d..86a5a457fe 100644 --- a/tools/TestErrorInstanceMigration/FailingEndpoint/Program.cs +++ b/tools/TestErrorInstanceMigration/FailingEndpoint/Program.cs @@ -1,8 +1,3 @@ -using NServiceBus; -using NServiceBus.Heartbeat; - -const string AuditQueue = "audit"; - // WithParticularPlatform injects the ASB connection string as ConnectionStrings__transport. var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__transport") ?? throw new InvalidOperationException("Azure Service Bus connection string was not supplied by the AppHost."); @@ -15,7 +10,6 @@ var transport = new AzureServiceBusTransport(connectionString, TopicTopology.Default); endpointConfiguration.UseTransport(transport); endpointConfiguration.SendFailedMessagesTo(ParticularPlatformConfig.ErrorQueue); -endpointConfiguration.AuditProcessedMessagesTo(AuditQueue); endpointConfiguration.EnableInstallers(); endpointConfiguration.UseSerialization(); endpointConfiguration.Recoverability() @@ -33,7 +27,7 @@ app.MapDefaultEndpoints(); -app.MapPost("/errors", async (IMessageSession messageSession, int? count) => +app.MapGet("/createerrors", async (IMessageSession messageSession, int? count) => { var numberOfErrors = Math.Clamp(count ?? 1, 1, 100); var ids = new Guid[numberOfErrors]; @@ -53,45 +47,8 @@ await messageSession.SendLocal(new FailDeterministically app.MapGet("/", () => Results.Ok(new { - usage = "POST /errors?count=1", + usage = "GET /createerrors", note = "Each generated message always fails, including when retried from ServicePulse." })); -// Generate one deterministic failure on startup so the error queue is non-empty immediately. -app.Lifetime.ApplicationStarted.Register(() => -{ - _ = Task.Run(async () => - { - try - { - using var scope = app.Services.CreateScope(); - var messageSession = scope.ServiceProvider.GetRequiredService(); - await messageSession.SendLocal(new FailDeterministically - { - ErrorId = Guid.NewGuid(), - RandomPayload = Convert.ToHexString(Guid.NewGuid().ToByteArray()) - }); - Console.WriteLine("Startup failure message sent."); - } - catch (Exception ex) - { - Console.WriteLine($"Failed to send startup failure message: {ex}"); - } - }); -}); - -await app.RunAsync(); - -public sealed class FailDeterministically : IMessage -{ - public Guid ErrorId { get; set; } - public string RandomPayload { get; set; } = string.Empty; -} - -public sealed class FailDeterministicallyHandler : IHandleMessages -{ - public Task Handle(FailDeterministically message, IMessageHandlerContext context) => - throw new SimulatedDeterministicFailure($"Error {message.ErrorId} is expected to fail on every attempt."); -} - -public sealed class SimulatedDeterministicFailure(string message) : Exception(message); \ No newline at end of file +await app.RunAsync(); \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/FailingEndpoint/SimulatedDeterministicFailure.cs b/tools/TestErrorInstanceMigration/FailingEndpoint/SimulatedDeterministicFailure.cs new file mode 100644 index 0000000000..bcc3d2742e --- /dev/null +++ b/tools/TestErrorInstanceMigration/FailingEndpoint/SimulatedDeterministicFailure.cs @@ -0,0 +1 @@ +public sealed class SimulatedDeterministicFailure(string message) : Exception(message); \ No newline at end of file diff --git a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.slnx b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.slnx index d26f3a35d7..26ca5a35fc 100644 --- a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.slnx +++ b/tools/TestErrorInstanceMigration/ReplacingErrorInstance.slnx @@ -1,5 +1,5 @@ - + diff --git a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/Extensions.cs b/tools/TestErrorInstanceMigration/ServiceDefaults/Extensions.cs similarity index 100% rename from tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/Extensions.cs rename to tools/TestErrorInstanceMigration/ServiceDefaults/Extensions.cs diff --git a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ParticularPlatformConfig.cs b/tools/TestErrorInstanceMigration/ServiceDefaults/ParticularPlatformConfig.cs similarity index 100% rename from tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ParticularPlatformConfig.cs rename to tools/TestErrorInstanceMigration/ServiceDefaults/ParticularPlatformConfig.cs diff --git a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ReplacingErrorInstance.ServiceDefaults.csproj b/tools/TestErrorInstanceMigration/ServiceDefaults/ServiceDefaults.csproj similarity index 92% rename from tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ReplacingErrorInstance.ServiceDefaults.csproj rename to tools/TestErrorInstanceMigration/ServiceDefaults/ServiceDefaults.csproj index 0a48696d6d..f1f7503238 100644 --- a/tools/TestErrorInstanceMigration/ReplacingErrorInstance.ServiceDefaults/ReplacingErrorInstance.ServiceDefaults.csproj +++ b/tools/TestErrorInstanceMigration/ServiceDefaults/ServiceDefaults.csproj @@ -5,6 +5,7 @@ enable enable true + ReplacingErrorInstance.ServiceDefaults