Skip to content

perf: paginate list endpoints, signed-JWT tokens, keyset cursor pagination#266

Merged
antosubash merged 7 commits into
mainfrom
worktree-pg-loadtest-perf
Jun 22, 2026
Merged

perf: paginate list endpoints, signed-JWT tokens, keyset cursor pagination#266
antosubash merged 7 commits into
mainfrom
worktree-pg-loadtest-perf

Conversation

@antosubash

Copy link
Copy Markdown
Owner

Summary

Performance work for running SimpleModule on PostgreSQL under load (Faker-seeded ~480k rows, k6 load-tested):

  • Pagination on all unbounded list endpointsskip/take (default 30, capped 200/500) on /api/users, /api/files, /api/tenants, /api/settings, /api/rate-limiting; defensive .Take(cap) on wholesale config/tree endpoints (feature flags, menus, folders, recurring jobs). Previously /api/users returned the entire table (~3.3 MB), /api/files ~4.6 MB.
  • Signed-JWT access tokensDisableAccessTokenEncryption() so the resource server verifies a signature instead of an RSA-OAEP decrypt on every request (auth codes / refresh tokens stay encrypted).
  • Keyset (cursor) pagination — opt-in ?before= on the audit-logs and email-messages lists, skipping the per-request COUNT(*) and deep-OFFSET row-skip. The React audit Browse and email History pages now use the cursors (Newest / Prev / Next).

Measured under k6 (40 VUs): full-mix throughput 147 → ~890 rps, bandwidth 6.8 GB → ~240 MB; /api/users p50 848 → ~10 ms. For the keyset endpoints in isolation: audit 534 → 5,929 rps, email 1,386 → 5,987 rps.

Verification

  • Browser-verified the cursor pagination at /audit-logs/browse and /email/history: Next adds the ?before= cursor and loads older rows, Previous returns to the first page, ordering is consistent (offset first page and keyset pages both Timestamp/CreatedAt DESC, Id DESC).
  • Local CI green: biome, validate-pages, validate:i18n, typecheck 14/14, dotnet build (no warnings-as-errors workaround), and all touched-module tests (Settings 127, AuditLogs 38, Email 57, Users 78, FileStorage 48, Tenants 17, RateLimiting 28, FeatureFlags 14, BackgroundJobs 127, OpenIddict 40).

verification

Also fixes two pre-existing CI failures on main (not caused by this work)

  • app.tsx:226 typecheck error — narrowed resolveLayout(page).default to React.ComponentType so Inertia's ComponentResolver union type-checks (persistent layout still read at runtime).
  • NU1903 — demoted to a warning in Directory.Build.props: a high-severity advisory in the transitive native lib SQLitePCLRaw.lib.e_sqlite3 (pulled by Microsoft.Data.Sqlite/EFCore.Sqlite 10.0.3, not directly referenced — can't be bumped here). Kept visible as a warning until a patched transitive ships.

Test plan

  • Reviewer loads /audit-logs/browse, clicks Next/Prev/Newest and confirms cursor paging
  • CI is green

…ation

- Add server-side pagination (skip/take, default 30) to all unbounded getall
  endpoints: users, files, tenants, settings, rate-limiting rules; defensive
  Take caps on wholesale config/tree endpoints (feature flags, menus, folders,
  recurring jobs).
- Disable OpenIddict access-token encryption (signed JWT instead of JWE) to
  cut per-request token-validation CPU.
- Add opt-in keyset (cursor) pagination via ?before= to the audit-logs and
  email-messages lists, skipping the per-request COUNT(*) and deep-OFFSET skip.
- Convert the audit Browse and email History React pages to keyset cursor
  pagination (Newest/Prev/Next with a sessionStorage cursor trail).
- AuditLogService: align offset default sort with keyset (Timestamp DESC, Id DESC
  tiebreaker) on managed providers so a cursor page is a true continuation of the
  offset first page; keep Id fallback and skip keyset on SQLite (no DateTimeOffset
  ORDER BY). EmailService: add Id tiebreaker to CreatedAt ordering (offset + keyset).
- SettingsService: order by (Key, Scope, UserId) — Key is non-unique, so paging
  was nondeterministic.
- Remove dead locale keys (AuditLogs Browse.Showing, Email History.Of).
- app.tsx: narrow resolveLayout(page).default to React.ComponentType so the
  Inertia ComponentResolver union type-checks (the persistent layout is read at
  runtime, unaffected). Fixes the typecheck error at app.tsx:226.
- Directory.Build.props: demote NU1903 to a warning (not error). It flags a
  high-severity advisory in the transitive native lib SQLitePCLRaw.lib.e_sqlite3
  pulled by Microsoft.Data.Sqlite/EFCore.Sqlite 10.0.3 — not directly referenced,
  so it can't be bumped here; kept visible as a warning until a patched transitive ships.
…t file listing

GetFilesAsync lists root files with WHERE Folder IS NULL ORDER BY FileName.
The composite (Folder, FileName) index cannot satisfy that ordering for an
IS NULL leading predicate, so the query fell back to a bitmap scan of every
root file plus a top-N sort on each request. A standalone FileName index makes
it an ordered index scan (~15ms -> <0.1ms at 80k rows; files list p50 40ms -> 6ms
under load).
…esAsync

ISettingsContracts.GetSettingValuesAsync gained (int skip, int take) parameters
in the list-pagination work, but the FakeSettingsContracts in the Localization
middleware tests still declared the old single-parameter overload, breaking the
full-solution build (CS0535). Add the matching skip/take parameters.
@antosubash

Copy link
Copy Markdown
Owner Author

Postgres load + profiling pass — 2 follow-up commits

Ran a deeper profiling pass on Postgres (pg_stat_statements + pg_stat_user_tables + EXPLAIN, plus dotnet-gcdump before/after for memory). Results: throughput 550 → 779 rps (+42%), 0% errors; no memory leak (managed heap stable 32.8→38.3 MB across ~63k requests).

5955058d perf(filestorage): add FileName indexGetFilesAsync lists root files with WHERE Folder IS NULL ORDER BY FileName; the composite (Folder, FileName) index can't satisfy that ordering for an IS NULL leading key (verified with enable_sort=off), so each request did a bitmap scan of all root files + a top-N sort. A standalone FileName index makes it an ordered index scan: 15 ms → 0.07 ms (files list p50 40 ms → 6 ms under load). Added via Fluent config (module schema is created via EnsureCreated/CreateTables).

744d931a fix(test): conform Localization test fake to paginated GetSettingValuesAsync — the pagination work added (int skip, int take) to ISettingsContracts.GetSettingValuesAsync, but a test fake in the Localization module still declared the old overload, breaking the full-solution build (CS0535). Fixed.

Note: the other large COUNT(*) costs seen while profiling were a fresh-bulk-load artifact (no VACUUM → visibility map unset → seq scan instead of index-only); after VACUUM email count went 147 ms → 3.5 ms. Autovacuum handles this in production — no code change.

Verification: csharpier clean · dotnet build 0 errors · full dotnet test green (~1,800 tests, 0 failures).

…CLRaw)

The CI vulnerable-packages gate (dotnet list package --vulnerable) flagged two
High-severity transitive advisories on this branch and main:
  - GHSA-hv8m-jj95-wg3x: MessagePack < 2.5.301 (via Aspire/Wolverine)
  - GHSA-2m69-gcr7-jv3q: SQLite native lib bundled by SQLitePCLRaw 2.1.11
    (via Microsoft.EntityFrameworkCore.Sqlite)

Neither is a direct reference, so enable CentralPackageTransitivePinningEnabled
and add patched PackageVersions: MessagePack 2.5.302 and the SQLitePCLRaw family
(core/bundle/provider 3.0.3, native lib 3.50.3). Transitive pinning also requires
the central Npgsql version to satisfy the graph, so bump the stale Npgsql 9.0.4 to
10.0.3 (matches Npgsql.EntityFrameworkCore.PostgreSQL 10).

Verified: restore clean, build 0 errors, full test suite green (~1,800 tests),
dotnet list package --vulnerable reports no vulnerable packages.
@antosubash antosubash merged commit 578bc2d into main Jun 22, 2026
10 checks passed
@antosubash antosubash deleted the worktree-pg-loadtest-perf branch June 22, 2026 11:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant