Repository navigation
perf(api): page recommendations with every list filter in SQL and an aggregate summary - #1596
Conversation
…aggregate summary A paginated GET /api/recommendations still read every filtered row into Go on every page request, because the summary and regions describe the whole filtered set and the suppression, account-override, enabled-provider, account-scope and savings-pct filters only ran in Go. The page is now read by PageStoredRecommendations: the filters are evaluated in SQL against the decoded payload, an aggregate query totals the summary (float8 sums ordered by ID, matching the Go addition order to the bit) and the regions, and only the page plus one row is returned. Rows on a suppressed 6-tuple are few, so they are still decided by the Go suppression and override code; overrides are resolved in Go for the keys present and passed to SQL as rules. All reads share one REPEATABLE READ snapshot, so the summary and the rows always describe the same collection. When SQL cannot reproduce a Go filter faithfully (float inputs outside safe bounds, unresolvable overrides, duplicate IDs, a cursor that is not valid UTF-8, rows Go would reject) the request falls back to the existing Go scan, which stays as the oracle for an integration test comparing both paths page by page across filters, scopes and page sizes. Closes LeanerCloud#981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 59 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rQ4. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale: none Reviewer: rQ2. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Problem
#1240 made a paginated
GET /api/recommendationswalk the cache in keyset chunks, which bounds the memory held. But every page request still read every filtered row into Go. The summary and regions describe the whole filtered set, and the suppression, account-override, enabled-provider, account-scope andmin_savings_pctfilters only ran in Go (see the old doc comment ininternal/api/handler_recommendations_scan.go). Walking an N-row tenant in pages of L rows therefore still moved N rows over the wire and throughjson.UnmarshalN/L times.Fix
A paginated request now goes through
Scheduler.PageRecommendations→PostgresStore.PageStoredRecommendations:AccountScopeinto the exact set of IDsAllowsgrants (scopeAllowedAccountIDs), so a scoped user is never shown, or counted against, an out-of-scope or account-less row.ResolveAccountConfigsForRecsfor the(account, provider, service)keys the scope's rows actually have, then passed as a JSON rule object keyed byAccountConfigKey, the same key the Go map uses.min_savings_pctfloor. It is evaluated in float8 operation for operation, so it rounds like Go:57/100*100 = 56.99999999999999is dropped at a floor of 57, as Go drops it.ec2andcompute) are few. The store reads them inside the snapshot, and the existingapplySuppressionIndex+filterRecsByResolvedConfigsdecide them. SQL keeps those rows by primary key. This avoids re-implementing the variant, Details-decode and "shared" tenancy rules in SQL.sum(... ORDER BY id)for savings and upfront, so the float8 additions happen in the order the Go summary used. Totals match the scan to the bit.count(DISTINCT id)detects duplicate IDs.REPEATABLE READ, READ ONLYtransaction. The summary and rows can no longer come from different collections. The old chunked scan could mix them; see the race test below.OFFSET 0-fenced subqueries, so expressions are not inlined into each other. The page read uses LATERAL stages, so the planner walksrecommendations_scan_key_idxin order and stops at the LIMIT.Where SQL can't be faithful, Go is used (fallback)
PageRecommendationsreturnsErrRecommendationsPageNeedsScan, and the handler serves the request from the unchanged Go scan (getRecommendationsPageByScan), in these cases:{0} ∪ [1e-100, 1e100]also fall back, as do non-number JSON in numeric fields and non-integer counts or terms.cloud_account_id, or two keys that join to the sameAccountConfigKeystring.None of these occur with data the collector writes. They exist so the result is never different from the scan, only slower.
Behavior notes
Closes #981
Verification
All evidence is local: unit/mock tests, plus integration tests against a real Postgres 16 container (testcontainers). Nothing ran against a real cloud account.
Commands run on this branch (head
70567463):New tests:
TestRecommendationsPageSQL_MatchesScanOracle(Postgres). This is the equivalence proof.sharedtenancy, unreadable Details, legacyec2vscompute, and expired rows, plus service configs and account overrides on every dimension.require.Equal: rows, badges, bit-exact totals, regions andnext_cursor. Out-of-scope rows are asserted absent. The SQL path must not fall back on this fixture.TestRecommendationsPageSQL_FallsBackToScan/_DuplicateIDsStillFail(Postgres). Each fallback input makes the SQL read reportNeedsScan, and the endpoint answers exactly as the scan does. Duplicate IDs still fail.TestGetRecommendations_PageDoesNotReadEveryRow(Postgres, the regression). It wraps the store, counts rows pulled throughListStoredRecommendationsChunkduring a full paginated walk, and requires 0.TestGetRecommendations_PageAndSummaryShareOneSnapshot(Postgres, concurrency). Four readers page while a writer rewrites all 1200 rows in place with alternating values. Every response's summary must be one collection's total, and every row must come from that same collection.scheduler_page_test.gocovers the suppression split and legacy spellings, each fallback reason, and suppression-read fail-closed.handler_recommendations_page_sql_test.gocovers the SQL path's use of scope and limit, each handler-side fallback, the error path, andscopeAllowedAccountIDsagainstAllows.Fail-before (fixture + bound test files copied onto
origin/mainatae6854b3):Both pass on this branch (
660 page reads raced 22 collections).Mutation probes: each mutation was applied, the test listed was run, and the file was restored.
<→<=READ ONLYwithoutREPEATABLE READsum(sv)with no ORDER BYThe no-ORDER-BY mutant survives because, in the current PG16 plan, the
count(DISTINCT id)input sort already feeds the sums in ID order. The explicitORDER BYis what guarantees the order, rather than the plan.Rough timing (local docker Postgres, no network). This comes from a throwaway benchmark that is not committed: 20,000 rows, one 500-row page.
min_savings_pct=20Over a real network the scan additionally ships every payload to the process, while the SQL path ships one page. The aggregate is still O(filtered rows) inside Postgres, which the summary inherently needs.
Combined-tree verification. Tested on a local, never-pushed tree:
queue/platform@594ac3ec= origin/mainae6854b3plus:Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers.go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....--build-tags integration.gocyclo -over 10is silent on the changed non-test files.-tags integration -count=1, one package at a time):internal/api,internal/auth,internal/config,internal/scheduler,internal/serverandinternal/testutilpass.scripts/test-git-secrets-allowlist.shpasses.Left out / for reviewers
DISTINCTread of override keys per request. Only the rows moved into Go are O(page).recommendations_scan_key_idxserves the page read.savings) used to fail every page. It now falls back, so it still fails. A few such shapes that SQL does not type-check, such as a non-stringprovider, would now only fail when that row lands on a page. The collector never writes these.Maintainer: please mirror the issue's triage labels on this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/many,effort/m,type/bug,triaged.🤖 Generated with Claude Code