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. */