perf(clickhouse): split mid-hour windows so grouped event reads route - #35
Merged
Merged
Conversation
The event projections added in #34 key on toStartOfHour(time), and the rewrite onto that key only applies when both window bounds land exactly on an hour. Console windows never do, so the rewrite always declined and the projections were never used. Split a mid-hour window into an hour-aligned interior, which is expressible on the projection key, plus the one or two partial hours either side, which stay on raw `time`. The branches partition the window, so an outer SUM over their UNION ALL is exact; ORDER BY / LIMIT move to that outer query. A window with no whole hour in it, a sub-hour interval, `aggregate('max')`, gauges, and any shape no projection covers all keep their single query. Expand the slate to the dimensions the console breaks down on: method, status, clientType, clientName, deviceName, osName, sdk. Each is its own single-dim projection — a shared (method, status) one keys the cross product, 19.2M rows on a 100M-row fixture against 1.15M + 2.55M apart.
Greptile SummaryThe PR accelerates mid-hour grouped ClickHouse event reads by splitting them into projection-routable whole-hour interiors and raw-table edge windows, then recombining their aggregates.
Confidence Score: 5/5The PR appears safe to merge, with no concrete correctness or security defect established in the changed paths. The split branches form disjoint half-open windows, retain millisecond precision, independently rename bindings, re-sum additive event aggregates before pagination, and fall back for unsupported dimensions, intervals, gauges, and aggregate modes. Important Files Changed
Reviews (1): Last reviewed commit: "perf(clickhouse): split mid-hour windows..." | Re-trigger Greptile |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
#34 reshaped the three event projections onto
(tenant, metric, toStartOfHour(time,'UTC'), dims)and addedbucketAlignedFilters()to re-expresstimepredicates onto that hourly key. The rewrite only applies when both bounds land exactly on an hour (if ($bound % 3_600_000 !== 0) return null;).Console windows never do — the production request that motivated this was 900,000 ms off the hour. So the rewrite always declined, the projections were never used, and breakdown queries fell back to a full base-table scan.
The split
A mid-hour window is now read as up to three branches that partition it:
toStartOfHour(time,'UTC')— the only part expressible on the projection key, and where nearly all the data istimeagainst the base table, bounded at2/windowHoursof the scanThey are
UNION ALLed and re-summed in an outer query.ORDER BY/LIMIT/OFFSETmove to that outer query, since no single branch holds a whole group. Each branch compiles independently and numbers its bindings fromparam0, so each set is renamed apart (b0_,b1_, …) before the branches are merged.The read stays on its single unsplit query when there is nothing to gain:
time15m)aggregate('max')sum(value)TYPE_GAUGE)timeSlate
Added
method,status,clientType,clientName,deviceName,osName,sdk— the dimensions the console breaks down on. Each is single-dim: a shared(method, status)projection keys the cross product, measuring 19.2M rows on a 100M-row fixture against 1.15M + 2.55M for the two on their own.ip,hostname,cityandresourceIdstay off as too high-cardinality (resourceIdalone keys 3.1M).Measured — 100M-row fixture, 26-day window 15 min off the hour, whale tenant
Rows and marks read are cache-independent;
ch_msis fromsystem.query_logwith mark, uncompressed and query-condition caches dropped before each run.groupBy(clientName)p_by_clientNamegroupBy(deviceName)p_by_deviceNamegroupBy(clientType)p_by_clientTypegroupBy(service)p_by_servicegroupBy(country)p_by_country1h/1d/1w/1M1d+groupBy(clientName)p_by_clientNamep_by_clientNamep_by_clientNamegroupBy(resourceType, resourceId)15mintervalProjection footprint on that fixture: 1,042,500 rows / 1.79 MiB across six projections against a 100M-row / 1.18 GiB base table.
pathis the exception — measure it before trusting itThe synthetic fixtures generate
pathas one of 200 templated routes. Productionpathholds resource ids, not route templates (/v1/databases/682c…/collections/682c…/documents), so its cardinality tracks a customer's resource count. The 229x improvement the 200-path fixture shows forp_by_pathdoes not generalise.Re-measured on a fixture with realistic
pathcardinality — 20M rows, one tenant, 2 metrics, 26 days, 30,000 distinct paths:path(30k values)country(100 values)clientType(4 values)And the storage cost on the same fixture:
p_by_pathp_by_countryp_by_clientTypeGlobal distinct keys (ignoring the per-part duplication above):
p_by_pathis 9.70M against 20M base rows — 48.5% of the table.So
p_by_pathcosts more disk than the columns it summarises, is written on every insert, and returns ~1.27x on the read. This PR keeps it as briefed, but the number that should decide its future is a productionuniqExact((tenant, metric, toStartOfHour(time), path))— nobody has that yet. The bounded-enum dims (clientType,deviceName,clientName,osName,method,status,sdk,country,service) are where the wins are, and they are the ones this PR adds.Correctness
Results are byte-identical to the unsplit query. Proven by running the same
find()calls againstorigin/mainand this branch on the 100M-row fixture and diffing the serialisedMetricdocuments:orderByvalue/time/dim with offset,between/ exclusive / open-ended bounds, sub-second edges, filters on and off the projection,aggregate('max'),15m— all identical.One caveat, pre-existing and unchanged:
ORDER BY value DESC LIMIT nhas no tiebreaker, so when groups tie at the cut-off which ones come back is arbitrary. On the realistic fixture 195 paths tie at the top value, andorigin/maindisagrees with itself across two runs on that shape. This PR does not change that.TYPE_EVENT+ grouped remains the gate — it is never relaxed to "aggregation hint present".TYPE_GAUGEreads (VerifyBilledUsage, concurrency rollups) never reach the split and keep their rawtimepredicate, asserted bytestGaugeReadIsNeverSplit. A 5-minute-aligned delta window keeps its exact results whether or not it is wide enough to peel an hour out of, asserted bytestFiveMinuteAlignedDeltaWindowKeepsItsResults.Tests
New
ClickHouseWindowSplitTest(33 tests): parity against aoptimize_use_projections = 0base-table scan plus asystem.query_log.projectionsrouting assertion for all ten slate dims and every chart interval; top-N limit applied after recombination; one-sided edges; per-branch binding renaming; unsplittable-window fallback;15mon the base table; uncovered dims and filters not split;maxnot split; gauges not split; 5-minute delta windows.ClickHouseDimRoutingTest::testMidHourWindowKeepsRawPredicateAndTotalsasserted that a mid-hour window does not route — the premise this PR inverts. It is nowtestMidHourWindowSplitsSoTheInteriorRoutesand asserts the projection fires with unchanged totals. Two dimensionless assertions were relaxed from namingp_by_pathto "some projection fired", since with ten entries the optimizer's pick among equivalent candidates is arbitrary.Suite: 339 tests, and the set of failing test IDs is identical to an unmodified
origin/maincheckout in the same environment (41DatabaseTestwith no MariaDB locally, 2ClickHouseSchemaTestindex-DDL rendering on ClickHouse 26.9 vs the 25.11 CI image, 1ClickHouseSampleTest). phpstan and pint clean.Notes
ADD PROJECTIONdoes not backfill existing parts. The seven new projections cover newly written parts and parts as they merge; historical coverage needsMATERIALIZE PROJECTION, same as fix(clickhouse): key event projections tenant-first on an hourly bucket #34.getTimeSeriesFromTable()also callsbucketAlignedFilters()and is deliberately left unchanged — the timing-out console queries go throughfind().