perf(support): cut dashboard overview and search query time#4389
Closed
joshuakrueger-dfx wants to merge 1 commit into
Closed
perf(support): cut dashboard overview and search query time#4389joshuakrueger-dfx wants to merge 1 commit into
joshuakrueger-dfx wants to merge 1 commit into
Conversation
Two support-dashboard reads were slow because of how they queried, not how much data they touched. Measured against a seeded copy at production cardinality (8.6k support_issue, 94.6k support_message), median of 7 runs through the real service methods: overview 204ms -> 97ms, search 106ms -> 20ms. getMessageStats resolved "what is the newest message" with two correlated subqueries per issue. At the ~1360 open tickets the overview loads that is 2720 subquery executions over rows the outer query already scanned. It now picks the newest message inside the same GROUP BY via array_agg ordered by id: 372ms -> 23ms, proven row for row against the old form on a real database, including the case where the newest id carries the older created. The search spent 62 of its 111ms on the getManyAndCount total alone: each term expands to an EXISTS over support_message with a leading-wildcard LIKE, which no b-tree can serve, so every message row was read to produce the count - again on every "load more". A pg_trgm GIN index on support_message.message removes that. The migration cannot break a deploy. Migrations run at boot in one transaction, so a raw failure would roll the batch back and the API would not start - an outage caused by a performance index. Both DDL statements run inside PL/pgSQL EXCEPTION blocks, which open implicit subtransactions, so a missing extension, a role without privileges, an extension outside the search_path, a lock timeout or a statement timeout all degrade to "index skipped, search stays slow" with a NOTICE. Verified against a real Postgres for each of those states. Also fixes two ordering defects on the same paths. Message threads had no ORDER BY at all, and SupportIssueJobService picked the last message with at(-1) over an unordered array, so the auto-responder could mail tickets support had already answered and skip ones that were waiting. Its sibling autoOnHold had always ordered. Both now order by created with an id tie-break, and the auto-response filter no longer throws on an issue whose first message was never written, which previously aborted the whole cron run.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two support-dashboard endpoints were slow, and the cost was in how they queried — not in the amount of data. Measured against a seeded copy at production cardinality (8.6k
support_issue, 94.6ksupport_message, 8.6k accounts), median of 7 runs through the real service methods:Overview — 76% of it was
getMessageStats, which asked "what is the newest message?" with two correlated subqueries per issue. At the ~1360 open tickets the overview loads, that is ~2720 subquery executions over the same rows the outer query already scanned.Search — 62 of the 111 ms were the
getManyAndCounttotal alone. Each search term expands to... OR EXISTS (SELECT 1 FROM support_message m WHERE m."issueId" = issue.id AND m.message LIKE '%term%'), and a leading-wildcardLIKEcannot use a b-tree index, so every message row was read to produce the count — again on every "load more".Change
getMessageStatsin one pass — the newest message is picked inside the sameGROUP BYvia(array_agg(... ORDER BY m.id DESC))[1]instead of two correlated subqueries. Same ordering semantics (byid, notcreated).support_message.message(new migration) so the search predicate and its total stop reading every message row.messageThreadQueryhelper for the three thread reads (closeIssue,getIssue,getIssueMessages): addsloadEagerRelations: falseand an explicitORDER BY id ASC.orderin the auto-responder —SupportIssueJobService.getAutoResponseIssuesfilters onmessages.at(-1).author, i.e. it indexes into the loaded array positionally, but loaded the thread unordered. Its siblingautoOnHoldhas always ordered. Without it the bot decides "the customer wrote last" off an arbitrary row, so it can mail tickets support already answered and skip ones actually waiting.Result
getMessageStatsin isolation: 372 ms → 23 ms for the ~1360 open issues (47 ms → 6 ms at 200; at 20 it barely matters). SearchgetManyAndCount: 111 ms → 18 ms, of which the total dropped 62 ms → 7 ms.Equivalence of the rewritten aggregate was verified against the real database rather than argued — identical
count,lastDateandlastAuthorat 20 / 200 / ~1360 issues, including the case where the newestidcarries the oldercreated.Only
support_message.messageis indexed. Trigram indexes onissue.name,user_data.firstnameandsurnamewere measured too and moved the combined query by ~3 ms, which does not justify three more GIN indexes.Migration
1784900000000-AddSupportMessageTrigramIndexMigrations run at boot inside a single transaction, so any raw failure here rolls the whole batch back and the API does not start — an outage caused by a pure performance index. A privilege check alone does not cover that: the extension may be absent from the server, blocked by a managed provider's allow-list, or installed into a schema outside the
search_path, in which casepg_extensionreports it as present whilegin_trgm_opsstill does not resolve.Both DDL statements therefore run inside PL/pgSQL
EXCEPTIONblocks. Catching in JS would not help — a failed statement poisons the Postgres transaction — but a PL/pgSQL exception block opens an implicit subtransaction, so the failure is contained.query_canceledis listed explicitly because Postgres excludes it fromWHEN OTHERS; a server-sidestatement_timeoutshorter than the index build would otherwise abort the batch anyway. Alock_timeoutbounds the wait if a writer already holds a conflicting lock, and is reset afterwards so it does not leak onto later migrations in the same batch.Every state was exercised against a real Postgres:
If that NOTICE appears, nothing retries — TypeORM records the migration as applied either way. Installing the extension afterwards needs a follow-up migration that creates the index alone. Worth checking before merge:
SELECT * FROM pg_extension WHERE extname = 'pg_trgm';Cost of catching
query_canceled: an operator'spg_cancel_backend()aimed at a slow build is swallowed too; cancelling this statement specifically needspg_terminate_backend.Plain
CREATE INDEX, notCONCURRENTLY— that is not allowed inside a transaction, and overriding the transaction mode throwsForbiddenTransactionModeOverrideErrorwhile the batch runs underall. The lock is a ShareLock:SELECTkeeps working,INSERTblocks. Because the batch is one transaction it is held until the whole batch commits, not just for the ~4-6 s build (GIN ~16 MB next to a ~13 MB table). New support and customer messages cannot be written for that window — a reason to ship this migration on its own if a release carries several slow ones.Also fixed on the same paths
Message threads had no
ORDER BYat all, andSupportIssueJobService.getAutoResponseIssuespicked the last message withmessages.at(-1)over that unordered array — so the auto-responder could mail tickets support had already answered and skip ones that were waiting. Its siblingautoOnHoldhad always ordered. Both now order bycreatedwith anidtie-break.The same filter also threw on an issue whose first message was never written (
createIssueInternalcommits the issue and its first message in separate transactions). The cron lock caught and logged it, but aborted the rest of the run — every minute, until someone read the log.Tests
support-message-trigram-index.migration.spec.ts— runs the real migration against a throwaway Postgres (MIGRATION_TEST_PG, skipped without a DB like the sibling suites): index shape,downafter a skippedup, idempotency, and — the point of the whole construction — that a failingCREATE INDEXleaves the migration batch usable. Two cases need database-global DDL (dropping the extension, creating a role); those are opt-in viaMIGRATION_TEST_PG_EXCLUSIVEso they cannot perturb the ~10 real-Postgres suites sharing one database in CI.message-stats-aggregate.spec.ts— executes the aggregate expressions the service itself exports, not a copy, against a real Postgres: newest-by-idsemantics, NULL handling, and row-for-row equality with the correlated-subquery form it replaced.support-issue-job.service.spec.ts— pins the ordering both auto-response call sites depend on, and the empty-thread guard.Every new test was checked in the other direction: each one was run against the code with its fix removed and confirmed to fail. Two earlier versions of these tests passed for the wrong reason and were rewritten.
Scope
DFX support board only. The RealUnit tenant screen carries the same class of defect and is deliberately not part of this PR.
Release Checklist
Pre-Release
Post-Release