From 0e90e05e75d481aa718b90b9a8363cf9dd5ce27e Mon Sep 17 00:00:00 2001 From: Yogesh Prajapati Date: Fri, 29 May 2026 19:13:11 +0100 Subject: [PATCH] Add Tier-4 features: CancellationToken (#609), AutoExecuteActions (#596), additionalInputs example (#573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three are additive; default behavior is unchanged. #609 — IRulesEngine.ExecuteAllRulesAsync gains an overload taking a CancellationToken. The token is observed cooperatively between rules (ExecuteAllRuleByWorkflow) and before each action (ExecuteActionAsync). A single rule's compiled expression isn't interrupted mid-evaluation; cancellation happens at rule/action boundaries where async work lives. The new 3-arg overload (string, RuleParameter[], CancellationToken) is strictly more specific than the existing `params object[]` overload, so call-site overload resolution continues to bind to the params forms when no token is supplied — no behavioral change at existing call sites. #596 — New ReSettings.AutoExecuteActions (default true). When false, ExecuteAllRulesAsync evaluates rules but skips automatic OnSuccess/OnFailure action execution, letting callers run actions selectively via ExecuteActionWorkflowAsync. Wired into the copy constructor. #573 — EvaluateRuleAction already supports additionalInputs; the reporter just lacked a working example. Added a test showing a computed additionalInput ("doubled" = input1.Value * 2) referenced by name in the target rule. Deferred (documented in the PR, not implemented): #623 (Dynamic.Core method- resolution limitation), #598 (non-standard implicit list projection), #565 (already achievable via NestedRuleExecutionMode.Performance), #569 (workflow schema change needing maintainer buy-in), #564 and #550 (niche). All 166 unit tests pass on net6 / net8 / net9 / net10. --- CHANGELOG.md | 7 ++ src/RulesEngine/Interfaces/IRulesEngine.cs | 15 +++- src/RulesEngine/Models/ReSettings.cs | 8 +++ src/RulesEngine/RulesEngine.cs | 34 +++++++-- test/RulesEngine.UnitTest/Issue573Test.cs | 68 ++++++++++++++++++ test/RulesEngine.UnitTest/Issue596Test.cs | 82 ++++++++++++++++++++++ test/RulesEngine.UnitTest/Issue609Test.cs | 60 ++++++++++++++++ 7 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 test/RulesEngine.UnitTest/Issue573Test.cs create mode 100644 test/RulesEngine.UnitTest/Issue596Test.cs create mode 100644 test/RulesEngine.UnitTest/Issue609Test.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 53a104b3..beaf7f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Features +- `IRulesEngine.ExecuteAllRulesAsync` gains an overload accepting a `CancellationToken`, observed cooperatively between rules and before each action. The existing `params object[]` and `params RuleParameter[]` overloads are unchanged; call-site overload resolution continues to pick them when no token is supplied (#609). +- New `ReSettings.AutoExecuteActions` (default `true`). Set to `false` to evaluate rules without automatically running their OnSuccess/OnFailure actions, so callers can run actions selectively via `ExecuteActionWorkflowAsync` (#596). +- Documented and tested passing computed `additionalInputs` into the `EvaluateRule` action — the additionalInput `Name` is referenced directly in the target rule's expression (#573). + ## [6.0.1-preview.1] ### Performance diff --git a/src/RulesEngine/Interfaces/IRulesEngine.cs b/src/RulesEngine/Interfaces/IRulesEngine.cs index 000bdfc3..aac2cd4b 100644 --- a/src/RulesEngine/Interfaces/IRulesEngine.cs +++ b/src/RulesEngine/Interfaces/IRulesEngine.cs @@ -3,6 +3,7 @@ using RulesEngine.Models; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace RulesEngine.Interfaces @@ -24,6 +25,16 @@ public interface IRulesEngine /// A variable number of rule parameters /// List of rule results ValueTask> ExecuteAllRulesAsync(string workflowName, params RuleParameter[] ruleParams); + + /// + /// This will execute all the rules of the specified workflow with cooperative cancellation. + /// + /// The name of the workflow with rules to execute against the inputs + /// The rule parameters + /// Token observed between rules and before each action + /// List of rule results + ValueTask> ExecuteAllRulesAsync(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken); + ValueTask ExecuteActionWorkflowAsync(string workflowName, string ruleName, RuleParameter[] ruleParameters); /// @@ -41,8 +52,8 @@ public interface IRulesEngine /// Removes the workflow from RulesEngine /// /// - void RemoveWorkflow(params string[] workflowNames); - + void RemoveWorkflow(params string[] workflowNames); + /// /// Checks is workflow exist. /// diff --git a/src/RulesEngine/Models/ReSettings.cs b/src/RulesEngine/Models/ReSettings.cs index 247d637a..a4a4262c 100644 --- a/src/RulesEngine/Models/ReSettings.cs +++ b/src/RulesEngine/Models/ReSettings.cs @@ -29,6 +29,7 @@ internal ReSettings(ReSettings reSettings) AutoRegisterInputType = reSettings.AutoRegisterInputType; UseFastExpressionCompiler = reSettings.UseFastExpressionCompiler; EnableExceptionAsErrorMessageForRuleExpressionParsing = reSettings.EnableExceptionAsErrorMessageForRuleExpressionParsing; + AutoExecuteActions = reSettings.AutoExecuteActions; } @@ -90,6 +91,13 @@ internal ReSettings(ReSettings reSettings) /// Default: true /// public bool EnableExceptionAsErrorMessageForRuleExpressionParsing { get; set; } = true; + + /// + /// When true (default), ExecuteAllRulesAsync automatically runs each matched rule's + /// OnSuccess/OnFailure action after evaluation. Set to false to evaluate rules only and + /// run actions yourself (e.g. via ExecuteActionWorkflowAsync) for selective control. See #596. + /// + public bool AutoExecuteActions { get; set; } = true; } public enum NestedRuleExecutionMode diff --git a/src/RulesEngine/RulesEngine.cs b/src/RulesEngine/RulesEngine.cs index 74d6a307..a7d715ff 100644 --- a/src/RulesEngine/RulesEngine.cs +++ b/src/RulesEngine/RulesEngine.cs @@ -14,6 +14,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; namespace RulesEngine @@ -102,21 +103,39 @@ public async ValueTask> ExecuteAllRulesAsync(string workflo /// A variable number of rule parameters /// List of rule results public async ValueTask> ExecuteAllRulesAsync(string workflowName, params RuleParameter[] ruleParams) + { + return await ExecuteAllRulesAsync(workflowName, ruleParams, default); + } + + /// + /// This will execute all the rules of the specified workflow with cooperative cancellation. + /// + /// The name of the workflow with rules to execute against the inputs + /// The rule parameters + /// Token observed between rules and before each action. A single + /// rule's compiled expression is not interrupted mid-evaluation; cancellation is cooperative at + /// rule and action boundaries. + /// List of rule results + public async ValueTask> ExecuteAllRulesAsync(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken) { var sortedRuleParams = ruleParams.ToList(); sortedRuleParams.Sort((RuleParameter a, RuleParameter b) => string.Compare(a.Name, b.Name)); - var ruleResultList = ValidateWorkflowAndExecuteRule(workflowName, sortedRuleParams.ToArray()); - await ExecuteActionAsync(ruleResultList); + var ruleResultList = ValidateWorkflowAndExecuteRule(workflowName, sortedRuleParams.ToArray(), cancellationToken); + if (_reSettings.AutoExecuteActions) + { + await ExecuteActionAsync(ruleResultList, cancellationToken); + } return ruleResultList; } - private async ValueTask ExecuteActionAsync(IEnumerable ruleResultList) + private async ValueTask ExecuteActionAsync(IEnumerable ruleResultList, CancellationToken cancellationToken = default) { foreach (var ruleResult in ruleResultList) { + cancellationToken.ThrowIfCancellationRequested(); if(ruleResult.ChildResults != null) { - await ExecuteActionAsync(ruleResult.ChildResults); + await ExecuteActionAsync(ruleResult.ChildResults, cancellationToken); } var actionResult = await ExecuteActionForRuleResult(ruleResult, false); ruleResult.ActionResult = new ActionResult { @@ -304,13 +323,13 @@ public void RemoveWorkflow(params string[] workflowNames) /// input /// workflow name /// list of rule result set - private List ValidateWorkflowAndExecuteRule(string workflowName, RuleParameter[] ruleParams) + private List ValidateWorkflowAndExecuteRule(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken = default) { List result; if (RegisterRule(workflowName, ruleParams)) { - result = ExecuteAllRuleByWorkflow(workflowName, ruleParams); + result = ExecuteAllRuleByWorkflow(workflowName, ruleParams, cancellationToken); } else { @@ -430,7 +449,7 @@ private static void CollectAllElementTypes(Type t, ISet collector) /// /// /// list of rule result set - private List ExecuteAllRuleByWorkflow(string workflowName, RuleParameter[] ruleParameters) + private List ExecuteAllRuleByWorkflow(string workflowName, RuleParameter[] ruleParameters, CancellationToken cancellationToken = default) { var result = new List(); var compiledRulesCacheKey = GetCompiledRulesKey(workflowName, ruleParameters); @@ -456,6 +475,7 @@ private List ExecuteAllRuleByWorkflow(string workflowName, RuleP foreach (var compiledRule in compiledRules) { + cancellationToken.ThrowIfCancellationRequested(); RuleResultTree resultTree; if (globalEvaluationException != null && ruleByName != null && ruleByName.TryGetValue(compiledRule.Key, out var rule)) { diff --git a/test/RulesEngine.UnitTest/Issue573Test.cs b/test/RulesEngine.UnitTest/Issue573Test.cs new file mode 100644 index 00000000..094c0609 --- /dev/null +++ b/test/RulesEngine.UnitTest/Issue573Test.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using RulesEngine.Models; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Xunit; + +namespace RulesEngine.UnitTest +{ + [ExcludeFromCodeCoverage] + public class Issue573Test + { + // Demonstrates the correct way to pass computed additionalInputs into an EvaluateRule + // action. The target rule can reference the additionalInput by its Name. The key detail + // (the source of the "Unknown identifier" confusion in #573) is that the additionalInput + // Name must match the identifier used in the target rule's expression. + [Fact] + public async Task EvaluateRuleAction_AdditionalInputs_AreAvailableToTargetRule() + { + var workflow = new Workflow + { + WorkflowName = "wf", + Rules = new[] + { + new Rule + { + RuleName = "Parent", + Expression = "input1.Value > 0", + Actions = new RuleActions + { + OnSuccess = new ActionInfo + { + Name = "EvaluateRule", + Context = new Dictionary + { + { "workflowName", "wf" }, + { "ruleName", "Child" }, + // Compute a new input named "doubled" from input1 and pass it on. + { "additionalInputs", new List + { + new ScopedParam { Name = "doubled", Expression = "input1.Value * 2" } + } + } + } + } + } + }, + new Rule + { + RuleName = "Child", + // References the additionalInput by name. + Expression = "doubled == 20" + } + } + }; + + var engine = new RulesEngine(new[] { workflow }); + var result = await engine.ExecuteActionWorkflowAsync("wf", "Parent", + new[] { RuleParameter.Create("input1", new { Value = 10 }) }); + + // Child rule succeeded because "doubled" (10*2=20) was available to it. + Assert.NotNull(result.Results); + Assert.Contains(result.Results, r => r.Rule.RuleName == "Child" && r.IsSuccess); + } + } +} diff --git a/test/RulesEngine.UnitTest/Issue596Test.cs b/test/RulesEngine.UnitTest/Issue596Test.cs new file mode 100644 index 00000000..9d341c87 --- /dev/null +++ b/test/RulesEngine.UnitTest/Issue596Test.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using RulesEngine.Actions; +using RulesEngine.Models; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace RulesEngine.UnitTest +{ + [ExcludeFromCodeCoverage] + public class Issue596Test + { + private class CountingAction : ActionBase + { + public static int RunCount; + public override ValueTask Run(ActionContext context, RuleParameter[] ruleParameters) + { + Interlocked.Increment(ref RunCount); + return new ValueTask("done"); + } + } + + private static Workflow WorkflowWithAction() => new Workflow + { + WorkflowName = "wf", + Rules = new[] { + new Rule { + RuleName = "R", + Expression = "true", + Actions = new RuleActions { + OnSuccess = new ActionInfo { Name = "counting", Context = new Dictionary() } + } + } + } + }; + + [Fact] + public async Task AutoExecuteActions_True_RunsActions_DefaultBehavior() + { + CountingAction.RunCount = 0; + var settings = new ReSettings + { + CustomActions = new Dictionary> { { "counting", () => new CountingAction() } } + }; + var engine = new RulesEngine(new[] { WorkflowWithAction() }, settings); + + var results = await engine.ExecuteAllRulesAsync("wf", "x"); + + Assert.True(results[0].IsSuccess); + Assert.Equal(1, CountingAction.RunCount); + } + + [Fact] + public async Task AutoExecuteActions_False_EvaluatesRulesButSkipsActions() + { + CountingAction.RunCount = 0; + var settings = new ReSettings + { + AutoExecuteActions = false, + CustomActions = new Dictionary> { { "counting", () => new CountingAction() } } + }; + var engine = new RulesEngine(new[] { WorkflowWithAction() }, settings); + + var results = await engine.ExecuteAllRulesAsync("wf", "x"); + + // Rule still evaluated... + Assert.True(results[0].IsSuccess); + // ...but the action did NOT run automatically. + Assert.Equal(0, CountingAction.RunCount); + + // Caller can still run the action selectively afterwards. + var actionResult = await engine.ExecuteActionWorkflowAsync("wf", "R", new RuleParameter[0]); + Assert.Equal("done", actionResult.Output); + Assert.Equal(1, CountingAction.RunCount); + } + } +} diff --git a/test/RulesEngine.UnitTest/Issue609Test.cs b/test/RulesEngine.UnitTest/Issue609Test.cs new file mode 100644 index 00000000..20aaee2a --- /dev/null +++ b/test/RulesEngine.UnitTest/Issue609Test.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using RulesEngine.Models; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace RulesEngine.UnitTest +{ + [ExcludeFromCodeCoverage] + public class Issue609Test + { + private static Workflow ManyRulesWorkflow(int count) => new Workflow + { + WorkflowName = "wf", + Rules = Enumerable.Range(0, count) + .Select(i => new Rule { RuleName = $"R{i}", Expression = "input1 >= 0" }) + .ToArray() + }; + + [Fact] + public async Task ExecuteAllRulesAsync_WithUncancelledToken_RunsNormally() + { + var engine = new RulesEngine(new[] { ManyRulesWorkflow(5) }); + var results = await engine.ExecuteAllRulesAsync( + "wf", new[] { RuleParameter.Create("input1", 1) }, CancellationToken.None); + Assert.Equal(5, results.Count); + Assert.All(results, r => Assert.True(r.IsSuccess)); + } + + [Fact] + public async Task ExecuteAllRulesAsync_WithAlreadyCancelledToken_Throws() + { + var engine = new RulesEngine(new[] { ManyRulesWorkflow(5) }); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + await engine.ExecuteAllRulesAsync( + "wf", new[] { RuleParameter.Create("input1", 1) }, cts.Token)); + } + + [Fact] + public async Task ExecuteAllRulesAsync_DefaultOverloads_StillWork_NoBehavioralBreakage() + { + // The pre-existing signatures still resolve correctly. The new 3-arg overload is + // strictly more specific than `params object[]`, so call sites that pass + // (string, array) continue to bind to the params overloads as before. + var engine = new RulesEngine(new[] { ManyRulesWorkflow(3) }); + var byParams = await engine.ExecuteAllRulesAsync("wf", 1); + var byRuleParams = await engine.ExecuteAllRulesAsync("wf", new[] { RuleParameter.Create("input1", 1) }); + Assert.Equal(3, byParams.Count); + Assert.Equal(3, byRuleParams.Count); + } + } +}