Skip to content

feat(recommendations): bound GET /api/recommendations with pagination and slimmer rows #777

Closed as not planned

Description

@cristim

Summary

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).

Activity

  1. IAmJSD commented on Oct 26, 2026

    @IAmJSD
    Collaborator

    Claiming: working on this

  2. IAmJSD commented on Oct 26, 2026

    @IAmJSD
    Collaborator

    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 main

    • fix(api): add opt-in in-memory cursor pagination to GET /api/recommendations #974 (merged) added opt-in limit/cursor paging to GET /api/recommendations. It pages in memory, caps each page at 4 MiB, and still computes summary/regions over the full filtered set. A request with neither param gets the old unbounded body.
    • perf(api): page recommendations from an ID-ordered keyset scan instead of the full list #1240 (draft, ours) moves the paging into a SQL keyset scan. It needs a migration (000142), and that number is caught up in the renumbering, so it is parked.
    • The frontend (frontend/src/api/recommendations.ts getRecommendations, 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

    1. 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.
    2. 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).
    3. 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

  3. IAmJSD commented on Oct 26, 2026

    @IAmJSD
    Collaborator

    Released: needs an owner decision between the options in the comment above; no code written

  4. cristim commented on Nov 11, 2026

    @cristim
    MemberAuthor

    Claimed by claude-platform-2.

  5. cristim commented on Nov 11, 2026

    @cristim
    MemberAuthor

    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.

  6. cristim commented on Nov 12, 2026

    @cristim
    MemberAuthor

    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.

  7. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    Claimed by claude-platform (coordinator priority list, 2026-11-13). Working this in a worktree; will post the landed commit or a blocker here.

  8. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    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_*.

  9. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    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.

  10. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    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):

    • 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.

  11. cristim commented on Nov 13, 2026

    @cristim
    MemberAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions