Repository navigation
fix(config/scheduler): prune recommendations of (provider, account) pairs that stopped collecting (#120 slice) - #1595
Conversation
…llecting UpsertRecommendations only evicts rows of the (provider, account) pairs that collected successfully in the current run, so the rows of a pair that never collects again (a disabled provider or account, credentials that keep failing) stayed on the Recommendations page forever. Add PostgresStore.PruneStaleRecommendations: it deletes the rows of every pair with no row collected in the last N days and never touches a pair with a row inside the window, however old its other rows are. It locks the table in SHARE ROW EXCLUSIVE mode, so it waits for an in-flight collection to commit and no collection commits between the pair check and the delete. The scheduler runs it best-effort after every persisted collection (daily on every cloud since LeanerCloud#1580; the cleanup sweep is opt-in everywhere), with N from CUDLY_STALE_RECOMMENDATION_RETENTION_DAYS, default 30 (about 30 missed daily collections). Refs LeanerCloud#120 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: rQ1. 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: 1 approving independent review(s) on |
Summary
This is the age-based sweep from #120 (the "unbounded list reads" item's fix direction, listed as "Left (workable)" in the 2026-10-28 status update). The other open items of #120 are not touched.
Root cause
UpsertRecommendationsonly evicts rows of the(provider, account_key)pairs that collected successfully in the current run. That's on purpose: a failed pair keeps showing its previous rows instead of going blank. But nothing ever removed those rows later. If a pair never collects again, its rows stay on the Recommendations page and inListStoredRecommendationsforever. That happens when a provider is removed fromenabled_providers, an account is disabled, or credentials keep failing. Registered accounts that are deleted outright were already covered, becauserecommendations.cloud_account_idisON DELETE CASCADE.Fix
PostgresStore.PruneStaleRecommendations(ctx, retentionDays) (int64, error)(internal/config/store_postgres_recommendations_prune.go, added toconfig.StoreInterfaceandmocks.MockConfigStore):(provider, account_key)pair that has no row withcollected_atin the lastretentionDaysdays.collected_at, and a successful collection also evicts the pair's older rows, so no separate bookkeeping table or migration is needed.retentionDays <= 0with an error.LOCK TABLE recommendations IN SHARE ROW EXCLUSIVE MODE.ROW EXCLUSIVElock that every INSERT/UPDATE/DELETE takes, and with itself. So the prune waits for any in-flightUpsertRecommendations, and collections that start meanwhile wait for the prune, which is a single DELETE.ACCESS SHARE) are not blocked.recommendationswithout writing to it, so the wait cannot deadlock.collected_at < cutoffbound follows from theNOT EXISTS, but I kept it so the planner can drive the delete fromrecommendations_collected_idx. It is the one clause no mutation probe can kill; see Verification.Scheduler.persistCollection, best-effort, after a successfulUpsertRecommendations(a failed write skips it).enable_cleanup_scheduleis opt-in (defaultfalse) on every platform. A sweep there would never run on most deployments.CUDLY_STALE_RECOMMENDATION_RETENTION_DAYS, defaultconfig.DefaultStaleRecommendationRetentionDays = 30.DefaultExecutionTTLDays.specs/recommendations-cache.mdand.env.example. It is not exposed as a Terraform variable; the default applies unless an operator sets the env var on the compute.Interaction with suppressions and purchases
purchase_suppressions) are keyed by(execution_id, account_id, provider, service, region, resource_type, engine)with their ownexpires_at. They have no FK to and no id reference intorecommendations. The scheduler subtracts them from stored recs at read time. Pruning a pair's rows leaves its suppressions untouched: they have nothing to subtract from until they expire on their own. If the pair collects again, any still-active suppression applies to the new rows as before.purchase_executions.recommendations) store a JSONB snapshot of the recs at creation time. They have no FK torecommendations.idand nothing reads them back from the cache, so pruning never changes, orphans or blocks an existing execution, approval or fire.purchase_pricing.go,planstep/resolve.go). After a prune they get the same result as after a normal eviction: the existing fail-closed 409 "is not in the current recommendation set; refresh the recommendations and try again", or the rec is simply absent from the step. The prune adds no new failure mode.payload->>'id', derived from the identity tuple). The table's UUID primary key is new, because rows are re-inserted, not updated.Behavior a reviewer should weigh
Refs #120
Verification
All commands ran with
export PATH=$HOME/.local/go/bin:$HOME/go/bin:$HOME/.local/bin:$PATH AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=trueon the branch head.Build/vet/lint:
go build ./...: okgo vet ./...andgo vet -tags integration ./...: okgolangci-lint run --allow-parallel-runners ./internal/config/... ./internal/scheduler/... ./internal/mocks/...: 0 issues (also with--build-tags integration)gocyclo -over 10 -ignore "_test\.go"on the changed non-test files: nothing over 10pre-commit run {trailing-whitespace,end-of-file-fixer,markdownlint,gosec,go-fmt} --files <changed files>: passedUnit tests (mock evidence):
go test -count=1 ./internal/scheduler/,./internal/config/and./internal/mocks/: ok. New tests ininternal/scheduler/stale_recommendations_test.go:0, negative,720hand garbage valuesIntegration tests (real Postgres via testcontainers,
internal/config/store_postgres_recommendations_prune_integration_test.go):What each test covers:
awspair is pruned even though a fresh ambientazurepair exists, so provider is part of the pair key. A stale registeredazurepair is pruned despite the fresh ambientazurepair, so the account is part of the key. The delete count is exact and a second run deletes nothing.0and-1return an error and delete nothing.UpsertRecommendationsrefreshes one row of a stale pair and is held open. A test transaction holds therecommendations_staterow lock, so the upsert stops after it has upserted the row. The real prune is then started and the test waits until it is blocked.Fail-before / mutation evidence (integration tests on real Postgres; each mutation was applied to the branch and reverted afterwards):
persistCollectionhook (main's behavior)LOCK TABLEremovedpair-untoucheddeleted (prune judged the pair on a pre-commit snapshot)fresh.provider = r.providerremovedfresh.account_key = r.account_keyremovedNOT EXISTS(pair scope) removedfresh-oldandfresh-olderdeleted) and ConcurrentCollection FAILSRemoving only the row-level
collected_at < cutoffbound while keeping the lock survives, as expected: under the lock it follows from theNOT EXISTSand is kept for the index plan, as described above.I did not run whole integration packages,
-race ./...or git-secrets; those run on the combined tree.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.Out of scope (left for owner decisions, per #120)
external_idchanges orphaning historyListStoredRecommendations, which waits on the feat(recommendations): bound GET /api/recommendations with pagination and slimmer rows #777 paging decisionI also didn't touch rows that partial collections leave behind inside a pair that is still collecting.
Frontend follow-up for the UI owner
None required. Pruned rows just stop appearing, and the existing freshness/error banner is unchanged.
Labels
Maintainer: please mirror #120's triage labels on this PR:
impact/internal,effort/l,type/chore,triaged,priority/p3,severity/low,urgency/eventually.🤖 Generated with Claude Code