Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,11 @@ private async Task<long> WaitForCommittedBindAsync(
string actorId,
CancellationToken ct)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(_options.BindCommitTimeout);
using var bindTimeoutCts = new CancellationTokenSource(_options.BindCommitTimeout);
using var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct, bindTimeoutCts.Token);
try
{
await foreach (var observed in sink.ReadAllAsync(timeoutCts.Token))
await foreach (var observed in sink.ReadAllAsync(readCts.Token))
{
if (TryObserveCommittedBind(
observed,
Expand All @@ -296,7 +296,7 @@ private async Task<long> WaitForCommittedBindAsync(
}
}
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested && timeoutCts.IsCancellationRequested)
catch (OperationCanceledException ex) when (bindTimeoutCts.IsCancellationRequested)
{
throw BindNotCommitted(definition, actorId, ex);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ internal sealed class WorkflowDefinitionBootstrapHostedService : IHostedService
private readonly FileBackedWorkflowCatalogPort _definitionMaterializer;
private readonly IOptions<WorkflowDefinitionFileSourceOptions> _options;
private readonly ILogger<WorkflowDefinitionBootstrapHostedService> _logger;
private CancellationTokenSource? _retryCts;
private Task? _retryTask;

public WorkflowDefinitionBootstrapHostedService(
IWorkflowDefinitionCatalog registry,
Expand Down Expand Up @@ -40,8 +42,127 @@ public async Task StartAsync(CancellationToken cancellationToken)
.Where(definition => definition != null)
.Select(definition => definition!)
.ToList();
await _definitionMaterializer.MaterializeAsync(definitions, cancellationToken);
try
{
await _definitionMaterializer.MaterializeAsync(definitions, cancellationToken);
}
catch (WorkflowDefinitionMaterializationException ex)
when (ex.Code == WorkflowDefinitionMaterializationException.BindNotCommittedCode &&
!cancellationToken.IsCancellationRequested)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recover the bind timeout when the startup token races it

When the host startup token and the private bind timeout are both requested, WaitForCommittedBindAsync now converts the cancellation into BindNotCommitted, but this filter immediately rejects that exception because the startup token is canceled. The exception therefore still escapes StartAsync and aborts host startup in the exact race this change is intended to recover; the added test uses CancellationToken.None and does not exercise this path.

Useful? React with 馃憤聽/ 馃憥.

{
_logger.LogWarning(
ex,
"Startup workflow definition bind was not observed before timeout; host startup will continue and retry materialization in the background. workflow_name={WorkflowName} actor_id={ActorId} expected_execution_mode={ExpectedExecutionMode}",
ex.WorkflowName,
ex.ActorId,
ex.ExpectedExecutionMode);
StartBackgroundRetry(definitions, ex.WorkflowName);
}
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_retryCts == null || _retryTask == null)
return;

await _retryCts.CancelAsync();
try
{
await _retryTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
}
finally
{
_retryCts.Dispose();
_retryCts = null;
_retryTask = null;
}
}

private void StartBackgroundRetry(
IReadOnlyList<WorkflowDefinitionRegistration> definitions,
string timedOutWorkflowName)
{
var retryDelay = _options.Value.BindCommitRetryDelay;
if (retryDelay <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(WorkflowDefinitionFileSourceOptions.BindCommitRetryDelay),
retryDelay,
"Workflow definition bind commit retry delay must be positive.");
}

var remainingDefinitions = GetDefinitionsAfter(definitions, timedOutWorkflowName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the timed-out workflow in the retry set

When an accepted dispatch never commits, GetDefinitionsAfter removes the failed workflow from the background retry set. For a single or last configured workflow this returns an empty list and starts no retry task; the same removal happens on each later timeout, so the host can run indefinitely without materializing that definition. Dispatch admission is explicitly accepted-only, so the timed-out definition must remain pending, with a committed-binding check before redispatch, rather than being treated as complete.

AGENTS.md reference: AGENTS.md:L57-L57

Useful? React with 馃憤聽/ 馃憥.

if (remainingDefinitions.Count == 0)
{
_logger.LogInformation(
"Startup workflow definition materialization has no remaining definitions after timed-out bind observation. workflow_name={WorkflowName}",
timedOutWorkflowName);
return;
}

_retryCts = new CancellationTokenSource();
_retryTask = RetryMaterializationAsync(remainingDefinitions, retryDelay, _retryCts.Token);
}

private async Task RetryMaterializationAsync(
IReadOnlyList<WorkflowDefinitionRegistration> definitions,
TimeSpan retryDelay,
CancellationToken ct)
{
var remainingDefinitions = definitions;
while (!ct.IsCancellationRequested && remainingDefinitions.Count > 0)
{
try
{
await Task.Delay(retryDelay, ct);
await _definitionMaterializer.MaterializeAsync(remainingDefinitions, ct);
_logger.LogInformation("Startup workflow definition materialization completed in background retry.");
return;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (WorkflowDefinitionMaterializationException ex)
when (ex.Code == WorkflowDefinitionMaterializationException.BindNotCommittedCode)
{
_logger.LogWarning(
ex,
"Startup workflow definition bind still has not been observed; materialization will continue with remaining definitions. workflow_name={WorkflowName} actor_id={ActorId} expected_execution_mode={ExpectedExecutionMode}",
ex.WorkflowName,
ex.ActorId,
ex.ExpectedExecutionMode);
remainingDefinitions = GetDefinitionsAfter(remainingDefinitions, ex.WorkflowName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Startup workflow definition materialization retry failed; materialization will retry.");
}
}
}

private static IReadOnlyList<WorkflowDefinitionRegistration> GetDefinitionsAfter(
IReadOnlyList<WorkflowDefinitionRegistration> definitions,
string workflowName)
{
var orderedDefinitions = definitions
.OrderBy(definition => definition.WorkflowName, StringComparer.OrdinalIgnoreCase)
.ToArray();
var failedIndex = Array.FindIndex(
orderedDefinitions,
definition => string.Equals(
definition.WorkflowName,
workflowName,
StringComparison.OrdinalIgnoreCase));
return failedIndex < 0 || failedIndex + 1 >= orderedDefinitions.Length
? []
: orderedDefinitions[(failedIndex + 1)..];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ public sealed class WorkflowDefinitionFileSourceOptions
WorkflowDefinitionDuplicatePolicy.Throw;

public TimeSpan BindCommitTimeout { get; set; } = TimeSpan.FromSeconds(30);

public TimeSpan BindCommitRetryDelay { get; set; } = TimeSpan.FromSeconds(30);
}
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,56 @@ public async Task Bootstrap_ShouldLoadConfiguredDirectories_AndHonorCancellation
}
}

[Fact]
public async Task Bootstrap_WhenStartupBindIsNotObserved_ShouldContinueHostStartup()
{
var tempDir = Path.Combine(Path.GetTempPath(), "wf-bootstrap-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "studio.yaml"), "name: studio");
var registry = new WorkflowDefinitionCatalog();
var options = new WorkflowDefinitionFileSourceOptions
{
DuplicatePolicy = WorkflowDefinitionDuplicatePolicy.Override,
BindCommitTimeout = TimeSpan.FromMilliseconds(1),
BindCommitRetryDelay = TimeSpan.FromMilliseconds(1),
};
options.WorkflowDirectories.Add(tempDir);
var materializerOptions = new WorkflowDefinitionFileSourceOptions
{
BindCommitTimeout = TimeSpan.FromMilliseconds(1),
};
var observations = new RecordingWorkflowDefinitionBindObservationRuntime();
var dispatch = new RecordingActorDispatchPort(observations)
{
PublishCommittedBind = false,
};
var service = new WorkflowDefinitionBootstrapHostedService(
registry,
new WorkflowDefinitionFileLoader(),
new FileBackedWorkflowCatalogPort(
new RecordingActorRuntime(),
dispatch,
observations,
observations,
new RecordingWorkflowCapabilityAdmissionService(),
Options.Create(materializerOptions),
NullLogger<FileBackedWorkflowCatalogPort>.Instance),
Options.Create(options),
NullLogger<WorkflowDefinitionBootstrapHostedService>.Instance);

await service.StartAsync(CancellationToken.None);

dispatch.Envelopes.Should().ContainSingle();
await service.StopAsync(CancellationToken.None);
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}

private static ServiceProvider CreateLocalRuntimeProvider(TimeSpan bindCommitTimeout)
{
var services = new ServiceCollection();
Expand Down Expand Up @@ -653,13 +703,16 @@ private sealed class RecordingActorDispatchPort(
{
public List<(string ActorId, EventEnvelope Envelope)> Envelopes { get; } = [];

public bool PublishCommittedBind { get; init; } = true;

public async Task<DispatchAdmission> DispatchAsync(
string actorId,
EventEnvelope envelope,
CancellationToken ct = default)
{
Envelopes.Add((actorId, envelope));
await observations.PublishCommittedBindAsync(actorId, envelope, ct);
if (PublishCommittedBind)
await observations.PublishCommittedBindAsync(actorId, envelope, ct);
return DispatchAdmissionFactory.Create(actorId, envelope);
}
}
Expand Down