Switches the database from SQL Server to MariaDB

The target Pi only has MariaDB, and SQL Server has no ARM64 build at
all - not a config problem, a real gap discovered during deployment
setup. Swapped the EF Core provider, regenerated every migration,
updated connection strings and the backup script everywhere they
appear.

Took two tries to land on a provider that actually works: Pomelo
builds fine against this project's EF Core 10 packages but fails at
runtime (it's compiled against 9's internal API surface, which moved
in 10 wherever Identity/DataProtection force the newer packages).
Oracle's official provider builds and migrates fine but has a real
MariaDB bug in its own migration-lock code, reproduced against a live
database. Kept Oracle's provider and worked around just that one
broken method - everything else it does is correct - rather than
give up more of the stack to chase a workaround.

Verified against a real local MariaDB end to end: all three
migrations applied, both hosts start clean, full suite still green.
This commit is contained in:
2026-07-29 11:59:09 +02:00
parent 579e0ceaac
commit 33d18ebbf8
33 changed files with 575 additions and 1367 deletions
@@ -57,6 +57,72 @@
- **Artifacts**: `operations/deployment/deployment-plan.md`, `deployment-instructions.md`,
`rollback-plan.md`
### Database Provider Migration — SQL Server → MariaDB (2026-07-29)
Discovered while finalizing Deployment Setup: the target Pi only runs MariaDB, and Microsoft SQL
Server has **no ARM64 build at all** (the `mcr.microsoft.com/mssql/server` image is `linux/amd64`
only; Azure SQL Edge, the former ARM answer, is retired). This invalidates ASM-04. Confirmed no
other machine is available, and the eventual production host (`mijnhostingpartner.nl`) will also
run MariaDB, plus the workload is small enough that MariaDB's performance is not a concern — user
decided: switch the database provider, not the deployment target.
**What changed** (application code, not just Operations docs):
- `SlpModularCms.Core.csproj`: `Microsoft.EntityFrameworkCore.SqlServer``MySql.EntityFrameworkCore`
10.0.7 (Oracle's official provider)
- `UseSqlServer(...)``UseMySQL(...)` in `ServiceCollectionExtensions.cs`, `AvailabilityModule.cs`,
`MasterModule.cs`
- `DatabaseMigrationExtensions.cs` + its test: `Microsoft.Data.SqlClient.SqlException`
`MySql.Data.MySqlClient.MySqlException` for the transient-failure classifier
- All three DbContexts' migrations deleted and regenerated (`ApplicationDbContext`,
`AvailabilityDbContext`, `MasterDbContext`) — no MySQL/MariaDB model-compatibility issues surfaced
(no index-length problems, no raw SQL anywhere in the codebase to translate)
- Connection strings updated across `appsettings.json` (Api + Api.Slave, all three tiers) from
SQL Server format to MySQL format
- `README.md` dev setup: MariaDB container command replacing the SQL Server one, with a migration
note for anyone returning to old instructions
- `deployment-instructions.md` / `rollback-plan.md`: connection string format, backup script
rewritten around `mariadb-dump` (was `sqlcmd`/`BACKUP DATABASE`), restore procedure rewritten
**Provider selection — two failed attempts before the working one, both reproduced against a real
MariaDB instance, not just reasoned about:**
1. **Pomelo.EntityFrameworkCore.MySql** (the usual first choice, explicit first-class MariaDB
support) — caps at EF Core 9.x, no EF Core 10 release exists. A Pomelo maintainer states
([PR #2017](https://github.com/PomeloFoundation/Pomelo.EntityFrameworkCore.MySql/pull/2017))
that Pomelo 9 / EF Core 9 packages work fine on a net10.0 TFM — confirmed true only when nothing
else forces EF Core 10 packages. This project's `Microsoft.AspNetCore.Identity.EntityFrameworkCore`
and `Microsoft.AspNetCore.DataProtection.EntityFrameworkCore` are versioned in lockstep with the
.NET 10 runtime and hard-require EF Core >= 10.0.9, so the resolved `Microsoft.EntityFrameworkCore.Abstractions`
ends up at 10.0.9 regardless. Restore only warns (NU1608), and it builds — but fails at runtime
with `MissingMethodException: AbstractionsStrings.ArgumentIsEmpty` the moment EF tooling touches
a DbContext: Pomelo's compiled assembly calls an internal EF Core 9 helper that no longer exists
in the 10.0.9 assembly actually loaded.
2. **MySql.EntityFrameworkCore 10.0.7** (Oracle's official provider) — its net10.0 dependency group
targets EF Core 10.0.7, compatible with 10.0.9. Builds and migrates cleanly, but
`dotnet ef database update` against the real MariaDB throws
`InvalidCastException: Unable to cast object of type 'System.DBNull' to type 'System.Int64'` in
`MySQLHistoryRepository.AcquireDatabaseLock()` — a confirmed MariaDB-incompatibility bug (MariaDB's
`GET_LOCK()` apparently returns `NULL` in a case Oracle's code doesn't handle, and Oracle's
provider is tested against real MySQL Server, not MariaDB). This isn't limited to CLI tooling —
the same code path runs on every application startup via `MigrateCoreDatabase()`.
**Working solution**: kept Oracle's `MySql.EntityFrameworkCore` 10.0.7 (otherwise fully compatible)
and added `NonLockingMySQLHistoryRepository` (`SlpModularCms.Core/Hosting/`), wired in via
`options.ReplaceService<IHistoryRepository, NonLockingMySQLHistoryRepository>()` on all three
`AddDbContext` registrations. Oracle's internal `MySQLHistoryRepository` class can't be subclassed
(it's `internal`, only its constructor is public), so the workaround constructs a real instance of
it via reflection and forwards every `IHistoryRepository` member to it **except**
`AcquireDatabaseLock`/`AcquireDatabaseLockAsync`, which return a no-op lock instead of ever reaching
the broken `GET_LOCK` call. Accepted as safe because this deployment model never runs migrations
from more than one place at a time (`MigrateCoreDatabase()` at startup, one deploy at a time via the
atomic-release sequence) — a genuinely concurrent multi-instance migration race is not a scenario
this architecture produces.
**Verified end to end**: all three migrations applied successfully to the user's real local
MariaDB (`dotnet ef database update`, all expected tables present including `DataProtectionKeys`
and the renamed Identity tables); both `SlpModularCms.Api` and `SlpModularCms.Api.Slave` start
cleanly against it (`/health` → 200, `MigrateCoreDatabase()` logs "already up to date" on the
second run); full backend suite re-confirmed at 372/372 passed, 0 build errors.
## Scope Decisions (from feature-selection.md)
- **Public website**: documentation/instructions only — where the website build lands in `wwwroot/`, how it coexists with `wwwroot/admin/`, and what a per-website workspace must deliver. The website's own build/deploy workflow stays out of scope (Q4 = A).
- **Environments**: local, test, production only.