diff --git a/JobTrackerApi.Tests/JobQueryParityTests.cs b/JobTrackerApi.Tests/JobQueryParityTests.cs index 4618d187..47209be6 100644 --- a/JobTrackerApi.Tests/JobQueryParityTests.cs +++ b/JobTrackerApi.Tests/JobQueryParityTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using System.Data.Common; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; @@ -8,6 +9,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Moq; using Xunit; @@ -16,6 +18,78 @@ namespace JobTrackerApi.Tests; // Real SQLite: EF InMemory cannot prove LIKE, relational ordering or tenant joins. public sealed class JobQueryParityTests { + [Theory] + [InlineData(250)] + [InlineData(2500)] + public async Task Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(int size) + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var aggregate = new CorrespondenceAggregateRecorder(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).AddInterceptors(aggregate).Options; + var user = new Mock(); + user.SetupGet(x => x.UserId).Returns("owner"); + await using var db = new JobTrackerContext(options, user.Object); + await db.Database.EnsureCreatedAsync(); + db.Users.AddRange(new ApplicationUser { Id = "owner", UserName = "owner" }, new ApplicationUser { Id = "other", UserName = "other" }); + var company = new Company { Name = "Synthetic", OwnerUserId = "owner" }; + var jobs = Enumerable.Range(0, size).Select(i => new JobApplication + { + Company = company, OwnerUserId = "owner", JobTitle = $"Application {i:D4}", Status = "Applied", + DateApplied = new DateTime(2026, 10, 1), + }).ToList(); + db.JobApplications.AddRange(jobs); + await db.SaveChangesAsync(); + db.Correspondences.AddRange(jobs.Select(job => new Correspondence + { + JobApplicationId = job.Id, Content = "Synthetic mail", From = "Company", Date = new DateTime(2026, 10, 2), + })); + await db.SaveChangesAsync(); + user.SetupGet(x => x.UserId).Returns("other"); + var foreign = new JobApplication { Company = new Company { Name = "Foreign", OwnerUserId = "other" }, OwnerUserId = "other", JobTitle = "Foreign", Status = "Applied" }; + db.JobApplications.Add(foreign); + await db.SaveChangesAsync(); + db.Correspondences.Add(new Correspondence { JobApplicationId = foreign.Id, Content = "Foreign mail", From = "Company", Date = new DateTime(2026, 10, 3) }); + await db.SaveChangesAsync(); + user.SetupGet(x => x.UserId).Returns("owner"); + var controller = new JobApplicationsController(db, Mock.Of(), TestHostFactory.CreateUserManager().Object) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "owner")], "test")) } } + }; + + var result = await controller.GetAll(page: 2, pageSize: 15, sortBy: "jobTitle", sortDir: "asc"); + var page = Assert.IsType>(Assert.IsType(result.Result).Value); + Assert.Equal(size, page.Total); + var expected = jobs.Skip(15).Take(15).Select(job => job.Id).ToArray(); + Assert.Equal(expected, page.Items.Select(job => job.Id)); + var (sql, parameters) = Assert.Single(aggregate.Commands); + await using var replay = connection.CreateCommand(); + replay.CommandText = sql; + foreach (var parameter in parameters) replay.Parameters.AddWithValue(parameter.Key, parameter.Value ?? DBNull.Value); + var materialised = new List(); + await using (var reader = await replay.ExecuteReaderAsync()) + while (await reader.ReadAsync()) materialised.Add(reader.GetInt32(0)); + Assert.Equal(15, materialised.Count); + Assert.Equal(expected.Order(), materialised.Order()); + + aggregate.Commands.Clear(); + await controller.GetBoard(); + await controller.GetAll(q: "absent"); + Assert.Empty(aggregate.Commands); + } + + private sealed class CorrespondenceAggregateRecorder : DbCommandInterceptor + { + public List<(string Sql, Dictionary Parameters)> Commands { get; } = []; + + public override ValueTask> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (command.CommandText.Contains("MAX(", StringComparison.OrdinalIgnoreCase) && command.CommandText.Contains("\"Correspondences\"", StringComparison.Ordinal)) + Commands.Add((command.CommandText, command.Parameters.Cast().ToDictionary(p => p.ParameterName, p => p.Value))); + return ValueTask.FromResult(result); + } + } + [Theory] [InlineData("all")] [InlineData("search")] diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index d47f4620..8749c819 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -542,8 +542,7 @@ Canonical profile: var settings = await GetCachedRuleSettingsAsync(cancellationToken); var now = DateTime.Now; - var lastMsg = await _db.Correspondences - .AsNoTracking() + Task> LoadLatestMessagesAsync(IQueryable messages) => messages .GroupBy(c => c.JobApplicationId) .Select(g => new { JobApplicationId = g.Key, Last = g.Max(x => x.Date) }) .ToDictionaryAsync(x => x.JobApplicationId, x => (DateTime?)x.Last, cancellationToken); @@ -556,11 +555,12 @@ Canonical profile: { // Both filters depend on rule evaluation and workflow signals that include normalized // note content, so evaluate before pagination to keep totals/pages correct. + var latestMessages = await LoadLatestMessagesAsync(_db.Correspondences.AsNoTracking()); var pre = await query.ToListAsync(cancellationToken); var filtered = new List(); foreach (var j in pre) { - lastMsg.TryGetValue(j.Id, out var lm); + latestMessages.TryGetValue(j.Id, out var lm); var d = RulesEngine.Evaluate(settings, j, now, lm); var signal = BuildWorkflowSignal(j, d); var matchesFollowUp = !needsFollowUp || d.NeedsFollowUp; @@ -592,7 +592,7 @@ Canonical profile: var dtoItems = new List(); foreach (var j in pageItems) { - lastMsg.TryGetValue(j.Id, out var lm); + latestMessages.TryGetValue(j.Id, out var lm); var d = RulesEngine.Evaluate(settings, j, now, lm); dtoItems.Add(BuildJobApplicationDto(j, d)); } @@ -623,6 +623,12 @@ Canonical profile: .Take(pageSize) .ToListAsync(cancellationToken); + if (items.Count == 0) return Ok(new PagedResult([], total, page, pageSize)); + + var pageIds = items.Select(job => job.Id).ToArray(); + var lastMsg = await LoadLatestMessagesAsync(_db.Correspondences.AsNoTracking() + .Where(message => pageIds.Contains(message.JobApplicationId))); + var dtos = new List(); foreach (var j in items) { diff --git a/docs/performance/PERF-07_APPLICATION_CORRESPONDENCE.md b/docs/performance/PERF-07_APPLICATION_CORRESPONDENCE.md new file mode 100644 index 00000000..3b0c7328 --- /dev/null +++ b/docs/performance/PERF-07_APPLICATION_CORRESPONDENCE.md @@ -0,0 +1,14 @@ +# PERF-07 · Page-scoped application correspondence + +Base `327d99dfdacc16f72e008807ea3f27805cf0c570` on `codex/a3-ui-10`. The ordinary applications table fetched the latest correspondence group for every owned application before paging. It now queries the existing latest-message aggregate once for the current page IDs. The normal board and empty page skip that unused aggregate. Follow-up and readiness filters still evaluate their existing full candidate set before paging to preserve membership and totals. + +The focused SQLite test records and replays the actual SQL aggregate. Each fixture contains one correspondence group per owned application, one foreign application with correspondence, and a 15-row second page. The old runtime returned 250 and 2,500 aggregate rows. The new runtime returns exactly the 15 groups belonging to the page in both fixtures. The ordinary board and empty page now issue zero correspondence aggregate commands. The existing tenant filter, ordering, latest-message logic and response shapes are unchanged. + +| Owned applications | Before aggregate rows | After aggregate rows | +| ---: | ---: | ---: | +| 250 | 250 | 15 | +| 2,500 | 2,500 | 15 | + +**PASSED** 36 selected Release tests with pinned SDK 9.0.317 and no restore. 18 JobQueryParity, 10 ApplicationCalendar, five RulesEngineProspect and three OwnershipGuard cases. The 16 existing parity cases cover ordering, filters, board, ownership and pagination. Two expected pre-fix failures are preserved. [Native red and final test logs](../ui-modernisation/evidence/perf-job-page/) + +These counts measure rows returned by the aggregate under a disposable SQLite fixture. MariaDB plan/rows examined, API response time and production performance remain unmeasured. No index, migration or worker configuration changed. diff --git a/docs/ui-modernisation/evidence/perf-job-page/RED.trx b/docs/ui-modernisation/evidence/perf-job-page/RED.trx new file mode 100644 index 00000000..fd679e5d --- /dev/null +++ b/docs/ui-modernisation/evidence/perf-job-page/RED.trx @@ -0,0 +1,92 @@ + + + + + + + + + + + Assert.Equal() Failure: Values differ +Expected: 15 +Actual: 2500 + at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) in C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs:line 72 + at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) in C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs:line 78 + at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) in C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs:line 78 + at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) in C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs:line 78 +--- End of stack trace from previous location --- + + + + + + + Assert.Equal() Failure: Values differ +Expected: 15 +Actual: 250 + at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) in C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs:line 72 + at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) in C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs:line 78 + at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) in C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs:line 78 + at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) in C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs:line 78 +--- End of stack trace from previous location --- + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 9.0.19) +[xUnit.net 00:00:00.50] Discovering: JobTrackerApi.Tests +[xUnit.net 00:00:00.65] Discovered: JobTrackerApi.Tests +[xUnit.net 00:00:00.66] Starting: JobTrackerApi.Tests +[xUnit.net 00:00:06.90] Assert.Equal() Failure: Values differ +[xUnit.net 00:00:06.90] Expected: 15 +[xUnit.net 00:00:06.90] Actual: 2500 +[xUnit.net 00:00:06.90] Stack Trace: +[xUnit.net 00:00:06.91] C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs(72,0): at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) +[xUnit.net 00:00:06.91] C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs(78,0): at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) +[xUnit.net 00:00:06.91] C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs(78,0): at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) +[xUnit.net 00:00:06.91] C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs(78,0): at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) +[xUnit.net 00:00:06.91] --- End of stack trace from previous location --- +[xUnit.net 00:00:07.34] Assert.Equal() Failure: Values differ +[xUnit.net 00:00:07.34] Expected: 15 +[xUnit.net 00:00:07.34] Actual: 250 +[xUnit.net 00:00:07.34] Stack Trace: +[xUnit.net 00:00:07.34] C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs(72,0): at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) +[xUnit.net 00:00:07.34] C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs(78,0): at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) +[xUnit.net 00:00:07.34] C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs(78,0): at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) +[xUnit.net 00:00:07.34] C:\Users\Connor\.codex\worktrees\direct-roadmap-integration\Job tracker\JobTrackerApi.Tests\JobQueryParityTests.cs(78,0): at JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(Int32 size) +[xUnit.net 00:00:07.34] --- End of stack trace from previous location --- +[xUnit.net 00:00:07.35] Finished: JobTrackerApi.Tests + + + + + [xUnit.net 00:00:06.90] JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(size: 2500) [FAIL] + + + [xUnit.net 00:00:07.34] JobTrackerApi.Tests.JobQueryParityTests.Ordinary_table_materialises_correspondence_groups_only_for_the_returned_page(size: 250) [FAIL] + + + + \ No newline at end of file diff --git a/docs/ui-modernisation/evidence/perf-job-page/RED.txt b/docs/ui-modernisation/evidence/perf-job-page/RED.txt new file mode 100644 index 00000000..b381cee9 Binary files /dev/null and b/docs/ui-modernisation/evidence/perf-job-page/RED.txt differ diff --git a/docs/ui-modernisation/evidence/perf-job-page/TESTS.trx b/docs/ui-modernisation/evidence/perf-job-page/TESTS.trx new file mode 100644 index 00000000..3392c9ba --- /dev/null +++ b/docs/ui-modernisation/evidence/perf-job-page/TESTS.trx @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 9.0.19) +[xUnit.net 00:00:00.32] Discovering: JobTrackerApi.Tests +[xUnit.net 00:00:00.47] Discovered: JobTrackerApi.Tests +[xUnit.net 00:00:00.48] Starting: JobTrackerApi.Tests +[xUnit.net 00:00:10.61] Finished: JobTrackerApi.Tests + + + + \ No newline at end of file diff --git a/docs/ui-modernisation/evidence/perf-job-page/TESTS.txt b/docs/ui-modernisation/evidence/perf-job-page/TESTS.txt new file mode 100644 index 00000000..46ba5e37 Binary files /dev/null and b/docs/ui-modernisation/evidence/perf-job-page/TESTS.txt differ