Implement async validation support for Microsoft.Extensions.Validation - #66487
#66487Implement async validation support for Microsoft.Extensions.Validation#66487
Conversation
9f2b358 to
3507c74
Compare
751b5ef to
2decfdb
Compare
|
Hey @dotnet/aspnet-build, looks like this PR is something you want to take a look at. |
2309efa to
d0d3a8e
Compare
javiercn
left a comment
There was a problem hiding this comment.
Haven't looked at the tests in detail, but there are a few more things to look into
| if (nextUseNeedsClone) | ||
| { | ||
| currentContext = context.CopyWithState(originalState); | ||
| (clonedContexts ??= new()).Add(currentContext); | ||
| nextUseNeedsClone = false; | ||
| } | ||
|
|
||
| currentContext.CurrentValidationPath = $"{currentPrefix}[{index}]"; | ||
| try | ||
| { | ||
| var task = validatableType.ValidateAsync(item, currentContext, cancellationToken); | ||
|
|
||
| if (task.IsCompletedSuccessfully) | ||
| { | ||
| await task; | ||
| } | ||
| else | ||
| { | ||
| nextUseNeedsClone = true; | ||
| (tasks ??= new()).Add(task); | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| (tasks ??= new()).Add(Task.FromException(ex)); | ||
| } |
There was a problem hiding this comment.
This can be a RunValidation helper or something like that, isn't it?
There was a problem hiding this comment.
@javiercn I assume you want to share a helper between ValidatableTypeInfo, ValidatablePropertyInfo, and ValidatableParameterInfo for the similar logic there.
But what parts do you exactly want to share.
The ValidateAsync calls are on different interfaces and probably can't be shared, the setting of validation path is also different. The only thing appears to the small IsCompletelySuccessfully check.
Also if we want to share the catch with Task.FromException logic, we will need that helper method to call ValidateAsync, so we will be passing around a Func<Task>. That lambda is going to capture some state and allocates, unless we have RunValidation<TState> and pass that state as a parameter, where the code shared seems to be small anyways.
| try | ||
| // First validate direct members | ||
| var originalState = context.CaptureMutableState(); | ||
| (List<Task>? localValidationTasks, List<ValidateContext>? clonedContexts) = await ValidateMembers(value, context, originalState, localValidationTasks: null, clonedContexts: null, cancellationToken); |
There was a problem hiding this comment.
(List? localValidationTasks, List? clonedContexts) = await ValidateMembers(value, context, originalState, localValidationTasks: null, clonedContexts: null, cancellationToken)
Consider passing a readonly struct here instead of the 6 parameters or at least grouping (List<Task>? localValidationTasks, List<ValidateContext>? clonedContexts) in a struct and passing and returning that
| internal void AddValidationErrorSuppressEvent(string path, IEnumerable<string> errors) | ||
| { | ||
| ValidationErrors ??= []; | ||
|
|
||
| if (ValidationErrors.TryGetValue(key, out var existingErrors) && !existingErrors.Contains(error)) | ||
| var validationErrors = _validationErrors; | ||
| if (validationErrors is null) | ||
| { | ||
| ValidationErrors[key] = [.. existingErrors, error]; | ||
| } | ||
| else | ||
| { | ||
| ValidationErrors[key] = [error]; | ||
| var newDictionary = new ConcurrentDictionary<string, IEnumerable<string>>(); | ||
| validationErrors = Interlocked.CompareExchange(ref _validationErrors, newDictionary, null) ?? newDictionary; | ||
| } | ||
|
|
||
| OnValidationError?.Invoke(new ValidationErrorContext | ||
| var existingErrors = (ConcurrentQueue<string>)validationErrors.GetOrAdd(path, static _ => new ConcurrentQueue<string>()); | ||
| foreach (var error in errors) | ||
| { | ||
| Name = name, | ||
| Path = key, | ||
| Errors = [error], | ||
| Container = container | ||
| }); | ||
| existingErrors.Enqueue(error); | ||
| } | ||
| } |
There was a problem hiding this comment.
I think we can probably significantly simplify this significantly. I believe we can always merge the errors without locking because we will only merge the errors after all the tasks have completed (we have awaited them) so there won't be any callback running concurrently at that point that is tied to the original context. So, we don't need the concurrent dictionary, and we don't need the concurrent queue. This is because the moment we detect that a task didn't finish synchronously that context is given exclusively to that task, and that propagates across levels, so even if we are validating Customer -> Order -> Product and product has a property that goes async, the task for validating product will be asyn (so another context would be created for any additional property in Order) and similar to other properties in Customer beyond Order. We can just use a dictionary and merge the errors into a list.
There was a problem hiding this comment.
I think currently we add the validation errors as soon as they come.
For example:
public class Order
{
[MyAsyncVal1]
[MyAsyncVal2]
public string S { get; }
}
Both attributes will be validating concurrently, and they use the same context, no cloning needed.
| } | ||
| }; | ||
|
|
||
| await ValidationHelpers.ValidateAttributesAsync( |
There was a problem hiding this comment.
This implementation with the callback and the generics is unnecessarily complex. Just define a private readonly context parameter and have one method to do the validation.
There was a problem hiding this comment.
The intent is to reduce the code duplication, and the use of the TState is only to allow the lambdas to be static to reduce allocations, it's a common pattern IMO.
I'm happy to refactor though if you see a better way. But I'm not following on the suggestion:
This implementation with the callback and the generics is unnecessarily complex. Just define a private readonly context parameter and have one method to do the validation.
Do you mean a readonly struct that we pass around? What would this struct contain and how does it simplify?
Proposal: factor the concurrent-validation bookkeeping into one placeI'd like to recommend adjusting this code to make it simpler and easier to read — fewer moving parts for a reader to hold in their head, with the same behavior. I'd apply it as four self-contained steps on top of this branch. Step 1 — Extract the clone-on-async loop into one typeRight now the "snapshot state → loop → internal struct AsyncValidationTracker
{
// ... fields: _originalContext, _originalState, _currentContext, _pendingTasks, _clonedContexts, _nextNeedsClone
// Reuses the context while validations complete synchronously; clones only after one goes async,
// so two concurrently-running validations never share a context.
public ValidateContext NextContext()
{
if (_nextNeedsClone)
{
_currentContext = _originalContext.CopyWithState(_originalState);
(_clonedContexts ??= []).Add(_currentContext);
_nextNeedsClone = false;
}
return _currentContext;
}
public void Track(Task validationTask)
{
if (validationTask.IsCompletedSuccessfully)
{
return; // synchronous: keep using the same context
}
_nextNeedsClone = true; // the next item must get its own clone
(_pendingTasks ??= []).Add(validationTask);
}
// Stays fully synchronous when nothing was tracked; otherwise awaits all and merges clone errors back.
public Task<bool> CompleteAsync()
=> _pendingTasks is null ? s_noClonedErrors : AwaitAndMergeAsync(_pendingTasks, _clonedContexts, _originalContext);
}Each of the three call sites then reads as a flat loop: var tracker = context.TrackAsyncValidations();
foreach (var item in enumerable)
{
if (item is not null && options.TryGetValidatableTypeInfo(item.GetType(), out var t))
{
var itemContext = tracker.NextContext(); // clones only after an async item
itemContext.CurrentValidationPath = $"{prefix}[{index}]";
try { tracker.Track(t.ValidateAsync(item, itemContext, ct)); }
catch (Exception ex) { tracker.Track(Task.FromException(ex)); }
}
index++;
}
await tracker.CompleteAsync();The clone-on-demand semantics are unchanged: the original context is mutated in place across synchronous items, and a Step 2 — Keep the synchronous attribute path off the async machinery
internal Task ValidateAttributesAsync(
object? value,
object? container,
IValidationErrorReporter reporter,
CancellationToken cancellationToken)
{
var validationAttributes = reporter.GetValidationAttributes();
return ValidateSynchronousAttributes(validationAttributes, value, container, reporter)
? ValidateAsynchronousAttributes(validationAttributes, value, container, reporter, cancellationToken)
: Task.CompletedTask; // no async attributes: no state machine, no allocation
}Step 3 — Replace the callback+state with a reporter the infos implementThe runner currently takes an internal interface IValidationErrorReporter
{
ValidationAttribute[] GetValidationAttributes();
void ReportError(ValidateContext context, object? container, ValidationAttribute attribute, ValidationResult result);
}and have the three infos implement it directly (explicitly, so nothing leaks onto the public surface). The error-reporting logic moves next to the data it needs, e.g. on void IValidationErrorReporter.ReportError(ValidateContext context, object? container, ValidationAttribute attribute, ValidationResult result)
{
var errorMessage = context.ResolveAttributeErrorMessage(Name, context.ValidationContext.DisplayName, declaringType: null, attribute, result);
if (errorMessage is null)
{
return;
}
var key = string.IsNullOrEmpty(context.CurrentValidationPath) ? Name : $"{context.CurrentValidationPath}.{Name}";
context.AddValidationError(new ValidationErrorContext
{
Name = Name,
Path = key,
Errors = [errorMessage],
Container = container,
});
}so the call site becomes a single line, passing the info itself as the reporter (no per-call object, no generic): await context.ValidateAttributesAsync(value, container: null, this, cancellationToken);Since the reporter supplies its own attributes via Step 4 — Tighten visibilityWith the tracker nested in What this buys us, concretelyReduced coupling
Improved cohesion
Easier to run (fewer paths / allocations)
Easier to follow
Behavior is unchanged — this is a structure-only change. |
Proposal: drop the synchronization on
|
javiercn
left a comment
There was a problem hiding this comment.
Looks great!!
Thanks for getting this done @Youssef1313!!
|
@copilot Revert the changes under |
Co-authored-by: Youssef1313 <31348972+Youssef1313@users.noreply.github.com>
Done. Reverted all PR changes under |
Fixes #64609
In MEV, we have multiple "levels" of validations:
ValidationAttributes on the parameterValidationAttributes on the property.ValidationAttributes on the typeEach level can be a mix of sync and async (except IValidatableObject - either sync or async).
The current implementation aims to:
On every element, we:
AsyncValidationAttributes first.AsyncValidationAttributes.AsyncValidationAttributefinishes, its result will be reported, if error, via theOnValidationErrorevent.In addition, the following changes are made:
ValidationErrorsno longer has a public setter. It will be problematic to do so if we go with "full parallelism" across all the levels. The knowledge of how to synchronize creating the dictionary will now be handled solely byValidateContext.ValidationErrorschanged to beIReadOnlyDictionary. The concreteDictionaryisn't thread-safe. This could have beenIDictionaryinstead ofIReadOnlyDictionary. This needs discussion. UsingIReadOnlyDictionarylimits the external ability to add errors to the context. However, this was done to keep the dictionary lazy and keep the property null when no validation errors happen.Validator, and makes more sense.