Skip to content

The Darling viewer's trend charts and calendar tell the same truth the MCP tools learned today: routed by retention tier and saying so, no fabricated first point, and a purged day is grey, not green (#3653 viewer ports) - #3666

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/3653-viewer-ports
Sep 19, 2026

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

Three fixes landed today on the Darling MCP surface — tier-routed duration trends (#3590), no fabricated first point on a differenced series (#3642), and the calendar's retention states (#3641) — and the WPF viewer that reads the same store did not get any of them. This PR is their viewer ports, the three items #3653 lists under "Viewer": partial for #3653; the issue stays open.

Performance Trends read raw only, so a 7-day chart on a TimescaleDB store plotted 4 days under an axis that said 7

ViewerDataService.GetQueryDurationTrendAsync / GetProcedureDurationTrendAsync (and the execution-count read beside them) went to query_stats / procedure_stats and nowhere else. The raw tier of a rolled table is dropped at RawRetentionSpan (four days) regardless of the collector's advertised retention, so the chart silently plotted what had not aged out, with nothing on it marking the difference — exactly the shape #3590 removed from the MCP trio.

The mechanism. The routing and the hourly-tier SQL now live in Storage, in a new DurationTrendRouting — the same home QueryStoreTrendRouting (#2736) took for the same reason: the viewer does not reference the service assembly, so a decision that must be identical in both apps cannot live in DarlingTrendReader alone. It holds get_query_trend's two-tier ladder (ShouldUseRawTier / ResolveTier: age → rollup availability (#1664) → materialized coverage (#1759)), RawTierMargin, TruncationSlack, DescribeCoverage, the SourceWord vocabulary, and BuildHourlyTrendSql(view, withDatabaseFilter). With withDatabaseFilter: false the builder's output is DarlingTrendReader.QueryDurationTrendHourlySql / ProcedureDurationTrendHourlySql byte for byte; with true it is the viewer's copy, plus exactly one line — the #1319 $4::text[] database filter (both hourly rollups group by database_name, so the filter survives the routing).

The viewer's three reads route through it and return a QueryTrendSeries (points + tier + EffectiveStartUtc + Truncated) — the MCP DurationTrendResult shape. The execution-count chart routes with its duration sibling and, on the hourly tier, reads the SAME statement's executions_per_second column (an ordinal parameter on the one shared read loop) rather than a fourth SQL text. The Query Store trend is not on this ladder: its routing is a materialization watermark, not a tier choice (#2736), and it stays as it was.

The chart says what it served. Each routed chart carries a title in the heatmap's title idiom: Source: raw (one point per collection) or Source: hourly rollup (one point per hour), and — only when the tier did not hold the window's head — — data begins <first served point>; the store no longer holds the rest of this window at this tier. The tier is spelled with the payload's own word (raw / hourly), so a user reading the chart and an agent reading get_query_duration_trend are told the same thing in the same vocabulary. ClearChart resets the plot, so a stale truncation note cannot survive a reload that came back empty. This is #1661's disclosure posture (the FinOps expensive-queries panel's text-horizon note) applied to the chart that most needed it.

Why not DarlingTrendReader made internal/public? The viewer cannot reference the Service project (it never has: #1661, #2530). Why not RetentionTierRouter? That is the built-in tabs' three-tier ladder with a one-DAY margin; the trends' widest window (seven days) never reaches the daily tier, and widening the raw margin to a day would push every 3–4-day chart onto the rollup for nothing (#2353's one-hour trade, stated there). Why not edit DarlingTrendReader.cs to alias the Storage members? That file belongs to other lanes tonight; its constants are pinned EQUAL to the Storage definitions (ViewerTrendRoutingPortTests) so they cannot drift, and turning them into one-line aliases is a follow-up for whichever lane next opens the file.

The viewer's trend SQL fabricated a 0 for the first differenced point

QueryDurationTrendSql, QueryStoreDurationTrendSql and ExecutionCountTrendSql still carried CASE … ELSE 0 END: the first collection in the window has no previous collection to difference against, its rate is unknowable, and it was plotted as a measured quiet instant that dragged every series' opening toward zero. The ELSE 0 is gone from all three (the procedure copy went in #3630; the MCP copies in #3642). The rate is NULL and every reader in the file goes through one loop that SKIPS the unrated row — not plotted as 0, not interpolated across (the heatmap's NaN-break posture: an unknowable point is not a point). The execution-count reader, which had its own loop coercing NULL to 0, now shares that loop.

The Performance Calendar banded purged days Healthy

The aggregate's day spine is a UNION over nine sources aging out at different horizons: the seven signal tables at 30 days, the collection log at 60, the alert log at 90. For every day between, the spine still names the day while every count the band reads has been purged and COALESCEd to zero — and measured zeros band Healthy. #3641 taught the MCP reader to judge each day (DailySummaryRetention.StateFor: collected / purged / past_horizon / no_run_record) against the store's retention horizon. The viewer read the same DailySummarySql.RangeSql — signal_sources_present and all — and never judged, because the horizon's computation lived in DarlingHealthReader with StoreConfigProvider.ResolveFleetRetentionDays and DarlingRetention's constants, none of which the viewer can see.

The mechanism. A new Storage DailySummaryHorizon holds the signal-collector list, the fleet-override SQL, the fleet read (ReadFleetRetentionOverrideDaysAsync) and ShortestSignalRetentionDays (a Func<string,int> resolver form the service feeds its purge resolver into, and a dictionary form the viewer feeds the read into). The retention facts it needs — DataRetentionBaseDays, CollectionLogRetentionDays, AlertHistoryRetentionDays, BaselineServingRawCollectors — move to Storage as DarlingRetentionHorizons, and DarlingRetention keeps its names as aliases of them. The fleet-retention RULE (valid override ≥ 1 else the collector default) is DarlingRetentionHorizons.ResolveFleetRetentionDays; StoreConfigProvider.ResolveFleetRetentionDays now locates the fleet row and delegates to it, behaviour-identical (one fleet row per collector exists, so first-match is the match). BaselineMath.BaselineWindowDays is passed in by both callers because the Analysis assembly is one Storage does not reference. DarlingHealthReader keeps its members as aliases/wrappers, so the tool and its tests read as before.

The viewer's GetDailySummaryRangeAsync reads the horizon once per range (like the banding tiers, so a schedule save mid-read cannot judge half a month against one horizon and half against another), stamps each row's DataState / RetentionHorizon / SignalSourcesPresent, and ToSignals() folds purged / past-horizon into HasData = false — the MCP row's fold and Lite's row's fold, verbatim. A purged cell is grey. The absent-day row (GetDailySummaryAsync) is judged too, the way the MCP single-day tool judges its absent day.

The tooltip says why. DailyHealthBandCalculator.Describe (Common) gains a state-aware overload: a grey cell used to say only "No data collected.", which for a purged day is false — the day WAS collected; retention took it. Purged: "No verdict: this day is before the store's retention horizon (2026-08-20) and its signal rows have been purged. The zeros are absences, not measurements." Past-horizon names how many of the 7 signal sources still hold rows. No-run-record inside retention keeps the signal lines and appends the disclosure. Collected is the plain overload verbatim. Lite's DailySummaryRow.SignalsTooltip calls the same overload (its row already carried the three members), so both SKUs' calendars say the same thing about a purged day — the twin port the boundary grants.

And the PVS top-5 trend's NULL → 0

PvsTrendPoint.PvsSizeMb coerced a NULL persistent_version_store_size_mb (an unmeasured pass) to 0 MB on both SKUs — a cliff drawn into a series that has none. The Darling viewer's reader, which has no payload consumer, skips the row. Lite's reader is shared with get_pvs_trend, so its PvsTrendPoint.PvsSizeMb becomes double?, the payload publishes null with the same pvs_measured flag its latest-snapshot rows carry (contract rule 5: a collection happened; its size is unknown), and the FinOps chart leaves the point out. DarlingPvsReader (the Darling MCP payload) is NOT touched here — see the issue note.

Blast radius

  • StoreConfigProvider.ResolveFleetRetentionDays is the purge's resolver; the delegation is behaviour-identical for every input shape (fleet row / per-server row / invalid row / frequency-only row / no row), pinned in ViewerCalendarRetentionPortTests.FleetRetentionRule_IsOneDefinition_ThePurgeDelegatesToIt, and StoreConfigProviderTests / DarlingRetentionTests / BaselineSupplyTests hold unchanged.
  • The viewer's Get{Query,Procedure,ExecutionCount}TrendAsync return QueryTrendSeries instead of List<QueryTrendPoint>; the two live tests that called them are updated (the first one had asserted the fabricated 0.0 first point by name — it now asserts the point is absent).
  • The alert path is untouched. The viewer's new reads are interactive, under ViewerCommandDeadlines.CurrentInteractiveReadSeconds, and bounded by shape (a bucket range on the rollup; a server_id IS NULL filter on a config table with one row per collector).
  • No migration rung, no knob, no CHANGELOG edit (entry proposed to the coordinator).

What this does NOT do

Test plan

New (compile-verified here with -p:EnableWindowsTargeting=true, first executed in CI): Darling/Darling.Tests/ViewerTrendRoutingPortTests.cs — MCP hourly SQL == Storage builder (no filter); viewer hourly == MCP + the one $4 line, inside the WHERE; RawTierMargin / TruncationSlack / HourlyBucketSecondsSql shared and equal to TimescaleSupport.HourlyBucket; ResolveTier and ShouldUseRawTier agree with DarlingTrendReader's on every accepted hours_back × availability × five coverage shapes (1,680 cells); DescribeCoverage / SourceWord match the MCP route's; the viewer routes through the shared resolver with the grain's own flag; the exec-count chart reads ordinal 2 of the shared text; DescribeTrendCoverage wording; every routed chart shows its coverage; PVS both SKUs. Plus ViewerTrendRoutingLivePostgresTests (scratch TimescaleDB database, the RollupBackfillLiveTests pattern): a 7-day window routes hourly with exact bucket-width rates (169 buckets, 8000/3600 ms/s), the database filter survives the routing, the exec-count chart rides the same statement, a recent window stays raw and drops its unrated first collection, and a window past the planted history is disclosed as truncated.
New: Darling/Darling.Tests/ViewerCalendarRetentionPortTests.cs — purged / past-horizon band NoData and inside-retention stands; tooltip per state; absent day judged; ShortestSignalRetentionDays viewer-form == MCP-form for every override shape the MCP test walks plus an invalid 0; the fleet rule is one definition the purge delegates to; the service's constants are aliases; DailySummaryRetention is one type consumed by both readers (reflection over all four assemblies + source pins of the fold and the ordinal-13 read); Lite/viewer rows are twins on the retention members. Plus ViewerCalendarRetentionLivePostgresTests (shared fixture, LiveStoreCleanup finally): a run-record-only day 10 days before the computed horizon is Purged/NoData with collection_runs = 1 real; a run record + surviving deadlock is PastHorizon (1 of 7); the viewer's horizon equals DarlingHealthReader's for the same store and the two readers' states agree day for day.
New in both DailyHealthBandTests twins: Describe_WithRetentionState_SaysWhatTheZerosAre.
Changed: ViewerQueriesRestTests (the ELSE-0 pin flips to DoesNotContain, the reader anchor follows the loop rename, a Query Store first-interval pin is added, the live trend test asserts one point not two), DeltaFamilyIntervalCompletionLivePostgresTests (.Points).

Executed locally (the brief's rule): a throwaway console harness outside the worktree referencing the built PerformanceMonitor.*.dlls ran every pure pin above against the real methods — all green — and, against a throwaway timescale/timescaledb:2.28.1-pg18 container on Docker desktop-linux: migrate → enable → hypertables → seed 8 days × 2 databases → ensure CAGGs → backfill, then the VIEWER's hourly text (with $4) returned 169 buckets at exactly 8000/3600 and 40/3600, the DbA filter gave 2000/3600, the deep window described itself truncated at the first materialized bucket, the viewer's raw QueryDurationTrendSql / ExecutionCountTrendSql (extracted from source) returned NULL for the first collection and exact rates after, and DailySummaryHorizon read a planted fleet deadlocks = 12 (ignoring a NULL-retention fleet row and a per-server row) to a horizon DarlingHealthReader.GetDailySummaryRangeAsync published identically. Container removed, harness deleted.

Builds (zero warnings): PerformanceMonitor.Darling.Storage, .Service, .Viewer, PerformanceMonitorLite, Darling.Tests, Lite.Tests, deprecated/Dashboard.Tests — all -c Debug -v q -nologo -p:EnableWindowsTargeting=true.

Which component(s) does this affect?

  • Lite
  • Darling
  • Lite Tests
  • Darling Tests
  • SQL collection scripts
  • Documentation
  • Full Dashboard (deprecated)
  • CLI Installer (deprecated)

How was this tested?

See the test plan: compile-verified test suites (first execution in CI), plus the pure pins and the moved/reused SQL executed locally against the built assemblies and a live PG18 + TimescaleDB 2.28.1 container.

Checklist

  • I have read the contributing guide
  • My code builds with zero warnings (dotnet build -c Debug)
  • I have tested my changes against at least one SQL Server version (store-side change; verified against a live PostgreSQL/TimescaleDB store)
  • I have not introduced any hardcoded credentials or server names

Supersedes #3661 (wedged merge state; closed unmerged). Same four commits, branch freshened against dev.

@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 18, 2026 23:47
…e MCP tools learned today: routed by retention tier and saying so, no fabricated first point, and a purged day is grey, not green (#3653 viewer ports)

Performance Trends: the query-duration, procedure-duration and execution-count
charts route through DurationTrendRouting (new, Storage) — the #3590 ladder
(age → rollup availability → materialized coverage) onto the hourly rollups —
and each chart titles itself with the tier it served from, the first served
point and a truncation note when the tier did not hold the window's head. The
hourly SQL is one builder both apps run; DarlingTrendReader's constants are
pinned equal to it. The ELSE 0 comes out of the viewer's four raw trend texts
and every reader skips the unrated first point.

Performance Calendar: DailySummaryHorizon (new, Storage) computes the retention
horizon the MCP health reader computes — the retention constants and the fleet
retention rule move to Storage and the service aliases them — and the viewer's
row is judged by DailySummaryRetention.StateFor, folds purged / past-horizon
into HasData = false (grey), and its tooltip says why through a state-aware
DailyHealthBandCalculator.Describe overload (Lite's row uses it too).

PVS top-5 trend: an unmeasured (NULL) size is no longer plotted as 0 MB on
either SKU; Lite's shared reader carries it as null for get_pvs_trend.
…asserted the fabricated 0 point now assert its absence; the PVS Lite pin is scoped to the trend read; the shared-resolver count is the two real sites; one merged summary block on Lite's PvsTrendPoint
… horizon the range read already computed, one fleet-override read instead of two
…laration, which the previous slice swallowed
@erikdarlingdata
erikdarlingdata merged commit 1de06bd into dev Sep 19, 2026
9 of 12 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3653-viewer-ports branch September 19, 2026 00:26
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…spliced once

Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2
worker's; each lane returned its entry to a buffer instead of touching this file,
so that fifty-plus PRs did not each rebase the same twenty lines. This is the one
splice. Every entry is one line (the archiver's compact() and the pins read them
that way); riders fold into their parent's entry (#3599 under #3590, #3619 under
#3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as
#3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main
is what is still pending.

[Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the
six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert
families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the
service-side wait sampler (#3645 / #3604), and the log-event classifier with its
temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The
other forty-eight are honesty fixes to existing surfaces and append to `### Fixed`
after the wave-1 bullets, in PR-number order.

Thirty-six reference definitions added for the issues the new entries cite and the
index did not yet define; the [Unreleased] group is one ascending run again, which
moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or
older is touched; the archive script was not run. One editorial touch: the #3585
entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists
only in the Darling MCP host).

tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor
1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the
750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…spliced once (#3672)

Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2
worker's; each lane returned its entry to a buffer instead of touching this file,
so that fifty-plus PRs did not each rebase the same twenty lines. This is the one
splice. Every entry is one line (the archiver's compact() and the pins read them
that way); riders fold into their parent's entry (#3599 under #3590, #3619 under
#3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as
#3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main
is what is still pending.

[Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the
six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert
families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the
service-side wait sampler (#3645 / #3604), and the log-event classifier with its
temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The
other forty-eight are honesty fixes to existing surfaces and append to `### Fixed`
after the wave-1 bullets, in PR-number order.

Thirty-six reference definitions added for the issues the new entries cite and the
index did not yet define; the [Unreleased] group is one ascending run again, which
moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or
older is touched; the archive script was not run. One editorial touch: the #3585
entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists
only in the Darling MCP host).

tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor
1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the
750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…lingPvsReader read persistent_version_store_size_mb as a bare double with IsDBNull ? 0, so a collection on which the DMV reported no size for a database became a real zero in the top-5 series — a cliff in a series that had none. The point is now null with a pvs_measured flag beside it, the same two keys Lite's twin has carried since #3666, and a measured 0 MB still travels as 0 (#3653) (#3682)
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…o the Storage definitions #3666 moved them to — a pin that fails only after two copies have drifted; they are now aliases, so there is one definition and nothing to drift. And the Query Store duration chart, the one Performance Trends chart #3666 left untitled, now says what served it and where the corrected rollup's materialized floor cut the window's head (#3653) (#3684)

Half 1 — DarlingTrendReader → DurationTrendRouting, zero behaviour. HourlyBucketSecondsSql is a
const alias; QueryDurationTrendHourlySql / ProcedureDurationTrendHourlySql are static readonly
bound to the Storage builder with withDatabaseFilter:false (a const cannot be initialized from a
call; no consumer needed const-ness); RawTierMargin / TruncationSlack alias the Storage fields;
ShouldUseRawTier / ResolveTier / DescribeCoverage delegate; the two "raw"/"hourly" ternaries go
through SourceWord. Proven byte-identical by a reflection harness over the built dll before and
after (both SQL texts, both TimeSpans, 1,680 ResolveTier cells, 168 ShouldUseRawTier, 840
DescribeCoverage tuples, both Source words — the only diff is the declaration kind). The #3666
equality pins that had become a function compared with itself (the resolver census, the coverage
loop) are retired; McpTrendReaderMembers_AreAliasesOfTheStorageDefinitions reads the declarations
and fails the moment any is restated.

Half 2 — the Query Store chart discloses its floor. GetQueryStoreDurationTrendAsync already
resolved RollupFloorUtc for the #2736 seam and threw it away; it now returns QueryStoreTrendSeries
(points + route + effective start + unserved-before). QueryStoreTrendRouting gains SourceWord
(rollup+raw / raw — the payload's words) and UnservedBefore (the floor when it sits above the
requested start; null on raw-only by construction, since raw is complete wherever the rollup has
not armed its purge), and DarlingMcpTrendTools reads both instead of its literals, so the tool and
the chart cannot disagree. The title goes through the same composer as the siblings' — same
frame, this route's own truth in the slots: the grain seam in the parenthetical, and a "data
begins" clause only when the head was measurably unserved, with the honest reason (not
materialized; --backfill-rollups reaches it) rather than the siblings' "no longer holds".
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