Repository navigation
Run CheckRules() as a priority-ordered batch through internal rule runners - #4990
rockfordlhotka wants to merge 7 commits into
Conversation
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>
There was a problem hiding this comment.
🟡 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); |
There was a problem hiding this comment.
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.
…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>
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>
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>


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:ProcessThroughPriority,StopProcessing) still applies per property.CascadeOnDirtyProperties, step 3 repeats until nothing changes, up toMaxRerunPasses(default 100). Past the cap it throwsInvalidOperationExceptionnaming the properties still changing. Before, rules that never settled recursed until aStackOverflowExceptionkilled the process.CheckRules(property)(the property-changed path) andCheckObjectRules()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 ownsCheckRules(),CheckRules(property)andCheckObjectRules().LegacyRuleRunner: the CSLA 10 code, moved out ofBusinessRulesunchanged.BatchRuleRunner: the newCheckRules(). It inherits the other two entry points fromLegacyRuleRunner.IRuleRunnerHost(internal, implemented byBusinessRules) keeps the mechanics:RunRulereads inputs, executes, applies outputs, records broken rules and tracks busy state. The post-completion cascade for async rules is passed in as a handler.AddCsla()and resolved lazily byBusinessRules, so they're still available after deserialization.New public API:
RuleOptionswithUseBatchCheckRules(...)andUseLegacyCheckRules(),BatchCheckRulesOptions.MaxRerunPasses,CslaOptions.RuleOptions, and theCslaOptions.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:Commits
Each commit builds and passes the tests:
IRuleRunnerHost. Refactor only, no behavior change.LegacyRuleRunner. Refactor only, no behavior change. The full suite passed at this commit.BatchRuleRunner(Optimize CheckRules to minimize re-running rules #3736) plusRuleOptions, the resource string and the upgrade doc.Review follow-ups:
RunningRuleson failure. Fixes the two Copilot review findings, each covered by a new test.Tests
CheckRulesBaselineTests(Add baseline tests for BusinessRules.CheckRules() #4981) now runs withUseLegacyCheckRules()and passes unchanged, so the legacy runner still matches CSLA 10.CheckRulesBatchTestscovers 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),MaxRerunPassesconfiguration and validation, last-choice-wins for the options, rerun handling ofDenyAsAffectedPropertyand short-circuited rules, the rule engine staying usable after the cap exception, andCheckRules()after serialization.InvalidOperationExceptionafter 100 passescsla.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.Benchmarksreferences the published Csla 10.1.0 package, so it can't measure this branch.Where this differs from the plan
Csla.Benchmarkscase, for the reason above.Behavior changes (documented in
Upgrading to CSLA 11.md)CheckRules().CheckRules()unless a value changed.CascadeIfDirtyhas no effect inCheckRules().🤖 Generated with Claude Code