@@ -129,6 +129,22 @@ class ClickHouse extends SQL
129129 */
130130 private ?string $ nextQueryId = null ;
131131
132+ /**
133+ * Route flat-sum event reads to the daily rollup table(s) when the
134+ * caller hasn't asked for any grouping. Default off — opt-in per
135+ * consumer via setUseDailyRollups(true).
136+ */
137+ private bool $ useDailyRollups = false ;
138+
139+ /**
140+ * Structured log entries recorded for each routing decision when
141+ * useDailyRollups is active. Ops dashboards read these to confirm
142+ * rollup hit-rate.
143+ *
144+ * @var array<array{operation: string, metric: ?string, route_decision: string, start: ?string, end: ?string, dimensions: array<int, string>, interval: ?string}>
145+ */
146+ private array $ routeLog = [];
147+
132148 /**
133149 * @param string $host ClickHouse host
134150 * @param string $username ClickHouse username (default: 'default')
@@ -275,6 +291,46 @@ public function setNextQueryId(?string $queryId): self
275291 return $ this ;
276292 }
277293
294+ /**
295+ * Toggle daily-rollup routing for flat-sum event reads.
296+ *
297+ * When enabled, sum() against TYPE_EVENT may route to the daily MV (or
298+ * a hybrid daily + raw UNION ALL when the window straddles today)
299+ * whenever the request shape has no grouping / interval and only
300+ * touches columns the daily MV indexes. Everything else falls back to
301+ * the raw events table.
302+ *
303+ * Default off — cloud consumers opt in explicitly.
304+ */
305+ public function setUseDailyRollups (bool $ enabled = true ): self
306+ {
307+ $ this ->useDailyRollups = $ enabled ;
308+ return $ this ;
309+ }
310+
311+ public function getUseDailyRollups (): bool
312+ {
313+ return $ this ->useDailyRollups ;
314+ }
315+
316+ /**
317+ * Return the in-memory route-decision log (operation, metric,
318+ * route_decision, window, dimensions, interval). Cleared by
319+ * clearRouteLog().
320+ *
321+ * @return array<array{operation: string, metric: ?string, route_decision: string, start: ?string, end: ?string, dimensions: array<int, string>, interval: ?string}>
322+ */
323+ public function getRouteLog (): array
324+ {
325+ return $ this ->routeLog ;
326+ }
327+
328+ public function clearRouteLog (): self
329+ {
330+ $ this ->routeLog = [];
331+ return $ this ;
332+ }
333+
278334 /**
279335 * Get connection statistics for monitoring.
280336 *
@@ -1940,9 +1996,222 @@ public function sum(array $queries = [], string $attribute = 'value', string $ty
19401996 {
19411997 $ this ->setOperationContext ('sum() ' );
19421998
1999+ if ($ this ->useDailyRollups && $ type === Usage::TYPE_EVENT && $ attribute === 'value ' ) {
2000+ $ plan = $ this ->extractRoutingPlan ($ queries );
2001+ $ route = $ this ->selectAggregateSource ($ plan );
2002+ $ this ->recordRoute ('sum ' , $ plan , $ route );
2003+
2004+ if ($ route === 'daily ' ) {
2005+ return $ this ->sumFromDaily ($ queries );
2006+ }
2007+ if ($ route === 'hybrid ' ) {
2008+ return $ this ->sumHybridDailyAndRaw ($ queries , $ plan );
2009+ }
2010+ }
2011+
19432012 return $ this ->sumFromTable ($ queries , $ attribute , $ type );
19442013 }
19452014
2015+ /**
2016+ * Snapshot of the parsed query shape relevant for routing.
2017+ *
2018+ * @param array<Query> $queries
2019+ * @return array{metric: ?string, start: ?string, end: ?string, filterColumns: array<int, string>, dimensions: array<int, string>, interval: ?string}
2020+ */
2021+ private function extractRoutingPlan (array $ queries ): array
2022+ {
2023+ $ metric = null ;
2024+ $ start = null ;
2025+ $ end = null ;
2026+ $ filterColumns = [];
2027+ $ dimensions = [];
2028+ $ interval = null ;
2029+
2030+ foreach ($ queries as $ query ) {
2031+ $ method = $ query ->getMethod ();
2032+ $ attribute = $ query ->getAttribute ();
2033+ $ values = $ query ->getValues ();
2034+
2035+ if ($ method === UsageQuery::TYPE_GROUP_BY ) {
2036+ if (!in_array ($ attribute , $ dimensions , true )) {
2037+ $ dimensions [] = $ attribute ;
2038+ }
2039+ continue ;
2040+ }
2041+ if ($ method === UsageQuery::TYPE_GROUP_BY_INTERVAL ) {
2042+ $ intervalValue = $ values [0 ] ?? null ;
2043+ $ interval = is_string ($ intervalValue ) ? $ intervalValue : null ;
2044+ continue ;
2045+ }
2046+ if (in_array ($ method , [Query::TYPE_LIMIT , Query::TYPE_OFFSET , Query::TYPE_ORDER_ASC , Query::TYPE_ORDER_DESC , Query::TYPE_CURSOR_AFTER , Query::TYPE_CURSOR_BEFORE ], true )) {
2047+ continue ;
2048+ }
2049+
2050+ if ($ attribute === '' || $ attribute === 'id ' ) {
2051+ continue ;
2052+ }
2053+
2054+ if (!in_array ($ attribute , $ filterColumns , true )) {
2055+ $ filterColumns [] = $ attribute ;
2056+ }
2057+
2058+ if ($ attribute === 'metric ' && $ method === Query::TYPE_EQUAL ) {
2059+ $ first = $ values [0 ] ?? null ;
2060+ if (is_string ($ first ) && count ($ values ) === 1 ) {
2061+ $ metric = $ first ;
2062+ }
2063+ }
2064+
2065+ if ($ attribute === 'time ' ) {
2066+ if ($ method === Query::TYPE_GREATER_EQUAL || $ method === Query::TYPE_GREATER ) {
2067+ $ start = $ this ->stringifyTime ($ values [0 ] ?? null );
2068+ } elseif ($ method === Query::TYPE_LESSER_EQUAL || $ method === Query::TYPE_LESSER ) {
2069+ $ end = $ this ->stringifyTime ($ values [0 ] ?? null );
2070+ } elseif ($ method === Query::TYPE_BETWEEN ) {
2071+ $ start = $ this ->stringifyTime ($ values [0 ] ?? null );
2072+ $ end = $ this ->stringifyTime ($ values [1 ] ?? null );
2073+ }
2074+ }
2075+ }
2076+
2077+ return [
2078+ 'metric ' => $ metric ,
2079+ 'start ' => $ start ,
2080+ 'end ' => $ end ,
2081+ 'filterColumns ' => $ filterColumns ,
2082+ 'dimensions ' => $ dimensions ,
2083+ 'interval ' => $ interval ,
2084+ ];
2085+ }
2086+
2087+ /**
2088+ * Pure routing decision: which physical table satisfies this read.
2089+ *
2090+ * Returns one of:
2091+ * - 'raw' — scan the events table (today's behaviour).
2092+ * - 'daily' — read the existing daily MV (whole window in closed days,
2093+ * no grouping, only daily-MV-compatible filters).
2094+ * - 'hybrid' — closed days from daily MV, today's partial from raw,
2095+ * combined via outer SUM over UNION ALL.
2096+ *
2097+ * The hard rule (per `usage-final-plan.md` §P1) is that grouping or
2098+ * interval ever forces 'raw'; multi-dim MV routes land in commit 5.
2099+ *
2100+ * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array<int, string>, dimensions: array<int, string>, interval: ?string} $plan
2101+ */
2102+ private function selectAggregateSource (array $ plan ): string
2103+ {
2104+ if (!empty ($ plan ['dimensions ' ]) || $ plan ['interval ' ] !== null ) {
2105+ return 'raw ' ;
2106+ }
2107+
2108+ foreach ($ plan ['filterColumns ' ] as $ column ) {
2109+ if (!in_array ($ column , self ::DAILY_COLUMNS , true ) && $ column !== 'tenant ' ) {
2110+ return 'raw ' ;
2111+ }
2112+ }
2113+
2114+ if ($ plan ['end ' ] === null ) {
2115+ return 'raw ' ;
2116+ }
2117+
2118+ $ startOfToday = (new \DateTime ('today ' , new \DateTimeZone ('UTC ' )))->format ('Y-m-d H:i:s ' );
2119+
2120+ try {
2121+ $ endDt = new \DateTime ($ plan ['end ' ]);
2122+ $ boundaryDt = new \DateTime ($ startOfToday );
2123+ } catch (\Exception $ e ) {
2124+ return 'raw ' ;
2125+ }
2126+
2127+ return $ endDt < $ boundaryDt ? 'daily ' : 'hybrid ' ;
2128+ }
2129+
2130+ private function stringifyTime (mixed $ value ): ?string
2131+ {
2132+ if ($ value instanceof \DateTime) {
2133+ return $ value ->format ('Y-m-d H:i:s ' );
2134+ }
2135+ return is_string ($ value ) ? $ value : null ;
2136+ }
2137+
2138+ /**
2139+ * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array<int, string>, dimensions: array<int, string>, interval: ?string} $plan
2140+ */
2141+ private function recordRoute (string $ operation , array $ plan , string $ route ): void
2142+ {
2143+ $ this ->routeLog [] = [
2144+ 'operation ' => $ operation ,
2145+ 'metric ' => $ plan ['metric ' ],
2146+ 'route_decision ' => $ route ,
2147+ 'start ' => $ plan ['start ' ],
2148+ 'end ' => $ plan ['end ' ],
2149+ 'dimensions ' => $ plan ['dimensions ' ],
2150+ 'interval ' => $ plan ['interval ' ],
2151+ ];
2152+ }
2153+
2154+ /**
2155+ * sumDaily() with the caller's queries forwarded as-is. Used by the
2156+ * 'daily' branch of selectAggregateSource(); the daily MV's
2157+ * SummingMergeTree engine re-aggregates over the dim columns so a flat
2158+ * `sum(value) WHERE metric=… AND time BETWEEN …` returns the same total
2159+ * as scanning raw events.
2160+ *
2161+ * @param array<Query> $queries
2162+ */
2163+ private function sumFromDaily (array $ queries ): int
2164+ {
2165+ return $ this ->sumDaily ($ queries , 'value ' );
2166+ }
2167+
2168+ /**
2169+ * Hybrid daily + raw read: closed days from the daily MV, today's
2170+ * partial from the raw events table, combined via outer SUM over
2171+ * UNION ALL.
2172+ *
2173+ * @param array<Query> $queries
2174+ * @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array<int, string>, dimensions: array<int, string>, interval: ?string} $plan
2175+ */
2176+ private function sumHybridDailyAndRaw (array $ queries , array $ plan ): int
2177+ {
2178+ $ startOfToday = (new \DateTime ('today ' , new \DateTimeZone ('UTC ' )))->format ('Y-m-d H:i:s.v ' );
2179+
2180+ $ dailyTable = $ this ->buildTableReference ($ this ->getEventsDailyTableName ());
2181+ $ eventsTable = $ this ->buildTableReference ($ this ->getEventsTableName ());
2182+
2183+ $ parsed = $ this ->parseQueries ($ queries , Usage::TYPE_EVENT );
2184+ $ rawFilters = $ parsed ['filters ' ];
2185+ $ params = $ parsed ['params ' ];
2186+
2187+ $ dailyFilters = $ rawFilters ;
2188+
2189+ $ params ['hybrid_boundary ' ] = $ startOfToday ;
2190+ $ dailyFilters [] = '`time` < {hybrid_boundary:DateTime64(3)} ' ;
2191+ $ rawFilters [] = '`time` >= {hybrid_boundary:DateTime64(3)} ' ;
2192+
2193+ $ dailyWhere = $ this ->buildWhereClause ($ dailyFilters , $ params );
2194+ $ rawWhere = $ this ->buildWhereClause ($ rawFilters , $ dailyWhere ['params ' ]);
2195+
2196+ $ sql = "
2197+ SELECT sum(total) AS total FROM (
2198+ SELECT sum(value) AS total FROM {$ dailyTable }{$ dailyWhere ['clause ' ]}
2199+ UNION ALL
2200+ SELECT sum(value) AS total FROM {$ eventsTable }{$ rawWhere ['clause ' ]}
2201+ )
2202+ FORMAT JSON
2203+ " ;
2204+
2205+ $ result = $ this ->query ($ sql , $ rawWhere ['params ' ]);
2206+ $ json = json_decode ($ result , true );
2207+
2208+ if (!is_array ($ json ) || !isset ($ json ['data ' ][0 ]['total ' ])) {
2209+ return 0 ;
2210+ }
2211+
2212+ return (int ) $ json ['data ' ][0 ]['total ' ];
2213+ }
2214+
19462215 /**
19472216 * Sum metric values from a specific table.
19482217 *
0 commit comments