Skip to content

Commit c40f2cd

Browse files
committed
feat(adapter): route flat-sum reads to daily MV when grouping not requested
1 parent 9fe8fc0 commit c40f2cd

3 files changed

Lines changed: 522 additions & 0 deletions

File tree

src/Usage/Adapter/ClickHouse.php

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*

src/Usage/Usage.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,20 @@ public function setSharedTables(bool $sharedTables): self
320320
return $this;
321321
}
322322

323+
/**
324+
* Opt into adapter-side daily-rollup routing for flat-sum event reads.
325+
*
326+
* Forwarded to the underlying adapter; only adapters that implement
327+
* `setUseDailyRollups()` honour the flag (ClickHouse does today).
328+
*/
329+
public function setUseDailyRollups(bool $enabled = true): self
330+
{
331+
if (method_exists($this->adapter, 'setUseDailyRollups')) {
332+
$this->adapter->setUseDailyRollups($enabled);
333+
}
334+
return $this;
335+
}
336+
323337
/**
324338
* Collect a metric into the in-memory buffer for deferred flushing.
325339
*

0 commit comments

Comments
 (0)