perf(lambdas): stop paying for avoidable database round trips - #366
Conversation
Every paginated list endpoint awaited its COUNT and then awaited the page,
though neither depends on the other. Same story in several controllers that
read a row, then read something the row was not needed for. Those awaits are
now Promise.all, and where the second query only re-read what the first wrote,
RETURNING replaces it.
The pagination counts keep the page's own predicate — the scope filter, the
projectId filter — so a total still cannot leak what the page withholds.
Also swaps the project-scope filter from `project_id IN ($1, ..., $n)` to
`project_id = ANY($1)`. The list length varies per caller, so the old form
made Postgres plan the query afresh for every distinct project count; one
bound array means one plan serves all of them. projectScopeIds keeps its
contract: null for an unrestricted caller, the [-1] sentinel for a caller who
is a member of nothing, never an empty array.
PATCH /users/{userId} and PATCH /expenditures/{id}/status now settle their 404
from the UPDATE's affected row instead of a SELECT before it. For the user
route that reorders 400-before-404 on a malformed body aimed at a missing id;
the tests that asserted the old order are updated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # apps/backend/lambdas/users/controllers/users.ts
The photos tests added in #353 mock updateTable().set().where().execute() followed by a re-read. patchUser now settles the update, the 404 and the response body in a single UPDATE ... RETURNING, so the chain ends in returningAll().executeTakeFirst() and the old mock returned undefined -- surfacing as a 500 rather than a 200 in the two tests that PATCH a valid profileImage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
|
|
🌿 ⏳ Creating preview environment… (logs) |
🌿 Preview environment — ready ✅Open: https://d3nmtjoh6ir9ym.cloudfront.net/pr-366/ Shared RDS + Cognito (prod data); DB migrations are not applied here — if this PR adds a migration, endpoints using the new columns will fail until it merges. New commits update this environment in place — a note is posted here on each update. Remove the |
…merge The merge of #367 swapped listReports' count to branch.project_rollup but deleted the page query along with the old count, leaving `data: reports` pointing at the const declared below for the unpaginated path. That is a compile error (TS2448/TS2454), so the whole reports suite failed to run. The page query is back, in a Promise.all with the rollup count, so the paginated path is still one round trip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🌿 Preview environment torn down 🧹 — the stack for this PR has been destroyed. |
What
Removes database round trips from the lambda controllers, and changes two query
shapes. No behavioural change beyond the two 404-ordering notes called out below.
Round trips
GET /donors(paginated)Promise.allcount + pageGET /donors/donations(paginated)Promise.allcount + pageGET /users(paginated)Promise.allcount + pageGET /reports(paginated)Promise.allcount + pageGET /expenditures(paginated)Promise.allcount + pagePOST /donors/donationsPromise.all, then the insertGET /expenditures/{id}LEFT JOIN users+JOIN projectsfolded into the row readPATCH /expenditures/{id}.returningAll()instead of a post-write re-read (the pre-write read stays — the RBACresourceOfcheck needs it)PATCH /expenditures/{id}/statusUPDATE ... RETURNINGsettles the 404 and the bodyPATCH /users/{userId}UPDATE ... RETURNING, existence folded into the affected rowPOST /reports(fetchReportData)Promise.allof members + donations + expendituresGET /projects/{id}/overviewPromise.all, now four queries"Waves" for the overview row because the three project-scoped queries were
already parallel; the project read was a serial hop in front of them.
Query shapes
1.
IN (...)to a single array parameter — done.projectScopeIdsreturns anumber[]whose length varies per caller, sowhere('project_id','in',scope)emitted
in ($1, ..., $n)and Postgres re-planned per distinct cardinality.Now
sql\project_id = ANY(${ids})`, one bound array, one plan. Changed at all three call sites:projects/controllers/projects.ts,donors/controllers/donations.ts,expenditures/services/scope.ts`.projectScopeIdsitself is untouched, so its contract holds exactly:nullforan unrestricted caller (filter skipped), and the
MATCHES_NOTHING(-1)sentinel — never an empty array — for a caller who is a member of nothing.
Verified against a real database on all three branches:
The empty-scope caller still sees nothing, and
= ANY('{-1}')is valid SQLwhere
IN ()would not have been.2.
UNIONrewrite of theORdisjunction — SKIPPED. Deliberately. Threereasons:
applyExpenditureScopeis the single function that puts the same predicate onboth the count and the page. A
UNIONof two arms is a page-shapedconstruction; the count would have to be built by different code, which is
exactly the divergence the pagination rule exists to prevent.
OFFSETpagination over aUNIONneedslimit + offsetrows fromeach arm before the merge, and
UNION's dedup can then drop the count belowlimitwhile more rows exist — short pages, the bug class this predicate ismeant to avoid.
your own projects), so the sort it would avoid is over a set that is close to
the one arm alone.
A correct slow query beats a fast wrong one, and the sort is addressable with
(project_id, spent_on DESC)/(entered_by, spent_on DESC)indexes withouttouching the predicate. Left for a PR that can own the index change too.
Pagination counts
Confirmed: every count still carries the page's own predicate.
getDonationsbuilds one
inScopeexpression and applies it to both queries;countExpendituresandqueryExpendituresboth go throughapplyExpenditureScopeand both take the sameprojectId;listReportsappliesthe same
projectIdfilter to both. Nothing counts a row its page would hide.Two ordering changes worth a reviewer's eye
PATCH /users/{userId}: the existenceSELECTis gone, so a malformed bodyaimed at a missing id now answers 400 rather than 404. That is the precedence
every other validated route here already has. Two tests asserted the old
order and are updated —
users.test.ts's "patch user 404 test" was sending animmutable
emailfield, so it was asserting a 404 over what is really a 400;its body now contains only the field it means to patch.
PATCH /expenditures/{id}/status: the pre-write read is dropped too, not justthe post-write one. There is no record-level gate on this route
(
expense:reviewon the route settles it), so nothing needed the row in hand;an
UPDATEmatching no row is a no-op and returns nothing, which is the same404 with the same message. This is one step past the brief — easy to revert to
3-to-2 if you would rather keep the read.
GET /projects/{id}/overviewnow issues the three scoped queries even when theproject does not exist. They return empty and the 404 is unchanged; the trade is
a little wasted work on a 404 for one less round trip on every success.
Verification
Built
shared/rbac,shared/types,shared/lambda-auth,shared/lambda-httpfrom this branch and pointed each lambda at them.
npx tsc --noEmitclean in allfive touched lambdas. Tests run against a throwaway
postgres:16-alpine, not theshared local instance.
health testfetcheslocalhost:3000; pre-existing, needs a dev server)467 of 468 passing; the one failure is the known health check.
Out of scope
Untouched:
shared/lambda-auth/src/authenticate.tsandrbac.ts, theunbounded-response / default-
limitwork,selectAll()narrowing, anddashboard.ts'sgetOverviewNode-side aggregation.🤖 Generated with Claude Code