Repository navigation
fix(purchase): scope purchase suppressions to the AZ, platform and tenancy bought - #1110
Conversation
…nancy bought The suppression key was the 6-tuple (account, provider, service, region, resource type, engine), so buying one zonal EC2 variant subtracted its count from every sibling on that tuple: buying us-east-1a Linux hid the us-east-1b, regional, Windows and dedicated recs for the whole grace window. Migration 000134 adds nullable availability_zone, platform and tenancy columns to purchase_suppressions (all-NULL or all-set by CHECK) and widens the per-execution UNIQUE key to cover them (NULLS NOT DISTINCT). It is an expand step that old code keeps working against: its 6-tuple inserts get NULL variant columns and it never relied on the dropped constraint. BuildSuppressions writes one row per 6-tuple and variant, reading the AZ and the Details platform/tenancy through the same helpers as RecIdentityKey. The scheduler's subtraction pass applies a row only to recs of the same variant. A row with no variant (written before 000134 or by old code during a rolling deploy) stays a wildcard over its 6-tuple, as does the row of a rec whose Details cannot be read, so the fallback is the old over-suppression, never over-showing purchased capacity. Closes LeanerCloud#816 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 12
✨ Finishing Touches
Comment |
|
VERDICT: APPROVE f127e3f Reviewed head f127e3f (base 8caed39). Findings (none blocking):
Migration: 000134 is still free on main (latest is 000133), and the open chain uses 135-139 (#1116, #1140, #1170, #1197, #1201). Up and down are symmetric: the down merges per-execution 6-tuple rows (summed count, max expiry) before it restores the original auto-named constraint. The test asserts the constraint name. There is no backfill by design: existing rows stay NULL, act as 6-tuple wildcards and age out within one grace window. That errs toward over-suppressing. NULLS NOT DISTINCT needs PG15+; every env pins 16. Readers checked: scheduler applySuppressions (ListRecommendations, ListRecommendationsStrict, GetRecommendationByID; planstep/resolve.go uses the suppressed list), and the writers BuildSuppressions at handler_purchases.go:2541/3461, plan_step_attach.go:348 and attach_recommendations.go:35. The write side and the read side share SuppressionVariant(). An unreadable Details falls back to a wildcard on both sides (over-suppresses). No path un-suppresses the purchased variant itself. Attacked:
Tests (merged tree, AWS config /dev/null): go build, go vet -tags integration on the touched packages: ok. Unit tests for config, scheduler, planstep, purchase and api: 5932 passed. |
|
Independent review 2 (claude-fleet, cold): APPROVE f127e3f Migration order: this PR adds 000134 and is the first link of a chain that must merge strictly in this order: #1110 (134) -> #1116 (135) -> #1140 (136) -> #1170 (137) -> #1197 (138) -> #1201 (139) golang-migrate skips a lower version once a higher one is applied.
Findings (none blocking):
Against #816:
Attacked:
Verified locally, on
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
Marked ready and merged by IAmJSD's agent, at IAmJSD's request, after 2 independent reviews and local verification (see the review and verification comments above, all at |
Merging main brought LeanerCloud#1110 (issue LeanerCloud#816), which changed aggregateSuppressions to return the raw rows grouped by 6-tuple (map[suppressionKey][]config.PurchaseSuppression) and moved the per-rec aggregate into applySuppressionIndex, because which rows apply now depends on the rec's variant (AZ, platform, tenancy). ScanRecommendations still held the old map[suppressionKey]*suppressionAgg, so the merged tree did not build. loadSuppressionIndex now returns main's grouped index and the scan passes it to the same applySuppressionIndex as ListRecommendations. Matching is per rec, so applying it chunk by chunk suppresses exactly what the non-scan path does. A new test pins the scan to ListRecommendations at every chunk size on an EC2 fixture with a variant purchase, a fully covered variant, a legacy wildcard row and a rec with unreadable Details, and checks the expected per-variant counts. Stripping variants from the scan's index fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d of the full list (#1240) * fix(api): add opt-in keyset pagination to GET /api/recommendations The list endpoint returned the whole filtered recommendations cache in a single body, so a tenant with a few thousand cached rows pushed the response past the 6 MB Lambda payload cap and the page failed with 502. Gzip (#775) raised that ceiling but did not bound it. Callers can now send limit (1-1000) and the returned next_cursor to read the list in pages ordered by recommendation ID. The cursor is an opaque base64url encoding of the last ID seen, so a row deleted between requests does not shift later pages. summary and regions still describe the full filtered set on every page, and compute capacity is only decoded for the returned rows. Requests without limit or cursor keep the existing unpaginated response, so the current frontend is unaffected until it adopts the parameters. openapi.yaml documents the parameters and the actual response shape. Refs #777 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): cap recommendation pages by serialized bytes and reject empty paging params A page was bounded only by row count, but the opaque Details blob makes a row's size data-dependent, so 1000 large rows could still pass the 6 MiB Lambda response cap. Pages now also stop once the rows, measured as they appear inside the Lambda response envelope (the body re-encoded as a JSON string, so quotes and backslashes count twice), plus the summary, regions, keys and next_cursor would pass a 4 MiB budget. The budget is checked against uncompressed bytes because the #775 gzip only runs when the client accepts it. A page always holds at least one row, so a row that alone is over the budget is returned on its own page with a logged warning instead of stalling the cursor walk. Legacy unpaginated mode is now selected only when neither limit nor cursor is present. A present but empty ?limit= or ?cursor= returns 400 instead of falling back to the unbounded response. Refs #723 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): reject duplicate recommendation IDs when paging and document 400s - Correct the ordering comment: the paging key is the application recommendation ID built from the natural-key columns, not the cache table's UUID primary key. A paginated request now fails if two rows share an ID, since the strict "after" comparison would skip one of them at a page boundary. - Describe the feature as opt-in in-memory cursor slicing, not a SQL keyset (store-side paging is #981). - TestGetRecommendations_UnpaginatedShapeUnchanged copies the expected IDs before the call so an in-place reorder in legacy mode fails it. - Add TestGetRecommendations_PaginationRejectsDuplicateIDs. - openapi: document the 400 response for bad limit/cursor/filter values, and say next_cursor's absence is the only end-of-list signal. Refs #723 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(db): index recommendations by byte-wise payload id for keyset scans Migration 000142 adds recommendations_scan_key_idx on (COALESCE(payload->>'id', '') COLLATE "C", id). The paginated GET /api/recommendations scan (issue #981) walks the cache in chunks in that order and resumes each chunk after the last (id, primary key) it read; without the index every chunk is a full scan plus a sort. COLLATE "C" is byte order, the same order Go's string comparison uses, so SQL paging keeps the order the API already returns. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(config): read stored recommendations in keyset chunks ListStoredRecommendationsChunk returns at most limit rows matching a RecommendationFilter, ordered by the byte-wise recommendation ID and then the table's UUID primary key, resuming strictly after a given position. The primary key keeps rows that share an ID in a total order, so a chunk boundary inside such a run neither skips nor repeats a row. The MinSavingsPct floor stays in-process (shared with ListStoredRecommendations via decodeStoredRecommendation); rows it drops still advance the position. Integration tests pin the order to Go's sort.Strings on IDs that a linguistic collation orders differently, cover duplicate IDs across every chunk size, the pct floor emptying a whole chunk, the empty table, an EXPLAIN check that recommendations_scan_key_idx serves the query without a Sort node, and walks racing concurrent upserts. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(scheduler): scan cached recommendations chunk by chunk ScanRecommendations walks the recommendations cache through ListStoredRecommendationsChunk in byte-wise ID order and applies the same suppressions and account overrides as ListRecommendations to each chunk, handing the survivors to a callback. It holds one chunk at a time instead of the whole filtered set. Suppressions are read once per scan and each (account, provider, service) override is resolved once per scan, not once per chunk. Read failures over-show exactly like ListRecommendations. The freshness read, cold-start kick and stale-while-revalidate kick are pulled out of ListRecommendations into startCacheRead/finishCacheRead so both reads share them unchanged. Tests pin the scan to ListRecommendations' output (sorted by ID) for chunk sizes 1 to 31 with suppressions and overrides active. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(api): page GET /api/recommendations from the chunked scan A paginated request (limit or cursor sent) no longer loads the whole filtered set through ListRecommendations and sorts it in memory. It runs Scheduler.ScanRecommendations, applies the enabled-providers and account scope filters to each chunk (their inputs read once per request), and feeds the survivors to recommendationsPageCollector. The collector totals every row into the summary and keeps only the rows after the cursor up to limit+1 (the page plus one row proving a next page exists), then cuts the page with the existing byte budget. Peak memory is one scan chunk plus one page instead of the whole tenant. Output is unchanged: rows arrive in byte-wise ID order (Go string order), duplicate IDs still fail the request, and the summary still covers the full filtered set. The unpaginated path is untouched. The in-memory sortRecommendationsAfter/buildBudgetedPage are removed; the existing #723 pagination tests now run against the scan path, with buildBudgetedPage kept as a test helper over the collector. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api): pin paginated recommendations order, ties and last page - PaginatedRequestDoesNotLoadFullList: the #981 regression; a paged request must use ScanRecommendations, never ListRecommendations. - PagedWalkMatchesLegacyOrder: for an unrestricted and a scoped reader, every chunk size and page size (including the exact-fit last page), the walk returns the legacy rows sorted by Go string order in consecutive pages, next_cursor on all but the last page, and the legacy summary on every page. IDs mix case, "|", "-", "/", spaces and accents. - PagedDuplicateIDs: ties fail every paged request at any chunk/page boundary; a twin removed by the scope filter is not a tie. - Background-collection scope (#627), the one-page memory bound, the out-of-order guard and the empty cache. - An integration test walks a real Postgres cache spanning several scan chunks with the min_savings_pct floor and matches the legacy output. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(scheduler): scan with main's variant-aware suppression index Merging main brought #1110 (issue #816), which changed aggregateSuppressions to return the raw rows grouped by 6-tuple (map[suppressionKey][]config.PurchaseSuppression) and moved the per-rec aggregate into applySuppressionIndex, because which rows apply now depends on the rec's variant (AZ, platform, tenancy). ScanRecommendations still held the old map[suppressionKey]*suppressionAgg, so the merged tree did not build. loadSuppressionIndex now returns main's grouped index and the scan passes it to the same applySuppressionIndex as ListRecommendations. Matching is per rec, so applying it chunk by chunk suppresses exactly what the non-scan path does. A new test pins the scan to ListRecommendations at every chunk size on an EC2 fixture with a variant purchase, a fully covered variant, a legacy wildcard row and a rec with unreadable Details, and checks the expected per-variant counts. Stripping variants from the scan's index fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber recommendations_scan_key_idx migration 000142 -> 000140 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140, 000141, 000143 and 000144 belong to PRs held on owner decisions (#1239, #1241, #1253); this PR takes 000140 so it can merge ahead of them, and they will take the next free numbers when decided. Rename only: the SQL is unchanged, and the test's version constant and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cristian Magherusan-Stanciu <cristim@users.noreply.github.com>
Root cause
The purchase suppression key was the 6-tuple (account, provider, service, region, resource type, engine).
BuildSuppressionswrote one row per 6-tuple, andaggregateSuppressions/applySuppressionIndexininternal/scheduler/scheduler.gosubtracted that row from every rec on the tuple. Buyingus-east-1aLinux/default therefore also cut theus-east-1b, regional, Windows and dedicated recs, and hid them for the whole grace window. The rec ID, identity key and recommendations natural key already carry AZ, platform and tenancy (#792, #806); only the suppression key did not.Fix
availability_zone,platformandtenancycolumns topurchase_suppressions, with no default, so there is no table rewrite. A CHECK keeps them all-NULL or all-set. The 6-tupleUNIQUEis replaced byUNIQUE NULLS NOT DISTINCT (execution_id, 6-tuple, availability_zone, platform, tenancy). Old code still works on the migrated schema: its inserts name only the 6-tuple, so the new columns are NULL; it adds up its counts per 6-tuple before inserting; and it never uses the old constraint (plain INSERT, no ON CONFLICT). Down merges rows of one execution that differ only by variant into one row per execution and 6-tuple. The merged row keeps the summed count and the latest expiry. Down then drops the columns and restores the old constraint.BuildSuppressionswrites one row per 6-tuple plus variant. The AZ comes fromrec.AvailabilityZone, and platform and tenancy come fromComputeIdentity(). These are the same readsRecIdentityKeyuses, through the newRecommendationRecord.SuppressionVariant(). Empty strings are known values (a regional AZ, or a non-EC2 rec), not wildcards.PurchaseSuppression.AppliesTo(recVariant)is true. That means an exact variant match, or a row with no variant.RecIdentityKey, so this fallback is defensive.computeDetailMismatchalready compares platform and tenancy.Closes #816
Deploy / runbook
auto_migrate=false. Apply 000134 before releasing this code. The migration is compatible with old code. New code on the old schema fails every suppression insert (column "availability_zone" does not exist), which rolls back the purchase or attach transaction. It fails closed (nothing is bought) but it does fail. Usedatabase-migration.ymlwithstepsset to the true pending count. Dev auto-migrates at startup before the stores are rebuilt.execution_id. Subtraction is computed at read time, so there is no read-modify-write and no cross-execution conflict. The existing concurrent integration tests (TestAttachIntegration_PlansOnOneAccountNeverOverAttach,TestAttachIntegration_ConcurrentTicksOnOnePlanMintOneRow,TestAttachResolvedRecommendations_ConcurrentAttachesOnce) pass on real Postgres 16 with the migration applied. I added no new race test because the change creates no new shared state.Verification
All tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid. Integration tests ran against a throwawaypostgres:16-alpinetestcontainer, through the real migration chain and the real store. No shared or deployed database was touched.Fail-before / pass-after (production files reset to origin/main 8caed39 and 000134 removed; tests unchanged):
go test ./internal/scheduler/ -run TestApplySuppressions_OnlyThePurchasedVariantfails on main.other-az,regional,windowsanddedicatedeach show count 2 instead of 5, withSuppressedCount3 and a badge. It passes with the fix.go test -tags integration ./internal/scheduler/ -run TestSuppressionsScopedToPurchasedVariant_RealPostgresfails on main with{"bought":2, "other-az":2, "regional":2, "windows":2, "dedicated":2, ...}. The fix gives{"bought":2, "other-az":5, "regional":5, "windows":5, "dedicated":5, "legacy-a":4, "legacy-b":4}. The legacy pair is a row inserted the way old code does it (no variant columns), and it still applies to both variants.New tests
internal/config:TestBuildSuppressions_SeparatesVariants,_NonComputeVariantIsKnownEmpty,_UnreadableDetailsFallBackToWildcard,TestPurchaseSuppression_AppliesTo; pgxmockTestPGXMock_CreateSuppression_WritesVariant. The existing round-trip and list tests now cover the NULL and set columns.internal/scheduler:TestApplySuppressions_OnlyThePurchasedVariant,_LegacyRowIsAWildcard,_NonComputeVariantMatches,_UnreadableRecDetailsMatchEveryRow, plus the real-Postgres test above.internal/database/postgres/migrations:TestMigration_PurchaseSuppressionsVariant(usespreviousMigrationVersion). It covers a pre-migration row reading back as NULL, old-code inserts still working, a legacy duplicate still rejected (NULLS NOT DISTINCT), five variant rows coexisting in one execution, an exact duplicate rejected, the CHECK rejecting a partial NULL, down merging into one row with the summed count and restoring the 6-tuple constraint, and up re-applying after down.Mutation probes (all 10 killed): match always true; variant never written; insert writes NULL; scan drops the variant; tenancy or AZ dropped from the variant; legacy rows not treated as wildcards; unreadable rec variant matching nothing; bucket ignoring the variant; earliest-expiry update disabled.
Commands run on the final tree
Evidence and gaps: this is unit, pgxmock and real-local-Postgres integration evidence on synthetic fixtures. It does not include a real AWS collection or purchase. I did not apply the migration to any deployed database.
Out of scope
idx_purchase_suppressions_lookupstays on the 6-tuple plusexpires_at. The list query filters only onexpires_at, and grouping still uses the 6-tuple, so this PR does not need a wider index.suppressed_count,suppression_expires_at,primary_suppression_execution_id) keep their shape and are now set per variant.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/few,effort/m,type/bug,triaged.🤖 Generated with Claude Code