From b8ec268736ab37f0e8cfc5e5c091cb90b40f813e Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 10:30:44 +0200 Subject: [PATCH] perf(db): add owner-prefixed hot-path indexes on JobApplications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add composite indexes (OwnerUserId, IsDeleted) — for the tenant-scoped list/board/stats/analytics queries that all filter !IsDeleted — and (OwnerUserId, FollowUpAt) for the reminders surface. Every JobApplication query is scoped by the OwnerUserId global filter first, so owner-prefixed composites are the useful shape; the pre-existing single OwnerUserId index is now a redundant prefix but kept to avoid churn. Status is intentionally excluded: Pomelo maps the unbounded string column to MariaDB longtext, which cannot be indexed without a prefix length. Applied via the startup schema reconciler (StartupInitializationExtensions), which is how this repo actually provisions schema/indexes on both providers (SQLite: CREATE INDEX IF NOT EXISTS; MariaDB: MySqlIndexExists-guarded CREATE INDEX) — NOT via EF migrations, whose committed ModelSnapshot is stale. OnModelCreating also declares the indexes for model consistency. Backend suite: 92/92 green. Co-Authored-By: Claude Opus 4.8 --- Data/JobTrackerContext.cs | 14 ++++++++++ .../StartupInitializationExtensions.cs | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 6067b28..b7c7f07 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -55,6 +55,20 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasIndex(j => j.OwnerUserId); + // Owner-prefixed composite indexes for the tenant-scoped hot paths. Every + // JobApplication query is scoped by the OwnerUserId global filter first, then + // filtered by IsDeleted (list/board/stats/analytics) or FollowUpAt (reminders). + // Status is intentionally excluded from the index because Pomelo maps the + // unbounded string column to longtext, which MariaDB cannot index without a + // prefix length. The actual index DDL is applied idempotently in + // StartupInitializationExtensions (this repo provisions schema via that + // reconciler, not via the EF ModelSnapshot, which is stale). + modelBuilder.Entity() + .HasIndex(j => new { j.OwnerUserId, j.IsDeleted }); + + modelBuilder.Entity() + .HasIndex(j => new { j.OwnerUserId, j.FollowUpAt }); + modelBuilder.Entity() .HasIndex(c => c.OwnerUserId); diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index fb76114..cd2b190 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -499,6 +499,16 @@ public static class StartupInitializationExtensions EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;"); EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;"); + // Hot-path composite indexes for tenant-scoped list/board/stats/analytics + // (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded + // on table existence: on a brand-new DB the table is created by Migrate() + // below, so the index is picked up on the next start. + if (HasTable(conn, "JobApplications")) + { + Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");"""); + Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");"""); + } + // Ensure data folder exists before creating/opening SQLite files. Directory.CreateDirectory(paths.DataRoot); } @@ -819,6 +829,22 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + // 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();