• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

microsoft / RulesEngine / 27382386240

11 Jun 2026 10:50PM UTC coverage: 94.568% (-0.2%) from 94.814%
27382386240

push

github

web-flow
Add opt-in parallel rule compilation (improved on top of #741) (#744)

* 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 <noreply@anthropic.com>

* Guard EnableParallelRuleCompilation and add tests

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 ParallelRu... (continued)

467 of 586 branches covered (79.69%)

14 of 17 new or added lines in 1 file covered. (82.35%)

853 of 902 relevant lines covered (94.57%)

749.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.46
/src/RulesEngine/RulesEngine.cs
1
// Copyright (c) Microsoft Corporation.
2
// Licensed under the MIT License.
3

4
using FluentValidation;
5
using RulesEngine.Actions;
6
using RulesEngine.Exceptions;
7
using RulesEngine.ExpressionBuilders;
8
using RulesEngine.Extensions;
9
using RulesEngine.HelperFunctions;
10
using RulesEngine.Interfaces;
11
using RulesEngine.Models;
12
using RulesEngine.Validators;
13
using System;
14
using System.Collections.Generic;
15
using System.Linq;
16
using System.Text.RegularExpressions;
17
using System.Threading;
18
using System.Threading.Tasks;
19

20
namespace RulesEngine
21
{
22
    using System.Text.Json;
23
    using System.Text.Json.Nodes;
24

25
    /// <summary>
26
    /// 
27
    /// </summary>
28
    /// <seealso cref="IRulesEngine" />
29
    public class RulesEngine : IRulesEngine
30
    {
31
        #region Variables
32
        private readonly ReSettings _reSettings;
33
        private readonly RulesCache _rulesCache;
34
        private readonly RuleExpressionParser _ruleExpressionParser;
35
        private readonly RuleCompiler _ruleCompiler;
36
        private readonly ActionFactory _actionFactory;
37
        private const string ParamParseRegex = "(\\$\\(.*?\\))";
38

39
        // Below this rule count, Parallel.For's scheduling cost exceeds the speedup from
40
        // distributing CompileRule across threads. See ReSettings.EnableParallelRuleCompilation.
41
        private const int MinRulesForParallelCompilation = 32;
42
        #endregion
43

44
        #region Constructor
45
        public RulesEngine(string[] jsonConfig, ReSettings reSettings = null) : this(reSettings)
148✔
46
        {
47
            var workflow = jsonConfig.Select(item => JsonSerializer.Deserialize<Workflow>(item)).ToArray();
368✔
48
            AddWorkflow(workflow);
148✔
49
        }
140✔
50

51
        public RulesEngine(Workflow[] Workflows, ReSettings reSettings = null) : this(reSettings)
276✔
52
        {
53
            AddWorkflow(Workflows);
276✔
54
        }
260✔
55

56
        public RulesEngine(ReSettings reSettings = null)
528✔
57
        {
58
            _reSettings = reSettings == null ? new ReSettings(): new ReSettings(reSettings);
528✔
59
            if(_reSettings.CacheConfig == null)
528✔
60
            {
61
                _reSettings.CacheConfig = new MemCacheConfig();         
528✔
62
            }
63
            _rulesCache = new RulesCache(_reSettings);
528✔
64
            _ruleExpressionParser = new RuleExpressionParser(_reSettings);
528✔
65
            _ruleCompiler = new RuleCompiler(new RuleExpressionBuilderFactory(_reSettings, _ruleExpressionParser),_reSettings);
528✔
66
            _actionFactory = new ActionFactory(GetActionRegistry(_reSettings));
528✔
67
        }
528✔
68

69
        private IDictionary<string, Func<ActionBase>> GetActionRegistry(ReSettings reSettings)
70
        {
71
            var actionDictionary = GetDefaultActionRegistry();
528✔
72
            var customActions = reSettings.CustomActions ?? new Dictionary<string, Func<ActionBase>>();
528✔
73
            foreach (var customAction in customActions)
1,112✔
74
            {
75
                actionDictionary.Add(customAction);
28✔
76
            }
77
            return actionDictionary;
528✔
78

79
        }
80
        #endregion
81

82
        #region Public Methods
83

84
        /// <summary>
85
        /// This will execute all the rules of the specified workflow
86
        /// </summary>
87
        /// <param name="workflowName">The name of the workflow with rules to execute against the inputs</param>
88
        /// <param name="inputs">A variable number of inputs</param>
89
        /// <returns>List of rule results</returns>
90
        public async ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, params object[] inputs)
91
        {
92
            var ruleParams = new List<RuleParameter>();
316✔
93

94
            for (var i = 0; i < inputs.Length; i++)
1,656✔
95
            {
96
                var input = inputs[i];
512✔
97
                ruleParams.Add(new RuleParameter($"input{i + 1}", input));
512✔
98
            }
99

100
            return await ExecuteAllRulesAsync(workflowName, ruleParams.ToArray());
316✔
101
        }
284✔
102

103
        /// <summary>
104
        /// This will execute all the rules of the specified workflow
105
        /// </summary>
106
        /// <param name="workflowName">The name of the workflow with rules to execute against the inputs</param>
107
        /// <param name="ruleParams">A variable number of rule parameters</param>
108
        /// <returns>List of rule results</returns>
109
        public async ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, params RuleParameter[] ruleParams)
110
        {
111
            return await ExecuteAllRulesAsync(workflowName, ruleParams, default);
476✔
112
        }
440✔
113

114
        /// <summary>
115
        /// This will execute all the rules of the specified workflow with cooperative cancellation.
116
        /// </summary>
117
        /// <param name="workflowName">The name of the workflow with rules to execute against the inputs</param>
118
        /// <param name="ruleParams">The rule parameters</param>
119
        /// <param name="cancellationToken">Token observed between rules and before each action. A single
120
        /// rule's compiled expression is not interrupted mid-evaluation; cancellation is cooperative at
121
        /// rule and action boundaries.</param>
122
        /// <returns>List of rule results</returns>
123
        public async ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken)
124
        {
125
            var sortedRuleParams = ruleParams.ToList();
484✔
126
            sortedRuleParams.Sort((RuleParameter a, RuleParameter b) => string.Compare(a.Name, b.Name));
992✔
127
            var ruleResultList = ValidateWorkflowAndExecuteRule(workflowName, sortedRuleParams.ToArray(), cancellationToken);
484✔
128
            if (_reSettings.AutoExecuteActions)
448✔
129
            {
130
                await ExecuteActionAsync(ruleResultList, cancellationToken);
444✔
131
            }
132
            return ruleResultList;
444✔
133
        }
444✔
134

135
        private async ValueTask ExecuteActionAsync(IEnumerable<RuleResultTree> ruleResultList, CancellationToken cancellationToken = default)
136
        {
137
            foreach (var ruleResult in ruleResultList)
5,100✔
138
            {
139
                cancellationToken.ThrowIfCancellationRequested();
1,988✔
140
                if(ruleResult.ChildResults !=  null)
1,988✔
141
                {
142
                    await ExecuteActionAsync(ruleResult.ChildResults, cancellationToken);
120✔
143
                }
144
                var actionResult = await ExecuteActionForRuleResult(ruleResult, false);
1,988✔
145
                ruleResult.ActionResult = new ActionResult {
1,988✔
146
                    Output = actionResult.Output,
1,988✔
147
                    Exception = actionResult.Exception
1,988✔
148
                };
1,988✔
149
                ThrowIfActionExceptionShouldPropagate(actionResult, ruleResult);
1,988✔
150
            }
1,984✔
151
        }
560✔
152

153
        public async ValueTask<ActionRuleResult> ExecuteActionWorkflowAsync(string workflowName, string ruleName, RuleParameter[] ruleParameters)
154
        {
155
            // Sort to match the cache-key convention used by ExecuteAllRulesAsync.
156
            var sortedRuleParams = ruleParameters.ToList();
104✔
157
            sortedRuleParams.Sort((a, b) => string.Compare(a.Name, b.Name));
108✔
158
            var sortedArr = sortedRuleParams.ToArray();
104✔
159

160
            // Compile the whole workflow once and reuse — was previously recompiled every call,
161
            // which was the hot path the reporter of #471 saw.
162
            if (!RegisterRule(workflowName, sortedArr))
104✔
163
            {
164
                throw new ArgumentException($"Rule config file is not present for the {workflowName} workflow");
4✔
165
            }
166

167
            var compiledRulesCacheKey = GetCompiledRulesKey(workflowName, sortedArr);
96✔
168
            var compiledRules = _rulesCache.GetCompiledRules(compiledRulesCacheKey);
96✔
169
            if (compiledRules == null || !compiledRules.TryGetValue(ruleName, out var compiledRule))
96✔
170
            {
171
                throw new ArgumentException($"Workflow `{workflowName}` does not contain any rule named `{ruleName}`");
4✔
172
            }
173

174
            var extendedRuleParameters = ApplyGlobalParams(compiledRulesCacheKey, sortedArr);
92✔
175
            var resultTree = compiledRule(extendedRuleParameters);
92✔
176
            // Mirror ExecuteAllRulesAsync's behavior: format the per-rule ErrorMessage template
177
            // into ExceptionMessage before any action runs / before returning. See #519.
178
            FormatErrorMessages(new[] { resultTree });
92✔
179
            var actionResult = await ExecuteActionForRuleResult(resultTree, true);
92✔
180
            ThrowIfActionExceptionShouldPropagate(actionResult, resultTree);
92✔
181
            return actionResult;
92✔
182
        }
92✔
183

184
        // Invokes the supplied globals delegate (if any) and appends the results as RuleParameters.
185
        private static RuleParameter[] AppendGlobals(RuleParameter[] ruleParameters, Func<object[], Dictionary<string, object>> globalParamsDelegate)
186
        {
187
            if (globalParamsDelegate == null)
552✔
188
            {
189
                return ruleParameters;
496✔
190
            }
191
            var inputs = ruleParameters.Select(c => c.Value).ToArray();
104✔
192
            var evaluated = globalParamsDelegate(inputs);
56✔
193
            var globals = evaluated.Select(kv => new RuleParameter(kv.Key, kv.Value));
108✔
194
            return ruleParameters.Concat(globals).ToArray();
52✔
195
        }
196

197
        private void ThrowIfActionExceptionShouldPropagate(ActionRuleResult actionResult, RuleResultTree resultTree)
198
        {
199
            if (actionResult?.Exception == null)
2,080!
200
            {
201
                return;
2,068✔
202
            }
203
            if (_reSettings.IgnoreException || _reSettings.EnableExceptionAsErrorMessage)
12✔
204
            {
205
                return;
8✔
206
            }
207
            actionResult.Exception.Data[nameof(Rule.RuleName)] = resultTree?.Rule?.RuleName;
4!
208
            throw actionResult.Exception;
4✔
209
        }
210

211
        private async ValueTask<ActionRuleResult> ExecuteActionForRuleResult(RuleResultTree resultTree, bool includeRuleResults = false)
212
        {
213
            var ruleActions = resultTree?.Rule?.Actions;
2,080!
214
            var actionInfo = resultTree?.IsSuccess == true ? ruleActions?.OnSuccess : ruleActions?.OnFailure;
2,080!
215

216
            if (actionInfo != null)
2,080✔
217
            {
218
                var action = _actionFactory.Get(actionInfo.Name);
108✔
219
                var ruleParameters = resultTree.Inputs.Select(kv => new RuleParameter(kv.Key, kv.Value)).ToArray();
200✔
220
                return await action.ExecuteAndReturnResultAsync(new ActionContext(actionInfo.Context, resultTree), ruleParameters, includeRuleResults);
108✔
221
            }
222
            else
223
            {
224
                //If there is no action,return output as null and return the result for rule
225
                return new ActionRuleResult {
1,972✔
226
                    Output = null,
1,972✔
227
                    Results = includeRuleResults ? new List<RuleResultTree>() { resultTree } : null
1,972✔
228
                };
1,972✔
229
            }
230
        }
2,080✔
231

232
        #endregion
233

234
        #region Private Methods
235

236
        /// <summary>
237
        /// Adds the workflow if the workflow name is not already added. Ignores the rest.
238
        /// </summary>
239
        /// <param name="workflows">The workflow rules.</param>
240
        /// <exception cref="RuleValidationException"></exception>
241
        public void AddWorkflow(params Workflow[] workflows)
242
        {
243
            try
244
            {
245
                foreach (var workflow in workflows)
4,020✔
246
                {                    
247
                    var validator = new WorkflowsValidator();
1,472✔
248
                    validator.ValidateAndThrow(workflow);
1,472✔
249
                    if (!_rulesCache.ContainsWorkflows(workflow.WorkflowName))
1,448✔
250
                    {
251
                        _rulesCache.AddOrUpdateWorkflows(workflow.WorkflowName, workflow);
1,444✔
252
                    }
253
                    else
254
                    {
255
                        throw new ValidationException($"Cannot add workflow `{workflow.WorkflowName}` as it already exists. Use `AddOrUpdateWorkflow` to update existing workflow");
4✔
256
                    }
257
                }
258
            }
524✔
259
            catch (ValidationException ex)
28✔
260
            {
261
                throw new RuleValidationException(ex.Message, ex.Errors);
28✔
262
            }
263
        }
524✔
264

265
        /// <summary>
266
        /// Adds new workflow rules if not previously added.
267
        /// Or updates the rules for an existing workflow.
268
        /// </summary>
269
        /// <param name="workflows">The workflow rules.</param>
270
        /// <exception cref="RuleValidationException"></exception>
271
        public void AddOrUpdateWorkflow(params Workflow[] workflows)
272
        {
273
            try
274
            {
275
                foreach (var workflow in workflows)
56✔
276
                {
277
                    var validator = new WorkflowsValidator();
16✔
278
                    validator.ValidateAndThrow(workflow);
16✔
279
                    _rulesCache.AddOrUpdateWorkflows(workflow.WorkflowName, workflow);
8✔
280
                }
281
            }
8✔
282
            catch (ValidationException ex)
8✔
283
            {
284
                throw new RuleValidationException(ex.Message, ex.Errors);
8✔
285
            }
286
        }
8✔
287

288
        public List<string> GetAllRegisteredWorkflowNames()
289
        {
290
            return _rulesCache.GetAllWorkflowNames();
8✔
291
        }
292

293
        /// <summary>
294
        /// Checks is workflow exist.
295
        /// </summary>
296
        /// <param name="workflowName">The workflow name.</param>
297
        /// <returns> <c>true</c> if contains the specified workflow name; otherwise, <c>false</c>.</returns>
298
        public bool ContainsWorkflow(string workflowName)
299
        {
300
            return _rulesCache.ContainsWorkflows(workflowName);
8✔
301
        }
302

303
        /// <summary>
304
        /// Clears the workflow.
305
        /// </summary>
306
        public void ClearWorkflows()
307
        {
308
            _rulesCache.Clear();
8✔
309
        }
8✔
310

311
        /// <summary>
312
        /// Removes the workflows.
313
        /// </summary>
314
        /// <param name="workflowNames">The workflow names.</param>
315
        public void RemoveWorkflow(params string[] workflowNames)
316
        {
317
            foreach (var workflowName in workflowNames)
80✔
318
            {
319
                _rulesCache.Remove(workflowName);
20✔
320
            }
321
        }
20✔
322

323
        /// <summary>
324
        /// This will validate workflow rules then call execute method
325
        /// </summary>
326
        /// <typeparam name="T">type of entity</typeparam>
327
        /// <param name="input">input</param>
328
        /// <param name="workflowName">workflow name</param>
329
        /// <returns>list of rule result set</returns>
330
        private List<RuleResultTree> ValidateWorkflowAndExecuteRule(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken = default)
331
        {
332
            List<RuleResultTree> result;
333

334
            if (RegisterRule(workflowName, ruleParams))
484✔
335
            {
336
                result = ExecuteAllRuleByWorkflow(workflowName, ruleParams, cancellationToken);
460✔
337
            }
338
            else
339
            {
340
                // if rules are not registered with Rules Engine
341
                throw new ArgumentException($"Rule config file is not present for the {workflowName} workflow");
16✔
342
            }
343
            return result;
448✔
344
        }
345

346
        /// <summary>
347
        /// This will compile the rules and store them to dictionary
348
        /// </summary>
349
        /// <param name="workflowName">workflow name</param>
350
        /// <param name="ruleParams">The rule parameters.</param>
351
        /// <returns>
352
        /// bool result
353
        /// </returns>
354
        private bool RegisterRule(string workflowName, params RuleParameter[] ruleParams)
355
        {
356
            var compileRulesKey = GetCompiledRulesKey(workflowName, ruleParams);
588✔
357
            if (_rulesCache.AreCompiledRulesUpToDate(compileRulesKey, workflowName))
588✔
358
            {
359
                return true;
52✔
360
            }
361

362
            var workflow = _rulesCache.GetWorkflow(workflowName);
536✔
363
            if (workflow != null)
536✔
364
            {
365
                var dictFunc = new Dictionary<string, RuleFunc<RuleResultTree>>();
516✔
366
                if (_reSettings.AutoRegisterInputType)
516✔
367
                {
368
                    var collector = new HashSet<Type>(_reSettings.CustomTypes.Safe());
512✔
369

370
                    foreach (var rp in ruleParams)
2,416✔
371
                    {
372
                        CollectAllElementTypes(rp.Type, collector);
696✔
373
                    }
374

375
                    _reSettings.CustomTypes = collector.ToArray();
512✔
376
                }
377

378
                // Compile global params ONCE per workflow registration. The resulting delegate is
379
                // invoked once per ExecuteAllRulesAsync call (in ExecuteAllRuleByWorkflow) and its
380
                // results passed as extra RuleParameters to each rule. See #714.
381
                RuleExpressionParameter[] globalParamValues;
382
                try
383
                {
384
                    globalParamValues = _ruleCompiler.GetRuleExpressionParameters(workflow.RuleExpressionType, workflow.GlobalParams, ruleParams);
516✔
385
                }
508✔
386
                catch (Exception ex)
8✔
387
                {
388
                    // Mirror the legacy per-rule error reporting when global-param compilation fails.
389
                    foreach (var rule in workflow.Rules.Where(c => c.Enabled))
52✔
390
                    {
391
                        var msg = $"Error while compiling rule `{rule.RuleName}`: {ex.Message}";
12✔
392
                        dictFunc.Add(rule.RuleName, Helpers.ToRuleExceptionResult(_reSettings, rule, new RuleException(msg, ex)));
12✔
393
                    }
394
                    _rulesCache.AddOrUpdateCompiledRule(compileRulesKey, dictFunc);
8✔
395
                    return true;
8✔
396
                }
397

398
                var globalParamExp = new Lazy<RuleExpressionParameter[]>(() => globalParamValues);
1,016✔
399

400
                if (globalParamValues.Length > 0)
508✔
401
                {
402
                    var globalParamsDelegate = _ruleCompiler.CompileScopedParams(workflow.RuleExpressionType, ruleParams, globalParamValues);
56✔
403
                    _rulesCache.AddOrUpdateGlobalParamsDelegate(compileRulesKey, globalParamsDelegate);
56✔
404
                }
405

406
                var enabledRules = workflow.Rules.Where(c => c.Enabled).ToArray();
2,412✔
407
                var compiledFuncs = new RuleFunc<RuleResultTree>[enabledRules.Length];
508✔
408

409
                // Parallel compilation helps only when:
410
                //   - the user opted in,
411
                //   - they're not also on UseFastExpressionCompiler (which regresses ~3× under
412
                //     parallel contention; FEC's internal locking serializes effort), and
413
                //   - there are enough rules to amortize Parallel.For's scheduling cost.
414
                var shouldParallelize = _reSettings.EnableParallelRuleCompilation
508✔
415
                                     && !_reSettings.UseFastExpressionCompiler
508✔
416
                                     && enabledRules.Length >= MinRulesForParallelCompilation;
508✔
417

418
                if (shouldParallelize)
508✔
419
                {
420
                    try
421
                    {
422
                        Parallel.For(0, enabledRules.Length, i => {
8✔
423
                            compiledFuncs[i] = CompileRule(enabledRules[i], workflow.RuleExpressionType, ruleParams, globalParamExp);
512✔
424
                        });
520✔
425
                    }
8✔
NEW
426
                    catch (AggregateException ae) when (ae.InnerExceptions.Count > 0)
×
427
                    {
428
                        // Preserve the serial-compilation contract: the first rule that fails
429
                        // to compile surfaces its own exception, not an AggregateException.
NEW
430
                        System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ae.InnerExceptions[0]).Throw();
×
NEW
431
                    }
×
432
                }
433
                else
434
                {
435
                    for (var i = 0; i < enabledRules.Length; i++)
3,696✔
436
                    {
437
                        compiledFuncs[i] = CompileRule(enabledRules[i], workflow.RuleExpressionType, ruleParams, globalParamExp);
1,360✔
438
                    }
439
                }
440
                for (var i = 0; i < enabledRules.Length; i++)
4,704✔
441
                {
442
                    dictFunc.Add(enabledRules[i].RuleName, compiledFuncs[i]);
1,856✔
443
                }
444

445
                _rulesCache.AddOrUpdateCompiledRule(compileRulesKey, dictFunc);
496✔
446
                return true;
496✔
447
            }
448
            else
449
            {
450
                return false;
20✔
451
            }
452
        }
8✔
453

454

455
        private RuleFunc<RuleResultTree> CompileRule(Rule rule, RuleExpressionType ruleExpressionType, RuleParameter[] ruleParams, Lazy<RuleExpressionParameter[]> scopedParams)
456
        {
457
            return _ruleCompiler.CompileRule(rule, ruleExpressionType, ruleParams, scopedParams);
1,872✔
458
        }
459

460
        private static void CollectAllElementTypes(Type t, ISet<Type> collector)
461
        {
462
            if (t == null || collector.Contains(t))
848✔
463
                return;
200✔
464

465
            collector.Add(t);
648✔
466

467
            if (t.IsGenericType)
648✔
468
            {
469
                foreach (var ga in t.GetGenericArguments())
504✔
470
                    CollectAllElementTypes(ga, collector);
152✔
471
            }
472

473
            if (t.IsArray)
648!
474
            {
475
                CollectAllElementTypes(t.GetElementType(), collector);
×
476
            }
477

478
            if (Nullable.GetUnderlyingType(t) is Type underly && !collector.Contains(underly))
648!
479
            {
480
                CollectAllElementTypes(underly, collector);
×
481
            }
482
        }
648✔
483

484
        /// <summary>
485
        /// This will execute the compiled rules 
486
        /// </summary>
487
        /// <param name="workflowName"></param>
488
        /// <param name="ruleParams"></param>
489
        /// <returns>list of rule result set</returns>
490
        private List<RuleResultTree> ExecuteAllRuleByWorkflow(string workflowName, RuleParameter[] ruleParameters, CancellationToken cancellationToken = default)
491
        {
492
            var result = new List<RuleResultTree>();
460✔
493
            var compiledRulesCacheKey = GetCompiledRulesKey(workflowName, ruleParameters);
460✔
494
            var compiledRules = _rulesCache.GetCompiledRules(compiledRulesCacheKey);
460✔
495

496
            RuleParameter[] extendedRuleParameters;
497
            Exception globalEvaluationException = null;
460✔
498
            try
499
            {
500
                extendedRuleParameters = ApplyGlobalParams(compiledRulesCacheKey, ruleParameters);
460✔
501
            }
456✔
502
            catch (Exception ex)
503
            {
504
                globalEvaluationException = ex;
4✔
505
                extendedRuleParameters = ruleParameters;
4✔
506
            }
4✔
507

508
            var ruleByName = new Dictionary<string, Rule>();
460✔
509
            foreach (var rule in _rulesCache.GetWorkflow(workflowName)?.Rules?.Where(c => c.Enabled) ?? Enumerable.Empty<Rule>())
6,232!
510
            {
511
                ruleByName[rule.RuleName] = rule;
1,760✔
512
            }
513

514
            foreach (var compiledRule in compiledRules)
4,388✔
515
            {
516
                cancellationToken.ThrowIfCancellationRequested();
1,740✔
517
                RuleResultTree resultTree;
518
                if (globalEvaluationException != null && ruleByName != null && ruleByName.TryGetValue(compiledRule.Key, out var rule))
1,736✔
519
                {
520
                    var msg = $"Error while executing scoped params for rule `{rule.RuleName}` - {globalEvaluationException.Message}";
8✔
521
                    var errFn = Helpers.ToRuleExceptionResult(_reSettings, rule, new RuleException(msg, globalEvaluationException));
8✔
522
                    resultTree = errFn(ruleParameters);
8✔
523
                }
524
                else
525
                {
526
                    resultTree = compiledRule.Value(extendedRuleParameters);
1,728✔
527
                }
528
                result.Add(resultTree);
1,728✔
529
            }
530

531
            FormatErrorMessages(result);
448✔
532
            return result;
448✔
533
        }
534

535
        private RuleParameter[] ApplyGlobalParams(string compiledRulesCacheKey, RuleParameter[] ruleParameters)
536
        {
537
            return AppendGlobals(ruleParameters, _rulesCache.GetGlobalParamsDelegate(compiledRulesCacheKey));
552✔
538
        }
539

540
        private string GetCompiledRulesKey(string workflowName, RuleParameter[] ruleParams)
541
        {
542
            var ruleParamsKey = string.Join("-", ruleParams.Select(c => $"{c.Name}_{c.Type.Name}"));
2,684✔
543
            var key = $"{workflowName}-" + ruleParamsKey;
1,144✔
544
            return key;
1,144✔
545
        }
546

547
        private IDictionary<string, Func<ActionBase>> GetDefaultActionRegistry()
548
        {
549
            return new Dictionary<string, Func<ActionBase>>{
528✔
550
                {"OutputExpression",() => new OutputExpressionAction(_ruleExpressionParser) },
56✔
551
                {"EvaluateRule", () => new EvaluateRuleAction(this,_ruleExpressionParser) }
24✔
552
            };
528✔
553
        }
554

555
        /// <summary>
556
        /// The result
557
        /// </summary>
558
        /// <param name="ruleResultList">The result.</param>
559
        /// <returns>Updated error message.</returns>
560
        private IEnumerable<RuleResultTree> FormatErrorMessages(IEnumerable<RuleResultTree> ruleResultList)
561
        {
562
            if (_reSettings.EnableFormattedErrorMessage)
540✔
563
            {
564
                foreach (var ruleResult in ruleResultList?.Where(r => !r.IsSuccess))
4,012!
565
                {
566
                    var errorMessage = ruleResult?.Rule?.ErrorMessage;
556!
567
                    if (string.IsNullOrWhiteSpace(ruleResult.ExceptionMessage) && errorMessage != null)
556✔
568
                    {
569
                        var errorParameters = Regex.Matches(errorMessage, ParamParseRegex);
80✔
570

571
                        var inputs = ruleResult.Inputs;
80✔
572
                        foreach (var param in errorParameters)
320✔
573
                        {
574
                            var paramVal = param?.ToString();
80!
575
                            var property = paramVal?.Substring(2, paramVal.Length - 3);
80!
576
                            if (property?.Split('.')?.Count() > 1)
80!
577
                            {
578
                                errorMessage = UpdateErrorMessage(errorMessage, inputs, property);
64✔
579
                            }
580
                            else
581
                            {
582
                                var arrParams = inputs?.Select(c => new { Name = c.Key, c.Value });
80!
583
                                var model = arrParams?.Where(a => string.Equals(a.Name, property))?.FirstOrDefault();
80!
584
                                var value = model?.Value != null ? JsonSerializer.Serialize(model?.Value) : null;
16!
585
                                errorMessage = errorMessage?.Replace($"$({property})", value ?? $"$({property})");
16!
586
                            }
587
                        }
588
                        ruleResult.ExceptionMessage = errorMessage;
80✔
589
                    }
590

591
                }
592
            }
593
            return ruleResultList;
540✔
594
        }
595

596
        /// <summary>
597
        /// Resolves a dotted-path placeholder like $(input1.Inner.Name) against the rule inputs,
598
        /// walking arbitrary depth. See #696.
599
        /// </summary>
600
        private static string UpdateErrorMessage(string errorMessage, IDictionary<string, object> inputs, string property)
601
        {
602
            var segments = property.Split('.');
64✔
603
            var typeName = segments[0];
64✔
604
            var model = inputs?.FirstOrDefault(c => string.Equals(c.Key, typeName));
336!
605
            if (model?.Value == null)
64!
606
            {
607
                return errorMessage;
8✔
608
            }
609

610
            var modelJson = JsonSerializer.Serialize(model.Value.Value);
56✔
611
            JsonNode current = JsonNode.Parse(modelJson);
56✔
612
            for (var i = 1; i < segments.Length && current != null; i++)
232✔
613
            {
614
                current = current is JsonObject jObj && jObj.TryGetPropertyValue(segments[i], out var next)
60!
615
                    ? next
60✔
616
                    : null;
60✔
617
            }
618

619
            if (current == null)
56!
620
            {
621
                return errorMessage;
×
622
            }
623

624
            // JsonValue (leaf scalar) should render without quotes; objects/arrays render as JSON.
625
            var replacement = current is JsonValue v && v.TryGetValue<string>(out var stringValue)
56✔
626
                ? stringValue
56✔
627
                : current.ToString();
56✔
628
            return errorMessage.Replace($"$({property})", replacement);
56✔
629
        }
630
        #endregion
631
    }
632
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc