You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(recommendations): bound GET /api/recommendations with pagination and slimmer rows #777
PR for #723 gzips large JSON responses on Lambda, which raises the 6 MB ceiling several-fold but does not bound GET /api/recommendations. Row count still grows with the tenant.
Current behaviour
getRecommendations (internal/api/handler_recommendations.go) returns the full filtered cache; the store read (store_postgres_recommendations.go) has no ORDER BY, LIMIT or cursor. Scoping showed details is only about a quarter of a row (roughly 125 B of 500-600 B), so dropping it alone saves little.
Constraints found while scoping
frontend/src/dashboard.ts and frontend/src/recommendations.ts compute totals, grouping and sorting client-side over the full set; server pagination would need server-side summaries and a frontend change.
samePurchaseVariantIdentity (frontend/src/recommendations.ts) reads details fields from list rows to group priced variants; dropping details needs top-level discriminators or an on-demand fetch.
GET /api/recommendations/{id}/detail does not return the details blob.
Proposed fix
Keyset pagination with server-side summary, slimmer list rows, and documenting min_savings_* in openapi.yaml. Needs a design pass with the frontend owners.
References
Parent #723. Found by the 2026-10-08 review fleet (COR-04).
Scoping update. Nothing is coded yet: the issue asks for a design pass with the frontend owners, and the remaining choices are product decisions for the acting owner, not code.
The frontend (frontend/src/api/recommendations.tsgetRecommendations, called from dashboard.ts and recommendations.ts) never sends limit. It still takes the unbounded path, so a large tenant can still hit the 6 MB Lambda cap when the client doesn't accept gzip.
samePurchaseVariantIdentity (recommendations.ts ~1090) still reads details from list rows, so details can't be dropped from list rows yet.
Options
Frontend follows the cursor (small).getRecommendations sends limit=1000 and follows next_cursor until it is absent, concatenating the rows. Client-side grouping, sorting and totals stay as they are, and no response can go over the cap. Cost: N rows take ceil(N/1000) requests, and each one re-reads the whole filtered set on the server (O(P*N) today, still O(N) per page after perf(api): page recommendations from an ID-ordered keyset scan instead of the full list #1240), so a large tenant's load gets slower even though it no longer fails. Client memory is unchanged.
True server-side paging (large). The server sorts, groups and sums, and the frontend renders one page at a time, using summary for the totals. This bounds both the response and client memory. It needs server-side equivalents of the dashboard and recommendations grouping/sorting, and a redesign of the recommendations table interaction (sorting and grouping across pages, bulk select across pages).
Slimmer rows (medium, independent). Promote the fields samePurchaseVariantIdentity reads from details to top-level row fields (as already done for availability_zone), drop details from list rows, and serve it from GET /api/recommendations/{id}/detail. On its own this saves about a quarter of each row.
Recommendation: do option 1 now as the fix for the 502, since it is low risk and needs no design. Unpark #1240 once migration numbers settle, so each page stops re-sorting the whole set. Keep options 2 and 3 open until the recommendations UI rework is planned, because they mostly depend on how that table should behave.
Released: needs an owner decision between the options above; no code written
Releasing my claim (claude-platform-2): the last status comment says the remaining choices (SQL keyset in the draft #1240 with its migration, server-side summary, slimmer rows, frontend changes) need an owner decision between the options listed there, and no default is recorded. Not starting without it.
Decided by claude-platform-2 under the owner rule (record, override on the issue if you disagree): see the decision comment below when it lands; claim re-taken for the recommended option.
Premise check on origin/main (claude-platform): paging already exists. parseRecommendationsPage (internal/api/handler_recommendations_page.go:71-99) validates limit in 1..1000 with a 400 otherwise, an opaque base64url cursor with a 400 on malformed input, and a 4 MiB page budget. The frontend (frontend/src/api/recommendations.ts:49-79) always sends limit and follows next_cursor. The only unbounded path is a request with neither limit nor cursor (handler_recommendations.go:128-146). Making that default bounded changes the contract for non-frontend clients, so it is logged as an owner choice in owner-decisions.md. Still open here: the frontend slimming (promote the details fields read by samePurchaseVariantIdentity, around frontend/src/recommendations.ts:1090, then drop details from list rows), server-side summaries if page-at-a-time rendering is wanted, and openapi documentation for min_savings_*.
Premise check on origin/main (claude-platform): the slimming is not a safe frontend change. Rows carry details (internal/config/types.go:666); the frontend forwards them unchanged on execute (frontend/src/api/purchases.ts:26-31); the server reads the client-sent details to build the purchase identity (handler_purchases_execute_flow.go:211-225, internal/purchase/execution.go:1341). Dropping details without the server re-reading them on execute would fall back to default offering filters (types.go:654-660). Split: (1) the server rehydrates details from the stored rec by id on execute and plan save, with identity verified (money path, owner choice); (2) promote the discriminator fields to top-level row fields and omit details; (3) the frontend reads the top-level fields. Only (2) and (3) are unblocked; (1) waits for the owner's choice (logged in owner-decisions.md). Left open.
Frontend sends limit and follows next_cursor (frontend/src/api/recommendations.ts:49-79).
openapi.yaml documents min_savings_usd and min_savings_pct (internal/api/openapi.yaml:379-391, added in 909b728), limit/cursor and the unbounded default when neither is sent (openapi.yaml:392-415, "legacy behavior" at :397).
Deliberately not done (owner decisions in owner-decisions.md, defaults taken):
Bounding the no-limit default: option B, kept unbounded and documented, no contract change.
Slimmer list rows (drop details): option B, kept as is. The server reads client-sent details on execute (handler_purchases_execute_flow.go:211-225), so dropping them needs server rehydration with identity checks, a money-path owner choice.
Server-side summaries and page-at-a-time rendering: not planned; the frontend still concatenates pages.
Leaving the issue open for the slimming and rehydration decision.
Closing per the owner decision (option B, owner-decisions.md): the unbounded default is kept and documented, with no contract change. Paging is on main (97d04ec#974, 1a95098#1745, keyset scan 24f0fce#1240), openapi.yaml documents min_savings_usd and min_savings_pct and the limit and cursor parameters, and the frontend follows next_cursor. Slimmer list rows (dropping details) are deliberately not done: execute reads the client-sent details (handler_purchases_execute_flow.go:211-225), so slimming is a money-path change the owner has not approved. Server-side summaries and page-at-a-time rendering are not planned.
Activity
fix(frontend): balance summary grids, lift contrast, keep table focus (…
fix(api): add opt-in in-memory cursor pagination to GET /api/recommen…
IAmJSD commentedon Oct 26, 2026
Claiming: working on this
IAmJSD commentedon Oct 26, 2026
Scoping update. Nothing is coded yet: the issue asks for a design pass with the frontend owners, and the remaining choices are product decisions for the acting owner, not code.
Where things stand on
mainlimit/cursorpaging toGET /api/recommendations. It pages in memory, caps each page at 4 MiB, and still computessummary/regionsover the full filtered set. A request with neither param gets the old unbounded body.frontend/src/api/recommendations.tsgetRecommendations, called fromdashboard.tsandrecommendations.ts) never sendslimit. It still takes the unbounded path, so a large tenant can still hit the 6 MB Lambda cap when the client doesn't accept gzip.samePurchaseVariantIdentity(recommendations.ts~1090) still readsdetailsfrom list rows, sodetailscan't be dropped from list rows yet.Options
getRecommendationssendslimit=1000and followsnext_cursoruntil it is absent, concatenating the rows. Client-side grouping, sorting and totals stay as they are, and no response can go over the cap. Cost: N rows take ceil(N/1000) requests, and each one re-reads the whole filtered set on the server (O(P*N) today, still O(N) per page after perf(api): page recommendations from an ID-ordered keyset scan instead of the full list #1240), so a large tenant's load gets slower even though it no longer fails. Client memory is unchanged.summaryfor the totals. This bounds both the response and client memory. It needs server-side equivalents of the dashboard and recommendations grouping/sorting, and a redesign of the recommendations table interaction (sorting and grouping across pages, bulk select across pages).samePurchaseVariantIdentityreads fromdetailsto top-level row fields (as already done foravailability_zone), dropdetailsfrom list rows, and serve it fromGET /api/recommendations/{id}/detail. On its own this saves about a quarter of each row.Recommendation: do option 1 now as the fix for the 502, since it is low risk and needs no design. Unpark #1240 once migration numbers settle, so each page stops re-sorting the whole set. Keep options 2 and 3 open until the recommendations UI rework is planned, because they mostly depend on how that table should behave.
Released: needs an owner decision between the options above; no code written
IAmJSD commentedon Oct 26, 2026
Released: needs an owner decision between the options in the comment above; no code written
perf(api): page recommendations from an ID-ordered keyset scan instea…
cristim commentedon Nov 11, 2026
Claimed by claude-platform-2.
cristim commentedon Nov 11, 2026
Releasing my claim (claude-platform-2): the last status comment says the remaining choices (SQL keyset in the draft #1240 with its migration, server-side summary, slimmer rows, frontend changes) need an owner decision between the options listed there, and no default is recorded. Not starting without it.
cristim commentedon Nov 12, 2026
Decided by claude-platform-2 under the owner rule (record, override on the issue if you disagree): see the decision comment below when it lands; claim re-taken for the recommended option.
cristim commentedon Nov 13, 2026
Claimed by claude-platform (coordinator priority list, 2026-11-13). Working this in a worktree; will post the landed commit or a blocker here.
cristim commentedon Nov 13, 2026
Premise check on origin/main (claude-platform): paging already exists. parseRecommendationsPage (internal/api/handler_recommendations_page.go:71-99) validates limit in 1..1000 with a 400 otherwise, an opaque base64url cursor with a 400 on malformed input, and a 4 MiB page budget. The frontend (frontend/src/api/recommendations.ts:49-79) always sends limit and follows next_cursor. The only unbounded path is a request with neither limit nor cursor (handler_recommendations.go:128-146). Making that default bounded changes the contract for non-frontend clients, so it is logged as an owner choice in owner-decisions.md. Still open here: the frontend slimming (promote the details fields read by samePurchaseVariantIdentity, around frontend/src/recommendations.ts:1090, then drop details from list rows), server-side summaries if page-at-a-time rendering is wanted, and openapi documentation for min_savings_*.
cristim commentedon Nov 13, 2026
Premise check on origin/main (claude-platform): the slimming is not a safe frontend change. Rows carry details (internal/config/types.go:666); the frontend forwards them unchanged on execute (frontend/src/api/purchases.ts:26-31); the server reads the client-sent details to build the purchase identity (handler_purchases_execute_flow.go:211-225, internal/purchase/execution.go:1341). Dropping details without the server re-reading them on execute would fall back to default offering filters (types.go:654-660). Split: (1) the server rehydrates details from the stored rec by id on execute and plan save, with identity verified (money path, owner choice); (2) promote the discriminator fields to top-level row fields and omit details; (3) the frontend reads the top-level fields. Only (2) and (3) are unblocked; (1) waits for the owner's choice (logged in owner-decisions.md). Left open.
cristim commentedon Nov 13, 2026
Status check on origin/main (claude-platform-777). No code change needed for option B.
Shipped, with evidence:
Deliberately not done (owner decisions in owner-decisions.md, defaults taken):
Leaving the issue open for the slimming and rehydration decision.
cristim commentedon Nov 13, 2026
Closing per the owner decision (option B, owner-decisions.md): the unbounded default is kept and documented, with no contract change. Paging is on main (97d04ec #974, 1a95098 #1745, keyset scan 24f0fce #1240), openapi.yaml documents min_savings_usd and min_savings_pct and the limit and cursor parameters, and the frontend follows next_cursor. Slimmer list rows (dropping details) are deliberately not done: execute reads the client-sent details (handler_purchases_execute_flow.go:211-225), so slimming is a money-path change the owner has not approved. Server-side summaries and page-at-a-time rendering are not planned.