Skip to content

Commit 720af37

Browse files
committed
feat(adapter): route gauge grouped reads to AMT rollups
1 parent b1f314c commit 720af37

4 files changed

Lines changed: 560 additions & 18 deletions

File tree

src/Usage/Adapter/ClickHouse.php

Lines changed: 206 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,7 +1896,7 @@ public function find(array $queries = [], ?string $type = null): array
18961896
if ($type !== null) {
18971897
if ($this->useDailyRollups && $type === Usage::TYPE_EVENT) {
18981898
$plan = $this->extractRoutingPlan($queries);
1899-
$route = $this->selectAggregateSource($plan);
1899+
$route = $this->selectAggregateSource($plan, Usage::TYPE_EVENT);
19001900
$this->recordRoute('find', $plan, $route);
19011901

19021902
if (str_starts_with($route, 'daily_by_')) {
@@ -1918,6 +1918,32 @@ public function find(array $queries = [], ?string $type = null): array
19181918
}
19191919
}
19201920
}
1921+
1922+
if ($this->useDailyRollups && $type === Usage::TYPE_GAUGE) {
1923+
$plan = $this->extractRoutingPlan($queries);
1924+
$route = $this->selectAggregateSource($plan, Usage::TYPE_GAUGE);
1925+
$this->recordRoute('find', $plan, $route);
1926+
1927+
if (str_starts_with($route, 'gauges_daily_by_')) {
1928+
$name = substr($route, strlen('gauges_daily_'));
1929+
$rollup = $this->getGaugeRollupByName($name);
1930+
if ($rollup !== null) {
1931+
$result = $this->findFromGaugeDailyByDim($rollup, $queries);
1932+
$this->maybeDualRead($queries, $type, $route, $plan, $result);
1933+
return $result;
1934+
}
1935+
}
1936+
if (str_starts_with($route, 'gauges_hybrid_by_')) {
1937+
$name = substr($route, strlen('gauges_hybrid_'));
1938+
$rollup = $this->getGaugeRollupByName($name);
1939+
if ($rollup !== null) {
1940+
$result = $this->findHybridFromGaugeDailyByDim($rollup, $queries);
1941+
$this->maybeDualRead($queries, $type, $route, $plan, $result);
1942+
return $result;
1943+
}
1944+
}
1945+
}
1946+
19211947
return $this->findFromTable($queries, $type);
19221948
}
19231949

@@ -2434,22 +2460,25 @@ private function extractRoutingPlan(array $queries): array
24342460
* Pure routing decision: which physical table satisfies this read.
24352461
*
24362462
* Returns one of:
2437-
* - 'raw' — scan the events table.
2438-
* - 'daily' — read the existing daily MV (no grouping, only
2463+
* - 'raw' — scan the raw events / gauges table.
2464+
* - 'daily' — read the existing events daily MV (no grouping, only
24392465
* daily-MV-compatible filters, window in closed days).
2440-
* - 'hybrid' — closed days from daily MV, today's partial from raw.
2466+
* - 'hybrid' — closed days from events daily MV, today's partial from raw.
24412467
* - 'daily_by_path' / 'daily_by_country' / 'daily_by_service' /
2442-
* 'daily_by_method_status' — read a per-dim rollup when the
2443-
* requested dimensions[] and filter columns match one of the
2444-
* four MVs.
2445-
* - 'hybrid_by_<dim>' — multi-dim MV closed days + raw today's partial.
2468+
* 'daily_by_method_status' — read an events per-dim rollup.
2469+
* - 'hybrid_by_<dim>' — events multi-dim MV closed days + raw today's
2470+
* partial.
2471+
* - 'gauges_daily_by_service' / 'gauges_daily_by_resource' — read a
2472+
* gauges per-dim AMT rollup.
2473+
* - 'gauges_hybrid_by_<dim>' — gauges multi-dim AMT MV closed days +
2474+
* raw today's partial.
24462475
*
24472476
* Any caller-requested sub-day interval forces 'raw' because the
24482477
* rollups are day-grained.
24492478
*
24502479
* @param array{metric: ?string, start: ?string, end: ?string, filterColumns: array<int, string>, dimensions: array<int, string>, interval: ?string} $plan
24512480
*/
2452-
private function selectAggregateSource(array $plan): string
2481+
private function selectAggregateSource(array $plan, string $type = Usage::TYPE_EVENT): string
24532482
{
24542483
if ($plan['interval'] !== null) {
24552484
return 'raw';
@@ -2467,6 +2496,27 @@ private function selectAggregateSource(array $plan): string
24672496
}
24682497
$straddlesToday = $endDt >= $boundaryDt;
24692498

2499+
if ($type === Usage::TYPE_GAUGE) {
2500+
if (empty($plan['dimensions'])) {
2501+
return 'raw';
2502+
}
2503+
2504+
$dimMatch = $this->matchGaugeDimRollup($plan['dimensions']);
2505+
if ($dimMatch === null) {
2506+
return 'raw';
2507+
}
2508+
2509+
$allowedFilters = array_merge(['metric', 'time', 'tenant'], $dimMatch['dims']);
2510+
foreach ($plan['filterColumns'] as $column) {
2511+
if (!in_array($column, $allowedFilters, true)) {
2512+
return 'raw';
2513+
}
2514+
}
2515+
2516+
$base = 'gauges_daily_' . $dimMatch['name'];
2517+
return $straddlesToday ? 'gauges_hybrid_' . $dimMatch['name'] : $base;
2518+
}
2519+
24702520
if (empty($plan['dimensions'])) {
24712521
foreach ($plan['filterColumns'] as $column) {
24722522
if (!in_array($column, self::DAILY_COLUMNS, true) && $column !== 'tenant') {
@@ -2493,8 +2543,8 @@ private function selectAggregateSource(array $plan): string
24932543
}
24942544

24952545
/**
2496-
* Find the per-dim rollup whose dim set matches the request exactly
2497-
* (order-insensitive). Returns null when no MV covers this shape.
2546+
* Find the events per-dim rollup whose dim set matches the request
2547+
* exactly (order-insensitive). Returns null when no MV covers this shape.
24982548
*
24992549
* @param array<int, string> $dimensions
25002550
* @return array{name: string, dims: array<int, string>}|null
@@ -2514,6 +2564,41 @@ private function matchDimRollup(array $dimensions): ?array
25142564
return null;
25152565
}
25162566

2567+
/**
2568+
* Find the gauge per-dim AMT rollup whose dim set matches the request
2569+
* exactly (order-insensitive). Returns null when no MV covers this shape.
2570+
*
2571+
* @param array<int, string> $dimensions
2572+
* @return array{name: string, dims: array<int, string>}|null
2573+
*/
2574+
private function matchGaugeDimRollup(array $dimensions): ?array
2575+
{
2576+
$needle = $dimensions;
2577+
sort($needle);
2578+
2579+
foreach (self::GAUGE_DIM_ROLLUPS as $rollup) {
2580+
$candidate = $rollup['dims'];
2581+
sort($candidate);
2582+
if ($candidate === $needle) {
2583+
return $rollup;
2584+
}
2585+
}
2586+
return null;
2587+
}
2588+
2589+
/**
2590+
* @return array{name: string, dims: array<int, string>}|null
2591+
*/
2592+
private function getGaugeRollupByName(string $name): ?array
2593+
{
2594+
foreach (self::GAUGE_DIM_ROLLUPS as $rollup) {
2595+
if ($rollup['name'] === $name) {
2596+
return $rollup;
2597+
}
2598+
}
2599+
return null;
2600+
}
2601+
25172602
private function stringifyTime(mixed $value): ?string
25182603
{
25192604
if ($value instanceof \DateTime) {
@@ -2674,6 +2759,116 @@ private function findHybridFromDailyByDim(array $rollup, array $queries): array
26742759
return $this->parseAggregatedResults($result, Usage::TYPE_EVENT);
26752760
}
26762761

2762+
/**
2763+
* Day-grained aggregated read from a per-dim gauge AMT rollup table.
2764+
* Same return shape as findFromDailyByDim but emits argMaxMerge(value)
2765+
* instead of sum(value) — gauges store argMaxState partials.
2766+
*
2767+
* @param array{name: string, dims: array<int, string>} $rollup
2768+
* @param array<Query> $queries
2769+
* @return array<Metric>
2770+
*/
2771+
private function findFromGaugeDailyByDim(array $rollup, array $queries): array
2772+
{
2773+
$tableName = $this->getGaugeDimRollupTableName($rollup['name']);
2774+
$fromTable = $this->buildTableReference($tableName);
2775+
2776+
$parsed = $this->parseQueries($queries, Usage::TYPE_GAUGE);
2777+
$whereData = $this->buildWhereClause($parsed['filters'], $parsed['params']);
2778+
2779+
$dimSelect = '';
2780+
$dimGroup = '';
2781+
if (!empty($rollup['dims'])) {
2782+
$escapedDims = array_map(fn (string $d): string => $this->escapeIdentifier($d), $rollup['dims']);
2783+
$dimSelect = ', ' . implode(', ', $escapedDims);
2784+
$dimGroup = ', ' . implode(', ', $escapedDims);
2785+
}
2786+
2787+
$orderClause = ' ORDER BY value DESC';
2788+
if (!empty($parsed['orderBy'])) {
2789+
$orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']);
2790+
}
2791+
$limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : '';
2792+
$offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : '';
2793+
2794+
$sql = "
2795+
SELECT metric, argMaxMerge(value) AS value{$dimSelect}
2796+
FROM {$fromTable}{$whereData['clause']}
2797+
GROUP BY metric{$dimGroup}{$orderClause}{$limitClause}{$offsetClause}
2798+
FORMAT JSON
2799+
";
2800+
2801+
$result = $this->query($sql, $whereData['params']);
2802+
return $this->parseAggregatedResults($result, Usage::TYPE_GAUGE);
2803+
}
2804+
2805+
/**
2806+
* Hybrid gauge AMT read: closed-day rollup partials (argMaxState) UNION
2807+
* raw gauges (argMaxState(value, time) over today's partial), with an
2808+
* outer argMaxMerge that combines both sides. Same return shape as
2809+
* findHybridFromDailyByDim but argMax-based.
2810+
*
2811+
* @param array{name: string, dims: array<int, string>} $rollup
2812+
* @param array<Query> $queries
2813+
* @return array<Metric>
2814+
*/
2815+
private function findHybridFromGaugeDailyByDim(array $rollup, array $queries): array
2816+
{
2817+
$startOfToday = (new \DateTime('today', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s.v');
2818+
2819+
$dailyName = $this->getGaugeDimRollupTableName($rollup['name']);
2820+
$dailyTable = $this->buildTableReference($dailyName);
2821+
$gaugesTable = $this->buildTableReference($this->getGaugesTableName());
2822+
2823+
$parsed = $this->parseQueries($queries, Usage::TYPE_GAUGE);
2824+
$rawFilters = $parsed['filters'];
2825+
$params = $parsed['params'];
2826+
2827+
$dailyFilters = $rawFilters;
2828+
2829+
$params['hybrid_boundary'] = $startOfToday;
2830+
$dailyFilters[] = '`time` < {hybrid_boundary:DateTime64(3)}';
2831+
$rawFilters[] = '`time` >= {hybrid_boundary:DateTime64(3)}';
2832+
2833+
$dailyWhere = $this->buildWhereClause($dailyFilters, $params);
2834+
$rawWhere = $this->buildWhereClause($rawFilters, $dailyWhere['params']);
2835+
2836+
$dimSelect = '';
2837+
$dimGroup = '';
2838+
$dimCols = '';
2839+
if (!empty($rollup['dims'])) {
2840+
$escapedDims = array_map(fn (string $d): string => $this->escapeIdentifier($d), $rollup['dims']);
2841+
$dimList = implode(', ', $escapedDims);
2842+
$dimSelect = ', ' . $dimList;
2843+
$dimGroup = ', ' . $dimList;
2844+
$dimCols = ', ' . $dimList;
2845+
}
2846+
2847+
$orderClause = ' ORDER BY value DESC';
2848+
if (!empty($parsed['orderBy'])) {
2849+
$orderClause = ' ORDER BY ' . implode(', ', $parsed['orderBy']);
2850+
}
2851+
$limitClause = isset($parsed['limit']) ? ' LIMIT {limit:UInt64}' : '';
2852+
$offsetClause = isset($parsed['offset']) ? ' OFFSET {offset:UInt64}' : '';
2853+
2854+
$sql = "
2855+
SELECT metric, argMaxMerge(value) AS value{$dimSelect}
2856+
FROM (
2857+
SELECT metric{$dimCols}, value
2858+
FROM {$dailyTable}{$dailyWhere['clause']}
2859+
UNION ALL
2860+
SELECT metric{$dimCols}, argMaxState(value, time) AS value
2861+
FROM {$gaugesTable}{$rawWhere['clause']}
2862+
GROUP BY metric{$dimGroup}
2863+
)
2864+
GROUP BY metric{$dimGroup}{$orderClause}{$limitClause}{$offsetClause}
2865+
FORMAT JSON
2866+
";
2867+
2868+
$result = $this->query($sql, $rawWhere['params']);
2869+
return $this->parseAggregatedResults($result, Usage::TYPE_GAUGE);
2870+
}
2871+
26772872
/**
26782873
* Dual-read sampler: with probability `$dualReadSampleRate`, re-run
26792874
* the same logical query against raw events and log a warning if the

tests/Benchmark/BenchmarkBase.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,16 @@ protected function seedGaugeRows(int $rows, string $metric = 'storage'): void
130130

131131
$tableRef = "`{$database}`.`{$gaugesTable}`";
132132

133-
$sql = "INSERT INTO {$tableRef} (id, metric, value, time, tenant) "
133+
$services = ['storage', 'databases', 'functions', 'sites'];
134+
$resources = ['file', 'database', 'function', 'site'];
135+
$svcExpr = "['" . implode("','", $services) . "'][1 + (number % " . count($services) . ")]";
136+
$resExpr = "['" . implode("','", $resources) . "'][1 + (number % " . count($resources) . ")]";
137+
138+
$sql = "INSERT INTO {$tableRef} (id, metric, value, time, tenant, service, resource) "
134139
. "SELECT lower(hex(randomString(16))), '" . addslashes($metric) . "', "
135140
. "number AS value, now() - toIntervalSecond(number % 86400) AS time, "
136-
. "'" . addslashes($this->tenant) . "' AS tenant "
141+
. "'" . addslashes($this->tenant) . "' AS tenant, "
142+
. "{$svcExpr} AS service, {$resExpr} AS resource "
137143
. "FROM numbers({$rows})";
138144

139145
$this->runRawSql($sql);

tests/Benchmark/GaugesBench.php

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Utopia\Query\Query;
66
use Utopia\Usage\Usage;
7+
use Utopia\Usage\UsageQuery;
78

89
class GaugesBench extends BenchmarkBase
910
{
@@ -20,21 +21,59 @@ protected function setUp(): void
2021

2122
public function testBenchmarks(): void
2223
{
23-
$start = (new \DateTime('-30 days'))->format('Y-m-d H:i:s');
24-
$end = (new \DateTime('+1 day'))->format('Y-m-d H:i:s');
24+
$start30d = (new \DateTime('-30 days'))->format('Y-m-d H:i:s');
25+
$endClosed = (new \DateTime('-2 days'))->format('Y-m-d H:i:s');
26+
$endPartial = (new \DateTime('+1 day'))->format('Y-m-d H:i:s');
2527

26-
$this->runBench('bench_gauges_latest_in_window', function (string $queryId) use ($start, $end): void {
28+
$this->runBench('bench_gauges_latest_in_window', function (string $queryId) use ($start30d, $endPartial): void {
2729
$this->adapter->setNextQueryId($queryId);
2830
$this->usage->getTotal(
2931
$this->metric,
3032
[
31-
Query::greaterThanEqual('time', $start),
32-
Query::lessThanEqual('time', $end),
33+
Query::greaterThanEqual('time', $start30d),
34+
Query::lessThanEqual('time', $endPartial),
3335
],
3436
Usage::TYPE_GAUGE
3537
);
3638
});
3739

40+
$this->adapter->setUseDailyRollups(true);
41+
42+
$this->runBench('bench_gauges_topN_service_30d', function (string $queryId) use ($start30d, $endClosed): void {
43+
$this->adapter->setNextQueryId($queryId);
44+
$this->usage->find([
45+
UsageQuery::groupBy('service'),
46+
Query::equal('metric', [$this->metric]),
47+
Query::greaterThanEqual('time', $start30d),
48+
Query::lessThanEqual('time', $endClosed),
49+
Query::limit(10),
50+
], Usage::TYPE_GAUGE);
51+
});
52+
53+
$this->runBench('bench_gauges_topN_resource_30d', function (string $queryId) use ($start30d, $endClosed): void {
54+
$this->adapter->setNextQueryId($queryId);
55+
$this->usage->find([
56+
UsageQuery::groupBy('resource'),
57+
Query::equal('metric', [$this->metric]),
58+
Query::greaterThanEqual('time', $start30d),
59+
Query::lessThanEqual('time', $endClosed),
60+
Query::limit(10),
61+
], Usage::TYPE_GAUGE);
62+
});
63+
64+
$this->runBench('bench_gauges_topN_service_today_partial', function (string $queryId) use ($start30d, $endPartial): void {
65+
$this->adapter->setNextQueryId($queryId);
66+
$this->usage->find([
67+
UsageQuery::groupBy('service'),
68+
Query::equal('metric', [$this->metric]),
69+
Query::greaterThanEqual('time', $start30d),
70+
Query::lessThanEqual('time', $endPartial),
71+
Query::limit(10),
72+
], Usage::TYPE_GAUGE);
73+
});
74+
75+
$this->adapter->setUseDailyRollups(false);
76+
3877
$this->assertNotEmpty($this->results, 'Benchmark scenarios must record results');
3978
}
4079
}

0 commit comments

Comments
 (0)