Skip to content

Re-derive the refresh grid so contention cannot depend on list position, and re-derive the compression grid with it - #3178

Merged
erikdarlingdata merged 15 commits into
devfrom
fix/3174-refresh-grid-derivation
Sep 8, 2026
Merged

Re-derive the refresh grid so contention cannot depend on list position, and re-derive the compression grid with it#3178
erikdarlingdata merged 15 commits into
devfrom
fix/3174-refresh-grid-derivation

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Closes #3174. Absorbs and supersedes #3168 (its four commits are merged in here, so its 896 census, its constant and its doc land with this).

The refresh grid's phase map was index % RefreshPhaseSlots * RefreshPhaseStepMinutes, so whether two policies collided was a property of where they sat in a list: thirteen policies over four slots collide by counting alone, and which ones collided depended on order. The three collect.query_stats consumers sat at positions 0, 3 and 9 and were distinct only by accident. Four policies including the heaviest all started on :00.

The map is injective now. HeaviestHourlyRefreshView is answered by identity; every other view takes its own consecutive minute in the light band. There is no modulus anywhere, so two policies cannot share a residue — thirteen policies hold thirteen distinct minutes at any list length the band can hold, in any order, with anything inserted anywhere.

The derivation, as a method

The hour is three bands, and every boundary comes from a measurement or from the catalog:

quantity derived from today
LightRefreshStepMinutes chosen: one policy per minute, the finest the grid has 1
LightHourlyRefreshCount HourlyRefreshPhaseOrder.Count - 1 12
CompressionPhaseGuardMinutes ceil(OtherHourlyRefreshObservedCeilingSeconds / 60) 4
HeaviestRefreshStartMinute (LightCount - 1) * step + guard 15
CompressionPhaseMaxPerMinute chosen: the spread the shipped grid has always produced 3
CompressionPhaseBandMinutes ceil(HypertableCount / MaxPerMinute) 24
HeaviestRefreshWindowMinutes the remainder: 60 - start - band 21

Two chosen inputs, everything else derived, and MinutesInHourlyCadence comes off HourlyRefreshScheduleSpan rather than a literal 60. The three bands are asserted to partition the hour — stronger than the old slot * slots == cadence, which went silently short for any step that did not divide 60.

The heaviest refresh's window is a remainder, not a size. Nothing in its derivation consults the ceiling it has to hold. Whether the window the hour can spare is wide enough is an assertion (ceiling < RefreshSlotWarningSeconds), so a ceiling that outgrows the hour goes red instead of quietly re-sizing the grid around itself — and the repair is then fewer compression minutes, a cheaper refresh, or a longer cadence for that one aggregate, because there is no wider window to take.

That also settles where the hour's spare minutes go without anyone choosing: the guard clears the light ceiling as surely at four minutes as at seven, and the compression band spreads the same hypertables at 3 per minute anywhere from 24 minutes wide to 27 — so the remainder lands on the only band where a minute changes an answer, at 50 s of watch-line lead time each.

Resulting grid: refreshes on :00:11 and :15, compression on :36:59.

Why no step change was available, with a constraint nobody had named

Other * 4 < Heaviest is red at every step because it contains no step term, and at 896 s against 226.8 s the ratio is 3.95x — so both prose claims it pinned ("under a quarter of it", "more than 4x") were false. That guard is gone, not renumbered, because the ratio was never what the geometry consumed. What the geometry consumes is a chain, and it is what the tests assert now:

OtherCeiling  <=  guard * 60  <  HeaviestCeiling
   226.8      <=     240      <      896

A light refresh fits inside the guard band; the heaviest one does not. That is why one refresh gets a window no compression minute may enter and the rest are treated as occupied only for the guard band after their start.

And a seventh consequence of a wider step, not counted on #3172: RefreshSlotPercentOfHourlyCadence is 100 / RefreshPhaseSlots, which is 25 only while the hour divides into four. Every available wider step — 20, 30, 60 — moves it to 33, 50 or 100, and V57 has already applied DEFAULT 25 to config.config_alert_settings.store_job_cadence_warn_percent on every live store. DarlingSelfAlertTests pins the C# seed equal to that rung's text precisely because the store column wins on a fresh store, so a moved percent means a new rung. Two independent kills on every uniform step, not one.

The knob therefore stays at 25 and its derivation is broken rather than its value moved, with the reason recorded on the constant: a non-uniform grid has no single slot for a percent of cadence to name, and the tightest band is one minute. What the derivation bought is kept as an assertion — the knob fires at 900 s, inside the 1,260 s window, so it still speaks before #3035's precondition is false. The tightness half ((pct + 1) * slots > 100) is gone with the uniform slot count it was expressed over; the knob now fires earlier than it strictly has to, which is the safe direction.

The compression grid is re-derived, not renumbered

Its minutes were the second half of each uniform slot, and a grid with no uniform slot makes those minutes meaningless. The rule is unchanged — no compression minute may start while a refresh is running — and it is now one predicate over the shipped grid: a minute is clear when it is at least its own band past every refresh start, where the band is the guard for a light refresh and the whole window for the heaviest. Writing the exclude-whole decision as "its band is its whole window" keeps it a decision rather than a special case, and the walk goes through RefreshPhaseMinutesFor, so the two cannot drift.

It produces a contiguous tail band because the refreshes are contiguous too, and the band's width comes from the catalog rather than from whatever the refreshes left over — so the re-derivation moves where compression runs without changing how concentrated it is, which is the one property #3112's midnight band is sensitive to (24 minutes before, 24 after; per-minute ceiling 3 before, 3 after).

Which band opens the hour is a decision, not a layout preference. The guard is one-sided by design, so a compression policy on the hour's last minute is still holding its AccessExclusiveLock when the next hour's grid opens, and whichever refresh opens the hour is the one that waits behind it. Opening with the light band puts that wait on a policy with a 226.8 s ceiling and no window to fit inside, and leaves the heaviest refresh starting a full light band clear of the previous hour's compression.

The light ceiling: 226.8 s, midnight regime included

Re-derived by the read HeaviestHourlyRefreshObservedCeilingSeconds already names, pointed at the other twelve policies — timescaledb_information.job_history, one row per run, read at 2026-09-08 14:57Z, same positional exclusion rule.

Both censuses are ONE STORE'S, and that is a precondition of the read rather than a caveat about the workload (#3175). job_history only records executions where timescaledb.enable_job_execution_logging is on; it is off by default, and the GUC is set by the v1 postgresql.conf block whose marker EnsureConfAppended finds already present on any pre-existing cluster — so a cluster predating the block never gains it and cannot be healed into it. Measured on two stores running the same binary: the older has the GUC absent, effective off, source = default, and 1 job_history row for 110 jobs; the newer has 39,020. A maximum over that view on an older store returns zero rows and reads as "no run exceeded the line." So neither the 594 → 778 → 896 series nor the 226.8 s figure is a fleet reading, and both are now scoped that way in the constants' own docs. #3175/#3177 has since merged and gives the GUC its own marker, so existing stores heal — which does not widen either population, because both reads predate the heal. A later census could be broader and would have to say so rather than inherit this one's scope. No shipped read touches job_history — every product surface uses job_stats, deliberately — so the gap is in what an investigation can ask, not in what the product reports.

This store's positive control is the census itself: 874 rows for twelve policies over three days is not what an unpopulated view looks like.

runs (post-boundary, 12 views) 874
removed by the succeeded/finish filter 0 (874 of 874)
maximum 226.8 s
95th percentile / median 42.2 s / 0.8 s

The two largest runs are both query_store_stats_hourly at 00:30: 226.8 s on 2026-09-08 against 160.4 s on 2026-09-07, 41% higher night over night. The third-largest is 140.1 s, at 13:45:00 on the boundary day — the same reading the old constant's 140 came from, reproduced as an ordinary post-boundary member, which is what says the method is the same one rather than a new one that happens to agree.

The midnight regime is IN, and the constant's own doc is why. It used to escape scrutiny on "NOTHING IS SIZED FROM IT … this figure only says how much margin that derivation happens to leave". A readout of a margin has to include the runs where the margin was consumed, or it stops being a readout of anything; removing the runs that fired a pin is the re-type-a-band-to-pass move one constant over. What the doc gains is the mechanism, and what changed underneath is that it is sized from now: the guard band is this figure rounded up to a whole minute, so the assertion became the requirement instead of a multiple of it.

The margin is the rounding, and it is 13.2 s. That is thin and it is stated rather than dressed up. What it buys is that the margin can only be consumed by the light refreshes actually getting slower, which goes red, where a multiple of a ceiling could be satisfied by a guard that had stopped covering anything. A wider guard is not free: every minute of it comes out of the window that has the measured growth series behind it.

The 874-run list is not republished — a list that long stops being re-derivable by reading and starts being a wall of digits — and that is stated as the limitation it is. What is pinned instead: the maximum against the constant, the run count against the exclusion control, the view count against the product's own LightHourlyRefreshCount, the percentile gap against the two quantiles, the growth percentage against the two readings, the third-largest against the second, and the guard band and its rounding margin against the constant. Nine new pins, five drift-swept.

Two orderings that inverted, said plainly

#3044's watch line now fires after #2136's knob, and that is forced. It was 750 s against the knob's 900 s; the window the hour can spare puts it at 1,050 s. The watch has to clear 896 s and the knob is frozen at 900 s by V57, and 896 < window * 50 < 900 has no integer solution — so no geometry restores the old order while the ceiling stands where it does. The cost is real: an operator sees the cadence alert first, and #2136's documented remedy ("extend the job's schedule_interval") is wrong for this job, because the hourly schedule interval is also its end_offset. #3044's line still carries the correct remedy and now arrives second. Pinned in its new direction so it cannot drift back silently; not fixed here, because moving the knob needs a rung.

The rejected watch-line alternative's rejection had to be re-taken, not restated. Slot less one guard band was 480 s and sat BELOW the recorded ceiling — a line under the ceiling warns on the very run the grid is sized against, and that ordering was the whole of its rejection. Shrinking the guard from half a uniform slot to the light ceiling raised it to 1,020 s, which now sits ABOVE the ceiling. Both lines clear it, they are 30 s apart, and lead time argues mildly for the lower one. What rejects it instead is coupling, which the old geometry could not have exposed: the guard is derived from a measurement of the other twelve policies, so the alternative would move the heaviest refresh's watch line whenever a light refresh got slower. Five sixths of the window depends on the window and nothing else. Both derivations are asserted, and the inversion itself is pinned.

#3168's six red tests

All six are green, and none needed anything other than geometry — every condition is the one #3168 left in place.

method condition now
TheDerivedFiguresAreExactlyStateable_AndTheConstantIsThePopulationMaximum a population reading at or past the watch line; Ceiling < WatchLine 896 < 1050; population max is 896
NoCompressionMinuteStartsWhileTheHeaviestRefreshIsStillRunning ceiling < RefreshSlotWarningSeconds; the gap literal 896 < 1050; gap 26 → 17
TheRefreshSlotClassifier_BandsTheLiveReadings_... Classify(ceiling) == InsideSlot InsideSlot
TheRejectedWatchLineAlternative_... the two orderings re-taken, see above
TheRefreshSlotReading_CarriesItsOwnVerdict_... atTheCeiling.Headroom == InsideSlot InsideSlot; 306 → 364 clear, 99.6% → 71.1%
TheRefreshSlotLogLine_IsLeveledByBand_... the line at the ceiling is Debug: Debug:

#3168's "at least 1076 s" was off by one and is fixed to 1,077. 1076 * 5 / 6 = 896, which is not above 896, and ClassifyRefreshSlotHeadroom warns at >=. Computed rather than reasoned: the smallest s with s * 5 / 6 > 896 is 1,077, and the smallest whole-minute window is 18.

The latent defect

TheRefreshSlotReading_...ReportsOverrunAsNegativeHeadroom probed overrun with a frozen literal 1200 and asserted -300 — a bound expressed over the grid, written as a value. It fails in the worst way available: at a 20-minute geometry 1200 is the window, headroom becomes 0, and re-typing the expected figure to match would have left the case green while it had stopped testing negative headroom at all. It is derived now — one sixth of the window past the wall, the same fraction the watch line uses, so no second chosen number — with the overrun asserted strictly positive so a degenerate geometry cannot make the case vacuous.

No other bound-written-as-a-literal was found in the grid. Every figure this diff touches is either a literal beside its own identity (so a frozen derivation is loud) or derived outright.

One pin of a different shape was found and answered rather than filed. The #3044 warning line names timescaledb_information.job_history as the route that can answer a maximum question, and a test pins that it does. That pin cannot tell an operator that following the pointer may return nothing — which is exactly what happened to get_store_metrics' description pin, green since #3119 while that route answered nothing on older clusters. The line now names the condition as well as the route, and that clause is pinned. Strictly beyond the grid; taken because the alternative is a Warning that points an operator at an empty relation, and the line's text is already being edited in this PR.

What this does NOT fix

It does not fix #3112's midnight band, and nothing here should be read as claiming to. That band is the daily chunk-close burst meeting the grid at the shared midnight boundary: at every other hour the compression ticks find nothing eligible and finish in seconds, and at 00:00Z every hypertable's newest 1-day chunk closes at once. A grid redesign changes where jobs sit, not how much work midnight carries. The re-derivation is deliberately neutral on concentration — same 24 minutes, same 3 per minute — so it neither helps nor hurts that band. Whether the chunk-close needs its own pre-midnight window is a separate question.

It also does not address the trend. The heaviest refresh went 594 → 778 → 896 s over three closed days. The window buys clearance (364 s to the wall, 154 s to the watch line, against 4 s and −146 s on a 15-minute slot) and the watch line now reports something the sizing figure does not already say. It does not make the refresh cheaper.

Verification

Darling.Tests targets net10.0-windows and cannot run on macOS, so the actual shipped test filesTimescaleSupportTests.cs, TimescaleContinuousAggregateTests.cs, RefreshCeilingProvenancePinTests.cs, DarlingSelfAlertTests.cs, unmodified, plus their real helpers — were compiled into a net10.0 xunit.v3 suite named Darling.Tests (so InternalsVisibleTo still applies) and run: 211 total, 0 failed, 0 errors, 17 skipped (every one live-Postgres gated), 0 not run. RefreshCeilingProvenancePinTests reads the real TimescaleSupport.cs through [CallerFilePath], so the prose pins ran against the shipped file. The live-Postgres and Windows-only paths are CI's to arbitrate.

Red-proofed by mutation, each applied to the committed tree, confirmed applied by a 1 1 numstat plus a content check, rebuilt (build failure hard-aborts the case), and restored:

mutation tests red
reintroduce a modulus in the phase map 5, incl. both distinctness pins and the rotation control
guard rounds down, so it stops covering the light ceiling 8
per-minute spread 3 → 2, starving the window 13
the bands no longer tile the hour 9
the cadence knob moves off the V57 default 3
clamp negative headroom at zero 1 — exactly the overrun test
flip the alternative's ordering word in the prose 2, incl. the drift sweep
restate the light ceiling as the old 140 8
restate the census run count / view count / growth % / rounding margin / third-largest 1–2 each

The rotation control is worth naming, and so is the fact that it took two goes. The contention test walks every rotation of the phase order and requires as many distinct minutes as there are policies. It first re-implemented the counting rule inline, because the public map can only be called at the one order HourlyRefreshPhaseOrder has — which proves the rule injective under permutation and leaves the shipped method exercised at exactly one order. That is the "a test that agrees with any derivation" failure one layer down, and it is the failure this grid exists to remove, so RefreshPhaseMinutesFor gained an internal overload taking the order and the loop calls the shipped map against each rotated slice. Only the ORDER is a parameter — the geometry still comes from the registry, so the seam cannot fabricate a different grid, and the two overloads are pinned equal at the shipped order. Raised by review.

And the control written to guard that was itself too weak. Assert.NotEqual(unrotatedMinutes, rotatedMinutes) does not prove the rotation moved anything: an overload that ignored its order parameter still returns a permutation of the unrotated minutes, so the sequences differ while nothing moved — and that mutation passed. It now asserts the minute a NAMED view gets changes, and the mutation (iterate HourlyRefreshPhaseOrder instead of order, which is precisely the defect the inline copy embodied) reddens exactly HourlyRefreshPhases_AreDistinctForEveryRelationTwoPoliciesContendFor.

CHANGELOG

Not edited here — entry text for the coordinator to batch:

- Re-derived the TimescaleDB hourly refresh grid so contention between refresh policies cannot depend on
  where a view sits in a list: every hourly policy now starts on its own minute of the hour, and the
  compression phase grid, its guard band and the heaviest refresh's window are each derived from a
  measurement or from the collector catalog rather than from a uniform step (#3174, #3168, #3166).

…ory census

The constant was 594 s, the maximum of a 16-run record that mixed a census
of the boundary day's tail with a sample of the days after it. The census
read the comment named as the fix has been done: every run of job
policy_refresh_continuous_aggregate query_store_stats_interval_hourly since
the 13:44:23 boundary, one row per run from
timescaledb_information.job_history, read at 2026-09-08 01:37Z.

57 succeeded runs after the boundary, 194 s to 896 s, median 418 s, none
failed and none without a finish time; 304 runs at or before it, median
1081.7 s, maximum 13300.7 s. The estimator is unchanged, so the constant is
896.

Two pre-registered triggers fired together. The 95th percentile is no longer
the maximum at 57 readings (830 s, 66 s below it), so the sample-size half of
the maximum-versus-percentile argument is retired and the decision is re-taken
on the half that never referenced n. And the sizing figure now sits 146 s
ABOVE RefreshSlotWarningSeconds, so every assertion holding the grid clear of
it is false. Those are left with their conditions exactly as written.
… state it

At 896 s of a 900 s slot the refresh still fits, so "inside one phase slot"
is true and load-bearing; the margin that made it "well inside" is 4 s and
naming it as comfort is the claim that has stopped being true.
Three of the six failing methods carried a comment explaining why they are
left failing and which decision owns the remedy; three did not, so the same
inversion read as an oversight in half the places it appears. Conditions are
unchanged in all six.

NoCompressionMinuteStartsWhileTheHeaviestRefreshIsStillRunning also needed the
distinction its two halves now have: the grid clearance genuinely passes (the
nearest compression minute is 1,320 s past the heaviest start), and only the
watch-line ordering and the 26%-gap literal derived from it are false.
… asserted

The open-question paragraph closed on "the excluded run is disqualified by
its duration alone", which was the belt to the boundary's braces. At a 896 s
population maximum 864 s is an ordinary member of the post-boundary range, so
that belt is gone and the sentence was contradicting the paragraph above it.
The positional rule needs no second reason, which is why it was chosen over
one.
The hourly phase map is injective now: the heaviest refresh is answered by
identity and every other policy takes its own consecutive minute, so thirteen
policies hold thirteen distinct minutes at any list order. The hour is three
derived bands - the light band and its guard, the heaviest refresh's window,
the compression band - and the window is the remainder, so nothing sizes
itself from the ceiling it has to hold.

The compression grid is re-derived from the same rule rather than renumbered,
the guard band comes from the light refreshes' own ceiling, and the band's
width comes from the catalog and a stated per-minute spread.
… census

226.8 s over 874 runs of the twelve non-heaviest hourly refresh policies,
post-boundary, with nothing removed by the succeeded/finish filter. The
midnight regime stays in, because the guard band is now derived from this
figure and a readout of a margin has to include the runs that consumed it.

The census's shape, the midnight mechanism behind the two largest runs, and
the guard band the ceiling sizes are all pinned against the constants rather
than restated.
Comment thread Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs Outdated
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Reviewed this PR (Darling-only C# change: TimescaleSupport.cs production logic plus DarlingConfig.cs and the four test files). Summary:

Scope / parity: No T-SQL and no Lite changes here — the hourly refresh/compression grid is a TimescaleDB-specific concept that only exists in Darling's PostgreSQL store, so there's no Lite counterpart to keep in parity with.

Math check: I worked through the re-derived grid by hand against the constants — LightHourlyRefreshCount (12) × LightRefreshStepMinutes (1) → last light refresh at :11, CompressionPhaseGuardMinutes = ceil(226.8/60) = 4 → HeaviestRefreshStartMinute = 15, CompressionPhaseBandMinutes = ceil(70/3) = 24 → HeaviestRefreshWindowMinutes = 60 − 15 − 24 = 21, RefreshPhaseSlotSeconds = 1260, RefreshSlotWarningSeconds = 1050. All of that matches the PR description and the shipped tests (including the rotation-invariance test proving the map is injective regardless of list order, and the tiling-partition assertion). The BuildCompressionPhaseMinutes per-view "own band vs. whole-window" exclusion logic also checks out against the stated :36–:59 compression band.

One finding posted inline: a stale XML doc comment on HeaviestRefreshSlotReading.PercentOfSlot still says "99.6%" for the recorded ceiling; the actual value (and what TimescaleSupportTests asserts) is 71.1% now that the slot is 1,260 s instead of 900 s. Looks like a leftover from an earlier commit in the stack that wasn't updated in the final grid re-derivation commit.

Security / injection: no new SQL string interpolation from external input — the grid math is all constants/derived catalog counts, and the SQL-building helpers (AddHourlyRefreshPolicySql, etc.) consume only the static HourlyRefreshPhaseOrder view names, not user input.

Performance: BuildCompressionPhaseMinutes is O(cadence × views²) ≈ 60×13×13, run once as a static initializer — negligible.

Everything else in the diff is XML-doc/test-comment prose re-derivation to match the new grid; I didn't find further code-level defects beyond the one inline comment.

…slot

PercentOfSlot's doc quoted 99.6%, which was 896 against a 900 s slot; against
the window the hour can spare it is 71.1%, which is what the test asserts.
The narrowing pin's margin, the population clause's warning-band note and one
watch-line figure were reading the same way.
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review summary

Reviewed the diff against TimescaleSupport.cs, DarlingConfig.cs, and the four test files. This is Darling-only C# (TimescaleDB continuous-aggregate/compression scheduling) — no T-SQL is touched, so the CONTRIBUTING.md T-SQL style rules don't apply here, and there's no Lite counterpart to check for parity drift (Lite has no TimescaleDB/continuous-aggregate concept; it's DuckDB-backed).

Correctness — hand-verified the grid arithmetic independently of the tests:

  • LightHourlyRefreshCount = 12, CompressionPhaseGuardMinutes = ceil(226.8/60) = 4, HeaviestRefreshStartMinute = 11*1 + 4 = 15 ✅.
  • CompressionPhaseBandMinutes = ceil(HypertableCount/3) = ceil(70/3) = 24 ✅.
  • HeaviestRefreshWindowMinutes = 60 - 15 - 24 = 21RefreshPhaseSlotSeconds = 1260, RefreshSlotWarningSeconds = 1050 — matches the PR description and the pinned test literals exactly.
  • RefreshPhaseMinutesFor is genuinely injective (verified the light-index/heaviest-identity logic by hand), and BuildCompressionPhaseMinutes's modular-distance predicate correctly excludes [start, start+band) per policy including wraparound — the union of light-band exclusions (0–14) plus the heaviest window (15–35) leaves exactly the documented 36–59 compression tail.
  • TryCompressionPhaseMinutesFor's index % CompressionPhaseMinutes.Count spread of 70 hypertables over a 24-wide band caps correctly at CompressionPhaseMaxPerMinute = 3 (70 = 24×2 + 22, so 22 minutes get 3, 2 minutes get 2).
  • Confirmed old RefreshPhaseStepMinutes/RefreshPhaseSlots members are fully removed (no stale references outside prose/comments), so nothing should fail to compile.
  • ConvergeContinuousAggregateRefreshAsync (unchanged in this diff) already calls through RefreshPhaseMinutesFor, so existing stores get correctly migrated onto the new per-view minutes on the next converge sweep — no separate migration needed.

Security: No new SQL-injection surface — the only inputs to the SQL-building helpers (AddHourlyRefreshPolicySql, SetCompressionSchedulePhaseSql, etc.) are fixed internal view/table name constants and computed integers, never external input.

Performance: BuildCompressionPhaseMinutes is O(cadence × policies) = O(60×13), computed once at static init; no regression.

One non-blocking design note: CompressionPhaseBandMinutes (and therefore HeaviestRefreshWindowMinutes) is now derived from HypertableCount, so an unrelated future PR that registers a new hypertable-backed collector will silently shrink the heaviest refresh's window and could turn red in TimescaleContinuousAggregateTests/TimescaleSupportTests far from where the change was made. That's clearly intentional (the assertions and error messages already anticipate it and point back at #3174), just flagging it as a coupling to be aware of for future collector PRs.

No correctness bugs, parity issues, or security concerns found. The doc-comment/test pinning is unusually (impressively) exhaustive and internally consistent everywhere I checked it by hand.

timescaledb_information.job_history only records executions where
timescaledb.enable_job_execution_logging is on, it is off by default, and the
conf block that sets it cannot be healed onto a cluster that predates it - so
a maximum over that view on an older store returns zero rows and reads as
nothing exceeded the line. Both censuses are one store's, said as a
precondition of the read rather than as a caveat about the workload.

The refresh-slot warning line names that route, so it now names the condition
too: a pin on a pointer's presence cannot tell an operator that following it
may return nothing.
Comment thread Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs Outdated
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review

This PR only touches Darling's C# side (TimescaleSupport.cs, DarlingConfig.cs, and their test files) — no T-SQL, so the CONTRIBUTING style rules don't apply here. It also carries no Lite counterpart: the grid it re-derives is TimescaleDB continuous-aggregate refresh scheduling, which is Darling/PostgreSQL-only (Lite has no TimescaleDB/continuous-aggregate concept), so this isn't a parity-drift case.

Arithmetic check. I independently recomputed every derived constant the PR's description and doc comments claim, rather than trusting the prose:

  • LightHourlyRefreshCount = 13 − 1 = 12
  • CompressionPhaseGuardMinutes = ⌈226.8 / 60⌉ = 4
  • HeaviestRefreshStartMinute = (12−1)×1 + 4 = 15
  • CompressionPhaseBandMinutes = ⌈HypertableCount / 3⌉ = 24
  • HeaviestRefreshWindowMinutes = 60 − 15 − 24 = 21 → RefreshPhaseSlotSeconds = 1260
  • RefreshSlotWarningSeconds = 1260×5/6 = 1050
  • Grid: light refreshes on :00–:11, heaviest on :15 (896s window fits in a 1260s slot, clearance 364s / 71.1%), compression on :36–:59 (24 min) — union of exclusions [0,36) matches the light-guard overlaps plus the heaviest window exactly.

All of this holds together and matches the numbers asserted in TimescaleSupportTests.cs, TimescaleContinuousAggregateTests.cs, DarlingSelfAlertTests.cs, and RefreshCeilingProvenancePinTests.cs. RefreshPhaseMinutesFor's injective-by-construction mapping (light views get their own consecutive index, heaviest gets identity) is correct, and BuildCompressionPhaseMinutes's modular distance check ((minute - start + cadence) % cadence < band) correctly excludes [start, start+band) mod 60 for every policy. I also confirmed no dangling references to the removed RefreshPhaseSlots/RefreshPhaseStepMinutes symbols remain anywhere outside doc-comment prose.

One finding posted inline (Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs, the rotation-control loop): it re-implements RefreshPhaseMinutesFor's logic locally to test injectivity-under-rotation, rather than calling the real method (which has no seam for injecting an alternate order). It's a real gap between what the test proves (the rule is injective under permutation) and what the PR's narrative claims (the shipped code is), worth a small refactor to close.

No correctness, security, or performance issues found in the actual logic — the constants, the injective phase map, and the compression-exclusion predicate are all internally consistent and match the extensive test pins.

The rotation control re-implemented the counting rule inline, because the
public map can only be called at the one order HourlyRefreshPhaseOrder has.
That proved the rule injective under permutation and left the shipped method
exercised at exactly one order.

The map now takes the order as a parameter and the product passes its own
list. Only the order is a parameter: the geometry still comes from the
registry, so the seam cannot fabricate a different grid.
Comparing the two minute sequences passes for an overload that ignores its
order parameter, because that still returns a permutation of the unrotated
minutes. What has to change is the minute a named view gets.
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Reviewed the diff (DarlingConfig.cs, TimescaleSupport.cs, and the four Darling.Tests files). Summary, since the actual code delta is small relative to the doc-comment volume (~150 lines of real logic/test-assertion changes vs. ~1,400 lines of XML doc prose):

Correctness — traced the core algorithm changes by hand and they check out:

  • RefreshPhaseMinutesFor(order, view): injective map (heaviest view → HeaviestRefreshStartMinute by identity, every other view → its own consecutive index in the light band) is correct and matches the shipped test pins (0..11, 15).
  • HeaviestRefreshStartMinute = (LightHourlyRefreshCount-1)*LightRefreshStepMinutes + CompressionPhaseGuardMinutes = 11 + 4 = 15; HeaviestRefreshWindowMinutes = 60 - 15 - 24 = 21 — both match the doc's claimed grid (:00:11 light, :15 heaviest, :36:59 compression).
  • BuildCompressionPhaseMinutes's single predicate ((minute - start + cadence) % cadence < band) correctly handles hour wraparound and folds both the light-guard and heaviest-window exclusions into one rule.
  • Independently recomputed the published 57-run census list (48 post-boundary-day values + 9 boundary-tail values): sums to exactly 27799s as the doc states, and max is 896 — the arithmetic in the doc comments is not just narrative, it's verifiably correct.
  • OtherHourlyRefreshObservedCeilingSeconds changed from const int to const double (226.8) — checked all call sites; every usage already treats it as a value that needs rounding/ceiling, no int-context breakage.
  • Confirmed the removed constants RefreshPhaseStepMinutes/RefreshPhaseSlots have zero remaining references outside historical <c> doc-comment text (in both source and tests) — no compile break from the rename/removal.

Lite/Darling parity — not applicable here. This whole change (TimescaleDB continuous-aggregate refresh/compression phase scheduling) is Darling/Postgres-only; grepped Lite for any equivalent concept and found nothing, so there's no counterpart that could have drifted.

Security — no new external input, no SQL string changes, no injection surface. The #3175 job_history/GUC-gap handling only changes log/doc text, not query construction.

T-SQL style (CONTRIBUTING.md) — no .sql or embedded-SQL-string changes in this PR, so the AS-alias / column = expr / OPTION(RECOMPILE) conventions don't apply here.

PerformanceCompressionPhaseMinutes is still built once at static init (≤60 minutes × 13 policies), no regression.

No correctness, parity, security, or performance issues found. One non-blocking observation: the doc-comment-to-code ratio is very high (single constants carry 60+ line <para> derivations); that's clearly a deliberate house style here (enforced by RefreshCeilingProvenancePinTests' regex-drift-sweep), so I'm not flagging it as a problem, just noting it makes the diff much larger than the behavioral change it represents.

#3175/#3177 gave timescaledb.enable_job_execution_logging its own conf marker,
so existing stores heal. That does not widen either census: both reads predate
the heal, and a later one would have to state its own scope rather than
inherit this one's.
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review summary

Reviewed this against correctness, Lite/Darling parity, security, and performance. This PR only touches Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs, Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs, and their test files — it's PostgreSQL/TimescaleDB continuous-aggregate scheduling logic, which has no Lite counterpart (Lite has no TimescaleDB refresh-policy concept), so no Lite/Darling parity drift applies here. No new SQL strings, migrations, or external input handling were introduced, so there's no new injection surface, and no T-SQL was touched (CONTRIBUTING's T-SQL style / OPTION(RECOMPILE) rules don't apply to this Postgres-side change).

Correctness — verified by hand:

  • RefreshPhaseMinutesFor(order, view): correctly returns HeaviestRefreshStartMinute for the heaviest view (matched by identity, not position) and lightIndex * LightRefreshStepMinutes for every other view, incrementing lightIndex only past non-heaviest candidates. This is injective by construction — no modulus, so no residue collisions regardless of list order or insertions.
  • Grid arithmetic checks out: LightHourlyRefreshCount = 12 → light band occupies minutes 0–11 → HeaviestRefreshStartMinute = (12-1)*1 + 4 = 15 → HeaviestRefreshWindowMinutes = 60 - 15 - 24 = 21 → compression band is exactly 36–59. The three bands partition the hour exactly, confirmed against TheRefreshGrid_TilesTheHourlyCadence... and CompressionPhaseGrid_Clears... in the tests.
  • BuildCompressionPhaseMinutes()'s wraparound check (minute - RefreshPhaseMinutesFor(view) + cadence) % cadence < band correctly excludes minutes within the guard band of every light refresh's start and within the full window of the heaviest refresh's start — spot-checked minutes 35 (blocked) and 36/59 (clear) by hand.
  • CompressionPhaseGuardMinutes = ceil(226.8 / 60) = 4, and RefreshSlotWarningSeconds = 1260 * 5 / 6 = 1050, both matching the doc prose and the pinned test values.
  • The const → static conversions (CompressionPhaseGuardMinutes, RefreshPhaseSlotSeconds, RefreshSlotWarningSeconds) have no other consumers outside TimescaleSupport.cs and its tests, so losing compile-time-constant-ness is safe (no switch/attribute usage elsewhere).
  • Old removed constants (RefreshPhaseStepMinutes, RefreshPhaseSlots) have zero remaining code references — only mentioned in historical prose inside doc comments, so nothing is left dangling.

Test suite: RefreshCeilingProvenancePinTests reflects on the actual doc-comment text via [CallerFilePath] and cross-checks the stated census numbers (sums, medians, percentiles by nearest rank, growth percentages) against the shipped constants — I independently re-derived several of these (the 57-run total/median, the 15/21/24 tiling, the guard-band rounding) and they're internally consistent. The rotation test (HourlyRefreshPhases_AreDistinctForEveryRelationTwoPoliciesContendFor) correctly exercises the shipped internal overload at every rotation rather than a re-implemented copy of the rule, which is the right way to guard against the "test agrees with any derivation" failure the PR description calls out.

No correctness, security, parity, or performance issues found. The computed properties recompute a handful of small values (bounded by collector/hypertable counts) rather than reading true constants, but they're only invoked from periodic alert-evaluation paths and a one-time static initializer, so there's no meaningful performance regression.

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Reviewed the diff (all six changed files are under Darling/; nothing in Lite/).

Parity: No Lite/Darling drift. This is TimescaleDB continuous-aggregate refresh scheduling, a concept that only exists on the Darling/Postgres side — Lite (DuckDB) has no continuous aggregates and, per the existing comment in Lite/Mcp/McpAlertTools.cs, explicitly "has no concept whatsoever of the fourth (store_job_cadence_warn_percent)" knob this PR touches. Correctly Darling-only.

Core logic (TimescaleSupport.cs): Traced the new grid derivation end to end —
LightRefreshStepMinutesLightHourlyRefreshCountHeaviestRefreshStartMinuteHeaviestRefreshWindowMinutesRefreshPhaseSlotSeconds/RefreshSlotWarningSeconds, and the new injective RefreshPhaseMinutesFor(order, view) map. The arithmetic is internally consistent: 12 light refreshes on minutes 0–11, a 4-minute guard, heaviest refresh at :15 with a 21-minute window, then a 24-minute compression band — tiles the 60-minute hour exactly, matches what BuildCompressionPhaseMinutes computes, and matches the pinned test expectations (TimescaleSupportTests, TimescaleContinuousAggregateTests). The map is genuinely order-independent (verified the light-index counter isn't reset by the heaviest view's position, and the new rotation test in TimescaleContinuousAggregateTests exercises that through the shipped internal overload rather than a reimplementation).

Provenance-pin tests: RefreshCeilingProvenancePinTests cross-checks the published run population (57 readings) against the doc comment's stated count/min/max/total/median/percentiles via Verify(), so a transcription error in the quoted census would fail CI rather than silently ship — didn't find a discrepancy in what I sampled.

Security / performance: No new SQL, no new I/O or external input handling. BuildCompressionPhaseMinutes is O(cadence × policies) ≈ 780 ops, run once at static init — no regression.

No correctness, parity, or security issues found. Given the scale of this PR (~1500 lines, mostly doc-comment/test provenance pinning) I did not hand-verify every one of the 57 quoted census figures by hand — that's exactly what RefreshCeilingProvenancePinTests.Verify() exists to do, and it should be relied on via CI rather than manual re-derivation.

erikdarlingdata added a commit that referenced this pull request Sep 8, 2026
CONTRIBUTING.md's comment convention asks for issue numbers alongside the
measurements. #3178 sets all three figures; #3166 is how a census raises the
ceiling and #3174/#3178 how a re-derivation narrows the slot, which are the two
changes that would turn this case red.
erikdarlingdata added a commit that referenced this pull request Sep 8, 2026
…sent tense (#3181)

* State what the refresh-slot routine-band case establishes, in the present tense

The comment above the InsideSlot case narrated a failure that no longer exists: it
said the recorded ceiling logs Warning rather than Debug and that the assertion
below it fails. Both are false on the current grid, and the assertion passes.

The ceiling is 896 s against a 1,260 s slot and a 1,050 s watch line, so
ClassifyRefreshSlotHeadroom bands it InsideSlot and LogHeaviestRefreshSlotHeadroom
takes the Debug arm. The replacement states that, and keeps the reason the case is
fed the grid's own sizing figure rather than a lower literal: both sides are read
from TimescaleSupport, so the case goes red if a census raises the ceiling or a
re-derivation narrows the slot until the product would call its own sizing figure
a warning.

The assertion and the value it is fed are unchanged.

* Cite the issues the ceiling, slot and watch line come from

CONTRIBUTING.md's comment convention asks for issue numbers alongside the
measurements. #3178 sets all three figures; #3166 is how a census raises the
ceiling and #3174/#3178 how a re-derivation narrows the slot, which are the two
changes that would turn this case red.
erikdarlingdata added a commit that referenced this pull request Sep 9, 2026
…of the PR bodies

The entry text does not only ride the PR description. #3150's own account of the previous
batch says 11 of its 66 entries came from a lane's handoff file in
`~/Documents/pm-issue-queue-inbox/inbox/` rather than the body, and three files there carry
dedicated entry text inside this range: `3164-changelog-entry.md`, `3166-changelog-entry.md`
and `3169-changelog-entry.md`. Searching only the bodies missed the first two.

- **#3164** (PR #3176) is no longer written from the PR description. Its lane wrote a full
  entry, and that text is used verbatim: it carries the 1,036 blocks/s upper bound, the
  reason the decoupling #3153 deferred is unreachable, the declined command-deadline route,
  the census deliberately not restated at the new ceiling, and the mutation that caught
  nothing. `[#2997]` joins the definition block at its ascending position, which that text
  cites and the file did not define.
- **#3166** (PR #3168, absorbed and superseded by #3178) had no entry at all. #3178 grouped
  it into its own citation with a one-line summary, but its lane wrote a separate entry for
  the census that took `HeaviestHourlyRefreshObservedCeilingSeconds` from 594 s to 896 s and
  inverted the watch-line ordering. It sits beside #3178's, on #3178's merge.
- **#3191's entry** said "#3184 named four of the six". #3184 was squash-merged and the
  four-of-six state never reached `dev`; it was a commit inside that PR, corrected before
  merge. "shipped" in this file means merged, so the clause now says a commit in #3184, which
  is what the #3187 lane's own report establishes.

`CHANGELOG.md` only, 60 insertions and no deletions against `origin/dev`: 33 entries
(6 Added, 3 Changed, 24 Fixed) and 27 definitions. All 15 definition runs keep the ordering
they had, 9 of 15 ascending before and after.
erikdarlingdata added a commit that referenced this pull request Sep 9, 2026
…ix merged pull requests (#3213)

* Record the CHANGELOG entries for thirty-one changes across twenty-five merged pull requests

Applies the `[Unreleased]` entries for the pull requests merged to `dev` from #3150 (the
previous batch) through #3203. Lanes do not edit `CHANGELOG.md` -- every one of them appends
to the same block, so a per-PR edit conflicts with whichever sibling merges first -- and the
entry text rides each PR's own description instead. This is the periodic commit that applies
it.

`CHANGELOG.md` only, 56 insertions and no deletions: 31 entries prepended inside
`## [Unreleased]` (6 under `### Added`, 2 under `### Changed`, 23 under `### Fixed`), newest
merge first within each section, plus the 25 link-reference definitions they need appended at
the foot of the file. Nothing already in the block was edited, reworded or reordered.

Three of the 28 pull requests in the range earn no entry, each on measured precedent rather
than judgement: #3150 is the previous batch pass itself, and no batch pass -- #2260, #2980,
#3150 -- is cited anywhere in the file; #3130 and #3131 are routine Dependabot bumps, and
none of the six merged Dependabot pull requests in the repository's history is cited either.

Two pull requests carried no entry text anywhere, so theirs are written from their
descriptions: #3176 (`pg_index_bloat`'s measured block rate) and #3184 (the PostgreSQL
target's permissions section).

* Record #3205's entry, which arrived on the merged base mid-batch

#3205 merged while this batch was assembled, so `origin/dev` was merged in rather than rebased
and its entry lands on top. An entry omitted because it arrived mid-batch is how a backlog
restarts, and the batch's purpose is to make `[Unreleased]` current at its own merged base.

One entry under `### Changed`, newest merge first ahead of #3195's, plus its link-reference
definition. #3205 has no other pull request in the range behind it.

* Take #3164's and #3166's entries from the lane handoff files instead of the PR bodies

The entry text does not only ride the PR description. #3150's own account of the previous
batch says 11 of its 66 entries came from a lane's handoff file in
`~/Documents/pm-issue-queue-inbox/inbox/` rather than the body, and three files there carry
dedicated entry text inside this range: `3164-changelog-entry.md`, `3166-changelog-entry.md`
and `3169-changelog-entry.md`. Searching only the bodies missed the first two.

- **#3164** (PR #3176) is no longer written from the PR description. Its lane wrote a full
  entry, and that text is used verbatim: it carries the 1,036 blocks/s upper bound, the
  reason the decoupling #3153 deferred is unreachable, the declined command-deadline route,
  the census deliberately not restated at the new ceiling, and the mutation that caught
  nothing. `[#2997]` joins the definition block at its ascending position, which that text
  cites and the file did not define.
- **#3166** (PR #3168, absorbed and superseded by #3178) had no entry at all. #3178 grouped
  it into its own citation with a one-line summary, but its lane wrote a separate entry for
  the census that took `HeaviestHourlyRefreshObservedCeilingSeconds` from 594 s to 896 s and
  inverted the watch-line ordering. It sits beside #3178's, on #3178's merge.
- **#3191's entry** said "#3184 named four of the six". #3184 was squash-merged and the
  four-of-six state never reached `dev`; it was a commit inside that PR, corrected before
  merge. "shipped" in this file means merged, so the clause now says a commit in #3184, which
  is what the #3187 lane's own report establishes.

`CHANGELOG.md` only, 60 insertions and no deletions against `origin/dev`: 33 entries
(6 Added, 3 Changed, 24 Fixed) and 27 definitions. All 15 definition runs keep the ordering
they had, 9 of 15 ascending before and after.

* Use the staged #3184 entry text instead of one written from the PR description

#3184's entry now exists, staged by the lane that did the work, so the batch no longer
invents one. Used verbatim, in `### Fixed` where its author targeted it and for the reason
they gave: the section did not merely lack detail, it asserted "One role covers every
collector", which is false in the direction that fails silently and which an operator would
act on.

Their text carries what a description-derived entry could not: the `pg_read_all_data` grant
is PostgreSQL 14+ and the pre-14 fallback the section gave was `GRANT SELECT` on a schema,
which is not valid PostgreSQL at all; the six collectors that need an extension, four of
which additionally need `shared_preload_libraries` and a server restart; and why
`pg_index_bloat` is unaffected by any grant. Both figures are live pre-change store reads
rather than estimates.

The citation is `[#3184]`, the pull request, because the change has no issue behind it, and
its definition uses the `/pull/` form. `CHANGELOG.md` only, still 60 insertions and no
deletions against `origin/dev`.
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