Repository navigation
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
Merged
Conversation
…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.
Closed
39 tasks done
erikdarlingdata
enabled auto-merge (squash)
September 20, 2026 14:08
erikdarlingdata
disabled auto-merge
September 20, 2026 14:14
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.
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'squeriesLEFT JOIN and the outer select'sCOALESCE(q.c, 0)printedunique_queries = 0beside 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 oneRetentionTierRouter's essay derives andTimescaleSupport.RepairMaterializationHolesAsync(#3731) repairs: a service down longer than a refresh policy'sstart_offsetresumes with a refresh that opens atnow - 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.
MaterializationHoleScanSqlcalls 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 ownWHEREadmits. The routedqueriesCTE gains a thirdUNION ALLmember that asks the same two questions of every day at or below the server's ceiling, withserver_id = $1on 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 offTimescaleSupport.MaterializationHoleTargets— the repair's own target list — so the daily tier probesquery_stats_hourly(its hierarchical source, 90 days), the hourly tier probes rawquery_stats(4 days), and the interval-honest successor's probe carriessample_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 anArgumentExceptionbefore it reaches the store. The outer select projectsCASE 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 readsq.crather thanq.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 stayspurged, notpast_horizon). The day joins the spine throughquerieslike 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 byc IS NULLalone 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 takesIndex Only Scan using …_server_id_bucketfor the rollup probe andidx_query_stats_time (server_id, collection_time)/ the hourly'sserver_id_bucketfor the source probe; the whole 31-day statement runs in 2 ms (daily) / 6 ms (hourly).Payload shape, one key name.
DarlingHealthReader.DailySummaryReadRow.UniqueQueriesand the viewer'sDailySummaryRow.UniqueQueriesbecomelong?; both readers keep the NULL at ordinal 3 (the0Lthey substituted is the lie).DailySummaryRangeReadResult.DaysMissingderives from the rows with a null count, so the list and the NULLs cannot disagree.get_daily_summaryandget_daily_summary_rangeemitunique_queries: nulland a trailingdays_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 (DailyHealthSignalshas 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' sharedPerformanceCalendarDay.UniqueQueriesbecomeslong?andDailyHealthBandCalculator.BuildKeyMetricsLinetakeslong?, printingnot materialized at this tierfor 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'sdays_missing[]renders as one notice line above the grid, and theunique_queriescell renders "not materialized" for a null; it fetches the one read thetable()descriptor did (theNoTab_FetchesTheSameReadTwicecomposites map carries it).DailySummaryRetention.StateForwas read and is not the home for a fourth word.DataStateis 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
PerformanceCalendarDay.UniqueQueries,BuildKeyMetricsLine's parameter) is a no-op there — Lite's callers hand alongand compile unchanged; Lite'sDailySummaryRow.UniqueQueriesstayslongand its SQL keeps seven day-arms. The only Lite-side edit is theDailyHealthBandTeststwin, which gains the same null-case test as Darling's so the two files stay identical as their headers promise. Lite'sget_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.PgMigrations/StorageVersiontouch.query_stats_dailyhierarchical 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.DarlingMcpInstructions.cs/DarlingWebEndpoints.cscatalog rows (lane 6's files): theget_daily_summary_rangeinstructions row does not yet namedays_missing. The tool[Description]s carry the vocabulary; the rows are a one-line follow-up for the lane that holds those files.Which component(s) does this affect?
DailyHealthBandTeststwin only)How was this tested?
Builds: Storage, Service, Viewer (with Common and Ui as dependencies),
Darling.TestsandLite.Tests,-c Release -p:EnableWindowsTargeting=true— 0 new warnings (the two pre-existing xUnit analyzer warnings on dev'sConfigChangeTraceAnchorTests.cs:94andLogTailOverlapThresholdPinTests.cs:95/DefaultTraceEventsCollectorDefinitionTests.cs:211are untouched and not this lane's files).node --checkonserver-tabs.js.Executed here, not only compiled — a net10.0 harness named
Darling.Testsover the built product assemblies, and a net10.0-windows harness (WindowsDesktop framework stripped from the runtimeconfig) for the Viewer-dependent pins: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 +DaysMissingderivation + band-does-not-move, every consumer's null-keeping read and one-key spelling), the updatedRetentionTierRouterTests(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 namedays_missing),ServerPageTabsTests(the composite in the duplicate-read map), theDailyHealthBandTeststwin, 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,ViewerCalendarRetentionPortTests118/119 — the one red enumerates every type in the Viewer assembly and is the WPF-load limit of this Mac, not a finding.timescale/timescaledb:2.28.1-pg18(DARLING_TEST_PG, the CI tag), all 11 live tests in that set executed, 0 skipped — including the newDailySummaryNotCarriedLiveTests: 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'sunique_queriesis JSON null anddays_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 throughHourlyRelationFor: the successor's first bucket is the 01:00 collection, an hour past the window's midnight start) anddays_missing = ["D1"]. ThenRepairMaterializationHolesAsyncfinds exactlyquery_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.generate_serieswithIndex Only Scan using …_server_id_bucketon the materialization and index-range probes on the source; 2.2 ms daily, 6.4 ms hourly.Rig torn down.
Checklist
dotnet build -c Debug)Partial #3653 — item 9 (A6, NULL-not-0 below the ceiling)