Repository navigation
Count offline CPUs and NUMA nodes correctly in the CPU scheduler collector (#5656) - #5657
Conversation
| IF @offline_cpu_warning = 1 | ||
| BEGIN | ||
| RAISERROR(N'CRITICAL: Offline CPUs detected: %d offline nodes', 0, 1, @current_offline_cpus) WITH NOWAIT; | ||
| RAISERROR(N'CRITICAL: Offline CPUs detected: %d offline CPUs', 0, 1, @current_offline_cpus) WITH NOWAIT; |
There was a problem hiding this comment.
Non-blocking: this debug line is now right, but the other message the same flag drives still frames the count as nodes — line 313 inserts N'CRITICAL: Offline CPUs detected in NUMA nodes' into config.collection_log, and that one is the row an operator actually reads back after the fact (the @debug = 1 RAISERROR only exists for an interactive run). offline_cpu_count no longer counts nodes at all, so "in NUMA nodes" is left over from the shape this PR removed.
Worth noting the new pin cannot see it: InstallScript_DebugMessage_NamesOfflineCpusNotNodes asserts DoesNotContain("offline nodes"), and line 313's wording is "in NUMA nodes", so it passes either way.
| { | ||
| var sql = copy == "collector" | ||
| ? CpuSchedulerStatsCollector.Instance.BuildQuery(CollectorTestContext.Make(new RecordingCollectorDeltaCalculator())).Text | ||
| : File.ReadAllText(FindRepoFile(Path.Combine("install", "17_collect_cpu_scheduler_stats.sql"))); |
There was a problem hiding this comment.
Non-blocking, but it qualifies the "pin both copies so they cannot drift back" claim: this half of the pin lives in Lite.Tests and reads install/17, and install/** is in build.yml's installer filter (.github/workflows/build.yml:690), not in lite (:644-686). A future PR that touches only install/17_collect_cpu_scheduler_stats.sql — re-introducing node_id <> 32767, say — fires installer and sql-validation, neither of which runs Lite.Tests, so the drift-back is caught by the nightly rather than by the PR. That is the same "a guard only guards if CI runs it on the edit it exists to catch" shape CONTRIBUTING's cross-app-guard section is about; CrossAppGuardCiGateTests does not derive it here because it only tracks the Lite/Darling roots, and Lite.Tests/DmvResourceDatabaseSentinelTests.cs:200 already reads install/56 the same way, so this is a pre-existing gap you are inheriting, not one you introduced. One line (- 'install/**/!(*.md)') in the lite filter would close it for both.
Smaller point on the same block: Assert.DoesNotContain("32767", sql) and Assert.DoesNotContain("is_online", sql) run against the whole 457-line install file, not just this SELECT, so an unrelated later use of either string anywhere in the proc reds this test for a reason that has nothing to do with #5656.
There was a problem hiding this comment.
Verdict: LGTM
Claims
- VERIFIED — the fix lands in both copies, substantively identical —
PerformanceMonitor.Collectors/CpuSchedulerStatsCollector.cs:168-191vsinstall/17_collect_cpu_scheduler_stats.sql:193-216. - VERIFIED — the DAC node is left out by state, not by a node id — collector
:182/ install:207; neither file contains32767ornode_idany more. - VERIFIED — online is now every non-
OFFLINE-prefixed state, replacing the exact= N'ONLINE'match — collector:176/ install:201. - VERIFIED — count and warning read ONE
VISIBLE OFFLINEderived table, and theis_online = 0subquery is gone from both files — collector:154,160,184-191/ install:183,186,209-216. - VERIFIED — the debug message now says "offline CPUs" — install
:390. - VERIFIED — no schema change and no reader change needed —
PayloadColumnsuntouched (collector:268-296), positional readers still land on 25/26 (Lite/Services/LocalDataService.CpuScheduler.cs:215-216,Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs:230-231), and the shared label already reads "Offline CPUs" with nodes shown separately (PerformanceMonitor.Common/CpuSchedulerMetrics.cs:52-53). - VERIFIED — the reader roster in the body is complete — my grep of
offline_cpu/OfflineCpureturns exactly that set, plusinstall/47_create_reporting_views.sql:2673-2741; nothing inPerformanceMonitor.Alertingreads either column. - VERIFIED — the verbatim contract was updated —
Lite.Tests/CpuSchedulerStatsCollectorDefinitionTests.cs:100-197matches collector:152-249line for line. - VERIFIED — the new tests assert what the body says —
CpuSchedulerStatsCollectorDefinitionTests.cs:224-237,244-245, run against both copies by thecollector/installtheory. - VERIFIED — "4 of 8 in the class" is arithmetically consistent — 6
[Fact](:200,241,249,259,303,340) + a 2-case[Theory](:217) = 8, and exactly four depend on the new SQL. - UNVERIFIED — the DAC node's id is 64 and every instance shows node 0
ONLINE/ node 64ONLINE DAC— a livesys.dm_os_nodesread; the tree records neither. - UNVERIFIED — the per-instance 2016-2025 old/new table — live DMV SELECTs.
- UNVERIFIED — the full new query returning 1/1/0/0 on five instances — a live run;
sql-validation.ymlonly checks the proc exists (.github/sql/ci_validate_installation.sql:48) and never executes it. - UNVERIFIED — the combined-state
VALUEStable (IDLE,IDLE_READY,ONLINE THREAD_RESOURCES_LOW,ONLINE HOT ADDED) — the whole basis of the two new predicates, and a read-only tree has no list of the states the DMV emits. - UNVERIFIED — Lite.Tests 87/0 and Darling.Tests 555/0/2 — needs a Windows build.
- UNVERIFIED — "the pinning test fails on the old SQL, 4 of 8 red" — needs the planted run; the count checks out, the red does not reproduce from the tree.
Riskiest lines
PerformanceMonitor.Collectors/CpuSchedulerStatsCollector.cs:190—status = N'VISIBLE OFFLINE'is strictly narrower than theis_online = 0it replaces (noHIDDEN OFFLINE), so it redefines when the warning and install/17'sCRITICALlog row fire, on both SKUs at once — pinned as a string byNodeAndOfflineCpuCounts_UseStateRulesAndOneSchedulerPredicate, UNPINNED on value.PerformanceMonitor.Collectors/CpuSchedulerStatsCollector.cs:182— the only thing keeping the DAC node out of the node counts is now a DMV string nothing in the tree documents; same string-only pin, UNPINNED on value. (Corollary, unreachable on a real instance so not filed: thisWHEREcan now empty the set, andSUMover no rows is NULL, whichreader.GetInt32(23)at:331would throw on.)install/17_collect_cpu_scheduler_stats.sql:183and:277— the install copy's newCONVERTandGROUP BYterm;sql-validation.ymldoes bind them atALTER PROCEDUREtime on 2017-2025, but only if this PR targetsdev, and nothing executes the proc — UNPINNED on value.
Traps near this diff
- "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted (#3684). Alias, do not copy." — applies to the structure, mitigated here — the C# literal and the T-SQL proc cannot be aliased, and the new theory pins both against the same independent predicates rather than against each other (
CpuSchedulerStatsCollectorDefinitionTests.cs:214-238). - "
OPTION(RECOMPILE)on collector queries. A statement added to an existing batch needs its own hint." — does not apply — no statement added; the derived table sits inside the oneINSERT … SELECT, which keeps its hint at install:278/ collector:249. - "Integer division in a rate column … truncates every rate under 1/s to 0." — does not apply — the only ratio here is the untouched
runnable_pct, already decimal with aNULLIFdenominator (collector:214-229); the new column is aCOUNT_BIGwidened tointeger. - "
CASE WHEN interval > 0 THEN delta / interval ELSE 0 ENDspells 'unknowable' as 0." — does not apply — point-in-time snapshot, no interval and no delta group (WatermarkColumn => null,:255; the no-delta pin at test:340-362); the touchedELSE 0arms are a count and a flag. - "Lite and Darling ship in parity: a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding, not a nit." — does not apply — the fix is in the shared definition both SKUs run (collector
:81), and no reader, tool or description changed on either side.
Divergence (blind read vs PR body)
Three, none of them a defect in the SQL:
- The body's "the Dashboard debug message … now says offline CPUs" is true of install
:390, but the message the same flag writes toconfig.collection_log— the one an operator reads back later — still saysN'CRITICAL: Offline CPUs detected in NUMA nodes'(install:313), and the new pin cannot see it (it assertsDoesNotContain("offline nodes")). Commented inline. - "Pin both copies so they cannot drift back" is weaker for the install half:
install/**is in build.yml'sinstallerfilter (:690), notlite(:644-686), so an install-only edit never runsLite.Testsand a drift-back falls to the nightly. Pre-existing (Lite.Tests/DmvResourceDatabaseSentinelTests.cs:200readsinstall/56the same way) and closable with one filter line. Commented inline. - "Online is every state except
OFFLINE" is implemented as a prefix test, so the rule is "no OFFLINE-prefixed state" — identical on the state list the body enumerates; a hypothetical… OFFLINEsuffix would read as online.
Reviewer checklist
- Run the install/17 procedure (not its SELECT) with
@debug = 1on a real instance — the body says it was not run, and the proc is the only place the newCONVERTlands in anintegercolumn through anINSERT. - A box with an affinity mask excluding CPUs, or a genuinely offline NUMA node: confirm
VISIBLE OFFLINEyields one row per offline CPU, and that nothing that used to raise the warning only throughHIDDEN OFFLINEnow goes silent. - Confirm no
sys.dm_os_nodesrow survivesNOT LIKE N'%DAC%'that the oldnode_id <> 32767would have dropped — i.e. no internal node now enterstotal_node_count. - Azure SQL Managed Instance, which both SKUs collect from (
AppliesTo = !IsAzureSqlDb, collector:262): readnode_state_descandsys.dm_os_schedulers.statusthere. - Full
Lite.Tests+Darling.Testson Windows andsql-validationon 2017/2019/2022/2025 — both out of reach from this tree.
…tall/, scoped install asserts
There was a problem hiding this comment.
Verdict: LGTM
Claims
- VERIFIED — the
config.collection_logtext said "in NUMA nodes" and was changed toCRITICAL: Offline CPUs detectedand pinned —install/17_collect_cpu_scheduler_stats.sql:313; new testInstallScript_CollectionLogText_NamesOfflineCpusNotNodes(Lite.Tests/CpuSchedulerStatsCollectorDefinitionTests.cs:249-255) - VERIFIED — the
litefilter in build.yml did not includeinstall/**, so an install-only PR never ran Lite.Tests; the line was added to both the build job'slitefilter and its shard copylite_shard—.github/workflows/build.yml:653and:2659; confirmedinstaller/sql-validationalready matchedinstall/**butlite/lite_sharddid not before this commit - VERIFIED — this also covers the existing
DmvResourceDatabaseSentinelTestsread ofinstall/56—install/56_collect_dmv_blocking_snapshot.sqlexists and is read at Lite.Tests/DmvResourceDatabaseSentinelTests.cs:200, and the new glob is unscoped to any one file underinstall/ - VERIFIED — the
32767andis_onlinechecks no longer read the whole install file, only the collection SELECT between the two anchors —CollectionSelecthelper (Lite.Tests/CpuSchedulerStatsCollectorDefinitionTests.cs:376-385), confirmed both anchor strings are unique (single occurrence each) in install/17 - UNVERIFIED — "Planting the old text back made the new pin fail (1 of 9 in the class). ... All 8 [Fact/Theory groups, 9 cases] pass on the fix" — a live test-run result; a read-only tree can confirm the test exists and the assertion direction (8 Fact/Theory attribute groups counted, one Theory with 2 InlineData cases = 9), but not the pass/fail outcome
- UNVERIFIED — "
CrossAppGuardCiGateTests(including the shard-copy pin)... pass" — same reason, requires running the suite
Riskiest lines
.github/workflows/build.yml:653(and its twin at:2659) — the newinstall/**/!(*.md)line must stay byte-identical across the build job'slitefilter and thelite_shardcopy or the shards silently stop matching the build job's scope — pinned byTheShardJobsFilters_AreByteForByteCopiesOfTheBuildJobs(Lite.Tests/CrossAppGuardCiGateTests.cs:1388)install/17_collect_cpu_scheduler_stats.sql:313— the collection_log text an operator reads after the fact — pinned byInstallScript_CollectionLogText_NamesOfflineCpusNotNodesLite.Tests/CpuSchedulerStatsCollectorDefinitionTests.cs:376-385(CollectionSelectanchor slicing) — a future rename of either anchor string would need to fail loudly rather than silently narrowing to an empty slice that trivially passes everyDoesNotContain— pinned by its own inlineAssert.True(start >= 0, ...)/Assert.True(end > start, ...)guards, not a separate named test
Traps near this diff
- "dorny/paths-filter v4 evaluates each pattern as an INDEPENDENT predicate under
predicate-quantifier: some... Keep the carve-out INSIDE the include:dir/**/!(*.md)." — applies to this diff's layer (new CI path filter), and is followed correctly: the new line is- 'install/**/!(*.md)'at build.yml:653/2659, carve-out inside the include, not a bare!**/*.mdsubtraction. - "Two copies of one definition pinned EQUAL to each other fail only after BOTH have drifted (#3684). Alias, do not copy." — applies to the pre-existing
lite/lite_shardduplicate structure this commit edits; the new line was added identically to both copies in the same relative position (right after the.ps1/.pyentries), and the existing byte-for-byte pin (TheShardJobsFilters_AreByteForByteCopiesOfTheBuildJobs) still holds since the two lists' concatenation order is unchanged.
Divergence (blind read vs PR body)
None — the body's "Review round 1" account matches the diff on every claim graded above: the collection_log text fix, the install/** filter addition to both copies, and the narrowing of the 32767/is_online checks to the collection SELECT slice.
Reviewer checklist
- Run the Lite.Tests
CpuSchedulerStatsCollectorDefinitionTestsclass (9 cases) andCrossAppGuardCiGateTeststo confirm the pass counts the PR body reports, since a read-only tree cannot execute the suite.
Claude posting for Erik Darling.
Fixes #5656.
The CPU scheduler collector reported one "Offline CPUs" and one extra NUMA node on every healthy instance. This fixes the SQL in both places it lives: the shared collector (
PerformanceMonitor.Collectors/CpuSchedulerStatsCollector.cs, used by Lite and Darling) and the Dashboard install script (install/17_collect_cpu_scheduler_stats.sql).The three faults
node_id <> 32767, but the DAC node's id is 64. It now gets left out by its state (node_state_desc NOT LIKE N'%DAC%').ONLINE. The state can combine values (for exampleONLINE DAC,IDLE,IDLE_READY,ONLINE THREAD_RESOURCES_LOW,ONLINE HOT ADDED). Online is now every state exceptOFFLINE(NOT LIKE N'OFFLINE%').offline_cpuscounted nodes that were not exactly online. The warning flag counted schedulers withis_online = 0. The two disagreed on some boxes. Both now read one count: visible (user) schedulers with statusVISIBLE OFFLINE, in one derived table. Hidden schedulers no longer feed the flag. The Dashboard debug message used to say "offline nodes" and now says "offline CPUs". Theconfig.collection_logrow the same flag writes saidCRITICAL: Offline CPUs detected in NUMA nodes. It now saysCRITICAL: Offline CPUs detected.No schema change, no column renames, no backfill. Rows already stored keep their old values. The readers need no change. A grep of
offline_cpu_countandOfflineCpushows they only read the stored column. The readers arePerformanceMonitor.Common/CpuSchedulerMetrics.cs, the Darling viewer, the Lite data service and MCP tool, andinstall/47_create_reporting_views.sql.Per-instance check (read-only, DMV SELECTs only)
I ran the old and new node and scheduler predicates as plain SELECTs on each test instance. Every instance has node 0
ONLINEand node 64ONLINE DAC, and no offline schedulers.I also ran the full new collector query (as a SELECT, not the procedure) on all five. It returned
total_node_count1,nodes_online_count1,offline_cpu_count0 andoffline_cpu_warning0 everywhere. Only DMVs were read.Combined node states, run on the 2019 instance against a
VALUESlist:Test plan
Lite.Tests/CpuSchedulerStatsCollectorDefinitionTests.cspin both copies. They check the state-based DAC filter and theOFFLINE%rule. They check there is no32767and no= N'ONLINE'. They check oneVISIBLE OFFLINEpredicate, with the count and the warning reading the same column, and nois_online. They check the debug message wording and theconfig.collection_logtext (the install script no longer containsin NUMA nodes). The32767andis_onlinechecks used to read the whole install script. They now read only its collection SELECT, sliced betweenmax_workers_count = osi.max_workers_countandOPTION(RECOMPILE);. Both anchors are asserted, so the slice cannot go empty.build.yml:- 'install/**/!(*.md)'added to thelitefilter and to its byte-for-byte shard copy (lite_shard). A PR that edits only an install script now runs Lite.Tests. This also covers the existingDmvResourceDatabaseSentinelTestsread ofinstall/56.CrossAppGuardCiGateTests(including the shard-copy pin), the CI select/map guards,LiteReadsTraitGuardTestsin Darling.Tests and the Census and Ratchet classes pass.VALUESrun above covers the state strings.Review round 1
config.collection_logtext said "in NUMA nodes" although the count no longer counts nodes. Changed toCRITICAL: Offline CPUs detectedand pinned. Planting the old text back made the new pin fail (1 of 9 in the class). The fix was restored and all 9 pass.install/17half of the pin lives in Lite.Tests. Thelitefilter inbuild.ymldid not includeinstall/**, so an install-only PR never ran it. Added the filter line in both the build job and its shard copy.32767andis_onlinechecks no longer read the whole install file, so an unrelated later use of either string cannot fail this test.