Skip to content

Commit 16afa8e

Browse files
committed
Move two-stage validation to Host.cs, fix cancellation rethrow, merge async extensions
1 parent 6924940 commit 16afa8e

6 files changed

Lines changed: 55 additions & 52 deletions

File tree

src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public Host(IServiceProvider services,
6666
/// Order:
6767
/// IHostLifetime.WaitForStartAsync
6868
/// Services.GetService{IStartupValidator}().Validate()
69+
/// Services.GetService{IAsyncStartupValidator}().ValidateAsync()
6970
/// IHostedLifecycleService.StartingAsync
7071
/// IHostedService.Start
7172
/// IHostedLifecycleService.StartedAsync
@@ -96,23 +97,31 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
9697
_hostedServices ??= Services.GetRequiredService<IEnumerable<IHostedService>>();
9798
_hostedLifecycleServices = GetHostLifecycles(_hostedServices);
9899

99-
// Call startup validators (prefer async if available).
100-
// IAsyncStartupValidator takes precedence; the built-in StartupValidator implements both
101-
// interfaces and its ValidateAsync() calls Validate() internally.
100+
// Two-stage startup validation:
101+
// Stage 1 (sync): Run IStartupValidator.Validate() — iterates _validators dictionary
102+
// (or user's custom implementation if registered).
103+
// If sync validation fails, skip async to avoid expensive I/O on invalid config.
104+
// Stage 2 (async): Run IAsyncStartupValidator.ValidateAsync() — iterates _asyncValidators
105+
// dictionary (or user's custom implementation if registered).
106+
//
107+
// Each interface is resolved independently via DI. TryAddTransient semantics ensure
108+
// user-registered implementations replace the built-in for each interface separately.
109+
IStartupValidator? validator = Services.GetService<IStartupValidator>();
110+
validator?.Validate();
111+
102112
IAsyncStartupValidator? asyncValidator = Services.GetService<IAsyncStartupValidator>();
103113
if (asyncValidator is not null)
104114
{
105115
await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false);
106116
}
107-
else
108-
{
109-
IStartupValidator? validator = Services.GetService<IStartupValidator>();
110-
validator?.Validate();
111-
}
112117
}
113118
catch (Exception ex)
114119
{
115-
cancellationToken.ThrowIfCancellationRequested();
120+
if (ex is OperationCanceledException)
121+
{
122+
cancellationToken.ThrowIfCancellationRequested();
123+
}
124+
116125
// service factory or validation failed, abort startup.
117126
exceptions.Add(ex);
118127
LogAndRethrow();

src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/Microsoft.Extensions.Options.DataAnnotations.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818

1919
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net11.0'))">
2020
<Compile Remove="DataAnnotationValidateOptionsAsync.cs" />
21-
<Compile Remove="OptionsBuilderDataAnnotationsExtensions.Async.cs" />
2221
</ItemGroup>
2322

2423
<ItemGroup Condition="'$(TargetFramework)' != '$(NetCoreAppCurrent)'">

src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.Async.cs

Lines changed: 0 additions & 29 deletions
This file was deleted.

src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace Microsoft.Extensions.DependencyInjection
99
/// <summary>
1010
/// Extension methods for adding configuration related options services to the DI container via <see cref="OptionsBuilder{TOptions}"/>.
1111
/// </summary>
12-
public static partial class OptionsBuilderDataAnnotationsExtensions
12+
public static class OptionsBuilderDataAnnotationsExtensions
1313
{
1414
/// <summary>
1515
/// Register this options instance for validation of its DataAnnotations.
@@ -24,5 +24,25 @@ public static partial class OptionsBuilderDataAnnotationsExtensions
2424
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(new DataAnnotationValidateOptions<TOptions>(optionsBuilder.Name));
2525
return optionsBuilder;
2626
}
27+
28+
#if NET11_0_OR_GREATER
29+
/// <summary>
30+
/// Register this options instance for asynchronous validation of its DataAnnotations.
31+
/// </summary>
32+
/// <typeparam name="TOptions">The options type to be configured.</typeparam>
33+
/// <param name="optionsBuilder">The options builder to add the services to.</param>
34+
/// <returns>The <see cref="OptionsBuilder{TOptions}"/> so that additional calls can be chained.</returns>
35+
/// <remarks>
36+
/// Async validators run only at startup when used with <c>ValidateOnStart</c>.
37+
/// <see cref="IOptionsMonitor{TOptions}"/> reload validation uses only synchronous validators.
38+
/// </remarks>
39+
[RequiresUnreferencedCode("Uses DataAnnotationValidateOptionsAsync which is unsafe given that the options type passed in when calling Validate cannot be statically analyzed so its" +
40+
" members may be trimmed.")]
41+
public static OptionsBuilder<TOptions> ValidateDataAnnotationsAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>(this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class
42+
{
43+
optionsBuilder.Services.AddSingleton<IAsyncValidateOptions<TOptions>>(new DataAnnotationValidateOptionsAsync<TOptions>(optionsBuilder.Name));
44+
return optionsBuilder;
45+
}
46+
#endif
2747
}
2848
}

src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,8 @@ public void Validate()
5555

5656
public async Task ValidateAsync(CancellationToken cancellationToken = default)
5757
{
58-
// Run sync validators first. If sync validation fails, skip async validators
59-
// to avoid expensive I/O operations on already-invalid configuration (two-stage validation).
60-
Validate();
61-
62-
// Then run async validators
58+
// Async validators only — sync validation is handled separately by
59+
// IStartupValidator.Validate() in Host.StartAsync() (two-stage orchestration).
6360
List<Exception>? exceptions = null;
6461

6562
foreach (Func<CancellationToken, Task> asyncValidator in _validatorOptions._asyncValidators.Values)

src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public async Task OptionsBuilder_AsyncValidate_RegistersAndExecutes()
7676
}
7777

7878
[Fact]
79-
public async Task StartupValidator_ValidateAsync_RunsBothSyncAndAsyncValidators()
79+
public async Task StartupValidator_TwoStage_RunsBothSyncAndAsyncValidators()
8080
{
8181
var services = new ServiceCollection();
8282
bool syncRan = false;
@@ -93,16 +93,19 @@ public async Task StartupValidator_ValidateAsync_RunsBothSyncAndAsyncValidators(
9393
.ValidateOnStart();
9494

9595
ServiceProvider sp = services.BuildServiceProvider();
96-
var validator = sp.GetRequiredService<IAsyncStartupValidator>();
97-
98-
await validator.ValidateAsync(CancellationToken.None);
9996

97+
// Two-stage orchestration: Host.cs calls Validate() then ValidateAsync() independently
98+
var syncValidator = sp.GetRequiredService<IStartupValidator>();
99+
syncValidator.Validate();
100100
Assert.True(syncRan);
101+
102+
var asyncValidator = sp.GetRequiredService<IAsyncStartupValidator>();
103+
await asyncValidator.ValidateAsync(CancellationToken.None);
101104
Assert.True(asyncRan);
102105
}
103106

104107
[Fact]
105-
public async Task StartupValidator_ValidateAsync_SyncFailureSkipsAsyncValidators()
108+
public async Task StartupValidator_TwoStage_SyncFailureSkipsAsyncValidators()
106109
{
107110
var services = new ServiceCollection();
108111
bool asyncRan = false;
@@ -118,9 +121,13 @@ public async Task StartupValidator_ValidateAsync_SyncFailureSkipsAsyncValidators
118121
.ValidateOnStart();
119122

120123
ServiceProvider sp = services.BuildServiceProvider();
121-
var validator = sp.GetRequiredService<IAsyncStartupValidator>();
122124

123-
await Assert.ThrowsAsync<OptionsValidationException>(() => validator.ValidateAsync(CancellationToken.None));
125+
// Stage 1: Sync throws — in Host.cs, this prevents reaching Stage 2
126+
var syncValidator = sp.GetRequiredService<IStartupValidator>();
127+
Assert.Throws<OptionsValidationException>(() => syncValidator.Validate());
128+
129+
// Stage 2 is never reached because the exception propagates.
130+
// Verify async didn't run (simulating Host.cs short-circuit behavior).
124131
Assert.False(asyncRan);
125132
}
126133

0 commit comments

Comments
 (0)