Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions tools/TestErrorInstanceMigration/AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using AppHost;
using Particular.Aspire.Hosting.ServicePlatform.Platform;

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}");

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.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<Projects.FailingEndpoint>("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();
return;



22 changes: 22 additions & 0 deletions tools/TestErrorInstanceMigration/AppHost/AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.5">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<UserSecretsId>5881c0e9-3e87-4447-85ed-6346bf54dc61</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.5" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.5" />
<PackageReference Include="Aspire.Hosting.SqlServer" Version="13.4.5" />
<PackageReference Include="Particular.Aspire.Hosting.ServicePlatform" Version="1.1.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\FailingEndpoint\FailingEndpoint.csproj" />
<ProjectReference Include="..\ServiceDefaults\ServiceDefaults.csproj" IsAspireProjectResource="false" />
</ItemGroup>
</Project>
83 changes: 83 additions & 0 deletions tools/TestErrorInstanceMigration/AppHost/BuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using Microsoft.Extensions.Hosting;
using Particular.Aspire.Hosting.ServicePlatform.Platform;

namespace AppHost;

public static class BuilderExtensions
{
public static IResourceBuilder<IResourceWithConnectionString> 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<ServiceControlErrorInstanceResource> AddRavenErrorInstance(
this IResourceBuilder<ParticularPlatformResource> platform,
IResourceBuilder<IResource> 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<ServiceControlErrorInstanceResource> AddSqlErrorInstance(
this IResourceBuilder<ParticularPlatformResource> platform,
IResourceBuilder<IResource> raven,
TargetPersistence targetPersistence,
IResourceBuilder<IResourceWithConnectionString> 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<T>(this IResourceBuilder<T> 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);
}
}
}
8 changes: 8 additions & 0 deletions tools/TestErrorInstanceMigration/AppHost/MigrationMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace AppHost;

public enum MigrationMode
{
Step1PreMigration,
Step2RetryMessages,
Step3PostMigration
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
9 changes: 9 additions & 0 deletions tools/TestErrorInstanceMigration/AppHost/TargetPersistence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace AppHost;

public enum TargetPersistence
{
SqlServer,
Sql = SqlServer,
PostgreSql,
Postgres = PostgreSql
}
9 changes: 9 additions & 0 deletions tools/TestErrorInstanceMigration/AppHost/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
public sealed class FailDeterministically : IMessage
{
public Guid ErrorId { get; set; }
public string RandomPayload { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
public sealed class FailDeterministicallyHandler : IHandleMessages<FailDeterministically>
{
public Task Handle(FailDeterministically message, IMessageHandlerContext context) =>
throw new SimulatedDeterministicFailure($"Error {message.ErrorId} is expected to fail on every attempt.");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="NServiceBus" Version="10.2.8" />
<PackageReference Include="NServiceBus.Heartbeat" Version="6.0.1" />
<PackageReference Include="NServiceBus.Transport.AzureServiceBus" Version="6.5.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ServiceDefaults\ServiceDefaults.csproj" />
</ItemGroup>
</Project>
54 changes: 54 additions & 0 deletions tools/TestErrorInstanceMigration/FailingEndpoint/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// 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.EnableInstallers();
endpointConfiguration.UseSerialization<SystemJsonSerializer>();
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.MapGet("/createerrors", 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 = "GET /createerrors",
note = "Each generated message always fails, including when retried from ServicePulse."
}));

await app.RunAsync();
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public sealed class SimulatedDeterministicFailure(string message) : Exception(message);
63 changes: 63 additions & 0 deletions tools/TestErrorInstanceMigration/README.md
Original file line number Diff line number Diff line change
@@ -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:<endpoint-port>/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.
5 changes: 5 additions & 0 deletions tools/TestErrorInstanceMigration/ReplacingErrorInstance.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Solution>
<Project Path="AppHost\AppHost.csproj" />
<Project Path="FailingEndpoint/FailingEndpoint.csproj" />
<Project Path="ServiceDefaults\ServiceDefaults.csproj" />
</Solution>
Loading
Loading