From d8087f12825ba58972f815c2b96bcc6946f509d0 Mon Sep 17 00:00:00 2001 From: Ben Luersen Date: Wed, 10 Jun 2026 16:15:05 -0400 Subject: [PATCH 1/2] Add opt-in parallel rule compilation for faster workflow warmup Rule compilation during workflow registration was strictly serial. For workflows with very large rule counts (10k+), warmup is dominated by this loop even after expression parsing is fixed. Adds ReSettings.EnableParallelRuleCompilation (default false). When enabled, rules are compiled with Parallel.For and results are added to the compiled-rule dictionary in the original order. An AggregateException from the parallel loop is unwrapped so the first failing rule surfaces its original exception, preserving the serial error contract (verified by the existing ExecuteRule_MissingMethodInExpression_ReturnsRulesFailed test). Benchmark, 20,000 unique rules with local params: 16.2s serial -> 4.7s parallel on a 16-thread machine. Co-Authored-By: Claude Fable 5 --- src/RulesEngine/Models/ReSettings.cs | 8 ++++++++ src/RulesEngine/RulesEngine.cs | 28 ++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/RulesEngine/Models/ReSettings.cs b/src/RulesEngine/Models/ReSettings.cs index a4a4262c..ef392277 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,13 @@ 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 rules. + /// 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..3e03d5a0 100644 --- a/src/RulesEngine/RulesEngine.cs +++ b/src/RulesEngine/RulesEngine.cs @@ -399,9 +399,33 @@ 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]; + if (_reSettings.EnableParallelRuleCompilation) { - dictFunc.Add(rule.RuleName, CompileRule(rule,workflow.RuleExpressionType, ruleParams, globalParamExp)); + try + { + System.Threading.Tasks.Parallel.For(0, enabledRules.Length, i => { + compiledFuncs[i] = CompileRule(enabledRules[i], workflow.RuleExpressionType, ruleParams, globalParamExp); + }); + } + catch (AggregateException ae) + { + // 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(enabledRules[i].RuleName, compiledFuncs[i]); } _rulesCache.AddOrUpdateCompiledRule(compileRulesKey, dictFunc); From b53bd96f8d79513382305d2d56164e313ce01ee1 Mon Sep 17 00:00:00 2001 From: Yogesh Prajapati Date: Thu, 11 Jun 2026 23:13:59 +0100 Subject: [PATCH 2/2] Guard EnableParallelRuleCompilation and add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #741 by @benluersen. The original opt-in parallel rule compilation is sound but had two latent footguns and no test coverage for the parallel path: 1. UseFastExpressionCompiler interaction (~2.7× regression when both flags are on, per the PR description) — users would flip the flag and silently get slower. The engine now declines to parallelize when UseFastExpressionCompiler = true and falls back to serial. 2. Below ~32 rules, Parallel.For's scheduling overhead exceeds the speedup. Added a MinRulesForParallelCompilation threshold so small workflows aren't penalised by enabling the flag globally. 3. catch (AggregateException ae) accessed ae.InnerExceptions[0] without bounds-checking. Replaced with a `when` filter so the catch only matches when there's actually an inner exception to rethrow. XML doc on ReSettings.EnableParallelRuleCompilation now spells out both fallback conditions so the contract is obvious without reading the implementation. New ParallelRuleCompilationTest covers: - Parallel and serial produce identical RuleResultTree shape and outcomes - The first compile failure surfaces as a per-rule ExceptionMessage, not an AggregateException - UseFastExpressionCompiler + parallel still produces correct results (the fallback is silent, only observable in benchmarks) - Sub-threshold workflows execute correctly with the flag enabled All 174 unit tests pass on net6 / net8 / net9 / net10. Co-authored-by: Ben Luersen --- CHANGELOG.md | 3 + src/RulesEngine/Models/ReSettings.cs | 13 +- src/RulesEngine/RulesEngine.cs | 20 ++- .../ParallelRuleCompilationTest.cs | 117 ++++++++++++++++++ 4 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 test/RulesEngine.UnitTest/ParallelRuleCompilationTest.cs 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 ef392277..db873598 100644 --- a/src/RulesEngine/Models/ReSettings.cs +++ b/src/RulesEngine/Models/ReSettings.cs @@ -102,9 +102,18 @@ internal ReSettings(ReSettings reSettings) /// /// When true, rules within a workflow are compiled in parallel during registration. - /// Significantly reduces warmup time for workflows with many rules. - /// Default: false + /// 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; } diff --git a/src/RulesEngine/RulesEngine.cs b/src/RulesEngine/RulesEngine.cs index 3e03d5a0..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 @@ -401,15 +405,25 @@ private bool RegisterRule(string workflowName, params RuleParameter[] ruleParams var enabledRules = workflow.Rules.Where(c => c.Enabled).ToArray(); var compiledFuncs = new RuleFunc[enabledRules.Length]; - if (_reSettings.EnableParallelRuleCompilation) + + // 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 { - System.Threading.Tasks.Parallel.For(0, enabledRules.Length, i => { + Parallel.For(0, enabledRules.Length, i => { compiledFuncs[i] = CompileRule(enabledRules[i], workflow.RuleExpressionType, ruleParams, globalParamExp); }); } - catch (AggregateException ae) + 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. 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)); + } + } +}