-
Notifications
You must be signed in to change notification settings - Fork 720
Add Enrich EF API #2125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Enrich EF API #2125
Changes from 4 commits
07758f5
a76069d
9deb56f
36005a9
3f8cdf8
5ba7fb0
f7771cb
fa41aa0
d3ffcd9
3a30cc1
7c6f96e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,3 +50,5 @@ uninstrumented | |
| upsert | ||
| uris | ||
| urls | ||
| Npgsql | ||
| Postgre | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,15 +66,124 @@ public static partial class AspireEFPostgreSqlExtensions | |
|
|
||
| configureSettings?.Invoke(settings); | ||
|
|
||
| if (settings.DbContextPooling) | ||
| builder.Services.AddDbContextPool<TContext>(ConfigureDbContext); | ||
|
|
||
| ConfigureInstrumentation<TContext>(builder, settings); | ||
|
|
||
| void ConfigureDbContext(DbContextOptionsBuilder dbContextOptionsBuilder) | ||
| { | ||
| // delay validating the ConnectionString until the DbContext is requested. This ensures an exception doesn't happen until a Logger is established. | ||
| if (string.IsNullOrEmpty(settings.ConnectionString)) | ||
| { | ||
| throw new InvalidOperationException($"ConnectionString is missing. It should be provided in 'ConnectionStrings:{connectionName}' or under the 'ConnectionString' key in '{DefaultConfigSectionName}' or '{typeSpecificSectionName}' configuration section."); | ||
| } | ||
|
|
||
| // We don't register a logger factory, because there is no need to: https://learn.microsoft.com/dotnet/api/microsoft.entityframeworkcore.dbcontextoptionsbuilder.useloggerfactory?view=efcore-7.0#remarks | ||
| dbContextOptionsBuilder.UseNpgsql(settings.ConnectionString, builder => | ||
| { | ||
| // Resiliency: | ||
| // 1. Connection resiliency automatically retries failed database commands: https://www.npgsql.org/efcore/misc/other.html#execution-strategy | ||
| if (settings.Retry) | ||
| { | ||
| builder.EnableRetryOnFailure(); | ||
| } | ||
| // 2. "Scale proportionally: You want to ensure that you don't scale out a resource to a point where it will exhaust other associated resources." | ||
| // The pooling is enabled by default, the min pool size is 0 by default: https://www.npgsql.org/doc/connection-string-parameters.html#pooling | ||
| // There is nothing for us to set here. | ||
| // 3. "Timeout: Places limit on the duration for which a caller can wait for a response." | ||
| // The timeouts have default values, except of Internal Command Timeout, which we should ignore: | ||
| // https://www.npgsql.org/doc/connection-string-parameters.html#timeouts-and-keepalive | ||
| // There is nothing for us to set here. | ||
| }); | ||
| configureDbContextOptions?.Invoke(dbContextOptionsBuilder); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Configures health check, logging and telemetry for the <see cref="DbContext" />. | ||
| /// </summary> | ||
| /// <exception cref="ArgumentNullException">Thrown if mandatory <paramref name="builder"/> is null.</exception> | ||
| /// <exception cref="InvalidOperationException">Thrown when mandatory <see cref="DbContext"/> is not registered in DI.</exception> | ||
| public static void EnrichNpgsqlDbContext<[DynamicallyAccessedMembers(RequiredByEF)] TContext>( | ||
| this IHostApplicationBuilder builder, | ||
| Action<NpgsqlEntityFrameworkCorePostgreSQLSettings>? configureSettings = null) where TContext : DbContext | ||
| { | ||
| ArgumentNullException.ThrowIfNull(builder); | ||
|
|
||
| NpgsqlEntityFrameworkCorePostgreSQLSettings settings = new(); | ||
| var typeSpecificSectionName = $"{DefaultConfigSectionName}:{typeof(TContext).Name}"; | ||
| var typeSpecificConfigurationSection = builder.Configuration.GetSection(typeSpecificSectionName); | ||
| if (typeSpecificConfigurationSection.Exists()) // https://github.com/dotnet/runtime/issues/91380 | ||
| { | ||
| builder.Services.AddDbContextPool<TContext>(ConfigureDbContext); | ||
| typeSpecificConfigurationSection.Bind(settings); | ||
| } | ||
| else | ||
| { | ||
| builder.Services.AddDbContext<TContext>(ConfigureDbContext); | ||
| builder.Configuration.GetSection(DefaultConfigSectionName).Bind(settings); | ||
| } | ||
|
|
||
| configureSettings?.Invoke(settings); | ||
|
|
||
| ConfigureRetry(); | ||
|
|
||
| ConfigureInstrumentation<TContext>(builder, settings); | ||
|
|
||
| void ConfigureRetry() | ||
| { | ||
| if (!settings.Retry) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // Resolving DbContext<TContextService> will resolve DbContextOptions<TContextImplementation>. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure I understand the significance of this line. What exactly does it mean? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a reminder that we seek the service descriptor for |
||
| // We need to replace the DbContextOptions service descriptor to inject more logic. This won't be necessary once | ||
| // Aspire targets .NET 9 as EF will respect the calls to services.ConfigureDbContext<TContext>(). c.f. https://github.com/dotnet/efcore/pull/32518 | ||
|
|
||
| var olDbContextOptionsDescriptor = builder.Services.FirstOrDefault(sd => sd.ServiceType == typeof(DbContextOptions<TContext>)); | ||
sebastienros marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
sebastienros marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| if (olDbContextOptionsDescriptor is null) | ||
| { | ||
| throw new InvalidOperationException($"DbContext<{nameof(TContext)}> was not registered"); | ||
| } | ||
|
|
||
| builder.Services.Remove(olDbContextOptionsDescriptor); | ||
|
|
||
| var dbContextOptionsDescriptor = new ServiceDescriptor( | ||
| olDbContextOptionsDescriptor.ServiceType, | ||
| olDbContextOptionsDescriptor.ServiceKey, | ||
| factory: (sp, key) => | ||
| { | ||
| DbContextOptionsBuilder? optionsBuilder = null; | ||
|
|
||
| if (olDbContextOptionsDescriptor.ImplementationFactory != null) | ||
| { | ||
| var instance = olDbContextOptionsDescriptor.ImplementationFactory?.Invoke(sp) as DbContextOptions<TContext>; | ||
sebastienros marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| if (instance == null) | ||
| { | ||
| throw new InvalidOperationException($"DbContextOptions<{nameof(TContext)}> couldn't be resolved"); | ||
| } | ||
|
|
||
| optionsBuilder = new DbContextOptionsBuilder<TContext>(instance); | ||
| } | ||
| else | ||
| { | ||
| optionsBuilder ??= new DbContextOptionsBuilder<TContext>(); | ||
| } | ||
|
|
||
| optionsBuilder.UseNpgsql(options => options.EnableRetryOnFailure()); | ||
|
|
||
| return optionsBuilder.Options; | ||
| }, | ||
| olDbContextOptionsDescriptor.Lifetime | ||
| ); | ||
|
|
||
| builder.Services.Add(dbContextOptionsDescriptor); | ||
| } | ||
| } | ||
|
|
||
| private static void ConfigureInstrumentation<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] TContext>(IHostApplicationBuilder builder, NpgsqlEntityFrameworkCorePostgreSQLSettings settings) where TContext : DbContext | ||
| { | ||
| if (settings.HealthChecks) | ||
| { | ||
| // calling MapHealthChecks is the responsibility of the app, not Component | ||
|
|
@@ -113,34 +222,5 @@ public static partial class AspireEFPostgreSqlExtensions | |
| NpgsqlCommon.AddNpgsqlMetrics(meterProviderBuilder); | ||
| }); | ||
| } | ||
|
|
||
| void ConfigureDbContext(DbContextOptionsBuilder dbContextOptionsBuilder) | ||
| { | ||
| // delay validating the ConnectionString until the DbContext is requested. This ensures an exception doesn't happen until a Logger is established. | ||
| if (string.IsNullOrEmpty(settings.ConnectionString)) | ||
| { | ||
| throw new InvalidOperationException($"ConnectionString is missing. It should be provided in 'ConnectionStrings:{connectionName}' or under the 'ConnectionString' key in '{DefaultConfigSectionName}' or '{typeSpecificSectionName}' configuration section."); | ||
| } | ||
|
|
||
| // We don't register a logger factory, because there is no need to: https://learn.microsoft.com/dotnet/api/microsoft.entityframeworkcore.dbcontextoptionsbuilder.useloggerfactory?view=efcore-7.0#remarks | ||
| dbContextOptionsBuilder.UseNpgsql(settings.ConnectionString, builder => | ||
| { | ||
| // Resiliency: | ||
| // 1. Connection resiliency automatically retries failed database commands: https://www.npgsql.org/efcore/misc/other.html#execution-strategy | ||
| if (settings.MaxRetryCount > 0) | ||
| { | ||
| builder.EnableRetryOnFailure(settings.MaxRetryCount); | ||
| } | ||
| // 2. "Scale proportionally: You want to ensure that you don't scale out a resource to a point where it will exhaust other associated resources." | ||
| // The pooling is enabled by default, the min pool size is 0 by default: https://www.npgsql.org/doc/connection-string-parameters.html#pooling | ||
| // There is nothing for us to set here. | ||
| // 3. "Timeout: Places limit on the duration for which a caller can wait for a response." | ||
| // The timeouts have default values, except of Internal Command Timeout, which we should ignore: | ||
| // https://www.npgsql.org/doc/connection-string-parameters.html#timeouts-and-keepalive | ||
| // There is nothing for us to set here. | ||
| }); | ||
|
|
||
| configureDbContextOptions?.Invoke(dbContextOptionsBuilder); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,25 +58,21 @@ | |
| "type": "string", | ||
| "description": "Gets or sets the connection string of the PostgreSQL database to connect to." | ||
| }, | ||
| "DbContextPooling": { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pooling is enabled by default in |
||
| "type": "boolean", | ||
| "description": "Gets or sets a boolean value that indicates whether the DbContext will be pooled or explicitly created every time it's requested.", | ||
| "default": true | ||
| }, | ||
| "HealthChecks": { | ||
| "type": "boolean", | ||
| "description": "Gets or sets a boolean value that indicates whether the database health check is enabled or not.", | ||
| "default": true | ||
| }, | ||
| "MaxRetryCount": { | ||
| "type": "integer", | ||
| "description": "Gets or sets the maximum number of retry attempts. Default value is 6, set it to 0 to disable the retry mechanism." | ||
| }, | ||
| "Metrics": { | ||
| "type": "boolean", | ||
| "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are enabled or not.", | ||
| "default": true | ||
| }, | ||
| "Retry": { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| "type": "boolean", | ||
| "description": "Gets or sets whether retries should be enabled.", | ||
| "default": true | ||
| }, | ||
| "Tracing": { | ||
| "type": "boolean", | ||
| "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is enabled or not.", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,13 @@ public class AspireEFPostgreSqlExtensionsTests | |
| { | ||
| private const string ConnectionString = "Host=localhost;Database=test;Username=postgres"; | ||
|
|
||
| internal static void ConfigureDbContextOptionsBuilderForTesting(DbContextOptionsBuilder builder) | ||
| { | ||
| // Don't cache the service provider in testing. | ||
| // Works around https://github.com/npgsql/efcore.pg/issues/2891, which is errantly caches connection strings across DI containers. | ||
| builder.EnableServiceProviderCaching(false); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ReadsFromConnectionStringsCorrectly() | ||
| { | ||
|
|
@@ -26,7 +33,7 @@ public void ReadsFromConnectionStringsCorrectly() | |
| new KeyValuePair<string, string?>("ConnectionStrings:npgsql", ConnectionString) | ||
| ]); | ||
|
|
||
| builder.AddNpgsqlDbContext<TestDbContext>("npgsql"); | ||
| builder.AddNpgsqlDbContext<TestDbContext>("npgsql", configureDbContextOptions: ConfigureDbContextOptionsBuilderForTesting); | ||
|
|
||
| var host = builder.Build(); | ||
| var context = host.Services.GetRequiredService<TestDbContext>(); | ||
|
|
@@ -42,7 +49,9 @@ public void ConnectionStringCanBeSetInCode() | |
| new KeyValuePair<string, string?>("ConnectionStrings:npgsql", "unused") | ||
| ]); | ||
|
|
||
| builder.AddNpgsqlDbContext<TestDbContext>("npgsql", settings => settings.ConnectionString = ConnectionString); | ||
| builder.AddNpgsqlDbContext<TestDbContext>("npgsql", | ||
| settings => settings.ConnectionString = ConnectionString, | ||
| configureDbContextOptions: ConfigureDbContextOptionsBuilderForTesting); | ||
|
|
||
| var host = builder.Build(); | ||
| var context = host.Services.GetRequiredService<TestDbContext>(); | ||
|
|
@@ -62,7 +71,7 @@ public void ConnectionNameWinsOverConfigSection() | |
| new KeyValuePair<string, string?>("ConnectionStrings:npgsql", ConnectionString) | ||
| ]); | ||
|
|
||
| builder.AddNpgsqlDbContext<TestDbContext>("npgsql"); | ||
| builder.AddNpgsqlDbContext<TestDbContext>("npgsql", configureDbContextOptions: ConfigureDbContextOptionsBuilderForTesting); | ||
|
|
||
| var host = builder.Build(); | ||
| var context = host.Services.GetRequiredService<TestDbContext>(); | ||
|
|
@@ -74,16 +83,17 @@ public void ConnectionNameWinsOverConfigSection() | |
| } | ||
|
|
||
| [Fact] | ||
| public void CanConfigureDbContextOptions() | ||
| public void AddNpgsqlCanConfigureDbContextOptions() | ||
| { | ||
| var builder = Host.CreateEmptyApplicationBuilder(null); | ||
| builder.Configuration.AddInMemoryCollection([ | ||
| new KeyValuePair<string, string?>("ConnectionStrings:npgsql", ConnectionString), | ||
| new KeyValuePair<string, string?>("Aspire:Npgsql:EntityFrameworkCore:PostgreSQL:MaxRetryCount", "304") | ||
| new KeyValuePair<string, string?>("Aspire:Npgsql:EntityFrameworkCore:PostgreSQL:Retry", "true") | ||
| ]); | ||
|
|
||
| builder.AddNpgsqlDbContext<TestDbContext>("npgsql", configureDbContextOptions: optionsBuilder => | ||
| { | ||
| ConfigureDbContextOptionsBuilderForTesting(optionsBuilder); | ||
| optionsBuilder.UseNpgsql(npgsqlBuilder => | ||
| { | ||
| npgsqlBuilder.CommandTimeout(123); | ||
|
|
@@ -109,8 +119,56 @@ public void CanConfigureDbContextOptions() | |
| Assert.NotNull(extension.ExecutionStrategyFactory); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment above this line needs to be updated. |
||
| var executionStrategy = extension.ExecutionStrategyFactory(new ExecutionStrategyDependencies(new CurrentDbContext(context), context.Options, null!)); | ||
| var retryStrategy = Assert.IsType<NpgsqlRetryingExecutionStrategy>(executionStrategy); | ||
| Assert.Equal(304, retryStrategy.MaxRetryCount); | ||
| Assert.Equal(new WorkaroundToReadProtectedField(context).MaxRetryCount, retryStrategy.MaxRetryCount); | ||
|
|
||
| #pragma warning restore EF1001 // Internal EF Core API usage. | ||
| } | ||
|
|
||
| [Fact] | ||
| public void AddNpgsqlCanConfigureDbContextOptionsWithoutRetry() | ||
| { | ||
| var builder = Host.CreateEmptyApplicationBuilder(null); | ||
| builder.Configuration.AddInMemoryCollection([ | ||
| new KeyValuePair<string, string?>("ConnectionStrings:npgsql", ConnectionString), | ||
| new KeyValuePair<string, string?>("Aspire:Npgsql:EntityFrameworkCore:PostgreSQL:Retry", "false") | ||
| ]); | ||
|
|
||
| builder.AddNpgsqlDbContext<TestDbContext>("npgsql", configureDbContextOptions: optionsBuilder => | ||
| { | ||
| ConfigureDbContextOptionsBuilderForTesting(optionsBuilder); | ||
| optionsBuilder.UseNpgsql(npgsqlBuilder => | ||
| { | ||
| npgsqlBuilder.CommandTimeout(123); | ||
| }); | ||
| }); | ||
|
|
||
| var host = builder.Build(); | ||
| var context = host.Services.GetRequiredService<TestDbContext>(); | ||
|
|
||
| #pragma warning disable EF1001 // Internal EF Core API usage. | ||
|
|
||
| var extension = context.Options.FindExtension<NpgsqlOptionsExtension>(); | ||
| Assert.NotNull(extension); | ||
|
|
||
| // ensure the command timeout was respected | ||
| Assert.Equal(123, extension.CommandTimeout); | ||
|
|
||
| // ensure the connection string from config was respected | ||
| var actualConnectionString = context.Database.GetDbConnection().ConnectionString; | ||
| Assert.Equal(ConnectionString, actualConnectionString); | ||
|
|
||
| // ensure no retry strategy was registered | ||
| Assert.Null(extension.ExecutionStrategyFactory); | ||
|
|
||
| #pragma warning restore EF1001 // Internal EF Core API usage. | ||
| } | ||
|
|
||
| public class WorkaroundToReadProtectedField : NpgsqlRetryingExecutionStrategy | ||
|
||
| { | ||
| public WorkaroundToReadProtectedField(DbContext context) : base(context) | ||
| { | ||
| } | ||
|
|
||
| public int RetryCount => base.MaxRetryCount; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,6 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using Aspire.Components.Common.Tests; | ||
| using Microsoft.EntityFrameworkCore; | ||
| using Npgsql.EntityFrameworkCore.PostgreSQL; | ||
| using Xunit; | ||
|
|
@@ -14,10 +13,6 @@ public class ConfigurationTests | |
| public void ConnectionStringIsNullByDefault() | ||
| => Assert.Null(new NpgsqlEntityFrameworkCorePostgreSQLSettings().ConnectionString); | ||
|
|
||
| [Fact] | ||
| public void DbContextPoolingIsEnabledByDefault() | ||
| => Assert.True(new NpgsqlEntityFrameworkCorePostgreSQLSettings().DbContextPooling); | ||
|
|
||
| [Fact] | ||
| public void HealthCheckIsEnabledByDefault() | ||
| => Assert.True(new NpgsqlEntityFrameworkCorePostgreSQLSettings().HealthChecks); | ||
|
|
@@ -30,16 +25,6 @@ public void TracingIsEnabledByDefault() | |
| public void MetricsAreEnabledByDefault() | ||
| => Assert.True(new NpgsqlEntityFrameworkCorePostgreSQLSettings().Metrics); | ||
|
|
||
| [Fact] | ||
| public void MaxRetryCountIsSameAsInTheDefaultNpgsqlPolicy() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Although I don't think these are super valuable tests, following the current example we should have one that ensures |
||
| { | ||
| DbContextOptionsBuilder<TestDbContext> dbContextOptionsBuilder = new(); | ||
| dbContextOptionsBuilder.UseNpgsql("fakeConnectionString"); | ||
| TestDbContext dbContext = new(dbContextOptionsBuilder.Options); | ||
|
|
||
| Assert.Equal(new WorkaroundToReadProtectedField(dbContext).RetryCount, new NpgsqlEntityFrameworkCorePostgreSQLSettings().MaxRetryCount); | ||
| } | ||
|
|
||
| public class WorkaroundToReadProtectedField : NpgsqlRetryingExecutionStrategy | ||
|
||
| { | ||
| public WorkaroundToReadProtectedField(DbContext context) : base(context) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we extract this into a common method?