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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Analysis engine: three more issue-classification arms in the shared FactScorer (Tier-2 parity)** ([#1517]) — the shared `PerformanceMonitor.Analysis` scorer/inference brain (ONE code path for Lite, the deprecated Dashboard, AND Darling) now classifies three problems the deprecated Dashboard's `report.*` / `config.critical_issues` SQL flagged but the ported engine had dropped. All three are pure `FactScorer` additions over facts the collectors ALREADY emit — no collector, schema, or `install/*.sql` change — so they light up in all three apps' recommendations at once, each citing its source SQL line in a code comment. (1) **Query Store disabled** — an INFO advisory folded into the existing `DB_CONFIG` finding, detected as `query_store_on_count < database_count` (user DBs only, system DBs excluded on both counts), mirroring `install/50_configuration_issues_analyzer.sql`'s INFO Query-Store-off issue (line 83); the DB-config recommendation now lists "N with Query Store off" and appends enable-QS guidance. (2) **Security-cache (TokenAndPermUserStore) growth** — a flat WARNING when the `USERSTORE_TOKENPERM` memory clerk reaches >= 1 GB, read from the otherwise context-only `MEMORY_CLERKS` fact (the clerk size rides in metadata keyed by clerk_type), mirroring `install/50`'s WARNING at `pages_kb / 1024 / 1024 >= 1.0` (lines 562, 583); a new recommendation names the `DBCC FREESYSTEMCACHE('TokenAndPermUserStore')` relief plus the ad-hoc / dynamic-SQL churn cause. (3) **Runnable-queue / worker pressure** — a STANDALONE arm rooting the collected `cpu_scheduler_stats` snapshot on `total_runnable_tasks_count`, tiered to `report.cpu_scheduler_pressure`'s `> 50 / > 20 / > 10` CASE plus the collector's `runnable_tasks_warning` small-box fallback (`install/47_create_reporting_views.sql` lines 1839-1844), additive to and distinct from the #1494 runnable-queue -> THREADPOOL amplifier (kept unchanged). Base severity maxes at 1.0 (WARNING) like every base fact — the CRITICAL band stays reserved for amplifier-corroborated cases (the runnable-queue -> THREADPOOL escalation). `FactScorer` tests in BOTH `Lite.Tests` and `Dashboard.Tests` pin every tier (fires when it should, stays silent below the bar, at the right band) and that a co-present worse `DB_CONFIG` issue is left unchanged. Behavior of the existing arms is unchanged.
- **Lite MCP: the 18 Darling-only tools ported (drains the MCP inventory ratchet to ZERO)** ([#1518]) — the mechanical `[McpServerTool]` companion to the #1504 / #1505 / #1509 viewer BUILDs: every tool the #1501 `CrossAppMcpToolInventoryPinTests` ratchet listed as Darling-only is now exposed by Lite's MCP host, so `KnownLiteMissingMcpTools` is **empty** (only the `get_blocked_process_reports` <-> `get_blocking` naming-drift pair remains, handled separately). The 18: `get_latch_stats`, `get_spinlock_stats`, `get_plan_cache_bloat`, `get_cpu_scheduler_pressure`, `get_resource_semaphore`, `get_default_trace_events`, `get_daily_summary`, `get_server_config_changes`, `get_database_config_changes`, `get_trace_flag_changes`, and the eight `get_health_parser_*` tools (system_health / severe_errors / io_issues / scheduler_issues / memory_conditions / cpu_tasks / memory_broker / memory_node_oom). Each is a thin wrapper over the reader Lite ALREADY had from the viewer builds — no new collection, no live-server hit: latch/spinlock wrap the latest-snapshot readers, plan-cache-bloat folds the true-total summary + composition grid + the shared `ClassifyPlanCacheBloat` banding, cpu-scheduler-pressure surfaces the latest scheduler snapshot with the collector's warning flags, daily-summary wraps the Performance Calendar aggregate (composite health band), the three config-change tools wrap the shared `ConfigChangeDiff` histories, and default-trace + the eight health-parser tools wrap the System Events parse-on-read readers (gated by the shared `SystemHealthSignificance` / `DefaultTraceEventSignificance` — the SAME significant set the viewer shows). The ONE reader added is `GetResourceSemaphoreSnapshotAsync` (a latest-snapshot read of the FULL semaphore ceiling columns — target / max-target / total workspace memory + cumulative timeout/forced-grant counts — which the existing per-pool `GetMemoryGrantChartDataAsync` chart reader doesn't surface), so Lite's `get_resource_semaphore` matches the Dashboard/Darling shape rather than duplicating `get_memory_grants`. A raw naive-UTC `EventTime` / `ChangeTime` accessor was added to the System Events + Config Changes viewer row DTOs so the tools emit ISO-8601 timestamps (matching every other Lite MCP tool) instead of the grids' display-local strings. All 18 are registered in `McpHostService` and documented in the MCP server instructions. Lite-only — no Darling, Full Dashboard, or `install/*.sql` changes.
- **Darling viewer: Query Store Regressions sub-tab (Dashboard parity BUILD)** ([#1516]) — the missing Dashboard **Query Store Regressions** view ported to the Darling viewer: the regressed-query GRID over the Query Store data Darling already collects (its `Query Store by Duration` tab + `QueryStoreHistoryWindow` read the same `query_store_stats` store). A new **Queries** sub-tab inserted at index 6 — right after `Query Store by Duration`, matching the Dashboard's Query Store → Query Store Regressions adjacency (every routing reference uses the NAMED constant, so only Expensive Queries / Query Heatmap shift, and the XAML `<TabItem>` order tracks the constants exactly — the same insert pattern as [#1515]). The read (`ViewerDataService.QueryStoreRegressions.cs`) is the Postgres port of the Dashboard's `report.query_store_regressions` inline TVF (`install/47_create_reporting_views.sql`) against `query_store_stats`: two windowed CTEs — BASELINE (every capture BEFORE the window) vs RECENT (the in-window captures) — averaged and joined per `(database, query_id)`, computing the duration / CPU / I/O regression percents, the execution-count-weighted `additional_duration_ms` (extra total time = per-exec duration delta × recent exec count), the plan-count deltas, and a DURATION-driven severity band, keeping only rows whose **CPU regressed > 25%** (the TVF's single-metric gate), ranked by added duration and capped at the TVF's TOP (50). Faithful store adaptations, all documented in the read: the baseline/recent split windows on `collection_time` (Darling's UTC window column — the frame the toolbar's bounds are in) rather than the TVF's `server_last_execution_time` (Darling's column is the server's LOCAL wall clock, so windowing it against UTC bounds would be a timezone bug); the recent query-text sample is `MAX(query_text)` from Darling's already-decompressed column (the TVF `DECOMPRESS()`es a compressed one); and it ports the TVF's ACTUAL behavior (unbounded baseline, plain AVG, CPU-only gate), NOT the stale intent comment on the Dashboard's C# caller (bounded/mirrored baseline, weighted averages, multi-metric, minimums) that the TVF does not actually run. The row VM reproduces **every Dashboard grid column** (Last Execution, Severity, Database, Query ID, Total Impact, Base/Recent Execs, Base/Recent Plans, Duration Δ%, Base/Recent Dur, CPU Δ%, Base/Recent CPU, I/O Δ%, Base/Recent Reads, Query Text); the grid honors the global #1319 database filter (`$4::text[] IS NULL OR database_name = ANY($4)`), carries a `DataGridFilterManager`, and wires the per-row **double-click execution-history** window (reusing `QueryStoreHistoryWindow` query-scoped with plan_id 0, since the regression aggregates over plans — mirroring the Dashboard grid's double-click). Like the Dashboard grid it has NO slicer / comparison / plan surface (the read is a baseline-vs-recent contrast and carries no plan XML — like the sibling Query Store tab, whose View-Plan is deferred), so it uses the plan-less query-grid menu (`QueryGridRowStyle`) whose **Copy Repro** builds a plan-less repro from the query-text sample. Tests pin the read's load-bearing clauses (base table, the `collection_time` baseline/recent split, the CPU gate, the added-duration ranking + `LIMIT 50`, the severity thresholds, the bigint/integer casts, the µs→ms conversion, the DB filter, and the PG positional dialect via the shared Queries dialect theory), the row display, the double-click key mapper, and Copy Repro, plus a `DARLING_TEST_PG`-gated live round-trip proving baseline-vs-recent detection, the CPU gate, the INNER-JOIN exclusion of NEW (no-baseline) queries, and the CRITICAL severity + added-duration math end-to-end. Darling-only — no Lite, Full Dashboard, or `install/*.sql` changes.
- **Darling viewer: live Current Active Queries sub-tab (service-mediated)** ([#1515]) — a LIVE, on-demand DMV snapshot of what is running on a monitored server RIGHT NOW, distinct from the stored **Active Queries** sub-tab (the collector's hourly snapshots). Darling's viewer is store-only, so it is delivered through the Stage-2 command plane exactly like `execute_actual_plan` / `fetch_plan`: the viewer enqueues a new `fetch_active_queries` command, the SERVICE (which holds a live connection to every monitored server) runs the query on the target's live connection and returns the rows in `result_json`, and the viewer renders them in a new **Current Active Queries** sub-tab (Queries sub-index 2, right after the stored Active Queries tab — every routing reference uses the named constant, so only the constant values shift) with a **Refresh** button (ON-DEMAND only — never auto-fetched on tab selection or a range refresh, since a live server hit is an explicit action). The DMV query is REUSED, not reinvented: a new read-only `DarlingCollectorRunner.FetchRowsAsync` (the "fetch phase only" twin of `RunAsync` — same `CollectorContext`, no store write) runs the shared `QuerySnapshotsCollector` against the live connection, so the live snapshot carries the SAME columns as the stored one and renders in the same `ViewerQuerySnapshotRow` grid (the Estimated / Actual plan buttons reuse the stored handlers on the plan the DMV read carried inline). New `CommandKind.FetchActiveQueries` is pinned viewer↔executor (`ViewerDataService.CommandFetchActiveQueries` ↔ `DarlingCommandExecutor.ResolvePlan`), requires `target_server_id` and takes no args, dispatches through `IDarlingCommandHost.FetchActiveQueriesLiveAsync` → `DarlingWorker.RunFetchActiveQueriesLiveAsync` (resolves the runtime under the servers lock, mirroring `RunFetchPlanAsync`), and returns legible `server not monitored` / `server not connected` / `timed out` / `permission denied` / `sql error` outcomes; the service caps the SQL read at 30s (comfortably under the viewer's 60s poll budget so a slow read reports a real outcome, not a poll miss), deletes the (large) command row after the viewer reads it, and — because enqueuing is a config write — a read-only `viewer` seat cannot fetch it (same rule as Pause / `fetch_plan`). It is a read-only DMV read (`sys.dm_exec_requests`/`sessions` + the sql_text / query_plan DMFs), so unlike `execute_actual_plan` it is NOT consent-class. Tests pin the command constant, the `ResolvePlan` case, and the FULL service-serialize → viewer-parse wire contract (no live SQL Server needed), plus the timeout / failure mappings and the poll-budget-vs-SQL-cap ordering; the live executor round-trip is gated on `DARLING_TEST_PG` like the `fetch_plan` one. Darling-only — no Lite, Full Dashboard, or `install/*.sql` changes.
Expand Down Expand Up @@ -305,6 +306,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#1484]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1484
[#1488]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1488
[#1486]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1486
[#1517]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1517
[#1510]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1510
[#1507]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1507
[#1506]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1506
Expand Down
173 changes: 173 additions & 0 deletions Dashboard.Tests/FactScorerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,179 @@ public void Score_Threadpool_RunnableQueueAmplifier_DoesNotFireBelowThreshold()
Assert.False(rqAmp.Matched);
}

// ── Tier-2 parity arms (batch 1): runnable-queue / security-cache / QS-off ──────────────────
// All three are additions to the SHARED FactScorer, so they fire identically for Lite, Dashboard,
// and Darling findings. Sourced thresholds are cited per arm against the deprecated Dashboard SQL.

// ARM 3 — runnable-task-queue depth roots a STANDALONE finding off the collected cpu_scheduler_stats
// snapshot, tiered to the Dashboard's report.cpu_scheduler_pressure CASE (install/47:1839-1841):
// > 50 CRITICAL-tier (base 1.0), > 20 HIGH (0.75), > 10 MEDIUM (0.5 — the 0.5 root entry point).
// Base maxes at 1.0 (top of WARNING) like every base fact; CRITICAL band is reached only via the
// #1494 runnable-queue -> THREADPOOL amplifier, which still fires independently.
[Theory]
[InlineData(60, 1.0)] // > 50 -> CRITICAL tier (install/47:1839)
[InlineData(30, 0.75)] // > 20 -> HIGH tier (install/47:1840)
[InlineData(15, 0.5)] // > 10 -> MEDIUM tier (install/47:1841)
public void Score_RunnableTasks_TiersMatchDashboard(double total, double expected)
{
var facts = new List<Fact>
{
new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = total,
Metadata = new() { ["total_runnable_tasks"] = total, ["runnable_tasks_warning"] = 1 } },
};

new FactScorer().ScoreAll(facts);

var rt = facts.First(f => f.Key == "RUNNABLE_TASKS");
// No amplifiers on RUNNABLE_TASKS and it is not tuning-class, so Severity == base.
Assert.Equal(expected, rt.Severity, precision: 4);
Assert.True(rt.Severity >= 0.5, "a non-NORMAL runnable queue must clear the 0.5 root threshold");
}

// A shallow queue below the > 10 bar with the warning flag CLEAR does not score (context-only).
[Fact]
public void Score_RunnableTasks_BelowThreshold_DoesNotScore()
{
var facts = new List<Fact>
{
new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 5,
Metadata = new() { ["total_runnable_tasks"] = 5, ["runnable_tasks_warning"] = 0 } },
};

new FactScorer().ScoreAll(facts);

Assert.Equal(0.0, facts.First(f => f.Key == "RUNNABLE_TASKS").Severity, precision: 4);
}

// Small-box fallback: below the absolute > 10 bar, the collector's runnable_tasks_warning flag
// (SUM(runnable_tasks_count) >= cpu_count) still scores HIGH (install/47:1844).
[Fact]
public void Score_RunnableTasks_WarningFlagOnSmallBox_ScoresHigh()
{
var facts = new List<Fact>
{
new() { Source = "cpu", Key = "RUNNABLE_TASKS", Value = 6,
Metadata = new() { ["total_runnable_tasks"] = 6, ["runnable_tasks_warning"] = 1 } },
};

new FactScorer().ScoreAll(facts);

Assert.Equal(0.75, facts.First(f => f.Key == "RUNNABLE_TASKS").Severity, precision: 4);
}

// ARM 2 — TokenAndPermUserStore (security cache) growth scores a flat WARNING at >= 1 GB off the
// otherwise context-only MEMORY_CLERKS fact (install/50:562 severity=WARNING, :583 threshold 1 GB).
// The clerk size rides in metadata keyed by clerk_type (= sys.dm_os_memory_clerks.type).
[Fact]
public void Score_MemoryClerks_SecurityCacheOver1Gb_ScoresWarning()
{
var facts = new List<Fact>
{
new() { Source = "memory", Key = "MEMORY_CLERKS", Value = 52_048,
Metadata = new()
{
["MEMORYCLERK_SQLBUFFERPOOL"] = 50_000,
["USERSTORE_TOKENPERM"] = 2_048, // 2 GB
["total_top_clerks_mb"] = 52_048,
["clerk_count"] = 2
} },
};

new FactScorer().ScoreAll(facts);

var mc = facts.First(f => f.Key == "MEMORY_CLERKS");
Assert.True(mc.Severity >= 0.75 && mc.Severity < 1.5, "security-cache growth bands WARNING");
}

// Under 1 GB does not score; MEMORY_CLERKS stays context-only.
[Fact]
public void Score_MemoryClerks_SecurityCacheUnder1Gb_DoesNotScore()
{
var facts = new List<Fact>
{
new() { Source = "memory", Key = "MEMORY_CLERKS", Value = 50_512,
Metadata = new()
{
["MEMORYCLERK_SQLBUFFERPOOL"] = 50_000,
["USERSTORE_TOKENPERM"] = 512, // 0.5 GB — below the 1 GB bar
} },
};

new FactScorer().ScoreAll(facts);

Assert.Equal(0.0, facts.First(f => f.Key == "MEMORY_CLERKS").Severity, precision: 4);
}

// A normal clerk set with no TokenAndPermUserStore entry scores 0 (context-only preserved).
[Fact]
public void Score_MemoryClerks_NoSecurityCacheClerk_DoesNotScore()
{
var facts = new List<Fact>
{
new() { Source = "memory", Key = "MEMORY_CLERKS", Value = 50_000,
Metadata = new() { ["MEMORYCLERK_SQLBUFFERPOOL"] = 50_000, ["clerk_count"] = 1 } },
};

new FactScorer().ScoreAll(facts);

Assert.Equal(0.0, facts.First(f => f.Key == "MEMORY_CLERKS").Severity, precision: 4);
}

// ARM 1 — Query Store off on a user database is an INFO advisory carried by the DB_CONFIG fact,
// detected as query_store_on_count < database_count (install/50:83 severity=INFO). Low 0.3 base =
// INFO band, and DB_CONFIG roots at any positive severity (a ConfigAdvisoryRootKey).
[Fact]
public void Score_DbConfig_QueryStoreOff_ScoresInfoAdvisory()
{
var facts = new List<Fact>
{
new() { Source = "database_config", Key = "DB_CONFIG", Value = 3,
Metadata = new() { ["database_count"] = 3, ["query_store_on_count"] = 1 } },
};

new FactScorer().ScoreAll(facts);

var db = facts.First(f => f.Key == "DB_CONFIG");
Assert.Equal(0.3, db.Severity, precision: 4);
Assert.True(db.Severity > 0 && db.Severity < 0.75, "QS-off is an INFO-band advisory");
}

// Every user DB has Query Store on and nothing else drifted -> DB_CONFIG does not score.
[Fact]
public void Score_DbConfig_QueryStoreAllOn_NoContribution()
{
var facts = new List<Fact>
{
new() { Source = "database_config", Key = "DB_CONFIG", Value = 3,
Metadata = new() { ["database_count"] = 3, ["query_store_on_count"] = 3 } },
};

new FactScorer().ScoreAll(facts);

Assert.Equal(0.0, facts.First(f => f.Key == "DB_CONFIG").Severity, precision: 4);
}

// QS-off never lowers a worse co-present DB_CONFIG issue (existing arms unchanged): AUTO_SHRINK on
// 3 DBs stays 0.9 even with Query Store also off on all of them.
[Fact]
public void Score_DbConfig_QueryStoreOff_DoesNotLowerWorseIssue()
{
var facts = new List<Fact>
{
new() { Source = "database_config", Key = "DB_CONFIG", Value = 3,
Metadata = new()
{
["database_count"] = 3,
["query_store_on_count"] = 0, // all off
["auto_shrink_on_count"] = 3 // worse issue -> 3 * 0.3 = 0.9
} },
};

new FactScorer().ScoreAll(facts);

Assert.Equal(0.9, facts.First(f => f.Key == "DB_CONFIG").Severity, precision: 4);
}

// Confidence-hardening: the low-quality fallback floors at 0.5 AFTER the confidence multiply, so a
// fired thin-baseline anomaly stays >= InferenceEngine's 0.5 root entry-point even if confidence
// ever drops below 1.0 (it is a hardcoded 1.0 today; this guards the future). Same shared scorer.
Expand Down
Loading
Loading