Skip to content

Run CheckRules() as a priority-ordered batch through internal rule runners - #4990

Open
rockfordlhotka wants to merge 7 commits into
mainfrom
issue-3736-4235/rule-runners
Open

rockfordlhotka wants to merge 7 commits into
mainfrom
issue-3736-4235/rule-runners

Conversation

@rockfordlhotka

@rockfordlhotka rockfordlhotka commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Closes #3736
Closes #4235

Implements the plan in #4978. The baseline tests from #4981 are the safety net.

What changes

BusinessRules.CheckRules() / CheckRulesAsync() now runs rules as a batch:

  1. Object rules run first, as before.
  2. Every property rule runs once, in priority order across all properties (CheckRules[Async] should respect rule priority to run rules #4235). Ties keep the CSLA 10 order. Short-circuiting (ProcessThroughPriority, StopProcessing) still applies per property.
  3. Only property groups invalidated by values that rules changed through output values rerun: the changed properties' own rules and the rules that use them as inputs (Optimize CheckRules to minimize re-running rules #3736).
  4. With CascadeOnDirtyProperties, step 3 repeats until nothing changes, up to MaxRerunPasses (default 100). Past the cap it throws InvalidOperationException naming the properties still changing. Before, rules that never settled recursed until a StackOverflowException killed the process.

CheckRules(property) (the property-changed path) and CheckObjectRules() are unchanged.

How it's built

As the plan describes, rule execution is split into policy and mechanics:

  • IRuleRunner (internal) decides which rules run, in what order, when to rerun and when to stop. It owns CheckRules(), CheckRules(property) and CheckObjectRules().
    • LegacyRuleRunner: the CSLA 10 code, moved out of BusinessRules unchanged.
    • BatchRuleRunner: the new CheckRules(). It inherits the other two entry points from LegacyRuleRunner.
  • IRuleRunnerHost (internal, implemented by BusinessRules) keeps the mechanics: RunRule reads inputs, executes, applies outputs, records broken rules and tracks busy state. The post-completion cascade for async rules is passed in as a handler.
  • Runners keep no state between calls. They're registered as singletons in AddCsla() and resolved lazily by BusinessRules, so they're still available after deserialization.

New public API: RuleOptions with UseBatchCheckRules(...) and UseLegacyCheckRules(), BatchCheckRulesOptions.MaxRerunPasses, CslaOptions.RuleOptions, and the CslaOptions.Rules(...) extension. Each method takes only the settings for that behavior, so no setting is silently ignored by the chosen runner; the last call wins:

services.AddCsla(o => o.Rules(r => r.UseBatchCheckRules(b => b.MaxRerunPasses = 200)));   // default; MaxRerunPasses defaults to 100
services.AddCsla(o => o.Rules(r => r.UseLegacyCheckRules()));                            // CSLA 10 behavior, temporary

Commits

Each commit builds and passes the tests:

  1. Extract single-rule execution behind IRuleRunnerHost. Refactor only, no behavior change.
  2. Move rule execution policy into LegacyRuleRunner. Refactor only, no behavior change. The full suite passed at this commit.
  3. BatchRuleRunner (Optimize CheckRules to minimize re-running rules #3736) plus RuleOptions, the resource string and the upgrade doc.
  4. Priority order across properties (CheckRules[Async] should respect rule priority to run rules #4235). Changes only the sort key, so it can be reverted on its own.

Review follow-ups:

  1. Keep excluded rules' results on rerun, and reset RunningRules on failure. Fixes the two Copilot review findings, each covered by a new test.
  2. Make the options specific to the chosen behavior (Stefan Ossendorf's review of Design doc for batch and priority-ordered CheckRules #4978).

Tests

  • CheckRulesBaselineTests (Add baseline tests for BusinessRules.CheckRules() #4981) now runs with UseLegacyCheckRules() and passes unchanged, so the legacy runner still matches CSLA 10.
  • New CheckRulesBatchTests covers the same scenarios against the default runner, so the two classes show each behavior change side by side. It adds the cycle cap (the never-settling cycle that crashes the legacy runner now throws), MaxRerunPasses configuration and validation, last-choice-wins for the options, rerun handling of DenyAsAffectedProperty and short-circuited rules, the rule engine staying usable after the cap exception, and CheckRules() after serialization.
Scenario Legacy rule runs Batch rule runs
#3736 credit-hold scenario, cascade on / off 24 / 21 9 / 9
Increment chain N1→N2→N3→N4, cascade on / off 6 / 5 5 / 5
Rule on P with Q as an affected property 4 3
Rules that use K as an input 4 3
Never-settling cycle, cascade on stack overflow InvalidOperationException after 100 passes
  • Full CI suite (csla.test.sln, TestCategory!=SkipOnCIServer) passes on net10.0 and net11.0, after the refactor commits and on the final commit.

Measurements

These come from a throwaway timing test (not committed) on a 20-property object with 104 rules (Release, net10.0, average per CheckRules() call). Csla.Benchmarks references the published Csla 10.1.0 package, so it can't measure this branch.

Values Legacy Batch
Already consistent 619 rule runs, ~330–450 µs 104 runs, ~75 µs
All zero (rules change 19 values) 619 runs (cascade off) / 1,083 runs (cascade on), ~340–740 µs 208 runs, ~115–145 µs

Where this differs from the plan

  • Step 6 (skip reruns for rules that already ran after a value changed) isn't included. The plan made it conditional on the benchmark. On the 104-rule object it saved no rule runs and was 10–20% slower, because there the reruns are real dependencies. It helps in narrower shapes, such as a chain registered in order or an object rule that sets a value. The code is on a local branch if real-world objects turn out to need it.
  • No Csla.Benchmarks case, for the reason above.

Behavior changes (documented in Upgrading to CSLA 11.md)

  • Cross-property order follows priority.
  • Reruns depend on values changed through output values. A rule that changes a property some other way no longer triggers dependent reruns in CheckRules().
  • Affected properties' rules don't rerun in CheckRules() unless a value changed.
  • CascadeIfDirty has no effect in CheckRules().
  • Rules that never settle throw instead of overflowing the stack.

🤖 Generated with Claude Code

rockfordlhotka and others added 4 commits November 5, 2026 10:29
Move the per-rule body of BusinessRules.RunRules into
IRuleRunnerHost.RunRule, which returns a RuleRunOutcome (affected and
dirty property names, broken error, stop processing). The cascade that
runs after an async rule completes is now passed in as a handler, so
the host owns the mechanics and the caller owns the policy.

No behavior change: RunRules keeps the same short-circuit loop over
RunRule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the internal IRuleRunner interface and move today's CheckRules(),
CheckRules(property) and CheckObjectRules() logic, including the
recursive CheckRulesForProperty cascade and the RunRules short-circuit
loop, from BusinessRules into LegacyRuleRunner. BusinessRules keeps the
suppress/RunningRules/AllRulesComplete bookkeeping and delegates to the
runner, which it resolves lazily from DI so it is available again after
deserialization. AddCsla() registers LegacyRuleRunner as a singleton.

No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add BatchRuleRunner and make it the default rule runner. CheckRules()
now runs object rules first, then every property rule once with no
cascade, then reruns only the property groups invalidated by values
that rules changed through output values (the changed properties' own
rules and the rules that use them as inputs). With
CascadeOnDirtyProperties, rerun passes repeat until nothing changes.
Short-circuiting stays per property. CheckRules(property) and
CheckObjectRules() keep the legacy behavior.

Add RuleOptions (CslaOptions.Rules(...)):
- UseLegacyCheckRules: registers LegacyRuleRunner instead (temporary
  opt-out to the CSLA 10 behavior).
- MaxCheckRulesPasses (default 100): rerun passes allowed before
  CheckRules() throws InvalidOperationException naming the properties
  still changing. Rules that never settle used to overflow the stack.

The baseline tests now pin the legacy runner and still pass unchanged.
CheckRulesBatchTests covers the same scenarios for the batch runner plus
the cycle cap and post-deserialization use. In the #3736 credit-hold
scenario the batch runs 9 rules where the legacy runner runs 24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Change the batch runner's run order to (priority, property, registration)
so CheckRules() honors rule priority across all properties, not only
within a property. Rules with the same priority keep the CSLA 10 order.
Short-circuiting stays per property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Rerun passes can erase validation errors and incorrectly mark objects valid, while cascade-limit exceptions leave RunningRules set and suppress subsequent completion notifications.

2 open issues
1 file not reviewed
  • Source/Csla/Properties/Resources.Designer.cs: Generated file
What changed in this PR

This PR changes CSLA’s full-object rule checks to priority-ordered batches, reducing redundant execution for interdependent rules while retaining a legacy compatibility option.

Changes:

  • Separates rule-execution policy into internal runners and keeps execution mechanics in BusinessRules.
  • Adds dirty-value-driven reruns, a configurable cascade-pass limit, and cross-property priority ordering.
  • Adds batch behavior tests, preserves legacy baseline tests, and documents migration considerations.
File Description
Source/​tests/​Csla.test/​ValidationRules/​CheckRulesBatchTests.cs Tests batch ordering, reruns, limits, and serialization.
Source/​tests/​Csla.test/​ValidationRules/​CheckRulesBaselineTests.cs Runs existing baselines with legacy behavior enabled.
Source/​Csla/​Rules/​RuleRunners/​LegacyRuleRunner.cs Extracts legacy rule-execution policy.
Source/​Csla/​Rules/​RuleRunners/​IRuleRunnerHost.cs Defines single-rule execution and outcome contracts.
Source/​Csla/​Rules/​RuleRunners/​IRuleRunner.cs Defines internal runner entry points.
Source/​Csla/​Rules/​RuleRunners/​BatchRuleRunner.cs Implements priority-ordered batches and bounded reruns.
Source/​Csla/​Rules/​BusinessRules.cs Delegates policy to runners and retains execution mechanics.
Source/​Csla/​Properties/​Resources.resx Adds the cascade-limit error message.
Source/​Csla/​Properties/​Resources.Designer.cs Exposes the new resource string.
Source/​Csla/​Configuration/​Fluent/​RuleOptions.cs Adds legacy-mode and cascade-limit settings.
Source/​Csla/​Configuration/​Fluent/​RuleConfigurationExtensions.cs Adds fluent rule configuration.
Source/​Csla/​Configuration/​Fluent/​CslaOptions.cs Exposes rule options.
Source/​Csla/​Configuration/​ConfigurationExtensions.cs Registers the selected runner as a singleton.
docs/​Upgrading to CSLA 11.md Documents behavior changes and compatibility settings.

Review effort: Balanced

var states = new Dictionary<IPropertyInfo, GroupState>(groups.Count);
foreach (var property in groups)
{
host.ClearBrokenRules(property);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 823eba4.

A failing test reproduced it: a rule that changes its own property forces a rerun, and a failing validator on that property is marked DenyAsAffectedProperty. Pass 2 cleared the validator's error without rerunning it, and the object reported itself valid.

Rerun passes now keep the broken rules of rules that can't run in the rerun mode, and clear everything else in the group as before. So results from rules that are short-circuited on the rerun still get removed. The new BrokenRulesCollection.ClearRules(property, keepRuleNames) does this. Pass 1 still clears everything, matching the legacy runner.

Tests:

  • CheckRules_RerunKeepsResultsOfRulesThatDenyRunningAsAffectedProperty: the error is kept.
  • CheckRules_RerunClearsResultsOfShortCircuitedRules: a rule short-circuited on the rerun has its old error removed.

LegacyRuleRunner has the same clear-then-skip pattern in its cascades (CheckRulesForProperty clears the property's broken rules before filtering by CanRunRule), including the property-changed path. That's existing CSLA 10 behavior: there it only appears depending on rule order, while the batch rerun pass made it happen every time. I left the legacy runner unchanged because it's meant to reproduce CSLA 10 exactly. The property-changed path is worth a separate issue.

Comment thread Source/Csla/Rules/BusinessRules.cs Resolved
…ilure

Address review feedback on the batch runner:

- A rerun pass cleared all broken rules for a property group, then ran
  it in CheckRules | AsAffectedProperty mode, where rules marked
  DenyAsAffectedProperty are skipped. Their errors from pass 1 were lost
  and the object could look valid. Rerun passes now keep the broken
  rules of rules that can't run in the rerun mode, and still clear the
  rest, so results of rules short-circuited on the rerun go away as
  before. Adds BrokenRulesCollection.ClearRules(property, keepRuleNames).

- CheckRules(), CheckRules(property) and CheckObjectRules() now restore
  RunningRules in a finally block, so the cascade-limit exception (or any
  other failure) doesn't leave RunningRules set and suppress later
  AllRulesComplete/ValidationComplete notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Address review feedback that a flat MaxCheckRulesPasses option is
confusing when the legacy behavior, which ignores it, is selected.

RuleOptions now chooses the CheckRules() behavior with methods, each
taking only the settings that apply to it:

  o.Rules(r => r.UseBatchCheckRules(b => b.MaxRerunPasses = 200));  // default
  o.Rules(r => r.UseLegacyCheckRules());                            // CSLA 10, temporary

MaxCheckRulesPasses moves to BatchCheckRulesOptions.MaxRerunPasses. If
both methods are called, the last call wins. The cascade-limit message
and the upgrade doc use the new names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rockfordlhotka added a commit that referenced this pull request Nov 6, 2026
Address review feedback on the plan:

- Spell out the pass-2 rerun set in words before the set notation, and
  use ':' for "such that" so the set-builder bar isn't read as "or".
- Rule options are now per behavior: UseBatchCheckRules(b => ...) with
  BatchCheckRulesOptions.MaxRerunPasses, and UseLegacyCheckRules(), so
  no setting is silently ignored by the chosen runner. Matches #4990.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rockfordlhotka added a commit that referenced this pull request Nov 6, 2026
Rewrite the plan as a record of the implementation in #4990: the actual
IRuleRunner/IRuleRunnerHost interfaces and where the bookkeeping lives,
the batch algorithm including the rerun handling of rules excluded in
AsAffectedProperty mode, how the work was delivered (#4981 plus one
commit per step in #4990), the measurements, why step 6 (skipping stale
reruns) was measured but not merged, the final options design, what is
and isn't in place for priority-ordered async execution, and the
follow-ups (#4991, the legacy clear-then-skip cascade, CascadeIfDirty,
the ignored CascadeTests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CheckRules() returns affected property names but the framework doesn't raise PropertyChanged for them, so don't claim it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CheckRules[Async] should respect rule priority to run rules Optimize CheckRules to minimize re-running rules

2 participants