fix(database): qualify RD21 saves against pinned MariaDB 12.2.2

This commit is contained in:
cesnimda
2026-10-24 01:47:50 +02:00
parent 0d16a691e0
commit a2c9bda0d1
30 changed files with 2226 additions and 63 deletions
@@ -939,7 +939,7 @@ Canonical profile:
_db.ChangeTracker.Clear();
return Conflict(new { code = "revision_conflict", message = "This job changed while saving. Your draft was not saved. Reload and review before saving again." });
}
catch (DbUpdateException)
catch
{
_db.ChangeTracker.Clear();
throw;
@@ -216,8 +216,11 @@ public sealed partial class ProfileCvController : ControllerBase
var runsQuery = _db.CvExtractionRuns
.AsNoTracking()
.Where(x => x.OwnerUserId == user.Id)
.Select(x => new CvExtractionRunListItem(
.Where(x => x.OwnerUserId == user.Id);
var isSqlite = _db.Database.IsSqlite();
if (!isSqlite)
runsQuery = runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10);
var runs = await runsQuery.Select(x => new CvExtractionRunListItem(
x.Id,
x.Trigger,
x.Status,
@@ -229,10 +232,9 @@ public sealed partial class ProfileCvController : ControllerBase
x.NormalizerVersion,
x.LlmPromptVersion,
x.ErrorMessage,
null));
var runs = _db.Database.IsSqlite()
? (await runsQuery.ToListAsync(HttpContext.RequestAborted)).OrderByDescending(x => x.StartedAtUtc).Take(10).ToList()
: await runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10).ToListAsync(HttpContext.RequestAborted);
null)).ToListAsync(HttpContext.RequestAborted);
if (isSqlite)
runs = runs.OrderByDescending(x => x.StartedAtUtc).Take(10).ToList();
var runIds = runs.Select(run => run.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToList();
var operations = await _db.UserOperations.AsNoTracking()
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Data;
public static class DatabaseConfiguration
{
public static string GetProvider(IConfiguration configuration) =>
(configuration["Database:Provider"] ?? "mariadb").Trim().ToLowerInvariant() switch
{
"mysql" or "mariadb" => "mariadb",
"sqlite" => "sqlite",
_ => throw new InvalidOperationException("Database:Provider must be mariadb (or mysql) or explicitly sqlite for transitional preservation."),
};
public static string GetConnectionString(IConfiguration configuration, string sqlitePath)
{
var provider = GetProvider(configuration);
var connection = configuration.GetConnectionString("JobTracker");
if (!string.IsNullOrWhiteSpace(connection)) return connection;
if (provider == "sqlite") return $"Data Source={sqlitePath}";
throw new InvalidOperationException("ConnectionStrings:JobTracker is required for MariaDB. SQLite is only used when Database:Provider is explicitly sqlite.");
}
public static void Configure(DbContextOptionsBuilder options, IConfiguration configuration, string sqlitePath)
{
var provider = GetProvider(configuration);
var connection = GetConnectionString(configuration, sqlitePath);
if (provider == "mariadb")
{
var configuredVersion = configuration["Database:ServerVersion"] ?? "12.2.2";
if (!Version.TryParse(configuredVersion, out var version) || version.Major < 1 || version.Build < 0)
throw new InvalidOperationException("Database:ServerVersion must be a MariaDB version in major.minor.patch form.");
// AutoDetect would open a server connection while options are being registered.
options.UseMySql(connection, new MariaDbServerVersion(version), mysql => mysql.MigrationsAssembly("JobTrackerApi"));
}
else
options.UseSqlite(connection, sqlite => sqlite.MigrationsAssembly("JobTrackerApi"));
}
}
+1 -25
View File
@@ -97,31 +97,7 @@ builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
var cfg = sp.GetRequiredService<IConfiguration>();
var paths = sp.GetRequiredService<AppPaths>();
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs))
{
cs = $"Data Source={paths.GetDbPath()}";
provider = "sqlite";
}
if (provider is "mysql" or "mariadb")
{
// Avoid ServerVersion.AutoDetect here because it forces an immediate DB connection
// during service registration, which can crash the API if MariaDB is temporarily
// unavailable or on a different network during deploy startup.
options.UseMySql(cs, new MariaDbServerVersion(new Version(11, 0, 0)), mysql =>
{
mysql.MigrationsAssembly("JobTrackerApi");
});
}
else
{
options.UseSqlite(cs, sqlite =>
{
sqlite.MigrationsAssembly("JobTrackerApi");
});
}
DatabaseConfiguration.Configure(options, cfg, paths.GetDbPath());
// We create Identity tables on startup in environments where `dotnet ef` isn't available.
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
@@ -1,4 +1,5 @@
using Microsoft.Data.Sqlite;
using JobTrackerApi.Data;
namespace JobTrackerApi.Services
{
@@ -28,15 +29,8 @@ namespace JobTrackerApi.Services
{
_logger = logger;
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs))
{
cs = $"Data Source={paths.GetDbPath()}";
provider = "sqlite";
}
_connectionString = cs;
var provider = DatabaseConfiguration.GetProvider(cfg);
_connectionString = DatabaseConfiguration.GetConnectionString(cfg, paths.GetDbPath());
IsSupported = provider == "sqlite";
BackupsRoot = Path.Combine(paths.DataRoot, "backups");
_retainCount = Math.Clamp(cfg.GetValue("Backups:RetainCount", 14), 1, 365);
@@ -264,8 +264,8 @@ public static class StartupInitializationExtensions
var paths = scope.ServiceProvider.GetRequiredService<AppPaths>();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var useSqliteBootstrap = provider is not "mysql" and not "mariadb";
var provider = DatabaseConfiguration.GetProvider(app.Configuration);
var useSqliteBootstrap = provider == "sqlite";
// Reconciliation opens the file before EF creates it; enable WAL explicitly so readers do not block commits.
if (useSqliteBootstrap) db.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;");
@@ -519,6 +519,9 @@ public static class StartupInitializationExtensions
// Correspondences/Attachments) -- re-run once more after Migrate() below via
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
ReconcileCoreAppColumnsMySql(conn);
EnsureMySqlColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE `AspNetUsers` ADD COLUMN `FirstName` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE `AspNetUsers` ADD COLUMN `LastName` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE `AspNetUsers` ADD COLUMN `DisplayName` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;");
@@ -848,7 +851,7 @@ public static class StartupInitializationExtensions
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var runtimeProvider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var runtimeProvider = DatabaseConfiguration.GetProvider(app.Configuration);
using var conn = db.Database.GetDbConnection();
conn.Open();
if (!CoreSchemaReady(conn, runtimeProvider))
@@ -1,4 +1,11 @@
{
"Database": {
"Provider": "mariadb",
"ServerVersion": "12.2.2"
},
"ConnectionStrings": {
"JobTracker": "Server=127.0.0.1;Port=33306;Database=jobtracker_dev;User ID=jobtracker_dev;Password=jt-development-only;SslMode=None"
},
"Logging": {
"LogLevel": {
"Default": "Information",