Files
jobtrackingapp/docs/infrastructure/database-ownership.md
T
cesnimda 7f426e255c
CI and Deploy / test (push) Failing after 1m1s
CI and Deploy / deploy (push) Has been skipped
fix(infrastructure): support clean MariaDB initialization
A completely empty MariaDB database could not start: the reconciler assumed
migration-owned tables already existed, and migrations assumed reconciler-owned
tables already existed. Neither could go first. Existing databases worked, so
only fresh installs were affected.

Startup is now an explicit sequence: connect, reconcile, migrate, reconcile,
start. The reconciler runs twice because neither position alone works — pass 1
repairs legacy schemas and creates the reconciler-owned tables that migrations
reference, pass 2 picks up everything that could not exist yet on a fresh
database. Every statement is existence-guarded, so the second pass is a no-op
scan on a correct database.

Untangled the overlapping ownership:

- RuleSettings is migration-owned. The reconciler also created it, which made a
  clean install fail with "Table 'RuleSettings' already exists". It now only
  seeds the default row, and only once the table exists.
- The six CareerProfile child tables are reconciler-owned. Their migration was
  scaffolded against SQLite and indexed an unbounded longtext OwnerUserId, which
  exceeds MariaDB's 3072-byte key limit; it is now a no-op and the reconciler
  carries correct per-provider DDL. OwnerUserId and ItemKey are bounded to
  varchar(255) in the model so the index fits.
- Reconciler tables that reference another table are guarded on their parent, so
  pass 1 skips them on an empty database instead of failing on the foreign key.
- All index creation goes through one EnsureMySqlIndex helper, guarded on table
  existence as well as index existence. This removes ten copies of the raw block
  that crashed on a missing table.
- The DbContext-owned connection is no longer disposed by the reconciler, and
  Open() is guarded on connection state, so the second pass can reuse it.

Verified against MariaDB 11 and SQLite: empty MariaDB (40 tables, starts),
restart on the populated database (idempotent, rows preserved), empty MariaDB
via the Docker image, fresh SQLite (42 tables), and an existing partially
migrated SQLite dev database (34 tables upgraded to 44 with all 13 applications
and 8 companies intact). 329 backend tests pass in Release.

Ownership rules, startup order, fresh install and production upgrade are
documented in docs/infrastructure/database-ownership.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 11:51:55 +02:00

7.2 KiB

Database ownership and startup order

2026-07-19. Which component creates which table, in what order, and why a clean MariaDB install used to fail. Read this before adding a table or touching StartupInitializationExtensions.

The problem this document exists to prevent

EF Core bakes provider-specific type names into a migration at scaffold time. Every migration in this repo was scaffolded against SQLite, so run against MariaDB it emits:

  • TEXT for DateTimeOffset and every unbounded string
  • INTEGER for bool and int
  • a PRIMARY KEY with no AUTO_INCREMENT

A composite index over one of those TEXT/longtext columns then exceeds MySQL's 3072-byte key limit and startup dies with Specified key was too long. This is not theoretical — it crashed production once (Phase 4 CvVariants) and made every clean MariaDB install fail until 2026-07-19.

Rule: a table whose migration was scaffolded against SQLite must not be created by that migration on MariaDB. Empty the migration and give the table to the reconciler, which carries correct DDL per provider.

Startup order

InitializeJobTrackerAsync runs exactly this sequence:

1. Connect
2. ReconcileSchema()      ← pass 1: repair existing schema, create reconciler-owned tables
3. Database.Migrate()     ← create every migration-owned table
4. ReconcileSchema()      ← pass 2: everything pass 1 had to skip
5. Seed admin, start services

Why the reconciler runs twice

Neither position alone works:

  • Pass 1 must come first. A legacy database has hand-added columns and Identity tables that predate the migrations; without repairing them (and stamping the legacy migration id into __EFMigrationsHistory) Migrate() collides with them. AddCareerProfileRelationalChildren also adds children that reference CareerProfiles, a reconciler-owned table — so it must exist before migrations run.
  • Pass 2 must come after. On a brand-new database the migration-owned tables do not exist during pass 1, so every reconciler table that references one (FK into JobApplications) is skipped, as are the index and AUTO_INCREMENT repairs.

Every statement in ReconcileSchema is existence-guarded, so the second pass is a no-op scan on an already-correct database. Two consequences worth knowing:

  • The DbConnection is not wrapped in using — it belongs to the DbContext, and disposing it in pass 1 made pass 2 throw ObjectDisposedException.
  • conn.Open() is guarded on ConnectionState, because pass 2 may inherit an open connection.

Ownership

Migration-owned

Created by EF migrations, never by the reconciler:

Companies, JobApplications, Jobs, Correspondences, Attachments, JobEvents, RuleSettings, and the ASP.NET Identity tables.

The reconciler may repair these (add a missing column, add an index, fix a non-AUTO_INCREMENT primary key) and may seed the default RuleSettings row — but it must never CREATE TABLE them. It used to create RuleSettings, which is precisely why a clean install failed with Table 'RuleSettings' already exists once Migrate() reached the initial migration.

Reconciler-owned

Created by StartupInitializationExtensions, with a no-op migration holding the model snapshot:

UserRuleSettings, SystemEmailSettings, CvUploadArtifacts, CvExtractionRuns, GmailConnections, MicrosoftGraphConnections, ImapConnections, TailoredCvDrafts, CareerProfiles, CareerProfileVersions, the six CareerProfile children (CareerExperiences, CareerEducations, CareerSkills, CareerProjects, CareerCertifications, CareerLanguages), InterviewPrepNotes, AiWorkspaceNotes, CvVariants, CvVariantVersions, AiInteractions, ApplicationChecklistItems, TwoFactorRecoveryCodes, TrustedDevices, UserSessions.

No-op migrations, each with a comment explaining why:

Migration Tables
20260717222917_AddCareerProfileRelationalChildren the six CareerProfile children
20260718074509_AddCvVariants CvVariants, CvVariantVersions
20260718131138_AddAiInteractions AiInteractions
20260719085904_AddApplicationChecklistItems ApplicationChecklistItems
20260719094728_SyncCareerChildKeyLengths snapshot sync only

Dependency guards

A reconciler table that references another table is guarded on its parent existing, so pass 1 skips it on a fresh database and pass 2 creates it:

Table Waits for
TailoredCvDrafts, InterviewPrepNotes, AiWorkspaceNotes, CvVariants, AiInteractions, ApplicationChecklistItems JobApplications (migration-owned)
CvVariantVersions CvVariants
CareerProfileVersions, the six CareerProfile children CareerProfiles
CvExtractionRuns CvUploadArtifacts

Index creation goes through one helper, EnsureMySqlIndex, which is guarded on table existence as well as index existence — repairing an absent table is not pass 1's job.

Adding a new table

  1. Add the entity and its DbSet, and bound every indexed string with HasMaxLength — an unbounded string becomes longtext, which MariaDB cannot index without a prefix length. This is what broke the CareerProfile children.
  2. dotnet ef migrations add …, then empty the Up/Down and say why in a comment.
  3. Add SQLite DDL (CREATE TABLE IF NOT EXISTS) and MySQL DDL (int AUTO_INCREMENT, varchar(n), datetime(6), tinyint(1)) to the reconciler. Guard the MySQL create on any parent table.
  4. Create indexes via EnsureMySqlIndex / CREATE INDEX IF NOT EXISTS.
  5. Verify on a real MariaDB container — see below. EF InMemory will not catch any of this.

Fresh install

No manual database preparation. Point the app at an empty database and start it:

# MariaDB
Database__Provider=mysql \
ConnectionStrings__JobTracker="Server=…;Database=jobtracker;User=…;Password=…;" \
dotnet run --project JobTrackerApi/JobTrackerApi.csproj

# SQLite (default) — creates Data__Root/jobtracker.db
dotnet run --project JobTrackerApi/JobTrackerApi.csproj

Create the empty schema/database itself (CREATE DATABASE jobtracker;); the application builds everything inside it.

Production upgrade

Deploy and restart. The reconciler is idempotent and additive:

  • it never drops a table that holds rows (DropMalformedMySqlTable checks the row count first)
  • it only adds missing columns, tables and indexes
  • migrations already recorded in __EFMigrationsHistory are not re-run, so emptying a migration's Up changes nothing for an existing database

No downtime step, no manual SQL, no data migration.

Verified

All four scenarios, 2026-07-19, against MariaDB 11 and SQLite:

Scenario Result
Empty MariaDB 40 tables created, app starts
Populated MariaDB, restart idempotent — still 40 tables, rows preserved
Empty MariaDB via the Docker image 40 tables created, app starts
Fresh SQLite 42 tables created, app starts
Existing partially-migrated SQLite dev DB (34 tables) upgraded to 44 tables, 13 applications and 8 companies preserved

Column types on MariaDB spot-checked: int AUTO_INCREMENT primary keys, varchar(255) owner keys, datetime(6) timestamps, tinyint(1) booleans, and every composite index inside the key limit.