Skip to content

feat(clickhouse): make rollup routing reach real windows; add backfillDaily and latest-value gauge projections - #39

Merged
levivannoort merged 3 commits into
utopia-php:mainfrom
levivannoort:feat/daily-rollup-backfill-and-routed-totals
Sep 4, 2026
Merged

feat(clickhouse): make rollup routing reach real windows; add backfillDaily and latest-value gauge projections#39
levivannoort merged 3 commits into
utopia-php:mainfrom
levivannoort:feat/daily-rollup-backfill-and-routed-totals

Conversation

@levivannoort

@levivannoort levivannoort commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Why

Rollup routing existed but could not fire for the windows production actually issues, and the gauge projections could not collapse a series. Both were measured against the largest production install (fra1: 275 GiB / 7.3 B raw events, 966 M gauge samples), not inferred.

Routing never reached billing. daily/hybrid both require a midnight-aligned start. Billing cycles are anchored at the moment a team upgraded — 0 of 451,293 production teams have a midnight-aligned invoice date (SELECT SUM(TIME(billingCurrentInvoiceDate)='00:00:00') ... → 0). So every billing read fell through to a full-cycle raw scan, which is the shape that saturated the box (the top query by CPU: 71 k executions/hour, 5,479 CPU-s, 487 GiB read).

Gauge projections were a copy of the base table. Their GROUP BY carried raw time, so they held one row per sample: production shows 964 M projection rows against 966 M base rows — column pruning only, no aggregation benefit. force_optimize_projection = 1 on the billing prefetch shape returned PROJECTION_NOT_USED.

What

1. split route — interior whole days from the rollup, only the partial head/tail days from raw, folded by an outer SUM over UNION ALL. Any window shape now uses the rollup for everything it can answer. Wired into both sum()/getTotal() and getTotalBatch().

2. backfillDaily($from, $to, $force) — runs the MV's exact aggregation over a half-open, UTC-midnight-aligned window, because the MV was created without POPULATE. Bounds normalize to UTC before validation (an offset midnight that is not a UTC midnight is refused); refuses a window that already holds rollup rows unless force; documented as a single-operator migration primitive (the check and the INSERT are separate statements — ClickHouse has no cross-statement transaction, and SummingMergeTree adds duplicates).

3. Latest-value gauge projections (p_latest_by_*) — keyed (tenant, metric, dims) with no time key, one argMax state per series. Production: 966 M samples collapse to 3.28 M series (294 samples per series). The existing time-keyed slate is unchanged, because windowed grouped reads need the time predicate expressible on the projection.

Two correctness bugs fixed on the way

  • Inclusive midnight upper bounds dropped rows at the bound. <= midnight covers the midnight instant; a day-granularity row cannot express it, and the daily route translated <= into <, silently dropping rows timestamped exactly at the bound. Found by differential testing against boundary-dense seeds (off by exactly the midnight row's value). Those windows now route split, which reads that instant from raw. Regression test: testInclusiveMidnightUpperBoundExcludesTheEndDayButKeepsTheMidnightInstant — 342 = 300 interior + the 42 row at exactly midnight, never the 9999 mid-end-day row.
  • Non-window time filters could be over-counted. A notBetween carves a mid-day hole no day row can honor, and the split interior would drop it. Such filters now pin the read to raw (hasIrregularTimeFilter), value-asserted.

Evidence

Differential parity on production data — the split route's exact SQL against 12 real high-volume tenants × 5 billing-shaped windows (arbitrary time-of-day anchors), 6 metrics each:

checked=60  mismatches=0
rows_read raw=3,714,152,244  split=221,378,946  reduction=16.8x

Deterministic parity matrix in CI (ClickHouseSplitParityTest): 11 window shapes — mid-day both ends, mid-day/midnight mixes, day-aligned, straddling today, inclusive at both alignments, sub-day, exactly-one-interior-day, month-long — each asserted equal to a raw scan on both the flat sum and the batch, and asserted to take the intended route, against seeds at midnight exactly / mid-morning / 23:59:59.999 on every day so any edge off-by-one changes a total. 22/22.

Gauge projection shapes (compose ClickHouse, identical data): time-keyed slate 2,880 rows read for the prefetch shape vs 200 for the latest slate (14.4x on 2 M rows; the production collapse factor is 294x). force_optimize_projection now accepts the shape it refused.

Full suite 376 tests / 1,686 assertions green; Pint and PHPStan clean. Split and backfill tests verified red against the previous implementation.

Deployment

  1. Merge + tag; materialize the new projections per region (ALTER TABLE … MATERIALIZE PROJECTION p_latest_by_*) — setup() only attaches them to parts written afterwards. Production shows why this matters: the existing slate covers 39–43 of 45 active parts, and a partially covered projection is unusable for the query.
  2. Per-month raw-vs-rollup parity check per region; repair gaps with backfillDaily. On fra1, 3 of 4 retained months already match raw exactly; the fourth is one DROP PARTITION + backfill.
  3. Bump the consumer, with dualReadSampleRate on for a parity window (the batched dual-read in this PR covers getTotalBatch).
  4. Then drop cloud's GAUGE_LOOKBACK_DAYS bound — see appwrite-labs/cloud#5660: a time predicate cannot be evaluated on a projection with no time key, so the bound would force the prefetch off the new projection (measured 200 rows unbounded vs 21,388 bounded). Materialize first, then drop.

Cost — measured, and one number corrected

An earlier revision of this description claimed the new projections' write cost "tracks their own row count, roughly 0.3%". That is right for storage and merge and wrong for inserts, so here are the measurements.

measured
Insert wall time, 1.2 M rows, 3 projections → 6 847 ms → 1,165 ms (+37.5%) — every insert block is aggregated once per projection regardless of how few rows come out
Projection storage added 213 KB → 228 KB (+7% of projection bytes); the latest slate held 1,046 rows against 1.2 M base rows
Absolute cost on production fra1 gauge NewPart work is 87 s/hour (0.024 cores); +37.5% of that is +0.009 cores
Against a 294× read reduction on the grouped prefetch shape

So: a large relative insert cost on a tiny absolute base. Worth it, but stated honestly rather than hidden behind the storage ratio.

Also verified while reviewing: applyFilters() applies only the tenant and filter predicates — no LIMIT/OFFSET leaks into the split branches (which would have silently truncated a branch), matching sumFromTable() and sumHybridDailyAndRaw().

Parity coverage extended

The first production run covered plain metric batches. Billing's network reads also carry resourceType filters (= 'site' / != 'site'), which route through the rollup's own resourceType column — that shape was untested, so it was run too: 6 tenants × 2 filter shapes × 2 windows = 24 comparisons, 0 mismatches.

…ackfillDaily

getTotalBatch() always scanned the raw events table, even for windows the
daily rollup answers — for Appwrite billing that is a whole-month raw scan
per metric batch per project per hour, and it is the shape that saturates
ClickHouse once the raw table grows. Batched event totals now route through
the same source selection sum()/getTotal() use: closed-day windows read the
daily rollup, open windows read rollup + today's raw tail, and anything the
rollup cannot answer (non-rollup filter columns, cursors, sub-day bounds)
stays raw. Decisions land in the route log under `getTotalBatch`, and the
dualReadSampleRate canary gets a batched twin that logs per-metric drift.
Gauges are untouched — they have no rollup.

Routing is only correct once the rollup covers the window, and the daily MV
was created without POPULATE, so backfillDaily($from, $to) runs the exact MV
aggregation over a half-open, UTC-midnight-aligned window. SummingMergeTree
folds duplicates by adding them, so the call refuses misaligned bounds and
refuses a window that already holds rollup rows unless force is passed after
the caller cleared the range.
@greptile-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR routes batched ClickHouse event totals through daily, hybrid, or split aggregate sources; adds an operator-managed daily-rollup backfill API; and introduces latest-value gauge projections.

  • Preserves raw routing for unsupported filters, dimensions, cursors, irregular time filters, and sub-day windows.
  • Splits non-midnight billing windows into daily-rollup interiors and raw edge ranges.
  • Adds parity, boundary, backfill, schema, and projection tests.
  • Normalizes backfill bounds to UTC before validating midnight alignment.
  • Keeps gauge aggregation semantics based on the latest timestamp.

Confidence Score: 5/5

The PR appears safe to merge; no blocking or independently actionable new defect remains.

No accepted new findings remain, and all previous findings were resolved: the rollup-coverage and concurrency concerns were withdrawn after their operational contracts were clarified, while the timezone-offset issue is fixed by UTC normalization before alignment validation.

Important Files Changed

Filename Overview
src/Usage/Adapter/ClickHouse.php Adds routed batch totals, split rollup/raw aggregation, daily backfill support, UTC-bound validation, and latest-value gauge projections; no actionable new correctness issue was established.
src/Usage/Adapter.php Adds a deliberately throwing default for adapters that do not maintain a daily rollup.
src/Usage/Usage.php Exposes the adapter’s daily-backfill operation through the public facade.
tests/Usage/Adapter/ClickHouseBackfillTest.php Covers restoration, overlap protection, forced backfill, UTC alignment, offset handling, and inverted bounds.
tests/Usage/Adapter/ClickHouseRoutingTest.php Extends route and value-parity coverage for split, hybrid, daily, batch, and irregular-filter cases.
tests/Usage/Adapter/ClickHouseSplitParityTest.php Differentially checks routed sums and batches against raw scans across representative boundary shapes.
tests/Usage/Adapter/ClickHouseGaugeProjectionTest.php Verifies that the latest-value projection is selectable and preserves grouped gauge results.
tests/Usage/Adapter/ClickHouseSchemaTest.php Pins both time-keyed and unwindowed latest-value gauge projection shapes.

Reviews (3): Last reviewed commit: "feat(clickhouse): split route for non-al..." | Re-trigger Greptile

Comment thread src/Usage/Adapter/ClickHouse.php
Comment thread src/Usage/Adapter/ClickHouse.php
Comment thread src/Usage/Adapter/ClickHouse.php Outdated
@levivannoort

Copy link
Copy Markdown
Contributor Author

Production coverage data (fra1, the largest deployment) that changes the rollout cost of this PR:

The daily MV has been live since provisioning — a per-month parity check of sum(value) raw vs rollup over the full retained window shows 3 of 4 months matching exactly (202606, 202608, 202609). Only 202607 under-counts, by ~0.5%, most plausibly the per-tenant purge path (purgeDaily() deliberately deletes a superset when a filter isn't expressible on the rollup) or a brief MV gap.

Implications:

  • No fleet-wide historical backfill is needed. The repair is ALTER TABLE … DROP PARTITION 202607 + one backfillDaily('2026-07-01', '2026-08-01') per affected region — exactly the drop-then-refill flow the overlap guard in this PR is designed around.
  • The parity check worth running per region before cutover:
    SELECT a.p, a.raw = b.rolled AS match
    FROM (SELECT toYYYYMM(time) p, sum(value) raw FROM {ns}_usage_events WHERE time < today() GROUP BY p) a
    LEFT JOIN (SELECT toYYYYMM(time) p, sum(value) rolled FROM {ns}_usage_events_daily WHERE time < today() GROUP BY p) b USING p ORDER BY p
  • Current fra1 cost this PR removes: the billing getTotalBatch SUM shapes read ~850 GiB/hour from the 275 GiB raw events table; the complete rollup is 92 MiB.

Also relevant for reviewers: the divergence mechanism (rollup can drift below raw after tenant purges) is a good argument for keeping the dualReadSampleRate canary on for a window after cutover, which this PR's batched dual-read supports.

…rrency contract

A bound carrying its own offset ('2026-01-01 00:00:00+05:00') passed the
midnight check in its own zone but was bound offset-less, so ClickHouse
read it as UTC midnight — a different instant than the caller intended.
Bounds now normalize to UTC before validation, so offset midnights that
are not UTC midnights are refused. backfillDaily() is also documented as
a single-operator migration primitive: the overlap check and the INSERT
are separate statements, so concurrent calls for the same window can
double the rollup — serialize, and parity-check before routing traffic.
…auge projections

Two measured gaps, both verified against the largest production install.

1. Routing never fired for the windows production uses. daily/hybrid
   require a midnight-aligned start, but billing cycles are anchored at
   the moment a team upgraded: 0 of 451,293 production teams have a
   midnight-aligned invoice date. Every billing read therefore fell to
   raw. The new 'split' route reads the interior whole days from the
   rollup and only the partial head/tail days from raw, so a window of
   any shape uses the rollup for everything it can answer. Measured on
   12 real high-volume tenants x 5 billing-shaped windows: 60/60 totals
   identical to raw, 3.71B rows read down to 221M (16.8x).

   Split also fixes a pre-existing correctness bug it inherited from the
   daily route: an inclusive midnight upper bound covers the midnight
   instant, which a day-granularity row cannot express, so translating
   `<= midnight` to `< midnight` silently dropped rows timestamped
   exactly at the bound. Those windows now route split, which reads that
   instant from raw. A non-window time filter (notBetween and friends)
   carves a shape no day row can honor and now pins the read to raw
   instead of over-counting.

2. Gauge projections could not collapse a series. Their GROUP BY carried
   raw `time`, making them 1:1 with the base table — measured in
   production: 964M projection rows against 966M base rows, i.e. column
   pruning only. The added latest-value slate is keyed
   (tenant, metric, dims) with no time key, one argMax state per series:
   966M samples collapse to 3.28M series (294 samples per series), and
   force_optimize_projection now accepts the grouped prefetch shape that
   it refused before. The windowed slate is unchanged — windowed grouped
   reads still need the time predicate expressible on the projection —
   and the new slate's merge cost tracks its own row count, ~0.3% of the
   base table's.

Existing installs must MATERIALIZE the new projections once; setup()
only attaches them to parts written afterwards. Production already shows
why that step matters: the current slate covers 39-43 of 45 active
parts, and a partially covered projection is unusable for the query.
@levivannoort levivannoort changed the title feat(clickhouse): route getTotalBatch through the daily rollup; add backfillDaily feat(clickhouse): make rollup routing reach real windows; add backfillDaily and latest-value gauge projections Sep 4, 2026
@levivannoort
levivannoort merged commit ae8876d into utopia-php:main Sep 4, 2026
4 checks passed
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.

2 participants