Repository navigation
fix(scheduler): withhold stale-row eviction after a partial sweep - #1708
Conversation
tolerateIncompleteSweep turned an Azure *PartialSubscriptionFailureError
into a nil error so the subscriptions that DID answer keep their data.
That much is right, but it discarded the fact that the sweep was
incomplete, and the account then landed in the succeeded-account roster
that authorizes stale-row eviction.
Eviction is account-granular:
DELETE FROM recommendations
WHERE collected_at < $1
AND (provider, account_key) IN (…)
keyed on account_key, the CUDly account UUID. A sweep, however, fails one
SUBSCRIPTION at a time. A sweep that never queried subscription B returns
no rows for B, so listing the account as succeeded deleted B's
previous-cycle rows. Its savings opportunities vanished and read as
"nothing to buy" rather than "collection incomplete" — the COR-03 hazard
mergeServiceResults already warns about, landing on a money-adjacent
table.
tolerateIncompleteSweep now also reports whether the sweep was complete,
and that flows through the per-provider collect functions into
fanOutPerAccount, which keeps a partially-swept account out of
SucceededAccountIDs while still counting it in SucceededCount.
The narrowness is the point. The rows that were collected are still
upserted by natural key, so the subscriptions that answered get fresh
data, and the account is NOT marked failed: failing it would blank a
whole account's dashboard over one flaky subscription, the opposite
failure and the one the succeeded-account roster exists to prevent. Only
the authority to DELETE is withheld. This reuses the semantics
UpsertRecommendations already documents for failed collections ("their
stale rows stay — callers see older data with a banner rather than a
blank section").
Scoping eviction to the subscriptions actually queried was considered and
rejected: the recommendations table has no subscription column (its
identity is (provider, account_key), migration 000030), so it would need
a new column, a migration, and the subscription threaded through
RecommendationRecord — and would still need this same signal plumbed the
same way.
Regression coverage drives the sub-account granularity the existing
account-level tests never reach: they assert that a collection returning
an ERROR is excluded, while a partial sweep returns data plus an error,
the one shape that is neither a clean success nor a failure.
TestCollectAzure_PartialSweepIsNotEvictionEligible fails on the pre-fix
code (roster is []string{""}). Controls pin both other directions: a
complete sweep must stay eviction-eligible, or stale rows accumulate
forever, and a hard error must still fail the account.
TestTolerateIncompleteSweep asserted only NoError on a partial sweep,
which encoded this hazard as intended behavior; it now pins the
completeness half of the contract too.
The function's NOTE framed the discarded signal as cosmetic ("so the
dashboard shows the sweep as partial"). The same signal fed the eviction
roster, so it was data loss, not display. Rewritten to say so.
last_collection_error is still cleared on a partial sweep. That column is
a single global row (recommendations_state WHERE id = 1), not per
account, so carrying one account's partial sweep in it is a separate
design decision; with eviction withheld the residue is observability, not
lost rows. Documented at the source and left as follow-up.
Note the fan-out is not yet reachable from the collection path: both
Azure collect paths pin a subscription (collectAzureAmbient only runs
when AZURE_SUBSCRIPTION_ID is set; collectAzureForAccount hard-errors on
an empty azure_subscription_id), and GetRecommendationsClient only builds
the multi-subscription client when no subscription is named. This lands
the guarantee before that changes.
Closes #1652
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 7 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
Adversarial review — verdict: safe to mergeIndependent review with fresh context, against a clean detached extract of Everything below was executed. Commands and output are quoted. The hazard I went looking for first, and did not findThe fix works by returning an empty eviction roster for a partial sweep. That is only safe if an empty roster means "evict nothing" and not "evict unscoped". if len(successfulCollects) > 0 {
... DELETE FROM recommendations WHERE collected_at < $1 AND (provider, account_key) IN (...)
}An empty roster skips the statement entirely. There is no unscoped-delete fallback. This also covers the all-accounts-partial case on the registered path, where 1. Withheld in every partial case, and only in partial cases
3. The pre-fix test genuinely fails, and the labelling is honestCopied the new test file onto unmodified Exactly one test flips, and it is the one that describes the bug — The caveat in the PR description is accurate, and I verified it rather than taking it: the full file cannot compile against — because the fix changes 6.
|
Closes #1652
The defect
tolerateIncompleteSweep(internal/scheduler/scheduler.go) converted an Azure*PartialSubscriptionFailureErrorinto anilerror so the subscriptions that did answer keep their data. That part is right. But it discarded the fact that the sweep was incomplete, and the account then landed in the succeeded-account roster that authorizes stale-row eviction.Eviction is account-granular:
keyed on
account_key, the CUDly account UUID. A sweep, however, fails one subscription at a time. A sweep that never queried subscription B returns no rows for B, so listing the account as succeeded deleted B's previous-cycle rows — its savings vanished and read as "nothing to buy" rather than "collection incomplete". That is the COR-03 hazardmergeServiceResultsalready warns about, landing on a money-adjacent table.Reachability — please read before prioritising
This is not live today. The fan-out client is only built at
providers/azure/provider.gostep 3, which requires no subscription to be named, and both scheduler Azure paths pin one:collectAzureAmbientonly runs whenAZURE_SUBSCRIPTION_ID != ""(the caller returns early on empty) and passes it through;collectAzureForAccounthard-errors on an emptyazure_subscription_idand passes it asProfile.The other
tolerateIncompleteSweepcall sites cannot produce the error at all: two are GCP, two are infetchAndConvert, whose only three callers pass"aws".PartialSubscriptionFailureErrorhas exactly one construction site repo-wide, inside the fan-out client. A pinned sweep also cannot produce it structurally: partial requires0 < Succeeded < Attempted, impossible atAttempted == 1.So this lands the guarantee before the fan-out becomes reachable, which is the cheap ordering and what the issue itself asks for.
The fix
tolerateIncompleteSweepnow returns(complete bool, err error). The flag threads through the per-provider collect functions intofanOutPerAccount, which counts a partially-swept account inSucceededCountbut keeps it out ofSucceededAccountIDs(recording it in a newIncompleteAccountIDs). Ambient paths route through a newambientEvictionRoster(complete, accountID)that yields an empty roster when incomplete.The narrowness is the point. Rows that were collected are still upserted by natural key, so the subscriptions that answered get fresh data, and the account is not marked failed — failing it would blank a whole account's dashboard over one flaky subscription, the opposite failure and the one the succeeded-account roster exists to prevent. Only the authority to
DELETEis withheld. This reuses semanticsUpsertRecommendationsalready documents for failed collections: "their stale rows stay — callers see older data with a banner rather than a blank section." Downstream needed no change: the eviction is already guarded byif len(successfulCollects) > 0.Why not scope eviction to the queried subscriptions
That was the first shape considered, and it is not expressible today: the
recommendationstable has no subscription column — its identity is(provider, account_key)(migration 000030) andRecommendationRecordcarries onlyCloudAccountID. It would need a new column, a migration, and the subscription threaded through the record, and would still need this same signal plumbed the same way. Larger blast radius, same prerequisite, for a path not yet reachable.One subtlety
fetchAndConverthas a zero-results retry that re-queries the same scope. Completeness is combined withcomplete = complete && fallbackCompleterather than assigned, so a retry that happens to come back whole cannot launder away the first sweep's missing subscriptions and re-authorize eviction.Tests
The existing account-level tests could not see this: they assert that a collection returning an error is excluded, while a partial sweep returns data plus an error — the one shape that is neither a clean success nor a failure. The new tests drive that sub-account granularity.
TestCollectAzure_PartialSweepIsNotEvictionEligiblefails on pre-fixmain:It deliberately drives the ambient path, whose signature this PR does not change, so it compiles and fails against unmodified
main. AfanOutPerAccount-level test could not demonstrate the pre-fix failure — this PR changes that function'sfnsignature, so such a test would fail to compile onmain, proving nothing. One is included anyway (TestFanOutPerAccount_PartialSweepNotInSucceededAccountIDs) as post-fix coverage of the registered-account path, which is the common production shape.Controls pin both other directions, since under-evicting is a quieter bug than over-evicting: a complete sweep must stay eviction-eligible (or stale rows accumulate forever), and a hard error must still fail the account.
TestTolerateIncompleteSweepasserted onlyNoErroron a partial sweep — it encoded this hazard as intended behaviour. It now pins the completeness half of the contract too.The store-layer half of the chain was already covered by
TestPostgresStore_UpsertRecommendations_PartialCollect("not in roster → rows preserved"), so the guarantee is now tested end to end.Comment corrected
The function's
NOTEframed the discarded signal as cosmetic — "so the dashboard shows the sweep as partial". The same signal fed the eviction roster, so it was data loss, not display. Rewritten to say so, since a future reader would otherwise trust it.Deliberately out of scope
last_collection_erroris still cleared on a partial sweep. It is a single global row (recommendations_state WHERE id = 1), not per-account, so carrying one account's partial sweep in it is a schema/UX decision rather than a mechanical fix; with eviction withheld the residue is observability, not lost rows. Documented at the source — worth a follow-up issue.ReplaceRecommendationsdoesDELETE FROM recommendationswith no scoping at all, so a partial sweep through it would wipe every account. It has no production caller (the only non-test reference is the interface declaration), so it is a latent landmine rather than a live path. Flagged, not touched.Verification
go build ./...exit 0;go vet ./...exit 0 — vet is what caught the stale test call sitesgo buildskips.go test ./...exit 0 (28 ok, 0 fail).-raceoninternal/scheduler,internal/config,providers/azure(+ all 11 azure sub-packages): exit 0, 0 data races.golangci-lintat the CI-pinned v2.10.1: baseline onmain0 issues / exit 0; branch 0 issues / exit 0. (An interim run surfaced 9 issues — 8 British spellingsmisspellenforces as US, plus oneunparam— fixed by hand rather than--fix.)gocyclo -over 10, CI's exact invocation: exit 0. Touched functions max out at 9 (fetchAndConvert).--no-verify.