Skip to content

Commit f1246ef

Browse files
author
aligneddev
committed
feat: Implement SQLite migration compatibility workarounds
1 parent 478cb04 commit f1246ef

4 files changed

Lines changed: 128 additions & 57 deletions

File tree

src/BikeTracking.Api.Tests/Endpoints/RidesEndpointsSqliteIntegrationTests.cs

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,12 @@
77
using BikeTracking.Api.Infrastructure.Persistence.Entities;
88
using Microsoft.AspNetCore.TestHost;
99
using Microsoft.EntityFrameworkCore;
10+
using Microsoft.Extensions.Logging.Abstractions;
1011

1112
namespace BikeTracking.Api.Tests.Endpoints;
1213

1314
public sealed class RidesEndpointsSqliteIntegrationTests
1415
{
15-
private static readonly string[] SqliteUnsupportedConstraintMigrations =
16-
[
17-
"20260327165005_AddRideMilesUpperBound",
18-
"20260327171355_FixRideMilesUpperBoundNumericComparison",
19-
];
20-
2116
[Fact]
2217
public async Task GetRideHistory_WithSqliteMigrationsApplied_ReturnsGasPricePerGallon()
2318
{
@@ -87,25 +82,10 @@ public static async Task<SqliteRidesApiHost> StartAsync()
8782
{
8883
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
8984

90-
// SQLite cannot execute DropCheckConstraint migrations. Apply the
91-
// supported migrations, mark those legacy migrations as applied,
92-
// then continue migration so endpoint queries run on migrated schema.
93-
await dbContext.Database.MigrateAsync("20260327000000_AddRideVersion");
94-
95-
var applied = (await dbContext.Database.GetAppliedMigrationsAsync()).ToHashSet();
96-
foreach (var migration in SqliteUnsupportedConstraintMigrations)
97-
{
98-
if (applied.Contains(migration))
99-
{
100-
continue;
101-
}
102-
103-
await dbContext.Database.ExecuteSqlRawAsync(
104-
"INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES ({0}, {1})",
105-
migration,
106-
"10.0.5"
107-
);
108-
}
85+
await SqliteMigrationBootstrapper.ApplyCompatibilityWorkaroundsAsync(
86+
dbContext,
87+
NullLogger.Instance
88+
);
10989

11090
await dbContext.Database.MigrateAsync();
11191
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using System.Reflection;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace BikeTracking.Api.Infrastructure.Persistence;
6+
7+
public static class SqliteMigrationBootstrapper
8+
{
9+
private static readonly string[] UnsupportedConstraintMigrations =
10+
[
11+
"20260327165005_AddRideMilesUpperBound",
12+
"20260327171355_FixRideMilesUpperBoundNumericComparison",
13+
];
14+
15+
public static async Task ApplyCompatibilityWorkaroundsAsync(
16+
BikeTrackingDbContext dbContext,
17+
ILogger logger
18+
)
19+
{
20+
if (dbContext.Database.ProviderName != "Microsoft.EntityFrameworkCore.Sqlite")
21+
{
22+
return;
23+
}
24+
25+
await ClearStaleMigrationLockAsync(dbContext, logger);
26+
27+
var applied = (await dbContext.Database.GetAppliedMigrationsAsync()).ToHashSet();
28+
var requiresSqliteWorkaround = UnsupportedConstraintMigrations.Any(migration =>
29+
!applied.Contains(migration)
30+
);
31+
32+
if (!requiresSqliteWorkaround)
33+
{
34+
return;
35+
}
36+
37+
await dbContext.Database.MigrateAsync("20260327000000_AddRideVersion");
38+
39+
applied = (await dbContext.Database.GetAppliedMigrationsAsync()).ToHashSet();
40+
var productVersion = GetEfProductVersion();
41+
42+
foreach (var migration in UnsupportedConstraintMigrations)
43+
{
44+
if (applied.Contains(migration))
45+
{
46+
continue;
47+
}
48+
49+
await dbContext.Database.ExecuteSqlRawAsync(
50+
"INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES ({0}, {1})",
51+
migration,
52+
productVersion
53+
);
54+
}
55+
}
56+
57+
private static async Task ClearStaleMigrationLockAsync(
58+
BikeTrackingDbContext dbContext,
59+
ILogger logger
60+
)
61+
{
62+
var connection = dbContext.Database.GetDbConnection();
63+
var shouldCloseConnection = connection.State != System.Data.ConnectionState.Open;
64+
65+
if (shouldCloseConnection)
66+
{
67+
await connection.OpenAsync();
68+
}
69+
70+
int hasLockTable;
71+
await using (var command = connection.CreateCommand())
72+
{
73+
command.CommandText =
74+
"SELECT COUNT(*) FROM \"sqlite_master\" WHERE \"name\" = '__EFMigrationsLock' AND \"type\" = 'table'";
75+
var scalar = await command.ExecuteScalarAsync();
76+
hasLockTable = Convert.ToInt32(scalar);
77+
}
78+
79+
if (shouldCloseConnection)
80+
{
81+
await connection.CloseAsync();
82+
}
83+
84+
if (hasLockTable == 0)
85+
{
86+
return;
87+
}
88+
89+
// If a previous process crashed during migration, SQLite can retain a stale
90+
// lock row and cause all future startups to wait forever acquiring the lock.
91+
// Only remove lock rows older than 30 seconds to avoid interfering with
92+
// legitimate in-progress migrations.
93+
var clearedLockRows = await dbContext.Database.ExecuteSqlRawAsync(
94+
"DELETE FROM \"__EFMigrationsLock\" WHERE \"Id\" = 1 AND \"Timestamp\" < datetime('now', '-30 seconds')"
95+
);
96+
if (clearedLockRows > 0)
97+
{
98+
logger.LogWarning(
99+
"Cleared {RowCount} stale EF migration lock row(s) before startup migration.",
100+
clearedLockRows
101+
);
102+
}
103+
}
104+
105+
private static string GetEfProductVersion()
106+
{
107+
var infoVersion = typeof(DbContext)
108+
.Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
109+
?.InformationalVersion;
110+
111+
if (!string.IsNullOrWhiteSpace(infoVersion))
112+
{
113+
var plusIndex = infoVersion.IndexOf('+');
114+
return plusIndex > 0 ? infoVersion[..plusIndex] : infoVersion;
115+
}
116+
117+
return typeof(DbContext).Assembly.GetName().Version?.ToString() ?? "unknown";
118+
}
119+
}

src/BikeTracking.Api/Program.cs

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -108,35 +108,7 @@
108108
{
109109
var dbContext = scope.ServiceProvider.GetRequiredService<BikeTrackingDbContext>();
110110

111-
// SQLite cannot execute DropCheckConstraint migrations. Apply the
112-
// supported migrations, mark those legacy migrations as applied,
113-
// then continue migration so endpoint queries run on migrated schema.
114-
var sqliteUnsupportedConstraintMigrations = new[]
115-
{
116-
"20260327165005_AddRideMilesUpperBound",
117-
"20260327171355_FixRideMilesUpperBoundNumericComparison",
118-
};
119-
120-
var provider = dbContext.Database.ProviderName;
121-
if (provider == "Microsoft.EntityFrameworkCore.Sqlite")
122-
{
123-
await dbContext.Database.MigrateAsync("20260327000000_AddRideVersion");
124-
125-
var applied = (await dbContext.Database.GetAppliedMigrationsAsync()).ToHashSet();
126-
foreach (var migration in sqliteUnsupportedConstraintMigrations)
127-
{
128-
if (applied.Contains(migration))
129-
{
130-
continue;
131-
}
132-
133-
await dbContext.Database.ExecuteSqlRawAsync(
134-
"INSERT INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") VALUES ({0}, {1})",
135-
migration,
136-
"10.0.5"
137-
);
138-
}
139-
}
111+
await SqliteMigrationBootstrapper.ApplyCompatibilityWorkaroundsAsync(dbContext, app.Logger);
140112

141113
await dbContext.Database.MigrateAsync();
142114
}

src/BikeTracking.Frontend/.npmrc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Project-local npm hardening.
2-
# This complements the container's global npm config and can be tuned per-repo.
3-
min-release-age=1440
1+
# Project-local npm hardening.
2+
# This complements the container's global npm config and can be tuned per-repo.
3+
min-release-age=3
44
ignore-scripts=true

0 commit comments

Comments
 (0)