Skip to content

Commit 0e90e05

Browse files
author
Yogesh Prajapati
committed
Add Tier-4 features: CancellationToken (#609), AutoExecuteActions (#596), additionalInputs example (#573)
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.
1 parent 3e6ce8b commit 0e90e05

7 files changed

Lines changed: 265 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### Features
8+
- `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).
9+
- 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).
10+
- Documented and tested passing computed `additionalInputs` into the `EvaluateRule` action — the additionalInput `Name` is referenced directly in the target rule's expression (#573).
11+
512
## [6.0.1-preview.1]
613

714
### Performance

src/RulesEngine/Interfaces/IRulesEngine.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using RulesEngine.Models;
55
using System.Collections.Generic;
6+
using System.Threading;
67
using System.Threading.Tasks;
78

89
namespace RulesEngine.Interfaces
@@ -24,6 +25,16 @@ public interface IRulesEngine
2425
/// <param name="ruleParams">A variable number of rule parameters</param>
2526
/// <returns>List of rule results</returns>
2627
ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, params RuleParameter[] ruleParams);
28+
29+
/// <summary>
30+
/// This will execute all the rules of the specified workflow with cooperative cancellation.
31+
/// </summary>
32+
/// <param name="workflowName">The name of the workflow with rules to execute against the inputs</param>
33+
/// <param name="ruleParams">The rule parameters</param>
34+
/// <param name="cancellationToken">Token observed between rules and before each action</param>
35+
/// <returns>List of rule results</returns>
36+
ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken);
37+
2738
ValueTask<ActionRuleResult> ExecuteActionWorkflowAsync(string workflowName, string ruleName, RuleParameter[] ruleParameters);
2839

2940
/// <summary>
@@ -41,8 +52,8 @@ public interface IRulesEngine
4152
/// Removes the workflow from RulesEngine
4253
/// </summary>
4354
/// <param name="workflowNames"></param>
44-
void RemoveWorkflow(params string[] workflowNames);
45-
55+
void RemoveWorkflow(params string[] workflowNames);
56+
4657
/// <summary>
4758
/// Checks is workflow exist.
4859
/// </summary>

src/RulesEngine/Models/ReSettings.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ internal ReSettings(ReSettings reSettings)
2929
AutoRegisterInputType = reSettings.AutoRegisterInputType;
3030
UseFastExpressionCompiler = reSettings.UseFastExpressionCompiler;
3131
EnableExceptionAsErrorMessageForRuleExpressionParsing = reSettings.EnableExceptionAsErrorMessageForRuleExpressionParsing;
32+
AutoExecuteActions = reSettings.AutoExecuteActions;
3233
}
3334

3435

@@ -90,6 +91,13 @@ internal ReSettings(ReSettings reSettings)
9091
/// Default: true
9192
/// </summary>
9293
public bool EnableExceptionAsErrorMessageForRuleExpressionParsing { get; set; } = true;
94+
95+
/// <summary>
96+
/// When true (default), ExecuteAllRulesAsync automatically runs each matched rule's
97+
/// OnSuccess/OnFailure action after evaluation. Set to false to evaluate rules only and
98+
/// run actions yourself (e.g. via ExecuteActionWorkflowAsync) for selective control. See #596.
99+
/// </summary>
100+
public bool AutoExecuteActions { get; set; } = true;
93101
}
94102

95103
public enum NestedRuleExecutionMode

src/RulesEngine/RulesEngine.cs

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using System.Collections.Generic;
1515
using System.Linq;
1616
using System.Text.RegularExpressions;
17+
using System.Threading;
1718
using System.Threading.Tasks;
1819

1920
namespace RulesEngine
@@ -102,21 +103,39 @@ public async ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflo
102103
/// <param name="ruleParams">A variable number of rule parameters</param>
103104
/// <returns>List of rule results</returns>
104105
public async ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, params RuleParameter[] ruleParams)
106+
{
107+
return await ExecuteAllRulesAsync(workflowName, ruleParams, default);
108+
}
109+
110+
/// <summary>
111+
/// This will execute all the rules of the specified workflow with cooperative cancellation.
112+
/// </summary>
113+
/// <param name="workflowName">The name of the workflow with rules to execute against the inputs</param>
114+
/// <param name="ruleParams">The rule parameters</param>
115+
/// <param name="cancellationToken">Token observed between rules and before each action. A single
116+
/// rule's compiled expression is not interrupted mid-evaluation; cancellation is cooperative at
117+
/// rule and action boundaries.</param>
118+
/// <returns>List of rule results</returns>
119+
public async ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken)
105120
{
106121
var sortedRuleParams = ruleParams.ToList();
107122
sortedRuleParams.Sort((RuleParameter a, RuleParameter b) => string.Compare(a.Name, b.Name));
108-
var ruleResultList = ValidateWorkflowAndExecuteRule(workflowName, sortedRuleParams.ToArray());
109-
await ExecuteActionAsync(ruleResultList);
123+
var ruleResultList = ValidateWorkflowAndExecuteRule(workflowName, sortedRuleParams.ToArray(), cancellationToken);
124+
if (_reSettings.AutoExecuteActions)
125+
{
126+
await ExecuteActionAsync(ruleResultList, cancellationToken);
127+
}
110128
return ruleResultList;
111129
}
112130

113-
private async ValueTask ExecuteActionAsync(IEnumerable<RuleResultTree> ruleResultList)
131+
private async ValueTask ExecuteActionAsync(IEnumerable<RuleResultTree> ruleResultList, CancellationToken cancellationToken = default)
114132
{
115133
foreach (var ruleResult in ruleResultList)
116134
{
135+
cancellationToken.ThrowIfCancellationRequested();
117136
if(ruleResult.ChildResults != null)
118137
{
119-
await ExecuteActionAsync(ruleResult.ChildResults);
138+
await ExecuteActionAsync(ruleResult.ChildResults, cancellationToken);
120139
}
121140
var actionResult = await ExecuteActionForRuleResult(ruleResult, false);
122141
ruleResult.ActionResult = new ActionResult {
@@ -304,13 +323,13 @@ public void RemoveWorkflow(params string[] workflowNames)
304323
/// <param name="input">input</param>
305324
/// <param name="workflowName">workflow name</param>
306325
/// <returns>list of rule result set</returns>
307-
private List<RuleResultTree> ValidateWorkflowAndExecuteRule(string workflowName, RuleParameter[] ruleParams)
326+
private List<RuleResultTree> ValidateWorkflowAndExecuteRule(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken = default)
308327
{
309328
List<RuleResultTree> result;
310329

311330
if (RegisterRule(workflowName, ruleParams))
312331
{
313-
result = ExecuteAllRuleByWorkflow(workflowName, ruleParams);
332+
result = ExecuteAllRuleByWorkflow(workflowName, ruleParams, cancellationToken);
314333
}
315334
else
316335
{
@@ -430,7 +449,7 @@ private static void CollectAllElementTypes(Type t, ISet<Type> collector)
430449
/// <param name="workflowName"></param>
431450
/// <param name="ruleParams"></param>
432451
/// <returns>list of rule result set</returns>
433-
private List<RuleResultTree> ExecuteAllRuleByWorkflow(string workflowName, RuleParameter[] ruleParameters)
452+
private List<RuleResultTree> ExecuteAllRuleByWorkflow(string workflowName, RuleParameter[] ruleParameters, CancellationToken cancellationToken = default)
434453
{
435454
var result = new List<RuleResultTree>();
436455
var compiledRulesCacheKey = GetCompiledRulesKey(workflowName, ruleParameters);
@@ -456,6 +475,7 @@ private List<RuleResultTree> ExecuteAllRuleByWorkflow(string workflowName, RuleP
456475

457476
foreach (var compiledRule in compiledRules)
458477
{
478+
cancellationToken.ThrowIfCancellationRequested();
459479
RuleResultTree resultTree;
460480
if (globalEvaluationException != null && ruleByName != null && ruleByName.TryGetValue(compiledRule.Key, out var rule))
461481
{
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using RulesEngine.Models;
5+
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Threading.Tasks;
8+
using Xunit;
9+
10+
namespace RulesEngine.UnitTest
11+
{
12+
[ExcludeFromCodeCoverage]
13+
public class Issue573Test
14+
{
15+
// Demonstrates the correct way to pass computed additionalInputs into an EvaluateRule
16+
// action. The target rule can reference the additionalInput by its Name. The key detail
17+
// (the source of the "Unknown identifier" confusion in #573) is that the additionalInput
18+
// Name must match the identifier used in the target rule's expression.
19+
[Fact]
20+
public async Task EvaluateRuleAction_AdditionalInputs_AreAvailableToTargetRule()
21+
{
22+
var workflow = new Workflow
23+
{
24+
WorkflowName = "wf",
25+
Rules = new[]
26+
{
27+
new Rule
28+
{
29+
RuleName = "Parent",
30+
Expression = "input1.Value > 0",
31+
Actions = new RuleActions
32+
{
33+
OnSuccess = new ActionInfo
34+
{
35+
Name = "EvaluateRule",
36+
Context = new Dictionary<string, object>
37+
{
38+
{ "workflowName", "wf" },
39+
{ "ruleName", "Child" },
40+
// Compute a new input named "doubled" from input1 and pass it on.
41+
{ "additionalInputs", new List<ScopedParam>
42+
{
43+
new ScopedParam { Name = "doubled", Expression = "input1.Value * 2" }
44+
}
45+
}
46+
}
47+
}
48+
}
49+
},
50+
new Rule
51+
{
52+
RuleName = "Child",
53+
// References the additionalInput by name.
54+
Expression = "doubled == 20"
55+
}
56+
}
57+
};
58+
59+
var engine = new RulesEngine(new[] { workflow });
60+
var result = await engine.ExecuteActionWorkflowAsync("wf", "Parent",
61+
new[] { RuleParameter.Create("input1", new { Value = 10 }) });
62+
63+
// Child rule succeeded because "doubled" (10*2=20) was available to it.
64+
Assert.NotNull(result.Results);
65+
Assert.Contains(result.Results, r => r.Rule.RuleName == "Child" && r.IsSuccess);
66+
}
67+
}
68+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using RulesEngine.Actions;
5+
using RulesEngine.Models;
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Diagnostics.CodeAnalysis;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using Xunit;
12+
13+
namespace RulesEngine.UnitTest
14+
{
15+
[ExcludeFromCodeCoverage]
16+
public class Issue596Test
17+
{
18+
private class CountingAction : ActionBase
19+
{
20+
public static int RunCount;
21+
public override ValueTask<object> Run(ActionContext context, RuleParameter[] ruleParameters)
22+
{
23+
Interlocked.Increment(ref RunCount);
24+
return new ValueTask<object>("done");
25+
}
26+
}
27+
28+
private static Workflow WorkflowWithAction() => new Workflow
29+
{
30+
WorkflowName = "wf",
31+
Rules = new[] {
32+
new Rule {
33+
RuleName = "R",
34+
Expression = "true",
35+
Actions = new RuleActions {
36+
OnSuccess = new ActionInfo { Name = "counting", Context = new Dictionary<string, object>() }
37+
}
38+
}
39+
}
40+
};
41+
42+
[Fact]
43+
public async Task AutoExecuteActions_True_RunsActions_DefaultBehavior()
44+
{
45+
CountingAction.RunCount = 0;
46+
var settings = new ReSettings
47+
{
48+
CustomActions = new Dictionary<string, Func<ActionBase>> { { "counting", () => new CountingAction() } }
49+
};
50+
var engine = new RulesEngine(new[] { WorkflowWithAction() }, settings);
51+
52+
var results = await engine.ExecuteAllRulesAsync("wf", "x");
53+
54+
Assert.True(results[0].IsSuccess);
55+
Assert.Equal(1, CountingAction.RunCount);
56+
}
57+
58+
[Fact]
59+
public async Task AutoExecuteActions_False_EvaluatesRulesButSkipsActions()
60+
{
61+
CountingAction.RunCount = 0;
62+
var settings = new ReSettings
63+
{
64+
AutoExecuteActions = false,
65+
CustomActions = new Dictionary<string, Func<ActionBase>> { { "counting", () => new CountingAction() } }
66+
};
67+
var engine = new RulesEngine(new[] { WorkflowWithAction() }, settings);
68+
69+
var results = await engine.ExecuteAllRulesAsync("wf", "x");
70+
71+
// Rule still evaluated...
72+
Assert.True(results[0].IsSuccess);
73+
// ...but the action did NOT run automatically.
74+
Assert.Equal(0, CountingAction.RunCount);
75+
76+
// Caller can still run the action selectively afterwards.
77+
var actionResult = await engine.ExecuteActionWorkflowAsync("wf", "R", new RuleParameter[0]);
78+
Assert.Equal("done", actionResult.Output);
79+
Assert.Equal(1, CountingAction.RunCount);
80+
}
81+
}
82+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using RulesEngine.Models;
5+
using System;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Linq;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Xunit;
11+
12+
namespace RulesEngine.UnitTest
13+
{
14+
[ExcludeFromCodeCoverage]
15+
public class Issue609Test
16+
{
17+
private static Workflow ManyRulesWorkflow(int count) => new Workflow
18+
{
19+
WorkflowName = "wf",
20+
Rules = Enumerable.Range(0, count)
21+
.Select(i => new Rule { RuleName = $"R{i}", Expression = "input1 >= 0" })
22+
.ToArray()
23+
};
24+
25+
[Fact]
26+
public async Task ExecuteAllRulesAsync_WithUncancelledToken_RunsNormally()
27+
{
28+
var engine = new RulesEngine(new[] { ManyRulesWorkflow(5) });
29+
var results = await engine.ExecuteAllRulesAsync(
30+
"wf", new[] { RuleParameter.Create("input1", 1) }, CancellationToken.None);
31+
Assert.Equal(5, results.Count);
32+
Assert.All(results, r => Assert.True(r.IsSuccess));
33+
}
34+
35+
[Fact]
36+
public async Task ExecuteAllRulesAsync_WithAlreadyCancelledToken_Throws()
37+
{
38+
var engine = new RulesEngine(new[] { ManyRulesWorkflow(5) });
39+
using var cts = new CancellationTokenSource();
40+
cts.Cancel();
41+
42+
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
43+
await engine.ExecuteAllRulesAsync(
44+
"wf", new[] { RuleParameter.Create("input1", 1) }, cts.Token));
45+
}
46+
47+
[Fact]
48+
public async Task ExecuteAllRulesAsync_DefaultOverloads_StillWork_NoBehavioralBreakage()
49+
{
50+
// The pre-existing signatures still resolve correctly. The new 3-arg overload is
51+
// strictly more specific than `params object[]`, so call sites that pass
52+
// (string, array) continue to bind to the params overloads as before.
53+
var engine = new RulesEngine(new[] { ManyRulesWorkflow(3) });
54+
var byParams = await engine.ExecuteAllRulesAsync("wf", 1);
55+
var byRuleParams = await engine.ExecuteAllRulesAsync("wf", new[] { RuleParameter.Create("input1", 1) });
56+
Assert.Equal(3, byParams.Count);
57+
Assert.Equal(3, byRuleParams.Count);
58+
}
59+
}
60+
}

0 commit comments

Comments
 (0)