Repository navigation
Daily rollups: days an earlier hourly repair left short are rebuilt once after the upgrade (#4716) - #4763
Merged
Conversation
…lready hold, so a partial day does not stand (#4716) An hourly hole repair invalidates the day above it in every daily that already holds a bucket for that day, but only the seam loop chased the daily, and only for days older than the daily policy's window. Once the closed range is whole, each dependent daily (derived from RollupViews and DailyAggregates) now force-refreshes the complete, touched days that have a materialized row, oldest first, at most three per daily per range, falling back to the plain refresh where the engine has no forced form. interval_daily days chain on to daygrain_daily. Failures are logged and never fail the hourly repair. The refreshed days are recorded in DailyBucketsChained, and the seam INFO line now carries HolesRemaining.
…nce after the upgrade (#4716) An hourly hole repair before the fix in the previous change left the day above it partial in every daily that already held a bucket for that day. A day older than the daily policy's 3-day window is never refreshed again, so it stays short and becomes the only copy when the hourly ages out. After the hole repair's completion stamp, on the same connection and while the repair is still marked running, the service now walks each non-frozen daily once: every complete day from the day after its source's oldest materialized bucket up to the daily policy's own window, comparing sum(sample_count) on the two materializations in one statement per day. A daily that holds fewer samples than its source is force-refreshed for that one day, one day at a time, and the healed days chain on to the daily's own dependents. A daily that has no row for a day, or holds more than its source, is left alone. Each daily gets a marker in collect.collector_state (collector partial_daily_heal, state_key the daily's view name, value 4716), written only when its whole walk and every refresh succeeded. A failure logs one warning naming the daily, the day and the SQLSTATE, stops that daily and moves on; cancellation writes no marker. One information line reports every run, including one with nothing to do. Also adds the unit tests the previous change lacked for CompleteDaysTouched and DependentDailiesOf.
… its daily, day and seconds (#4716)
…endent-daily refresh already refreshed, so each day refreshes once and is counted once (#4716)
… holds refreshes it once and counts it once (#4716)
…patterns so the member scan reads the whole declaration (#4716)
erikdarlingdata
marked this pull request as ready for review
September 29, 2026 09:28
This was referenced Sep 29, 2026
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.
Fixes #4716.
Why
Before the fix that stops it (#4739), an hourly hole repair left the day above it partial in every daily rollup that already held a bucket for that day. A day older than the daily policy's 3-day window is never refreshed again, so it stays short, and it becomes the only copy once the hourly rows age out. #4739 stops new cases. Days already left short stay short, so this adds a one-time check that finds and rebuilds them.
What changes
The heal runs inside the start-time hole repair, after the repair's completion stamp, on the same connection and while the repair's running flag is still set. A failure in it never changes the stamp, the repair's summary line, or the service start. Plain-PostgreSQL mode never reaches it.
TimescaleSupport.MaterializationHoles.cs(next to A repaired hourly hole refreshes the days its dependent dailies already hold, so no partial day stands (#4716) #4739's code):PartialDailyHealOrder()lists the non-frozen dailies with their source, derived fromDailyAggregatesandRollupViews. A daily built on another daily comes after it (query_store_stats_interval_dailybeforequery_store_stats_daygrain_daily).PartialDailyHealDays(sourceOldest, utcNow)is the day window: each complete day from the day after the source's oldest materialized bucket, up to but not includingAlignDown(utcNow, 1 day)minus the daily policy's 3 days.PartialDailyDaySamplesSqlreadssum(sample_count)for one day on the source's materialization hypertable and on the daily's materialization hypertable, never the views, in one statement per day, behind the sameOFFSET 0fenceDailyRowExistsAsyncuses. The daily side is read first and the source side only when the daily has a row for that day.PartialDayNeedsRefresh(sourceSamples, dailySamples)is the pure decision.HealPartialDailyAsyncwalks one daily. Each partial day is refreshed on its own, one at a time, withRollupBackfill.RepairAsync(SQLSTATE 42883 falls back toRunSliceAsync, asRefreshDependentDailiesAsyncdoes). Each refresh logs at Information with its duration in seconds and both sample totals. The healed days then chain to the daily's own dependents withRefreshDependentDailiesAsync. A failure logs one Warning (the daily, the day, the SQLSTATE), stops that daily and returns an incomplete outcome. Cancellation propagates.PartialDailyHeal.cs(new, shaped likePlanForceActionDetailScrub): orders the dailies, keeps the marker, adds up the run. Marker:collect.collector_stateunderDarlingObservability.FleetServerId, collectorpartial_daily_heal,state_keythe daily's view name, value4716, written for a daily only when its whole walk and every refresh succeeded. No migration.DarlingWorker.cs:RunPartialDailyHealAsynccalls it after the stamp and writes one Information line per run, including a run with nothing to do: dailies checked, already done, days compared, partial, refreshed, days chained on, failures, seconds. A cancelled run says so; any other failure is one Warning.Two changes to #4739's own path, both in
TimescaleSupport.MaterializationHoles.cs:RefreshDependentDailiesAsyncnow logs each forced daily refresh at Information with the daily, the day and its duration in seconds, the same shape as the heal's per-day line. The existing per-daily summary line stays. The function now returns the(daily, day)pairs it refreshed (the dailies chained behind them included), each added once its refresh succeeds, and its callers count them. A daily that fails partway therefore counts the days it did refresh; before, it counted none of that daily's days.RefreshDependentDailiesAsyncrefreshes the dependent dailies' complete days, and the olderChainedDailyRangechase then refreshed the same days again, soDailyBucketsChainedcounted them twice.RepairRangeAsyncnow returns the pairs its refresh made (empty unless the range closed), andChainDailyAsyncpasses them to the new pure functionChaseRangesAfterRefresh, which returns the chase range minus the days already refreshed for the successor daily, as contiguous whole-day ranges. The chase runs once per remaining range and counts only those days. It still materializes the days the daily holds no bucket for yet, which is what it exists for; its hourly-hole re-scan now runs per remaining range instead of over the whole chase range. If a later range throws, the days already chased stay counted. The catch-up chase (a seam that was already closed) does not go through this and is unchanged.Two calls where the design left a choice:
The live test seeds
collect.query_store_stats(other live tests in this suite already insert its raw rows), throughquery_store_stats_hourlyandquery_store_stats_daily.Refresh safety
The forced daily refreshes (#4739's and the heal's) run one at a time on one connection, only on the maintenance path, and never in parallel with each other or with the heal. How each was checked, by reading the code and
git grep, not by a concurrency test:foreach(the per-daily and per-day loops ofRefreshDependentDailiesAsyncand the per-day loop ofHealPartialDailyAsync), on the repair's own connection. There is noTask.WhenAllor parallel loop in either. The seam chase, which now runs once per remaining range, awaits each range in aforeachinChainDailyAsyncon the same connection.RepairMaterializationHolesAsyncisRunMaterializationHoleRepairAsync(the start-time repair, and the Periodic pass's relaunch when the repair epoch is stale), and the only caller ofRepairMaterializationSeamsAsyncis the retention re-evaluation pass. Neither is a collection cycle._materializationHoleRepairRunningis set at the top ofRunMaterializationHoleRepairAsyncand cleared in itsfinally, and the heal runs before thatfinally. The retention re-evaluation pass (the seam pass) returns early while the flag is set, and the Periodic relaunch checks the flag, so no other repair refreshes an aggregate while the heal runs. A daily's own refresh policy job can still land on the same aggregate;RollupBackfillalready retries the55P03that produces.Cost
Measured on a production store before this change: one statement summing sample_count over 84 days of the query_store_stats hourly took 265 s cold and 19 s warm. The heal reads one day per statement, so each statement takes a few seconds. It runs one statement at a time on the repair's own connection, never on a collection connection, and each daily is walked once: its marker stops the next start from walking it again.
Test plan
All live runs are on a throwaway PostgreSQL 18.6 with TimescaleDB 2.30.1.
Darling.Testsbuilds with 0 warnings and 0 errors, after merging the newestdev(the last merge was clean and brought in two unrelated commits).PartialDailyHealPureTests: 25 of 25 pass. The 20 from before coverCompleteDaysTouched,DependentDailiesOf, the heal's order, the five decision cases, both edges of the day window and the statement's shape. Five are new, forChaseRangesAfterRefresh: days the refresh already refreshed are not chased again and each day is counted once (a four-day seam, the chase capped to three days, the daily holding three of them: 3 refreshed + 1 chased = 4 distinct days, where the old chase ran all 3 of its days again), every day refreshed leaves nothing to chase, nothing refreshed leaves the whole range, another daily's pairs and days outside the chase change nothing, and an edge day shrinks the range from that end. RED first: with the function returning the whole chase range (the old behavior), 3 of the 5 failed (the main case, every day refreshed, an edge day); the other 2 are identity cases and passed on that stub too.PartialDailyHealLiveTests: 1 of 1 passes. Five days are seeded; the day whose daily is short is rebuilt (48 samples become 50, equal to its hourly); a day that already matches keeps a marker value a refresh would erase; a day with no daily row still has none; a day whose daily holds one more sample than its hourly keeps it; the oldest day is not compared. The summary says 1 refreshed, the marker is set for all 7 dailies, the second run finds every daily already done and compares and refreshes nothing, and a cancelled run writes no marker.DailyAfterHoleRepairLiveTests: 1 of 1 passes.FrozenRollupLiveTests: 30 of 30 pass (the 29 existing ones, which include every seam-chase pin onDailyBucketsChained, plus the new one below).Outage_SeamCapSplit_TheDayTheDailyAlreadyHolds_IsRefreshedOnceByTheClosedRangeAndCountedOncepasses. It uses the 30-hour cap-split seam: pass 1 repairs 24 buckets; between the passes the successor daily is refreshed over the seam's first day, leaving a partial day (18 of 24 hours, worker time 16200); pass 2 closes the last 6 buckets,DailyBucketsChainedis 1, and the day is whole (21600, equal to its hourly total). With the chase changed back to ignore the refreshed days it fails: expected 1, actual 2.PartialDayNeedsRefreshforced to false,PartialDailyHealLiveTestsFAILS (expected 1 refreshed, actual 0); restored, it passes. The plant is not committed.EXPLAIN (ANALYZE)of the per-day statement (the shape ofPartialDailyDaySamplesSql, prepared with its two timestamp parameters, on two hypertables with the two columns it reads, 60 days of hourly rows in the source, one day missing from the daily): for that day with no daily row the source side (the scalar sub-select, shown as InitPlan 2, with its aggregate and both scans) readsnever executedand the daily side reads 1 buffer; for a day with a daily row the source side runs (72 rows, 4 buffers).StartupCommandTimeoutTests,StorageCommandTimeoutTests,DocCommentHygiene,LivePostgresCollectionHygieneTests,LiveCleanupConversionRatchetTests,McpPayloadContractCensusTests,AlertReadFailureSurfaceTests: 232 of 232 pass.Darling.Testssuite with no PostgreSQL connection set (live tests skip), on the final tree after mergingdev: 17186 total, 0 failed, 1123 skipped, 1 not run. The first full run had one failure,TsqlConventionGuardTests.TheMemberScan_ReadsEveryDeclarationWhole: its member scan stopped short ofPartialDayNeedsRefreshbecause of theis { }property patterns in that expression body. The member now compares the nullable totals withHasValueandValue(same decision; the pure decision cases still pass) and the guard passes withKnownTruncatedRangesunchanged.git diff origin/dev --statshows six files, 1245 insertions and 20 deletions (the deletions are edited lines ofRefreshDependentDailiesAsync,RepairRangeAsync,ChainDailyAsyncand their comments).CHANGELOG
SECTION: Fixed
ENTRY:
REF:
[Darling store: a repaired hourly hole leaves an existing daily bucket partial, and it becomes the only copy after 90 days #4716]: Darling store: a repaired hourly hole leaves an existing daily bucket partial, and it becomes the only copy after 90 days #4716