Skip to content

Event-windowed reads: bound by collection_time so TimescaleDB skips out-of-window chunks (#4229) - #4235

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/4229-event-window-floor
Sep 25, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
fix/4229-event-window-floor

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Part of #4229.

Lane V2b. Lane V2 finished the SQL source changes and the string-level category pin for all 6 read groups. It did not stand up a rig or run anything live. This PR adds the rig verification, the live-Postgres correctness proof, all required gates, and a full UTC suite run.

Why

#3895 added EventWindowFloor for one problem: a read windowed only on an event's timestamp (event_time, deadlock_time) gives TimescaleDB nothing to exclude a chunk on. The table is partitioned on collection_time, not the event column. The floor adds a collection_time lower bound the planner can act on. An event is always collected at or after it happens, so the floor cannot drop a qualifying row. That fix covered the fleet overview reads only.

Six more reads listed in #4229 have the same shape and never got the floor: BlockingTrendSql, SystemHealthEventsByTypeSql, GetJobHistoryAsync, DefaultTraceEventsByWindowSql, BlockingDurationStatsSql with BlockingPairRowsSql, and MemoryPressureEventsSql.

What changes

Every read above, and its MCP/service twin where one exists, now carries AND collection_time >= $N. The parameter is EventWindowFloor.For(windowStart): naive UTC, one chunk before the window. It is bound as a parameter, not an expression, so the planner can see it and exclude chunks. The event-time predicate itself is untouched.

  • BlockingTrendSql (viewer and DarlingBlockingTrendReader). Both the XE and DMV-fallback CTEs get their own floor.
  • SystemHealthEventsByTypeSql (viewer and DarlingSystemHealthReader). One shared statement covers all 9 System Events readers and 10 sub-tabs. Both texts are pinned identical.
  • GetJobHistoryAsync, split into a testable BuildJobHistorySql(bool scopedToServer). The floor binds against collection_time directly, not the de-skewed run_datetime expression.
  • DefaultTraceEventsByWindowSql (viewer) and DarlingDefaultTraceReader.EventsByWindowSql. The floor binds against collection_time directly rather than the de-skewed expression.
  • BlockingDurationStatsSql (viewer and DarlingDataReader). Same two-CTE shape as BlockingTrendSql.
  • BlockingPairRowsSql (viewer only, consumed only by the block-chain viewer UI).
  • MemoryPressureEventsSql (viewer only). Windows on sample_time, floored the same way.

Two items from the issue are out of this PR (see "Not mine" below). The Job History GROUP BY rewrite and 30-second timer change belong in a later PR. GetAgentStatusAsync's LATERAL rewrite is #3947.

Job 1: rig numbers

Seeded probe (a scratch Postgres database on the rig, migrated via PgMigrations) with 4 days of out-of-window history and 3 in-window rows per table. Ran EXPLAIN (ANALYZE, BUFFERS) for a 24-hour window, old text vs. new.

This is a small synthetic seed (1-2 rows per chunk). The absolute buffer/row counts are tiny. What it isolates is the floor's actual mechanism: fewer chunks opened at plan time. The issue's field numbers (204 ms to 5 ms) are the production-scale evidence. This table confirms the mechanism.

Read Chunks (old / new) Planning time (old / new) Execution time (old / new) Planning buffers (old / new)
BlockingTrendSql (2 CTEs) 12 / 6 18.066 ms / 0.806 ms 0.415 ms / 0.107 ms 1400 / 108
SystemHealthEventsByTypeSql 6 / 3 8.993 ms / 0.324 ms 0.093 ms / 0.029 ms 457 / 36
DefaultTraceEventsByWindowSql 6 / 3 9.312 ms / 0.466 ms 0.149 ms / 0.060 ms 526 / 42
BlockingDurationStatsSql (2 CTEs) 12 / 6 1.015 ms / 0.586 ms 0.283 ms / 0.108 ms 140 / 50
MemoryPressureEventsSql 6 / 3 8.810 ms / 0.308 ms 0.082 ms / 0.029 ms 467 / 36
BuildJobHistorySql(false) 6 / 3 8.521 ms / 0.428 ms 0.101 ms / 0.045 ms 471 / 41
BlockingPairRowsSql 6 / 3 0.398 ms / 0.267 ms 0.053 ms / 0.034 ms 101 / 28

BlockingDurationStatsSql's old planning time is an outlier low. It ran right after BlockingTrendSql's identical tables in the same psql session, with a warm catalog cache. Chunk count and buffers are the reliable columns across the whole table. The two-CTE reads plan up to 3 chunks per CTE for a 24-hour window. That is the window's own 1-2 chunks plus the floor's one extra day, doubled because two hypertables are scanned. The issue's "3 chunks or fewer" wording covers a single-table scan.

Job 2: the issue's live pin

On that same seeded probe store, EXPLAIN of SystemHealthEventsByTypeSql for the 24-hour window touches exactly 3 chunks with the floor and 6 without. Both counts are reproduced automatically by EventWindowedReadsAreBoundedLivePostgresTests (below). It runs both texts every time and asserts the floored one is substantially smaller than the oracle's own footprint.

Job 3 and the rest of Job 2: EventWindowedReadsAreBoundedLivePostgresTests

New live-Postgres test class, Darling/Darling.Tests/EventWindowedReadsAreBoundedLivePostgresTests.cs, [Collection("live-postgres")]. Seeds 4 days of out-of-window chunks plus 3 in-window rows per table. The 3 in-window rows cover a normal case, a row 20 hours late to collection, and a row with the event clock 50 minutes ahead. All sit well inside EventWindowFloor.SkewAllowance.

  • TheFlooredReads_ReturnExactlyWhatTheUnflooredReadsReturned: for each of the 7 statement shapes, the floored text and its pre-Reads windowed on an event timestamp have no collection_time floor, so they scan every retained chunk: system health 204→5 ms (10 sub-tabs), default trace 169→25 ms, Job History 1.9→1.2 s (WPF viewer and web/MCP twins) #4229 text return byte-identical row sets. Count is asserted non-vacuous.
  • TheFlooredReads_PlanAtMostThreeChunksForA24HourWindow: EXPLAIN (COSTS OFF) for each group, asserting the floor excludes at least 3 of the 4 seeded old-day chunks. This is a relative assertion (floored chunks far below the oracle's own chunk count), not the issue's literal "3 or fewer". The darlingtest store is shared by every live class in the suite. Other classes seed rows with near-"now" timestamps that can land inside this test's floor window and inflate its chunk count. FleetReadsAreBoundedByTheFleetTests hit the same problem and used the same fix. I confirmed by hand against a fresh database that the floored system-health plan touches exactly 3 chunks for a 24-hour window. That is the literal proof of the issue's pin. The committed regression test uses the relative form so it stays green on the shared store.

Two existing tests were casualties of this change, both mine to fix. ViewerMemorySqlTests.MemoryPressureEventsSql_WindowsOnSampleTime_SelectsIndicators asserted that MemoryPressureEventsSql never mentions collection_time. The floor makes that false. Updated to assert the floor predicate instead of its absence. LivePostgresCollectionHygieneTests failed on the new class for missing a shared-store declaration. Added [Collection("live-postgres")].

Job 4: Lite parity

Lite's DuckDB store has no hypertable or chunk concept and no retention partitioning like TimescaleDB. It is a single local file per machine. DuckDB's row-group min/max zonemap pruning happens automatically per-column at scan time. It does not require a predicate on a declared partition column the way TimescaleDB's chunk exclusion does. The failure mode #4229 fixes has no DuckDB-side analog.

A collection_time bound helps DuckDB skip row groups time-clustered by insertion order. This lane did not measure that. The bound is not ported to Lite's copies of these reads without evidence it does anything. A follow-up lane can measure it directly in Lite.Tests (DuckDB runs in-process there, no rig needed).

Job 5: gates and the full suite

  • DocCommentHygiene, StorageCommandTimeoutTests, FleetReadsAreBoundedByTheFleetTests (and its live class), McpPayloadContractCensusTests, EventWindowedReadsCarryTheFloorTests, EventWindowedReadsAreBoundedLivePostgresTests: 195/195 pass together.
  • git merge origin/dev: clean, no conflicts.
  • Full Darling.Tests suite, rig set to UTC, fresh darlingtest: 13,809 total, 0 failed, 47 skipped, 1 not run. ServerListAndSummaryPlanShapeTests, CaptureDownChunkOrderTests, and ViewerW2aLivePostgresTests.ServerSummary_... all passed. They were a local time-zone artifact, not regressions from this PR.
  • Lite.Tests: not run. No Lite source changed this PR.

Not mine

Two items from the issue stay out of this PR, per the brief. The Job History GROUP BY rewrite and 30-second timer change belong in a later PR after this one merges. GetAgentStatusAsync's LATERAL ... LIMIT 1 rewrite is #3947.

CHANGELOG entry

SECTION: Fixed
ENTRY:

Generated with Claude Code

https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ

erikdarlingdata and others added 4 commits September 25, 2026 00:31
#3895 (#4229)

BlockingTrendSql, SystemHealthEventsByTypeSql, GetJobHistoryAsync,
DefaultTraceEventsByWindowSql, BlockingDurationStatsSql, BlockingPairRowsSql and
MemoryPressureEventsSql (viewer + MCP/service twins where they exist) window only
on an event's own timestamp, so on a hypertable partitioned on collection_time they
scan every retained chunk. Each now also carries
AND collection_time >= EventWindowFloor.For(windowStart), one chunk before the
window, bound as a parameter so the planner can exclude chunks. Results are
unchanged: an event is always collected at or after it happens, within the floor's
one-chunk skew allowance.

GetJobHistoryAsync's SQL builder is split into a testable BuildJobHistorySql(bool)
so both the fleet-wide and single-server parameter shapes can be pinned without a
live Postgres.

Source-only checkpoint; pin tests, live tests and rig EXPLAIN numbers follow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
EventWindowedReadsCarryTheFloorTests enumerates every statement/twin touched by
the previous commit (BlockingTrendSql, SystemHealthEventsByTypeSql,
DefaultTraceEventsByWindowSql/EventsByWindowSql, BlockingDurationStatsSql,
BlockingPairRowsSql, MemoryPressureEventsSql, both BuildJobHistorySql parameter
shapes) and asserts each event-time scan carries its own
"collection_time >= $N" predicate, counting scans rather than just detecting one,
since the two-CTE XE/DMV reads have two. Positive controls hold each statement's
pre-#4229 text and must count zero, so the rule is proven non-vacuous on the tree
this issue was filed against. 16/16 pass.

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

EventWindowedReadsAreBoundedLivePostgresTests seeds each of the six #4229 read groups' tables past
3 days of chunks and proves both halves the string-level pin cannot: the floored statement returns
exactly what its unfloored predecessor returned (including a late-collected row and a row whose
event clock ran ahead of its collection), and the floor lets TimescaleDB exclude chunks it plans
against the oracle's own footprint rather than a hard ceiling, since darlingtest is a store every
live class shares. Updates ViewerMemorySqlTests' pre-#4229 assumption that MemoryPressureEventsSql
never mentions collection_time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata erikdarlingdata changed the title DO NOT MERGE (event-time reads): bound event-windowed reads by collection_time so they stop scanning every chunk (#4229) Event-windowed reads: bound by collection_time so TimescaleDB skips out-of-window chunks (#4229) Sep 25, 2026
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 25, 2026 06:11
@erikdarlingdata
erikdarlingdata merged commit e5f9a96 into dev Sep 25, 2026
19 of 20 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4229-event-window-floor branch September 25, 2026 06:11
erikdarlingdata added a commit that referenced this pull request Sep 25, 2026
…of (flake from #4235) (#4275)

EventWindowedReadsAreBoundedLivePostgresTests failed dev's Build runs 36115696476
and 36117118128 on job_history's AssertChunksAsync. #4229's GROUP BY fix split the
floored job_history read into two independently floor-bounded scans (job_stats CTE
plus the base join), so the floored plan now visits every in-window chunk twice.
PlanChunkScans.Count counts scan nodes, not physical chunks, so that doubling ate
the two-chunk reduction margin the test asserted, even on a freshly created
database with nothing else in it (measured old=7, new=6 raw nodes, reduction 1).

Add PlanChunkScans.DistinctChunkCount and use it in AssertChunksAsync so a chunk
scanned twice by two Append branches over the same hypertable counts once. Measured
after the fix: old=7, new=3 distinct chunks, reduction 4, comfortably clearing
minReduction: 2. Confirmed the assertion still catches a real regression: with the
floor neutralized for one read, old=7, new=7, and the test correctly fails.


Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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