BusinessRuleAsync and CSLA0018
brinawebb opened this issue · comments
Hi everyone. I'm trying to define an async business rule and am having a bit of trouble. If I follow the code in the samples for AsyncRule I get an analyzer error saying that I should remove the context.Complete(). If I remove context.Complete() the rule doesn't seem to complete and my unit tests fail. Curious if the sample is correct and if not, if anyone has a sample class that shows how to use these? Here's the sample code from Csla Samples with the error.
Version and Platform
CSLA version: 5.1.0
OS: Windows
Platform: .NET Standard 2.0
What you're getting here is an analyzer error - specifically, this one: https://github.com/MarimerLLC/csla/blob/master/docs/analyzers/CSLA0018-IsCompleteCalledInAsynchronousBusinessRuleAnalyzer.md. @rockfordlhotka and I talked about this, and my understanding is that Complete()
should not be called in ExecuteAsync()
.
Now, if removing Complete()
is causing an issue with the rule itself, you can "Configure or Suppress issues" for now, or set the severity level of the rule in an .editorconfig for now. Then you won't get the error. Hopefully @rockfordlhotka can clarify what's going on here.
@brinawebb you shouldn't need to call Complete
in an ExecuteAsync
method.
For example, this works:
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Csla;
using Csla.Rules;
namespace ConsoleApp5
{
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine("Starting");
var obj = await DataPortal.CreateAsync<Test>();
Console.WriteLine("non-async results:");
foreach (var item in obj.BrokenRulesCollection)
Console.WriteLine(item.Description);
Console.WriteLine();
obj.Name = "someone";
Console.WriteLine("non-async property changed results:");
foreach (var item in obj.BrokenRulesCollection)
Console.WriteLine(item.Description);
Console.WriteLine();
while (obj.IsBusy)
await Task.Delay(10);
Console.WriteLine("after isbusy wait");
Console.WriteLine();
Console.WriteLine("all results:");
foreach (var item in obj.BrokenRulesCollection)
Console.WriteLine(item.Description);
Console.WriteLine();
}
}
[Serializable]
public class Test : BusinessBase<Test>
{
public static readonly PropertyInfo<string> NameProperty = RegisterProperty<string>(nameof(Name));
[Required]
public string Name
{
get => GetProperty(NameProperty);
set => SetProperty(NameProperty, value);
}
protected override void AddBusinessRules()
{
base.AddBusinessRules();
BusinessRules.AddRule(new DelayRule(NameProperty));
}
}
public class DelayRule : BusinessRuleAsync
{
public DelayRule(Csla.Core.IPropertyInfo primaryProperty)
: base(primaryProperty)
{
InputProperties.Add(PrimaryProperty);
}
protected override async Task ExecuteAsync(IRuleContext context)
{
var value = (string)context.InputPropertyValues[PrimaryProperty];
if (!string.IsNullOrWhiteSpace(value))
{
await Task.Delay(2000);
context.AddInformationResult("info is here");
}
}
}
}
@brinawebb is it possible that the rule is firing but the object's IsBusy
is true in your test?
As a side note, doing a spin on IsBusy
feels really dirty to check to see if an async rule has been run.
Hi Rocky.
You have an await Task.Delay(2000); in your code. If you take that out then you'll get a warning that your method lacks an await. Is that OK? I've confirmed that my unit tests are indeed failing if they're set up this way.
Thanks Jason, I'm not using IsBusy in any of my unit tests.
@brinawebb I'm guessing @rockfordlhotka has that Task.Delay()
in his example to simulate asynchronous work. You're right, if you take that out, you get a warning, so the example needs something to do async.
If you don't have an await
in an async methods, my guess is that the async machinery will do optimizations to actually act in a synchronous fashion (the details are pretty complex to get into here). But the whole point of having an async rule is that you want to await asynchronous work and get the advantages of doing that.
Side note @rockfordlhotka, would it be beneficial to have a BusyChanged
event that people can set up a handler on, rather than spinning on IsBusy
? Or does this already exist?
Thanks Jason. It does look like it has something to do with IsBusy? But it's perplexing as I'm not calling it, maybe MSTest is?
Just created a simple unit test with MsTest and added the simple object above. I had to add this code to create the item:
public async static Task<Test> NewTestAsync()
{
Test item = await DataPortal.CreateAsync<Test>();
return item;
}
It fails in the unit test when I try and create the object.
// Exeption below when calling this line
Test theTest = await Test.NewTestAsync();
Assert.IsTrue(theTest.BrokenRulesCollection.Count == 1);
theTest.Name = "ABC123";
Assert.IsTrue(theTest.BrokenRulesCollection.Count == 0);
Test method OptionTests.TestRockyCode threw exception:
Csla.DataPortalException: DataPortal.Create failed (Test.IsBusy == true) ---> System.InvalidOperationException: Test.IsBusy == true
Actually there is a BusyChanged event, I forgot about it...
@brinawebb is there any way you can post an example to a GitHub repo or something similar so we can see the full example?
I suspect the issue is that the object is still busy when the Create method completes, and the data portal won't transport a busy object.
Either don't run the async rule on create, or await BusyChanged before allowing the Create method to complete.
@rockfordlhotka so that makes me wonder, would it be possible to set some configuration on CSLA to say "WaitOnBusy" (or a better name than that) that tells the DataPortal
not to return the object until the object is not busy? Or would that cause far more headaches than it's worth?
Hi @JasonBock and @rockfordlhotka . Thank you very very much for your help! I've created a repo here: https://github.com/brinawebb/TempBizRuleAsync
Start the Test Explorer and run the one unit test that's there. You can toggle between lines 22 and 23 in Person.cs to see the error.
So I ran the code in the repo, and @rockfordlhotka, I think CSLA might have a bug. If you run his code, he's not doing anything to run the rules or not run them. The failure occurs within DataPortal.CreateAsync()
. I don't see what the code could do to stop it, unless I'm missing a switch somewhere.
Bug is such a strong word!! 👀 This behavior is probably not desired, but it is a result of the implementation - does that make it a bug?
The question then, is how to best address it from
- What do people do today
- What changes could/should happen to CSLA to make this better in the future
Today
Today the answer is to not let the async rule run when the object is being created. Possibilities:
- Don't have rules run at all during creation
- Don't call
base.DataPortal_Create()
b/c that's what runs rules during creation - Use
BypassPropertyChecks
in your create method to prevent per-property rule invocation
- Don't call
- Prevent the specific rule from running during creation
- Add a check for
IsNew
in your rule or default value (like in my example code) - Use short-circuiting to prevent the rule from running due to
IsNew
or default value
- Add a check for
Future enhancement
The cleanest answer might be to have the data portal wait, after your operation method completes, until the object isn't busy.
I don't think this is so easy to do, because we're really talking about the entire object graph, not just the root object. I don't recall if we currently have an aggregated IsBusy
vs IsSelfBusy
concept, but I don't think so. That concept might be required if it doesn't exist - because really the data portal would need to wait until the entire graph is not busy.
Also we'd need a timeout, because (due a bug or whatever) it is possible that some rule might never complete, and we can't leave a server task/thread tied up forever. This would need a setting and a default (30 secs? 90 secs?).
Great, thank you Rocky. I'll just continue to use the non-Async rules for now. I don't have any long-running rules anyways, but was wanting to use it in case I do so that the busy indicator will show. It would be a last resort for me to do something that would delay the rule, like a command object or something.
Not a dealbreaker, I can easily work around this. Thanks again!
Here's what happens on a create operation, in three different scenarios:
- You don't implement any server-side create code yourself
- The
DataPortal_Create
base class method is invoked, and it callsCheckRules
- The
- You override
DataPortal_Create
- You choose whether to invoke
base.DataPortal_Create()
or to manually callCheckRules
- You choose whether to invoke
- You use the
Create
attribute (recommended)- You choose whether to call
CheckRules
- You choose whether to call
In other words, in your code you can add
[Create]
private void Create()
{ }
to your business class and you'll find that no rules run when creating a new instance of the type. Thus not running any async rules.
The drawback is that NO rules run, even the ones you might prefer did run as a new instance is created.
So there's a problem here - but I think the workaround allow most folks to get by. I say this with some confidence because this behavior has been around since CSLA started supporting async rules (many years ago), and (believe it or not) you are the first person to bring up the issue to me.
I love this discussion, years ago when I switched to csla 4.0 and started implementing business rules , I also followed the rules samples ( developed by @jonnybee, if memory serves).
I asked a question about asynchronous business rules and @jonnybee reccomended to stick with synchronous rules which I did!!!!
But I am just curious is there a need for asynchronous rules?
Regards
Oh yes, very much!!
The most common example is where you allow the user to enter something like a product code for a new product, and this code must be unique.
Rather than having the user fill in the entire form, click Save, and then find out that the product code is already used, you can run a rule that finds out if the product code already exists.
But in all modern smart client UI frameworks (WPF,UWP,XF,Blazor) such a rule must be async because it needs to call an app server or database.
Thank you @rockfordlhotka , so in the example you mentioned we need to make sure two things happen
- not to run the async rules while 'creating' the
object by using the techniques you
recommended ( check for IsNew() etc.) - only run the async rule when the property
changes, product code in your example ,
we don't have to do anything for this, cala takes.
care of this.
Hope my understanding is correct.
Regards
Hi @Chicagoan2016. I've found this is happening with both Fetch and Create.
It it is happening in Fetch you must not be using BypassPropertyChecks. That's not good, as you should use that structure to avoid a number of negative side effects.
It it is happening in Fetch you must not be using BypassPropertyChecks. That's not good, as you should use that structure to avoid a number of negative side effects.
That's what I have been using in all of my applications so far, there was only instance where I didn't trust the 'data source'.
But if we put the 'IsNew()' check in our async rules, they wouldn't get called during Fetch?
Hi @rockfordlhotka . I put a DataPortal_Fetch in the sample repo and added a new unit test for the Get and cannot get it to work there either. I used BypassPropertyChecks, plus a call to BusinessRules.CheckRules()
We have always called CheckRules() here but maybe this isn't the correct thing to do anymore if we go with an async rule? We have CheckRules() in case rules changed and existing data is invalid, nice to see this when it's loaded. Or, another case is that we have some objects where the end-user can customize their required fields.
You have hit on the key point I'm trying to make.
If you run business rules (CheckRules
) you must wait for IsBusy
to be false before exiting your data portal method. That's all that's necessary.
So if you call base.DataPortal_Create
it calls CheckRules
. Or if you explicitly call CheckRules
. These are what are running the rules on the server.
If you don't exit the method until IsBusy
is false
you won't have a problem.
OK, thanks. I think I'll wait for a Csla official Rocky solution :-)
The problem with all of this is that it requires the developer to have to remember if they have asynchronous rules and then don't call CheckRules()
. The problem becomes that a user would have to handle the IsBusy
case directly in their implementation, which means you either spin on IsBusy
(bad) or do some kind of magic to create a Task
that will finish when the object is no longer busy. This seems really messy to me :|.
I really don't want to discourage developers from building asynchronous rules, because I/O operations should be async whenever possible. And while I get @rockfordlhotka's point about a "timeout" in the operation, I also fear that too, because maybe it will take the entire object graph X amount of seconds to finish, whether it was sync or async.
On a sort of unrelated note, software development does require a certain burning passion (mental illness?) that makes you want to discuss asynchronous vs synchronous rules on a Saturday night : ) but I digress!
Back to the point, I am going to go ahead and suggest we could have two 'CheckRules' methods, one for synchronous and other for asynchronous, we could name them CheckSyncRules() and CheckASynchRules(), while typing this I realize may be could keep the one method CheckRules() but change the implementation and pass a boolean to it like the 'ForceUpdate' boolean. When the boolean is passed by the developer CheckRules() will check both asynchronous and synchronous rules.
Kind regards
Fwiw, I've started prototyping some thoughts in the actual work issue.
@hurcane I agree, and the problem we face right now is that there's not an easy way for a developer to prevent the server-side data portal from attempting to return the object graph to the caller while it remains busy running async rules.
You should be able to run async rules on the server. And it should be easy to ensure they finish before the data portal returns.
My current thinking (as per the actual work issue) is a CheckRulesAsync
method that doesn't complete until all async rules have completed.
You can also tell the rule engine to NOT call the async rule on the server side.
Flag | Property rule | Object rule |
---|---|---|
CanRunInCheckRules | Yes | Yes |
CanRunAsAffectedProperty | Yes | No |
CanRunOnServer | Yes | No |
These flags should be set in the ctor of the BusinessRule.
What may make this even more complex is that an async rule can trigger new async rules on Affected properties.
There is also the issue of CascadeOnDirtyProperties that may force recursively CheckRules for as long as an AddOutValue will modify the property to a new value.
Just an observation from my side: When I upgraded to csla 5.x from csla 4.x I had to change my async rules to inherit from PropertyRyle to PropertyRuleAsync. I made the mistake to change it to BusinessRuleAsync instead of PropertyRuleAsync, but the CanRunAsXXX flags are not available to set on BusinessRuleAsync. Only available on PropertyRuleAsync.
@hurcane I agree, and the problem we face right now is that there's not an easy way for a developer to prevent the server-side data portal from attempting to return the object graph to the caller while it remains busy running async rules.
You should be able to run async rules on the server. And it should be easy to ensure they finish before the data portal returns.
My current thinking (as per the actual work issue) is a
CheckRulesAsync
method that doesn't complete until all async rules have completed.
We actually have been hitting this, and our work around has involved hooking up to Busychanged, creating a TaskCompletionSource, calling CheckRules, awaiting the Tcs (which is set when busychanged says IsBusy == false)
The new feature to address this is in 5.2.0.