From 47819d72aba739c56a4384f50e6bfb3373783bae Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 15 Jul 2026 17:33:42 +1200 Subject: [PATCH] feat: add ordinal gauge dimension column Multi-node resources (e.g. dedicated database StatefulSets) report one gauge sample per pod. Without a distinguishing dimension, replica rows for the same (metric, resourceId) collapse into one argMax series and corrupt each other. The ordinal column carries the pod ordinal (0 is the primary) so per-replica series stay distinct and remain queryable via filters and groupBy. Existing gauge tables gain the column automatically through ensureDimColumns (ADD COLUMN IF NOT EXISTS) at setup. Co-Authored-By: Claude Opus 4.8 --- src/Usage/Adapter/ClickHouse.php | 2 + src/Usage/Adapter/Database.php | 19 ++++++- src/Usage/Metric.php | 17 +++++-- .../Adapter/ClickHouseColumnTypeTest.php | 21 ++++++-- tests/Usage/Adapter/ClickHouseSchemaTest.php | 1 + tests/Usage/Adapter/ClickHouseTest.php | 50 +++++++++++++++++++ tests/Usage/MetricTest.php | 30 ++++++++++- 7 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/Usage/Adapter/ClickHouse.php b/src/Usage/Adapter/ClickHouse.php index 8460166..0ad833a 100644 --- a/src/Usage/Adapter/ClickHouse.php +++ b/src/Usage/Adapter/ClickHouse.php @@ -1215,6 +1215,8 @@ private function getColumnType(string $id, string $type = 'event'): string 'connectionUsageType', 'autonomousSystemNumber', // sdk identity 'sdk', 'sdkVersion', + // gauge replica ordinal + 'ordinal', ]; if (in_array($id, $lowCardinality, true)) { diff --git a/src/Usage/Adapter/Database.php b/src/Usage/Adapter/Database.php index 4d25bdb..dae9526 100644 --- a/src/Usage/Adapter/Database.php +++ b/src/Usage/Adapter/Database.php @@ -76,7 +76,8 @@ public function setup(): void // Event schema is a superset of the gauge schema for the dimensions // that exist in both (resourceId, resourceInternalId, teamId, // teamInternalId), so a single Database collection backed by the - // event schema works for both types. + // event schema works for both types. Gauge-only columns (ordinal) + // are appended below. $attributes = $this->getAttributeDocuments('event'); $indexDocs = $this->getIndexDocuments('event'); @@ -97,6 +98,22 @@ public function setup(): void 'attributes' => ['type'], ]); + // Gauge-only replica ordinal dimension. + $attributes[] = new Document([ + '$id' => 'ordinal', + 'type' => 'string', + 'size' => 255, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]); + $indexDocs[] = new Document([ + '$id' => 'index-ordinal', + 'type' => 'key', + 'attributes' => ['ordinal'], + ]); + try { $this->db->createCollection( $this->collection, diff --git a/src/Usage/Metric.php b/src/Usage/Metric.php index 416efcc..6c1eff9 100644 --- a/src/Usage/Metric.php +++ b/src/Usage/Metric.php @@ -69,7 +69,7 @@ class Metric extends ArrayObject /** * Gauge-specific column names that are extracted from tags into dedicated columns. */ - public const GAUGE_COLUMNS = ['service', 'resourceType', 'teamId', 'teamInternalId', 'resourceId', 'resourceInternalId']; + public const GAUGE_COLUMNS = ['service', 'resourceType', 'teamId', 'teamInternalId', 'resourceId', 'resourceInternalId', 'ordinal']; /** * Construct a new metric object. @@ -99,6 +99,7 @@ class Metric extends ArrayObject * * Gauge-only dimension columns (see GAUGE_COLUMNS): * - teamId / teamInternalId / resourceId / resourceInternalId + * - ordinal: replica ordinal for multi-node resources (0 is the primary) * * @param array $input Metric data */ @@ -279,6 +280,15 @@ public function getResourceInternalId(): ?string return is_string($v) ? $v : null; } + /** + * Get replica ordinal (gauge metrics). 0 is the primary; 1+ are replicas. + */ + public function getOrdinal(): ?string + { + $v = $this->getAttribute('ordinal', null); + return is_string($v) ? $v : null; + } + /** * Get team id (event/gauge metrics). */ @@ -721,6 +731,7 @@ public static function getGaugeSchema(): array $stringColumn('teamInternalId', 255), $stringColumn('resourceId', 255), $stringColumn('resourceInternalId', 255), + $stringColumn('ordinal', 255), ]; } @@ -778,9 +789,9 @@ static function (string $col) use ($setIndexed): array { */ public static function getGaugeIndexes(): array { - $indexed = ['service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId']; + $indexed = ['service', 'resourceType', 'resourceId', 'resourceInternalId', 'teamId', 'teamInternalId', 'ordinal']; - $setIndexed = ['service', 'resourceType']; + $setIndexed = ['service', 'resourceType', 'ordinal']; return array_map( static fn (string $col): array => [ diff --git a/tests/Usage/Adapter/ClickHouseColumnTypeTest.php b/tests/Usage/Adapter/ClickHouseColumnTypeTest.php index 834015e..b6cf638 100644 --- a/tests/Usage/Adapter/ClickHouseColumnTypeTest.php +++ b/tests/Usage/Adapter/ClickHouseColumnTypeTest.php @@ -33,11 +33,11 @@ protected function setUp(): void $this->getColumnType->setAccessible(true); } - private function columnType(string $id): string + private function columnType(string $id, string $type = 'event'): string { - /** @var string $type */ - $type = $this->getColumnType->invoke($this->adapter, $id, 'event'); - return $type; + /** @var string $columnType */ + $columnType = $this->getColumnType->invoke($this->adapter, $id, $type); + return $columnType; } /** @@ -86,4 +86,17 @@ public function testLowCardinalitySdkColumns(): void ); } } + + /** + * The gauge replica ordinal holds a handful of distinct values, so it + * must map to LowCardinality(Nullable(String)). + */ + public function testLowCardinalityOrdinalColumn(): void + { + $this->assertSame( + 'LowCardinality(Nullable(String))', + $this->columnType('ordinal', 'gauge'), + 'ordinal should be LowCardinality(Nullable(String))' + ); + } } diff --git a/tests/Usage/Adapter/ClickHouseSchemaTest.php b/tests/Usage/Adapter/ClickHouseSchemaTest.php index 94e8618..c5bd17c 100644 --- a/tests/Usage/Adapter/ClickHouseSchemaTest.php +++ b/tests/Usage/Adapter/ClickHouseSchemaTest.php @@ -269,6 +269,7 @@ private function expectedDimAssertions(array $columns, string $type): array 'continentCode', 'subdivisions', 'connectionType', 'connectionUsageType', 'autonomousSystemNumber', 'sdk', 'sdkVersion', + 'ordinal', ]; $baseKey = ['id', 'metric', 'value', 'time', 'tenant']; diff --git a/tests/Usage/Adapter/ClickHouseTest.php b/tests/Usage/Adapter/ClickHouseTest.php index 83aea81..b294065 100644 --- a/tests/Usage/Adapter/ClickHouseTest.php +++ b/tests/Usage/Adapter/ClickHouseTest.php @@ -314,6 +314,7 @@ public function testGaugeColumnsRoundTrip(): void 'teamInternalId' => '7', 'resourceId' => 'r1', 'resourceInternalId' => '42', + 'ordinal' => '0', ], ], ], Usage::TYPE_GAUGE)); @@ -330,6 +331,55 @@ public function testGaugeColumnsRoundTrip(): void $this->assertEquals('7', $metric->getTeamInternalId()); $this->assertEquals('r1', $metric->getResourceId()); $this->assertEquals('42', $metric->getResourceInternalId()); + $this->assertEquals('0', $metric->getOrdinal()); + } + + /** + * Per-replica gauge rows for one resource stay distinct series via the + * ordinal dimension: filtering by ordinal isolates one node, grouping by + * ordinal returns the latest snapshot per node. + */ + public function testGaugeOrdinalSeparatesReplicaSeries(): void + { + $this->usage->purge('1', [], Usage::TYPE_GAUGE); + + $this->assertTrue($this->usage->addBatch([ + [ + 'tenant' => '1', + 'metric' => 'gauge-ordinal-test', + 'value' => 10, + 'tags' => ['resourceType' => 'databases', 'resourceId' => 'db1', 'ordinal' => '0'], + ], + [ + 'tenant' => '1', + 'metric' => 'gauge-ordinal-test', + 'value' => 20, + 'tags' => ['resourceType' => 'databases', 'resourceId' => 'db1', 'ordinal' => '1'], + ], + ], Usage::TYPE_GAUGE)); + + $primary = $this->usage->find('1', [ + \Utopia\Query\Query::equal('metric', ['gauge-ordinal-test']), + \Utopia\Query\Query::equal('ordinal', ['0']), + ], Usage::TYPE_GAUGE); + + $this->assertCount(1, $primary); + $this->assertEquals(10, $primary[0]->getValue()); + $this->assertEquals('0', $primary[0]->getOrdinal()); + + $perNode = $this->usage->find('1', [ + \Utopia\Query\Query::equal('metric', ['gauge-ordinal-test']), + UsageQuery::groupBy('ordinal'), + ], Usage::TYPE_GAUGE); + + $this->assertCount(2, $perNode); + $byOrdinal = []; + foreach ($perNode as $row) { + $ordinal = $row->getOrdinal(); + $this->assertNotNull($ordinal); + $byOrdinal[$ordinal] = (int) $row->getValue(); + } + $this->assertEquals(['0' => 10, '1' => 20], $byOrdinal); } public function testUnknownTagKeyThrows(): void diff --git a/tests/Usage/MetricTest.php b/tests/Usage/MetricTest.php index 718b148..fa7e3e6 100644 --- a/tests/Usage/MetricTest.php +++ b/tests/Usage/MetricTest.php @@ -626,10 +626,38 @@ public function testEventColumnsConstant(): void */ public function testGaugeColumnsConstant(): void { - $expected = ['service', 'resourceType', 'teamId', 'teamInternalId', 'resourceId', 'resourceInternalId']; + $expected = ['service', 'resourceType', 'teamId', 'teamInternalId', 'resourceId', 'resourceInternalId', 'ordinal']; $this->assertSame($expected, Metric::GAUGE_COLUMNS); } + /** + * The replica ordinal is a gauge-only dimension: present in GAUGE_COLUMNS, + * the gauge schema and gauge indexes, extracted from tags into its column, + * and readable via the typed accessor. Events must not carry it. + */ + public function testOrdinalIsGaugeOnly(): void + { + $this->assertContains('ordinal', Metric::GAUGE_COLUMNS); + $this->assertNotContains('ordinal', Metric::EVENT_COLUMNS); + + $gaugeIds = array_column(Metric::getGaugeSchema(), '$id'); + $this->assertContains('ordinal', $gaugeIds); + + $eventIds = array_column(Metric::getEventSchema(), '$id'); + $this->assertNotContains('ordinal', $eventIds); + + $indexIds = array_column(Metric::getGaugeIndexes(), '$id'); + $this->assertContains('index-ordinal', $indexIds); + + $columns = Metric::extractColumns(['resourceId' => 'db_a', 'ordinal' => 1], 'gauge'); + $this->assertSame('1', $columns['ordinal']); + $this->assertSame('db_a', $columns['resourceId']); + + $metric = new Metric(['ordinal' => '2']); + $this->assertSame('2', $metric->getOrdinal()); + $this->assertNull((new Metric([]))->getOrdinal()); + } + /** * Test that the event schema contains every new dimension column. */