Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **The sweep-body detach policy (#2700/#2717) is now pinned, including the invariant that makes it safe** ([#2840]) - `query_store` and `plan_correction` are fired DETACHED from the sequential per-server collection body, because the outer launch loop will not relaunch that body while it runs (INV-2, one body per server), so one slow collector delays every other collector for that server. That set had no test at all, and the criterion for admitting a third collector was recorded nowhere. Measured against production `collection_log`, the set is exactly right: `query_store` at p90 65,053ms and `plan_correction` at max 133,934ms are the only per-cycle cost outliers, and the intuitive derivation from enumerated-vs-scalar fanout is disproved in BOTH directions - `database_scoped_config` fans out to 10 databases at p90 127ms, while `procedure_stats` has zero fanout at p90 11,964ms, so a fanout-derived split would detach the cheap collector and leave the expensive one starving the tier. The pin that generalizes asserts no detached collector sits on the 1-minute tier: a detached run SKIPS when its previous run is still in flight, so detaching a 1-minute collector converts starvation into guaranteed misses - exactly what would happen if the residual ~1.5-minute floor ([#2841]) were "fixed" by detaching `procedure_stats`.
- **`query_store` plan and text fetch phases now name the STORE half separately from the TARGET half** ([#2811]) - `plan_fetch:Nms` and `text_fetch:Nms` each time a whole METHOD, not a query: opening a store connection, a touch/probe round trip against the store, at most two statements against the monitored server, and a store write. Three of those four steps are Postgres, and the blended number cannot say which held the time - so a 189,562ms `plan_fetch` measured on a production database (85% of a 224s run) was read as SQL Server query time for a full day. Two measurements of that target statement disagree by two orders of magnitude and BOTH are real: ~57,073ms CPU per execution against the collector's actual workload of cold plans missing from the store, versus ~650ms for 400 ids in a controlled A/B over hot recently-executed plans. Cycles that run a separate fetch now emit a second line - `plan_fetch:Nms = probe:Nms + target:Nms + write:Nms + other:Nms (N chunk(s), N ids)` - where `other:` is the computed residual so the parts SUM to the parent by construction rather than approximately, and a large `other:` is itself the finding (the cost would be in the collector's own bookkeeping, in neither database). The id and chunk counts make per-id cost computable, which is the only honest way to compare a cold production pass against a hot-plan benchmark. On its OWN line rather than nested in the existing one, because that line is parsed by tooling outside this repo and every existing collector line stays byte-identical. Same argument as [#2312] one seam further down, and instrumentation only - no query, hint or retention change.
- **OIDC sign-in for the web dashboard: token validation and claim-to-role mapping** ([#2550]) - opt-in via `web.network.oidc` (authorization code + PKCE against any standard OIDC provider, resolved through its discovery document; the client secret takes the same DPAPI / `env:` / `file:` treatment as the endpoint tokens). The login page grows an SSO button, a successful sign-in mints the SAME `#2583` session cookie with the authenticated subject and role sealed inside the HMAC-signed subject slot, and the shared `?token=` path keeps working byte-for-byte unchanged as the scripted-caller / break-glass credential - OIDC entirely OFF when unconfigured, zero change for existing deployments. Role mapping: `roleClaim` + `adminRoles`/`viewerRoles` map IdP groups onto two seats - edit, or read-only - and a signed-in user matching neither list is refused outright, which is per-user revocation working. The read-only seat is enforced by a group-level middleware gate ahead of routing rather than per-endpoint attributes, so endpoints added by other lanes are covered automatically (verified live against `#2729`'s triage endpoint: GET 200, POST 403, with no wiring in that lane). ID tokens are validated for issuer, audience + `azp`, expiry with clock skew, and the nonce binding them to the transaction this host started; the discovery document's issuer must equal the configured authority and its endpoints must be `https` (or loopback), so a discovery response cannot choose the issuer or redirect the secret-bearing token POST to cleartext. Sign-in refusals ride the rate-limited refusal log; a subject carrying ASCII control characters is refused rather than laundered, so it can never forge a line in the audit trail. Exercised end-to-end against a live Keycloak with real users, groups and a confidential client: full code+PKCE handshake, admin and viewer mapping, unmapped-user refusal, wrong password, tampered cookie, forged state, shared-token fallback, and logout.
- **The auto force-plan bot's detection half arrives, and it cannot write to anything** ([#2138] phase 1) - the bot re-judges each scheduled analysis pass's `PLAN_REGRESSION` force-plan targets through the SAME `FactRemediation.ForcePlanBlockers` gate agents already read in `structured_remediation` (#2146) - one policy, so advise and act cannot drift, and the #2140 never-auto-force-a-parameter-sensitive-target rule is now enforced code rather than prose. Every decision lands in schema **v107**'s `collect.plan_force_actions` journal with the evidence that produced it (regression factor, cpu/exec numbers, blockers, mode, outcome). **This phase ships no write path at all**: the `IPlanForceExecutor` seam is declared with no implementation, `PlanForceBot` holds no executor and no connection factory, and three tests pin it - the compiled service assembly is searched byte-wise for `sp_query_store_force_plan` / `sp_query_store_unforce_plan` / `FREEPROCCACHE` and must contain none, no shipped assembly may contain a concrete `IPlanForceExecutor`, and `PlanForceBot` may not name the seam in any member signature. So the whole feature is detection, evidence and a dry-run ledger; the write path is its own reviewable change. The journal is APPEND-ONLY (outcomes and reviews are their own rows via `related_action_id`, never UPDATEs) and deliberately not enrolled in retention - an audit of what a bot decided about production outlives the metrics that motivated it. The gates it will one day guard a write with are all here and all shipped closed: `forcePlanBot.enabled` false, `forcePlanBot.dryRun` true, and the new per-server `config_monitored_servers.plan_force_bot_enabled` opt-in, which defaults FALSE on every row and has NO darling.json counterpart on purpose (the registry is authoritative after seeding, so a file knob would be a silent no-op - the #2254 trap applied to a write authorization). Open all three on this build and the decision journals as WITHHELD, which is the honest answer rather than a silent downgrade to would-force. Flap-proofing is structural and rehearsed in shadow mode, because dry-run is not a separate code path: it spends the same per-query cooldown (24h) and per-server daily budget (3), and two taken-back forces cool a query down for a week through a WINDOWED history read - eligibility returns by the window sliding, never by clearing a flag (the #2677 lesson). The self-review verdict table ships complete and specced too (re-judge a force at +1h/+24h against the cpu/exec baseline it was sold on; take it back when it is not at least 25% better, because a force that buys nothing still pins a plan against future data change and has to pay rent) - settling the rules a live force will be judged by before anything can place one.
Expand Down Expand Up @@ -3154,3 +3155,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#2823]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2823
[#2826]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2826
[#2827]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2827
[#2840]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2840
[#2841]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2841
121 changes: 121 additions & 0 deletions Darling/Darling.Tests/SweepBodyDetachPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System.Collections.Generic;
using System.Linq;
using PerformanceMonitor.Collectors;
using PerformanceMonitor.Darling.Service;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// Pins the policy behind #2700/#2717 — WHICH collectors are detached from the sequential per-server
/// collection body, and the invariant that makes detaching safe. Neither had any test before #2840.
///
/// <para><b>Why the set is what it is, measured.</b> The body runs every due collector for a server
/// together, and the outer launch loop will not relaunch it while it runs (INV-2, one body per server),
/// so one slow collector delays every other collector for that server. From
/// <c>collect.collection_log</c> on prod-pos-use1-monitor-01 over 24h (2026-09-03):</para>
///
/// <list type="table">
/// <item><description><c>query_store</c> — p50 3,350ms, <b>p90 65,053ms</b>, max 364,202ms</description></item>
/// <item><description><c>index_object_stats</c> — p90 15,640ms, but 1440-minute cadence (42 runs/day
/// fleet-wide, one per server per day), so its body impact is amortised to nil</description></item>
/// <item><description><c>procedure_stats</c> — p50 5,982ms, p90 11,964ms, but <b>1-minute tier</b></description></item>
/// <item><description><c>query_stats</c> — p50 2,070ms, p90 5,062ms, 1-minute tier</description></item>
/// <item><description><c>plan_correction</c> — p50 2,681ms, <b>max 133,934ms</b> — the same bimodal
/// shape as query_store with a smaller worst case, which is why #2717 followed #2700</description></item>
/// </list>
///
/// <para><b>The criterion is measured p90 against the fast tier's cadence, NOT collector shape.</b>
/// Enumerated-vs-scalar fanout was the intuitive rule and the data disproves it in both directions:
/// <c>database_scoped_config</c> fans out to 10 databases at p90 127ms, while <c>procedure_stats</c>
/// has zero fanout at p90 11,964ms. A fanout-derived split would detach the cheap collector and leave
/// the expensive one starving the tier. See #2840.</para>
///
/// <para><b>The 4.5x evidence.</b> use2 runs the same Balanced preset with Query Store dead since
Comment on lines +39 to +42

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: FrequencyMinutes < 2 also matches FrequencyMinutes == 0 (the on-load-only config-snapshot tier), not just the 1-minute tier. A 0-cadence collector runs once per connect, so detaching one can't cause the starvation-by-skipped-relaunch this invariant is guarding against — but if one were ever added to the detach set, this test would fail and point at "sits on the 1-minute tier," which would be a misleading diagnosis for that case. Not a blocking issue given today's fixed detach set (query_store, plan_correction, both cadence 5), just worth a > 0 && < 2 or a comment noting 0 is deliberately included as "no relaunch cadence to skip against" if that's intentional.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch, fixed in the follow-up commit — narrowed to FrequencyMinutes is > 0 and < 2 with a comment naming why 0 is excluded: an on-load-only collector runs once per connect, so there is no relaunch to skip and detaching one is harmless. You were right that it would have failed with the wrong diagnosis.

Not theoretical either — grep -c "= new(0, " on CollectorScheduleDefaults returns 5, so there are five collectors that would have tripped it.

Re-proved red after the change: detaching procedure_stats (cadence 1) is still caught, so narrowing did not weaken the pin.

/// 2026-08-17 17:36. Its <c>query_stats</c> delivered cadence stepped from 4.69-9.02 min (Query Store
/// live) to 1.44-1.57 min (dead) the following day, and held there for two weeks.</para>
/// </summary>
public sealed class SweepBodyDetachPolicyTests
{
/// <summary>The collectors #2700/#2717 detach, by the criterion documented on this class.</summary>
private static readonly string[] ExpectedDetached = { "query_store", "plan_correction" };

private static bool IsDetached(string name) =>
DarlingWorker.IsQueryStoreCollector(name) || DarlingWorker.IsPlanCorrectionCollector(name);

/// <summary>
/// The invariant that makes detaching safe, and the one that GENERALISES: a detached collector runs
/// fire-and-forget behind its own per-(server, collector) gate, so a still-in-flight run SKIPS rather
/// than overlapping. Skipping a 5-minute collector defers a tick; skipping a 1-minute collector is the
/// starvation this policy exists to prevent. So detaching anything on the 1-minute tier trades one
/// failure mode for another — it must not happen silently.
///
/// <para>This is what would fire if someone "fixed" the residual ~1.5-minute floor (#2841) by
/// detaching <c>procedure_stats</c> (p90 11,964ms, 1-minute tier) instead of addressing the body's
/// sequential execution.</para>
///
/// <para>Scoped to <c>FrequencyMinutes is &gt; 0 and &lt; 2</c>: cadence 0 is the on-load-only tier,
/// which runs once per connect and so has no relaunch to skip — detaching one is harmless and this
/// invariant would misdiagnose it as a 1-minute-tier hazard.</para>
/// </summary>
[Fact]
public void NoDetachedCollectorSitsOnTheOneMinuteTier()
{
/* > 0 deliberately: FrequencyMinutes 0 is the on-load-only tier (config snapshots), which runs
once per connect and has no relaunch cadence to skip against — so detaching one cannot cause
the starvation this guards, and flagging it here would report the wrong diagnosis. */
var offenders = CollectorScheduleDefaults.All
.Where(kv => IsDetached(kv.Key) && kv.Value.FrequencyMinutes is > 0 and < 2)
.Select(kv => $"{kv.Key} (every {kv.Value.FrequencyMinutes}min)")
.OrderBy(s => s)
.ToList();

Assert.True(
offenders.Count == 0,
"A detached collector skips rather than queues when its previous run is still in flight, so "
+ "detaching a 1-minute-tier collector converts starvation into guaranteed misses. Offending: "
+ string.Join(", ", offenders));
}

/// <summary>
/// The detach set is exactly the measured cost outliers — asserted over the WHOLE catalog rather than
/// by naming the two, so a third collector added to the predicate fails here and sends its author to
/// the criterion on this class instead of to a name list.
/// </summary>
[Fact]
public void TheDetachSetIsExactlyTheMeasuredCostOutliers()
{
var actual = CollectorScheduleDefaults.All.Keys
.Where(IsDetached)
.OrderBy(n => n, System.StringComparer.OrdinalIgnoreCase)
.ToList();

Assert.Equal(ExpectedDetached.OrderBy(n => n, System.StringComparer.OrdinalIgnoreCase), actual);
}

/// <summary>
/// Every name the detach predicate answers for must exist in the catalog. A predicate matching a name
/// no collector declares is dead code that reads as active policy — and both predicates compare against
/// the collector's OWN declared <c>Name</c> rather than a literal precisely so a rename cannot silently
/// unhook the detach, which this asserts is still true.
/// </summary>
[Fact]
public void EveryDetachedNameIsARealCatalogCollector()
{
foreach (var name in ExpectedDetached)
{
Assert.True(
CollectorScheduleDefaults.All.ContainsKey(name),
$"'{name}' is detached from the sweep body but is not in CollectorScheduleDefaults.");
Assert.True(IsDetached(name), $"'{name}' is expected to be detached but the predicate says no.");
}
}
}
Loading