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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ConductorSharp.Client/ConductorSharp.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Authors>Codaxy</Authors>
<Company>Codaxy</Company>
<PackageId>ConductorSharp.Client</PackageId>
<Version>4.3.0</Version>
<Version>4.4.0</Version>
<Description>Client library for Netflix Conductor, with some additional quality of life features.</Description>
<RepositoryUrl>https://github.com/codaxy/conductor-sharp</RepositoryUrl>
<PackageTags>netflix;conductor</PackageTags>
Expand Down
2 changes: 1 addition & 1 deletion src/ConductorSharp.Engine/ConductorSharp.Engine.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Authors>Codaxy</Authors>
<Company>Codaxy</Company>
<PackageId>ConductorSharp.Engine</PackageId>
<Version>4.3.0</Version>
<Version>4.4.0</Version>
<Description>Client library for Netflix Conductor, with some additional quality of life features.</Description>
<RepositoryUrl>https://github.com/codaxy/conductor-sharp</RepositoryUrl>
<PackageTags>netflix;conductor</PackageTags>
Expand Down
2 changes: 2 additions & 0 deletions src/ConductorSharp.Engine/Extensions/ConductorSharpBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ params Assembly[] handlerAssemblies

Builder.AddScoped<ConductorSharpExecutionContext>();

Builder.AddTransient<FailedTaskStructuredErrorReader>();

Builder.AddSingleton<IConductorSharpHealthService, InMemoryHealthService>();

Builder.AddTransient<IPollTimingStrategy, InverseExponentialBackoff>();
Expand Down
6 changes: 6 additions & 0 deletions src/ConductorSharp.Engine/Model/StructuredError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ public class StructuredError
/// <summary>Current structured-error payload shape version.</summary>
public const int CurrentVersion = 1;

/// <summary>
/// Reserved classification code for failures that declared no structured error. Producers must not
/// use it for declared errors; consumers may treat it as "no classification available".
/// </summary>
public const string UnclassifiedCode = "UNCLASSIFIED";

/// <summary>Stable, opaque classification code (e.g. an implementation-defined code, or <c>UNCLASSIFIED</c>).</summary>
public string Code { get; set; }

Expand Down
136 changes: 136 additions & 0 deletions src/ConductorSharp.Engine/Util/FailedTaskStructuredErrorReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ConductorSharp.Client.Service;
using ConductorSharp.Engine.Model;
using TaskStatus = ConductorSharp.Client.Generated.TaskStatus;

namespace ConductorSharp.Engine.Util
{
/// <summary>
/// Walks a failed workflow execution through sub-workflows to the deepest failed task and reads the
/// structured error it declared via the shared <c>structured_error</c> task-output contract
/// (<see cref="StructuredErrorSerializer"/>).
/// <para>
/// This is the single reader for the contract: consumers that harvest a failure classification out of an
/// execution (failure workflows, notification builders, drill-downs) should use it instead of re-implementing
/// the descent, so they cannot drift apart on which task "the" failure is.
/// </para>
/// </summary>
public class FailedTaskStructuredErrorReader
{
private readonly IWorkflowService _workflowService;

public FailedTaskStructuredErrorReader(IWorkflowService workflowService)
{
_workflowService = workflowService;
}

/// <summary>
/// Reads the structured error declared by the deepest failed task of <paramref name="workflowId"/>,
/// following the Try pattern: returns <c>false</c> (with a <c>null</c> <paramref name="error"/>) when
/// the execution has no failed task or the failed task declared nothing. Synchronous — an <c>out</c>
/// parameter rules out <c>async</c> — so the underlying Conductor call blocks the calling thread.
/// </summary>
public bool TryRead(string workflowId, out StructuredError error, CancellationToken cancellationToken)
{
var failed = FindDeepestFailedTaskAsync(workflowId, cancellationToken).GetAwaiter().GetResult();

if (failed?.OutputData != null && StructuredErrorSerializer.TryDeserialize(failed.OutputData, out var structured))
{
error = structured;
return true;
}

error = null;
return false;
}

/// <summary>
/// Like <see cref="TryRead"/>, but always yields an error: a failure that declared nothing is
/// classified as <see cref="StructuredError.UnclassifiedCode"/> with <paramref name="genericReason"/> as the
/// sanitized reason, so raw internals never cross a boundary by default. The returned
/// <see cref="StructuredError.Message"/> always carries the most specific diagnostic available: the declared
/// message when there is one, otherwise an internal locator (workflow id, task id, reference name, raw
/// reason) suitable for drill-down.
/// </summary>
/// <param name="workflowId">Execution to walk.</param>
/// <param name="genericReason">
/// Sanitized reason used when the failure declared no reason of its own. Callers own this text — it is
/// what crosses their boundary.
/// </param>
/// <param name="fallbackReason">
/// Optional raw failure reason already known to the caller (e.g. the failure workflow's input); used only
/// inside the diagnostic message, never as the sanitized reason.
/// </param>
public async Task<StructuredError> ReadOrFallbackAsync(
string workflowId,
string genericReason,
string fallbackReason,
CancellationToken cancellationToken
)
{
var failed = await FindDeepestFailedTaskAsync(workflowId, cancellationToken);

if (failed?.OutputData != null && StructuredErrorSerializer.TryDeserialize(failed.OutputData, out var structured))
{
return new StructuredError
{
Code = structured.Code,
Reason = string.IsNullOrEmpty(structured.Reason) ? genericReason : structured.Reason,
Message = string.IsNullOrEmpty(structured.Message) ? BuildDiagnosticMessage(failed, fallbackReason) : structured.Message,
ReferenceError = structured.ReferenceError,
Version = structured.Version
};
}

return new StructuredError
{
Code = StructuredError.UnclassifiedCode,
Reason = genericReason,
Message = BuildDiagnosticMessage(failed, fallbackReason)
};
}

/// <summary>
/// Walks the execution to the task that actually failed. A phase often runs as a SUB_WORKFLOW inside a
/// dynamic fork, and the aggregating JOIN task is frequently the <i>last</i> failed task in the parent —
/// so any failed sub-workflow is descended into first (walking from the last), and FORK/JOIN aggregators
/// are skipped when picking a leaf. Returns <c>null</c> when the execution has no failed task.
/// </summary>
public async Task<ConductorSharp.Client.Generated.Task> FindDeepestFailedTaskAsync(string workflowId, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(workflowId))
return null;

var workflow = await _workflowService.GetExecutionStatusAsync(workflowId, true, cancellationToken);

var failedTasks = (workflow.Tasks ?? []).Where(t => t.Status is TaskStatus.FAILED or TaskStatus.FAILED_WITH_TERMINAL_ERROR).ToList();

if (failedTasks.Count == 0)
return null;

for (var i = failedTasks.Count - 1; i >= 0; i--)
{
var subWorkflowId = failedTasks[i].SubWorkflowId;
if (!string.IsNullOrEmpty(subWorkflowId))
{
var deeper = await FindDeepestFailedTaskAsync(subWorkflowId, cancellationToken);
if (deeper != null)
return deeper;
}
}

return failedTasks.LastOrDefault(t => t.TaskType is not ("JOIN" or "FORK")) ?? failedTasks[^1];
}

private static string BuildDiagnosticMessage(ConductorSharp.Client.Generated.Task task, string fallbackReason)
{
if (task == null)
return fallbackReason ?? "No failed task found.";

var raw = task.ReasonForIncompletion ?? fallbackReason;
return $"workflowId={task.WorkflowInstanceId}; taskId={task.TaskId}; ref={task.ReferenceTaskName}; reason={raw}";
}
}
}
31 changes: 13 additions & 18 deletions src/ConductorSharp.Engine/Util/StructuredErrorSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@
namespace ConductorSharp.Engine.Util
{
/// <summary>
/// Defines the <c>structured_error</c> task-output contract (key + shape) and the read side used by all consumers
/// (<see cref="TryParse"/>). There are two producers, both emitting the same shape because they serialize the same
/// <see cref="StructuredError"/> type with the same serializer settings:
/// <list type="bullet">
/// <item>the execution-manager catch block, which serializes <see cref="ErrorOutput.StructuredError"/> when a
/// worker throws a <see cref="ConductorSharp.Engine.Exceptions.StructuredErrorException"/>; and</item>
/// <item>external signal senders (which have no exception to catch), which render via <see cref="ToOutputData"/>.</item>
/// </list>
/// Defines the <c>structured_error</c> task-output contract (key + shape): the write side
/// (<see cref="Serialize"/>) and the tolerant read side used by all consumers (<see cref="TryDeserialize"/>).
/// The execution-manager catch block emits the same shape without this class, by serializing
/// <see cref="ErrorOutput.StructuredError"/> with the same serializer settings when a worker throws a
/// <see cref="ConductorSharp.Engine.Exceptions.StructuredErrorException"/>. Internal on purpose: consumers
/// read the contract through <see cref="FailedTaskStructuredErrorReader"/>, and producers outside this
/// assembly declare errors by throwing, not by rendering the payload themselves.
/// </summary>
public static class StructuredErrorSerializer
internal static class StructuredErrorSerializer
{
/// <summary>Well-known task-output key carrying the structured error payload.</summary>
public const string OutputKey = "structured_error";
Expand All @@ -24,19 +23,15 @@ public static class StructuredErrorSerializer
/// Renders a <see cref="StructuredError"/> to an output-data fragment (<c>{ "structured_error": { ... } }</c>)
/// using the standard snake_case IO serializer settings. Returns an empty dictionary for a null error.
/// </summary>
public static IDictionary<string, object> ToOutputData(StructuredError error)
public static IDictionary<string, object> Serialize(StructuredError error)
{
if (error == null)
return new Dictionary<string, object>();

return new Dictionary<string, object> { [OutputKey] = ToOutputValue(error) };
}

/// <summary>Renders just the value placed under <see cref="OutputKey"/>, in the canonical snake_case shape.</summary>
public static object ToOutputValue(StructuredError error)
{
var json = JsonConvert.SerializeObject(error, ConductorConstants.IoJsonSerializerSettings);
return JsonConvert.DeserializeObject<IDictionary<string, object>>(json, ConductorConstants.IoJsonSerializerSettings);
var value = JsonConvert.DeserializeObject<IDictionary<string, object>>(json, ConductorConstants.IoJsonSerializerSettings);

return new Dictionary<string, object> { [OutputKey] = value };
}

/// <summary>
Expand All @@ -45,7 +40,7 @@ public static object ToOutputValue(StructuredError error)
/// payload returns <c>false</c> and never throws, so a parse problem degrades error quality (falling back to
/// the generic path) rather than failing the failure workflow.
/// </summary>
public static bool TryParse(IDictionary<string, object> taskOutput, out StructuredError error)
public static bool TryDeserialize(IDictionary<string, object> taskOutput, out StructuredError error)
{
error = null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>4.3.0</Version>
<Version>4.4.0</Version>
<Authors>Codaxy</Authors>
<Company>Codaxy</Company>
</PropertyGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/ConductorSharp.Patterns/ConductorSharp.Patterns.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<GeneratePackageOnBuild>False</GeneratePackageOnBuild>
<Authors>Codaxy</Authors>
<Company>Codaxy</Company>
<Version>4.3.0</Version>
<Version>4.4.0</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static IExecutionManagerBuilder AddConductorSharpPatterns(this IExecution
{
executionManagerBuilder.Builder.RegisterWorkerTask<ReadWorkflowTasks>();
executionManagerBuilder.Builder.RegisterWorkerTask<WaitSeconds>();
executionManagerBuilder.Builder.RegisterWorkerTask<BuildFailureError>();
executionManagerBuilder.Builder.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(typeof(WaitSeconds).Assembly));

return executionManagerBuilder;
Expand Down
73 changes: 73 additions & 0 deletions src/ConductorSharp.Patterns/Tasks/BuildFailureError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ConductorSharp.Engine;
using ConductorSharp.Engine.Builders.Metadata;
using ConductorSharp.Engine.Model;
using ConductorSharp.Engine.Util;
using MediatR;

namespace ConductorSharp.Patterns.Tasks
{
#region models
public class BuildFailureErrorRequest : IRequest<BuildFailureErrorResponse>
{
/// <summary>
/// Id of the failed workflow execution to classify (typically the failure workflow's <c>workflowId</c> input).
/// </summary>
public string? WorkflowId { get; set; }

/// <summary>
/// Sanitized reason used when the failure declared no reason of its own. This is the text that crosses the
/// caller's boundary — supply domain wording here; defaults to a neutral generic.
/// </summary>
public string? GenericReason { get; set; }

/// <summary>
/// Optional raw failure reason already known to the caller (e.g. the failure workflow's <c>reason</c> input);
/// used only inside the diagnostic message, never as the sanitized reason.
/// </summary>
public string? FallbackReason { get; set; }
}

public record BuildFailureErrorResponse(StructuredError Error);

#endregion

/// <summary>
/// Walks the given failed execution to its deepest failed task (via
/// <see cref="FailedTaskStructuredErrorReader"/>) and returns the structured error it declared — or the
/// <see cref="StructuredError.UnclassifiedCode"/> fallback with the sanitized <c>GenericReason</c> when it
/// declared nothing. Intended for failure workflows that persist a failure classification atomically with the
/// failed state.
/// <para>
/// Registered by <c>AddConductorSharpPatterns</c> under the shared task name. The task is a stateless,
/// read-only Conductor API lookup, so on a shared cluster it is safe for multiple services to register
/// and poll the same queue — whichever picks the task up produces the same result. Use Conductor task
/// domains if poller isolation is ever required.
/// </para>
/// </summary>
[OriginalName(Constants.TaskNamePrefix + "_build_failure_error")]
public class BuildFailureError(FailedTaskStructuredErrorReader errorReader)
: TaskRequestHandler<BuildFailureErrorRequest, BuildFailureErrorResponse>
{
public const string DefaultGenericReason = "The workflow failed.";

private readonly FailedTaskStructuredErrorReader _errorReader = errorReader;

public override async Task<BuildFailureErrorResponse> Handle(BuildFailureErrorRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.WorkflowId))
throw new Exception("No workflowId provided");

var error = await _errorReader.ReadOrFallbackAsync(
request.WorkflowId,
string.IsNullOrEmpty(request.GenericReason) ? DefaultGenericReason : request.GenericReason,
request.FallbackReason,
cancellationToken
);

return new BuildFailureErrorResponse(error);
}
}
}
2 changes: 1 addition & 1 deletion src/ConductorSharp.Toolkit/ConductorSharp.Toolkit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<Nullable>disable</Nullable>
<PackAsTool>true</PackAsTool>
<ToolCommandName>dotnet-conductorsharp</ToolCommandName>
<Version>4.3.0</Version>
<Version>4.4.0</Version>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading
Loading