perf(db): add owner-prefixed hot-path indexes on JobApplications

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 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-05 10:30:44 +02:00
parent 6cb593ab5c
commit b8ec268736
2 changed files with 40 additions and 0 deletions
+14
View File
@@ -55,6 +55,20 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<JobApplication>()
.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<JobApplication>()
.HasIndex(j => new { j.OwnerUserId, j.IsDeleted });
modelBuilder.Entity<JobApplication>()
.HasIndex(j => new { j.OwnerUserId, j.FollowUpAt });
modelBuilder.Entity<Company>()
.HasIndex(c => c.OwnerUserId);
@@ -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();