From bca750446f1069eb55d648be838be192d70c2d30 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 10 Nov 2026 17:42:08 +0100 Subject: [PATCH] perf(applications): bound correspondence reads to the current page --- JobTrackerApi.Tests/JobQueryParityTests.cs | 74 ++++++ .../Controllers/JobApplicationsController.cs | 14 +- .../PERF-07_APPLICATION_CORRESPONDENCE.md | 14 + .../evidence/perf-job-page/RED.trx | 92 +++++++ .../evidence/perf-job-page/RED.txt | Bin 0 -> 8858 bytes .../evidence/perf-job-page/TESTS.trx | 244 ++++++++++++++++++ .../evidence/perf-job-page/TESTS.txt | Bin 0 -> 2084 bytes 7 files changed, 434 insertions(+), 4 deletions(-) create mode 100644 docs/performance/PERF-07_APPLICATION_CORRESPONDENCE.md create mode 100644 docs/ui-modernisation/evidence/perf-job-page/RED.trx create mode 100644 docs/ui-modernisation/evidence/perf-job-page/RED.txt create mode 100644 docs/ui-modernisation/evidence/perf-job-page/TESTS.trx create mode 100644 docs/ui-modernisation/evidence/perf-job-page/TESTS.txt 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 0000000000000000000000000000000000000000..b381cee9bf4161445ce9814c781b3c40fff5c287 GIT binary patch literal 8858 zcmeI2ZEqVz5Xa{=65nB|5K2XzCTSa}5lA6*BoMR^r+I@b%dwqe>e#OBv@t?_bl~@& z@y3@nNiBgQtejRp-|fTf>^%H$cJ}`MD_OGdZD>b!U}I}p+g@3hyB!-@kG6_^!*$oH z*0AR`@wN%~yNnr7hV*UNI^%}cq4kWXw_f|ornGe_>feEyp4!w_xEnH8$4f#Rz?ZM> z%^VNzy0;%%{oFeeZNR#;=Xu zGP>QPeN;fG1a7t%IfcH8DDF6oA$2W!CRTIIb<=Az=*8z(-QmzmL`yNgVucAhRS1}UeG_6 zWpx+*envjxX9ns@lA3LHu9FG$pHZuK$-vuZ)^yn%xdfW?^@~D}_C8m=u_pA=`)N$k z(}?>4R3zapcWtgsfAEc4_k7lx-G@$5yZ0|VWnJHa*1~45(p~0BD-nkuDu3Ed z7jc%k5{J7;R_l7{Rxm>*1J`TrQ~a0ip1L2@-}4%^t$n~3LSNV0K-IBARw|qb3wZ%P zC68K8eD*F{JagL|!FwRqyX)GU+riA_IbreH!)w@*;;b>N6&A$0BDxZ`2^&w`W8@2q z-Z4j>X%DJ_FQRdkMf9+)187EEu|Ds2v)+7Dp1h45CUZZQ-7F-R>_?Zu1epmBs_w&K zTRV)2=vnw)v)6Ndt-1EIl!Yo`Ift%Ip`mbHh<8j8vPmc&{hzFFI>xqALi zN<=$b1^X>+q3fP{TW#UX(iTcw;9VnJC~cv9M`+)&u!Raayb8}X-js?HW1p&MFtT=w z44y_E6LBc^)c#lT&Kr+zPd(D<)6(|HBO+eqf>ac_EVBpI9{mBk@>SV`ULCb+#sAG7 z^s*hO;=3GmMy}vC$MzwihuF;{ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [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 0000000000000000000000000000000000000000..46ba5e37260f70eca2a72bf94caa2ae3d0caf385 GIT binary patch literal 2084 zcmdUwL2uJQ5QX0viT|+NKx(2!AP|*6Dg_Q4P(?ydzPPbdOw-tkU7BAHd~Y2$7EP-l zE<~$1yE8j)XJ=;L{Q4Ob^hu@8HPKS3rn*$l>{yiwbOXKRd!i#H`l>qWYUU@{EEpww z6AiH|m7!g+x{1sg3M8{azl zHjfi-VzfkGz?<_+qt^+t9GutqdU%Vx4Pl61bH-ym;^sl8tY>-`eZE;oU<}eVC+%N+ zhUD29X<}9$y4$XideBCTpWbIb&%0LG zZdKDoD(ES1{Ifn#>1HXjj@hizfzFsOsr;5--ss1g51z%>ex~v5>j>!jdxJ)L1@1^M z;@79F^paI;Yql^eq~M#s7I2w)t9@&M{}sO}dF$Bi0DaYCr~q%KST^8$81Xn_l*m$c z25vLsJBrc{d4`R7u10D;Z8+r{>n!f_ETF66=>_)1T35xr_Hc=}?RnE1O-=;v`?;|L%2V zD`8Aj+{0Ee7tY5iduoQG`-GU7sT?Ke%{j>Ela{vQkcxBv*w}2e`2$MoBRv+d6| h>j~bq*}rT1o}2gkrFV?zZq9e>+26mMewS=3`!_HjbOQhY literal 0 HcmV?d00001