fix(clickhouse): key event projections tenant-first on an hourly bucket - #34
Conversation
The per-dim event projections were declared as `GROUP BY metric, time, tenant, <dim>`, which broke them twice over. Tenant landed third, so a projection — sorted by its own GROUP BY order — threw away the tenant-first granule skipping the base table is explicitly ordered for. On a 100M-row table a small tenant's read is rejected outright: "Projection p_cur is usable but requires reading 344 marks, which is not better than the original table with 26 marks". And `time` is DateTime64(3) holding the raw emission timestamp, so the GROUP BY grouped almost nothing: 11.23M rows / 76.98 MiB of projection for a 1.18 GiB table, i.e. a worse-sorted full-size copy. Rekeying to (tenant, metric, toStartOfHour(time), dim) makes the projection O(cardinality) rather than O(rows) — 162.5K rows / 264 KiB, the same size at 10M and at 100M rows — and it is then actually selected. Because the projection stores the bucket and not raw `time`, a read only routes if its time predicate is expressed on the bucket too. The aggregated event reads now do that, but only where the rewrite is exactly equivalent: bounds already on an hour boundary. Anything else keeps its raw predicate and today's result, so no caller's window edge moves. Coarser buckets compose over the hourly key (`toStartOfDay(<bucket>)`) rather than reading raw `time`, which both routes and returns identical values. Sub-hour intervals and all gauge reads are left alone. Gauge projections keep raw `time` — argMax orders on it — and only gain the tenant-first key order.
Greptile SummaryThe PR reshapes ClickHouse event projections around tenant-first hourly buckets and rewrites exactly aligned event-read predicates so those projections can serve grouped queries.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (4): Last reviewed commit: "test(clickhouse): cover 1w and 1M bucket..." | Re-trigger Greptile |
Gauges are reverted to their original shape. Production says they have no problem to fix: over 24h in nyc3, all 246 usage read timeouts named `projects_usage_events` and none named `projects_usage_gauges`, while /v1/usage/gauges served 402 requests over 3h at HTTP 200 throughout — ~27x the request volume of /v1/usage/events and entirely healthy. There is no benefit to trade against that either. Gauges must keep raw `time` because argMax(value, time) orders on it, so a reshape only reorders keys and aggregates nothing: in a fixture the reshaped gauge projections grew 20.84 MiB -> 21.72 MiB at an unchanged row count. That buys four extra DROP + MATERIALIZE mutation pairs per region per table, each with its own read-degradation window. addProjection() is restored verbatim, so the emitted gauge DDL is byte-identical to main; the events shape moves to its own builder rather than a flag threaded through the shared one.
There was a problem hiding this comment.
Pull request overview
Reshapes ClickHouse event projections for efficient tenant-first, hourly aggregation while preserving exact query results.
Changes:
- Adds tenant-first hourly event projections while leaving gauges unchanged.
- Rewrites aligned time predicates to use hourly buckets.
- Adds schema, routing, fallback, and result-parity tests.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/Usage/Adapter/ClickHouse.php |
Implements event projection schema and aligned bucket routing. |
tests/Usage/Adapter/ClickHouseSchemaTest.php |
Verifies event and gauge projection shapes. |
tests/Usage/Adapter/ClickHouseDimRoutingTest.php |
Tests projection routing, fallback, and result parity. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| private const EVENT_TIME_BUCKET = "toStartOfHour(`time`, 'UTC')"; | ||
|
|
||
| /** Sub-hour intervals need detail the bucket summed away. @var list<string> */ | ||
| private const BUCKET_ROUTABLE_INTERVALS = ['1h', '1d', '1w', '1M']; |
There was a problem hiding this comment.
Good catch on the coverage gap — added in 89e7bb3, which turns the day-interval test into a provider over 1d/1w/1M so each asserts both projection use and parity against a base-table scan.
On correctness the composition was already sound: toStartOfInterval(toStartOfHour(t,'UTC'), INTERVAL X) equals toStartOfInterval(t, INTERVAL X) with zero mismatches across 5M timestamps spanning ~5 years, for 1h/1d/1w/1M. Week and month boundaries are whole numbers of hours, so hour truncation nests inside them. But the suite was proving that only for 1d, so the guard was worth having.
Problem
addProjection()declared every per-dim event projection as:Two independent defects:
GROUP BYorder, so this discards the tenant-first granule skipping the base table is deliberately ordered for (ORDER BY (tenant, metric, time, id)). Every read filterstenant … AND metric … AND time ….timeisDateTime64(3)holding the raw emission timestamp, so theGROUP BYgroups almost nothing.Combined, the projection is a worse-sorted, full-size copy of the table — and for a small tenant ClickHouse rejects it outright.
Evidence (ClickHouse 26.9.1, 100M-row synthetic table, 1.18 GiB, 201 tenants, whale = 60% of rows, 26-day window)
Verbatim, from
--send_logs_level=debugon a small tenant:Storage — same dim (
clientName, cardinality 50), same data:(metric, time, tenant, dim)(tenant, metric, hour, dim)The new shape is 162,500 rows at both 10M and 100M base rows — O(cardinality), not O(rows).
Read cost (
SelectedMarks/SelectedRows, cache-independent):Wall clock on the same box (warm cache, 3 runs): small tenant 0.020/0.022/0.021 s → 0.014/0.016/0.016 s; whale 0.032/0.034/0.035 s → 0.017/0.017/0.017 s.
Projection-row count by dimension on the 100M table, i.e. what a dim would cost if added to the slate — the
(tenant, metric, hour)floor is 127.5K rows:servicecountrypathresourceIdWhat changed
1. Projection shape. Events are now keyed
(tenant, metric, toStartOfHour(time,'UTC'), …dims), built by a newaddEventProjection(). Gauges are untouched — see below.2. Read path. The projection stores the bucket, not raw
time, so a predicate ontimecannot be evaluated against it.findAggregatedFromTable()andgetTimeSeriesFromTable()now re-express the time filters on the bucket — only where that is exactly equivalent, i.e. where the bounds already land on an hour boundary:time >= X, X on the hourbucket >= Xtime < Y, Y on the hourbucket < Ytime <= Y, Y+1ms on the hour (e.g.23:59:59.999)bucket < Y+1msbetween(X, Y), both as above>= 13:37,<= now)Verified on all 100M rows: with aligned bounds the totals are byte-identical (1,043,827,325 both ways; 490,351,576 both ways). With an unaligned bound the naive rewrites are wrong in both directions — 986,619,370 (narrower) or 990,706,150 (wider) against a true 988,160,900 — which is exactly why they are declined rather than approximated.
3. Coarser intervals compose over the bucket instead of nesting a subquery.
toStartOfDay(time)does not match the projection key, buttoStartOfDay(toStartOfHour(time,'UTC'),'UTC')does, and is value-identical: 0 mismatching rows out of 100M for 1h, 1d, 1w and 1M. Sub-hour intervals (1m/15m/30m) would need detail the projection has summed away — 50M/100M rows differ — so they keep reading rawtime.Why gauges are deliberately excluded
An earlier revision of this PR also reshaped the gauge projections. That was dropped, because the case for it does not survive contact with production:
projects_usage_events, zero onprojects_usage_gauges. Over a 3h window/v1/usage/gaugesserved 402 requests, all HTTP 200 — roughly 27x the request volume of/v1/usage/events(15 in the same window), and completely healthy.time:argMax(value, time)orders on it, so bucketing it away would leave grouped argMax reads with nothing to order on. A gauge reshape therefore only reorders keys and aggregates nothing — in a migration fixture the reshaped gauge projections got slightly worse, 20.84 MiB → 21.72 MiB at an unchanged row count.DROP+MATERIALIZEmutation pairs per region per table, each with its own read-degradation window, plus argMax semantics to re-reason about.addProjection()is restored verbatim frommain, and the events shape lives in a separateaddEventProjection()rather than being switched by a flag through the shared one. So the gauge path has a zero-line diff, and the emitted gauge DDL is byte-identical — verified by runningsetup()on bothorigin/mainand this branch against a live ClickHouse and diffingSHOW CREATE TABLE(1312 bytes, md5181c0f67935c543feaa04b80d02960d5, identical).ClickHouseSchemaTest::testGaugeProjectionsAreLeftOnTheirOriginalShapepins the original(metric, time, tenant, dims)key order so this exclusion is not silently "finished" later without fresh measurements.Caller audit — appwrite-labs/cloud and appwrite/server-ce
Every read call site of the library in both repos, and whether this PR can alter its result (not just its speed):
Platform/Tasks/VerifyBilledUsage.php:822-824(×3)getTotalBatch/sumDailyBatch>= from,< to, day-alignedVerifyBilledUsage.php:873getTotal(gauge)< toonlyVerifyBilledUsage.php:891find(gauge) +groupBy('resourceId')< toonlyBilling/Workers/ProjectAggregation.php:1199find(gauge)[firstDay, lastDay)day-alignedUsage/Http/Events/XList.php:203sum(event)>= start,<= endsum()is untouchedUsage/Http/Events/XList.php:232find(event) + interval + dimsUsage/Http/Gauges/XList.php:192,200,220,259,373,459find(gauge)Usage/Concurrency.php:64findAcrossTenants(event) +groupBy('tenant')+1m-family interval5mis sub-hour, excludedUsage/Concurrency.php:112,137findAcrossTenants(gauge)>= now−168hUsage/Connection.php:110-112findAcrossTenants×2,findDailyfindDailyuntouchedWorkers/Deletes.php:175purgeBilling is provably unaffected. Every billing read (C1–C4) either uses an API this PR does not touch (
getTotalBatch,sumDailyBatch,getTotal) or is a gauge read, and gauges are untouched by this PR. Nothing on the billing path reachesfindAggregatedFromTable()orgetTimeSeriesFromTable()onTYPE_EVENT. And even if it did, day-aligned midnight bounds are hour-aligned, so the rewrite would be the exact-equivalence case.The only behaviour change available to any caller is a faster plan for an identical result. There is no opt-in flag because none is needed: the rewrite is declined rather than approximated whenever equivalence cannot be proven.
setup()is skipped in production (cloud'sapp/http.phpprovisioning block is insideif ($isProduction) { … return; }, andapp/init/resources.php:799-805wraps the adapter soConnection::setup()is a no-op). Production tables are provisioned by a separate out-of-band script, so nothing here migrates itself.ADD PROJECTION IF NOT EXISTSwill silently keep the old definition on an existing table.Per region, on the events table only (namespaced
projects_*andconsole_*in cloud) — three projections,p_by_path,p_by_country,p_by_service:Gauge tables need no action at all. The migration script compares live projection definitions against what the library emits, and gauges now match byte-for-byte, so it will skip them on its own.
Watch
system.mutationsfor completion. Step 3 rewrites every part of the events table, so run it off-peak; the new projections are ~300× smaller than the ones being dropped, so the steady-state disk and write-amplification cost goes down, not up.Until step 3 completes, reads fall back to the base table — the same plan a small tenant already gets today, so there is no window where reads are worse than before.
Follow-up (deliberately not in this PR)
path,country,serviceare projected. The reshape makes a low-cardinality dim nearly free (127.5K floor → 162.5K for a cardinality-100 dim), soclientType,clientName,osName,deviceName,sdk,method+statusare cheap on the read side. I did not measure the insert-side cost of carrying more projections, and that is the number that should decide the slate — so it belongs in its own PR with a write benchmark.Events/XListbuilds>= start/<= endfrom raw user timestamps, so console reads decline the rewrite and stay on the base table. Flooring those bounds to the hour in server-ce would let them route; alternatively the adapter could split the partial edge hours out and union them, the waysumHybridDailyAndRaw()already does for the daily MV.Verification
EXPLAIN projections=1andsystem.query_log.projections(runtime evidence, not just the plan) assert routing in the tests.optimize_use_projections = 0, for bothfind(groupByInterval('1d'))andgetTimeSeries('1d').(metric, time, tenant, dims)key order, and gauge reads to keeptoStartOfHourout of their emitted SQL.phpstan: no errors.pint --test: pass.DatabaseTesterrors are environment-only (no MariaDB here) and are byte-identical to those on an unmodifiedorigin/maincheckout, compared by test ID rather than by count.