Table creation should be deferred until host is being started
It appears that Hangfire actually tries to create its tables in the call to MapHangfireDashboard. Unfortunately, this does not play nice if an IHostedService is used to initially create the database. After all, the host is started only after the middleware registrations have been made. As such, the application fails to create the Hangfire tables, because it attempts to do so before the database exists.
The problem would be gone if Hangfire would spin up its database in an IHostedService. Doing so has a number of additional advantages:
- Asynchronous activities, such as database interaction, belong in asynchronous places, not in the synchronous
Main()orConfigure()methods. - This puts control of the order of startup activities back in the hands of the developer.
- This is the industry standard for such startup tasks.
- Hooking up the Hangfire Dashboard in the middleware pipeline does not physically require access to the database. (Any checks that things exist as expected could then be performed in an
IHostedService, still before startup completes.)
I currently cannot properly work around this issue, because it is impossible to hook up the middleware pipeline after my migrations have been run. They migrations are used by multiple applications and therefore cannot (and generally should not) be moved out of IHostedService.
Also running into this problem.
I tried structuring my program like the following, but this fails because the MapHangfireDashboard call is trying to eagerly instantiate the job storage for some reason, instead having it be injected into the middleware on-demand.
var builder = WebApplication.CreateBuilder(new () { Args = args });
// ... services configuration
services.AddHangfire((config) =>
{
// Storage is configured below, after EF migrations,
// so the database has a chance to be created before Hangfire starts using it.
});
services.AddHangfireServer();
var app = builder.Build();
app.MapHangfireDashboard("/hangfiredashboard");
app.MapRazorPages();
app.MapDefaultControllerRoute();
// Initialize/migrate database.
using (var scope = app.Services.CreateScope())
{
var serviceScope = scope.ServiceProvider;
// Run database migrations.
using var db = serviceScope.GetRequiredService<AppDbContext>();
db.Database.SetCommandTimeout(TimeSpan.FromMinutes(10));
db.Database.Migrate();
// Configure Hangfire storage only after the database has definitely been created.
serviceScope.GetRequiredService<Hangfire.IGlobalConfiguration>().UseSqlServerStorage(connString);
// Setup hangfire background jobs...
RecurringJob.AddOrUpdate<MyJob>(x => x.Invoke(), Cron.Hourly(0));
}
app.Run();
It would even nicer if I didn't have to the gymnastics of moving UseSqlServerStorage to be so late if adjustments were made as @Timovzl described to not try to access the database immediately upon invocation of UseSqlServerStorage.
Alternatively we can install the schema ourselves, but this got even more cumbersome of a workaround in 1.8 since we have to disable both PrepareSchemaIfNecessary and TryAutoDetectSchemaDependentOptions, as well as the need to pass several options into SqlServerObjectsInstaller that would normally be sourced from our Hangfire config in AddHangfire.
using var db = serviceScope.GetRequiredService<AppDbContext>();
await this.Database.MigrateAsync();
// Install Hangfire storage only after the database has definitely been created.
await db.Database.ExecuteSqlRawAsync(SqlServerObjectsInstaller.GetInstallScript(null, false));
@ascott18 For lack of a solution, I find your workaround of manually installing the schema very interesting.
Do you think we might be able to inject some dummy service to replace an original one, and actually keep PrepareSchemaIfNecessary enabled, and then intercept and remember the install script or its parameterization? If we could, that might make it easier for us to then run it ourselves.