fix(gateway): remove the worker startup-probe retry options that could never retry (#188)

WorkerProcessStartedProbe only checks HasExited and throws a non-retryable
WorkerProcessLaunchException, so StartupProbeRetryAttempts and
StartupProbeRetryDelayMilliseconds configured a Polly retry that no shipped
probe could trigger. Real readiness is WorkerClient's WorkerReady handshake
under the same StartupTimeoutSeconds budget, so the probe now runs once and
the two options, their validation, the Polly pipeline (and the server's now
unused Polly.Core reference) and their docs are removed. A stale value in
configuration is ignored by the binder.

Tests: WorkerProcessLauncherTests.LaunchAsync_WhenStartupProbeFails_DoesNotRetryTheProbe,
WorkerOptions_HasNoStartupProbeRetryOptions (both fail on the old code).
This commit is contained in:
Joseph Doherty
2026-11-06 16:05:42 -05:00
parent 3a3d9e8e06
commit ff397b27be
10 changed files with 48 additions and 105 deletions
-5
View File
@@ -22,8 +22,6 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
"WorkingDirectory": null, "WorkingDirectory": null,
"RequiredArchitecture": "X86", "RequiredArchitecture": "X86",
"StartupTimeoutSeconds": 30, "StartupTimeoutSeconds": 30,
"StartupProbeRetryAttempts": 3,
"StartupProbeRetryDelayMilliseconds": 250,
"PipeConnectAttemptTimeoutMilliseconds": 2000, "PipeConnectAttemptTimeoutMilliseconds": 2000,
"ShutdownTimeoutSeconds": 10, "ShutdownTimeoutSeconds": 10,
"HeartbeatIntervalSeconds": 5, "HeartbeatIntervalSeconds": 5,
@@ -116,8 +114,6 @@ launch CWD (SEC-01, SEC-33).
| `MxGateway:Worker:WorkingDirectory` | `null` | Optional working directory for the worker process. When set, it must be a valid filesystem path. | | `MxGateway:Worker:WorkingDirectory` | `null` | Optional working directory for the worker process. When set, it must be a valid filesystem path. |
| `MxGateway:Worker:RequiredArchitecture` | `X86` | Required Portable Executable architecture for the worker. Supported values are `X86` and `X64`; MXAccess parity uses `X86`. | | `MxGateway:Worker:RequiredArchitecture` | `X86` | Required Portable Executable architecture for the worker. Supported values are `X86` and `X64`; MXAccess parity uses `X86`. |
| `MxGateway:Worker:StartupTimeoutSeconds` | `30` | Total startup budget for process launch, startup probe, pipe connect, handshake, and worker readiness. | | `MxGateway:Worker:StartupTimeoutSeconds` | `30` | Total startup budget for process launch, startup probe, pipe connect, handshake, and worker readiness. |
| `MxGateway:Worker:StartupProbeRetryAttempts` | `3` | Number of retry attempts for transient worker startup probe failures before pipe connection and handshake continue. |
| `MxGateway:Worker:StartupProbeRetryDelayMilliseconds` | `250` | Delay between transient startup probe retry attempts. |
| `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. | | `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. |
| `MxGateway:Worker:WriteCompletionWaitMilliseconds` | `1500` | Bounded wait the worker holds a unary write reply (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`; bulk writes excluded) for the matching MXAccess `OnWriteComplete` callback, so the reply's `statuses` carry the real commit outcome. `0` disables the wait (pure fire-and-forget replies). Must be `>= 0`. The gateway conveys the value to the worker via the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment variable. Consumers that time their own writes must budget above this wait: OtOpcUa's GalaxyDriver wraps gateway writes in a 2 s Tier A resilience timeout, so a deployment raising this option past ~2000 must raise that driver `ResilienceConfig` write timeout in step or slow-but-successful commits surface as consumer-side failures. | | `MxGateway:Worker:WriteCompletionWaitMilliseconds` | `1500` | Bounded wait the worker holds a unary write reply (`Write`/`Write2`/`WriteSecured`/`WriteSecured2`; bulk writes excluded) for the matching MXAccess `OnWriteComplete` callback, so the reply's `statuses` carry the real commit outcome. `0` disables the wait (pure fire-and-forget replies). Must be `>= 0`. The gateway conveys the value to the worker via the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment variable. Consumers that time their own writes must budget above this wait: OtOpcUa's GalaxyDriver wraps gateway writes in a 2 s Tier A resilience timeout, so a deployment raising this option past ~2000 must raise that driver `ResilienceConfig` write timeout in step or slow-but-successful commits surface as consumer-side failures. |
| `MxGateway:Worker:EventQueueCapacity` | `10000` | Capacity, in events, of the worker's outbound MXAccess event queue. Must be between `1000` and `1000000`. This is burst headroom, not a throttle: the queue has no drop policy, so filling it records a `QueueOverflow` worker fault and faults the session. Raise it for sessions whose subscription set can outrun the drain loop (large advise sets, slow event consumers); the backing queue pre-allocates its slots, so the ceiling keeps a mistyped value from committing the 32-bit worker to an outsized allocation. The gateway conveys the value to the worker via the `MXGATEWAY_EVENT_QUEUE_CAPACITY` environment variable; a missing or unusable value leaves the worker on the 10000 default rather than failing the session. | | `MxGateway:Worker:EventQueueCapacity` | `10000` | Capacity, in events, of the worker's outbound MXAccess event queue. Must be between `1000` and `1000000`. This is burst headroom, not a throttle: the queue has no drop policy, so filling it records a `QueueOverflow` worker fault and faults the session. Raise it for sessions whose subscription set can outrun the drain loop (large advise sets, slow event consumers); the backing queue pre-allocates its slots, so the ceiling keeps a mistyped value from committing the 32-bit worker to an outsized allocation. The gateway conveys the value to the worker via the `MXGATEWAY_EVENT_QUEUE_CAPACITY` environment variable; a missing or unusable value leaves the worker on the 10000 default rather than failing the session. |
@@ -126,7 +122,6 @@ launch CWD (SEC-01, SEC-33).
| `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. | | `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. |
| `MxGateway:Worker:MaxMessageBytes` | `16842752` | Maximum worker IPC frame payload size in bytes. The validator allows values from `1024` through `268435456`, and additionally requires this to be at least `MxGateway:Protocol:MaxGrpcMessageBytes` plus a 65536-byte (64 KiB) envelope-overhead reserve **and at most that cap plus twice the reserve**. The default is the 16 MiB public gRPC cap plus one reserve. The gateway conveys the negotiated value to the worker in the handshake (`GatewayHello.max_frame_bytes`), so the worker frames to the same limit rather than a hard-coded default. The band is narrow by design, so changing either message-size knob alone no longer validates — move `MxGateway:Protocol:MaxGrpcMessageBytes` and this one together, keeping this one one to two reserves above it. | | `MxGateway:Worker:MaxMessageBytes` | `16842752` | Maximum worker IPC frame payload size in bytes. The validator allows values from `1024` through `268435456`, and additionally requires this to be at least `MxGateway:Protocol:MaxGrpcMessageBytes` plus a 65536-byte (64 KiB) envelope-overhead reserve **and at most that cap plus twice the reserve**. The default is the 16 MiB public gRPC cap plus one reserve. The gateway conveys the negotiated value to the worker in the handshake (`GatewayHello.max_frame_bytes`), so the worker frames to the same limit rather than a hard-coded default. The band is narrow by design, so changing either message-size knob alone no longer validates — move `MxGateway:Protocol:MaxGrpcMessageBytes` and this one together, keeping this one one to two reserves above it. |
`StartupProbeRetryAttempts`, `StartupProbeRetryDelayMilliseconds`,
`PipeConnectAttemptTimeoutMilliseconds`, timeout values, heartbeat values, and `PipeConnectAttemptTimeoutMilliseconds`, timeout values, heartbeat values, and
`MaxMessageBytes` must be positive. `MaxMessageBytes` is intentionally bounded `MaxMessageBytes` must be positive. `MaxMessageBytes` is intentionally bounded
to avoid accidental large allocations from malformed or oversized frames. It to avoid accidental large allocations from malformed or oversized frames. It
-2
View File
@@ -940,8 +940,6 @@ Suggested configuration shape:
"WorkingDirectory": null, "WorkingDirectory": null,
"RequiredArchitecture": "X86", "RequiredArchitecture": "X86",
"StartupTimeoutSeconds": 30, "StartupTimeoutSeconds": 30,
"StartupProbeRetryAttempts": 3,
"StartupProbeRetryDelayMilliseconds": 250,
"PipeConnectAttemptTimeoutMilliseconds": 2000, "PipeConnectAttemptTimeoutMilliseconds": 2000,
"ShutdownTimeoutSeconds": 10, "ShutdownTimeoutSeconds": 10,
"HeartbeatIntervalSeconds": 5, "HeartbeatIntervalSeconds": 5,
+3 -5
View File
@@ -109,7 +109,7 @@ The scalar fields mirror the counters and gauges. The four dictionaries provide
- `CommandFailuresByMethod` keys by gRPC method name. - `CommandFailuresByMethod` keys by gRPC method name.
- `EventsByFamily` keys by event family (the `Family` enum on a worker event). - `EventsByFamily` keys by event family (the `Family` enum on a worker event).
- `EventsBySession` keys by `sessionId`; entries are removed via `RemoveSessionEvents` when a session closes so the map does not grow without bound. - `EventsBySession` keys by `sessionId`; entries are removed via `RemoveSessionEvents` when a session closes so the map does not grow without bound.
- `RetryAttemptsByArea` keys by the resilience `area` tag, e.g. `worker_startup`. - `RetryAttemptsByArea` keys by the resilience `area` tag passed to `RetryAttempted(area)`. No gateway code path records a retry at present (the worker launcher's startup-probe retry was removed in #188), so it is empty unless a future resilience path records one.
`EventsReceived` is read with `Interlocked.Read(ref _eventsReceived)` because `EventReceived` increments it via `Interlocked.Increment` outside the lock to keep the event-ingestion path non-blocking. `CommandsStarted`, `CommandsSucceeded`, `CommandsFailed`, and `CommandFailuresByMethod` are read the same way: the command counters run two-to-three times per gRPC call, so they are recorded with `Interlocked` and a `ConcurrentDictionary` rather than under `_syncRoot` (PERF-02). The two queue depths are pulled from their registered sources before the lock is taken, since those delegates reach into subscriber channels and worker clients. `EventsReceived` is read with `Interlocked.Read(ref _eventsReceived)` because `EventReceived` increments it via `Interlocked.Increment` outside the lock to keep the event-ingestion path non-blocking. `CommandsStarted`, `CommandsSucceeded`, `CommandsFailed`, and `CommandFailuresByMethod` are read the same way: the command counters run two-to-three times per gRPC call, so they are recorded with `Interlocked` and a `ConcurrentDictionary` rather than under `_syncRoot` (PERF-02). The two queue depths are pulled from their registered sources before the lock is taken, since those delegates reach into subscriber channels and worker clients.
@@ -152,15 +152,13 @@ _metrics.RemoveSessionEvents(session.SessionId);
### Worker process launcher ### Worker process launcher
`Workers/WorkerProcessLauncher.cs` records process-level kills and startup retries: `Workers/WorkerProcessLauncher.cs` records process-level kills:
```csharp ```csharp
_metrics.WorkerKilled(reason); _metrics.WorkerKilled(reason);
...
_metrics.RetryAttempted("worker_startup");
``` ```
The `worker_startup` tag is hard-coded so the `RetryAttemptsByArea` snapshot reports launcher retries distinctly from other resilience areas. The launcher no longer records retries: its startup probe runs once (#188).
### Session worker client factory ### Session worker client factory
+9 -11
View File
@@ -49,17 +49,15 @@ Before starting the process, the launcher validates that the configured worker
path exists, has a `.exe` extension, contains a valid Windows Portable path exists, has a `.exe` extension, contains a valid Windows Portable
Executable header, and matches the configured `RequiredArchitecture`. Executable header, and matches the configured `RequiredArchitecture`.
After the process starts, `IWorkerStartupProbe` waits for startup readiness. After the process starts, `IWorkerStartupProbe` runs once. The default probe
The default probe only verifies that the worker did not exit immediately. The only verifies that the worker did not exit immediately; a worker that has
worker client replaces this probe when pipe connection, hello, and already exited cannot be recovered by waiting, so the probe is not retried.
`WorkerReady` handling are implemented. Real readiness is the pipe connection, hello, and `WorkerReady` handshake that
`WorkerClient` performs after the launch, under the same
Startup probing uses a bounded Polly retry policy. The gateway starts the worker `WorkerOptions.StartupTimeoutSeconds` budget. (The former
process once, then retries only transient startup-probe failures while the `StartupProbeRetryAttempts` / `StartupProbeRetryDelayMilliseconds` options were
process remains alive. The policy is configured by removed because no shipped probe could ever produce a retryable failure; a
`WorkerOptions.StartupProbeRetryAttempts` and stale value in configuration is ignored by the binder.)
`WorkerOptions.StartupProbeRetryDelayMilliseconds`; the retry counter is
recorded as `mxgateway.retries.attempted` with `area=worker_startup`.
The worker uses the exported pipe-attempt value inside its own bounded Polly The worker uses the exported pipe-attempt value inside its own bounded Polly
retry loop. retry loop.
@@ -246,14 +246,6 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
options.StartupTimeoutSeconds, options.StartupTimeoutSeconds,
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.", "MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
builder); builder);
AddIfNotPositive(
options.StartupProbeRetryAttempts,
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
builder);
AddIfNotPositive(
options.StartupProbeRetryDelayMilliseconds,
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
builder);
AddIfNotPositive( AddIfNotPositive(
options.PipeConnectAttemptTimeoutMilliseconds, options.PipeConnectAttemptTimeoutMilliseconds,
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
@@ -15,12 +15,6 @@ public sealed class WorkerOptions
/// <summary>The maximum time in seconds for the worker to start.</summary> /// <summary>The maximum time in seconds for the worker to start.</summary>
public int StartupTimeoutSeconds { get; init; } = 30; public int StartupTimeoutSeconds { get; init; } = 30;
/// <summary>The number of retry attempts for the startup probe.</summary>
public int StartupProbeRetryAttempts { get; init; } = 3;
/// <summary>The delay in milliseconds between startup probe retries.</summary>
public int StartupProbeRetryDelayMilliseconds { get; init; } = 250;
/// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary> /// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary>
public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000; public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000;
@@ -5,8 +5,6 @@ using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Metrics;
using Polly;
using Polly.Retry;
namespace ZB.MOM.WW.MxGateway.Server.Workers; namespace ZB.MOM.WW.MxGateway.Server.Workers;
@@ -140,15 +138,11 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
using CancellationTokenSource startupTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using CancellationTokenSource startupTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
startupTimeout.CancelAfter(TimeSpan.FromSeconds(_workerOptions.StartupTimeoutSeconds)); startupTimeout.CancelAfter(TimeSpan.FromSeconds(_workerOptions.StartupTimeoutSeconds));
await CreateStartupProbePipeline(process) // The probe runs once. Real readiness is the WorkerReady handshake WorkerClient performs
.ExecuteAsync( // after launch, under the same StartupTimeoutSeconds budget; the probe only catches a
async token => // worker that exited immediately, which no retry can recover (#188).
{ await _startupProbe
await _startupProbe .WaitUntilReadyAsync(process, request, startupTimeout.Token)
.WaitUntilReadyAsync(process, request, token)
.ConfigureAwait(false);
},
startupTimeout.Token)
.ConfigureAwait(false); .ConfigureAwait(false);
return new WorkerProcessHandle(process, commandLine, startedAt); return new WorkerProcessHandle(process, commandLine, startedAt);
@@ -310,43 +304,6 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
} }
} }
private ResiliencePipeline CreateStartupProbePipeline(IWorkerProcess process)
{
RetryStrategyOptions retryOptions = new()
{
MaxRetryAttempts = Math.Max(0, _workerOptions.StartupProbeRetryAttempts - 1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(_workerOptions.StartupProbeRetryDelayMilliseconds),
MaxDelay = TimeSpan.FromSeconds(2),
ShouldHandle = new PredicateBuilder().Handle<Exception>(exception =>
ShouldRetryStartupProbe(exception, process)),
OnRetry = args =>
{
_metrics.RetryAttempted("worker_startup");
_logger.LogDebug(
args.Outcome.Exception,
"Retrying worker startup probe after transient failure. Attempt {Attempt}.",
args.AttemptNumber + 1);
return default;
},
};
return new ResiliencePipelineBuilder()
.AddRetry(retryOptions)
.Build();
}
private static bool ShouldRetryStartupProbe(Exception exception, IWorkerProcess process)
{
if (exception is OperationCanceledException or WorkerProcessLaunchException)
{
return false;
}
return !process.HasExited;
}
private static void ValidateRequest(WorkerProcessLaunchRequest request) private static void ValidateRequest(WorkerProcessLaunchRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.SessionId)) if (string.IsNullOrWhiteSpace(request.SessionId))
@@ -35,7 +35,6 @@
<!-- Security pin: GHSA-846f-f764-76mx / GHSA-2fmv-rv2c-59x7. Microsoft.Data.SqlClient 6.0.2 <!-- Security pin: GHSA-846f-f764-76mx / GHSA-2fmv-rv2c-59x7. Microsoft.Data.SqlClient 6.0.2
pulls the vulnerable System.Security.Cryptography.Pkcs 9.0.4; 10.0.13 patches it. --> pulls the vulnerable System.Security.Cryptography.Pkcs 9.0.4; 10.0.13 patches it. -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" /> <PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
<PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -38,8 +38,6 @@ public sealed class GatewayOptionsTests
Assert.Equal(@"src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe", options.Worker.ExecutablePath); Assert.Equal(@"src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe", options.Worker.ExecutablePath);
Assert.Equal(WorkerArchitecture.X86, options.Worker.RequiredArchitecture); Assert.Equal(WorkerArchitecture.X86, options.Worker.RequiredArchitecture);
Assert.Equal(30, options.Worker.StartupTimeoutSeconds); Assert.Equal(30, options.Worker.StartupTimeoutSeconds);
Assert.Equal(3, options.Worker.StartupProbeRetryAttempts);
Assert.Equal(250, options.Worker.StartupProbeRetryDelayMilliseconds);
Assert.Equal(2000, options.Worker.PipeConnectAttemptTimeoutMilliseconds); Assert.Equal(2000, options.Worker.PipeConnectAttemptTimeoutMilliseconds);
Assert.Equal(1500, options.Worker.WriteCompletionWaitMilliseconds); Assert.Equal(1500, options.Worker.WriteCompletionWaitMilliseconds);
Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds); Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds);
@@ -110,7 +108,6 @@ public sealed class GatewayOptionsTests
/// <param name="expectedFailure">Expected validation error message.</param> /// <param name="expectedFailure">Expected validation error message.</param>
[Theory] [Theory]
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")] [InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
[InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")]
[InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")] [InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")]
[InlineData("MxGateway:Worker:WriteCompletionWaitMilliseconds", "-1", "MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.")] [InlineData("MxGateway:Worker:WriteCompletionWaitMilliseconds", "-1", "MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.")]
[InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")] [InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")]
@@ -167,32 +167,48 @@ public sealed class WorkerProcessLauncherTests
Assert.Equal(1, metrics.GetSnapshot().WorkerKills); Assert.Equal(1, metrics.GetSnapshot().WorkerKills);
} }
/// <summary>Verifies that transient startup probe failures are retried without respawning the worker process.</summary> /// <summary>
/// The startup probe runs once. A probe failure fails the launch — there is no retry to
/// configure, because real readiness is the WorkerReady handshake that follows the launch
/// (#188).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task LaunchAsync_WhenStartupProbeFailsTransiently_RetriesWithoutRespawningWorker() public async Task LaunchAsync_WhenStartupProbeFails_DoesNotRetryTheProbe()
{ {
using TestDirectory directory = TestDirectory.Create(); using TestDirectory directory = TestDirectory.Create();
string executablePath = directory.CreateWorkerExecutable(machine: 0x014c); string executablePath = directory.CreateWorkerExecutable(machine: 0x014c);
FakeWorkerProcess process = new(processId: 1234); FakeWorkerProcess process = new(processId: 1234);
FakeWorkerProcessFactory processFactory = new(process); FakeWorkerProcessFactory processFactory = new(process);
GatewayMetrics metrics = new(); GatewayMetrics metrics = new();
TransientStartupProbe probe = new(failuresBeforeSuccess: 1);
WorkerProcessLauncher launcher = CreateLauncher( WorkerProcessLauncher launcher = CreateLauncher(
executablePath, executablePath,
processFactory, processFactory,
new TransientStartupProbe(failuresBeforeSuccess: 1), probe,
metrics, metrics);
startupProbeRetryAttempts: 2,
startupProbeRetryDelayMilliseconds: 1);
using WorkerProcessHandle handle = await launcher.LaunchAsync(CreateRequest()); WorkerProcessLaunchException exception =
await Assert.ThrowsAsync<WorkerProcessLaunchException>(
async () => await launcher.LaunchAsync(CreateRequest()));
Assert.Same(process, handle.Process); Assert.Equal(WorkerProcessLaunchErrorCode.StartupFailed, exception.ErrorCode);
Assert.Equal(1, probe.Attempts);
Assert.Equal(1, processFactory.StartCount); Assert.Equal(1, processFactory.StartCount);
Assert.False(process.KillCalled); Assert.True(process.KillCalled);
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot(); Assert.Equal(0, metrics.GetSnapshot().RetryAttempts);
Assert.Equal(1, snapshot.RetryAttempts); }
Assert.Equal(1, snapshot.RetryAttemptsByArea["worker_startup"]);
/// <summary>
/// The retry options are gone from <see cref="WorkerOptions"/> so configuration cannot
/// promise a startup-probe retry that never happens (#188).
/// </summary>
[Fact]
public void WorkerOptions_HasNoStartupProbeRetryOptions()
{
Assert.DoesNotContain(
typeof(WorkerOptions).GetProperties(),
property => property.Name.StartsWith("StartupProbeRetry", StringComparison.Ordinal));
} }
/// <summary>Verifies that a startup probe timeout kills and disposes the worker process.</summary> /// <summary>Verifies that a startup probe timeout kills and disposes the worker process.</summary>
@@ -290,8 +306,6 @@ public sealed class WorkerProcessLauncherTests
IWorkerStartupProbe startupProbe, IWorkerStartupProbe startupProbe,
GatewayMetrics? metrics = null, GatewayMetrics? metrics = null,
int startupTimeoutSeconds = 30, int startupTimeoutSeconds = 30,
int startupProbeRetryAttempts = 3,
int startupProbeRetryDelayMilliseconds = 250,
int eventQueueCapacity = 10000, int eventQueueCapacity = 10000,
string ackClientName = "MxGateway") string ackClientName = "MxGateway")
{ {
@@ -303,8 +317,6 @@ public sealed class WorkerProcessLauncherTests
ExecutablePath = executablePath, ExecutablePath = executablePath,
RequiredArchitecture = WorkerArchitecture.X86, RequiredArchitecture = WorkerArchitecture.X86,
StartupTimeoutSeconds = startupTimeoutSeconds, StartupTimeoutSeconds = startupTimeoutSeconds,
StartupProbeRetryAttempts = startupProbeRetryAttempts,
StartupProbeRetryDelayMilliseconds = startupProbeRetryDelayMilliseconds,
EventQueueCapacity = eventQueueCapacity, EventQueueCapacity = eventQueueCapacity,
}, },
}; };
@@ -388,6 +400,9 @@ public sealed class WorkerProcessLauncherTests
{ {
private int _attempts; private int _attempts;
/// <summary>Gets the number of times the probe was invoked.</summary>
public int Attempts => Volatile.Read(ref _attempts);
/// <inheritdoc /> /// <inheritdoc />
public Task WaitUntilReadyAsync( public Task WaitUntilReadyAsync(
IWorkerProcess process, IWorkerProcess process,