Add async startup validation for Microsoft.Extensions.Options#128788
Add async startup validation for Microsoft.Extensions.Options#128788ViveliDuCh wants to merge 2 commits into
Conversation
Implement the async validation API surface approved in dotnet#128100 (DataAnnotations layer): New types: - AsyncValidationAttribute: abstract base class for async validation scenarios. IsValid(object?, ValidationContext) is abstract override; IsValidAsync is the primary async entry point. Sealed override of IsValid(object?) delegates to the context overload. - IAsyncValidatableObject: extends IValidatableObject with ValidateAsync returning IAsyncEnumerable<ValidationResult>. No default interface method. New Validator async methods (8 total): - TryValidateObjectAsync (2 overloads) - TryValidatePropertyAsync - TryValidateValueAsync - ValidateObjectAsync (2 overloads) - ValidatePropertyAsync - ValidateValueAsync All return Task/Task<bool> per API review decision. Implementation details: - 3-step async pipeline matching sync structure: property validation, type attributes, IAsyncValidatableObject/IValidatableObject. - Property validation runs in parallel via Task.WhenAny with linked CancellationTokenSource for cooperative breakOnFirstError. - Per-value validation is two-phase: sync attributes first (abort early), then async attributes in parallel. - try/finally blocks observe in-flight tasks on all exit paths to prevent UnobservedTaskException from the finalizer thread. Refactoring: - Extracted EnsureValidationResultErrorMessage as private protected helper in ValidationAttribute, shared by sync GetValidationResult and async GetValidationResultAsync. - Added RequiresValidationContext XML remark clarifying async behavior. - Added ValidationContext.Items thread-safety remark for parallel async validation. Tests: ~112 test cases covering all async Validator methods, mixed sync/async attributes, breakOnFirstError with parallel async, cancellation propagation, IAsyncValidatableObject, class-level async attributes, error message formatting, sync fallback, and gate-based concurrency probing for deterministic parallelism verification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add IAsyncValidateOptions<T> and IAsyncStartupValidator interfaces - Add AsyncValidateOptions<T, TDep1..TDep5> lambda-based validators - Add async Validate overloads on OptionsBuilder<T> (0-5 dependencies) - Extend ValidateOnStart to prefer IAsyncStartupValidator when available - Add DataAnnotationValidateOptionsAsync<T> and ValidateDataAnnotationsAsync extension - Wire IAsyncStartupValidator into Host.StartAsync
|
Tagging subscribers to this area: @dotnet/area-system-componentmodel-dataannotations |
There was a problem hiding this comment.
Pull request overview
This PR adds asynchronous startup validation to Microsoft.Extensions.Options, building on async DataAnnotations support from PR #128656. It introduces a parallel async validation pipeline that runs during Host.StartAsync() while leaving the synchronous IOptions<T>.Value / OptionsFactory.Create() path untouched, so existing behavior is preserved.
Changes:
- New public APIs:
IAsyncValidateOptions<T>,IAsyncStartupValidator,AsyncValidateOptions<TOptions>(0–5 deps),DataAnnotationValidateOptionsAsync<T>, asyncValidateoverloads onOptionsBuilder<T>, andValidateDataAnnotationsAsync(). StartupValidatornow also implementsIAsyncStartupValidator(running sync validators first, then async);ValidateOnStart()registers async entries whenIAsyncValidateOptions<T>services are present.Host.StartAsync()prefersIAsyncStartupValidatoroverIStartupValidatorwhen both are registered, with corresponding ref-assembly updates and System.ComponentModel.Annotations async validation primitives (AsyncValidationAttribute,IAsyncValidatableObject, asyncValidatormethods).
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/AsyncValidationAttribute.cs |
New abstract base for async validation attributes. |
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/IAsyncValidatableObject.cs |
New interface extending IValidatableObject with IAsyncEnumerable results. |
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/Validator.cs |
Adds 8 async Validator methods with two-phase parallel async validation. |
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationAttribute.cs |
Adds EnsureValidationResultErrorMessage helper used by both sync and async paths. |
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationContext.cs |
Doc-only thread-safety remarks for Items. |
src/libraries/System.ComponentModel.Annotations/src/System.ComponentModel.Annotations.csproj |
Includes new files in compilation. |
src/libraries/System.ComponentModel.Annotations/ref/System.ComponentModel.Annotations.cs |
Ref assembly for new async APIs. |
src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidatorTests.cs |
Extensive tests for Validator.*Async methods, parallelism, cancellation, and IAsyncValidatableObject. |
src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidationAttributeTests.cs |
Tests for AsyncValidationAttribute. |
src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs |
New interface for async options validation. |
src/libraries/Microsoft.Extensions.Options/src/IAsyncStartupValidator.cs |
New interface for async startup validation. |
src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs |
Async validator classes (0–5 dependencies). |
src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs |
Async Validate(...) overloads. |
src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs |
ValidateOnStart now registers IAsyncStartupValidator and async validator entries. |
src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs |
Adds _asyncValidators dictionary. |
src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs |
StartupValidator implements IAsyncStartupValidator. |
src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs |
Ref assembly updates for async API surface. |
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptionsAsync.cs |
Async DataAnnotations validator for options (recursive [ValidateObjectMembers]/[ValidateEnumeratedItems]). |
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs |
Marks the class partial. |
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.Async.cs |
Adds ValidateDataAnnotationsAsync<T> extension. |
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/Microsoft.Extensions.Options.DataAnnotations.csproj |
Excludes async sources for non-NetCoreAppCurrent. |
src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj / .Async.cs |
Ref assembly entries for async API. |
src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs |
Prefers IAsyncStartupValidator over IStartupValidator during StartAsync. |
| { | ||
| vo._asyncValidators[(typeof(TOptions), optionsBuilder.Name)] = async (CancellationToken ct) => | ||
| { | ||
| // Retrieve the options value (already created by sync Validate() call) |
| IAsyncStartupValidator? asyncValidator = Services.GetService<IAsyncStartupValidator>(); | ||
| if (asyncValidator is not null) | ||
| { | ||
| await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false); | ||
| } | ||
| else | ||
| { | ||
| IStartupValidator? validator = Services.GetService<IStartupValidator>(); | ||
| validator?.Validate(); | ||
| } |
| public async Task ValidateAsync(CancellationToken cancellationToken = default) | ||
| { | ||
| // Run sync validators first (this triggers options creation + sync validation) | ||
| Validate(); | ||
|
|
||
| // Then run async validators | ||
| List<Exception>? exceptions = null; | ||
|
|
||
| foreach (Func<CancellationToken, Task> asyncValidator in _validatorOptions._asyncValidators.Values) | ||
| { | ||
| try | ||
| { | ||
| await asyncValidator(cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (OptionsValidationException ex) | ||
| { | ||
| exceptions ??= new(); | ||
| exceptions.Add(ex); | ||
| } | ||
| } |
| .Configure<IOptionsMonitor<TOptions>, IEnumerable<IAsyncValidateOptions<TOptions>>>((vo, options, asyncValidators) => | ||
| { | ||
| // Materialize the validators into a list to check if any are registered | ||
| var validators = new List<IAsyncValidateOptions<TOptions>>(asyncValidators); | ||
| if (validators.Count > 0) | ||
| { | ||
| vo._asyncValidators[(typeof(TOptions), optionsBuilder.Name)] = async (CancellationToken ct) => | ||
| { | ||
| // Retrieve the options value (already created by sync Validate() call) | ||
| TOptions optionsValue = options.Get(optionsBuilder.Name); | ||
|
|
||
| // Run async validators | ||
| List<string>? failures = null; | ||
| foreach (IAsyncValidateOptions<TOptions> validator in validators) | ||
| { | ||
| ValidateOptionsResult result = await validator.ValidateAsync(optionsBuilder.Name, optionsValue, ct).ConfigureAwait(false); | ||
| if (result is not null && result.Failed) | ||
| { | ||
| failures ??= new List<string>(); | ||
| failures.AddRange(result.Failures); | ||
| } | ||
| } | ||
|
|
||
| if (failures is not null && failures.Count > 0) | ||
| { | ||
| throw new OptionsValidationException(optionsBuilder.Name, typeof(TOptions), failures); | ||
| } | ||
| }; | ||
| } | ||
| }); |
| IAsyncStartupValidator? asyncValidator = Services.GetService<IAsyncStartupValidator>(); | ||
| if (asyncValidator is not null) | ||
| { | ||
| await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false); |
There was a problem hiding this comment.
In case of cancellation, do we want to just throw or do we still want that to go through logging?
| @@ -7,6 +7,10 @@ | |||
| <Compile Include="Microsoft.Extensions.Options.DataAnnotations.cs" /> | |||
| </ItemGroup> | |||
|
|
|||
| <ItemGroup Condition="'$(TargetFramework)' == '$(NetCoreAppCurrent)'"> | |||
There was a problem hiding this comment.
Should this be Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net11.0'))"?
I think we will always want this for net11+ builds, and don't want it to be affected by future bumps to NetCoreAppCurrent?
| { | ||
| errors ??= new List<string>(); | ||
|
|
||
| foreach (ValidationResult result in results!) |
There was a problem hiding this comment.
Is the null suppression here needed?
| continue; | ||
| } | ||
|
|
||
| object? value = propertyInfo!.GetValue(options); |
There was a problem hiding this comment.
Same here for the null suppression.
| visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance); | ||
| visited.Add(options); | ||
|
|
||
| results ??= new List<ValidationResult>(); |
There was a problem hiding this comment.
Looks like results is never null here so this line should be redundant?
| { | ||
| visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance); | ||
| visited.Add(options); | ||
| results ??= new List<ValidationResult>(); |
| @@ -16,6 +16,11 @@ | |||
| <Compile Include="$(CoreLibSharedDir)System\Runtime\CompilerServices\CompilerLoweringPreserveAttribute.cs" /> | |||
| </ItemGroup> | |||
|
|
|||
| <ItemGroup Condition="'$(TargetFramework)' != '$(NetCoreAppCurrent)'"> | |||
There was a problem hiding this comment.
Same comment here about the condition.
Fixes #128100
Implements async startup validation for
Microsoft.Extensions.Optionsas approved in API review.Follow-up to #128656
OptionsFactory.Create()andIOptions<T>.Valueremain fully synchronous. Async validators run in a separate step duringHost.StartAsync()only. Lazy validation via.Valueand runtime reload viaIOptionsMonitor<T>are not affected. See design rationale.What's included
IAsyncValidateOptions<TOptions>— async counterpart toIValidateOptions<T>returningTask<ValidateOptionsResult>IAsyncStartupValidator— async counterpart toIStartupValidatorfor host-level startup validationAsyncValidateOptions<TOptions>throughAsyncValidateOptions<TOptions, TDep1..TDep5>— lambda-based async validators (0–5 dependencies), mirroring the syncValidateOptions<T, TDep>familyValidateoverloads onOptionsBuilder<TOptions>(0–5 dependencies) — registersIAsyncValidateOptions<T>via lambdaDataAnnotationValidateOptionsAsync<TOptions>— async counterpart toDataAnnotationValidateOptions<T>, callsValidator.TryValidateObjectAsyncand walks[ValidateObjectMembers]/[ValidateEnumeratedItems]recursivelyValidateDataAnnotationsAsync()extension method onOptionsBuilderDataAnnotationsExtensionsStartupValidatorextended to implementIAsyncStartupValidator— runs sync validators first, then async validators, collecting allOptionsValidationExceptionsHost.StartAsync()updated to preferIAsyncStartupValidatorwhen available, falling back to syncIStartupValidatorValidateOnStart()extended to register async validator entries alongside sync entries whenIAsyncValidateOptions<T>services are presentNot in scope
IAsyncOptions<T>,IAsyncOptionsSnapshot<T>, orIAsyncOptionsMonitor<T>— lazy async resolution is blocked byIOptions<T>.Valuebeing a C# property andOptionsCacheusingLazy<T>/ConcurrentDictionary(no async counterparts in the BCL)IOptionsMonitor<T>config changes —OnChangecallbacks remain sync-only[OptionsValidator]source generator emittingValidateAsync()forIAsyncValidateOptions<T>is tracked separatelyImplementation notes
StartupValidator.ValidateAsync()calls syncValidate()first (which triggersIOptions<T>.Value→OptionsFactory.Create()→ sync validators), then iterates registered async validators sequentiallyValidateOnStart()registers both sync and async entries inStartupValidatorOptions. Async entries are only added whenIAsyncValidateOptions<T>services are detected at configure-timeHost.StartAsync()resolvesIAsyncStartupValidatorfirst; if not available, falls back toIStartupValidator(backward compatible)OptionsValidationExceptions from different async validators are aggregated into anAggregateExceptionwhen more than one failsAPI review decisions reflected
Task<ValidateOptionsResult>return type onIAsyncValidateOptions<T>(notValueTask)IAsyncStartupValidatoras a separate interface (not extendingIStartupValidator)Validateoverloads onOptionsBuilder<T>acceptFunc<TOptions, ..., CancellationToken, Task<bool>>StartupValidatorimplements bothIStartupValidatorandIAsyncStartupValidator, registered as a single service