Skip to content

fix: aggregate auto-full-drives planner warnings into one event per condition instead of per node - #2768

Closed
kristina-solovyova wants to merge 1 commit into
08-17-docs_document_act-as-daemonset_mode_op-345_from
08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node
Closed

fix: aggregate auto-full-drives planner warnings into one event per condition instead of per node#2768
kristina-solovyova wants to merge 1 commit into
08-17-docs_document_act-as-daemonset_mode_op-345_from
08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node

Conversation

@kristina-solovyova

Copy link
Copy Markdown
Collaborator

No description provided.

@kristina-solovyova
kristina-solovyova marked this pull request as ready for review August 20, 2026 08:33
@kristina-solovyova
kristina-solovyova requested a review from a team as a code owner August 20, 2026 08:33
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

kristina-solovyova commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more


How to use the Graphite Merge Queue

Add the label main-merge-queue to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has required the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @kristina-solovyova's task in 4m 7s —— View job


Review: aggregate auto-full-drives planner warnings

  • Gather context (diff vs 08-17-docs_document_act-as-daemonset_mode_op-345_, parent-PR background)
  • Review planner aggregation, event plumbing, CLI rendering, docs, tests
  • Post inline findings + summary

The core change is right and well executed: the walk collects ineligible / deferred / deleting across the whole pass and emits one Warning per condition, Warning.Subject and the whole keyPerNode / RecordEventThrottledPerSubject machinery are deleted rather than left dangling, and node ordering is already deterministic (autofulldrives.go:46 sorts by name) so the aggregated lists don't churn. listNodes capping at autoFullDrivesMaxNamedNodes is a good call, and reusing the constant from infeasibility.go keeps one cap for the whole mode. Merging both deferral causes into a single WarningKindTransient warning — because they share one reason whose throttle key ignores the message — is the subtle bit, and TestPlanAutoFullDrives_UnscheduledAndDeletingCauses_MergeIntoOneWarning pins exactly that.

Four findings, posted inline:

1. cmd/weka-capacity/plan.go:676-683 — misattributed NOTE (main issue). The NodeIneligible branch now fires for any notPlanned row whenever any node in the fleet is ineligible. A node skipped for HasDeletingDriveContainer, or any fit-failure node on an infeasible plan, is also notPlanned with DrivesUsed=0 < DrivesAvail, and gets labelled cordoned/not ready/untolerated taint. Before the PR, warningForNode matched by Subject and gave the deleting node its own text. The row already holds the per-node truth (NodeCapacity.IneligibleReason / HasDeletingDriveContainer), so it never needs to consult the fleet warning for this — suggestion inline. Fix this →

2. internal/capacityplanner/autofulldrives_compute.go:165ComputeLayout still emits two warnings in one pass. autoPlaceNewCompute appends a second WarningKindComputeLayout warning (:379) on the next line, so with per-reason throttling the second is silently dropped for the full 15-minute window — the exact hazard formatPlacementDeferredWarning's comment calls out. The doc row added here ("Every compute-sizing advisory … joined into one message per pass") asserts a property the code doesn't yet guarantee.

3. internal/controllers/wekacluster/planner_events.go:66-73 — 15 min → 1 min for PlacementDeferred / NodeIneligible. Aggregation removed the need for the per-node key, but not the rationale for the converged-state window that the doc paragraph above the table still states. A node cordoned for maintenance now posts every minute all day. And the freshness argument doesn't quite hold: the throttle key ignores the message, so a changed node set isn't what re-posts — the window expiring is. If refresh-on-change is the goal, key these two on reason+message; otherwise keep plannerConvergedEventInterval. DrivesStranded is the same shape of snapshot and stays at 15 min, so the three rows don't follow one rule as written.

4. internal/capacityplanner/autofulldrives_sizing.go:95-102 — nit, file placement. The file header declares it the pure sizing layer; ineligibility/deferral message formatting isn't sizing. Also, the 10-name cap understates the real message length when formatPlacementDeferredWarning emits two lists.

No security concerns; no performance concerns (the aggregation replaces per-node allocations with three small slices). Test coverage for the new behaviour is solid — the listNodes cap table test, the merged-causes test, and the controller-level "exactly 1 event naming all nodes" regression test all target the right invariants.

One caveat: go test was blocked by the sandbox in this run, so I reviewed statically and did not execute the suite — worth confirming CI is green before merge.
· branch 08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node

@graphite-app

graphite-app Bot commented Aug 20, 2026

Copy link
Copy Markdown

Graphite Automations

"Add anton/matt/sergey/kristina as reviwers on operator PRs" took an action on this PR • (08/20/26)

2 reviewers were added to this PR based on Anton Bykov's automation.

Comment thread cmd/weka-capacity/plan.go
Comment on lines 676 to 683
if row.DrivesUsed < row.DrivesAvail {
row.Note = warningForNode(plan.Warnings, n.NodeName)
// A stranded node has no per-node Subject to match above (see hasFleetWarning) — point at the
// fleet warning instead, but only for a row the plan actually placed something on:
// nodeStateNotPlanned means the walk never sized this node at all, so "held back by the pin"
// would misattribute an infeasibility skip to the pin.
if row.Note == "" && row.State != nodeStateNotPlanned &&
hasFleetWarning(plan.Warnings, capacityplanner.WarningKindDrivesStranded) {
switch {
case row.State == nodeStateNotPlanned && hasFleetWarning(plan.Warnings, capacityplanner.WarningKindNodeIneligible):
row.Note = "cordoned/not ready/untolerated taint — see WARNINGS"
case row.State != nodeStateNotPlanned && hasFleetWarning(plan.Warnings, capacityplanner.WarningKindDrivesStranded):
row.Note = "drives held back by the numDrives pin — see WARNINGS"
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misattributed NOTE for not-planned rows. With Subject gone, this branch fires for every nodeStateNotPlanned row as soon as any node in the fleet is ineligible — the row itself may have nothing to do with cordon/taint. Two concrete cases:

  • a node skipped for HasDeletingDriveContainer (autofulldrives.go:146) is notPlanned with DrivesUsed=0 < DrivesAvail, and gets labelled cordoned/not ready/untolerated taint if some other node happens to be cordoned. Before this PR, warningForNode gave it its own "container being deleted" text.
  • on an infeasible plan, every fit-failure node is notPlanned too, and gets the same wrong label.

The row already has the authoritative per-node answer in hand — NodeCapacity.IneligibleReason / HasDeletingDriveContainer (internal/capacityplanner/nodecapacity.go:36) — so the aggregated warning doesn't need to be consulted for this at all:

Suggested change
if row.DrivesUsed < row.DrivesAvail {
row.Note = warningForNode(plan.Warnings, n.NodeName)
// A stranded node has no per-node Subject to match above (see hasFleetWarning) — point at the
// fleet warning instead, but only for a row the plan actually placed something on:
// nodeStateNotPlanned means the walk never sized this node at all, so "held back by the pin"
// would misattribute an infeasibility skip to the pin.
if row.Note == "" && row.State != nodeStateNotPlanned &&
hasFleetWarning(plan.Warnings, capacityplanner.WarningKindDrivesStranded) {
switch {
case row.State == nodeStateNotPlanned && hasFleetWarning(plan.Warnings, capacityplanner.WarningKindNodeIneligible):
row.Note = "cordoned/not ready/untolerated taint — see WARNINGS"
case row.State != nodeStateNotPlanned && hasFleetWarning(plan.Warnings, capacityplanner.WarningKindDrivesStranded):
row.Note = "drives held back by the numDrives pin — see WARNINGS"
}
}
if row.DrivesUsed < row.DrivesAvail {
switch {
case row.State == nodeStateNotPlanned && n.IneligibleReason != "":
row.Note = n.IneligibleReason + " — see WARNINGS"
case row.State == nodeStateNotPlanned && n.HasDeletingDriveContainer:
row.Note = "drive container being deleted — see WARNINGS"
case row.State != nodeStateNotPlanned && hasFleetWarning(plan.Warnings, capacityplanner.WarningKindDrivesStranded):
row.Note = "drives held back by the numDrives pin — see WARNINGS"
}
}

That also makes the NOTE strictly more informative (the actual reason verbatim, matching what the warning says about that node) and leaves hasFleetWarning with a single caller.

for _, w := range warnings {
plan.Warnings = append(plan.Warnings, Warning{Kind: WarningKindComputeLayout, Message: w})
if len(warnings) > 0 {
plan.Warnings = append(plan.Warnings, fleetWarning(WarningKindComputeLayout, "%s", strings.Join(warnings, "; ")))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Joining these is a real improvement, but it doesn't finish the job for ComputeLayout: autoPlaceNewCompute appends a second WarningKindComputeLayout warning of its own (autofulldrives_compute.go:379, the form-cluster-floor surplus advisory), and it runs on the very next line — so a pass that hits both produces two warnings under the same reason.

That is exactly the hazard formatPlacementDeferredWarning documents ("two warnings would let one silently suppress the other"): emitPlannerEvent now throttles on eventtype+reason only, so the second one is silently dropped for the whole 15-minute AutoFullDrivesComputeLayout window. The doc change in this PR also asserts the property that doesn't hold yet — "Every compute-sizing advisory from the shared compute layout step, joined into one message per pass" (act-as-daemonset.md:864).

Cheapest fix that keeps the invariant "one Warning per reason per pass": accumulate compute advisories on in (or return them from autoPlaceNewCompute) and emit the single joined fleetWarning once at the end of planComputeAutoFullDrives, the same shape the drive walk uses.

Comment on lines +66 to +73
reasonAutoFullDrivesDrivesStranded: {corev1.EventTypeNormal, plannerConvergedEventInterval},
// 1 minute, not plannerConvergedEventInterval: the message is a fleet-wide snapshot naming every deferred
// node, and a short window lets it refresh as pods bind during formation.
reasonAutoFullDrivesPlacementDeferred: {corev1.EventTypeNormal, time.Minute},
// Normal, not Warning: withholding a node costs nothing on its own — the plan proceeds on the rest, and
// when the loss does matter the plan turns infeasible and AutoFullDrivesInfeasible carries that as a
// Warning. Keyed per node so one node's condition cannot starve another's out of the throttle window.
reasonAutoFullDrivesNodeIneligible: {corev1.EventTypeNormal, plannerConvergedEventInterval, keyPerNode},
reasonAutoFullDrivesComputeLayout: {corev1.EventTypeWarning, plannerConvergedEventInterval, keyPerReason},
reasonAutoFullDrivesWarning: {corev1.EventTypeWarning, plannerConvergedEventInterval, keyPerReason},
// Warning. 1 minute so the named node list stays current as nodes drain out of the set.
reasonAutoFullDrivesNodeIneligible: {corev1.EventTypeNormal, time.Minute},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping these two from 15 min to 1 min looks like the wrong half of the trade. Aggregation removed the reason for the per-node key, but not the reason for the long window — the doc paragraph right above the table still says it: "The advisories that describe a converged state use a long window, because a permanently compute-limited cluster is healthy and re-posting … every minute forever trips alerting", and the sentence this PR deletes from the NodeIneligible row made the same point for a node left cordoned for maintenance.

Concretely, a node cordoned for a day-long maintenance now posts an AutoFullDrivesNodeIneligible event every minute for that whole day instead of 4/hour. They're Normal, so alerting is mostly spared, but identical repeats are not free: client-side event spam filtering is per (source, involvedObject), so a minute-cadence event burns the WekaCluster object's budget and can suppress a genuinely new event on the same cluster.

The freshness argument is also weaker than it reads: the throttle key ignores the message, so a changed node set is not what re-posts — the window expiring is. 15 min still refreshes the list, just more slowly, which for cordon/taint (an administrative state that persists for minutes-to-hours) seems like the right cadence.

If refresh-on-change is genuinely what you want here, the honest way to get it is to include the message in the throttle key for these two reasons — quiet while the set is stable, immediate re-post when a node enters or leaves it — rather than a blanket 15× cadence increase. DrivesStranded is left at 15 min while being the same shape of fleet-wide snapshot, so as written the three rows don't follow a consistent rule either.

Comment on lines +95 to +102
// listNodes joins per-node parts, capping the list at autoFullDrivesMaxNamedNodes with a "(+N more)" tail
// so a large fleet cannot turn one aggregated event into a multi-KB message.
func listNodes(parts []string) string {
if len(parts) <= autoFullDrivesMaxNamedNodes {
return strings.Join(parts, ", ")
}
return fmt.Sprintf("%s (+%d more)", strings.Join(parts[:autoFullDrivesMaxNamedNodes], ", "),
len(parts)-autoFullDrivesMaxNamedNodes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: listNodes, formatIneligibleWarning and formatPlacementDeferredWarning don't belong in this file by its own header — "the pure sizing layer … every semantics rule about how big a container is, with no resource model at all". Eligibility and placement-deferral messages are neither. formatStrandedWarning fits (stranding is a sizing outcome of the numDrives pin); the two new ones read like they want to live next to their producer in autofulldrives.go, or in a small autofulldrives_warnings.go alongside listNodes.

Also worth a thought while the cap is fresh: listNodes bounds each list at 10, but formatPlacementDeferredWarning can emit two of them (up to 20 names) and formatIneligibleWarning's entries each carry a reason suffix, so the aggregated message is a good deal longer than the 10-name cap suggests for an event message.

@kristina-solovyova
kristina-solovyova force-pushed the 08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node branch from 8303204 to ba8c753 Compare August 20, 2026 10:01
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @kristina-solovyova's task in 5m 3s —— View job


Re-review: aggregate auto-full-drives planner warnings

  • Diff current HEAD against my previous review (branch was force-pushed)
  • Check whether the four earlier findings were addressed
  • Review the new code for fresh issues
  • Post inline findings + summary

All four findings from the previous round are addressed, and addressed properly rather than papered over:

  1. Misattributed NOTEplan.go:679-688 now reads IneligibleReason / HasDeletingDriveContainer off the row's own NodeCapacity, and TestAutoFullDrivesNodeRows_NotPlannedNoteFromNodeStateNotFleetWarning pins all three cases including the "n-unfit NOTE stays empty" one. The hasFleetWarning doc now states the soundness condition (single-cause kinds only) instead of the stale Subject=="" rule.
  2. ComputeLayout double-emit — advisories accumulate through autoPlaceNewCompute and leave as one joined fleetWarning; the prefix moved to the join so it appears once, and the test asserts len(layout) == 1 plus the prefix.
  3. Throttle windows — both reasons back on plannerConvergedEventInterval, with the cordon-for-maintenance rationale restored in the comment, the table, and the spec test. The three converged-state rows now follow one rule.
  4. File placement — new autofulldrives_warnings.go with a header that states the one-Warning-per-reason invariant, and formatStrandedWarning moved along with it so all four formatters live together.

Four new comments posted inline, one of which I'd want resolved before merge:

1. autofulldrives.go:239-247 — the aggregated warnings are dropped on the autoSizeNode early return. setInfeasible(&plan, report); return at :157-160 sits above the new emission block, so ineligible/deleting/deferred collected from nodes already walked is discarded. Inline as the walk, they used to survive. It also splits the two infeasibility paths: autoNodeFitInfeasible runs after the block and keeps its advisories, a pin-conflict infeasibility loses them. Production impact is nil (the controller returns before reading warnings on an infeasible plan) — the visible loss is the weka-capacity dry-run's WARNINGS section. Hoisting the block into a closure called on both exits fixes it. Fix this →

2. funcs_fd_planning.go:117-127 — the same bug you just fixed, still standing for clusterCapacity. Three loops emit N messages under one reason each (ShrinkEvents, Warnings, OverProvisions), and RecordEventThrottled keys on eventtype+reason. Because the list order is stable, element 0 wins every window forever and elements 1..N-1 are never posted — not delayed, invisible. Pre-existing and reasonable as a follow-up, but the doc paragraph you added asserts aggregation as a repo-wide property, and it only holds on the auto path.

3. autofulldrives_compute.go:164-174 — nit. deriveComputeLayout never assigns its warnings named return (all three exits return it nil), so autoPlaceNewCompute's surplus advisory isn't merely "the one that fires in practice", it's the only one that can fire. The comment reads as if two producers are live.

4. autofulldrives_warnings.go:74-80 — cosmetic. "placement deferred on N node(s)" overstates the unscheduled-pod group, where growth rather than placement is deferred; the per-cause clauses below get it right.

No security concerns. No performance concerns — the aggregation replaces per-node Warning allocations with three slices, and listNodes bounds the message. Determinism is fine: autoFullDrivesNodes sorts by name (:46), so all three lists are stable across reconciles and won't churn the event text.

Test coverage for the new behaviour is genuinely good — the listNodes cap table test checks under/at/over the boundary and asserts the name past the cap is absent, TestFormatPlacementDeferredWarning_OmitsEmptyGroups covers the empty-clause and singular/plural retry tail, and the controller-level 3-node regression test asserts exactly one event naming all of them rather than just "≥1 event".

Caveat, same as last round: go build / go test were blocked by the sandbox, so this is a static review. Please confirm CI is green — in particular TestPlanAutoFullDrives_UnscheduledAndDeletingCauses_MergeIntoOneWarning, whose fixture depends on deleting not appearing in existingDrives.
· branch 08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node

Comment on lines +239 to +247
if len(stranded) > 0 {
plan.Warnings = append(plan.Warnings, formatStrandedWarning(stranded, desired.NumDrives))
}
if len(ineligible) > 0 {
plan.Warnings = append(plan.Warnings, formatIneligibleWarning(ineligible, ineligibleDrives))
}
if len(deferred) > 0 || len(deleting) > 0 {
plan.Warnings = append(plan.Warnings, formatPlacementDeferredWarning(deferred, deleting))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the emission after the walk drops these three warnings on the autoSizeNode early return. autoSizeNode failing (:157-160) does setInfeasible(&plan, report); return plan, ... — above this block — so everything ineligible/deleting/deferred collected from the nodes already walked is discarded. Before the PR those were appended inline as the walk hit each node, so they survived that return.

Two things make it worth fixing rather than accepting:

  • it contradicts the other infeasibility path six lines down: autoNodeFitInfeasible(failures) runs after this block, so a fit-failure infeasibility keeps its ineligible/deferred advisories while a pin-conflict infeasibility (driveCores above a node's drive count, numDrives above its signed count) silently loses them;
  • the visible consumer is the weka-capacity dry-run, which renders WARNINGS for an infeasible plan. A run that hits the pin conflict on node 3 of 14 now shows nothing about the 2 cordoned nodes it already walked past. (No production event change — planAutoFullDrives returns early on plan.Infeasible != "" before emitting warnings.)

Cheapest fix is to make the emission unconditional at every exit — hoist lines 239-247 into a small emitWalkWarnings(&plan) closure defined above the loop and call it on the autoSizeNode return too, or defer it.

Comment on lines 117 to 127
for _, msg := range plan.ShrinkEvents {
r.emitPlannerEvent(reasonClusterCapacityShrink, "", msg)
r.emitPlannerEvent(reasonClusterCapacityShrink, msg)
}
// clusterCapacity uses a single reason for all its warnings (layout advisories); only the message is
// read from the classified Warning. Auto full drives instead splits by cause (autoFullDrivesWarningReason).
for _, w := range plan.Warnings {
r.emitPlannerEvent(reasonClusterCapacityHeterogeneousGrowth, "", w.Message)
r.emitPlannerEvent(reasonClusterCapacityHeterogeneousGrowth, w.Message)
}
for _, msg := range plan.OverProvisions {
r.emitPlannerEvent(reasonClusterCapacityOverProvisioned, "", msg)
r.emitPlannerEvent(reasonClusterCapacityOverProvisioned, msg)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not introduced here, but this PR's whole thesis makes it stand out and it's on lines you're already touching: these three loops are the same bug you just fixed for auto-full-drives, still standing for clusterCapacity.

Each emits N messages under one reason, and RecordEventThrottled keys on eventtype+reason only (reconciler_loop.go:176). So on a plan with 3 shrink events, the first posts and events 2-3 are dropped; the next reconcile re-emits the same list in the same order, so element 0 wins again and elements 1..N-1 are never posted at all — not delayed, invisible. Same for plan.Warnings under ClusterCapacityHeterogeneousGrowth and plan.OverProvisions.

Auto-full-drives now guarantees one Warning per reason per pass at the producer; clusterCapacity has no such guarantee and its producer (planner.go:1200) appends one heterogeneous-growth advisory per pool. Either join at the call site here (strings.Join(capacityplanner.WarningMessages(plan.Warnings), "; "), and likewise for the two []string slices), or aggregate in the planner the way formatStrandedWarning does. Fine as a follow-up if you'd rather keep this PR scoped to the auto path — but worth a line in the commit or a ticket, since the doc paragraph you added ("A per-reason window is enough because each planner advisory is already aggregated") now reads as a repo-wide claim that only holds for auto-full-drives.

Comment on lines +164 to 174
// Every compute advisory accumulates here and leaves as one Warning: they share the single reason
// AutoFullDrivesComputeLayout, whose throttle key ignores the message, so a second Warning under that
// reason is silently dropped for the whole window instead of reported. autoPlaceNewCompute's surplus
// advisory is the one that fires in practice; deriveComputeLayout feeds the same join.
advisories := append([]string(nil), warnings...)
autoPlaceNewCompute(in, plan, kept, placeable, coreHeadroom, count, cores, newTarget, &advisories)
if len(advisories) > 0 {
plan.Warnings = append(plan.Warnings,
fleetWarning(WarningKindComputeLayout, "auto full drives: %s", strings.Join(advisories, "; ")))
}
autoPlaceNewCompute(in, plan, kept, placeable, coreHeadroom, count, cores, newTarget)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The join is the right shape and closes the double-emit hazard. Two small things:

"deriveComputeLayout feeds the same join" doesn't describe live behaviour. deriveComputeLayout declares warnings []string as a named return and never assigns to it — all three success paths are return count, cores, "", warnings with warnings still nil (compute_layout.go:90,124,151,178). So advisories starts empty every time and autoPlaceNewCompute's surplus advisory is not just "the one that fires in practice", it's the only one that can fire at all today. The comment reads as if two producers are live; worth saying it's a seam kept open for when deriveComputeLayout grows advisories, or dropping that clause.

plan.Warnings still gets the advisory when autoPlaceNewCompute sets the plan infeasible (:392 surplus append happens before the count > len(candidates) setInfeasible at :399). Same as before the PR and harmless — the controller returns on Infeasible != "" before reading warnings — but the CLI shows both. Only worth a thought if you're already tidying the infeasible-plan-carries-no-advisories rule that planAutoFullDrivesDrives states in its own comment.

Comment on lines +74 to +80
retry := "it retries automatically"
if len(clauses) > 1 {
retry = "both retry automatically"
}
return fleetWarning(WarningKindTransient,
"auto full drives: placement deferred on %d node(s) this pass; %s; %s",
len(deferred)+len(deleting), strings.Join(clauses, "; "), retry)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit on wording: "placement deferred on %d node(s)" overstates the deferred group — those nodes already host a container, and what's deferred is its growth, not placement. The deleting group is the one where placement waits. The clause texts get this right individually ("growth waits for the scheduler" / "new placement waits for it"), so only the lead-in is off; "auto full drives: %d node(s) skipped this pass" would cover both without picking a side. The reason name AutoFullDrivesPlacementDeferred is stuck with the same slight stretch, so this is cosmetic.

@kristina-solovyova
kristina-solovyova force-pushed the 08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node branch from ba8c753 to 2fd3b85 Compare August 20, 2026 11:44
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @kristina-solovyova's task in 4m 16s —— View job


Re-review (round 3): aggregate auto-full-drives planner warnings

  • Diff current HEAD against the previously reviewed revision
  • Verify the four round-2 findings
  • Review the new code (flushWarnings, plannerAggregateEventInterval, the CLI Unscheduled NOTE)
  • Post inline findings + summary

Round-2 findings 1, 3 and 4 (the doc half of it) are addressed:

  1. Warnings dropped on the mid-walk returnflushWarnings is now a closure defined before the loop (autofulldrives.go:120-130) and called on both exits, with a comment stating why (the CLI's NOTE column points at these entries). TestPlanAutoFullDrives_InfeasibleMidWalk_KeepsWarningsAlreadyCollected pins it with a name-ordered fixture. No double-emit — the early path returns immediately.
  2. deriveComputeLayout comment — corrected to "the only contributor today: deriveComputeLayout never populates its warnings return".
  3. Throttle windows — new plannerAggregateEventInterval = 3 min applied to the three fleet-wide aggregates, with the reasoning (key ignores the message, so the window also bounds how long a newly-joined node stays unreported) written into the const, the row comments, the doc table and the spec test.
  4. clusterCapacity's per-reason loops — the doc no longer claims aggregation repo-wide, so the overreach is gone; the underlying funcs_fd_planning.go:117-127 behaviour (elements 1..N-1 of ShrinkEvents/Warnings/OverProvisions never posting, since the list order is stable and the key ignores the message) is still there as a follow-up.

Also new since last round and worth calling out as good: the CLI gained a fourth NOTE case for an unscheduled pod (plan.go:687), which was a genuine gap — an unscheduled node lands in deferred, keeps State=existing, and used to render a bare used < avail with no explanation. Ordering is right too: the walk defers before the fit, so deferral outranks the stranding pointer.

Two findings, posted inline:

1. cmd/weka-capacity/plan.go:683-690 — the NOTE still promises WARNINGS entries the walk never wrote. flushWarnings covers the nodes visited before an autoSizeNode infeasibility returns; these three cases read NodeCapacity, which is populated for every node. Since rows and the walk share name order, a node sorted after the failing one renders "cordoned — see WARNINGS" against a WARNINGS block that says nothing about it — flip the new test's fixture to a-pinned/z-cordoned and you can see it. The — see WARNINGS suffix earns nothing on these three (the reason is already verbatim on the row); dropping it makes the note true on every plan and leaves the pointer only on DrivesStranded, which is gated on the warning actually existing. Fix this →

2. internal/controllers/wekacluster/planner_events.go:46-51 — 3 min sweeps DrivesStranded along with the two that need it. Stranding under a pinned numDrives is a permanent, operator-requested state — exactly what plannerConvergedEventInterval exists for — and it now re-posts 20×/hour forever. Beyond noise: RecordEvent uses a plain record.EventRecorder, whose default spam filter is a 25-token bucket refilling at 1 per 300 s shared across every event on that WekaCluster. Three aggregates at 3 min ≈ 60/hour against a 12/hour refill, so a persistently stranded fleet with a cordoned node drains it in ~30 min and then drops the next AutoFullDrivesInfeasible on that cluster. Cheapest fix: leave DrivesStranded on the converged window (its set only changes when the pin is edited or drives are signed, both of which emit other events) and keep the 3-minute one for NodeIneligible/PlacementDeferred. The stronger fix is the one the comment is really asking for — include the message in the throttle key for the aggregates.

Still open from round 2, both fine to leave: the "placement deferred on N node(s)" lead-in overstating the growth-deferral group (autofulldrives_warnings.go:79), and clusterCapacity's per-reason emission loops.

No security concerns. No performance concerns. Determinism holds — autoFullDrivesNodes sorts by name, so all three lists and the CLI rows are stable across reconciles. Test coverage for the new behaviour is good: the mid-walk-return test, the three-way not-planned NOTE test, the len(layout) == 1 compute assertion, and the listNodes under/at/over-cap table test all pin the right invariants.

Caveat, unchanged from previous rounds: go build and go test are blocked by this sandbox, so this is a static review — please confirm CI is green.
· branch 08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node

Comment thread cmd/weka-capacity/plan.go
Comment on lines +683 to 690
case row.State == nodeStateNotPlanned && n.IneligibleReason != "":
row.Note = n.IneligibleReason + " — see WARNINGS"
case row.State == nodeStateNotPlanned && n.HasDeletingDriveContainer:
row.Note = "drive container being deleted — see WARNINGS"
case existingByNode[n.NodeName].Unscheduled:
row.Note = "pod has not been scheduled yet — see WARNINGS"
case row.State != nodeStateNotPlanned && hasFleetWarning(plan.Warnings, capacityplanner.WarningKindDrivesStranded):
row.Note = "drives held back by the numDrives pin — see WARNINGS"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flushWarnings fixes the prefix of the walk, but the NOTE column still promises WARNINGS entries for its suffix. These three per-node cases read the node's own inventory, which is populated for every node regardless of how far the walk got — while the aggregated warnings only cover nodes visited before an autoSizeNode infeasibility returned (autofulldrives.go:171-176). Nodes are walked and rendered in name order, so a node sorted after the failing one gets "cordoned — see WARNINGS" with no NodeIneligible entry in WARNINGS to see.

Concretely, flip the fixture in TestPlanAutoFullDrives_InfeasibleMidWalk_KeepsWarningsAlreadyCollected (a-pinned / z-cordoned instead of a-cordoned / z-pinned): the plan is infeasible, z-cordoned's row says "cordoned — see WARNINGS", and WARNINGS has nothing about it. Same for HasDeletingDriveContainer and Unscheduled.

The suffix is what creates the promise, and for these three cases it earns nothing — the reason is already spelled out verbatim on the row, and the aggregated warning adds only the fleet total. Dropping it makes the note true on every plan:

Suggested change
case row.State == nodeStateNotPlanned && n.IneligibleReason != "":
row.Note = n.IneligibleReason + " — see WARNINGS"
case row.State == nodeStateNotPlanned && n.HasDeletingDriveContainer:
row.Note = "drive container being deleted — see WARNINGS"
case existingByNode[n.NodeName].Unscheduled:
row.Note = "pod has not been scheduled yet — see WARNINGS"
case row.State != nodeStateNotPlanned && hasFleetWarning(plan.Warnings, capacityplanner.WarningKindDrivesStranded):
row.Note = "drives held back by the numDrives pin — see WARNINGS"
switch {
case row.State == nodeStateNotPlanned && n.IneligibleReason != "":
row.Note = n.IneligibleReason
case row.State == nodeStateNotPlanned && n.HasDeletingDriveContainer:
row.Note = "drive container being deleted"
case existingByNode[n.NodeName].Unscheduled:
row.Note = "pod has not been scheduled yet"
case row.State != nodeStateNotPlanned && hasFleetWarning(plan.Warnings, capacityplanner.WarningKindDrivesStranded):
row.Note = "drives held back by the numDrives pin — see WARNINGS"
}

The DrivesStranded case keeps its pointer legitimately: it is gated on the warning actually being present.

Comment on lines +46 to +51
// plannerAggregateEventInterval throttles the fleet-wide aggregates, whose message names the affected node
// set. RecordEventThrottled keys on eventtype+reason and ignores the message, so a window also withholds an
// aggregate naming a *different* set: a node cordoned two minutes after the first event would otherwise wait
// out the whole converged-state window. Short enough that a changed set is reported promptly, long enough
// that a stable one is not re-posted every reconcile.
const plannerAggregateEventInterval = 3 * time.Minute

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The middle ground is a fair call and the rationale is now written down — but it moves DrivesStranded from 15 min to 3 min as collateral, and it's the one reason of the three that describes a permanent, operator-requested state. A cluster with numDrives pinned re-posts it 20×/hour forever, where the whole point of plannerConvergedEventInterval was that a converged-but-constrained cluster should be quiet.

The cost isn't just noise. RecordEvent goes to a plain record.EventRecorder, so every event passes client-go's EventSourceObjectSpamFilter — a token bucket of burst 25 refilling at 1 per 300 s, keyed by (source, involvedObject) in the default correlator, i.e. shared by every event this controller posts on that WekaCluster. Three aggregate reasons at 3 min ≈ 60 events/hour against a 12/hour refill: a fleet that is persistently stranded and has a cordoned node drains the bucket in roughly half an hour, after which the next AutoFullDrivesInfeasible or ClusterCapacityInfeasible on that cluster is dropped rather than delayed. At 15 min the three together sat at 12/hour, right at the refill rate. (I couldn't read the vendored client-go v0.36.3 source in this sandbox to re-confirm the spam-key function, so treat the exact key as "the default one".)

Two ways out that don't reintroduce the stale-set problem:

  • Keep plannerAggregateEventInterval for the two that genuinely change set membership (NodeIneligible, PlacementDeferred) and leave DrivesStranded on plannerConvergedEventInterval — its set only changes when someone edits the pin or signs drives, both of which already produce other events.
  • Or make the throttle key include the message for the aggregates, which is what the comment actually wants ("the throttle key cannot tell one node list from another"): silent while the set is stable, immediate re-post the moment a node joins or leaves it. RecordEventThrottled's key is eventtype+reason at reconciler_loop.go:176, so this is a caller-side change — emitPlannerEvent could pass reason+"/"+hash(message) for the reasons flagged as aggregates.

Non-blocking if you've weighed it; the first option is a one-line change and gets most of the benefit.

@kristina-solovyova
kristina-solovyova force-pushed the 08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node branch from 2fd3b85 to 796b25d Compare August 20, 2026 12:29
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @kristina-solovyova's task in 3m 46s —— View job


Re-review (round 4): aggregate auto-full-drives planner warnings

  • Diff current HEAD against the previously reviewed revision
  • Verify the two round-3 findings
  • Review the new code (note closure, listNodesCapped, doc/troubleshooting rewrite)
  • Post inline findings + summary

Both round-3 findings are answered:

  1. The NOTE promising WARNINGS entries the walk never wrote — fixed better than I suggested. Rather than dropping the suffix, plan.go:643-648 introduces a note() closure that earns the — see WARNINGS pointer only when the matching aggregate actually reached plan.Warnings, so the reason stays verbatim on the row either way. TestAutoFullDrivesNodeRows_ConditionWithoutWarning_NoWarningsPointer pins all three arms.
  2. DrivesStranded swept to 3 min — kept at plannerAggregateEventInterval, and the doc now states one rule for all three aggregates ("the window also bounds how long a node that joins the set after the last event stays unreported"). That rationale does hold for stranding — a newly-signed node can join the stranded set — so a uniform rule is defensible. The client-go spam-bucket arithmetic I raised (3 aggregates × 3 min ≈ 60/hr against a 12/hr refill, shared per involvedObject) is unchanged and still the reason I'd have kept stranding on the converged window; treating it as weighed-and-decided.

Also new and good since round 3: listNodesCapped splits the name budget across the two placement-deferral causes so a message with both can't spend the 10-name cap twice, and the troubleshooting section now says plainly that the NodeIneligible advisory is suppressed on an infeasible plan — which matches funcs_fd_planning.go:220-233 returning before the warning loop.

Three findings, posted inline; only the first is worth resolving:

1. cmd/weka-capacity/plan.go:640-648 — the pointer is still promised to nodes the aggregate doesn't name. Gating on hasFleetWarning closes the no warning at all case but not the partial warning case. flushWarnings snapshots only the nodes walked before an autoSizeNode abort, while the row conditions come from NodeCapacity, which is populated for every node — and both are name-ordered. So a-cordoned / m-pinned / z-cordoned yields a NodeIneligible message naming only a-cordoned, with z-cordoned's row saying cordoned — see WARNINGS. WarningKindTransient is worse, since one kind carries two causes: a single unscheduled node before the abort makes the gate true for a HasDeletingDriveContainer node after it, whose only clause is about scheduling. It also makes the new hasFleetWarning doc's "gating a pointer at the WARNINGS section is sound for any kind" too strong. Name-matching isn't the fix (the n1/n10 collision); dropping the suffix on the three per-node cases, or tracking whether the walk completed, both are. Fix this →

2. autofulldrives_warnings_test.go:53 — the new budget test replaced TestFormatPlacementDeferredWarning_OmitsEmptyGroups instead of joining it. Single-cause coverage is now gone: the empty-clause omission, the full limit == autoFullDrivesMaxNamedNodes branch, and the "it retries automatically" singular tail — which a repo-wide grep now finds only on autofulldrives_warnings.go:81 and in no test.

3. autofulldrives_warnings.go:66-70 — nit. The flat halving allocates by group count, not need: 1 deferred + 30 deleting gives both limit = 5, naming 6 nodes when 10 were available. Handing each group the other's unused headroom is a few lines. Fine to leave — the cap is a size guard, not a display budget.

Still open from earlier rounds and fine to carry: the "placement deferred on N node(s)" lead-in overstating the growth-deferral group (autofulldrives_warnings.go:86), and clusterCapacity's three per-reason emission loops in funcs_fd_planning.go:117-127, where elements 1..N-1 never post because the list order is stable and the key ignores the message.

No security concerns. No performance concerns. Determinism holds — autoFullDrivesNodes sorts by name, so the aggregated lists and the CLI rows are stable across reconciles. Test coverage for the new behaviour is otherwise strong: the three-way not-planned NOTE test, the condition-without-warning test, the mid-walk-return test, the len(layout) == 1 compute assertion, the listNodes under/at/over-cap table, and the controller-level 3-node "exactly 1 event naming all of them" regression.

Caveat, unchanged from every prior round: go build and go test are blocked by this sandbox, so nothing here was executed — please confirm CI is green.
· branch 08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node

Comment thread cmd/weka-capacity/plan.go
Comment on lines +640 to +648
// The reason is the node's own; the pointer is only earned when the matching aggregate reached
// plan.Warnings — a mid-walk infeasible abort stops collecting, leaving later nodes a condition
// with no warning to point at.
note := func(kind capacityplanner.WarningKind, reason string) string {
if hasFleetWarning(plan.Warnings, kind) {
return reason + " — see WARNINGS"
}
return reason
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gating the pointer on the aggregate being present is the right shape, and it closes the "no warning at all" half of the previous finding — but it doesn't close the half where the aggregate exists yet doesn't cover this node. flushWarnings snapshots the nodes walked before the abort; the row conditions come from NodeCapacity, which is populated for all of them. Since both are name-ordered, a node sorted after the failing one can share a kind with an earlier node and still be missing from the message:

a-cordoned   →  ineligible = ["a-cordoned (cordoned)"]
m-pinned     →  autoSizeNode reports; flushWarnings(); return
z-cordoned   →  never walked

WARNINGS then reads 1 node(s) ... are ineligible ...: a-cordoned (cordoned) while z-cordoned's row says cordoned — see WARNINGS. Same for WarningKindTransient, which is worse because it carries two causes: one deferred node before the abort makes hasFleetWarning(Transient) true, so a HasDeletingDriveContainer node after the abort points at a message whose only clause is "pod not scheduled yet".

That also makes the hasFleetWarning doc's second sentence too strong — "gating a pointer at the WARNINGS section is sound for any kind" holds only when kind-presence implies this row is named in it, which is exactly what the abort breaks.

Matching on the node name inside the message is not the fix (it reintroduces the n1/n10 collision warningForNode was written to avoid). Either drop the suffix on the three per-node cases — the reason is already verbatim on the row and the aggregate adds only a fleet total, so nothing is lost — or have the planner mark whether the walk ran to completion and gate on that, since warnings are complete for the autoNodeFitInfeasible path and incomplete only for the mid-walk one.

The existing regression test only covers plan.Warnings == nil; the case above needs a fixture where the aggregate is present but partial. Fix this →

Comment on lines +53 to +56
// TestFormatPlacementDeferredWarning_SharesBudgetAcrossCauses guards against spending the cap twice: with
// both causes present, deferred and deleting must split one autoFullDrivesMaxNamedNodes budget rather than
// each getting the full cap, or a fleet with plenty of both would name up to 2x the intended maximum.
func TestFormatPlacementDeferredWarning_SharesBudgetAcrossCauses(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The budget-sharing test is a good addition, but it replaced TestFormatPlacementDeferredWarning_OmitsEmptyGroups rather than joining it, so the single-cause path lost its only coverage. Nothing now exercises:

  • clauses with exactly one entry — i.e. that the empty group is omitted and no stray ; separator or empty node list is emitted;
  • the "it retries automatically" singular tail (autofulldrives_warnings.go:81), which is currently unreachable from any test in the repo — a grep for that string hits only the source line;
  • limit == autoFullDrivesMaxNamedNodes when only one cause is present, which is the branch the new test's if len(deferred) > 0 && len(deleting) > 0 guard skips.

Both are cheap to keep — a two-case subtest (deferred only / deleting only) alongside the shared-budget one restores it.

Comment on lines +66 to +70
// Two causes share one message, so halve the budget rather than let each spend the full cap.
limit := autoFullDrivesMaxNamedNodes
if len(deferred) > 0 && len(deleting) > 0 {
limit = autoFullDrivesMaxNamedNodes / 2
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: a flat halving spends the budget by group count rather than by need, so a lopsided pass names far fewer nodes than the cap allows. With 1 deferred and 30 deleting, both get limit = 5: the message names 6 nodes and truncates the deleting list at 5, while 9 of the 10-name budget was available. Giving the smaller group what it actually needs and the rest to the other covers both cases with one extra line:

limit := autoFullDrivesMaxNamedNodes
if len(deferred) > 0 && len(deleting) > 0 {
    limit = autoFullDrivesMaxNamedNodes / 2
}
deferredLimit, deletingLimit := limit, limit
if spare := limit - len(deferred); spare > 0 {
    deletingLimit += spare
}
if spare := limit - len(deleting); spare > 0 {
    deferredLimit += spare
}

Not worth much on its own — the cap is a message-size guard, not a display budget — so fine to leave if you'd rather keep the two lines.

@graphite-app

graphite-app Bot commented Sep 9, 2026

Copy link
Copy Markdown

Merge activity

  • Sep 9, 8:29 AM UTC: kristina-solovyova added this pull request to the Graphite merge queue.
  • Sep 9, 8:29 AM UTC: The Graphite merge queue removed this pull request due to downstack failures on PR #2745.
  • Sep 9, 8:29 AM UTC: The Graphite merge queue removed this pull request due to downstack failures on PR #2745.
  • Sep 9, 8:34 AM UTC: kristina-solovyova added this pull request to the Graphite merge queue.
  • Sep 9, 8:34 AM UTC: The Graphite merge queue removed this pull request due to downstack failures on PR #2745.
  • Sep 9, 8:34 AM UTC: The Graphite merge queue removed this pull request due to downstack failures on PR #2745.
  • Sep 9, 9:00 AM UTC: kristina-solovyova added this pull request to the Graphite merge queue.
  • Sep 9, 9:00 AM UTC: CI is running for this pull request on a draft pull request (#2804) due to your merge queue CI optimization settings.
  • Sep 9, 9:53 AM UTC: Merged by the Graphite merge queue via draft PR: #2804.

@kristina-solovyova
kristina-solovyova force-pushed the 08-17-docs_document_act-as-daemonset_mode_op-345_ branch from c168aa3 to e4a3d56 Compare September 9, 2026 08:53
@kristina-solovyova
kristina-solovyova force-pushed the 08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node branch from 2db8a12 to 678d205 Compare September 9, 2026 08:53
@graphite-app graphite-app Bot closed this Sep 9, 2026
@graphite-app
graphite-app Bot deleted the 08-20-fix_aggregate_auto-full-drives_planner_warnings_into_one_event_per_condition_instead_of_per_node branch September 9, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants