diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c4009ad..3d61ae8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+### Features
+- New `ReSettings.EnableParallelRuleCompilation` (default `false`). When `true`, workflow rules are compiled in parallel during registration, materially reducing warmup time for workflows with many thousands of rules. Silently falls back to serial compilation when combined with `UseFastExpressionCompiler = true` (which regresses ~3× under contention) or for workflows below an internal scheduling-cost threshold. Builds on the warmup work in #740 (#741).
+
## [6.0.1-preview.2]
### Features
diff --git a/src/RulesEngine/Models/ReSettings.cs b/src/RulesEngine/Models/ReSettings.cs
index a4a4262c..db873598 100644
--- a/src/RulesEngine/Models/ReSettings.cs
+++ b/src/RulesEngine/Models/ReSettings.cs
@@ -30,6 +30,7 @@ internal ReSettings(ReSettings reSettings)
UseFastExpressionCompiler = reSettings.UseFastExpressionCompiler;
EnableExceptionAsErrorMessageForRuleExpressionParsing = reSettings.EnableExceptionAsErrorMessageForRuleExpressionParsing;
AutoExecuteActions = reSettings.AutoExecuteActions;
+ EnableParallelRuleCompilation = reSettings.EnableParallelRuleCompilation;
}
@@ -98,6 +99,22 @@ internal ReSettings(ReSettings reSettings)
/// run actions yourself (e.g. via ExecuteActionWorkflowAsync) for selective control. See #596.
///
public bool AutoExecuteActions { get; set; } = true;
+
+ ///
+ /// When true, rules within a workflow are compiled in parallel during registration.
+ /// Significantly reduces warmup time for workflows with many thousands of rules.
+ ///
+ ///
+ /// Silently falls back to serial compilation when:
+ ///
+ /// - is also true — FastExpressionCompiler
+ /// regresses ~3× under parallel contention, so the engine declines to parallelize that mix.
+ /// - The workflow has fewer enabled rules than an internal threshold (32) where
+ /// the dispatch cost outweighs the speedup.
+ ///
+ /// Default: false.
+ ///
+ public bool EnableParallelRuleCompilation { get; set; } = false;
}
public enum NestedRuleExecutionMode
diff --git a/src/RulesEngine/RulesEngine.cs b/src/RulesEngine/RulesEngine.cs
index a7d715ff..391898ec 100644
--- a/src/RulesEngine/RulesEngine.cs
+++ b/src/RulesEngine/RulesEngine.cs
@@ -35,6 +35,10 @@ public class RulesEngine : IRulesEngine
private readonly RuleCompiler _ruleCompiler;
private readonly ActionFactory _actionFactory;
private const string ParamParseRegex = "(\\$\\(.*?\\))";
+
+ // Below this rule count, Parallel.For's scheduling cost exceeds the speedup from
+ // distributing CompileRule across threads. See ReSettings.EnableParallelRuleCompilation.
+ private const int MinRulesForParallelCompilation = 32;
#endregion
#region Constructor
@@ -399,9 +403,43 @@ private bool RegisterRule(string workflowName, params RuleParameter[] ruleParams
_rulesCache.AddOrUpdateGlobalParamsDelegate(compileRulesKey, globalParamsDelegate);
}
- foreach (var rule in workflow.Rules.Where(c => c.Enabled))
+ var enabledRules = workflow.Rules.Where(c => c.Enabled).ToArray();
+ var compiledFuncs = new RuleFunc[enabledRules.Length];
+
+ // Parallel compilation helps only when:
+ // - the user opted in,
+ // - they're not also on UseFastExpressionCompiler (which regresses ~3× under
+ // parallel contention; FEC's internal locking serializes effort), and
+ // - there are enough rules to amortize Parallel.For's scheduling cost.
+ var shouldParallelize = _reSettings.EnableParallelRuleCompilation
+ && !_reSettings.UseFastExpressionCompiler
+ && enabledRules.Length >= MinRulesForParallelCompilation;
+
+ if (shouldParallelize)
+ {
+ try
+ {
+ Parallel.For(0, enabledRules.Length, i => {
+ compiledFuncs[i] = CompileRule(enabledRules[i], workflow.RuleExpressionType, ruleParams, globalParamExp);
+ });
+ }
+ catch (AggregateException ae) when (ae.InnerExceptions.Count > 0)
+ {
+ // Preserve the serial-compilation contract: the first rule that fails
+ // to compile surfaces its own exception, not an AggregateException.
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ae.InnerExceptions[0]).Throw();
+ }
+ }
+ else
+ {
+ for (var i = 0; i < enabledRules.Length; i++)
+ {
+ compiledFuncs[i] = CompileRule(enabledRules[i], workflow.RuleExpressionType, ruleParams, globalParamExp);
+ }
+ }
+ for (var i = 0; i < enabledRules.Length; i++)
{
- dictFunc.Add(rule.RuleName, CompileRule(rule,workflow.RuleExpressionType, ruleParams, globalParamExp));
+ dictFunc.Add(enabledRules[i].RuleName, compiledFuncs[i]);
}
_rulesCache.AddOrUpdateCompiledRule(compileRulesKey, dictFunc);
diff --git a/test/RulesEngine.UnitTest/ParallelRuleCompilationTest.cs b/test/RulesEngine.UnitTest/ParallelRuleCompilationTest.cs
new file mode 100644
index 00000000..f5d49017
--- /dev/null
+++ b/test/RulesEngine.UnitTest/ParallelRuleCompilationTest.cs
@@ -0,0 +1,117 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using RulesEngine.Models;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace RulesEngine.UnitTest
+{
+ [ExcludeFromCodeCoverage]
+ public class ParallelRuleCompilationTest
+ {
+ // Larger than the internal MinRulesForParallelCompilation threshold so parallel mode actually engages.
+ private const int RuleCount = 64;
+
+ private static Workflow BuildLargeWorkflow() => new Workflow
+ {
+ WorkflowName = "wf",
+ Rules = Enumerable.Range(0, RuleCount)
+ .Select(i => new Rule { RuleName = $"R{i}", Expression = $"input1 >= {i}" })
+ .ToArray()
+ };
+
+ [Fact]
+ public async Task ParallelCompilation_ProducesIdenticalResultsAsSerial()
+ {
+ var workflow = BuildLargeWorkflow();
+
+ var serialEngine = new RulesEngine(new[] { workflow },
+ new ReSettings { EnableParallelRuleCompilation = false });
+ var parallelEngine = new RulesEngine(new[] { workflow },
+ new ReSettings { EnableParallelRuleCompilation = true });
+
+ var serial = await serialEngine.ExecuteAllRulesAsync(
+ "wf", new[] { RuleParameter.Create("input1", 32) });
+ var parallel = await parallelEngine.ExecuteAllRulesAsync(
+ "wf", new[] { RuleParameter.Create("input1", 32) });
+
+ Assert.Equal(serial.Count, parallel.Count);
+ for (var i = 0; i < serial.Count; i++)
+ {
+ Assert.Equal(serial[i].Rule.RuleName, parallel[i].Rule.RuleName);
+ Assert.Equal(serial[i].IsSuccess, parallel[i].IsSuccess);
+ }
+ }
+
+ [Fact]
+ public async Task ParallelCompilation_PreservesExceptionMessage_WhenRuleExpressionThrows()
+ {
+ // Inject a deliberately broken rule into a large-enough workflow that the parallel
+ // path engages, then assert the per-rule ExceptionMessage explains the underlying
+ // failure rather than leaking an AggregateException.
+ var rules = Enumerable.Range(0, RuleCount)
+ .Select(i => new Rule { RuleName = $"R{i}", Expression = "input1 >= 0" })
+ .ToList();
+ rules[5].Expression = "input1.NoSuchMember.Foo()";
+
+ var workflow = new Workflow { WorkflowName = "wf", Rules = rules };
+ var engine = new RulesEngine(new[] { workflow },
+ new ReSettings { EnableParallelRuleCompilation = true });
+
+ var results = await engine.ExecuteAllRulesAsync(
+ "wf", new[] { RuleParameter.Create("input1", 1) });
+
+ var broken = results.Single(r => r.Rule.RuleName == "R5");
+ Assert.False(broken.IsSuccess);
+ Assert.False(string.IsNullOrEmpty(broken.ExceptionMessage));
+ Assert.DoesNotContain("AggregateException", broken.ExceptionMessage);
+ }
+
+ [Fact]
+ public async Task ParallelCompilation_FallsBackToSerial_WhenFastExpressionCompilerEnabled()
+ {
+ // The flag combination is permitted at construction time (back-compat), but the
+ // engine declines to parallelize and silently uses the serial path. We can only
+ // assert correctness here (results match serial). The fallback itself is
+ // observable in benchmarks, not in functional tests.
+ var engine = new RulesEngine(new[] { BuildLargeWorkflow() },
+ new ReSettings
+ {
+ EnableParallelRuleCompilation = true,
+ UseFastExpressionCompiler = true
+ });
+
+ var results = await engine.ExecuteAllRulesAsync(
+ "wf", new[] { RuleParameter.Create("input1", 100) });
+
+ Assert.Equal(RuleCount, results.Count);
+ Assert.All(results, r => Assert.True(r.IsSuccess));
+ }
+
+ [Fact]
+ public async Task ParallelCompilation_FallsBackToSerial_ForSmallWorkflowsBelowThreshold()
+ {
+ // Below the minimum threshold, the engine declines to parallelize. Verify a
+ // 5-rule workflow still works correctly with the flag enabled.
+ var workflow = new Workflow
+ {
+ WorkflowName = "wf",
+ Rules = Enumerable.Range(0, 5)
+ .Select(i => new Rule { RuleName = $"R{i}", Expression = $"input1 >= {i}" })
+ .ToArray()
+ };
+ var engine = new RulesEngine(new[] { workflow },
+ new ReSettings { EnableParallelRuleCompilation = true });
+
+ var results = await engine.ExecuteAllRulesAsync(
+ "wf", new[] { RuleParameter.Create("input1", 3) });
+
+ Assert.Equal(5, results.Count);
+ // input1=3 means R0..R3 succeed, R4 fails. Same outcome whether serial or parallel.
+ Assert.Equal(4, results.Count(r => r.IsSuccess));
+ }
+ }
+}