Skip to content

Reconcile each raw hypertable's chunk interval once a day from its ingest (#4211) - #4344

Merged
erikdarlingdata merged 9 commits into
devfrom
feature/4211-chunk-interval-wiring
Sep 25, 2026
Merged

erikdarlingdata merged 9 commits into
devfrom
feature/4211-chunk-interval-wiring

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Part of #4211.

Why

RawChunkIntervalPlanner (#4332, already on dev) derives each raw hypertable's target
chunk_time_interval from its own ingest rate and a RAM budget, but it is pure: no database calls, no
schedule, nothing wired to a live store. Every raw hypertable still keeps the fixed interval it was
created with, so a table whose write rate grows or shrinks over time stays on a chunk size sized for its
first day. This PR is #4211's second half (lane I2, finished by I2b after I2 stopped with the work
uncommitted): it reads live ingest and catalog state, calls the planner, applies its decisions with
set_chunk_time_interval, and records what happened.

What changes

Per the ruling on #4211 (issuecomment-5836205190), the five pieces:

  1. The reconciler - Darling/PerformanceMonitor.Darling.Storage/RawChunkIntervalReconciler.cs. Reads
    every raw hypertable's ingest rate (compressed chunks' before_compression_total_bytes, heap and index
    together, over their wall-clock span - never the 2.29-only hypertable_compression_stats columns),
    current interval, and when it last moved (from the rung history table, so the once-a-day hold survives a
    restart). Calls RawChunkIntervalPlanner.Plan, applies every changing decision inside its own
    transaction, and records the rung history plus a per-run row carrying pg_stat_wal.wal_bytes (ruling
    decision 8 / review finding L4, so a later stage can correlate WAL growth against interval moves). Runs
    at startup and once a day, riding the same daily-purge tick every other maintenance chore uses; never
    throws past one run - a bad reading degrades to "no changes" next time.
  2. The off switch - Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs,
    RawChunkIntervalReconcileEnabled (default true, file-only, takes effect on restart).
  3. The wiring - Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs. Gates the whole pass on
    _timescaleAvailable && config.RawChunkIntervalReconcileEnabled before the daily-purge tick calls the
    reconciler, and resolves budget B: ResolveRawChunkIntervalBudgetBytesAsync reads the same raw RAM
    figure DarlingManagedPostgres.DeriveMemorySettings sizes Postgres from on a managed store (through
    DarlingStoreHostProfile.GatherHostFacts, which itself calls
    DarlingManagedPostgres.TryReadWindowsPhysicalMemoryBytes - never a second GlobalMemoryStatusEx
    call), and reads shared_buffers/effective_cache_size live off pg_settings via
    RawChunkIntervalPlanner.BringYourOwnBudgetBytes on a bring-your-own store.
  4. Migration V144 - Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs adds
    collect.raw_chunk_interval_rung_history (one row per rung change: table, when, from/to hours, reason,
    and the three inputs the decision was made from) and collect.raw_chunk_interval_reconcile_runs (one row
    per run, whether or not it changed anything, with wal_bytes). StorageVersion.cs moves 143 to 144, and
    the probe sentinel lands in Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
    (MapProbedSchemaVersion's new top arm, ordinal 119).
  5. Tests - Darling/Darling.Tests/RawChunkIntervalReconcilerLiveTests.cs (end-to-end narrow-then-hold
    against a real TimescaleDB, the bring-your-own budget path, the off-switch source check) and
    Darling/Darling.Tests/RawChunkIntervalRungHistoryRungTests.cs (the V144 rung pin).

I2b's own fix on top of I2's checkpoint (e45e705): RawChunkIntervalRungHistoryRungTests.cs (V144)
correctly took over the "I am the top rung" claim, but QueryStoreIntervalLatestRungTests.cs (V143) still
asserted it - RungVersion == SchemaVersion and "no reader.GetBoolean(119) in the viewer" - both of which
broke the moment V144 appended ordinal 119. Stepped V143's file down the same way V141's file
(CollectionCaveatsRungTests.cs) already stepped down when V142 landed on it: RungVersion < SchemaVersion, an atThisRung/behind boolean probe instead of all-true, and a nextArm check against
V144's arm. This was the entire cause of the 2 failures the checkpoint's first run reported.

Pre-merge fixes

From the PR tender's pre-arm check, all in da47a539 (report: PR comment 5838942976):

  • The planner's reasons are now formatted with the invariant culture (string.Create(CultureInfo.InvariantCulture, ...)). This PR stores the two "moved" reasons in collect.raw_chunk_interval_rung_history.reason, and {x:N0} followed the host's culture: a German Windows host would have written 1.234.567 B. A planner test runs a narrowing and a widening plan under de-DE and checks for the invariant text. The test was revert-proven.
  • ReconcileAsync's summary said it never throws for one table's failure. It does: that table's transaction rolls back, tables already applied stay applied and recorded, and the rest wait for the next daily pass. The worker's catch logs it. The comment now says that.
  • V144's summary said the reconciler's caller purges the two new tables. Nothing does. The comment now says neither is purged: the run table gains one row a day and the history one row per move, and the history has to keep at least MinimumDaysBetweenMoves days anyway.

CHANGELOG entry

SECTION: Changed
ENTRY:
- **Raw hypertables now re-tune their own chunk interval once a day from actual ingest** ([#4344]) - Every raw hypertable used the same fixed one-day chunk interval whatever its write rate, so a busy table's open chunk, with its indexes, could outgrow the memory meant to hold it. Darling now reads each raw hypertable's compressed ingest rate daily, alongside a RAM-derived budget, and narrows the interval one rung at a time through `set_chunk_time_interval` when the store-wide total is over budget, and widens it one rung when the total would stay under half the budget; a new rung history table records every change, alongside a per-run WAL-volume record. On by default; set `rawChunkIntervalReconcileEnabled: false` in the config file to turn it off.
REF:
[#4344]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/4344

Test plan

  • dotnet build Darling/Darling.Tests/Darling.Tests.csproj - 0 Warning(s), 0 Error(s).
  • Targeted classes (each its own -class filter, against a fresh darlingtest on port 55991):
    *RawChunkInterval* (24), *DarlingRetention* + *TimescaleSupport* + *PgMigration* +
    *SchemaVersion* (145), *ViewerSchemaVersionGateTests* (32), *StorageCommandTimeoutTests* (19),
    *StartupCommandTimeoutTests* (14), *LivePostgresCollectionHygieneTests* (11),
    *LiveCleanupConversionRatchetTests* (15), *McpPayloadContractCensusTests* (68),
    *DocCommentHygiene* (77). All 0 failed on the first pass, before origin/dev was merged in.
  • Revert proof: temporarily forced IntervalLastChangedUtc to always be null instead of reading
    it from the history table, rebuilt, ran
    EndToEnd_NarrowsOneRungOverBudget_ThenHoldsForOneDayFromHistory_AndMigrationReapplyIsANoOp_AgainstDevPostgres
    alone - phase 2 (the same-day hold) failed (Expected: 0, Actual: 1, meaning it moved a second time
    instead of holding). Restored the read, rebuilt, reran - passed.
  • Budget B source check: confirmed by reading the call chain, not just the comments -
    DarlingWorker.ResolveRawChunkIntervalBudgetBytesAsync on a managed store calls
    DarlingStoreHostProfile.GatherHostFacts(), which calls
    DarlingManagedPostgres.TryReadWindowsPhysicalMemoryBytes directly (DarlingStoreHostProfile.cs:264).
    The startup sizing path (DarlingManagedPostgres.GetTotalPhysicalMemoryBytes /
    TryGetAuthoritativePhysicalMemoryBytes, which feeds DeriveMemorySettings) calls the same
    TryReadWindowsPhysicalMemoryBytes (DarlingManagedPostgres.cs:3331). Same read, not a second
    GlobalMemoryStatusEx call. On a bring-your-own store, BringYourOwnBudgetBytes_ReadFromPgSettings_AgainstDevPostgres
    already exercises the live pg_settings path. No code change was needed for this check.
  • Merged origin/dev (eac444e, PostgreSQL-target baselines PR) - clean merge, no conflicts. dev is
    still at schema version 143, so V144 needed no renumbering.
  • Full Darling.Tests suite, run against a freshly recreated darlingtest, twice:
    • First full run (before the V143-rung-handoff fix): Total: 14174, Errors: 0, Failed: 2, Skipped:
      48, Not Run: 1, Time: 826.060s. Both failures were
      QueryStoreIntervalLatestRungTests.TheRungIsRegisteredAtTheTopOfADenseLadder and
      .TheProbeMapsAFullyMigratedStoreToThisTopRung - fixed as described above.
    • Second full run (after the fix, same fresh database): Total: 14174, Errors: 0, Failed: 0,
      Skipped: 48, Not Run: 1, Time: 968.524s.
    • The "Not Run: 1" is stable across both runs and is not from this PR: the test project has exactly two
      [Fact(Explicit = true)] tests in the whole suite
      (McpSchemaCompatServiceLeakRaceTests - an intentionally-manual race repro for Lite budget test sometimes sees get_alert_history's dataService parameter in a full run #4075 - and
      RollupCoverageRoutingTests - marked explicit pending LA-3b), neither of which this PR touches.
    • The 48 skips are all live tests gated on environment variables this rig doesn't set (csvlog targets,
      a second UTC-logging target, DARLING_TEST_PGRUNTIME, etc.) - the standard shape for this suite.
  • Lite.Tests - not run. This PR touches only Darling's own PostgreSQL/TimescaleDB store; Lite has no
    PostgreSQL storage layer, so there is no Lite parity surface for a chunk-interval rule.
  • Installer.Tests - never run per lane orders.

Not done

Per the brief, these are left for lane I3:

For the coordinator

  • I2's rig (port 55991, C:\GitHub\worktrees\rig-i2) was reused per the brief and has now been stopped and
    the directory removed.
  • The only defect found in I2's checkpoint was the V143-rung-handoff gap above; everything else (the
    reconciler, the off switch, the wiring, the migration, budget sourcing) matched the ruling and needed no
    changes.

I3 (decision 9 pins and stale text)

Branch: pushed on top of I2b's commit (072cf2b6 -> 117cb49f). Build: 0 Warning(s), 0 Error(s). Targeted
classes (*FleetReadsAreBoundedByTheFleet*, *TimescaleSupport*, *RawChunkInterval*, *DarlingRetention*,
StartupCommandTimeoutTests, DocCommentHygiene*): 237 total, 0 failed, 21 skipped (live Postgres tests, no
rig per the brief). DocCommentHygieneTests run alone afterward: 77/77 passed (it catches a broken <see cref>, among other things).

The pin. FleetReadsAreBoundedByTheFleetTests.cs's EventWindowFloor_IsOneChunkBeforeTheWindow_AsNaiveUtc
(~line 119) asserted TimeSpan.FromDays(TimescaleSupport.ChunkIntervalDays) == EventWindowFloor.SkewAllowance.
Now asserts TimeSpan.FromDays(1) directly, with a comment saying why: since #4211 ruling decision 1,
SkewAllowance is its own clock-skew tolerance, not the raw chunk width, so a table
RawChunkIntervalPlanner narrows must be free to move its chunk_time_interval without dragging this pin
along.

Stale text in TimescaleSupport.cs. Reworded 7 spots - all comments or one log template, no behavior
change:

  1. CompressScheduleInterval's doc (~line 90): "12 hours [is] the default on EVERY store shape" was wrong
    for a narrowed table - TimescaleDB's own uncapped default for a 12h or 6h chunk would compute 6h or 3h, not
    12h. Scoped the claim to day-or-wider chunks and named the narrowed-table exception.
  2. CompressionPhaseMaxPerMinute's doc (~line 4559): "every hypertable's newest 1-day chunk becomes eligible
    at the same UTC midnight" is still literally true - every ladder rung (1-day, 12h, 6h) divides a day
    evenly, so midnight stays a shared boundary - but it was incomplete: a narrowed table also picks up
    boundaries of its own. Reworded to say both.
  3. The "why a spread rather than one shared minute" paragraph (~line 4619): the same "CompressAfterDays and
    ChunkIntervalDays are both 1" framing, now stale since only CompressAfterDays stays fixed at 1 day.
    Reworded, and noted a narrowed table's extra boundaries argue for more spread, not less.
  4. The "what sits on either side" paragraph (~line 6896): "twenty-three hours of the day those policies find
    nothing eligible" no longer holds for a narrowed table, which has real work at 2 or 4 hours instead of 1.
    Scoped to "a table still at the 1-day chunk ceiling", with a "fewer for a narrowed table" clause.
  5. AggregateCompressionBandFirstHour's doc (~line 6938): "the 00: hour's compression band is the one that
    does a day's compressing" implied that is the only heavy hour; a narrowed table also does real work at
    noon (12h chunks) or every 6 hours. Reworded to say the 00: hour is the one every raw table shares, and
    named the extra hours a narrowed table adds that this constant does not try to clear. Raw and aggregate
    compression lock different chunks per the paragraph directly above this one, so this reads as a
    resource-contention question rather than a lock-safety gap - flagging it for the coordinator in case it is
    worth a closer look, not as a bug I found evidence of.
  6. The RefreshOverrun log template (~line 10662): "every hypertable's newest {Days}d chunk becomes eligible
    at the same UTC midnight, so one tick a day carries a full day's rewrite while the other twenty-three find
    nothing" printed CompressAfterDays (which stays 1 day, correctly, per the brief's own rule), but the
    surrounding prose claimed a universal once-daily pattern. Reworded to describe the delay-after-close
    mechanism generally, scoped "one tick a day" to the 1-day ceiling, and named what a narrowed table does
    instead.
  7. CompressionActivity.AssignedPhaseMinute's doc (~line 11205): referenced "the daily chunk close of a
    1-day raw chunk" to explain why the log in Lite overview: show Online/Offline status #6 does not fit an aggregate job. Reworded to match Lite overview: show Online/Offline status #6's new
    framing.

Checked, left alone:

🤖 Generated with Claude Code

https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ

erikdarlingdata and others added 9 commits September 25, 2026 13:31
Adds RawChunkIntervalPlanner, a pure static planner (#4211): given each raw
hypertable's ingest rate, current chunk_time_interval and a RAM-derived
budget, it decides a target interval on a 24h/12h/6h ladder, one rung per
table per day, budget-aware across every table (not only the "heavy" ones),
with hysteresis on the way back up and a store-wide chunk-count cap. No
database wiring yet - wiring this into the TimescaleDB ensure path and
compress_after is a separate change.

Per the #4211 ruling (issuecomment-5836205190): compress_after stays fixed
at 1 day everywhere regardless of a table's derived interval; only
chunk_time_interval moves. ChunkIntervalDays and CompressAfterDays become
the ladder's ceiling rather than "the" uniform chunk width, so their doc
comments, a retention test comment and RollupBackfill's #1778 slice note are
reworded to match. EventWindowFloor.SkewAllowance gets its own 1-day
constant instead of reading ChunkIntervalDays. A new pin asserts
compress_after stays at least HourlyRefreshStartOffset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
The #4211 ruling (issuecomment-5836734285) replaces the per-table equal
share (budgetBytes / tables.Count) with a store-wide check: a table
moves back up one rung only when the store-wide open-chunk total, with
that move made, stays at or under half of B. With about 72 hypertables
the equal share almost never let a narrowed heavy table widen again.
Widening now considers the lightest-rate table first, same tie-break
as narrowing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
… rung history (#4211)

Checkpoint of lane I2's work, committed by the coordinator after the lane
stopped. Its first full-suite run reported 4 failures and 1 test not run;
they are unresolved, and a finisher lane picks them up from here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
I2's V144 (raw_chunk_interval_rung_history) added its own top-rung claim
correctly, but left QueryStoreIntervalLatestRungTests.cs still claiming to
be the top: RungVersion == SchemaVersion and "no reader.GetBoolean(119)".
Both broke the moment V144 appended ordinal 119. Steps V143's file down
the same way V141's stepped down for V142 (CollectionCaveatsRungTests):
RungVersion < SchemaVersion, an atThisRung/behind boolean probe instead of
all-true, and a nextArm check against V144's arm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
… text

The FleetReadsAreBoundedByTheFleet pin tied EventWindowFloor.SkewAllowance
to TimescaleSupport.ChunkIntervalDays. Since #4211 decision 1 gave
SkewAllowance its own 1-day constant (a clock-skew tolerance, not a chunk
width), pin it to a literal TimeSpan.FromDays(1) instead so the two are
free to move apart.

TimescaleSupport.cs carried several comments and one log template that
assumed every raw chunk is a day wide, so a table RawChunkIntervalPlanner
narrows to 12h or 6h becomes eligible at every one of its own chunk
boundaries, not only the shared UTC midnight the unnarrowed tables use.
Reworded each to say so, without changing any scheduling behavior.

Review issuecomment-5827820395 items L1-L3, ruling decision 9. L1
(RollupBackfill.SliceWidth) and the CompressAfterDays-printing log
templates were already correct on this branch. L3's "7.0 GB against a
predicted 6.8 GB" measurement lives only in issue #4211's body, not in
RawChunkIntervalPlanner.cs or RawChunkIntervalReconciler.cs, so neither
file needed a change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Item 1: RawChunkIntervalPlanner's seven Decision reasons formatted {x:N0}
with the host's culture. The rung history table now persists that text
(collect.raw_chunk_interval_rung_history.reason), so a German-locale host
would write "1.234.567 B" beside an invariant-formatted log line reading
"1,234,567 B". Wrap all seven in string.Create(CultureInfo.InvariantCulture,
$"..."). Add a fact that runs a narrowing and a widening Plan under de-DE
and asserts the exact invariant N0 string; revert-proofed by hand (dropping
the wrap on the narrowing reason turns "1,000,000 B" into "1.000.000 B" and
fails the new fact).

Item 2: ReconcileAsync's summary claimed it never throws for a planning or
apply failure on one table. There is no catch in the per-table loop, so it
does throw: that table's transaction rolls back, tables already applied
earlier in the pass stay applied and recorded, and the remaining tables and
the run row wait for the next daily pass. Reworded the doc comment to say
that, and that the caller (DarlingWorker's daily tick) catches it and logs
at Error. Comment only.

Item 3: PgMigrations' V144 summary claimed the run and history tables are
"purged by the reconciler's own caller". Grepped Darling.Storage and
Darling.Service: only SELECT/INSERT touch either table, nothing deletes
from them. Reworded to say neither table is purged, sized the growth (one
run row per daily pass, about 365 a year; one history row per move), and
noted the history has to keep at least MinimumDaysBetweenMoves days anyway
since the next pass reads MAX(changed_at) from it. Doc comment only, V144Sql
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Pushed three pre-merge fixes to this branch as one commit: da47a539 (new head da47a5392972261d4b5d8a47fd4bdfb0ea5b4587, was 117cb49f).

Item 1: invariant-culture reason text

Darling/PerformanceMonitor.Darling.Storage/RawChunkIntervalPlanner.cs — wrapped all seven Decision reasons in string.Create(CultureInfo.InvariantCulture, $"...") and added using System.Globalization;. Text unchanged otherwise. Final line numbers: 215, 221, 252, 260, 291, 298, 308.

Added using System.Globalization; to Darling/Darling.Tests/RawChunkIntervalPlannerTests.cs and one new fact, ReasonText_FormatsNumbersInvariantly_UnderACommaDecimalCulture (line 379), plus a private nested CultureScope : IDisposable copied from PgLogEventMetricsTests's pattern. The fact switches to de-DE, runs one narrowing Plan (budget 1,000,000 B, one table at 1,000,000 B/h, 24h) and one widening Plan (budget 2,400,000 B, one table at 100,000 B/h, 6h, last changed 3 days ago), and asserts each changing decision's Reason equals the exact invariant string:

  • narrowing: "moved to 12 h: store-wide open-chunk bytes exceeded the 1,000,000 B budget (rate-ordered, 1,000,000 B/h)"
  • widening: "moved up to 12 h: the store holds 1,200,000 B, under half the 2,400,000 B budget"

Revert-proofed by hand: dropped the string.Create wrap from the narrowing ("moved to") reason alone, rebuilt, ran the new fact by itself, and it failed:

Darling.Tests.RawChunkIntervalPlannerTests.ReasonText_FormatsNumbersInvariantly_UnderACommaDecimalCulture [FAIL]
  Assert.Equal() Failure: Strings differ
                                         ↓ (pos 57)
  Expected: ···"hunk bytes exceeded the 1,000,000 B budget (rate-o"···
  Actual:   ···"hunk bytes exceeded the 1.000.000 B budget (rate-o"···
                                         ↑ (pos 57)

That is de-DE's dot grouping leaking into the persisted reason, exactly the bug this item fixes. Restored the wrap, rebuilt, reran: passes again.

Item 2: ReconcileAsync's summary now says what it actually does on a per-table failure

Darling/PerformanceMonitor.Darling.Storage/RawChunkIntervalReconciler.cs, lines 110-118 (ReconcileAsync's <summary>). Confirmed by reading the method: each changing decision gets its own BeginTransactionAsync/CommitAsync pair (lines 170-202), with no catch anywhere in the foreach loop or around it. Reworded the summary from "Never throws for a planning or apply failure on one table" to state a failure throws out of the method, that table's transaction rolls back, tables already committed earlier in the same pass stay applied and recorded, and the remaining tables and the run row (inserted only after the loop, lines 211-215) wait for the next daily pass. Also names the actual catch: DarlingWorker.cs line 7804-7806 (catch (Exception ex) { _logger.LogError(ex, "raw chunk interval reconcile failed"); }). Comment only, no code change.

Item 3: V144's doc comment no longer claims a purge that doesn't exist

Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs, lines 2046-2058 (the V144Sql doc comment; V144Sql itself untouched). Grepped Darling.Storage and Darling.Service for both table names first: the only hits outside this doc comment and the CREATE TABLE/index statements are one SELECT ... MAX(changed_at) read (RawChunkIntervalReconciler.cs line 68) and two INSERTs (lines 98, 106) — nothing deletes from either table. Reworded to say neither table is purged, sized the growth (the run table gains one row per daily pass, about 365 a year; the history one row per move), and noted the history has to keep at least MinimumDaysBetweenMoves days anyway because the next pass reads MAX(changed_at) from it (RawChunkIntervalPlanner.cs line 83, value 1). Left the existing "outside CollectorCatalog.All" reasoning (self-telemetry about the store's own tuning, not collected monitoring data) as-is.

Build and test

Darling/Darling.Tests/Darling.Tests.csproj -c Debug: Build succeeded, 0 Warning(s), 0 Error(s).

  • *RawChunkIntervalPlannerTests*: Total: 19, Errors: 0, Failed: 0, Skipped: 0
  • *DocCommentHygiene*: Total: 77, Errors: 0, Failed: 0, Skipped: 0

No live tests run (none needed for these three items; CI runs the full suite). Installer.Tests not run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 25, 2026 20:16
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 25, 2026 20:16
@erikdarlingdata
erikdarlingdata merged commit 32f728a into dev Sep 25, 2026
17 of 18 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/4211-chunk-interval-wiring branch September 25, 2026 20:25
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
…s rung V144 -> V145

#4344 took V144 on dev. Lane B4 stopped at its turn limit mid-renumber; this commits its
work as it stood (product side renumbered: V145 migration, StorageVersion 145, the probe arm,
both min-schema constants; the rung test and one doc comment partly renumbered). The
finishing lane completes the test side and runs the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
The dev merge (#4344) took V144 for raw-chunk-interval-rung-history and
moved this PR's wide-interval-table rung to V145. Update the leftover
test references that still said V144/144 for the wide rung:
QueryStoreIntervalWideWriterTests' migration lookup and doc comment,
QueryStoreIntervalWideGridLiveTests' and QueryStoreTopMcpLiveTests'
"V144 is present" comments, and QueryStoreIntervalWideRungTests' V144
field name (it already used the correct RungVersion=145, just under a
stale name).

Also fix a real regression the full suite caught:
McpReadCommandTimeoutTests' receiver census flagged the new
TryGetQueryStoreTopFromTableAsync's two NpgsqlCommand constructions
(a literal SQL string, and transaction passed positionally) as
unrecognised receivers. The census deliberately does not auto-accept
a 3-arg NpgsqlCommand(sql, connection, transaction) shape anywhere in
.Service, because the HypoPG experiment's monitored-TARGET command
uses the identical shape. Named the literal SQL and switched both
constructions to the already-recognized 2-arg
NpgsqlCommand(sqlIdentifier, connection) form with Transaction set
through the object initializer instead of positionally - same
behavior, unambiguous receiver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata added a commit that referenced this pull request Sep 26, 2026
* Add the wide Query Store interval table's write side (#3953)

Lane B1: the table, writer, coverage claim, purge and schema gate for
a second latest-snapshot-per-interval table that holds every outcome
(Regular, Aborted, Exception) beside V143's Regular-only table, per
review D4R + F1's measurement (split shape (c), ruled). B2 builds the
three reads on this branch next.

* Add live tests for the wide interval table, and demote V143's rung test (#3953)

Lane B1: rung tests for V144 (mirroring V143's own rung file's shape),
live writer tests (table-equals-raw-dedup across every outcome, a
wide-only apply fault, the old-writer gap check, and 9-day retention),
merged with origin/dev (no conflicts, no renumbering needed).

Demotes QueryStoreIntervalLatestRungTests' "top rung" claims to
QueryStoreIntervalWideRungTests, the same transformation V143 itself
applied to V142's rung file when it landed.

* B2: build the wide interval table's read gate and move the Queries grid onto it (#3953)

QueryStoreIntervalWide.UseTable/ReadsTableAsync (Storage) decide, per the ruling
(issuecomment-5836972848) and review D4R items H3/M1, whether a grid/MCP/slicer read may use
collect.query_store_interval_wide instead of raw. ViewerDataService.QueryStoreTopSql is split into a
shared suffix plus a raw prefix (byte-identical to before) and a new table prefix, chosen per call by
the gate inside one read-only REPEATABLE READ transaction so the decision and the read see the same
snapshot.

* Make the table read's transaction read-only and fault-safe (#3953)

TryGetQueryStoreTopQueriesFromTableAsync's REPEATABLE READ transaction
was not marked READ ONLY, unlike the gate's own read intent. It also
had no fault handling: an exception from opening the connection,
starting the transaction, running the gate, or reading the table would
surface to the caller instead of falling back to raw, unlike
QueryStoreIntervalWide.ReadsTableAsync, which already catches its own
faults (except cancellation) and returns the "read raw" answer.

* Add the #3953 grid live pass: gate, clamp, and raw/table equality (#3953)

QueryStoreIntervalWideGridLiveTests.cs runs the Queries grid's gate and
table read against a real PostgreSQL store, seeded through the real
write path (SeedGridAsync, reusable by a later lane for the MCP and
slicer reads): several intervals, every outcome, an open interval
re-fetched across three collections, and two databases.

- QueryStoreTopTableSql at the gate's clamped start equals
  QueryStoreTopSql (EXCEPT ALL both ways, every column) for both an
  open end and a literal end at applied_through, and
  GetQueryStoreTopQueriesAsync's end-to-end rows equal raw's.
- The clamp: once raw's oldest chunk is dropped (a real
  TimescaleDB hypertable/drop_chunks), the table read still equals
  raw when bound at ClampedStart, and provably does not without it.
- The live gate: a pending row, a literal end before applied_through,
  and a sub-12-hour window each force UseTable=false on a real
  connection; the passing case returns true.

222 tests green: the two new classes plus every class in
*QueryStoreInterval*, ViewerQueriesTests, QueryStoreTopWindowTests,
DatabaseFilterTests, *QueryStoreSliceTieBreak*,
ViewerSchemaVersionGateTests, StorageCommandTimeoutTests,
LivePostgresCollectionHygieneTests, LiveCleanupConversionRatchetTests
and DocCommentHygiene*.

* B3b: move the Queries slicer onto the #3953 wide-interval table

Adds QueryStoreSlicerTableSql (query_store_interval_wide) beside the
unchanged QueryStoreSlicerSql, gated per ruling issuecomment-5836972848:
QueryStoreIntervalWide.ReadsTableAsync called with the slicer's own
raw floor (windowStart - 1 hour, since the raw statement reads from
$2 - interval '1 hour'), minWindow: Zero so its bundled threshold clause
never fires on the widened window, and two clauses specific to this
read checked separately: QueryStoreSlicerMinWindow on the true
requested window, and QueryStoreSlicerHasLegacyRowSql, a defensive
gate that sends the whole read to raw if the table holds any row whose
interval_start_time_utc is null (a pre-#1841 legacy snapshot) --
for such a row the table keeps only the interval's overall latest
snapshot while raw's dedupe keeps the latest snapshot collected inside
the window, and those can be different rows with no way for the table
alone to recover the earlier one.

GetQueryStoreSlicerDataAsync gains literalEndUtc (default null), and
ViewerServerTab.Queries's tab-window caller passes IsCustomRange ?
endUtc : null, matching the grid's existing convention. The shared
ReadQueryStatsSlicerAsync and the query/procedure slicers that use it
are untouched.

* Move the MCP get_query_store_top read onto the #3953 wide-interval gate

Splits DarlingDataReader.QueryStoreTopSql into a raw prefix, a table prefix
reading query_store_interval_wide, and a shared suffix (the same shape B2
used for the viewer's grid), and wires GetQueryStoreTopAsync to try the table
on one read-only REPEATABLE READ connection via QueryStoreIntervalWide.ReadsTableAsync,
falling back to the unchanged raw statement on any gate refusal or fault. The
web viewer's /api/read/get_query_store_top mirror reaches the same gate since
it calls the same method.

* B4: index the slicer's legacy-row probe, add the NULL-start live test (#3953)

QueryStoreSlicerHasLegacyRowSql runs WHERE server_id = $1 AND
interval_start_time_utc IS NULL on every slicer table read.
ux_query_store_interval_wide doesn't carry interval_start_time_utc, so
absent a matching index the probe scanned the server's whole row set in
the common case where no such row exists. Add a partial index on V144's
own text (server_id) WHERE interval_start_time_utc IS NULL, near-empty
in steady state since the column has been populated since #1841.

EXPLAIN on 50k seeded rows for one server: before, a Seq Scan reading
1250 buffers in 10.3ms; after, an Index Only Scan reading 2 buffers in
0.03ms.

Add the live test B3b's brief called out but didn't write: with no
NULL-start row for a server every other gate clause holds, the slicer
reads the table and a table-only sinkhole row shows up; adding exactly
one NULL-start row for the same server, unchanged otherwise, flips the
read to raw and the sinkhole row disappears. Proved against the old
shape by disabling the hasLegacyRow branch locally: the new test failed
as expected, then passed again once reverted.

* B4: pin the wide-table min-schema-version constants to V144 (#3953)

Name the grid's and slicer's >= 144 checks (ViewerDataService.QueryStore.cs,
previously bare literals) as one shared QueryStoreIntervalWideMinSchemaVersion
constant, alongside B3a's own DarlingDataReader.QueryStoreTopTableMinSchemaVersion,
which stays separate and untouched: that surface compares the compiled
StorageVersion.SchemaVersion instead of probing the store, since the service
always migrates before it serves.

Add QueryStoreIntervalWideMinSchemaVersionPinTests, which finds the
PgMigrations entry that creates collect.query_store_interval_wide by what it
creates (not by today's version number) and asserts both constants equal it,
so a renumber can't miss one. Proved by setting the viewer's constant to 145
locally: the test failed with "Expected: 144 Actual: 145" as expected, then
passed again once reverted.

* Finish the V144->V145 renumber and fix the MCP command census (#3953)

The dev merge (#4344) took V144 for raw-chunk-interval-rung-history and
moved this PR's wide-interval-table rung to V145. Update the leftover
test references that still said V144/144 for the wide rung:
QueryStoreIntervalWideWriterTests' migration lookup and doc comment,
QueryStoreIntervalWideGridLiveTests' and QueryStoreTopMcpLiveTests'
"V144 is present" comments, and QueryStoreIntervalWideRungTests' V144
field name (it already used the correct RungVersion=145, just under a
stale name).

Also fix a real regression the full suite caught:
McpReadCommandTimeoutTests' receiver census flagged the new
TryGetQueryStoreTopFromTableAsync's two NpgsqlCommand constructions
(a literal SQL string, and transaction passed positionally) as
unrecognised receivers. The census deliberately does not auto-accept
a 3-arg NpgsqlCommand(sql, connection, transaction) shape anywhere in
.Service, because the HypoPG experiment's monitored-TARGET command
uses the identical shape. Named the literal SQL and switched both
constructions to the already-recognized 2-arg
NpgsqlCommand(sqlIdentifier, connection) form with Transaction set
through the object initializer instead of positionally - same
behavior, unambiguous receiver.

* Fix a stacked-summary regression from the census fix (#3953)

My previous commit inserted SetTransactionReadOnlySql's own doc
comment between TryGetQueryStoreTopFromTableAsync's existing
<summary> block and the method itself, which DocCommentHygieneTests
correctly caught as two stacked summaries. Move the constant and its
comment above the method's own doc block instead, so each summary sits
directly over its own member.

* Set #3953 wide-table thresholds from measured 12-hour cells (B4t)

Ruling issuecomment-5836972848 item 3: read the table only for windows
of 12 hours or more, unless the table is slower than raw at the ruled
12-hour cell, in which case that read's threshold becomes 24 hours.

Measured all three reads (grid, MCP top, slicer) end-to-end through
their C# read paths on rig-d4, a 15-day/3.46M-row seed at a field
store's rate, median of 5 alternating runs per cell:
- grid stays at 12 hours: table 1488.1 ms vs raw 1725.6 ms at 12h.
- MCP top moves to 24 hours: table 737.0 ms vs raw 581.1 ms at 12h.
- slicer moves to 24 hours: table 815.8 ms vs raw 595.3 ms at 12h.

* Remove the Query Store slicer's table route (#4341, ruling issuecomment-5840737421)

Timing showed raw beats the table at 6h/12h/24h with non-overlapping
spreads, and ties at 7d, so any threshold would have made every 24h-7d
slicer read slower. GetQueryStoreSlicerDataAsync now reads the raw
statement at every window, unchanged from before #3953's table route
existed. The grid's 12h route and MCP top's 24h route are untouched.

Removed: QueryStoreSlicerTableSql, the QueryStoreSlicerRawPrefix /
QueryStoreSlicerTablePrefix / QueryStoreSlicerSuffix split (folded
back into one QueryStoreSlicerSql, byte-identical raw SQL),
QueryStoreSlicerMinWindow, QueryStoreSlicerHasLegacyRowSql,
TryGetQueryStoreSlicerDataFromTableAsync, and the now-unused
literalEndUtc parameter on GetQueryStoreSlicerDataAsync and its one
caller. Deleted QueryStoreIntervalWideSlicerLiveTests.cs (existed only
to pin the removed table route). Lite has no twin slicer table route
from #3953, so there is nothing to remove there.

* Check the window before the #3953 gate's round trips (review H1, M3)

H1: a short-window or literal-end-before-applied_through grid/MCP read can only
ever land on raw, so check those clauses BEFORE opening the second connection,
transaction, or the unindexed PlainTableFloorSql scan. The viewer and MCP top
reads both check the window first; QueryStoreIntervalWide.ReadsTableAsync also
short-circuits clause 4 and clause 2 before the table-floor round trip.

M2 (partial, unverified grant finding reported separately): added a Trace
warning to both silent fallback catches so a permission failure can't fall
back to raw forever with no log.

M3: cache the viewer's schema-version probe per ViewerDataService instance,
consulted only after the window check.

Adds a live round-trip-counting test (pg_stat_user_tables.seq_scan/idx_scan)
proving a 6h grid read touches query_store_interval_wide zero times.

* Drop V145's slicer-only partial index, renumber the #3953 comments to V145, restore file BOMs (#3953)

* Apply the fault-injection collection to QueryStoreIntervalWideWriterTests

rf2 added QueryStoreIntervalWideFaultInjectionCollection with
DisableParallelization but never attached it to the writer class it
was written for. Kept the existing #1776 own-store exemption comment
(the class still mints its own ScratchPostgres database and does not
race live-postgres), and added the missing [Collection] attribute so
the class actually serializes against the other ScratchPostgres
classes on this assembly's parallel pool instead of racing them.

* Clean up stale grid/MCP/slicer text and the unused literalEndUtc param (M1 b-d)

The slicer route to the wide table was removed by an earlier commit,
but three doc comments still called the wide-table read decision
'grid/MCP/slicer' and one comment still described a partial index
that was already deleted. Rewrote them to 'grid/MCP top', which is
what actually reads the table now.

Also removed the unused literalEndUtc parameter from
GetQueryStoreSlicerDataAsync, left over from the same removed route.
No caller passed it by name, so the change is source-compatible for
every existing call site.
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