Skip to content
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

Allow EF Core migration tooling to use app host as startup project #2090

Closed
kkirkfield opened this issue Feb 5, 2024 · 7 comments
Closed
Labels
area-app-model Issues pertaining to the APIs in Aspire.Hosting, e.g. DistributedApplication needs-author-action An issue or pull request that requires more info or actions from the author.

Comments

@kkirkfield
Copy link

While exploring .NET Aspire I've run into many of the issues outlined in #1899. I started working on an attempt to improve the migrations experience for local development. Would appreciate thoughts and input 😃

Database migration issues with the current developer experience

When using the .NET EF Core tools to create and manage migrations, the AppHost project cannot currently be set as the startup project because the EF Core design-time library has no way to resolve the DbContext.

One workaround currently is to manually specify the connection string in the appsettings.json of the projects. This workaround is not great because you must duplicate the configuration in multiple places and cannot take advantage of service discovery and randomized ports. Another issue that this workaround doesn't solve for is waiting for the database container to be started.

New API proof of concept

The new API I'm suggesting addresses the issues above and better integrates the EF Core design-time tools into projects using .NET Aspire.

To use the API below, the developer just needs to chain .WithDesignTimeDbContext<TResource, TDbContext>() to any database resource builder in the AppHost project.

Example:

using RazorPagesApp.Data;

var builder = DistributedApplication.CreateBuilder(args);

var database = builder.AddPostgres("postgres")
    .AddDatabase("database")
    .WithDesignTimeDbContext<PostgresDatabaseResource, ApplicationDbContext>();

builder.AddProject<Projects.RazorPagesApp>("app")
    .WithReference(database)
    .WithLaunchProfile("https");

builder.Build() .Run();

ResourceBuilderExtensions.cs

This extension method is responsible for registering the IDbContextFactory with the IHost services provider for the AppHost project. This allows the HostFactoryResolver used by the EF Core tools to resolve the DbContext when the AppHost project is the startup project. It also connects the lifecycle hooks in .NET Aspire to the factory in order to create the DbContext at runtime after the database endpoints are running.

using Aspire.Hosting.Lifecycle;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Aspire.Hosting.ApplicationModel;

public static class ResourceBuilderExtensions
{
    public static IResourceBuilder<TResource> WithDesignTimeDbContext<TResource, TDbContext>(
        this IResourceBuilder<TResource> builder)
        where TResource : IResourceWithConnectionString
        where TDbContext : DbContext
    {
        var resourceName = builder.Resource.Name;

        builder.ApplicationBuilder.Services.AddDbContextFactory<TDbContext, DesignTimeDbContextFactory<TDbContext>>();

        builder.ApplicationBuilder.Services.AddLifecycleHook(services =>
        {
            var dbContextFactory = (DesignTimeDbContextFactory<TDbContext>)services.GetRequiredService<IDbContextFactory<TDbContext>>();

            return new ConnectionStringAvailableLifecycleHook(resourceName, dbContextFactory.SetConnectionString);
        });

        return builder;
    }
}

DesignTimeDbContextFactory.cs

This class is responsible for creating the DbContext at runtime that will be used by the EF Core tools. It uses a TaskCompletionSource to wait for the .NET Aspire lifecycle hooks to indicate that the database endpoints are running.

Currently this proof of concept is hard-coded to use Postgres, but this can be generalized in the final version.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Aspire.Hosting.ApplicationModel;

public class DesignTimeDbContextFactory<TDbContext>(
    IServiceProvider serviceProvider)
    : IDbContextFactory<TDbContext>
    where TDbContext : DbContext
{
    private readonly IServiceProvider _serviceProvider = serviceProvider;

    private string? _connectionString;
    private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);

    public TDbContext CreateDbContext()
    {
        return Task.Run(() => CreateDbContextAsync())
            .GetAwaiter()
            .GetResult();
    }

    public async Task<TDbContext> CreateDbContextAsync(
        CancellationToken cancellationToken = default)
    {
        if (_connectionString is null)
        {
            await Task.Run(() => _tcs.Task);
        }

        var options = new DbContextOptionsBuilder<TDbContext>()
            .UseNpgsql(_connectionString)
            .Options;

        return ActivatorUtilities.CreateInstance<TDbContext>(_serviceProvider, options);
    }

    public void SetConnectionString(string connectionString)
    {
        _connectionString = connectionString;
        _tcs.SetResult();
    }
}

ConnectionStringAvailableLifecycleHook.cs

This class is registered to listen to the lifecycle hooks. Once the connection string for the resource is available it triggers the TaskCompletionSource in the DesignTimeDbContextFactory to complete so that new DbContext instances can be created with the connection string of the running database.

using Aspire.Hosting.Lifecycle;

namespace Aspire.Hosting.ApplicationModel;

public class ConnectionStringAvailableLifecycleHook(
    string resourceName,
    Action<string> connectionStringAvailable)
    : IDistributedApplicationLifecycleHook
{
    private readonly string _resourceName = resourceName;
    private readonly Action<string> _connectionStringAvailable = connectionStringAvailable;

    public Task AfterEndpointsAllocatedAsync(
        DistributedApplicationModel appModel,
        CancellationToken cancellationToken = default)
    {
        var resource = appModel.Resources
            .OfType<IResourceWithConnectionString>()
            .FirstOrDefault(r => r.Name == _resourceName);

        if (resource is null)
        {
            return Task.CompletedTask;
        }

        var connectionString = resource.GetConnectionString();

        if (connectionString is not null)
        {
            _connectionStringAvailable.Invoke(connectionString);
        }

        return Task.CompletedTask;
    }
}

Issues with this proof of concept

Currently this proof of concept does not work because the EF Core tools stop the startup application after the service provider is resolved. This prevents the host from running and the lifecycle hooks above to not execute. See: https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs#L122 and https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.HostFactoryResolver/src/HostFactoryResolver.cs#L339

One solution to this would be to update the ResolveServiceProviderFactory() method to accept the stopApplication parameter from the EF Core tools command line. The ResolveHostFactory() method already has a parameter for this and the logic to keep the application running. This just needs to be passed down. Then the EF Core tools can stop the application after the DbContext is resolved.

The other improvement I would like to make is allowing the DbContextOptions to be configured as part of .WithDesignTimeDbContext() so that this new API works for all database resource types and to allow for further customization.

@dotnet-issue-labeler dotnet-issue-labeler bot added the area-app-model Issues pertaining to the APIs in Aspire.Hosting, e.g. DistributedApplication label Feb 5, 2024
@JamesNK
Copy link
Member

JamesNK commented Feb 13, 2024

@kkirkfield Hi, thanks for taking the time to look into improving migrations. I copied your source code to play around with your idea.

It looks like the goal of your changes is to make dotnet ef migration tool be able to target the Aspire host to create/manage/deploy migrations. Is that correct?

In your example below, where does the source code for ApplicationDbContext live? Is it being referenced from RazorPagesApp, or does it live somewhere else?

using RazorPagesApp.Data;

var builder = DistributedApplication.CreateBuilder(args);

var database = builder.AddPostgres("postgres")
    .AddDatabase("database")
    .WithDesignTimeDbContext<PostgresDatabaseResource, ApplicationDbContext>();

builder.AddProject<Projects.RazorPagesApp>("app")
    .WithReference(database)
    .WithLaunchProfile("https");

builder.Build() .Run();

If you have time, it would be useful to see a repo with your exepriment. That would give an overview of how your idea hangs together.

One solution to this would be to update the ResolveServiceProviderFactory() method to accept the stopApplication parameter from the EF Core tools command line. The ResolveHostFactory() method already has a parameter for this and the logic to keep the application running. This just needs to be passed down. Then the EF Core tools can stop the application after the DbContext is resolved.

Unfortunatly EF Core for .NET 8 (which Aspire is initially targeting) is done. Larger changes to EF's tooling most likely have to wait for .NET 9. But figuring out the problems now and coming up with a plan early is still extremely useful.

@kkirkfield
Copy link
Author

@JamesNK Here is the link to a sample project: https://github.com/kkirkfield/DotnetAspireIssue2090Example. The readme has some example steps you can run to see where it is failing.

It looks like the goal of your changes is to make dotnet ef migration tool be able to target the Aspire host to create/manage/deploy migrations. Is that correct?

Yes, I am trying to find a workflow that uses the .NET EF Core tools to add, remove, and apply migrations to the development database that is started by the AppHost. Ideally the RazorPagesApp in this example shouldn't be applying the migrations during development, so it makes sense that the AppHost should handle applying these migrations during startup.

In your example below, where does the source code for ApplicationDbContext live? Is it being referenced from RazorPagesApp, or does it live somewhere else?

Yes, in this sample it just lives in the RazorPagesApp, but in a real world project using a layered architecture. this might live in a separate library project.

@davidfowl
Copy link
Member

Adding a reference to the EF mode in the apphost feels like it'll land us back in dependency hell.

@cisionmarkwalls
Copy link

cisionmarkwalls commented Feb 16, 2024

My current workaround for this is to use TestContainers to create the database in the AppHost so I have a solid connection string and then run my migrations on the database in the AppHost before running the DistributedApplication:

var postgreSqlContainer = new PostgreSqlBuilder()
  .WithImage("postgres:15.1")
  .Build();
postgreSqlContainer.StartAsync();

Then pass the connectionstring of that db through to the containers that need it through .WithEnvironment.
Then between the DistributedApplicationBuilder Build and Run I execute my database migrations and setup the database with base data.

That works especially well for our team because we already use TestContainers everywhere for tests, but it would be an extra dependency here. I wonder if we could have some sort of "BuildAllTheThings" command on IResourceBuilder so we have a real connection string earlier in the process? I don't know the internals of that well enough to know if its possible.

@davidfowl
Copy link
Member

I would rather have code running at the right time than enabling individual resources to start a random arbitrary times (it gets trickier when there are dependencies as well).

Instead, you can write code that runs later, so you can kick of the migration process.

@JamesNK
Copy link
Member

JamesNK commented Feb 18, 2024

@kkirkfield I've checked in some changes that makes adding migrations better (no longer errors because of missing connection string). I've also added a migrations playground app: #2263

It doesn't attempt what you're doing here and allow the apphost to be the startup project, but it doesn't block doing that in the future either.

@JamesNK JamesNK changed the title Improve EF Core database migration experience Allow EF Core migration tooling to use app host as startup project Feb 18, 2024
@mitchdenny mitchdenny added the needs-author-action An issue or pull request that requires more info or actions from the author. label Apr 9, 2024
@mitchdenny mitchdenny added this to the Backlog milestone Apr 9, 2024
Copy link

This submission has been automatically marked as stale because it has been marked as requiring author action but has not had any activity for 14 days.
It will be closed if no further activity occurs within 7 days of this comment.

@dotnet-policy-service dotnet-policy-service bot removed this from the Backlog milestone Apr 30, 2024
@github-actions github-actions bot locked and limited conversation to collaborators Jun 1, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-app-model Issues pertaining to the APIs in Aspire.Hosting, e.g. DistributedApplication needs-author-action An issue or pull request that requires more info or actions from the author.
Projects
None yet
Development

No branches or pull requests

6 participants