From 881ba588cbabfd7f4ef436ce4855add55b7cd88b Mon Sep 17 00:00:00 2001 From: "milan.svitlica" Date: Mon, 19 Oct 2026 13:37:06 +0200 Subject: [PATCH 1/4] CxODEV-2161: keep the engine polling after Conductor errors; a timeout is not a shutdown (CxODEV-2000) WorkflowEngineBackgroundService ran the deployment and the execution manager once. Any error, or an HTTP timeout mistaken for a shutdown, ended polling for good while the host stayed up (and, for a timeout, stayed Healthy). RunAsync is now a supervisor loop: deploy, mark running, run the execution manager; on any error that is not our own shutdown, mark not running, log an error, back off (1 s doubling to 30 s, 500 ms jitter) and start again. The backoff resets after a successful deploy. Co-Authored-By: Claude Opus 5.5 --- .../WorkflowEngineBackgroundService.cs | 138 +++++++--- .../WorkflowEngineBackgroundServiceTests.cs | 249 ++++++++++++++++++ 2 files changed, 357 insertions(+), 30 deletions(-) create mode 100644 test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs diff --git a/src/ConductorSharp.Engine/Service/WorkflowEngineBackgroundService.cs b/src/ConductorSharp.Engine/Service/WorkflowEngineBackgroundService.cs index f8d2e84e..996abf84 100644 --- a/src/ConductorSharp.Engine/Service/WorkflowEngineBackgroundService.cs +++ b/src/ConductorSharp.Engine/Service/WorkflowEngineBackgroundService.cs @@ -10,22 +10,70 @@ namespace ConductorSharp.Engine.Service { - internal class WorkflowEngineBackgroundService( - IConductorSharpHealthService healthService, - ILogger logger, - IDeploymentService deploymentService, - IExecutionManager executionManager, - ModuleDeployment deployment - ) : IHostedService, IDisposable + internal class WorkflowEngineBackgroundService : IHostedService, IDisposable { - private readonly ILogger _logger = logger; - private readonly IDeploymentService _deploymentService = deploymentService; - private readonly IExecutionManager _executionManager = executionManager; - private readonly ModuleDeployment _deployment = deployment; - private readonly IConductorSharpHealthService _healthService = healthService; + private static readonly TimeSpan DefaultInitialRetryDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan DefaultMaxRetryDelay = TimeSpan.FromSeconds(30); + private static readonly TimeSpan DefaultMaxJitter = TimeSpan.FromMilliseconds(500); + + private readonly ILogger _logger; + private readonly IDeploymentService _deploymentService; + private readonly IExecutionManager _executionManager; + private readonly ModuleDeployment _deployment; + private readonly IConductorSharpHealthService _healthService; + private readonly TimeSpan _initialRetryDelay; + private readonly TimeSpan _maxRetryDelay; + private readonly TimeSpan _maxJitter; + private readonly Func _delay; + private readonly Func _jitter; private Task _executingTask; private readonly CancellationTokenSource _stoppingCts = new(); + public WorkflowEngineBackgroundService( + IConductorSharpHealthService healthService, + ILogger logger, + IDeploymentService deploymentService, + IExecutionManager executionManager, + ModuleDeployment deployment + ) + : this( + healthService, + logger, + deploymentService, + executionManager, + deployment, + DefaultInitialRetryDelay, + DefaultMaxRetryDelay, + DefaultMaxJitter, + Task.Delay, + Random.Shared.NextDouble + ) { } + + internal WorkflowEngineBackgroundService( + IConductorSharpHealthService healthService, + ILogger logger, + IDeploymentService deploymentService, + IExecutionManager executionManager, + ModuleDeployment deployment, + TimeSpan initialRetryDelay, + TimeSpan maxRetryDelay, + TimeSpan maxJitter, + Func delay, + Func jitter + ) + { + _healthService = healthService; + _logger = logger; + _deploymentService = deploymentService; + _executionManager = executionManager; + _deployment = deployment; + _initialRetryDelay = initialRetryDelay; + _maxRetryDelay = maxRetryDelay; + _maxJitter = maxJitter; + _delay = delay; + _jitter = jitter; + } + public Task StartAsync(CancellationToken cancellationToken) { _executingTask = RunAsync(_stoppingCts.Token); @@ -38,31 +86,61 @@ public Task StartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } + /// + /// Deploys the definitions and runs the execution manager until the service stops. Any failure, including a + /// Conductor request timeout, marks the engine as not running and starts both again after a capped backoff. + /// private async Task RunAsync(CancellationToken cancellationToken) { try { _healthService.RemoveHealthData(); - await _deploymentService.Deploy(_deployment); - await _healthService.SetExecutionManagerRunning(cancellationToken); - await _executionManager.StartAsync(cancellationToken); - } - catch (TaskCanceledException) - { + var retryDelay = _initialRetryDelay; + var attempt = 0; + + while (!cancellationToken.IsCancellationRequested) + { + try + { + await _deploymentService.Deploy(_deployment); + retryDelay = _initialRetryDelay; + attempt = 0; + await _healthService.SetExecutionManagerRunning(cancellationToken); + await _executionManager.StartAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + attempt++; + await _healthService.UnsetExecutionManagerRunning(CancellationToken.None); + var delay = retryDelay + TimeSpan.FromMilliseconds(_jitter() * _maxJitter.TotalMilliseconds); + + _logger.LogError( + exception, + "ConductorSharp engine stopped polling (attempt {Attempt}); retrying in {RetryDelay}. API errors: {ApiErrors}", + attempt, + delay, + (exception as ApiException)?.Errors + ); + + try + { + await _delay(delay, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + + retryDelay = TimeSpan.FromMilliseconds(Math.Min(retryDelay.TotalMilliseconds * 2, _maxRetryDelay.TotalMilliseconds)); + } + } + _logger.LogInformation("Stopping ConductorSharp background service"); } - catch (ApiException exception) - { - await _healthService.UnsetExecutionManagerRunning(cancellationToken); - _logger.LogCritical(exception, "Workflow Engine Background Service encountered an API error(s): {apiErrors}", exception.Errors); - throw; - } - catch (Exception exception) - { - await _healthService.UnsetExecutionManagerRunning(cancellationToken); - _logger.LogCritical(exception, "Workflow Engine Background Service encountered an error"); - throw; - } finally { _healthService.RemoveHealthData(); diff --git a/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs b/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs new file mode 100644 index 00000000..eeef7971 --- /dev/null +++ b/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs @@ -0,0 +1,249 @@ +using ConductorSharp.Client.Generated; +using ConductorSharp.Engine.Health; +using ConductorSharp.Engine.Service; +using Microsoft.Extensions.Logging; +using Task = System.Threading.Tasks.Task; + +namespace ConductorSharp.Engine.Tests.Unit; + +public class WorkflowEngineBackgroundServiceTests +{ + private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); + + private readonly InMemoryHealthService _healthService = new(); + private readonly List _delays = []; + private readonly ListLogger _logger = new(); + + public WorkflowEngineBackgroundServiceTests() => _healthService.ResetHealthData().Wait(); + + [Fact] + public async Task DeployApiError_IsRetriedAndEngineStarts() + { + var deployment = new ScriptedDeploymentService(Api(500), Api(409)); + var executionManager = new ScriptedExecutionManager(); + var service = CreateService(deployment, executionManager, RecordDelay); + + await service.StartAsync(CancellationToken.None); + await executionManager.Running.Task.WaitAsync(WaitTimeout); + + Assert.Equal(3, deployment.Calls); + Assert.Equal(1, executionManager.Calls); + Assert.Equal(new[] { 1, 2 }.Select(s => TimeSpan.FromSeconds(s)), _delays); + Assert.True((await _healthService.GetHealthData()).IsExecutionManagerRunning); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task ConnectionError_WhileRunning_RestartsEngine() + { + var deployment = new ScriptedDeploymentService(); + var executionManager = new ScriptedExecutionManager(new HttpRequestException("Connection refused")); + var service = CreateService(deployment, executionManager, RecordDelay); + + await service.StartAsync(CancellationToken.None); + await executionManager.Running.Task.WaitAsync(WaitTimeout); + + Assert.Equal(2, deployment.Calls); + Assert.Equal(2, executionManager.Calls); + Assert.True((await _healthService.GetHealthData()).IsExecutionManagerRunning); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task HttpTimeout_IsAnErrorNotAShutdown() + { + var deployment = new ScriptedDeploymentService(); + var executionManager = new ScriptedExecutionManager(new TaskCanceledException("timeout", new TimeoutException())); + var service = CreateService(deployment, executionManager, RecordDelay); + + await service.StartAsync(CancellationToken.None); + await executionManager.Running.Task.WaitAsync(WaitTimeout); + + Assert.Equal(2, executionManager.Calls); + Assert.Single(_delays); + Assert.Contains(_logger.Entries, e => e.Level == LogLevel.Error); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task HealthIsNotRunning_WhileBackingOff() + { + var deployment = new ScriptedDeploymentService(); + var executionManager = new ScriptedExecutionManager(new HttpRequestException("Connection refused")); + var backingOff = new TaskCompletionSource(); + var resume = new TaskCompletionSource(); + var service = CreateService( + deployment, + executionManager, + (delay, cancellationToken) => + { + backingOff.TrySetResult(); + return resume.Task.WaitAsync(cancellationToken); + } + ); + + await service.StartAsync(CancellationToken.None); + await backingOff.Task.WaitAsync(WaitTimeout); + + Assert.False((await _healthService.GetHealthData()).IsExecutionManagerRunning); + + resume.SetResult(); + await executionManager.Running.Task.WaitAsync(WaitTimeout); + + Assert.True((await _healthService.GetHealthData()).IsExecutionManagerRunning); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task NonTransientDeployError_IsRetriedWithCappedBackoff() + { + var deployment = new ScriptedDeploymentService(Enumerable.Range(0, 8).Select(_ => Api(401)).ToArray()); + var executionManager = new ScriptedExecutionManager(); + var service = CreateService(deployment, executionManager, RecordDelay); + + await service.StartAsync(CancellationToken.None); + await executionManager.Running.Task.WaitAsync(WaitTimeout); + + Assert.Equal(new[] { 1, 2, 4, 8, 16, 30, 30, 30 }.Select(s => TimeSpan.FromSeconds(s)), _delays); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task SuccessfulDeploy_ResetsBackoff() + { + var deployment = new ScriptedDeploymentService(Api(500), Api(500), Api(500)); + var executionManager = new ScriptedExecutionManager(new HttpRequestException("Connection refused")); + var service = CreateService(deployment, executionManager, RecordDelay); + + await service.StartAsync(CancellationToken.None); + await executionManager.Running.Task.WaitAsync(WaitTimeout); + + Assert.Equal(new[] { 1, 2, 4, 1 }.Select(s => TimeSpan.FromSeconds(s)), _delays); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task Stop_DuringBackoff_ExitsWithoutRetry() + { + var deployment = new ScriptedDeploymentService(Api(500)); + var executionManager = new ScriptedExecutionManager(); + var backingOff = new TaskCompletionSource(); + var service = CreateService( + deployment, + executionManager, + (delay, cancellationToken) => + { + backingOff.TrySetResult(); + return Task.Delay(Timeout.Infinite, cancellationToken); + } + ); + + await service.StartAsync(CancellationToken.None); + await backingOff.Task.WaitAsync(WaitTimeout); + await service.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.Equal(1, deployment.Calls); + Assert.Equal(0, executionManager.Calls); + Assert.Contains(_logger.Entries, e => e.Message == "Stopping ConductorSharp background service"); + } + + [Fact] + public async Task Stop_WhileRunning_DoesNotRestart() + { + var deployment = new ScriptedDeploymentService(); + var executionManager = new ScriptedExecutionManager(); + var service = CreateService(deployment, executionManager, RecordDelay); + + await service.StartAsync(CancellationToken.None); + await executionManager.Running.Task.WaitAsync(WaitTimeout); + await service.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.Equal(1, executionManager.Calls); + Assert.Empty(_delays); + Assert.DoesNotContain(_logger.Entries, e => e.Level >= LogLevel.Error); + } + + private Task RecordDelay(TimeSpan delay, CancellationToken cancellationToken) + { + _delays.Add(delay); + return Task.CompletedTask; + } + + private WorkflowEngineBackgroundService CreateService( + IDeploymentService deploymentService, + IExecutionManager executionManager, + Func delay + ) => + new( + _healthService, + _logger, + deploymentService, + executionManager, + new ModuleDeployment([], [], []), + initialRetryDelay: TimeSpan.FromSeconds(1), + maxRetryDelay: TimeSpan.FromSeconds(30), + maxJitter: TimeSpan.FromMilliseconds(500), + delay: delay, + jitter: () => 0 + ); + + private static ApiException Api(int statusCode) => + new($"HTTP {statusCode}", statusCode, "{}", new Dictionary>(), null); + + private sealed class ScriptedDeploymentService(params Exception[] failures) : IDeploymentService + { + private readonly Queue _failures = new(failures); + + public int Calls { get; private set; } + + public Task Deploy(Deployment deployment) + { + Calls++; + return _failures.TryDequeue(out var failure) ? Task.FromException(failure) : Task.CompletedTask; + } + + public Task Remove(Deployment deployment) => Task.CompletedTask; + } + + /// Throws the queued failures, one per call; once they run out, polls (blocks) until cancelled. + private sealed class ScriptedExecutionManager(params Exception[] failures) : IExecutionManager + { + private readonly Queue _failures = new(failures); + + public int Calls { get; private set; } + + public TaskCompletionSource Running { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task StartAsync(CancellationToken cancellationToken) + { + Calls++; + if (_failures.TryDequeue(out var failure)) + throw failure; + + Running.TrySetResult(); + await Task.Delay(Timeout.Infinite, cancellationToken); + } + } + + private sealed class ListLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + lock (Entries) + Entries.Add((logLevel, formatter(state, exception))); + } + } +} From a926cb48a9082d070c6c1d1d1f888b43ddc8e2bc Mon Sep 17 00:00:00 2001 From: "milan.svitlica" Date: Mon, 19 Oct 2026 13:42:27 +0200 Subject: [PATCH 2/4] CxODEV-2161: end-to-end recovery tests with a fake Conductor A real engine (DI wiring, hosted service, beta execution manager, HTTP client) against a fake Conductor that answers 503, hangs (request timeout) or loses the task-def registration race, then recovers. Each test checks that the engine backs off, reports not running, and completes a task queued after recovery; one also checks that a task in flight during the outage still completes. All four fail on the old loop. Co-Authored-By: Claude Opus 5.5 --- .../Integration/EngineRecoveryTests.cs | 308 ++++++++++++++++++ .../WorkflowEngineBackgroundServiceTests.cs | 1 + 2 files changed, 309 insertions(+) create mode 100644 test/ConductorSharp.Engine.Tests/Integration/EngineRecoveryTests.cs diff --git a/test/ConductorSharp.Engine.Tests/Integration/EngineRecoveryTests.cs b/test/ConductorSharp.Engine.Tests/Integration/EngineRecoveryTests.cs new file mode 100644 index 00000000..049b416a --- /dev/null +++ b/test/ConductorSharp.Engine.Tests/Integration/EngineRecoveryTests.cs @@ -0,0 +1,308 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Text; +using ConductorSharp.Client.Service; +using ConductorSharp.Engine.Builders.Metadata; +using ConductorSharp.Engine.Extensions; +using ConductorSharp.Engine.Health; +using ConductorSharp.Engine.Service; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using Task = System.Threading.Tasks.Task; + +namespace ConductorSharp.Engine.Tests.Integration; + +/// +/// A real engine (DI wiring, hosted service, beta execution manager, HTTP client) against a fake Conductor that can +/// fail, hang and recover. Retry delays are shortened; everything else is production code. +/// +[Collection(nameof(InMemoryHealthService))] +public class EngineRecoveryTests : IAsyncLifetime +{ + private const string ProbeTaskName = "recovery_probe_task"; + private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(10); + + private readonly FakeConductor _conductor = new(); + private readonly ProbeGate _gate = new(); + private ServiceProvider _provider; + private List _hostedServices = []; + private IConductorSharpHealthService _health; + + public async Task InitializeAsync() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(_gate); + services + .AddConductorSharp(baseUrl: "http://conductor") + .AddExecutionManager( + maxConcurrentWorkers: 4, + sleepInterval: 250, + longPollInterval: 250, + domain: null, + typeof(EngineRecoveryTests).Assembly + ) + .UseBetaExecutionManager() + .SetHealthCheckService(); + services.RegisterWorkerTask(); + + services.ConfigureHttpClientDefaults( + http => http.ConfigurePrimaryHttpMessageHandler(() => _conductor).ConfigureHttpClient(c => c.Timeout = TimeSpan.FromMilliseconds(300)) + ); + services.AddSingleton( + sp => + new TaskQueuePollingService( + sp.GetRequiredService(), + sp.GetRequiredService>(), + maxAttempts: 2, + initialRetryDelay: TimeSpan.FromMilliseconds(10), + maxRetryDelay: TimeSpan.FromMilliseconds(50), + maxJitter: TimeSpan.Zero, + delay: Task.Delay, + jitter: () => 0 + ) + ); + services.RemoveAll(); + services.AddTransient( + sp => + new WorkflowEngineBackgroundService( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + initialRetryDelay: TimeSpan.FromMilliseconds(20), + maxRetryDelay: TimeSpan.FromMilliseconds(100), + maxJitter: TimeSpan.Zero, + delay: Task.Delay, + jitter: () => 0 + ) + ); + + _provider = services.BuildServiceProvider(); + _health = _provider.GetRequiredService(); + await _health.ResetHealthData(); + } + + public async Task DisposeAsync() + { + _gate.Open(); + foreach (var hostedService in _hostedServices) + await hostedService.StopAsync(CancellationToken.None); + await _provider.DisposeAsync(); + } + + [Fact] + public async Task Outage503_EngineResumesAndPicksUpTask() + { + await StartEngineAsync(); + await WaitUntil(() => Task.FromResult(_conductor.QueueReads > 0), "the engine polls"); + + _conductor.Mode = FakeConductor.Fail503; + await WaitUntil(IsNotRunning, "the engine backs off (Unhealthy)"); + _conductor.Mode = FakeConductor.Ok; + _conductor.Enqueue("after-outage"); + + await WaitUntil(() => IsCompleted("after-outage"), "the task queued after the outage completes"); + Assert.True((await _health.GetHealthData()).IsExecutionManagerRunning); + } + + [Fact] + public async Task Hang_EngineResumesAndPicksUpTask() + { + await StartEngineAsync(); + await WaitUntil(() => Task.FromResult(_conductor.QueueReads > 0), "the engine polls"); + + _conductor.Mode = FakeConductor.Hang; + await WaitUntil(IsNotRunning, "the engine backs off (Unhealthy) after request timeouts"); + _conductor.Mode = FakeConductor.Ok; + _conductor.Enqueue("after-hang"); + + await WaitUntil(() => IsCompleted("after-hang"), "the task queued after the hang completes"); + Assert.True((await _health.GetHealthData()).IsExecutionManagerRunning); + } + + [Fact] + public async Task RegistrationRace_EngineStartsAfterRetry() + { + _conductor.FailNextTaskDefRegistration = true; + await StartEngineAsync(); + _conductor.Enqueue("after-race"); + + await WaitUntil(() => IsCompleted("after-race"), "the task completes after the failed registration"); + Assert.Equal(2, _conductor.TaskDefRegistrations); + } + + [Fact] + public async Task TaskInFlight_SurvivesLoopRestart() + { + await StartEngineAsync(); + _gate.Close(); + _conductor.Enqueue("in-flight"); + await WaitUntil(() => Task.FromResult(_conductor.HandedOut.Contains("in-flight")), "the engine picks up the task"); + + _conductor.Mode = FakeConductor.Fail503; + await WaitUntil(IsNotRunning, "the engine backs off (Unhealthy)"); + _conductor.Mode = FakeConductor.Ok; + await WaitUntil(async () => !await IsNotRunning(), "the engine runs again"); + Assert.False(await IsCompleted("in-flight")); + _gate.Open(); + + await WaitUntil(() => IsCompleted("in-flight"), "the task picked up before the outage completes"); + _conductor.Enqueue("after-restart"); + await WaitUntil(() => IsCompleted("after-restart"), "a new task completes after the restart"); + } + + private async Task StartEngineAsync() + { + _hostedServices = _provider.GetServices().ToList(); + foreach (var hostedService in _hostedServices) + await hostedService.StartAsync(CancellationToken.None); + } + + private async Task IsNotRunning() => !(await _health.GetHealthData()).IsExecutionManagerRunning; + + private Task IsCompleted(string taskId) => Task.FromResult(_conductor.Updates.TryGetValue(taskId, out var status) && status == "COMPLETED"); + + private static async Task WaitUntil(Func> condition, string what) + { + var stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < WaitTimeout) + { + if (await condition()) + return; + await Task.Delay(20); + } + + Assert.Fail($"Timed out after {WaitTimeout.TotalSeconds} s waiting until {what}"); + } + + public class RecoveryProbe : IRequest + { + public class Response { } + + // The gate is optional: other tests scan this assembly for handlers and validate their own containers. + [OriginalName(ProbeTaskName)] + public class Handler(ProbeGate gate = null) : TaskRequestHandler + { + public override async Task Handle(RecoveryProbe request, CancellationToken cancellationToken) + { + if (gate != null) + await gate.WaitAsync(cancellationToken); + return new Response(); + } + } + } + + /// Lets a test hold the probe handler mid-execution. + public class ProbeGate + { + private volatile TaskCompletionSource _gate = Opened(); + + public void Close() => _gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Open() => _gate.TrySetResult(); + + public Task WaitAsync(CancellationToken cancellationToken) => _gate.Task.WaitAsync(cancellationToken); + + private static TaskCompletionSource Opened() + { + var gate = new TaskCompletionSource(); + gate.SetResult(); + return gate; + } + } + + /// + /// Answers the Conductor endpoints the engine uses. switches between a healthy Conductor, one + /// that answers 503 to everything, and one that accepts requests but never answers (docker pause). + /// + private sealed class FakeConductor : HttpMessageHandler + { + public const string Ok = "ok"; + public const string Fail503 = "fail503"; + public const string Hang = "hang"; + + private readonly ConcurrentQueue _queued = new(); + private int _queueReads; + private int _taskDefRegistrations; + + public volatile string Mode = Ok; + public volatile bool FailNextTaskDefRegistration; + + public int QueueReads => Volatile.Read(ref _queueReads); + public int TaskDefRegistrations => Volatile.Read(ref _taskDefRegistrations); + public ConcurrentBag HandedOut { get; } = []; + public ConcurrentDictionary Updates { get; } = new(); + + public void Enqueue(string taskId) => _queued.Enqueue(taskId); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (Mode == Fail503) + return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + if (Mode == Hang) + await Task.Delay(Timeout.Infinite, cancellationToken); + + var path = request.RequestUri!.AbsolutePath; + + if (request.Method == HttpMethod.Post && path == "/api/metadata/taskdefs") + { + Interlocked.Increment(ref _taskDefRegistrations); + if (!FailNextTaskDefRegistration) + return new HttpResponseMessage(HttpStatusCode.OK); + + FailNextTaskDefRegistration = false; + return Json( + HttpStatusCode.InternalServerError, + new { status = 500, message = "ERROR: duplicate key value violates unique constraint \"meta_task_def_pkey\"" } + ); + } + + if (request.Method == HttpMethod.Get && path == "/api/tasks/queue/all") + { + Interlocked.Increment(ref _queueReads); + return Json(HttpStatusCode.OK, new Dictionary { [ProbeTaskName] = _queued.Count }); + } + + if (request.Method == HttpMethod.Get && path == $"/api/tasks/poll/{ProbeTaskName}") + { + if (!_queued.TryDequeue(out var taskId)) + return new HttpResponseMessage(HttpStatusCode.NoContent); + + HandedOut.Add(taskId); + return Json( + HttpStatusCode.OK, + new + { + taskId, + taskDefName = ProbeTaskName, + taskType = ProbeTaskName, + referenceTaskName = "probe", + workflowInstanceId = $"workflow-{taskId}", + workflowType = "recovery_probe_workflow", + status = "IN_PROGRESS", + inputData = new { } + } + ); + } + + if (request.Method == HttpMethod.Post && path == "/api/tasks") + { + var result = JObject.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); + var taskId = (string)result["taskId"]; + Updates[taskId] = (string)result["status"]; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(taskId) }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + private static HttpResponseMessage Json(HttpStatusCode statusCode, object body) => + new(statusCode) { Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json") }; + } +} diff --git a/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs b/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs index eeef7971..7455a4f2 100644 --- a/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs +++ b/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs @@ -6,6 +6,7 @@ namespace ConductorSharp.Engine.Tests.Unit; +[Collection(nameof(InMemoryHealthService))] // InMemoryHealthService keeps its flag in a static field public class WorkflowEngineBackgroundServiceTests { private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5); From 3d30841c9677be932bb690eeaae5c1c056e8729c Mon Sep 17 00:00:00 2001 From: "milan.svitlica" Date: Mon, 19 Oct 2026 13:43:59 +0200 Subject: [PATCH 3/4] Bump packages to 4.4.2 Co-Authored-By: Claude Opus 5.5 --- src/ConductorSharp.Client/ConductorSharp.Client.csproj | 2 +- src/ConductorSharp.Engine/ConductorSharp.Engine.csproj | 2 +- .../ConductorSharp.KafkaCancellationNotifier.csproj | 2 +- src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj | 2 +- src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ConductorSharp.Client/ConductorSharp.Client.csproj b/src/ConductorSharp.Client/ConductorSharp.Client.csproj index 1a506640..b88a9d53 100644 --- a/src/ConductorSharp.Client/ConductorSharp.Client.csproj +++ b/src/ConductorSharp.Client/ConductorSharp.Client.csproj @@ -6,7 +6,7 @@ Codaxy Codaxy ConductorSharp.Client - 4.4.1 + 4.4.2 Client library for Netflix Conductor, with some additional quality of life features. https://github.com/codaxy/conductor-sharp netflix;conductor diff --git a/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj b/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj index df478cee..97235c2e 100644 --- a/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj +++ b/src/ConductorSharp.Engine/ConductorSharp.Engine.csproj @@ -6,7 +6,7 @@ Codaxy Codaxy ConductorSharp.Engine - 4.4.1 + 4.4.2 Client library for Netflix Conductor, with some additional quality of life features. https://github.com/codaxy/conductor-sharp netflix;conductor diff --git a/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj b/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj index 989e052e..7595db51 100644 --- a/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj +++ b/src/ConductorSharp.KafkaCancellationNotifier/ConductorSharp.KafkaCancellationNotifier.csproj @@ -4,7 +4,7 @@ net6.0 enable enable - 4.4.1 + 4.4.2 Codaxy Codaxy diff --git a/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj b/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj index 70163e9e..c7b264b2 100644 --- a/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj +++ b/src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj @@ -7,7 +7,7 @@ False Codaxy Codaxy - 4.4.1 + 4.4.2 diff --git a/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj b/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj index 0b48430b..37157bae 100644 --- a/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj +++ b/src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj @@ -7,7 +7,7 @@ disable true dotnet-conductorsharp - 4.4.1 + 4.4.2 From c1492a0b91f4064e7b6fc6b1f86d83ff5e739e7c Mon Sep 17 00:00:00 2001 From: "milan.svitlica" Date: Mon, 19 Oct 2026 14:01:43 +0200 Subject: [PATCH 4/4] CxODEV-2161: mark the engine running on a successful queue read; let shutdown cancel a hung deploy Review findings on the supervisor loop: - Health and the backoff reset followed a successful deploy, not a successful poll. With Conductor's metadata up and its task queues down (e.g. the queue backend), the engine reported Healthy most of the time while polling nothing, and redeployed about once a second on a 4xx. TaskQueuePollingService now marks the engine running after a successful read; the background service no longer marks it after deploy and restarts the backoff only after a run that polled. Unhealthy is still set only by the background service. - Deploy takes no token, so a hung deploy blocked StopAsync. It now runs under WaitAsync(stoppingToken). Co-Authored-By: Claude Opus 5.5 --- .../Service/TaskQueuePollingService.cs | 15 ++- .../WorkflowEngineBackgroundService.cs | 14 ++- .../Integration/EngineRecoveryTests.cs | 27 +++++ .../Unit/TaskQueuePollingServiceTests.cs | 29 ++++- .../WorkflowEngineBackgroundServiceTests.cs | 100 +++++++++++++++--- 5 files changed, 164 insertions(+), 21 deletions(-) diff --git a/src/ConductorSharp.Engine/Service/TaskQueuePollingService.cs b/src/ConductorSharp.Engine/Service/TaskQueuePollingService.cs index f293217c..35877f8e 100644 --- a/src/ConductorSharp.Engine/Service/TaskQueuePollingService.cs +++ b/src/ConductorSharp.Engine/Service/TaskQueuePollingService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using ConductorSharp.Client.Generated; using ConductorSharp.Client.Service; +using ConductorSharp.Engine.Health; using Microsoft.Extensions.Logging; using Task = System.Threading.Tasks.Task; @@ -19,6 +20,7 @@ internal class TaskQueuePollingService private readonly ITaskService _taskService; private readonly ILogger _logger; + private readonly IConductorSharpHealthService _healthService; private readonly int _maxAttempts; private readonly TimeSpan _initialRetryDelay; private readonly TimeSpan _maxRetryDelay; @@ -26,10 +28,11 @@ internal class TaskQueuePollingService private readonly Func _delay; private readonly Func _jitter; - public TaskQueuePollingService(ITaskService taskService, ILogger logger) + public TaskQueuePollingService(ITaskService taskService, ILogger logger, IConductorSharpHealthService healthService) : this( taskService, logger, + healthService, DefaultMaxAttempts, DefaultInitialRetryDelay, DefaultMaxRetryDelay, @@ -41,6 +44,7 @@ public TaskQueuePollingService(ITaskService taskService, ILogger logger, + IConductorSharpHealthService healthService, int maxAttempts, TimeSpan initialRetryDelay, TimeSpan maxRetryDelay, @@ -51,6 +55,7 @@ Func jitter { _taskService = taskService; _logger = logger; + _healthService = healthService; _maxAttempts = maxAttempts; _initialRetryDelay = initialRetryDelay; _maxRetryDelay = maxRetryDelay; @@ -67,7 +72,13 @@ public async Task> ListQueuesAsync(CancellationToken c { try { - return await _taskService.ListQueuesAsync(cancellationToken); + var queues = await _taskService.ListQueuesAsync(cancellationToken); + + // The engine counts as running once it reads the queues; the background service clears this on failure. + if (!(await _healthService.GetHealthData(cancellationToken)).IsExecutionManagerRunning) + await _healthService.SetExecutionManagerRunning(cancellationToken); + + return queues; } catch (Exception exception) when (!cancellationToken.IsCancellationRequested && IsTransient(exception) && attempt < _maxAttempts) { diff --git a/src/ConductorSharp.Engine/Service/WorkflowEngineBackgroundService.cs b/src/ConductorSharp.Engine/Service/WorkflowEngineBackgroundService.cs index 996abf84..18dec395 100644 --- a/src/ConductorSharp.Engine/Service/WorkflowEngineBackgroundService.cs +++ b/src/ConductorSharp.Engine/Service/WorkflowEngineBackgroundService.cs @@ -89,6 +89,8 @@ public Task StartAsync(CancellationToken cancellationToken) /// /// Deploys the definitions and runs the execution manager until the service stops. Any failure, including a /// Conductor request timeout, marks the engine as not running and starts both again after a capped backoff. + /// The engine is marked running by its first successful queue read (); + /// only a run that got that far starts the backoff over. /// private async Task RunAsync(CancellationToken cancellationToken) { @@ -102,10 +104,8 @@ private async Task RunAsync(CancellationToken cancellationToken) { try { - await _deploymentService.Deploy(_deployment); - retryDelay = _initialRetryDelay; - attempt = 0; - await _healthService.SetExecutionManagerRunning(cancellationToken); + // Deploy takes no token; WaitAsync lets a shutdown leave a hung request behind. + await _deploymentService.Deploy(_deployment).WaitAsync(cancellationToken); await _executionManager.StartAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -114,6 +114,12 @@ private async Task RunAsync(CancellationToken cancellationToken) } catch (Exception exception) { + if ((await _healthService.GetHealthData(CancellationToken.None)).IsExecutionManagerRunning) + { + retryDelay = _initialRetryDelay; + attempt = 0; + } + attempt++; await _healthService.UnsetExecutionManagerRunning(CancellationToken.None); var delay = retryDelay + TimeSpan.FromMilliseconds(_jitter() * _maxJitter.TotalMilliseconds); diff --git a/test/ConductorSharp.Engine.Tests/Integration/EngineRecoveryTests.cs b/test/ConductorSharp.Engine.Tests/Integration/EngineRecoveryTests.cs index 049b416a..741eebc4 100644 --- a/test/ConductorSharp.Engine.Tests/Integration/EngineRecoveryTests.cs +++ b/test/ConductorSharp.Engine.Tests/Integration/EngineRecoveryTests.cs @@ -58,6 +58,7 @@ public async Task InitializeAsync() new TaskQueuePollingService( sp.GetRequiredService(), sp.GetRequiredService>(), + sp.GetRequiredService(), maxAttempts: 2, initialRetryDelay: TimeSpan.FromMilliseconds(10), maxRetryDelay: TimeSpan.FromMilliseconds(50), @@ -126,6 +127,28 @@ public async Task Hang_EngineResumesAndPicksUpTask() Assert.True((await _health.GetHealthData()).IsExecutionManagerRunning); } + [Fact] + public async Task QueuesFailWhileMetadataWorks_EngineStaysUnhealthy() + { + _conductor.Mode = FakeConductor.FailTasks; + await StartEngineAsync(); + await WaitUntil(() => Task.FromResult(_conductor.TaskDefRegistrations >= 3), "the engine has redeployed twice"); + + var samples = new List(); + for (var i = 0; i < 50; i++) + { + samples.Add((await _health.GetHealthData()).IsExecutionManagerRunning); + await Task.Delay(10); + } + + Assert.DoesNotContain(true, samples); + + _conductor.Mode = FakeConductor.Ok; + _conductor.Enqueue("after-queue-outage"); + await WaitUntil(() => IsCompleted("after-queue-outage"), "the task queued after the queue outage completes"); + Assert.True((await _health.GetHealthData()).IsExecutionManagerRunning); + } + [Fact] public async Task RegistrationRace_EngineStartsAfterRetry() { @@ -226,6 +249,7 @@ private sealed class FakeConductor : HttpMessageHandler public const string Ok = "ok"; public const string Fail503 = "fail503"; public const string Hang = "hang"; + public const string FailTasks = "failTasks"; // metadata works, every /api/tasks call answers 500 private readonly ConcurrentQueue _queued = new(); private int _queueReads; @@ -250,6 +274,9 @@ protected override async Task SendAsync(HttpRequestMessage var path = request.RequestUri!.AbsolutePath; + if (Mode == FailTasks && path.StartsWith("/api/tasks")) + return Json(HttpStatusCode.InternalServerError, new { status = 500, message = "queue backend unavailable" }); + if (request.Method == HttpMethod.Post && path == "/api/metadata/taskdefs") { Interlocked.Increment(ref _taskDefRegistrations); diff --git a/test/ConductorSharp.Engine.Tests/Unit/TaskQueuePollingServiceTests.cs b/test/ConductorSharp.Engine.Tests/Unit/TaskQueuePollingServiceTests.cs index 38db4d24..af363381 100644 --- a/test/ConductorSharp.Engine.Tests/Unit/TaskQueuePollingServiceTests.cs +++ b/test/ConductorSharp.Engine.Tests/Unit/TaskQueuePollingServiceTests.cs @@ -2,14 +2,40 @@ using System.Text; using ConductorSharp.Client.Generated; using ConductorSharp.Client.Service; +using ConductorSharp.Engine.Health; using ConductorSharp.Engine.Service; using Microsoft.Extensions.Logging.Abstractions; using Task = System.Threading.Tasks.Task; namespace ConductorSharp.Engine.Tests.Unit; +[Collection(nameof(InMemoryHealthService))] // InMemoryHealthService keeps its flag in a static field public class TaskQueuePollingServiceTests { + private readonly InMemoryHealthService _healthService = new(); + + public TaskQueuePollingServiceTests() => _healthService.ResetHealthData().Wait(); + + [Fact] + public async Task ListQueuesAsync_MarksEngineRunning_AfterSuccessfulRead() + { + var service = CreateService(new SequenceHandler(HttpStatusCode.InternalServerError, HttpStatusCode.OK), []); + + await service.ListQueuesAsync(CancellationToken.None); + + Assert.True((await _healthService.GetHealthData()).IsExecutionManagerRunning); + } + + [Fact] + public async Task ListQueuesAsync_DoesNotMarkEngineRunning_WhenReadsFail() + { + var service = CreateService(new SequenceHandler(HttpStatusCode.Unauthorized), []); + + await Assert.ThrowsAsync(() => service.ListQueuesAsync(CancellationToken.None)); + + Assert.False((await _healthService.GetHealthData()).IsExecutionManagerRunning); + } + [Fact] public async Task ListQueuesAsync_RetriesTransientFailuresAndReturnsQueues() { @@ -58,7 +84,7 @@ public async Task ListQueuesAsync_RethrowsAfterRetryLimit() Assert.Equal(4, delays.Count); } - private static TaskQueuePollingService CreateService(HttpMessageHandler handler, ICollection delays) + private TaskQueuePollingService CreateService(HttpMessageHandler handler, ICollection delays) { var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://conductor/") }; var taskService = new TaskService(httpClient); @@ -66,6 +92,7 @@ private static TaskQueuePollingService CreateService(HttpMessageHandler handler, return new TaskQueuePollingService( taskService, NullLogger.Instance, + _healthService, maxAttempts: 5, initialRetryDelay: TimeSpan.FromSeconds(1), maxRetryDelay: TimeSpan.FromSeconds(30), diff --git a/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs b/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs index 7455a4f2..4a3f52d0 100644 --- a/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs +++ b/test/ConductorSharp.Engine.Tests/Unit/WorkflowEngineBackgroundServiceTests.cs @@ -21,7 +21,7 @@ public class WorkflowEngineBackgroundServiceTests public async Task DeployApiError_IsRetriedAndEngineStarts() { var deployment = new ScriptedDeploymentService(Api(500), Api(409)); - var executionManager = new ScriptedExecutionManager(); + var executionManager = new ScriptedExecutionManager(_healthService); var service = CreateService(deployment, executionManager, RecordDelay); await service.StartAsync(CancellationToken.None); @@ -39,7 +39,7 @@ public async Task DeployApiError_IsRetriedAndEngineStarts() public async Task ConnectionError_WhileRunning_RestartsEngine() { var deployment = new ScriptedDeploymentService(); - var executionManager = new ScriptedExecutionManager(new HttpRequestException("Connection refused")); + var executionManager = new ScriptedExecutionManager(_healthService, Fails(new HttpRequestException("Connection refused"))); var service = CreateService(deployment, executionManager, RecordDelay); await service.StartAsync(CancellationToken.None); @@ -56,7 +56,7 @@ public async Task ConnectionError_WhileRunning_RestartsEngine() public async Task HttpTimeout_IsAnErrorNotAShutdown() { var deployment = new ScriptedDeploymentService(); - var executionManager = new ScriptedExecutionManager(new TaskCanceledException("timeout", new TimeoutException())); + var executionManager = new ScriptedExecutionManager(_healthService, Fails(new TaskCanceledException("timeout", new TimeoutException()))); var service = CreateService(deployment, executionManager, RecordDelay); await service.StartAsync(CancellationToken.None); @@ -73,7 +73,7 @@ public async Task HttpTimeout_IsAnErrorNotAShutdown() public async Task HealthIsNotRunning_WhileBackingOff() { var deployment = new ScriptedDeploymentService(); - var executionManager = new ScriptedExecutionManager(new HttpRequestException("Connection refused")); + var executionManager = new ScriptedExecutionManager(_healthService, PollsThenFails(new HttpRequestException("Connection refused"))); var backingOff = new TaskCompletionSource(); var resume = new TaskCompletionSource(); var service = CreateService( @@ -103,7 +103,7 @@ public async Task HealthIsNotRunning_WhileBackingOff() public async Task NonTransientDeployError_IsRetriedWithCappedBackoff() { var deployment = new ScriptedDeploymentService(Enumerable.Range(0, 8).Select(_ => Api(401)).ToArray()); - var executionManager = new ScriptedExecutionManager(); + var executionManager = new ScriptedExecutionManager(_healthService); var service = CreateService(deployment, executionManager, RecordDelay); await service.StartAsync(CancellationToken.None); @@ -115,10 +115,10 @@ public async Task NonTransientDeployError_IsRetriedWithCappedBackoff() } [Fact] - public async Task SuccessfulDeploy_ResetsBackoff() + public async Task SuccessfulPoll_ResetsBackoff() { var deployment = new ScriptedDeploymentService(Api(500), Api(500), Api(500)); - var executionManager = new ScriptedExecutionManager(new HttpRequestException("Connection refused")); + var executionManager = new ScriptedExecutionManager(_healthService, PollsThenFails(new HttpRequestException("Connection refused"))); var service = CreateService(deployment, executionManager, RecordDelay); await service.StartAsync(CancellationToken.None); @@ -129,11 +129,57 @@ public async Task SuccessfulDeploy_ResetsBackoff() await service.StopAsync(CancellationToken.None); } + [Fact] + public async Task SuccessfulDeployWithoutPoll_KeepsBackingOff() + { + // Metadata works but the task queues fail (e.g. Conductor's queue backend is down). + var deployment = new ScriptedDeploymentService(); + var executionManager = new ScriptedExecutionManager(_healthService, Fails(Api(500)), Fails(Api(500)), Fails(Api(500))); + var service = CreateService(deployment, executionManager, RecordDelay); + + await service.StartAsync(CancellationToken.None); + await executionManager.Running.Task.WaitAsync(WaitTimeout); + + Assert.Equal(new[] { 1, 2, 4 }.Select(s => TimeSpan.FromSeconds(s)), _delays); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task SuccessfulDeploy_DoesNotMarkEngineRunning() + { + var deployment = new ScriptedDeploymentService(); + var executionManager = new ScriptedExecutionManager(_healthService) { PollsWhenRunning = false }; + var service = CreateService(deployment, executionManager, RecordDelay); + + await service.StartAsync(CancellationToken.None); + await executionManager.Running.Task.WaitAsync(WaitTimeout); + + Assert.False((await _healthService.GetHealthData()).IsExecutionManagerRunning); + + await service.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task Stop_WhileDeployHangs_ReturnsPromptly() + { + var deployment = new ScriptedDeploymentService { Hangs = true }; + var executionManager = new ScriptedExecutionManager(_healthService); + var service = CreateService(deployment, executionManager, RecordDelay); + + await service.StartAsync(CancellationToken.None); + await deployment.Called.Task.WaitAsync(WaitTimeout); + await service.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(1)); + + Assert.Equal(0, executionManager.Calls); + Assert.Contains(_logger.Entries, e => e.Message == "Stopping ConductorSharp background service"); + } + [Fact] public async Task Stop_DuringBackoff_ExitsWithoutRetry() { var deployment = new ScriptedDeploymentService(Api(500)); - var executionManager = new ScriptedExecutionManager(); + var executionManager = new ScriptedExecutionManager(_healthService); var backingOff = new TaskCompletionSource(); var service = CreateService( deployment, @@ -158,7 +204,7 @@ public async Task Stop_DuringBackoff_ExitsWithoutRetry() public async Task Stop_WhileRunning_DoesNotRestart() { var deployment = new ScriptedDeploymentService(); - var executionManager = new ScriptedExecutionManager(); + var executionManager = new ScriptedExecutionManager(_healthService); var service = CreateService(deployment, executionManager, RecordDelay); await service.StartAsync(CancellationToken.None); @@ -197,36 +243,62 @@ Func delay private static ApiException Api(int statusCode) => new($"HTTP {statusCode}", statusCode, "{}", new Dictionary>(), null); + private static Run Fails(Exception failure) => new(failure, PollsFirst: false); + + private static Run PollsThenFails(Exception failure) => new(failure, PollsFirst: true); + + private sealed record Run(Exception Failure, bool PollsFirst); + private sealed class ScriptedDeploymentService(params Exception[] failures) : IDeploymentService { private readonly Queue _failures = new(failures); public int Calls { get; private set; } + /// When set, Deploy never completes (a hung Conductor request). + public bool Hangs { get; init; } + + public TaskCompletionSource Called { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task Deploy(Deployment deployment) { Calls++; + Called.TrySetResult(); + if (Hangs) + return new TaskCompletionSource().Task; return _failures.TryDequeue(out var failure) ? Task.FromException(failure) : Task.CompletedTask; } public Task Remove(Deployment deployment) => Task.CompletedTask; } - /// Throws the queued failures, one per call; once they run out, polls (blocks) until cancelled. - private sealed class ScriptedExecutionManager(params Exception[] failures) : IExecutionManager + /// + /// Plays the queued runs, one per call. Once they run out, it reads the queues successfully (marks the engine + /// running, as does) and blocks until cancelled. + /// + private sealed class ScriptedExecutionManager(IConductorSharpHealthService healthService, params Run[] runs) : IExecutionManager { - private readonly Queue _failures = new(failures); + private readonly Queue _runs = new(runs); public int Calls { get; private set; } + /// When cleared, the final run blocks without a successful queue read. + public bool PollsWhenRunning { get; init; } = true; + public TaskCompletionSource Running { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public async Task StartAsync(CancellationToken cancellationToken) { Calls++; - if (_failures.TryDequeue(out var failure)) - throw failure; + if (_runs.TryDequeue(out var run)) + { + if (run.PollsFirst) + await healthService.SetExecutionManagerRunning(cancellationToken); + throw run.Failure; + } + if (PollsWhenRunning) + await healthService.SetExecutionManagerRunning(cancellationToken); Running.TrySetResult(); await Task.Delay(Timeout.Infinite, cancellationToken); }