From bd07876a4131a73635593d3a5d6f4e707ddf7d9c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 19:50:58 +0200 Subject: [PATCH] fix(db): stop startup crash from MySQL composite-index key length Prod was hard-down: InitializeJobTrackerAsync threw an unhandled MySqlException ("Specified key was too long; max key length is 3072 bytes") while creating IX_JobApplications_OwnerUserId_FollowUpAt, which crashed Program.Main before the app could start (surfaced to users as a 500 on Google sign-in, but really affected every request). Root cause: this reconciler assumes OwnerUserId is varchar(255), but the live column was provisioned wider by an earlier EF migration, close enough to the utf8mb4 3072-byte limit that pairing it with a second column tips a composite index over. Fix: - Prefix-index OwnerUserId at 191 chars (safe under the legacy 767-byte-per-column limit, still far wider than the GUID-like Identity ids actually stored) in every composite/unique index that includes it, so index creation no longer depends on the column's actual declared width. - Wrap each CREATE INDEX in try/catch + LogWarning instead of letting it propagate: a schema reconciler is best-effort and one failed index must never crash startup, matching the existing non-fatal pattern already used a few lines below for legacy-schema ownership claims. Backend build + full test suite (177 passing) verified green. --- .../StartupInitializationExtensions.cs | 138 ++++++------------ 1 file changed, 47 insertions(+), 91 deletions(-) diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index c3cf867..3665ba8 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -977,105 +977,61 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } - if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) + // Schema reconciliation must never crash app startup: an index that fails + // (e.g. combined key exceeds MySQL's 3072-byte limit because an older + // migration made OwnerUserId wider than the varchar(255) this reconciler + // assumes) is logged and skipped rather than taking prod down. OwnerUserId + // is prefix-indexed at 191 chars (safe under utf8mb4's 767-byte legacy + // per-column key limit, and far longer than the GUID-like Identity ids + // actually stored there) so composite indexes stay well under the cap + // regardless of the column's declared width. + void TryCreateIndex(string table, string indexName, string columnsSql) { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); + if (MySqlIndexExists(conn, table, indexName)) return; + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"CREATE INDEX `{indexName}` ON `{table}` ({columnsSql});"; + cmd.ExecuteNonQuery(); + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Skipping index {Index} on {Table} during startup reconciliation.", indexName, table); + } } - if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId")) + void TryCreateUniqueIndex(string table, string indexName, string columnsSql) { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); + if (MySqlIndexExists(conn, table, indexName)) return; + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"CREATE UNIQUE INDEX `{indexName}` ON `{table}` ({columnsSql});"; + cmd.ExecuteNonQuery(); + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Skipping unique index {Index} on {Table} during startup reconciliation.", indexName, table); + } } + TryCreateIndex("Companies", "IX_Companies_OwnerUserId", "`OwnerUserId`(191)"); + TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId", "`OwnerUserId`(191)"); + // Hot-path composite indexes for tenant-scoped list/board/stats/analytics // (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). - if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_IsDeleted` ON `JobApplications` (`OwnerUserId`, `IsDeleted`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId_FollowUpAt` ON `JobApplications` (`OwnerUserId`, `FollowUpAt`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc` ON `CvUploadArtifacts` (`OwnerUserId`, `UploadedAtUtc`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_OwnerUserId_StartedAtUtc` ON `CvExtractionRuns` (`OwnerUserId`, `StartedAtUtc`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_GmailConnections_OwnerUserId` ON `GmailConnections` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_GmailConnections_OwnerUserId_GmailAddress` ON `GmailConnections` (`OwnerUserId`, `GmailAddress`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_MicrosoftGraphConnections_OwnerUserId` ON `MicrosoftGraphConnections` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_MicrosoftGraphConnections_OwnerUserId_MailAddress` ON `MicrosoftGraphConnections` (`OwnerUserId`, `MailAddress`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "ImapConnections", "IX_ImapConnections_OwnerUserId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_ImapConnections_OwnerUserId` ON `ImapConnections` (`OwnerUserId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);"; - cmd.ExecuteNonQuery(); - } - - if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId")) - { - using var cmd = conn.CreateCommand(); - cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);"; - cmd.ExecuteNonQuery(); - } + TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`"); + TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`"); + TryCreateIndex("CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc", "`OwnerUserId`(191), `UploadedAtUtc`"); + TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc", "`OwnerUserId`(191), `StartedAtUtc`"); + TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId", "`ArtifactId`"); + TryCreateIndex("GmailConnections", "IX_GmailConnections_OwnerUserId", "`OwnerUserId`(191)"); + TryCreateUniqueIndex("GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress", "`OwnerUserId`(191), `GmailAddress`(191)"); + TryCreateIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId", "`OwnerUserId`(191)"); + TryCreateUniqueIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress", "`OwnerUserId`(191), `MailAddress`(191)"); + TryCreateUniqueIndex("ImapConnections", "IX_ImapConnections_OwnerUserId", "`OwnerUserId`(191)"); + TryCreateUniqueIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId", "`OwnerUserId`(191), `JobApplicationId`"); + TryCreateIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId", "`JobApplicationId`"); } }