Skip to content

Commit 3fc7d09

Browse files
tests: ensure DB schema is setup before the host starts
1 parent 18bf02c commit 3fc7d09

13 files changed

Lines changed: 167 additions & 25 deletions

File tree

‎src/ServiceControl.AcceptanceTests/Recoverability/When_hosting_error_ingestion_only.cs‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,13 @@ namespace ServiceControl.AcceptanceTests.Recoverability
1818
using NUnit.Framework;
1919
using Particular.ServiceControl.Hosting;
2020
using ServiceBus.Management.Infrastructure.Settings;
21-
using ServiceControl.ExternalIntegrations;
2221
using ServiceControl.Hosting.Commands;
2322
using ServiceControl.Infrastructure;
2423
using ServiceControl.MessageFailures;
2524
using ServiceControl.Operations;
25+
using ServiceControl.Persistence.DataMigration;
2626
using ServiceControl.Persistence.EFCore.DbContexts;
2727
using ServiceControl.Persistence.EFCore.Entities;
28-
using ServiceControl.Persistence.EFCore.Infrastructure;
29-
using ServiceControl.Recoverability;
3028
using ServiceControl.Transports;
3129

3230
class When_hosting_error_ingestion_only : AcceptanceTest
@@ -72,9 +70,38 @@ public async Task Should_ingest_without_an_endpoint_and_without_the_single_owner
7270
"InternalCustomChecksHostedService", // reports this node's ingestion health to the database
7371
"MetricsReporterHostedService",
7472
"HealthCheckPublisherHostedService", // inert, no IHealthCheckPublisher is registered
75-
"ExternalIntegrationRequestsDataStore" // its drain is inert here, nothing calls Subscribe
73+
"ExternalIntegrationRequestsDataStore", // its drain is inert here, nothing calls Subscribe
74+
"CheckpointTableIsReadable", // reads one table and refuses a start against a schema older than the build
75+
"RecordHostOpenedOnTarget", // this node writes to the target, so the stamp belongs here, and it upserts one settings row
76+
"FinishedCopyBeforeAnIngestionNodeOpens" // keeps this node out of a database a copy has not finished filling
7677
];
7778

79+
[Test]
80+
public async Task Should_refuse_to_start_while_a_copy_into_the_database_is_unfinished()
81+
{
82+
var settings = await CreateSettings();
83+
84+
await new SetupCommand().Execute(new HostArguments([]), settings);
85+
86+
var host = ErrorIngestionOnlyCommand.BuildHost(settings);
87+
88+
try
89+
{
90+
await host.Services.GetRequiredService<IMigrationCheckpointStore>().Upsert(
91+
new MigrationCheckpoint(MigrationCategoryIds.KnownEndpoints, MigrationCategoryState.InProgress, null, 0, 0, null, null, null, null, null, null));
92+
93+
var exception = Assert.ThrowsAsync<Exception>(() => host.StartAsync());
94+
95+
Assert.That(exception.Message, Does.Contain("has not finished")
96+
.And.Contain(MigrationCategoryIds.KnownEndpoints)
97+
.And.Contain(MigrationSettings.AllowIncompleteExitKey));
98+
}
99+
finally
100+
{
101+
await host.DisposeAsync();
102+
}
103+
}
104+
78105
[Test]
79106
public void Should_refuse_to_start_against_unsupported_storage()
80107
{

‎src/ServiceControl.Migration.AcceptanceTests/When_a_startup_check_fails.cs‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ namespace ServiceControl.Migration.AcceptanceTests;
99
using System.Threading;
1010
using System.Threading.Tasks;
1111
using Microsoft.EntityFrameworkCore;
12+
using Microsoft.EntityFrameworkCore.Infrastructure;
13+
using Microsoft.EntityFrameworkCore.Migrations;
1214
using NUnit.Framework;
1315
using ServiceControl.Hosting.Commands;
14-
using ServiceControl.Migration.Checks;
1516
using ServiceControl.Persistence.DataMigration;
1617
using ServiceControl.Persistence.EFCore.Abstractions;
1718

@@ -63,8 +64,19 @@ public async Task A_retry_history_depth_that_empties_the_migrated_table_is_refus
6364
[Test]
6465
public async Task A_target_whose_schema_migrations_were_never_applied_is_refused()
6566
{
66-
// Double quoted so PostgreSQL keeps the capitals, which SQL Server also accepts under its default QUOTED_IDENTIFIER.
67-
await QueryTarget(dbContext => dbContext.Database.ExecuteSqlRawAsync("""DELETE FROM "__EFMigrationsHistory" """));
67+
await QueryTarget(async dbContext =>
68+
{
69+
// EF's own history repository, because it is the only thing that knows where the history table is once the persister is given a schema.
70+
var history = dbContext.GetService<IHistoryRepository>();
71+
var applied = await history.GetAppliedMigrationsAsync();
72+
73+
foreach (var row in applied)
74+
{
75+
await dbContext.Database.ExecuteSqlRawAsync(history.GetDeleteScript(row.MigrationId));
76+
}
77+
78+
return applied.Count;
79+
});
6880

6981
var refusal = await RefusedStartup();
7082

‎src/ServiceControl.Persistence.Tests.InMemory/PersistenceTestsContext.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public Task Setup(IHostApplicationBuilder hostBuilder)
2424
return Task.CompletedTask;
2525
}
2626

27+
public Task InstallSchema(IHost host) => Task.CompletedTask;
28+
2729
public Task PostSetup(IHost host) => Task.CompletedTask;
2830

2931
public Task TearDown() => Task.CompletedTask;

‎src/ServiceControl.Persistence.Tests.PostgreSql/PersistenceTestsContext.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public async Task Setup(IHostApplicationBuilder hostBuilder)
5353
hostBuilder.Services.AddSingleton<TimeProvider>(FakeTime);
5454
}
5555

56-
public async Task PostSetup(IHost host)
56+
public async Task InstallSchema(IHost host)
5757
{
5858
this.host = host;
5959

@@ -62,6 +62,8 @@ public async Task PostSetup(IHost host)
6262
await db.Database.MigrateAsync();
6363
}
6464

65+
public Task PostSetup(IHost host) => Task.CompletedTask;
66+
6567
public async Task TearDown()
6668
{
6769
DeleteBodyStorage();

‎src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/RavenMigrationSourceTests.cs‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
namespace ServiceControl.Persistence.Tests.RavenDB.DataMigration;
22

33
using System;
4+
using System.Collections.Generic;
45
using System.Linq;
56
using System.Threading.Tasks;
67
using NUnit.Framework;
@@ -92,15 +93,20 @@ public async Task Counts_a_category_with_nothing_in_it_as_zero()
9293
[Test]
9394
public async Task Reading_every_category_creates_no_index()
9495
{
95-
await SeedEndpointSettings(2);
96-
await MonitoringDataStore.CreateIfNotExists(new EndpointDetails { Name = "Sales.Orders", HostId = Guid.NewGuid(), Host = "HOST01" });
96+
await using var source = await OpenMigrationSource();
97+
98+
Assert.That(source.SupportedCategoryIds, Is.SubsetOf(Seeds.Keys), "A category read with nothing in it cannot show whether its reader builds an index, so a new reader needs its seed adding here.");
99+
100+
foreach (var categoryId in source.SupportedCategoryIds)
101+
{
102+
await Seeds[categoryId](this);
103+
}
97104

98105
var before = await IndexNames();
99106

100-
await using (var source = await OpenMigrationSource())
107+
foreach (var categoryId in source.SupportedCategoryIds)
101108
{
102-
await CollectBatches(source, KnownEndpointsCategory);
103-
await CollectBatches(source, EndpointSettingsCategory);
109+
await CollectBatches(source, MigrationCategoryRegistry.Find(categoryId));
104110
}
105111

106112
var after = await IndexNames();
@@ -112,6 +118,14 @@ public async Task Reading_every_category_creates_no_index()
112118
});
113119
}
114120

121+
// Keyed by category id so a reader added without seed data fails the index test by name instead of passing
122+
// over an empty collection.
123+
static readonly Dictionary<string, Func<RavenMigrationSourceTests, Task>> Seeds = new()
124+
{
125+
[MigrationCategoryIds.EndpointSettings] = tests => tests.SeedEndpointSettings(2),
126+
[MigrationCategoryIds.KnownEndpoints] = tests => tests.MonitoringDataStore.CreateIfNotExists(new EndpointDetails { Name = "Sales.Orders", HostId = Guid.NewGuid(), Host = "HOST01" })
127+
};
128+
115129
string DatabaseName => ((RavenPersisterSettings)PersistenceSettings).DatabaseName;
116130

117131
async Task<string[]> IndexNames() =>
@@ -120,7 +134,6 @@ async Task<string[]> IndexNames() =>
120134
const int IndexNamePageSize = 1024;
121135

122136
static readonly MigrationCategory EndpointSettingsCategory = MigrationCategoryRegistry.Find("EndpointSettings");
123-
static readonly MigrationCategory KnownEndpointsCategory = MigrationCategoryRegistry.Find("KnownEndpoints");
124137

125138
async Task SeedEndpointSettings(int count)
126139
{

‎src/ServiceControl.Persistence.Tests.RavenDB/PersistenceTestsContext.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ public async Task Setup(IHostApplicationBuilder hostBuilder)
5050
persistence.AddInstaller(hostBuilder.Services);
5151
}
5252

53+
public Task InstallSchema(IHost host) => Task.CompletedTask;
54+
5355
public async Task PostSetup(IHost host)
5456
{
5557
DocumentStore = await host.Services.GetRequiredService<IRavenDocumentStoreProvider>().GetDocumentStore();

‎src/ServiceControl.Persistence.Tests.SqlServer/EndpointSettingsKeyCollationTests.cs‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ namespace ServiceControl.Persistence.Tests;
55
using System.Linq;
66
using System.Threading.Tasks;
77
using Microsoft.EntityFrameworkCore;
8+
using Microsoft.EntityFrameworkCore.Infrastructure;
9+
using Microsoft.EntityFrameworkCore.Storage;
810
using Microsoft.Extensions.DependencyInjection;
911
using NUnit.Framework;
1012
using ServiceControl.Operations;
@@ -22,19 +24,26 @@ public async Task The_key_columns_own_collation_decides_a_merge_when_the_databas
2224

2325
using (var scope = ServiceProvider.CreateScope())
2426
{
25-
var database = scope.ServiceProvider.GetRequiredService<ServiceControlDbContext>().Database;
27+
var dbContext = scope.ServiceProvider.GetRequiredService<ServiceControlDbContext>();
28+
var database = dbContext.Database;
2629

2730
databaseIgnoresCase = await database
2831
.SqlQuery<int>($"SELECT CONVERT(int, DATABASEPROPERTYEX(DB_NAME(), 'ComparisonStyle')) & 1 AS [Value]")
2932
.SingleAsync() == 1;
3033

34+
// Named from the model, because the table sits in the configured schema when the persister has one.
35+
var entityType = dbContext.Model.FindEntityType(typeof(EndpointSettingsEntity));
36+
var table = dbContext.GetService<ISqlGenerationHelper>().DelimitIdentifier(entityType.GetTableName(), entityType.GetSchema());
37+
3138
// The column is given the opposite of the database default, because a test where the two agree cannot show which one decided.
32-
await database.ExecuteSqlRawAsync("""
39+
var recollate = $"""
3340
DECLARE @columnCollation sysname = IIF(CONVERT(int, DATABASEPROPERTYEX(DB_NAME(), 'ComparisonStyle')) & 1 = 1, N'Latin1_General_CS_AS', N'Latin1_General_CI_AS');
34-
ALTER TABLE [EndpointSettings] DROP CONSTRAINT [PK_EndpointSettings];
35-
EXEC (N'ALTER TABLE [EndpointSettings] ALTER COLUMN [Name] nvarchar(450) COLLATE ' + @columnCollation + N' NOT NULL');
36-
ALTER TABLE [EndpointSettings] ADD CONSTRAINT [PK_EndpointSettings] PRIMARY KEY ([Name]);
37-
""");
41+
ALTER TABLE {table} DROP CONSTRAINT [PK_EndpointSettings];
42+
EXEC (N'ALTER TABLE {table} ALTER COLUMN [Name] nvarchar(450) COLLATE ' + @columnCollation + N' NOT NULL');
43+
ALTER TABLE {table} ADD CONSTRAINT [PK_EndpointSettings] PRIMARY KEY ([Name]);
44+
""";
45+
46+
await database.ExecuteSqlRawAsync(recollate);
3847
}
3948

4049
foreach (var name in new[] { "Sales", "sales" })

‎src/ServiceControl.Persistence.Tests.SqlServer/PersistenceTestsContext.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public async Task Setup(IHostApplicationBuilder hostBuilder)
5252
hostBuilder.Services.AddSingleton<TimeProvider>(FakeTime);
5353
}
5454

55-
public async Task PostSetup(IHost host)
55+
public async Task InstallSchema(IHost host)
5656
{
5757
this.host = host;
5858

@@ -61,6 +61,8 @@ public async Task PostSetup(IHost host)
6161
await db.Database.MigrateAsync();
6262
}
6363

64+
public Task PostSetup(IHost host) => Task.CompletedTask;
65+
6466
public async Task TearDown()
6567
{
6668
DeleteBodyStorage();

‎src/ServiceControl.Persistence.Tests/EFCore/Migration/MigrationTargetReadinessTests.cs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ namespace ServiceControl.Persistence.Tests;
55
using System.Linq;
66
using System.Threading.Tasks;
77
using Microsoft.EntityFrameworkCore;
8+
using Microsoft.EntityFrameworkCore.Infrastructure;
9+
using Microsoft.EntityFrameworkCore.Migrations;
810
using Microsoft.Extensions.DependencyInjection;
911
using Microsoft.Extensions.Hosting;
1012
using NUnit.Framework;
@@ -46,8 +48,13 @@ public async Task The_schema_check_refuses_a_database_whose_migrations_have_not_
4648
using var scope = ServiceProvider.CreateScope();
4749
var dbContext = scope.ServiceProvider.GetRequiredService<ServiceControlDbContext>();
4850

49-
// Double quoted so PostgreSQL keeps the capitals, which SQL Server also accepts under its default QUOTED_IDENTIFIER.
50-
await dbContext.Database.ExecuteSqlRawAsync("""DELETE FROM "__EFMigrationsHistory" """);
51+
// EF's own history repository, because it is the only thing that knows where the history table is once the persister is given a schema.
52+
var history = dbContext.GetService<IHistoryRepository>();
53+
54+
foreach (var row in await history.GetAppliedMigrationsAsync())
55+
{
56+
await dbContext.Database.ExecuteSqlRawAsync(history.GetDeleteScript(row.MigrationId));
57+
}
5158

5259
var exception = Assert.ThrowsAsync<Exception>(() => Readiness.ContributedChecks().OfType<SchemaIsCurrentCheck>().Single().Run());
5360

@@ -123,7 +130,7 @@ public async Task The_target_answers_from_a_container_whose_hosted_services_have
123130

124131
await context.Setup(hostBuilder);
125132
var host = hostBuilder.Build();
126-
await context.PostSetup(host);
133+
await context.InstallSchema(host);
127134

128135
return (host, context);
129136
}

‎src/ServiceControl.Persistence.Tests/IPersistenceTestsContext.cs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ public interface IPersistenceTestsContext
1010
{
1111
Task Setup(IHostApplicationBuilder hostBuilder);
1212

13+
/// <summary>
14+
/// Puts the schema in place on the built host, before it is started, the way <c>--setup</c> does in
15+
/// production. The EF Core persisters refuse to start against a database whose schema predates the
16+
/// build, and that check runs before any hosted service, so migrating after the start is too late.
17+
/// </summary>
18+
Task InstallSchema(IHost host);
19+
1320
Task PostSetup(IHost host);
1421

1522
Task TearDown();

0 commit comments

Comments
 (0)