@@ -1115,6 +1115,13 @@ private function validateGroupByAttribute(string $attribute, string $type): bool
11151115 {
11161116 $ allowed = $ type === Usage::TYPE_GAUGE ? Metric::GAUGE_COLUMNS : Metric::EVENT_COLUMNS ;
11171117
1118+ // `tenant` is a real column in shared-tables mode, not a dimension on
1119+ // Metric. Grouping by it is what makes a cross-tenant read
1120+ // ({@see findAcrossTenants}) attributable, so allow it there.
1121+ if ($ this ->sharedTables ) {
1122+ $ allowed [] = 'tenant ' ;
1123+ }
1124+
11181125 if (in_array ($ attribute , $ allowed , true )) {
11191126 return true ;
11201127 }
@@ -1279,9 +1286,10 @@ private function getColumnCodec(string $id): string
12791286 * @param string $type Metric type ('event' or 'gauge')
12801287 * @param array<string,mixed> $tags Tags
12811288 * @param int|null $metricIndex Index for batch error messages
1289+ * @param bool $allowNegative Permit a negative value for this row (default: reject)
12821290 * @throws Exception
12831291 */
1284- private function validateMetricData (string $ metric , int $ value , string $ type , array $ tags , ?int $ metricIndex = null ): void
1292+ private function validateMetricData (string $ metric , int $ value , string $ type , array $ tags , ?int $ metricIndex = null , bool $ allowNegative = false ): void
12851293 {
12861294 $ prefix = $ metricIndex !== null ? "Metric # {$ metricIndex }: " : '' ;
12871295
@@ -1293,7 +1301,11 @@ private function validateMetricData(string $metric, int $value, string $type, ar
12931301 throw new Exception ($ prefix . 'Metric exceeds maximum size of 255 characters ' );
12941302 }
12951303
1296- if ($ value < 0 ) {
1304+ // Negatives are rejected by default so a buggy negative count/bandwidth
1305+ // is caught. A row opts in with `allowNegative` for genuine signed
1306+ // deltas (realtime connections emit +1/-1). The library stays generic —
1307+ // the caller decides which metrics may be negative.
1308+ if ($ value < 0 && !$ allowNegative ) {
12971309 throw new Exception ($ prefix . 'Value cannot be negative ' );
12981310 }
12991311
@@ -1331,7 +1343,10 @@ private function validateMetricsBatch(array $metrics, string $type): void
13311343
13321344 /** @var array<string, mixed> */
13331345 $ tags = $ metricData ['tags ' ] ?? [];
1334- $ this ->validateMetricData ($ metric , $ value , $ type , $ tags , $ index );
1346+ // `allowNegative` is a validation-only flag carried on the row; it
1347+ // gates the negative-value guard and is never stored as a column.
1348+ $ allowNegative = (bool ) ($ metricData ['allowNegative ' ] ?? false );
1349+ $ this ->validateMetricData ($ metric , $ value , $ type , $ tags , $ index , $ allowNegative );
13351350
13361351 $ hasTenant = array_key_exists ('tenant ' , $ metricData );
13371352
@@ -1452,6 +1467,44 @@ public function find(string $tenant, array $queries = [], ?string $type = null):
14521467 {
14531468 $ this ->setOperationContext ('find() ' );
14541469
1470+ return $ this ->findScoped ($ tenant , $ queries , $ type );
1471+ }
1472+
1473+ /**
1474+ * Find metrics across every tenant. Applies no tenant filter, so it is
1475+ * restricted to shared-tables mode and reserved for operator-side
1476+ * aggregation jobs — see {@see Adapter::findAcrossTenants()}.
1477+ *
1478+ * Callers should add `groupBy('tenant')` to keep rows attributable; the
1479+ * aggregated paths already carry `tenant` through select/group-by in
1480+ * shared-tables mode.
1481+ *
1482+ * @param array<Query> $queries
1483+ * @param string|null $type
1484+ * @return array<Metric>
1485+ * @throws Exception
1486+ */
1487+ public function findAcrossTenants (array $ queries = [], ?string $ type = null ): array
1488+ {
1489+ $ this ->setOperationContext ('findAcrossTenants() ' );
1490+
1491+ if (!$ this ->sharedTables ) {
1492+ throw new Exception ('findAcrossTenants() requires shared-tables mode; use find() instead ' );
1493+ }
1494+
1495+ return $ this ->findScoped (null , $ queries , $ type );
1496+ }
1497+
1498+ /**
1499+ * Shared body for find()/findAcrossTenants(). A null $tenant means no
1500+ * tenant filter (cross-tenant); a string scopes to that tenant.
1501+ *
1502+ * @param array<Query> $queries
1503+ * @return array<Metric>
1504+ * @throws Exception
1505+ */
1506+ private function findScoped (?string $ tenant , array $ queries , ?string $ type ): array
1507+ {
14551508 if ($ type !== null ) {
14561509 return $ this ->findFromTable ($ tenant , $ queries , $ type );
14571510 }
@@ -1533,12 +1586,15 @@ private function queriesMatchType(array $queries, string $type): bool
15331586 * - Gauges: SELECT metric, argMax(value, time) as value, toStartOfInterval(time, INTERVAL ...) as time
15341587 * Results are grouped by metric and time bucket, ordered by time ASC.
15351588 *
1589+ * An `aggregate('max')` query overrides the per-type default value
1590+ * expression — see {@see findAggregatedFromTable()}.
1591+ *
15361592 * @param array<Query> $queries
15371593 * @param string $type 'event' or 'gauge'
15381594 * @return array<Metric>
15391595 * @throws Exception
15401596 */
1541- private function findFromTable (string $ tenant , array $ queries , string $ type ): array
1597+ private function findFromTable (? string $ tenant , array $ queries , string $ type ): array
15421598 {
15431599 $ tableName = $ this ->getTableForType ($ type );
15441600 $ fromTable = $ this ->buildTableReference ($ tableName );
@@ -1551,9 +1607,17 @@ private function findFromTable(string $tenant, array $queries, string $type): ar
15511607 throw new Exception ('Cursor pagination cannot be combined with groupByInterval ' );
15521608 }
15531609
1554- // Route through the aggregated path whenever any aggregation
1555- // hint is present — time bucketing, dimension breakdown, or both.
1556- if (isset ($ parsed ['groupByInterval ' ]) || !empty ($ parsed ['groupBy ' ])) {
1610+ // Route through the aggregated path whenever any aggregation hint is
1611+ // present — time bucketing, dimension breakdown, an explicit
1612+ // aggregate(), or any combination. An aggregate() on its own still
1613+ // counts: `aggregate('max')` with no interval and no dimensions is the
1614+ // flat "highest value over this window" shape, and without this it
1615+ // would fall through and return raw rows instead.
1616+ if (
1617+ isset ($ parsed ['groupByInterval ' ])
1618+ || !empty ($ parsed ['groupBy ' ])
1619+ || isset ($ parsed ['aggregate ' ])
1620+ ) {
15571621 return $ this ->findAggregatedFromTable ($ parsed , $ fromTable , $ type );
15581622 }
15591623
@@ -1614,7 +1678,7 @@ private function findFromTable(string $tenant, array $queries, string $type): ar
16141678 * toStartOfInterval(time, INTERVAL 1 HOUR) as time
16151679 * FROM table WHERE ... GROUP BY metric, time ORDER BY time ASC
16161680 *
1617- * @param array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array<int, string>} $parsed Parsed query data from parseQueries()
1681+ * @param array{filters: array<string>, params: array<string, mixed>, orderBy?: array<string>, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array<int, string>, aggregate?: string } $parsed Parsed query data from parseQueries()
16181682 * @param string $fromTable Fully qualified table reference
16191683 * @param string $type 'event' or 'gauge'
16201684 * @return array<Metric>
@@ -1624,11 +1688,18 @@ private function findAggregatedFromTable(array $parsed, string $fromTable, strin
16241688 {
16251689 $ hasInterval = isset ($ parsed ['groupByInterval ' ]);
16261690
1627- // Choose aggregation function based on metric type
1691+ // Choose aggregation function based on metric type. `aggregate('max')`
1692+ // overrides both defaults: for gauges it takes the highest reading in
1693+ // the bucket rather than the latest one (argMax), which is what rolling
1694+ // a sampled level series up to a coarser interval needs.
16281695 $ valueExpr = $ type === Usage::TYPE_GAUGE
16291696 ? 'argMax(value, time) as value '
16301697 : 'SUM(value) as value ' ;
16311698
1699+ if (($ parsed ['aggregate ' ] ?? null ) === 'max ' ) {
1700+ $ valueExpr = 'max(value) as value ' ;
1701+ }
1702+
16321703 // Bucket column is only emitted when time bucketing is requested.
16331704 // Without it the result is a flat aggregate per (metric, …dims).
16341705 $ bucketSelect = '' ;
@@ -3470,15 +3541,18 @@ private function buildOrderBySql(array $orderAttributes, bool $flip = false): ar
34703541 * @param string $tenant Tenant scope (shared-tables mode)
34713542 * @param array<Query> $queries
34723543 * @param string $type 'event' or 'gauge' — used for attribute validation
3473- * @return array{filters: array<int, string>, params: array<string, mixed>, orderBy?: array<string>, orderAttributes?: array<int, array{attribute: string, direction: string}>, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array<int, string>, cursor?: array<string, mixed>, cursorDirection?: string}
3544+ * @return array{filters: array<int, string>, params: array<string, mixed>, orderBy?: array<string>, orderAttributes?: array<int, array{attribute: string, direction: string}>, limit?: int, offset?: int, groupByInterval?: string, groupBy?: array<int, string>, aggregate?: string, cursor?: array<string, mixed>, cursorDirection?: string}
34743545 * @throws Exception
34753546 */
3476- private function parseQueries (string $ tenant , array $ queries , string $ type = 'event ' ): array
3547+ private function parseQueries (? string $ tenant , array $ queries , string $ type = 'event ' ): array
34773548 {
3478- if ($ this ->sharedTables ) {
3479- // An empty tenant would compile to `tenant = ''` and silently read
3480- // an empty scope. Fail fast instead, like the write side. ("0" is
3481- // a valid tenant id, so check for '' specifically.)
3549+ // A null tenant is the explicit cross-tenant read used by operator-side
3550+ // aggregation ({@see findAcrossTenants}) — no tenant filter is applied.
3551+ // It is deliberately distinct from '': an empty string would compile to
3552+ // `tenant = ''` and silently read an empty scope, so that still fails
3553+ // fast, like the write side. ("0" is a valid tenant id, so check for ''
3554+ // specifically.)
3555+ if ($ this ->sharedTables && $ tenant !== null ) {
34823556 if ($ tenant === '' ) {
34833557 throw new Exception ('Tenant cannot be empty in shared-tables mode ' );
34843558 }
@@ -3493,6 +3567,7 @@ private function parseQueries(string $tenant, array $queries, string $type = 'ev
34933567 $ offset = null ;
34943568 $ groupByInterval = null ;
34953569 $ groupBy = [];
3570+ $ aggregate = null ;
34963571 $ cursor = null ;
34973572 $ cursorDirection = null ;
34983573 $ paramCounter = 0 ;
@@ -3739,6 +3814,16 @@ private function parseQueries(string $tenant, array $queries, string $type = 'ev
37393814 $ groupBy [] = $ attribute ;
37403815 }
37413816 break ;
3817+
3818+ case UsageQuery::TYPE_AGGREGATE :
3819+ $ aggValue = $ values [0 ] ?? null ;
3820+ if (!is_string ($ aggValue ) || !in_array ($ aggValue , UsageQuery::VALID_AGGREGATES , true )) {
3821+ throw new Exception (
3822+ 'Invalid aggregate: expected one of ' . implode (', ' , UsageQuery::VALID_AGGREGATES )
3823+ );
3824+ }
3825+ $ aggregate = $ aggValue ;
3826+ break ;
37423827 }
37433828 }
37443829
@@ -3768,6 +3853,10 @@ private function parseQueries(string $tenant, array $queries, string $type = 'ev
37683853 $ result ['groupBy ' ] = $ groupBy ;
37693854 }
37703855
3856+ if ($ aggregate !== null ) {
3857+ $ result ['aggregate ' ] = $ aggregate ;
3858+ }
3859+
37713860 if ($ cursor !== null && $ cursorDirection !== null ) {
37723861 $ result ['cursor ' ] = $ cursor ;
37733862 $ result ['cursorDirection ' ] = $ cursorDirection ;
0 commit comments