From 8b4a60f4af398797410fdaa17f5b8bac78f934e6 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 24 Sep 2026 12:23:46 +0800 Subject: [PATCH 1/3] Add tests and logic for error ingestion only setup command handling --- .../Hosting/HostArgumentsTests.cs | 52 +++++++++++++++++++ .../Hosting/SetupCommandTests.cs | 22 ++++++++ .../Hosting/Commands/SetupCommand.cs | 7 +++ src/ServiceControl/Hosting/HostArguments.cs | 12 +++-- 4 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 src/ServiceControl.UnitTests/Hosting/HostArgumentsTests.cs create mode 100644 src/ServiceControl.UnitTests/Hosting/SetupCommandTests.cs diff --git a/src/ServiceControl.UnitTests/Hosting/HostArgumentsTests.cs b/src/ServiceControl.UnitTests/Hosting/HostArgumentsTests.cs new file mode 100644 index 0000000000..f6e4ef3d91 --- /dev/null +++ b/src/ServiceControl.UnitTests/Hosting/HostArgumentsTests.cs @@ -0,0 +1,52 @@ +namespace ServiceControl.UnitTests.Hosting +{ + using System; + using NUnit.Framework; + using Particular.ServiceControl.Hosting; + using ServiceControl.Hosting.Commands; + + [TestFixture] + class HostArgumentsTests + { + [Test] + public void Setup_keeps_the_error_ingestion_only_flag_so_setup_can_refuse_it() + { + var arguments = new HostArguments(["--setup", "--error-ingestion-only"]); + + Assert.Multiple(() => + { + Assert.That(arguments.Command, Is.EqualTo(typeof(SetupCommand))); + Assert.That(arguments.ErrorIngestionOnly, Is.True); + }); + } + + [Test] + public void Error_ingestion_only_alone_runs_the_ingestion_only_host() + { + var arguments = new HostArguments(["--error-ingestion-only"]); + + Assert.That(arguments.Command, Is.EqualTo(typeof(ErrorIngestionOnlyCommand))); + } + + [TestCase("--maintenance", typeof(MaintenanceModeCommand))] + [TestCase("--import-failed-errors", typeof(ImportFailedErrorsCommand))] + public void Other_modes_still_win_over_error_ingestion_only(string mode, Type expected) + { + var arguments = new HostArguments([mode, "--error-ingestion-only"]); + + Assert.That(arguments.Command, Is.EqualTo(expected)); + } + + [Test] + public void Setup_alone_is_not_error_ingestion_only() + { + var arguments = new HostArguments(["--setup"]); + + Assert.Multiple(() => + { + Assert.That(arguments.Command, Is.EqualTo(typeof(SetupCommand))); + Assert.That(arguments.ErrorIngestionOnly, Is.False); + }); + } + } +} diff --git a/src/ServiceControl.UnitTests/Hosting/SetupCommandTests.cs b/src/ServiceControl.UnitTests/Hosting/SetupCommandTests.cs new file mode 100644 index 0000000000..3c2e744e98 --- /dev/null +++ b/src/ServiceControl.UnitTests/Hosting/SetupCommandTests.cs @@ -0,0 +1,22 @@ +namespace ServiceControl.UnitTests.Hosting +{ + using System; + using NUnit.Framework; + using Particular.ServiceControl.Hosting; + using ServiceControl.Hosting.Commands; + + [TestFixture] + class SetupCommandTests + { + [Test] + public void Refuses_to_set_up_an_error_ingestion_only_worker() + { + var arguments = new HostArguments(["--setup", "--error-ingestion-only"]); + + // No settings, so a missing guard fails on a null reference instead of provisioning whatever this machine configures. + var exception = Assert.ThrowsAsync(() => new SetupCommand().Execute(arguments, settings: null)); + + Assert.That(exception.Message, Does.Contain("--error-ingestion-only runs no setup")); + } + } +} diff --git a/src/ServiceControl/Hosting/Commands/SetupCommand.cs b/src/ServiceControl/Hosting/Commands/SetupCommand.cs index a719eaa8eb..5630781b30 100644 --- a/src/ServiceControl/Hosting/Commands/SetupCommand.cs +++ b/src/ServiceControl/Hosting/Commands/SetupCommand.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Hosting.Commands { + using System; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -18,6 +19,12 @@ class SetupCommand : AbstractCommand { public override async Task Execute(HostArguments args, Settings settings, CancellationToken cancellationToken = default) { + if (args.ErrorIngestionOnly) + { + throw new Exception( + "--error-ingestion-only runs no setup: the queues and database belong to the normal instance, which provisions them. Start this worker with --error-ingestion-only alone, without --setup or --setup-and-run."); + } + var hostBuilder = Host.CreateApplicationBuilder(); hostBuilder.AddServiceControlInstallers(settings); diff --git a/src/ServiceControl/Hosting/HostArguments.cs b/src/ServiceControl/Hosting/HostArguments.cs index b260543662..88fe15a162 100644 --- a/src/ServiceControl/Hosting/HostArguments.cs +++ b/src/ServiceControl/Hosting/HostArguments.cs @@ -58,12 +58,15 @@ public HostArguments(string[] args) { "error-ingestion-only", "Run only error ingestion, for scaling out ingestion across several processes", - s => Command = typeof(ErrorIngestionOnlyCommand) + s => ErrorIngestionOnly = true } }; try { + // Parsed before setup returns, so setup can refuse to provision an ingestion-only worker. + errorIngestionOnlyOptions.Parse(args); + externalInstallerOptions.Parse(args); if (Command == typeof(SetupCommand)) @@ -85,10 +88,9 @@ public HostArguments(string[] args) return; } - errorIngestionOnlyOptions.Parse(args); - - if (Command == typeof(ErrorIngestionOnlyCommand)) + if (ErrorIngestionOnly) { + Command = typeof(ErrorIngestionOnlyCommand); return; } @@ -107,6 +109,8 @@ public HostArguments(string[] args) public bool SkipQueueCreation { get; private set; } + public bool ErrorIngestionOnly { get; private set; } + public void PrintUsage() { var helpText = string.Empty; From 42c25c18982f3271f75d8f103ac97f760a6b6cd7 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 24 Sep 2026 12:28:13 +0800 Subject: [PATCH 2/3] Clarify error ingestion only mode restrictions in Help.txt --- src/ServiceControl/Hosting/Help.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ServiceControl/Hosting/Help.txt b/src/ServiceControl/Hosting/Help.txt index 4925b8c494..d616180539 100644 --- a/src/ServiceControl/Hosting/Help.txt +++ b/src/ServiceControl/Hosting/Help.txt @@ -18,6 +18,7 @@ Runs a host that only drains the error queue into the configured database, so se share the ingestion load. Requires SQL Server or PostgreSQL storage, and requires that the database has already been provisioned by a normal instance. Exactly one normal instance must still be running: it owns the retry pipeline, the retention sweep, integration event dispatch and heartbeat monitoring. +This mode runs no setup, so combining it with --setup or --setup-and-run is refused. Message bodies must be stored somewhere every host can read, so this mode should not be combined with file system body storage unless the path is a shared mount. From 98b14d91f8a3a12f9922b2289664dec5cf2715bd Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 24 Sep 2026 12:51:53 +0800 Subject: [PATCH 3/3] Set default concurrency level to 10 for error ingestion only setup --- .../When_hosting_error_ingestion_only.cs | 19 +++++++++++++++++++ .../HostApplicationBuilderExtensions.cs | 3 +++ 2 files changed, 22 insertions(+) diff --git a/src/ServiceControl.AcceptanceTests/Recoverability/When_hosting_error_ingestion_only.cs b/src/ServiceControl.AcceptanceTests/Recoverability/When_hosting_error_ingestion_only.cs index c9472b6b94..6b8c118688 100644 --- a/src/ServiceControl.AcceptanceTests/Recoverability/When_hosting_error_ingestion_only.cs +++ b/src/ServiceControl.AcceptanceTests/Recoverability/When_hosting_error_ingestion_only.cs @@ -64,6 +64,25 @@ public async Task Should_ingest_without_an_endpoint_and_without_the_single_owner } } + [Test] + public async Task Should_default_the_concurrency_to_10_when_none_is_configured() + { + var settings = await CreateSettings(); + settings.MaximumConcurrencyLevel = null; + + var host = ErrorIngestionOnlyCommand.BuildHost(settings); + + try + { + Assert.DoesNotThrow(() => host.Services.GetServices().ToArray()); + Assert.That(host.Services.GetRequiredService().MaxConcurrency, Is.EqualTo(10)); + } + finally + { + await host.DisposeAsync(); + } + } + static readonly string[] ExpectedHostedServices = [ "GenericWebHostService", // health endpoint only, no ServiceControl API diff --git a/src/ServiceControl/HostApplicationBuilderExtensions.cs b/src/ServiceControl/HostApplicationBuilderExtensions.cs index 9b682fe1a6..cc43874a16 100644 --- a/src/ServiceControl/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl/HostApplicationBuilderExtensions.cs @@ -111,6 +111,9 @@ public static void AddServiceControl(this IHostApplicationBuilder hostBuilder, S { // Ingestion receives through its own transport infrastructure and forwards through // that same infrastructure's dispatcher, so the endpoint is not hosted at all. + // A hosted endpoint gets this default from CustomizePrimaryEndpoint, which this branch skips. + transportSettings.MaxConcurrency ??= 10; + var machineName = NServiceBus.Support.RuntimeEnvironment.MachineName; services.AddSingleton(new HostInformation( DeterministicGuid.MakeId(machineName, settings.InstanceName),