Skip to content

Serve a sweep_pressure verdict from get_collection_health so half-rate collection is visible (#2296) - #2308

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/2296-sweep-pressure-visible
Aug 17, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
fix/2296-sweep-pressure-visible

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Implements option 3 of #2296 — the "worth doing regardless" one. Options 1 (move the two servers in-region, gated on the use1 darling_monitor credential) and 2 (longer cadence for saturated servers, a data-grain decision) stay open on the issue; this PR does not close it.

The gap

Two servers collect at half their configured cadence and nothing but a service-log warning says so. The mechanism: RunDueCollectorsAsync runs collectors serially and re-stamps NextDue from one now; when the body's own execution outlives the 60s cadence (multi-01's four heaviest collectors average ~60.7s combined), it finishes after the next due time, the watchdog skips the relaunch (~50 warnings/hour), and the delivered interval is a multiple of the configured one. Meanwhile get_collection_health reports every collector HEALTHY — correctly, because from each collector's own seat nothing is wrong. No counter of skipped relaunches exists anywhere, in memory or the store.

The signal, and why this one

get_collection_health (both SKUs) now serves a top-level sweep_pressure block: busy_ms_per_minute, busy_percent, a verdict (OK / AT_RISK ≥ 75% / SATURATED ≥ 100%), the three heaviest contributors (attribution — "which collectors spend the budget" is the actionable half), and a note naming the consequence and the levers when the verdict isn't OK.

Built from execution demand, not delivered-gap statistics, deliberately: at fleet scale the delivered cadence stretches benignly — bounded sweep concurrency queues bodies, and the fleet-wide median gap already runs a multiple of the configured minute — so gap-based detection would flag all 52 servers and drown the two that matter. Execution demand (each collector's avg_duration_ms amortized by its own cadence) is the arithmetic behind the watchdog's own "has not completed after Ns of EXECUTION" distinction, and queueing cannot inflate it. This is also why the watchdog itself classifies Hang (running) apart from Queued (capacity).

No SQL change, no migration, no schema change — the roll-up is computed from the rows the tool already reads plus the shared CollectorScheduleDefaults, resolved exactly the way the health banding already resolves cadence (shipped defaults, for three-surface parity).

Shape

  • Decision in SweepPressureClassifier (PerformanceMonitor.Common), pure and static like CollectorHealthClassifier — callers resolve cadence, only the verdict lives there. Thresholds carry their rationale: 75% because the average smooths variance (intermittent overrun is how saturation looks before it's constant), 100% because the body mathematically cannot fit. The motivating server computes 101.2%.
  • Both SKUs' tool projections are byte-identical twins, as the rest of this tool already is; CollectorHealth/CollectorHealthRow expose their existing cadence resolution internally rather than duplicating it.
  • Tool description + MCP instructions row updated so a caller learns the field exists.

Tests

SweepPressureClassifierTests — the same decision table pinned in Darling.Tests and Lite.Tests (the CollectorHealthClassifier twin-suite pattern): the motivating profile verbatim (22,141 + 16,590 + 13,544 + 8,437 ms → SATURATED at ~101%), a healthy profile, both inclusive band edges, on-load/zero-duration exclusion, own-cadence amortization (the pin that forbids charging a heavy daily collector at the fast cadence), and the empty window.

Verification scope

Both test projects build clean on macOS (EnableWindowsTargeting). The Windows suites can't run locally, so the full decision table was executed against the real PerformanceMonitor.Common build via a throwaway net10.0 harness: 12/12 pass, including the motivating server's exact numbers. Not covered locally: the xUnit twins executing (CI's build job), and the tool end-to-end against a live store — the number it rolls up (avg_duration_ms) is already live-pinned by the existing collection-health tests.

🤖 Generated with Claude Code

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Reviewed the diff (SweepPressureClassifier + both SKUs' get_collection_health wiring + tests). Overall this is clean: the classifier is pure/static like CollectorHealthClassifier, the Lite and Darling tool-handler blocks are byte-identical, the underlying SQL (window, AVG(duration_ms), GROUP BY) already matches between Postgres and DuckDB, and the test decision tables are pinned identically in both *.Tests projects. Threshold math checks out (75%/100% bands, inclusive edges, on-load/zero-duration exclusion, per-collector cadence amortization) against the shipped CollectorScheduleDefaults (fastest cadence is genuinely 1 minute, confirming the hardcoded 60,000 ms budget).

One parity gap, not touched by this PR's diff so I can't leave it inline:

Lite/Mcp/McpInstructions.cs (line 39) — the get_collection_health row in Lite's MCP client-instructions table was not updated for this change:

| `get_collection_health` | Shows collector health: running, failing, or stale | `server_name` |

Darling's equivalent (DarlingMcpInstructions.cs) was updated in this PR to mention the new sweep_pressure verdict:

| `get_collection_health` | Per-collector health (running / failing / stale) over the last 7 days, plus the server's sweep_pressure verdict (a SATURATED body collects at a multiple of its configured cadence with every collector healthy) | `server_name` |

Lite's tool itself (McpHealthTools.cs) got both the code and the [Description(...)] attribute updated correctly — only this separate instructions-table row was missed. Since this text is what's sent to MCP clients at session init (distinct from the per-tool Description), an LLM client relying on it alone won't know Lite's get_collection_health also now serves sweep_pressure. Worth a one-line follow-up mirroring Darling's wording.

No correctness, security, or performance issues found otherwise.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review

Went through the SweepPressureClassifier addition (PerformanceMonitor.Common/ServerHealthBands.cs), both MCP tool wirings, and the twin test suites.

Logic checks out. Walked every branch by hand against the pinned test table (motivating-server 60,712 ms/min ≈ 101%, band edges at 44,999/45,000/59,999/60,000, on-load + zero-duration exclusion, cadence amortization, empty window) — all match Compute()'s implementation exactly, including the inclusive >= boundaries.

Verified the 60,000 ms budget assumption, since it's hardcoded rather than derived: confirmed the min non-zero FrequencyMinutes across CollectorScheduleDefaults is 1 (no sub-minute cadence exists), and that both SKUs' schedulers actually deliver a ~1-minute budget per due collector — Lite's CollectionBackgroundService ticks on a literal TimeSpan.FromMinutes(1), and Darling's outer loop ticks every 15s but independently enforces the same 60s completion budget via SweepWatchdogSeconds in DarlingWorker.cs. The constant holds for both, just via different mechanisms.

Lite/Darling parity: the classifier, both MCP tool bodies, [Description(...)] text, and the two SweepPressureClassifierTests.cs files are byte-identical twins as intended — good.

One parity gap, in a file this PR doesn't touch: Lite/Mcp/McpInstructions.cs:39 still reads get_collection_health | Shows collector health: running, failing, or stale, while the Darling counterpart (DarlingMcpInstructions.cs) was updated in this PR to mention the new sweep_pressure` verdict:

| `get_collection_health` | Per-collector health (running / failing / stale) over the last 7 days, plus the server's sweep_pressure verdict (a SATURATED body collects at a multiple of its configured cadence with every collector healthy) | `server_name` |

This is the markdown resource the MCP client surfaces as usage guidance (separate from the [McpServerTool] Description attribute, which was updated identically in both McpHealthTools.cs/DarlingMcpDataTools.cs). Not a functional bug — the field still ships in Lite's JSON response — but worth a one-line fix so Lite users get the same discoverability hint Darling users now get. Can't post this as an inline comment since the file isn't part of this diff.

No security, correctness, or performance concerns found otherwise. Not flagging missing-index DMV recommendations per repo convention.

@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Both passes' one finding fixed in 59eebce — Lite's McpInstructions row now carries the same sweep_pressure hint as Darling's, so the session-init guidance and the per-tool Description agree in both SKUs. Good catch on a file the diff didn't touch.

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review

Reviewed against CONTRIBUTING.md's style guidance (no repo CLAUDE.md found — this repo's dev docs live there instead), with the Lite/Darling parity requirement in mind. This is C#-only (no T-SQL touched), so the T-SQL style rules don't apply here.

Parity: Full file-coverage parity check passed — every surface touched on the Darling side (DarlingDataReader.cs, DarlingMcpDataTools.cs, DarlingMcpInstructions.cs, Darling.Tests) has a byte-identical counterpart on the Lite side (LocalDataService.CollectionHealth.cs, McpHealthTools.cs, McpInstructions.cs, Lite.Tests). The shared decision (SweepPressureClassifier) lives once in PerformanceMonitor.Common, and the two GetCollectionHealth JSON-building blocks are byte-identical, matching this codebase's established twin-suite convention. The deprecated Dashboard/ (Full Edition) MCP tools are correctly left untouched — that surface is retired, not a parity target.

Correctness:

  • SweepPressureClassifier.Compute arithmetic checks out against its own test table (verified the 60,712 ms/min sum and the 75%/100% inclusive band edges by hand).
  • Both SQL health queries (GROUP BY collector_name) confirmed to yield one row per collector, so the Compute/heaviest_collectors sums aren't at risk of double-counting a collector across multiple rows.
  • private → internal on FrequencyMinutes is a pure widening, same assembly in both cases — no compile-time or behavioral risk to existing callers.
  • One edge case flagged inline: on Lite, FrequencyMinutes resolves from the shipped CollectorScheduleDefaults, not the per-install ScheduleManager-resolved cadence a user may have edited. See inline comment for the concrete failure mode.

Security/perf: No new SQL, no new I/O, no untrusted input handled differently. The added LINQ work is O(n) over a small per-server collector list — no regression risk.

No missing-index DMV suggestions here, per the project's standing rule.

by cadence) against the minute the fastest cadence holds; heaviest_collectors names where
the budget goes, which is the actionable half of the answer. */
var pressure = SweepPressureClassifier.Compute(
rows.Select(r => (r.CollectorName, r.AvgDurationMs, r.FrequencyMinutes)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor correctness edge case, Lite-only: r.FrequencyMinutes (from CollectorHealthRow.FrequencyMinutes, Lite/Services/LocalDataService.CollectionHealth.cs:364-365) resolves the collector's cadence from the shipped CollectorScheduleDefaults, not from ScheduleManager's per-install, user-editable schedule (Lite/Services/ScheduleManager.cs:146 shows the setter that lets a user actually change FrequencyMinutes per install).

That's a deliberate, documented choice inherited from the existing health-banding code ("shipped default...so all three surfaces stay in parity"), and it's fine for staleness banding, which is inherently approximate. But sweep_pressure is a precise quantitative signal with a hard 75%/100% threshold, and on Lite specifically the real body composition can diverge from the shipped defaults:

  • A user who slows down a heavy collector (e.g. bumps index_object_stats from 1440→4320 min because it was too heavy) would still get it amortized at the old, faster shipped cadence — possibly reporting AT_RISK/SATURATED on a body that now comfortably fits.
  • A user who speeds up a collector's cadence would get it amortized at the old, slower shipped cadence — understating real demand, and potentially reporting OK on a server that's actually saturated. This is the false-negative case that defeats the point of the feature (Darling has no schedule knobs, so this gap doesn't apply there — Lite-only).

Not necessarily blocking — just flagging since CollectorHealthRow currently has no resolved-per-install-cadence property to plug in instead, so fixing this would need a small plumbing change (passing the ScheduleManager-resolved frequency through). Worth a conscious call on whether the parity/simplicity tradeoff is acceptable here, or whether sweep pressure specifically should use the real cadence.

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.

1 participant