diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 59316c6..43558e3 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -825,6 +825,102 @@ private function applyFilters(ClickHouseBuilder $builder, ?string $tenant, array } } + /** + * Re-express `time` filters on the hourly bucket so a grouped read can route. + * Only hour-aligned bounds are exactly expressible; anything else returns null + * and keeps the raw predicate, so no caller's window shifts. + * + * @param array $filters + * @return array{filters: array, conditions: array, bindings: array}|null + */ + private function bucketAlignedFilters(array $filters): ?array + { + $kept = []; + $lower = null; + $upper = null; + + foreach ($filters as $query) { + if ($query->getAttribute() !== 'time') { + $kept[] = $query; + continue; + } + + $values = $query->getValues(); + + // Half-open [lower, upper) in epoch ms. An unreadable bound aborts rather + // than being dropped, which would silently widen the window. + $from = null; + $to = null; + switch ($query->getMethod()) { + case Method::GreaterThanEqual: + $from = $this->toEpochMillis($values[0] ?? null); + break; + case Method::GreaterThan: + $from = $this->toEpochMillis($values[0] ?? null); + $from = $from === null ? null : $from + 1; + break; + case Method::LessThan: + $to = $this->toEpochMillis($values[0] ?? null); + break; + case Method::LessThanEqual: + $to = $this->toEpochMillis($values[0] ?? null); + $to = $to === null ? null : $to + 1; + break; + case Method::Between: + $from = $this->toEpochMillis($values[0] ?? null); + $to = $this->toEpochMillis($values[1] ?? null); + if ($from === null || $to === null) { + return null; + } + $to++; + break; + default: + return null; + } + + if ($from === null && $to === null) { + return null; + } + + $lower = $from === null ? $lower : ($lower === null ? $from : max($lower, $from)); + $upper = $to === null ? $upper : ($upper === null ? $to : min($upper, $to)); + } + + $conditions = []; + $bindings = []; + foreach ([['bucketFrom', '>=', $lower], ['bucketTo', '<', $upper]] as [$name, $operator, $bound]) { + if ($bound === null) { + continue; + } + if ($bound % 3_600_000 !== 0) { + return null; + } + $conditions[] = self::EVENT_TIME_BUCKET . " {$operator} {{$name}:" . $this->getParamType('time') . '}'; + $bindings[$name] = (new DateTimeImmutable('@' . \intdiv($bound, 1000)))->format('Y-m-d H:i:s.v'); + } + + return ['filters' => $kept, 'conditions' => $conditions, 'bindings' => $bindings]; + } + + /** + * Null for anything unreadable, which aborts the rewrite rather than guessing. + */ + private function toEpochMillis(mixed $value): ?int + { + $text = $this->stringifyTime($value); + if ($text === null) { + return null; + } + + try { + $dt = new DateTimeImmutable($text, new DateTimeZone('UTC')); + } catch (Exception $e) { + return null; + } + + return $dt->getTimestamp() * 1000 + (int) $dt->format('v'); + } + /** * Walk an array of Query objects and rewrite `time` values into ClickHouse * wire format (`Y-m-d H:i:s.v`). The builder forwards values verbatim, so @@ -881,6 +977,12 @@ private function decodeTotal(string $result): int return self::toInt($rows[0]['total']); } + /** Reads must emit this verbatim to route; the optimizer matches on the exact expression. */ + private const EVENT_TIME_BUCKET = "toStartOfHour(`time`, 'UTC')"; + + /** Sub-hour intervals need detail the bucket summed away. @var list */ + private const BUCKET_ROUTABLE_INTERVALS = ['1h', '1d', '1w', '1M']; + /** * Per-dim projection slate. Each entry declares an `ADD PROJECTION` on * the base events table. The ClickHouse optimizer transparently routes @@ -962,11 +1064,10 @@ public function setup(): void $this->setLightweightMutationProjectionMode($this->getEventsTableName()); foreach (self::EVENT_PROJECTIONS as $projection) { - $this->addProjection( + $this->addEventProjection( $this->getEventsTableName(), $projection['name'], - $projection['dims'], - 'sum(value) AS value' + $projection['dims'] ); } $this->setLightweightMutationProjectionMode($this->getGaugesTableName()); @@ -1167,6 +1268,45 @@ private function addProjection(string $baseTable, string $name, array $dims, str $this->query($sql); } + /** + * Keyed (tenant, metric, hourly bucket, ...dims). + * + * Tenant leads because a projection is sorted by its GROUP BY order; the hourly + * key keeps it O(tenant × metric × hour × dim) instead of a copy of the table. + * + * @param array $dims + */ + private function addEventProjection(string $baseTable, string $name, array $dims): void + { + $escapedTable = $this->escapeIdentifier($this->database) . '.' . $this->escapeIdentifier($baseTable); + + $selectParts = []; + $groupParts = []; + if ($this->sharedTables) { + $selectParts[] = 'tenant'; + $groupParts[] = 'tenant'; + } + $selectParts[] = 'metric'; + $groupParts[] = 'metric'; + $selectParts[] = self::EVENT_TIME_BUCKET . ' AS `timeBucket`'; + $groupParts[] = '`timeBucket`'; + foreach ($dims as $dim) { + $selectParts[] = $this->escapeIdentifier($dim); + $groupParts[] = $this->escapeIdentifier($dim); + } + $selectParts[] = 'sum(value) AS value'; + + $selectSql = implode(', ', $selectParts); + $groupSql = implode(', ', $groupParts); + + $sql = "ALTER TABLE {$escapedTable} ADD PROJECTION IF NOT EXISTS {$name} (" + . "SELECT {$selectSql} " + . "GROUP BY {$groupSql}" + . ")"; + + $this->query($sql); + } + /** * Create a MergeTree table for the given type via the schema layer. * @@ -2363,11 +2503,21 @@ private function findAggregatedFromTable(?string $tenant, array $parsed, string $groupParts = ['`metric`']; + // Only events carry an hourly key, and only hour-or-coarser derives from it. + $bucketed = null; + if ( + $type === Usage::TYPE_EVENT + && (!$hasInterval || in_array($parsed['groupByInterval'], self::BUCKET_ROUTABLE_INTERVALS, true)) + ) { + $bucketed = $this->bucketAlignedFilters($parsed['filters']); + } + $timeExpr = $bucketed === null ? '`time`' : self::EVENT_TIME_BUCKET; + // Bucket column is only emitted when time bucketing is requested. // Without it the result is a flat aggregate per (metric, …dims). if ($hasInterval) { $intervalSql = UsageQuery::VALID_INTERVALS[$parsed['groupByInterval']]; - $builder->selectRaw("toStartOfInterval(`time`, {$intervalSql}) AS `bucket`"); + $builder->selectRaw("toStartOfInterval({$timeExpr}, {$intervalSql}) AS `bucket`"); $groupParts[] = '`bucket`'; } @@ -2377,6 +2527,15 @@ private function findAggregatedFromTable(?string $tenant, array $parsed, string $groupParts[] = $escapedDim; } + $bucketBindings = []; + if ($bucketed !== null) { + $parsed['filters'] = $bucketed['filters']; + $bucketBindings = $bucketed['bindings']; + foreach ($bucketed['conditions'] as $condition) { + $builder->whereRaw($condition); + } + } + $this->applyFilters($builder, $tenant, $parsed); $builder->groupByRaw(implode(', ', $groupParts)); @@ -2417,7 +2576,7 @@ private function findAggregatedFromTable(?string $tenant, array $parsed, string $statement = $builder->build(); $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - $result = $this->query($sql, $statement->namedBindings ?? []); + $result = $this->query($sql, array_merge($statement->namedBindings ?? [], $bucketBindings)); return $this->parseAggregatedResults($result, $type); } @@ -3528,24 +3687,40 @@ private function getTimeSeriesFromTable(string $tenant, array $metrics, string $ ? 'SUM(`value`) AS `agg_value`' : 'argMax(`value`, `time`) AS `agg_value`'; + $window = Query::between('time', $this->formatDateTime($startDate), $this->formatDateTime($endDate)); + + // Hour-or-coarser, so both re-express on the hourly key when bounds align. + $bucketed = $type === Usage::TYPE_EVENT + ? $this->bucketAlignedFilters(array_merge($parsed['filters'], [$window])) + : null; + $timeExpr = $bucketed === null ? '`time`' : self::EVENT_TIME_BUCKET; + $builder = $this->newBuilder($type) ->from($tableName) ->select(['metric']) - ->selectRaw("{$timeFunction}(`time`, 'UTC') AS `bucket`") + ->selectRaw("{$timeFunction}({$timeExpr}, 'UTC') AS `bucket`") ->selectRaw($valueExpr) - ->filter([ - Query::equal('metric', $metrics), - Query::between('time', $this->formatDateTime($startDate), $this->formatDateTime($endDate)), - ]) + ->filter([Query::equal('metric', $metrics)]) ->groupByRaw('`metric`, `bucket`') ->orderByRaw('`bucket` ASC'); + $bucketBindings = []; + if ($bucketed === null) { + $builder->filter([$window]); + } else { + $parsed['filters'] = $bucketed['filters']; + $bucketBindings = $bucketed['bindings']; + foreach ($bucketed['conditions'] as $condition) { + $builder->whereRaw($condition); + } + } + $this->applyFilters($builder, $tenant, $parsed); $statement = $builder->build(); $sql = $this->qualifyDdl($statement->query, $tableName) . ' FORMAT JSON'; - $result = $this->query($sql, $statement->namedBindings ?? []); + $result = $this->query($sql, array_merge($statement->namedBindings ?? [], $bucketBindings)); $rows = $this->decodeRows($result); // Initialize result structure diff --git a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php index f6129dc..efff584 100644 --- a/tests/Usage/Adapter/ClickHouseDimRoutingTest.php +++ b/tests/Usage/Adapter/ClickHouseDimRoutingTest.php @@ -56,6 +56,30 @@ protected function tearDown(): void $this->usage->purge('1'); } + /** + * Start of the containing hour. The event projections are keyed on + * toStartOfHour(time), so only a bound on an hour boundary can be + * re-expressed on the bucket without moving the window edge — everything + * else keeps the raw predicate and reads the base table. + */ + private function hourAligned(string $modifier): string + { + $dt = new DateTime($modifier, new DateTimeZone('UTC')); + return $dt->setTime((int) $dt->format('H'), 0, 0)->format('Y-m-d H:i:s'); + } + + /** + * Last representable instant of the containing hour. An *inclusive* upper + * bound is only expressible on the bucket one tick below a boundary: + * `<= 13:59:59.999` is `< 14:00`, whereas `<= 14:00:00.000` would need the + * single instant 14:00 out of a bucket the projection stores whole. + */ + private function hourEnd(string $modifier): string + { + $dt = new DateTime($modifier, new DateTimeZone('UTC')); + return $dt->setTime((int) $dt->format('H'), 59, 59)->format('Y-m-d H:i:s.') . '999'; + } + /** * @param array $tags */ @@ -104,8 +128,8 @@ public static function topNProjectionProvider(): array */ public function testTopNGroupedQueryRoutesToMatchingProjection(array $dims, string $expectedProjection): void { - $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); - $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); $queries = []; foreach ($dims as $dim) { @@ -126,8 +150,8 @@ public function testTopNGroupedQueryRoutesToMatchingProjection(array $dims, stri public function testMultiDimNotInAnyProjectionFallsBackToTable(): void { - $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); - $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); @@ -147,8 +171,8 @@ public function testFilterOnExtraColumnStillRoutesToProjectionWhenColumnPresent( // resourceType is a column on the events table but not in p_by_path's // projection; the optimizer cannot satisfy this query from the // projection and must scan the base table. - $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); - $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); @@ -165,12 +189,10 @@ public function testFilterOnExtraColumnStillRoutesToProjectionWhenColumnPresent( public function testSubDayIntervalStillRoutesToProjection(): void { - // Projections retain raw `time`, so the 1h bucket query - // toStartOfInterval(time, 1 HOUR) can still be satisfied from - // the projection — and that's a net win over scanning the base - // table. - $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); - $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); + // The 1h bucket is the projection's own key, so this is the shape it + // serves most directly. + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); @@ -187,8 +209,8 @@ public function testSubDayIntervalStillRoutesToProjection(): void public function testWindowStraddlesTodayStillRoutesToProjection(): void { - $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); - $end = (new DateTime('+1 hour'))->format('Y-m-d H:i:s'); + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('+2 hours'); $queryId = bin2hex(random_bytes(8)); $this->adapter->setNextQueryId($queryId); @@ -207,6 +229,171 @@ public function testWindowStraddlesTodayStillRoutesToProjection(): void $this->assertProjectionUsed($queryId, 'p_by_path'); } + /** + * @return array + */ + public static function coarseIntervalProvider(): array + { + return ['1d' => ['1d'], '1w' => ['1w'], '1M' => ['1M']]; + } + + /** + * @dataProvider coarseIntervalProvider + */ + public function testCoarseIntervalRoutesThroughTheHourlyBucket(string $interval): void + { + // Every routable bucket is a whole number of hours, so composing it over + // the projection's key yields the same value the base table would. + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); + + $queryId = bin2hex(random_bytes(8)); + $this->adapter->setNextQueryId($queryId); + $rolled = $this->usage->find('1', [ + UsageQuery::groupByInterval('time', $interval), + UsageQuery::groupBy('path'), + Query::equal('metric', [$this->metric]), + Query::greaterThanEqual('time', $start), + Query::lessThanEqual('time', $end), + ], Usage::TYPE_EVENT); + + $this->assertSame($this->rawTotal($start, $end), $this->totalOf($rolled)); + $this->assertProjectionUsed($queryId, 'p_by_path'); + } + + public function testSubHourIntervalReadsTheBaseTable(): void + { + // A 15m bucket needs detail the hourly projection has already summed + // away, so the read stays on the base table. + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); + + $queryId = bin2hex(random_bytes(8)); + $this->adapter->setNextQueryId($queryId); + $rolled = $this->usage->find('1', [ + UsageQuery::groupByInterval('time', '15m'), + UsageQuery::groupBy('path'), + Query::equal('metric', [$this->metric]), + Query::greaterThanEqual('time', $start), + Query::lessThanEqual('time', $end), + ], Usage::TYPE_EVENT); + + $this->assertSame($this->rawTotal($start, $end), $this->totalOf($rolled)); + $this->assertNoProjectionUsed($queryId); + } + + public function testMidHourWindowKeepsRawPredicateAndTotals(): void + { + // The window edge cannot be expressed on the bucket, so the rewrite is + // declined: slower, but the caller's boundary is not moved. + $start = (new DateTime('-7 days', new DateTimeZone('UTC')))->setTime(13, 37, 21)->format('Y-m-d H:i:s'); + $end = (new DateTime('-2 days', new DateTimeZone('UTC')))->setTime(9, 14, 3)->format('Y-m-d H:i:s'); + + $queryId = bin2hex(random_bytes(8)); + $this->adapter->setNextQueryId($queryId); + $rolled = $this->usage->find('1', [ + UsageQuery::groupBy('path'), + Query::equal('metric', [$this->metric]), + Query::greaterThanEqual('time', $start), + Query::lessThanEqual('time', $end), + Query::limit(50), + ], Usage::TYPE_EVENT); + + $this->assertSame($this->rawTotal($start, $end), $this->totalOf($rolled)); + $this->assertNoProjectionUsed($queryId); + } + + public function testDayIntervalBucketsMatchAnUnbucketedDayRollup(): void + { + // The day bucket is composed over the projection's hourly key rather + // than taken from raw `time`. That only holds if every bucket value + // survives the composition, so compare bucket-for-bucket against a + // direct base-table scan rather than just the grand total. + foreach (['-5 days -2 hours', '-5 days -9 hours', '-4 days -1 hour', '-4 days -17 hours'] as $offset) { + $this->seedHistoricalRow($this->metric, 7, $offset, ['path' => '/v1/a']); + } + + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); + + $queryId = bin2hex(random_bytes(8)); + $this->adapter->setNextQueryId($queryId); + $rolled = $this->usage->find('1', [ + UsageQuery::groupByInterval('time', '1d'), + Query::equal('metric', [$this->metric]), + Query::greaterThanEqual('time', $start), + Query::lessThanEqual('time', $end), + ], Usage::TYPE_EVENT); + + $this->assertProjectionUsed($queryId, 'p_by_path'); + $this->assertNotEmpty($rolled); + $this->assertSame($this->rawDayBuckets($start, $end), $this->bucketsOf($rolled)); + } + + public function testTimeSeriesDayIntervalRoutesAndMatchesRawScan(): void + { + foreach (['-5 days -2 hours', '-5 days -9 hours', '-4 days -1 hour'] as $offset) { + $this->seedHistoricalRow($this->metric, 7, $offset, ['path' => '/v1/a']); + } + + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); + + $queryId = bin2hex(random_bytes(8)); + $this->adapter->setNextQueryId($queryId); + $series = $this->usage->getTimeSeries('1', [$this->metric], '1d', $start, $end, [], false, Usage::TYPE_EVENT); + + $this->assertProjectionUsed($queryId, 'p_by_path'); + + $points = []; + foreach ($series[$this->metric]['data'] as $point) { + $points[(string) $point['date']] = (int) $point['value']; + } + $this->assertNotEmpty($points); + $this->assertSame($this->rawDayBuckets($start, $end), $points); + } + + public function testGaugeReadKeepsTheRawTimePredicate(): void + { + // Gauge projections still key on raw `time`, so gauge reads are left + // on their original predicate — the billing gauge rollups included. + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); + + $queryId = bin2hex(random_bytes(8)); + $this->adapter->setNextQueryId($queryId); + $this->usage->find('1', [ + UsageQuery::groupBy('resourceId'), + Query::equal('metric', [$this->metric]), + Query::greaterThanEqual('time', $start), + Query::lessThanEqual('time', $end), + ], Usage::TYPE_GAUGE); + + $this->assertStringNotContainsString('toStartOfHour', $this->queryTextFor($queryId)); + } + + public function testInclusiveEndOfDayUpperBoundRoutes(): void + { + // `<= 23:59:59.999` is `< midnight`, which is on an hour boundary — + // the shape callers reach for when they mean "through the end of the + // day" still routes. + $start = (new DateTime('-7 days', new DateTimeZone('UTC')))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); + $end = (new DateTime('-2 days', new DateTimeZone('UTC')))->setTime(23, 59, 59)->format('Y-m-d H:i:s.') . '999'; + + $queryId = bin2hex(random_bytes(8)); + $this->adapter->setNextQueryId($queryId); + $rolled = $this->usage->find('1', [ + UsageQuery::groupBy('path'), + Query::equal('metric', [$this->metric]), + Query::greaterThanEqual('time', $start), + Query::lessThanEqual('time', $end), + Query::limit(50), + ], Usage::TYPE_EVENT); + + $this->assertSame($this->rawTotal($start, $end), $this->totalOf($rolled)); + $this->assertProjectionUsed($queryId, 'p_by_path'); + } + /** * @param array $queries */ @@ -235,6 +422,70 @@ private function rawTotal(string $start, string $end): int return is_int($result) ? $result : 0; } + /** + * Day totals read straight off the base table with projections disabled — + * the reference the composed day bucket has to reproduce exactly. + * + * @return array + */ + private function rawDayBuckets(string $start, string $end): array + { + $database = $this->databaseName($this->adapter); + $table = $this->resolveTableName($this->adapter, 'getEventsTableName'); + + $raw = $this->queryRaw($this->adapter, "SELECT toStartOfDay(`time`, 'UTC') AS bucket, sum(`value`) AS value " + . "FROM `{$database}`.`{$table}` " + . "WHERE `tenant` = '1' AND `metric` = '" . addslashes($this->metric) . "' " + . "AND `time` >= '{$start}' AND `time` <= '{$end}' " + . 'GROUP BY bucket ORDER BY bucket ASC ' + . 'SETTINGS optimize_use_projections = 0 FORMAT JSON'); + + $json = json_decode($raw, true); + $out = []; + $data = (is_array($json) && is_array($json['data'] ?? null)) ? $json['data'] : []; + foreach ($data as $row) { + if (!is_array($row) || !is_string($row['bucket'] ?? null) || !is_numeric($row['value'] ?? null)) { + continue; + } + $out[str_replace(' ', 'T', $row['bucket']) . '+00:00'] = (int) $row['value']; + } + return $out; + } + + /** + * @param array<\Utopia\Usage\Metric> $metrics + * @return array + */ + private function bucketsOf(array $metrics): array + { + $out = []; + foreach ($metrics as $metric) { + $time = $metric->getAttribute('time'); + $value = $metric->getValue(0); + $out[is_string($time) ? $time : ''] = is_numeric($value) ? (int) $value : 0; + } + ksort($out); + return $out; + } + + private function queryTextFor(string $queryId): string + { + $this->queryRaw($this->adapter, 'SYSTEM FLUSH LOGS'); + + $escaped = addslashes($queryId); + $raw = $this->queryRaw($this->adapter, "SELECT query FROM system.query_log " + . "WHERE query_id = '{$escaped}' AND type = 'QueryFinish' " + . 'ORDER BY event_time DESC LIMIT 1 FORMAT JSON'); + + $json = json_decode($raw, true); + $data = (is_array($json) && is_array($json['data'] ?? null)) ? $json['data'] : []; + $row = $data[0] ?? null; + if (is_array($row) && is_string($row['query'] ?? null)) { + return $row['query']; + } + return ''; + } + /** * @param array<\Utopia\Usage\Metric> $metrics */ @@ -301,8 +552,8 @@ private function projectionsForQueryId(string $queryId): array public function testIdFilterForcesRaw(): void { - $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); - $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); $route = $this->routeFor([ Query::equal('metric', [$this->metric]), @@ -316,8 +567,8 @@ public function testIdFilterForcesRaw(): void public function testValueFilterForcesRaw(): void { - $start = (new DateTime('-7 days'))->format('Y-m-d H:i:s'); - $end = (new DateTime('-2 days'))->format('Y-m-d H:i:s'); + $start = $this->hourAligned('-7 days'); + $end = $this->hourEnd('-2 days'); $route = $this->routeFor([ Query::equal('metric', [$this->metric]), diff --git a/tests/Usage/Adapter/ClickHouseSchemaTest.php b/tests/Usage/Adapter/ClickHouseSchemaTest.php index 4829c27..1742f6a 100644 --- a/tests/Usage/Adapter/ClickHouseSchemaTest.php +++ b/tests/Usage/Adapter/ClickHouseSchemaTest.php @@ -57,6 +57,34 @@ public function testEventsTableCarriesCodecsAndLowCardinality(): void $this->assertStringContainsString('`sdkVersion` LowCardinality(Nullable(String)) CODEC(ZSTD(3))', $ddl); } + public function testEventProjectionsLeadWithTenantAndKeyOnTheHourlyBucket(): void + { + $ddl = $this->showCreate($this->resolveTableName($this->adapter, 'getEventsTableName')); + + // A projection is sorted by its GROUP BY order, so key order is the + // whole point of this assertion, not just its contents. + $this->assertStringContainsString( + "GROUP BY\n tenant,\n metric,\n timeBucket,\n path", + $ddl + ); + $this->assertStringContainsString("toStartOfHour(time, 'UTC') AS timeBucket", $ddl); + } + + public function testGaugeProjectionsAreLeftOnTheirOriginalShape(): void + { + $ddl = $this->showCreate($this->resolveTableName($this->adapter, 'getGaugesTableName')); + + // Gauges are deliberately excluded from the events reshape: they show + // no measured read problem, and bucketing `time` away would only cost + // a migration, since argMax orders on the raw column. Pinned here so + // the exclusion is not "finished" without fresh measurements. + $this->assertStringContainsString( + "GROUP BY\n metric,\n time,\n tenant,\n service", + $ddl + ); + $this->assertStringNotContainsString('timeBucket', $ddl); + } + public function testEventsTableSwapsBloomForSetOnLowCardinality(): void { $ddl = $this->showCreate($this->resolveTableName($this->adapter, 'getEventsTableName'));