diff --git a/all.sln b/all.sln index 326be2274..c5ca7a6ea 100644 --- a/all.sln +++ b/all.sln @@ -243,6 +243,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Workfl EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Workflow.Versioning.Runtime.Test", "test\Dapr.Workflow.Versioning.Runtime.Test\Dapr.Workflow.Versioning.Runtime.Test.csproj", "{4FF7F075-2818-41E4-A88F-743417EA0A99}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkflowVersioning", "examples\Workflow\WorkflowVersioning\WorkflowVersioning.csproj", "{837E02A5-D1C0-4F60-AF93-71117BF3B6DC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -645,6 +647,10 @@ Global {4FF7F075-2818-41E4-A88F-743417EA0A99}.Debug|Any CPU.Build.0 = Debug|Any CPU {4FF7F075-2818-41E4-A88F-743417EA0A99}.Release|Any CPU.ActiveCfg = Release|Any CPU {4FF7F075-2818-41E4-A88F-743417EA0A99}.Release|Any CPU.Build.0 = Release|Any CPU + {837E02A5-D1C0-4F60-AF93-71117BF3B6DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {837E02A5-D1C0-4F60-AF93-71117BF3B6DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {837E02A5-D1C0-4F60-AF93-71117BF3B6DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {837E02A5-D1C0-4F60-AF93-71117BF3B6DC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -763,6 +769,7 @@ Global {CB619F1E-B90C-4BCB-9DDA-A5A4F5967661} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {1AD32297-630E-4DFB-B3E4-CAFCE993F27F} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} {4FF7F075-2818-41E4-A88F-743417EA0A99} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {837E02A5-D1C0-4F60-AF93-71117BF3B6DC} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/examples/Workflow/WorkflowVersioning/Program.cs b/examples/Workflow/WorkflowVersioning/Program.cs new file mode 100644 index 000000000..2e0a2bb8f --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/Program.cs @@ -0,0 +1,48 @@ +using Dapr.Workflow; +using Dapr.Workflow.Versioning; +using WorkflowVersioning.Services; +using WorkflowVersioning.Workflows.VacationApproval.Activities; +using WorkflowVersioning.Workflows.VacationApproval.Models; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); + +builder.Services.AddDaprWorkflowVersioning(); + +// By default, it registers a numerical versioning strategy - let's demonstrate overriding this with a date-based approach +// and non-standard options +const string optionsName = "workflow-defaults"; +builder.Services.UseDefaultWorkflowStrategy(optionsName); +builder.Services.ConfigureStrategyOptions(optionsName, o => +{ + o.SuffixPrefix = "V"; +}); + +builder.Services.AddDaprWorkflow(w => +{ + w.RegisterActivity(); +}); + +var app = builder.Build(); + +app.MapGet("/start/{workflowId}", + async (DaprWorkflowClient workflowClient, [AsParameters] VacationRequest request, string workflowId) => + { + await workflowClient.ScheduleNewWorkflowAsync("VacationApprovalWorkflow", workflowId, request); + var a = 0; + a++; + + }); + +app.MapGet("/approve/{workflowId}", async (DaprWorkflowClient workflowClient, string workflowId) => +{ + await workflowClient.RaiseEventAsync(workflowId, "Approval", true); +}); + +app.MapGet("/reject/{workflowId}", async (DaprWorkflowClient workflowClient, string workflowId) => +{ + await workflowClient.RaiseEventAsync(workflowId, "Approval", false); +}); + +await app.RunAsync(); diff --git a/examples/Workflow/WorkflowVersioning/Properties/launchSettings.json b/examples/Workflow/WorkflowVersioning/Properties/launchSettings.json new file mode 100644 index 000000000..d6ae4f3c0 --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5228", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7035;http://localhost:5228", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/Workflow/WorkflowVersioning/Services/EmailService.cs b/examples/Workflow/WorkflowVersioning/Services/EmailService.cs new file mode 100644 index 000000000..75bd9527f --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/Services/EmailService.cs @@ -0,0 +1,10 @@ +namespace WorkflowVersioning.Services; + +public sealed class EmailService : IEmailService +{ + public Task SendEmailAsync(string to, string body) + { + // No-op + return Task.CompletedTask; + } +} diff --git a/examples/Workflow/WorkflowVersioning/Services/IEmailService.cs b/examples/Workflow/WorkflowVersioning/Services/IEmailService.cs new file mode 100644 index 000000000..ba57cc7c0 --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/Services/IEmailService.cs @@ -0,0 +1,6 @@ +namespace WorkflowVersioning.Services; + +public interface IEmailService +{ + Task SendEmailAsync(string to, string body); +} diff --git a/examples/Workflow/WorkflowVersioning/Versioning/NumericalStrategy.cs b/examples/Workflow/WorkflowVersioning/Versioning/NumericalStrategy.cs new file mode 100644 index 000000000..48cf0296c --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/Versioning/NumericalStrategy.cs @@ -0,0 +1,42 @@ +using System.Globalization; +using Dapr.Workflow.Versioning; + +namespace WorkflowVersioning.Versioning; + +public sealed class NumericalStrategy : IWorkflowVersionStrategy +{ + public bool TryParse(string typeName, out string canonicalName, out string version) + { + canonicalName = string.Empty; + version = string.Empty; + + if (string.IsNullOrWhiteSpace(typeName)) + return false; + + // Extract trailing digits as the version + int i = typeName.Length - 1; + while (i >= 0 && char.IsDigit(typeName[i])) + i--; + + if (i < typeName.Length - 1) + { + canonicalName = typeName[..(i + 1)]; + version = typeName[(i + 1)..]; + } + else + { + canonicalName = typeName; + version = "0"; + } + + return true; + } + + public int Compare(string? v1, string? v2) + { + var left = int.Parse(v1 ?? "0", CultureInfo.InvariantCulture); + var right = int.Parse(v2 ?? "0", CultureInfo.InvariantCulture); + + return left.CompareTo(right); + } +} diff --git a/examples/Workflow/WorkflowVersioning/WorkflowVersioning.csproj b/examples/Workflow/WorkflowVersioning/WorkflowVersioning.csproj new file mode 100644 index 000000000..97b761cbc --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/WorkflowVersioning.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + true + $(BaseIntermediateOutputPath)Generated + + + + + + + + + diff --git a/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/Activities/SendEmailActivity.cs b/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/Activities/SendEmailActivity.cs new file mode 100644 index 000000000..49575923c --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/Activities/SendEmailActivity.cs @@ -0,0 +1,15 @@ +using Dapr.Workflow; +using WorkflowVersioning.Services; + +namespace WorkflowVersioning.Workflows.VacationApproval.Activities; + +public sealed class SendEmailActivity(IEmailService emailSvc) : WorkflowActivity +{ + public override async Task RunAsync(WorkflowActivityContext context, EmailActivityInput input) + { + await emailSvc.SendEmailAsync(input.To, input.Message); + return null; + } +} + +public sealed record EmailActivityInput(string To, string Message); diff --git a/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/Models/VacationRequest.cs b/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/Models/VacationRequest.cs new file mode 100644 index 000000000..2bbab6ce4 --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/Models/VacationRequest.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc; + +namespace WorkflowVersioning.Workflows.VacationApproval.Models; + +public sealed record VacationRequest( + [FromQuery(Name = "name")] string EmployeeName, + [FromQuery(Name = "start")] DateOnly StartDate, + [FromQuery(Name = "end")] DateOnly EndDate); diff --git a/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/VacationApprovalWorkflow.cs b/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/VacationApprovalWorkflow.cs new file mode 100644 index 000000000..21d1d0329 --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/VacationApprovalWorkflow.cs @@ -0,0 +1,45 @@ +using Dapr.Workflow; +using WorkflowVersioning.Workflows.VacationApproval.Activities; +using WorkflowVersioning.Workflows.VacationApproval.Models; + +namespace WorkflowVersioning.Workflows.VacationApproval; + +public sealed class VacationApprovalWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext context, VacationRequest input) + { + // Oops - only send the approval if this is at least two weeks out + if (context.IsPatched("needs-two-weeks-notice")) + { + var now = context.CurrentUtcDateTime; + if (input.StartDate < new DateOnly(now.Year, now.Month, now.Day).AddDays(14)) + { + // Need at least two weeks of notice + return false; + } + } + + // Send approval email to the manager + await context.CallActivityAsync(nameof(SendEmailActivity), + new EmailActivityInput("manager@localhost", + $"Vacation request '{context.InstanceId}' from {input.EmployeeName} from {input.StartDate:d} to {input.EndDate:d}")); + + // Wait for approval + try + { + await context.WaitForExternalEventAsync("Approval", timeout: TimeSpan.FromSeconds(120)); + } + catch (TaskCanceledException) + { + await context.CallActivityAsync(nameof(SendEmailActivity), + new EmailActivityInput($"{input.EmployeeName}@localhost", + $"Vacation request '{context.InstanceId}' denied from {input.StartDate:d} to {input.EndDate:d}")); + return false; + } + + await context.CallActivityAsync(nameof(SendEmailActivity), + new EmailActivityInput($"{input.EmployeeName}@localhost", + $"Vacation request '{context.InstanceId}' approved from {input.StartDate:d} to {input.EndDate:d}")); + return true; + } +} diff --git a/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/VacationApprovalWorkflowV2.cs b/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/VacationApprovalWorkflowV2.cs new file mode 100644 index 000000000..c67e84197 --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/Workflows/VacationApproval/VacationApprovalWorkflowV2.cs @@ -0,0 +1,55 @@ +using Dapr.Workflow; +using WorkflowVersioning.Workflows.VacationApproval.Activities; +using WorkflowVersioning.Workflows.VacationApproval.Models; + +namespace WorkflowVersioning.Workflows.VacationApproval; + +public sealed class VacationApprovalWorkflowV2 : Workflow +{ + public override async Task RunAsync(WorkflowContext context, VacationRequest input) + { + var logger = context.CreateReplaySafeLogger(); + + // Only send the approval if this is at least two weeks out + var now = context.CurrentUtcDateTime; + if (input.StartDate < new DateOnly(now.Year, now.Month, now.Day).AddDays(14)) + { + // Need at least two weeks of notice + return false; + } + + // Send approval email to the manager + logger.LogInformation("Sending approval email to manager for workflow '{workflowId}'", context.InstanceId); + await context.CallActivityAsync(nameof(SendEmailActivity), + new EmailActivityInput("manager@localhost", + $"Vacation request '{context.InstanceId}' from {input.EmployeeName} from {input.StartDate:d} to {input.EndDate:d}")); + + // Refactored the following and fixed a bug: + // 1) If the approval is rejected, still approves if the external event doesn't time out + + // Wait for approval and respond accordingly + bool approvalResponse; + var denialMessage = + $"Vacation request '{context.InstanceId}' denied from {input.StartDate:d} to {input.EndDate:d}"; + try + { + logger.LogInformation("Waiting for approval for workflow '{workflowId}'", context.InstanceId); + approvalResponse = await context.WaitForExternalEventAsync("Approval", timeout: TimeSpan.FromSeconds(120)); + } + catch (TaskCanceledException) + { + logger.LogWarning("Approval timeout for workflow '{workflowId}'", context.InstanceId); + await context.CallActivityAsync(nameof(SendEmailActivity), + new EmailActivityInput($"{input.EmployeeName}@localhost", denialMessage)); + return false; + } + + var approvalMessage = + $"Vacation request '{context.InstanceId}' approved from {input.StartDate:d} to {input.EndDate:d}"; + logger.LogInformation("Received approval decision for workflow '{workflowId}', status: '{status}'", context.InstanceId, approvalResponse ? "Approved" : "Denied"); + var emailInput = new EmailActivityInput($"{input.EmployeeName}@localhost", approvalResponse ? approvalMessage : denialMessage); + await context.CallActivityAsync(nameof(SendEmailActivity), emailInput); + + return approvalResponse; + } +} diff --git a/examples/Workflow/WorkflowVersioning/appsettings.Development.json b/examples/Workflow/WorkflowVersioning/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/examples/Workflow/WorkflowVersioning/appsettings.json b/examples/Workflow/WorkflowVersioning/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/examples/Workflow/WorkflowVersioning/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs b/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs index 46893f3f0..d0e40d559 100644 --- a/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs +++ b/src/Dapr.Workflow.Versioning.Generators/WorkflowSourceGenerator.cs @@ -42,26 +42,77 @@ public void Initialize(IncrementalGeneratorInitializationContext context) WorkflowBase: c.GetTypeByMetadataName(WorkflowBaseMetadataName), WorkflowVersionAttribute: c.GetTypeByMetadataName(WorkflowVersionAttributeFullName))); + // Report diagnostic about base type resolution + context.RegisterSourceOutput(known, (spc, ks) => + { + if (ks.WorkflowBase is null) + { + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "DAPRWFVER001", + "Workflow base type not found", + $"The source generator could not find the type '{WorkflowBaseMetadataName}'. Ensure that Dapr.Workflow.Abstractions is properly referenced.", + "Dapr.Workflow.Versioning", + DiagnosticSeverity.Warning, + isEnabledByDefault: true), + Location.None)); + } + else + { + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "DAPRWFVER003", + "Workflow base type found", + $"Source generator successfully found workflow base type '{WorkflowBaseMetadataName}'", + "Dapr.Workflow.Versioning", + DiagnosticSeverity.Info, + isEnabledByDefault: true), + Location.None)); + } + }); + // Discover candidate class symbols var candidates = context.SyntaxProvider.CreateSyntaxProvider( static (node, _) => node is ClassDeclarationSyntax { BaseList: not null }, static (ctx, _) => (INamedTypeSymbol?)ctx.SemanticModel.GetDeclaredSymbol((ClassDeclarationSyntax)ctx.Node)); + // Count candidates for diagnostics + var candidatesWithCount = candidates.Collect(); + context.RegisterSourceOutput(candidatesWithCount, (spc, candidateArray) => + { + var nonNullCount = candidateArray.Count(x => x is not null); + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "DAPRWFVER004", + "Candidate classes found", + $"Source generator found {nonNullCount} candidate class(es) with base lists (out of {candidateArray.Length} total)", + "Dapr.Workflow.Versioning", + DiagnosticSeverity.Info, + isEnabledByDefault: true), + Location.None)); + }); + // Combine the attribute symbol with each candidate symbol var inputs = candidates.Combine(known); - // Filter and transform with proper symbol equality checks - var discovered = inputs + // Filter and transform with proper symbol equality checks, tracking each step + var discoveredWithDiagnostics = inputs .Select((pair, _) => { var (symbol, ks) = pair; if (symbol is null) - return null; + return (Workflow: (DiscoveredWorkflow?)null, Diagnostic: (string?)null); + + var symbolName = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); // Check derives from Dapr.Workflow.Workflow<,> if (!InheritsFromWorkflow(symbol, ks.WorkflowBase)) - return null; + { + // Report why this candidate was rejected + var baseTypeInfo = symbol.BaseType?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) ?? "null"; + return (Workflow: (DiscoveredWorkflow?)null, Diagnostic: (string?)$"Rejected '{symbolName}': does not inherit from Workflow<,> (base type: {baseTypeInfo})"); + } // Look for [WorkflowVersion] by symbol identity AttributeData? attrData = null; @@ -76,12 +127,96 @@ public void Initialize(IncrementalGeneratorInitializationContext context) string.Equals(a.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), $"global::{WorkflowVersionAttributeFullName}", StringComparison.Ordinal)); - return BuildDiscoveredWorkflow(symbol, attrData); - }) + var workflow = BuildDiscoveredWorkflow(symbol, attrData); + return (Workflow: (DiscoveredWorkflow?)workflow, Diagnostic: (string?)$"Discovered workflow: '{symbolName}'"); + }); + + // Separate workflows from diagnostics + var discovered = discoveredWithDiagnostics + .Select((item, _) => item.Workflow) .Where(x => x is not null); + // Report diagnostics about filtering + context.RegisterSourceOutput(discoveredWithDiagnostics.Collect(), (spc, items) => + { + foreach (var item in items) + { + if (item.Diagnostic is not null) + { + const DiagnosticSeverity severity = DiagnosticSeverity.Info; + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "DAPRWFVER005", + "Workflow filtering", + item.Diagnostic, + "Dapr.Workflow.Versioning", + severity, + isEnabledByDefault: true), + Location.None)); + } + } + }); + // Collect and emit - context.RegisterSourceOutput(discovered.Collect(), EmitRegistry); + context.RegisterSourceOutput(discovered.Collect(), (spc, items) => + { + var workflows = items.Where(x => x is not null).ToList(); + + // Always show a visible message about workflow discovery (Info level to avoid build warnings) + if (workflows.Count > 0) + { + var workflowNames = string.Join(", ", workflows.Select(w => w!.WorkflowTypeName.Split('.').Last())); + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "DAPRWFVER008", + "Workflow versioning active", + $"Dapr Workflow Versioning: Discovered {workflows.Count} workflow(s): {workflowNames}. Build with -v:n to see this message.", + "Dapr.Workflow.Versioning", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + helpLinkUri: "https://docs.dapr.io/developing-applications/building-blocks/workflow/"), + Location.None)); + } + + // Detailed info for verbose builds + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "DAPRWFVER002", + "Final workflow count", + $"Source generator will generate registry for {workflows.Count} workflow(s). To view generated code, add true to your project file.", + "Dapr.Workflow.Versioning", + DiagnosticSeverity.Info, + isEnabledByDefault: true), + Location.None)); + + if (workflows.Count > 0) + { + foreach (var wf in workflows) + { + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "DAPRWFVER006", + "Generating for workflow", + $"Generating registration code for workflow: {wf!.WorkflowTypeName}", + "Dapr.Workflow.Versioning", + DiagnosticSeverity.Info, + isEnabledByDefault: true), + Location.None)); + } + + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "DAPRWFVER007", + "Generated file info", + $"Generated 'Dapr_Workflow_Versioning.g.cs' with workflow registration code. Set EmitCompilerGeneratedFiles=true in your project to write generated files to disk at obj/$(Configuration)/$(TargetFramework)/generated/", + "Dapr.Workflow.Versioning", + DiagnosticSeverity.Info, + isEnabledByDefault: true), + Location.None)); + } + + EmitRegistry(spc, items); + }); } private static bool InheritsFromWorkflow(INamedTypeSymbol symbol, INamedTypeSymbol? workflowBase) @@ -139,6 +274,7 @@ private static DiscoveredWorkflow BuildDiscoveredWorkflow( return new DiscoveredWorkflow( WorkflowTypeName: workflowSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + WorkflowSimpleName: workflowSymbol.Name, DeclaredCanonicalName: string.IsNullOrWhiteSpace(canonical) ? null : canonical, DeclaredVersion: string.IsNullOrWhiteSpace(version) ? null : version, StrategyTypeName: strategyTypeName, @@ -164,6 +300,10 @@ private static void EmitRegistry( .Select(x => x!) .Distinct(new DiscoveredWorkflowComparer()) .OrderBy(x => x.WorkflowTypeName, StringComparer.Ordinal) + .ThenBy(x => x.DeclaredCanonicalName ?? string.Empty, StringComparer.Ordinal) + .ThenBy(x => x.DeclaredVersion ?? string.Empty, StringComparer.Ordinal) + .ThenBy(x => x.StrategyTypeName ?? string.Empty, StringComparer.Ordinal) + .ThenBy(x => x.OptionsName ?? string.Empty, StringComparer.Ordinal) .ToList(); if (list.Count == 0) @@ -255,10 +395,11 @@ private static string GenerateRegistrySource(IReadOnlyList d sb.AppendLine(" var entries = CreateEntries();"); sb.AppendLine(); - sb.AppendLine(" // Register concrete workflow implementations."); + sb.AppendLine(" // Register concrete workflow implementations with internal names to avoid collisions."); + sb.AppendLine(" // These registrations use the fully-qualified type name as the workflow name."); foreach (var wf in discovered) { - sb.AppendLine($" options.RegisterWorkflow<{wf.WorkflowTypeName}>();"); + sb.AppendLine($" options.RegisterWorkflow<{wf.WorkflowTypeName}>({CodeLiteral(wf.WorkflowTypeName)});"); } sb.AppendLine(); @@ -270,6 +411,17 @@ private static string GenerateRegistrySource(IReadOnlyList d sb.AppendLine(" var latestName = kvp.Value;"); sb.AppendLine(" RegisterAlias(options, canonical, latestName);"); sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" // Register simple-name aliases for convenience. Collisions are resolved deterministically"); + sb.AppendLine(" // by generator ordering (first registration wins)."); + var simpleAliasNames = new HashSet(StringComparer.Ordinal); + foreach (var wf in discovered) + { + if (simpleAliasNames.Add(wf.WorkflowSimpleName)) + { + sb.AppendLine($" options.RegisterWorkflow<{wf.WorkflowTypeName}>({CodeLiteral(wf.WorkflowSimpleName)});"); + } + } sb.AppendLine(" }"); sb.AppendLine(); @@ -334,8 +486,7 @@ private static string GenerateRegistrySource(IReadOnlyList d sb.AppendLine(" }"); sb.AppendLine(); - sb.AppendLine( - " private static IReadOnlyDictionary> BuildRegistry(global::System.IServiceProvider services, List entries, out Dictionary latestMap)"); + sb.AppendLine(" private static IReadOnlyDictionary> BuildRegistry(global::System.IServiceProvider services, List entries, out Dictionary latestMap)"); sb.AppendLine(" {"); sb.AppendLine(" latestMap = new Dictionary(StringComparer.Ordinal);"); sb.AppendLine(); @@ -468,6 +619,7 @@ private static string GenerateRegistrySource(IReadOnlyList d private sealed record DiscoveredWorkflow( string WorkflowTypeName, + string WorkflowSimpleName, string? DeclaredCanonicalName, string? DeclaredVersion, string? StrategyTypeName, @@ -477,9 +629,30 @@ private sealed record DiscoveredWorkflow( private sealed class DiscoveredWorkflowComparer : IEqualityComparer { public bool Equals(DiscoveredWorkflow? x, DiscoveredWorkflow? y) - => StringComparer.Ordinal.Equals(x?.WorkflowTypeName, y?.WorkflowTypeName); + { + if (ReferenceEquals(x, y)) + return true; + if (x is null || y is null) + return false; + + return StringComparer.Ordinal.Equals(x.WorkflowTypeName, y.WorkflowTypeName) + && StringComparer.Ordinal.Equals(x.DeclaredCanonicalName ?? string.Empty, y.DeclaredCanonicalName ?? string.Empty) + && StringComparer.Ordinal.Equals(x.DeclaredVersion ?? string.Empty, y.DeclaredVersion ?? string.Empty) + && StringComparer.Ordinal.Equals(x.StrategyTypeName ?? string.Empty, y.StrategyTypeName ?? string.Empty) + && StringComparer.Ordinal.Equals(x.OptionsName ?? string.Empty, y.OptionsName ?? string.Empty); + } public int GetHashCode(DiscoveredWorkflow obj) - => StringComparer.Ordinal.GetHashCode(obj.WorkflowTypeName); + { + unchecked + { + var hash = StringComparer.Ordinal.GetHashCode(obj.WorkflowTypeName); + hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(obj.DeclaredCanonicalName ?? string.Empty); + hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(obj.DeclaredVersion ?? string.Empty); + hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(obj.StrategyTypeName ?? string.Empty); + hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(obj.OptionsName ?? string.Empty); + return hash; + } + } } } diff --git a/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersioningServiceCollectionExtensions.cs b/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersioningServiceCollectionExtensions.cs index ad7403919..354d83a51 100644 --- a/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersioningServiceCollectionExtensions.cs +++ b/src/Dapr.Workflow.Versioning.Runtime/WorkflowVersioningServiceCollectionExtensions.cs @@ -33,30 +33,35 @@ public static IServiceCollection AddDaprWorkflowVersioning( { ArgumentNullException.ThrowIfNull(services); - // Options container for defaults - var opts = new WorkflowVersioningOptions(); - configure?.Invoke(opts); - - if (opts.DefaultStrategy is null) + services.AddOptions(); + if (configure is not null) { - opts.DefaultStrategy = sp => - { - var factory = sp.GetRequiredService(); - return factory.Create(typeof(NumericVersionStrategy), canonicalName: "DEFAULT", optionsName: null, services: sp); - }; + services.Configure(configure); } - if (opts.DefaultSelector is null) + services.PostConfigure(opts => { - opts.DefaultSelector = sp => + if (opts.DefaultStrategy is null) { - var factory = sp.GetRequiredService(); - return factory.Create(typeof(MaxVersionSelector), canonicalName: "DEFAULT", optionsName: null, services: sp); - }; - } + opts.DefaultStrategy = sp => + { + var factory = sp.GetRequiredService(); + return factory.Create(typeof(NumericVersionStrategy), canonicalName: "DEFAULT", optionsName: null, services: sp); + }; + } + + if (opts.DefaultSelector is null) + { + opts.DefaultSelector = sp => + { + var factory = sp.GetRequiredService(); + return factory.Create(typeof(MaxVersionSelector), canonicalName: "DEFAULT", optionsName: null, services: sp); + }; + } + }); // Register singletons for options, diagnostics, factories and resolver - services.AddSingleton(opts); + services.AddSingleton(sp => sp.GetRequiredService>().Value); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyTests.cs index 2b2e517ea..17f67a76f 100644 --- a/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyTests.cs +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/DateSuffixVersionStrategyTests.cs @@ -78,6 +78,27 @@ public void TryParse_ShouldUseNamedFormatFromFactory() Assert.Equal("2026-02-12", version); } + [Fact] + public void TryParse_ShouldReadFromDate() + { + var services = new ServiceCollection(); + const string optionsName = "workflow-defaults"; + services.AddOptions(optionsName) + .Configure(o => o.DateFormat = "yyyyMMddHHmmss"); + + using var provider = services.BuildServiceProvider(); + var factory = new DefaultWorkflowVersionStrategyFactory(); + var strategy = (DateSuffixVersionStrategy)factory.Create( + typeof(DateSuffixVersionStrategy), + canonicalName: "", + optionsName: optionsName, + services: provider); + + Assert.True(strategy.TryParse("VacationApprovalWorkflow20260212153700", out var canonical, out var version)); + Assert.Equal("VacationApprovalWorkflow", canonical); + Assert.Equal("20260212153700", version); + } + [Fact] public void Compare_ShouldOrderByDate() { diff --git a/test/Dapr.Workflow.Versioning.Runtime.Test/WorkflowVersioningServiceCollectionExtensionsTests.cs b/test/Dapr.Workflow.Versioning.Runtime.Test/WorkflowVersioningServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..4c3ffc0a2 --- /dev/null +++ b/test/Dapr.Workflow.Versioning.Runtime.Test/WorkflowVersioningServiceCollectionExtensionsTests.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.Workflow.Versioning.Runtime.Test; + +public class WorkflowVersioningServiceCollectionExtensionsTests +{ + [Fact] + public void UseDefaultWorkflowStrategy_UsesNamedOptions() + { + var services = new ServiceCollection(); + services.AddDaprWorkflowVersioning(); + + const string optionsName = "workflow-defaults"; + services.UseDefaultWorkflowStrategy(optionsName); + services.ConfigureStrategyOptions(optionsName, o => + { + o.DateFormat = "yyyyMMddHHmmss"; + }); + + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService(); + var strategy = options.DefaultStrategy?.Invoke(provider); + + Assert.NotNull(strategy); + Assert.True(strategy!.TryParse("VacationApprovalWorkflow20260212153700", out var canonical, out var version)); + Assert.Equal("VacationApprovalWorkflow", canonical); + Assert.Equal("20260212153700", version); + } +}