Skip to content

The Daily-routed calendar prints NULL, not 0, for a day the rollup tier skipped below its ceiling and names it in days_missing[] — the hole scan's two probes per server at day grain, on both daily tools, the viewer's day-detail line and the web calendar (partial #3653 — item 9, A6) - #3788

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/3653-daily-calendar-null-not-0
Sep 20, 2026

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

The lie. A rollup-tier calendar answers its query count from a continuous aggregate. A day at or below this server's rollup ceiling for which the rollup held no row fell out of DailySummarySql's queries LEFT JOIN and the outer select's COALESCE(q.c, 0) printed unique_queries = 0 beside that same day's real wait, CPU and deadlock numbers — the same 0 a measured-quiet day prints. They are different facts. Zero says "measured, nothing ran"; what happened is that the tier never materialized the day. The shape that leaves such a day is the one RetentionTierRouter's essay derives and TimescaleSupport.RepairMaterializationHolesAsync (#3731) repairs: a service down longer than a refresh policy's start_offset resumes with a refresh that opens at now - start_offset, and every bucket between the last pre-outage refresh's window end and the outage is skipped. #3731 closes such holes at the next service start, so the case is rarer than it was and not gone: between the first post-resume policy refresh (which moves the ceiling past the skipped days and makes them "below" it) and the next start, and for any hole past the per-start cap, the calendar still read the skipped day as 0. The item's clause: "NULL-not-0 for a day skipped BELOW the rollup ceiling (payload-shape, census lane)".

Mechanism — the witness is the hole scan's definition, per server, at day grain, not a second one. MaterializationHoleScanSql calls a bucket a hole when the materialization holds NO row for it AND the aggregate's SOURCE holds at least one row in it that the aggregate's own WHERE admits. The routed queries CTE gains a third UNION ALL member that asks the same two questions of every day at or below the server's ceiling, with server_id = $1 on both probes and the day as the bucket, emitting (day, NULL::bigint) where both hold. The relation's source, its time column and its filter are read off TimescaleSupport.MaterializationHoleTargets — the repair's own target list — so the daily tier probes query_stats_hourly (its hierarchical source, 90 days), the hourly tier probes raw query_stats (4 days), and the interval-honest successor's probe carries sample_interval_seconds IS DISTINCT FROM 0 (MaterializationHoleSourceFilterFor), so a day holding only restart rows is not called a hole. A relation the registry does not know is refused with an ArgumentException before it reaches the store. The outer select projects CASE WHEN q.d IS NULL THEN 0 ELSE q.c END AS unique_queries — a no-op on raw, where a COUNT is never NULL — so one outer text serves every tier, and the presence arm reads q.c rather than q.d, so a not-carried day is not counted as a source that holds the day (a purged shell whose one surviving "signal" is a day the tier never carried stays purged, not past_horizon). The day joins the spine through queries like any other, so it is printed: its source proves it was collected.

Why this witness, and what it refuses to claim. The second probe is what keeps the disclosure honest where the raw table was simply empty: a server that ran nothing that day has no source row, no rollup row, and is not named — its 0 is what raw would have said, and the live test plants exactly that control beside the hole. A bucket holding zero distinct hashes cannot exist (every query rollup GROUPs BY query_hash, so a rollup row's count is ≥ 1 by construction — pinned on the CREATE texts), which is why the not-carried row is told apart by c IS NULL alone and needs no flag column. The witness also bounds itself: a day whose source rows the purge has already taken is undecidable by any witness and reads as it did; on the daily tier that is a day older than the hourly's 90 days, which is also the alert log's horizon, so such a day is at most a boundary-day shell already banded NoData. A day BELOW the relation's floor that the source still holds (the straddle the router's essay accepts "with no signal to the caller") answers the same two probes the same way, so for as long as the source holds the day this is also that one reader's partial-coverage notice; the router essay now says so, and says that the other routed readers still carry the gap. Cost follows the window's length, not the tables' weight: one (server_id, bucket) group-index probe and one (server_id, time) index probe per day over at most a year of days — measured on the rig with a 41-server population, the planner takes Index Only Scan using …_server_id_bucket for the rollup probe and idx_query_stats_time (server_id, collection_time) / the hourly's server_id_bucket for the source probe; the whole 31-day statement runs in 2 ms (daily) / 6 ms (hourly).

Payload shape, one key name. DarlingHealthReader.DailySummaryReadRow.UniqueQueries and the viewer's DailySummaryRow.UniqueQueries become long?; both readers keep the NULL at ordinal 3 (the 0L they substituted is the lie). DailySummaryRangeReadResult.DaysMissing derives from the rows with a null count, so the list and the NULLs cannot disagree. get_daily_summary and get_daily_summary_range emit unique_queries: null and a trailing days_missing[] (empty when every day was carried, which is every raw-tier window); both descriptions say what the null is and that the band does not read it. The band never read the count (DailyHealthSignals has no member for it), so a NULL moves no verdict — a not-carried day is not "quiet" and it is not "No Data" either; it is one column's disclosure. The desktop viewers' shared PerformanceCalendarDay.UniqueQueries becomes long? and DailyHealthBandCalculator.BuildKeyMetricsLine takes long?, printing not materialized at this tier for the null (not 0, not the grids' dash — the dash is "there was none of this", the opposite claim). The web Daily Health Calendar becomes a composite (dailyCalendarPanel) so the read's days_missing[] renders as one notice line above the grid, and the unique_queries cell renders "not materialized" for a null; it fetches the one read the table() descriptor did (the NoTab_FetchesTheSameReadTwice composites map carries it).

DailySummaryRetention.StateFor was read and is not the home for a fourth word. DataState is a row-level verdict about the band's inputs against the retention horizon (Collected / purged / past_horizon / no_run_record). "Not carried at this tier" is one column's condition on a row whose band is still a verdict — the other signals are raw-tier measurements — so a fourth state would withhold or recolour a band the count never entered. The per-column NULL plus the one list is the shape; the pin that a not-carried day bands identically to the same day carried is in the new tests.

What it does NOT do

  • Lite is untouched. It has no rollup tier: its calendar reads raw DuckDB, where every day inside retention is the source itself. The shared model's field widening (PerformanceCalendarDay.UniqueQueries, BuildKeyMetricsLine's parameter) is a no-op there — Lite's callers hand a long and compile unchanged; Lite's DailySummaryRow.UniqueQueries stays long and its SQL keeps seven day-arms. The only Lite-side edit is the DailyHealthBandTests twin, which gains the same null-case test as Darling's so the two files stay identical as their headers promise. Lite's get_daily_summary* descriptions no longer match Darling's byte-for-byte (they were equal by coincidence, not pinned); Lite's cannot honestly mention a rollup tier.
  • No rung. No schema, no migration, no PgMigrations/StorageVersion touch.
  • Not the daily successors (query_stats_daily hierarchical from the legacy hourly) — Daily band re-derivation is DEFERRED until a daily successor is added (band full 23/23) — the slicing arm landed in #3865 (buckets_per_batch = 1); the storm class was fixed by the WAL ceiling (#3802) #3745's; the daily tier's source stays the legacy hourly, and the not-carried probe reads it as registered.
  • Not a partial-day disclosure. A day the hourly carried 22 of 24 buckets for (the ≤2 h outage tail) prints its real-but-partial count as before; this lane's clause is the day with no row.
  • Not DarlingMcpInstructions.cs / DarlingWebEndpoints.cs catalog rows (lane 6's files): the get_daily_summary_range instructions row does not yet name days_missing. The tool [Description]s carry the vocabulary; the rows are a one-line follow-up for the lane that holds those files.
  • Not the other routed readers' straddle notice — the router essay's accepted gap stands for them; only the daily summary now tells its caller.

Which component(s) does this affect?

  • Lite
  • Darling
  • Lite Tests (the DailyHealthBandTests twin only)
  • Darling Tests
  • SQL collection scripts
  • Documentation
  • Full Dashboard (deprecated)
  • CLI Installer (deprecated)

How was this tested?

Builds: Storage, Service, Viewer (with Common and Ui as dependencies), Darling.Tests and Lite.Tests, -c Release -p:EnableWindowsTargeting=true — 0 new warnings (the two pre-existing xUnit analyzer warnings on dev's ConfigChangeTraceAnchorTests.cs:94 and LogTailOverlapThresholdPinTests.cs:95 / DefaultTraceEventsCollectorDefinitionTests.cs:211 are untouched and not this lane's files). node --check on server-tabs.js.

Executed here, not only compiled — a net10.0 harness named Darling.Tests over the built product assemblies, and a net10.0-windows harness (WindowsDesktop framework stripped from the runtimeconfig) for the Viewer-dependent pins:

  • Pure pins, 483/483 including the new DailySummaryNotCarriedTests (outer projection + presence arm, every routed form's two probes against the registry's source/time column/filter, the zero-hash impossibility on the three CREATE texts, the unregistered-relation refusal, the reader's null + DaysMissing derivation + band-does-not-move, every consumer's null-keeping read and one-key spelling), the updated RetentionTierRouterTests (member order and ceiling gate on both tiers), IntervalHonestHourlyRollupTests (successor text = legacy text with exactly the relation swap and the WHERE appended), DarlingMcpHealthToolsTests (six day-arms + one count-arm = SignalSourceCount; Lite's copy keeps seven; descriptions name days_missing), ServerPageTabsTests (the composite in the duplicate-read map), the DailyHealthBandTests twin, and the census set: RepoFileAdoptionTests, StragglerCommandTimeoutTests, LiveCleanupConversionRatchetTests, MeasurementContractCensusTests (both SKUs — Lite's on the post-Rule 6's C# half was a stated bound, not a census: every per-second payload key on both SKUs is now traced to a reader field of its own rate name, a C# quotient over a measured span, a rate helper handed the interval, or an aggregate over a rate — a stored delta under the name is red, and the seven keys whose provenance is another name are rostered with the alias or model quotient each rests on (partial #3653 — item 16, rule 6 C# half) #3784 rule-6 census), McpPageContractTests (both SKUs), FactSourceRegistryTests, McpPayloadContractCensusTests, McpLatestSnapshotStampTests, FleetIdentifierScrubTests, DocCommentHygieneTests, TsqlConventionGuardTests, StoreSqlClockDisciplineTests. Viewer harness: RetentionTierRouterTests, ViewerDailySummarySqlTests, RollupCoverageRoutingTests, ViewerCalendarRetentionPortTests 118/119 — the one red enumerates every type in the Viewer assembly and is the WPF-load limit of this Mac, not a finding.
  • Live, on a throwaway timescale/timescaledb:2.28.1-pg18 (DARLING_TEST_PG, the CI tag), all 11 live tests in that set executed, 0 skipped — including the new DailySummaryNotCarriedLiveTests: two servers, one layout (D0 3 hashes, D1 NOTHING, D2 5 hashes, D3 7, D4 raw only), hourly refreshed over D0..D3, daily over D0 and D3 and never D2. At the daily tier: [D0 3, D2 NULL, D3 7, D4 2], D1 absent, presence [1, 0, 1, 1]. Hourly tier: no NULL, D4 from raw. One restart row (interval 0) planted on D1: the legacy hourly reads D1 NULL (it admits the row), the successor does not name D1 and reads D2 NULL. Through the reader, router and tool on the 100-day-old server: routes Daily by age, rows carry the null, DaysMissing = [D2], the wire's unique_queries is JSON null and days_missing = ["D2"]; the single-day tool on D2 answers its purged envelope (the horizon's vocabulary wins) without throwing on the NULL. On the recent server the supply rule keeps the legacy (asserted through HourlyRelationFor: the successor's first bucket is the 01:00 collection, an hour past the window's midnight start) and days_missing = ["D1"]. Then RepairMaterializationHolesAsync finds exactly query_stats_daily's [D2, D3), closes it, and the same read prints 5 with presence 1; the old cluster (past the scan horizon) still discloses.
  • EXPLAIN (ANALYZE, BUFFERS) of both routed statements on the rig with 41 servers × 30 days: the not-carried member is a Nested Loop Anti/Semi Join over generate_series with Index Only Scan using …_server_id_bucket on the materialization and index-range probes on the source; 2.2 ms daily, 6.4 ms hourly.

Rig torn down.

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; proven against PostgreSQL 18 + TimescaleDB 2.28.1)
  • I have not introduced any hardcoded credentials or server names

Partial #3653 — item 9 (A6, NULL-not-0 below the ceiling)

…er skipped below its ceiling, and names the day in days_missing[] — the hole scan's two probes per server at day grain, on both daily tools, the viewer's day-detail line and the web calendar (partial #3653 — item 9, A6)

The lie: a day at or below a server's rollup ceiling for which the rollup held no row fell out of the daily
summary's LEFT JOIN and was COALESCEd to unique_queries = 0 beside that day's real wait, CPU and deadlock
numbers — the same 0 a measured-quiet day prints. What happened is that the tier never materialized the day
(the pre-outage tail RetentionTierRouter's essay derives; repaired at the next start by #3731, so rarer, not
gone: between the post-resume refresh that moves the ceiling and that start, and past the per-start cap, the
calendar still read the skipped day).

The witness is TimescaleSupport.MaterializationHoleScanSql's own definition, per server, at day grain — no
second one: the routed queries CTE gains a third UNION ALL member emitting (day, NULL) for every day at or
below the server's ceiling where the rollup holds no row for the server AND the rollup's registered source
(MaterializationHoleTargets: the daily's legacy hourly, the hourly's raw query_stats, with the interval-honest
successor's own WHERE carried) holds an admitted row. A server that ran nothing that day has no source row
and is not named. The outer select projects CASE WHEN q.d IS NULL THEN 0 ELSE q.c END (a no-op on raw) and
the presence arm reads q.c so a not-carried day is not a source that holds the day. Readers keep long?;
DailySummaryRangeReadResult.DaysMissing derives from the NULLs; get_daily_summary and get_daily_summary_range
emit unique_queries: null and a trailing days_missing[]; PerformanceCalendarDay.UniqueQueries and
BuildKeyMetricsLine take long? ("not materialized at this tier"); the web calendar becomes a composite that
renders "not materialized" and one notice line from days_missing. Lite has no rollup tier and is untouched
(the shared model's field widening is a no-op there).

Proven live on TimescaleDB 2.28.1 / PG18: the skipped day reads NULL at the daily tier and is listed, the
empty day is absent, the day past the ceiling reads from raw, the hourly that carried everything has no
NULL, the restart-only day is a hole for the legacy and not for the successor, the reader routes the
100-day window to Daily and the wire carries null + days_missing, and RepairMaterializationHolesAsync then
closes exactly the NULL day. Planner: (server_id, bucket) index probes per day, 2 ms over a 31-day window
with a 41-server population.
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 20, 2026 14:08
@erikdarlingdata
erikdarlingdata merged commit 8ff53f4 into dev Sep 20, 2026
6 of 7 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3653-daily-calendar-null-not-0 branch September 20, 2026 14:14
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