Repository navigation
Daily Summary calendar caches closed days for an hour (#4232) - #4307
Merged
Merged
Conversation
DailySummaryRangeCache<TRow> holds the closed-day portion of one daily-summary range read as one block for an hour; a refresh inside that hour re-runs the statement only over the still-open days (today, and yesterday during a two-hour post-midnight grace) and joins them to the block. Lives in Storage next to DailySummarySql so both the WPF viewer and the service can share it without seeing each other. Checked ruling item 7 first: no column DailySummarySql.RangeSqlFor returns depends on more than its own day (every CTE groups strictly within its own WHERE-bounded rows; no window function spans days), so there is no band or rank to protect from the cache. TRow is still the row from BEFORE any later banding/threshold/horizon stamp, so callers re-apply that judgment fresh after every read, cached or not. Unit pins (no database): cached-vs-fresh equality across a late row landing for yesterday inside the grace window, a second refresh reading only the open sub-range through the runRange seam, the one-hour block TTL and two-hour grace with a moved clock, an explicit end time skipping the cache every time, and a dedicated pin for the case where the grace boundary crosses mid-TTL (proved against the unguarded shape: without the closed-end boundary check, yesterday's row is silently dropped from the join). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
DarlingHealthReader.GetDailySummaryRangeAsync (get_daily_summary_range, shared by the web /api/read mirror and MCP clients) and ViewerDataService.ReadDailySummaryRangeAsync (the WPF Daily Summary calendar) now route their range reads through DailySummaryRangeCache instead of reading every day fresh on every refresh. Both resolve the tier-routed SQL text ONCE per call and reuse that same text for every sub-range the cache asks for (including the open-days-only re-read), so a 30-day range that routes to the hourly rollup cannot have its "today" slice quietly re-resolve to raw. Each reader splits its row read into a raw parse (what gets cached) and a banding/retention stamp (RateTiers/ReferenceUtc/DataState/ RetentionHorizon for the service, DataState/HealthBand for the viewer) applied fresh to every row, cached or not, on every call. The service keeps ONE static cache (DarlingHealthReader.RangeCache), keyed in part by the NpgsqlDataSource reference so distinct stores never collide. The viewer keeps a per-instance cache field, matching the existing GetRollupAvailabilityAsync pattern. get_daily_summary_range now passes asOfNow: as_of is null, so an explicit as_of always reads live per ruling item 5. Also: a pre-existing live test (DarlingDailySummaryRangeTests) shared one server ID between an empty-store check and a seeded-range check; the empty check now warms the cache's block for that server/range first, so the seeded read (same block, same hour) saw the stale empty block instead of the just-seeded rows. Split it onto its own server ID -- the "nothing ever collected" case does not need to share infrastructure with the gap-and-band case, and doing so was papering over two different stores as one. Added a new live pin that seeds a closed day and an open day, reads, seeds a second row on each, reads again, and asserts the closed day is unchanged (served from the cached block) while today picks up its new row (never cached). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
McpFilterSemanticsLivePostgresTests.DailySummary_StopsPaintingPurgedDaysGreen mutates a closed day (45 days old) between two range reads of the same server/range through the same store object, then asserts the second read reflects the mutation immediately. The #4232 cache serves that read from its one-hour block instead, by design (a real backfill shows up within the hour, not on the next statement) -- so the test was testing the cache's own contract, not a product bug. Add DailySummaryRangeCache<TRow>.Clear() (public: Storage's InternalsVisibleTo reaches Darling.Tests only, not the service) and DarlingHealthReader.ResetRangeCacheForTests() (internal, visible to Darling.Tests through the service's own InternalsVisibleTo). Call it between each closed-day mutation and the re-read that expects to see it, in every test the census found doing this: - McpFilterSemanticsLivePostgresTests: the deadlock insert before the `survived` read, and the deadlock delete before the `shortened` read (same test, two separate mutate/reread pairs on the same ghost day). - DailySummaryNotCarriedTests.ASkippedDayBelowTheCeiling...: the repair pass doesn't touch the old server's rows, but the first read at the top of the test had already warmed that exact range's block, so the closing assertion was being served from cache and never reaching the store at all -- reset restores it as a real check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
…-closed-day-cache
…4232) The full Darling.Tests.exe run flagged DocCommentHygieneTests.NoMember CarriesTwoStackedSummaryBlocks: GetDailySummaryRangeAsync's own summary and RangeCache's summary sat back to back with no blank line between them, so both attached to RangeCache as trivia. Predates this branch's cache work (present in a75cd1d, before this lane's changes); reordered so each member carries exactly one summary block, keeping the new ResetRangeCacheForTests doc in place. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
DailySummaryRangeCache never removed a block: the key carries the range, so every days_back value, every server and every day added an entry nothing replaced. Trim expired blocks (past BlockTtl) and cap the total at MaxBlocks (1024), evicting the oldest first, on every cache miss. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Owner
Author
|
Pushed the bound fix to The change
Tests
Test totals (all against commit d012ac8, Debug build, 0 warnings / 0 errors)
Full suite not run here; CI runs it. Skipped the plain-English pass on this comment per the brief. 🤖 Generated with Claude Code |
erikdarlingdata
marked this pull request as ready for review
September 25, 2026 16:53
erikdarlingdata
enabled auto-merge (squash)
September 25, 2026 16:53
This was referenced Sep 25, 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.
Closes #4232.
Why
The Daily Summary calendar (WPF, every 1-minute tab refresh) and
get_daily_summary_range(web Overview tabthrough
/api/read, MCP clients) recompute the whole displayed range on every poll, even thoughevery day but today (and briefly yesterday) is closed and its rows do not change. The issue measured this at
4.4 s cold / 0.41 s warm per poll for a 25-day range on a production store. Lite's own month read measured
94.6 ms warm on the same shape of query, already under the bar the issue set, so Lite needed no change
(issuecomment-5834845077) -- this PR is the whole fix for #4232.
What changes
DailySummaryRangeCache<TRow>inPerformanceMonitor.Darling.Storage, next toDailySummarySql. Itholds the CLOSED portion of one range read as one block for an hour. A refresh inside that hour re-runs the
statement only over the days still open (today, plus yesterday during a two-hour post-midnight grace for a
late collector run) and joins them to the block. After an hour, one refresh recomputes the whole range and
repopulates it. The caller resolves the tier-routed statement text ONCE for the range as a whole and the
cache reuses that same text for every sub-range it asks for, including the open-only re-read, folded into the
cache key -- this stops a sub-range from routing to a different retention tier than the range it is part of.
d012ac8c). On every miss, it first drops every block older thanBlockTtl(onehour), then evicts the oldest blocks until fewer than
MaxBlocks(1,024) remain, before it adds the newblock. Both removals use the
TryRemove(KeyValuePair)overload, so a block that another caller justreplaced is never removed. Before this, the key carried the range, so every distinct server/range/SQL
combination added a block that nothing removed.
DarlingHealthReader.GetDailySummaryRangeAsync(service) routes through one staticDarlingHealthReader.RangeCache, shared by the web viewer and MCP clients.ViewerDataService.ReadDailySummaryRangeAsync(WPF) keeps its own per-instance cache. Both readers cache the RAW per-day row from before the
RateTiers/ReferenceUtc/DataState/RetentionHorizonstamp and re-apply that stamp fresh to every row,cached or fresh, on every call -- a deadlock-rate-threshold setting or the purge horizon can move inside the
cache's one-hour lifetime.
design. A row that lands late for a closed day -- an outage catch-up, a backfill -- shows within the hour,
not immediately, where it showed immediately before this change. An explicit
as_oftimestamp always readslive and is never served from the cache.
DailySummaryRangeCache<TRow>.Clear()(public -- Storage'sInternalsVisibleToreachesDarling.Testsonly, not the service) andDarlingHealthReader.ResetRangeCacheForTests()(internal, visibleto
Darling.Teststhrough the service's ownInternalsVisibleTo), a test-only seam so a live test can forceits next read to hit the store instead of a block an earlier call in the same process warmed.
GetDailySummaryRangeAsync'ssummary and
RangeCache's summary sat back to back with no blank line between them, so both attached toRangeCacheas one member's trivia (DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks).Reordered so each member carries exactly one summary block.
Census (#4232 item 2): every test reaching the cached range readers
Grepped every test file reaching
GetDailySummaryRange,GetDailySummaryRangeAsync, orReadDailySummaryRangeAsyncacrossDarling.Tests(the pattern also catches theAsyncspelling as asubstring):
DailySummaryNotCarriedTests.cs,DailySummaryReadShapeTests.cs,DarlingDailySummaryRangeTests.cs,McpFilterSemanticsLivePostgresTests.cs,McpToolGuideHeads.SqlCore.cs(a doc-comment mention, not a call),ViewerCalendarRetentionPortTests.cs. Also checked every single-dayGetDailySummary/GetDailySummaryAsynccall site, since that path funnels through the same cached range reader over a one-day window
(
DarlingHealthReader.GetDailySummaryAsynccallsGetDailySummaryRangeAsyncfor[day, day+1)).Found and fixed three call sites that mutate a closed day's rows and then read the same server/range again
through the same store object -- each now calls
ResetRangeCacheForTests()between the mutation and there-read, with a comment naming the one-hour contract and #4232:
McpFilterSemanticsLivePostgresTests.DailySummary_StopsPaintingPurgedDaysGreen_AndPublishesTheHorizon:inserts a deadlock on a 45-day-old closed day, then re-reads the same 60-day range -- this is the failure
the brief named. The same test later DELETEs that row and reads the range a third time; same pattern, same
fix, even though the assertions on that third read do not happen to check the specific value the delete
changed.
DailySummaryNotCarriedTests.ASkippedDayBelowTheCeiling_ReadsNullAndIsNamed_AnEmptyDayIsAbsent_AndTheRepairClosesIt_AgainstDevPostgres:the materialization repair does not touch the old server's rows (the test's own comment says so), but the
first read of that exact range had already warmed the block, so the closing assertion was being served from
cache and never reaching the store at all. The reset restores it as a real check.
Checked and found no fix needed:
DarlingDailySummaryRangeTests.GetDailySummaryRangeAsync_ClosedDayCache_SecondReadDoesNotSeeANewRowOnAClosedDay_ButDoesOnToday-- this IS the cache's own pin, deliberately exercising the one-hour behavior.
DarlingDailySummaryRangeTests.TheCalendar_BandsEachDaySeparately_ShowsCollectionGaps_AndAnchors-- itsanchored re-reads pass an explicit
as_of, which bypasses the cache entirely (ruling item 5); itsnever-collected check already has its own server ID (this PR's earlier commit).
DailySummaryReadShapeTests.EveryDailyRead_ProbesCoverageOnce_HoweverManyRace_AgainstDevPostgres-- racesreads, no mutation between them.
ViewerCalendarRetentionLivePostgresTests.Calendar_BandsAPurgedDayNoData_AndJudgesAgainstTheMcpHorizon_AgainstDevPostgres-- the two
DarlingHealthReaderreads use differentfromDatevalues, so they are different cache keys, andthe viewer's own per-instance cache reads the seeded rows on its first (and only) call.
DarlingMcpHealthToolsTests'sGetDailySummarycall sites -- one read per scenario, no mutate-then-reread.EXPLAIN (ANALYZE, BUFFERS): old path vs. new path (#4232 ruling item 9)
Seeded one server on the rig's
probedatabase (schema copied from the migrateddarlingtest, sameTimescaleDB/UTC rig CI uses) with 31 closed days of
wait_stats/collection_logat a 5-minute collectioncadence, 15 positive-delta wait types per run (133,920
wait_statsrows, 8,928collection_logrows), plus apartial open "today" at the same cadence (2,880 more
wait_statsrows). RanDailySummarySql.RangeSql(theraw, untiered statement -- the same text
GetWindowSignalsAsync's single-day read already runs, and a freshrig with no continuous aggregates routes here anyway) through
PREPARE/EXPLAIN (ANALYZE, BUFFERS) EXECUTE,twice each, warm numbers below:
wait_statsscanidx_wait_stats_time, 2,880 rowsA roughly 46x drop in execution time and 31x drop in buffer hits at this seed, and the planner switches from a
full sequential scan of every row this server has ever reported to an index-bounded scan of the one open day.
The reduction is structural, not tied to this seed's size: the new path's cost is bounded by one day's rows
regardless of how wide the displayed range is or how much history the server carries, so a busier server or a
wider range only widens the gap.
Test plan
Darling.Tests.DailySummaryRangeCacheTests(9 tests, no database), unchanged from this PR's earliercommit: all pass.
dotnet buildon Storage, Service, Viewer and Darling.Tests: 0 warnings, 0 errors.d012ac8c), both shown to fail with the eviction code removed:GetRangeAsync_ExpiredBlock_EvictedOnNextMissForAnotherKey_AndOldKeyMissesAgainandGetRangeAsync_MoreThanMaxBlocksDistinctKeys_CountStaysCapped_OldestKeysDropped(1,034 distinct keys,the count stays at 1,024, and the oldest key misses again).
DailySummaryRangeCacheTests11/11,DocCommentHygieneTests77/77 pass.McpFilterSemanticsLivePostgresTests(4 tests, including the fixedDailySummary_StopsPaintingPurgedDaysGreen_AndPublishesTheHorizon): all pass against the rig.DailySummaryNotCarriedTests,DarlingDailySummaryRangeTests,DailySummaryRangeCacheTests,ViewerCalendarRetentionPortTests,ViewerCalendarRetentionLivePostgresTests,DailySummaryReadShapeTests(26 tests): all pass against the rig.
git merge origin/dev: clean, no conflicts.Darling.Tests.exesuite, once, against a freshly createddarlingteston the UTC rig:Total: 14059, Errors: 0, Failed: 3 (at first pass), Skipped: 49 (environment-gated, e.g.
DARLING_TEST_PGRUNTIME/jsonlog targets this lane did not set), Not Run: 1, Time: 960s. All 3 failuresinvestigated:
-
DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks-- real, fixed above (own commit).-
ServerListAndSummaryPlanShapeTests.TheShippedReads_TouchFarFewerChunks_AndReturnTheSameNewestCollection_AgainstDevPostgresand
CaptureDownChunkOrderTests.TheShippedRead_ExecutesOnlyTheNewestChunk_AndTheNewestRunDecides_AgainstDevPostgres-- both plan-shape/chunk-order tests unrelated to the daily-summary files this PR touches. Re-ran both,
plus the doc-comment test, alone on a freshly recreated
darlingtest: all 81 tests in those threeclasses passed (
Total: 81, Failed: 0). Not reproducible in isolation, so the cause was cross-teststate from the single big run, not this PR's code; not filed.
What the coordinator should double-check
they recur in CI, since a flake that only shows up under the full suite's ordering/load is exactly the kind
the wave-boundary census watches for.
CHANGELOG entry
SECTION: Fixed
ENTRY:
get_daily_summary_rangecache closed days for an hour ([Daily Summary calendar caches closed days for an hour (#4232) #4307]) - The WPF Daily Summary calendar (every 1-minute refresh) andget_daily_summary_range(the web Overview tab and MCP clients) recomputed every day in the displayed range on every poll, even though days before today do not change. A refresh now recomputes only today (and briefly yesterday, for a late collector run) and reuses the rest from a one-hour cache. A row that lands late for a closed day -- an outage catch-up, a backfill -- can take up to an hour to show, where it showed immediately before; an explicitas_oftimestamp still always reads live. The cache holds at most 1,024 ranges, and drops entries older than an hour the next time it computes a range.REF:
[Daily Summary calendar caches closed days for an hour (#4232) #4307]: Daily Summary calendar caches closed days for an hour (#4232) #4307
🤖 Generated with Claude Code
https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ