Repository navigation
Collection Health: qualify a persistently-empty enumeration against the target's user databases - #1868
Merged
Conversation
…he target's user databases #1837 shipped the persistence half of the empty-enumeration sentence ("all 96 runs") off the collection_log aggregate the grid already read, and stopped there: it could not tell a target that is legitimately empty from one that has user databases and is enumerating none of them. This adds the missing half. The three collectors that enumerate user databases (query_store, index_object_stats, database_scoped_config) now render their persistent empty-enumeration note as "... (all 96 runs, target has user databases)" when the store has seen user databases on the same server inside the same window. Informational only. No new status, no new band, and Classify never sees the flag - a legitimately empty target keeps reading HEALTHY, which is #1837's non-negotiable constraint, re-asserted against the new input in both suites. Four gates, all load-bearing: the note must be the empty-enumeration one (a probe-failure note names its own cause), every run in the window must have carried it, the collector must actually enumerate databases, and there must be inventory to go on. Absent, aged-out, or uncollected inventory yields no qualifier rather than a stale claim. The inventory is one uncorrelated EXISTS against database_size_stats, chosen over database_config because it runs on the scheduled loop, is indexed on (server_id, collection_time), and reads sys.master_files so it still sees databases the monitoring login cannot ENTER. Screened on database_id > 4, since the size collector takes every ONLINE database. Its window is the health read's own $2 - no second parameter. The Darling Viewer's fleet rollup projects CAST(NULL AS integer) for the ordinal and no value, holding #1863's line: it groups server_id INTO its result so there is no single server to probe, and with the note text already NULL a real value could not change one rendered character. All four surfaces inherit the text from the one shared formatter; the MCP payload also carries the raw target_has_user_databases. Filed rather than shipped: #1867 - no Availability-Group mapping, because no AG collector enumerates today and the Darling store has no v_ag_replica_states view to join. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review summaryWent through the full diff (11 files, Correctness
Lite/Darling parity
Security
Performance
Nice test coverage (live DuckDB + live Postgres through both readers, mutation-tested per the PR description). Nothing to change from my end. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1852.
What was missing
#1837 shipped the qualifier that says HOW MUCH of the window an enumerated collector spent empty -
enumeration yielded 0 items ... (all 96 runs)- and deliberately stopped there. It could not tell apart:Answering that needs a per-server inventory the collection_log aggregate does not carry. This adds it.
What ships
The three collectors that enumerate user databases now render a persistent empty-enumeration note as
... (all 96 runs, target has user databases)when the store has seen user databases on the same server inside the same window. All four Collection Health surfaces inherit it from the one sharedCollectorHealthClassifier.FormatCollectionNote- Lite's grid, the Darling Viewer's grid, bothget_collection_healthMCP tools, and the web dashboard's table behind them. Both WPF grids already bindNoteFormattedand the web table already binds the server-composednote_summary, so no XAML and no JavaScript changed: the audit the design asked for came back "they inherit it".The MCP payload also carries the raw
target_has_user_databases, so a caller diagnosing an empty collector gets a boolean instead of parsing it out of a sentence.The banding is untouched, which is the ceiling #1854 pinned. No new status value, nothing new reaches
Classify, and both suites re-assert the neutrality test against the new input - two collectors identical except for the note AND the inventory flag band identically. A monitoring tool that reddens on a filter nobody minds gets ignored.The mapping, and why it is only user databases
CollectorHealthClassifier.ExpectsUserDatabasesis an explicit name set -query_store,database_scoped_config,index_object_stats- kept that way for the same reasonOnLoadCollectorNamesis: so the classifier stays free of a dependency on the collector catalog. It is not trusted on faith. Both suites walkCollectorCatalog.All, invokeBuildEnumerationQueryon an on-prem target, and assert the resulting set is exactly those three AND equals the classifier's set, so a collector cannot start or stop enumerating without a decision landing here.That pin is also what settled the AG half of the design sketch. No AG collector enumerates: the empty-enumeration note is written in exactly one place (
EnumeratedCollectorDriver.BuildNote, reached only from the enumerated path), andAgReplicaStatesCollector/AgDatabaseReplicaStatesCollectorare plain single-query collectors. A mapping for them could never fire. Independently, the Darling store has nov_ag_replica_statesview - Lite creates av_view for every catalog table, Darling's list is hand-maintained and omits it - so joining one needs a schema migration with aSchemaVersionbump. Filed as #1867 with what shipping it would take, rather than half-wiring a mapping whose signal is never supplied.Staleness, and the four gates
The qualifier appears only when all four hold, and each has its own test:
The inventory window is the health read's own second parameter - the same trailing seven days the grid summarizes. No second knob, and an inventory that aged out is not evidence about the target today, so it yields nothing rather than a claim the store can no longer support.
Why database_size_stats and not database_config
database_configis the intuitive inventory and the wrong one, for three reasons found while building this:PgSchemaGenerator.CreateIndexreturns null for it, mirroring Lite);sys.databases, wheredatabase_size_statsreadssys.master_files- so the size collector still sees databases the monitoring login cannot enter, which is exactly the fault being diagnosed.database_size_statsruns every 60 minutes, is indexed on(server_id, collection_time), and screens ondatabase_id > 4- the collector takes every ONLINE database, tempdb included, so a bare row check would be true on every server alive. That screen is not decoration: dropping it to> 0is one of the mutations below.Cost, measured
The subquery is uncorrelated, so Postgres evaluates it once per query as an
InitPlanrather than per row. Measured on PG 18.4 + TimescaleDB with the product's own settings (work_mem 16MB, shared_buffers 1GB), 200 servers x 4,000,000collection_logrows over 7 days plus 3,360,000database_size_statsrows:In the common case the two shapes are indistinguishable - the run-to-run spread is wider than the difference. The worst case is the legitimately-empty target, where the probe walks that server's whole window and finds nothing; 7-12 ms on a read that already costs ~100 ms, well inside the per-server budget. The plan confirms the shape outright:
The fleet rollup pays nothing, holding #1863's line.
FleetCollectionHealthSqlprojectsCAST(NULL AS integer) AS has_user_databasesfor the ordinalMapHealthRowshares and no value, for #1863's two reasons plus one of its own: this read groupsserver_idINTO its result, so there is no single server to probe an inventory for. A truthful fleet version would be a cross-collector join across every enabled server on a query the status bar re-runs on every aggregate-tab refresh. Withlast_notealready NULL there, a real value could not change one rendered character. Pinned at source AND behaviorally, against live data that DOES qualify on the per-server read.Test plan
Paired suites in both apps (
EmptyEnumerationInventoryTests/DarlingEmptyEnumerationInventoryTests), each holding the formatter gates, the catalog pin, the banding neutrality, and live-store behavior - real DuckDB and real PostgreSQL 18.4 through BOTH Postgres readers (the Viewer's and the MCP service's), because a source pin cannot tell an inventory subquery that matches from one that silently never does.Covered behaviorally: the qualifier appears only with a persistent empty note AND inventory; system databases alone are not inventory; an inventory older than the window says nothing; another server's says nothing; no inventory at all leaves the note byte-identical to #1837's; an unmapped collector is never qualified however much inventory there is; a probe-only note is not qualified; the combined note is; the fleet read still carries none while the per-server read on the same rows does.
Two shared contracts get their own pins:
EnumeratedCollectorDriver.EmptyEnumerationMessagemust start withCollectorHealthClassifier.EmptyEnumerationMarker(the marker is one duplicated substring, because Common deliberately does not reference Collectors - inverting that boundary would drag the collector layer into every consumer of Common), and the expectation set must equal the catalog's actual enumerators.Three existing pins were strengthened rather than merely updated: both apps' "the property delegates to the shared formatter" tests now assert it on a row where the new inputs CHANGE the answer, so they would fail if
NoteFormatteddropped them; the fleet ordinal-parity pin now also pins the deliberate NULL and thatv_database_size_statsappears in the per-server read and NOT the fleet one; and the MCPnote_summarypin is whitespace-collapsed so it survives the wrapped argument list instead of pinning one formatting of it.Watched red - six mutations, each reverted after:
database_id > 4to> 0The second row is why this was worth doing: the Darling suite originally seeded system and user databases together, so it stayed green with the screen removed. It now has a system-databases-only case, and goes red.
dotnet testLite 1713 passed / 0 failed; Darling 3826 passed / 0 failed / 9 skipped against a live store (dropped and recreated first).dotnet build PerformanceMonitor.sln -t:Rebuild: 0 warnings, 0 errors. Installer.Tests not run.Filed rather than shipped
Note for the reviewer
The one methodological trap worth recording: an early portability probe created stub
collection_log/database_size_statstables in the same scratch database the live tests then migrated, andCREATE TABLE IF NOT EXISTSno-opped over them - so the first live run failed on a column that exists everywhere real.dropdb && createdband it was gone. The suite numbers above are from a database recreated fresh.🤖 Generated with Claude Code