Skip to content

Commit baeef33

Browse files
Merge pull request #22 from utopia-php/clo-4542
feat(usage): signed deltas, cross-tenant reads and max aggregation (CLO-4542)
2 parents df935bb + 51d11b6 commit baeef33

10 files changed

Lines changed: 724 additions & 22 deletions

File tree

‎src/Usage/Accumulator.php‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class Accumulator
1616
private Usage $usage;
1717

1818
/**
19-
* @var array<string, array{tenant: string, metric: string, value: int, type: string, tags: array<string, mixed>, time?: \DateTime}>
19+
* @var array<string, array{tenant: string, metric: string, value: int, type: string, tags: array<string, mixed>, allowNegative: bool, time?: \DateTime}>
2020
*/
2121
private array $buffer = [];
2222

@@ -38,9 +38,16 @@ public function __construct(Usage $usage)
3838
* Events fold additively; earliest non-null time wins on merge.
3939
* Gauges use last-write-wins.
4040
*
41+
* Negative values are rejected by default for every metric, so a buggy
42+
* negative count/bandwidth is still caught. A caller that emits a genuine
43+
* signed delta (e.g. realtime connections `+1`/`-1`) opts in per call with
44+
* `$allowNegative = true`. The library stays generic — the decision lives
45+
* with the caller, not with any metric-name knowledge here.
46+
*
4147
* @param array<string,mixed> $tags
48+
* @param bool $allowNegative Permit a negative value for this metric (default: reject).
4249
*/
43-
public function collect(string $tenant, string $metric, int $value, string $type, array $tags = [], ?\DateTime $time = null): self
50+
public function collect(string $tenant, string $metric, int $value, string $type, array $tags = [], ?\DateTime $time = null, bool $allowNegative = false): self
4451
{
4552
// Compare against '' rather than empty(): the string "0" is a valid
4653
// tenant/metric id but empty("0") is true in PHP.
@@ -50,7 +57,7 @@ public function collect(string $tenant, string $metric, int $value, string $type
5057
if ($metric === '') {
5158
throw new \InvalidArgumentException('Metric name cannot be empty');
5259
}
53-
if ($value < 0) {
60+
if ($value < 0 && !$allowNegative) {
5461
throw new \InvalidArgumentException('Value cannot be negative');
5562
}
5663
if ($type !== Usage::TYPE_EVENT && $type !== Usage::TYPE_GAUGE) {
@@ -68,6 +75,15 @@ public function collect(string $tenant, string $metric, int $value, string $type
6875
if ($type === Usage::TYPE_EVENT && isset($this->buffer[$key])) {
6976
// earliest time wins on merge
7077
$this->buffer[$key]['value'] += $value;
78+
79+
// The opt-in is a property of the folded row, not of whichever
80+
// call happened to create it. Folding a signed delta into an entry
81+
// opened by a plain positive must not leave the net row looking
82+
// unauthorised: it would be rejected at write time, and since a
83+
// failed batch keeps its entries buffered, every later flush would
84+
// retry the same rejection.
85+
$this->buffer[$key]['allowNegative'] = $this->buffer[$key]['allowNegative'] || $allowNegative;
86+
7187
if ($time !== null && (!isset($this->buffer[$key]['time']) || $time < $this->buffer[$key]['time'])) {
7288
$this->buffer[$key]['time'] = $time;
7389
}
@@ -78,6 +94,7 @@ public function collect(string $tenant, string $metric, int $value, string $type
7894
'value' => $value,
7995
'type' => $type,
8096
'tags' => $tags,
97+
'allowNegative' => $allowNegative,
8198
];
8299
if ($time !== null) {
83100
$entry['time'] = $time;

‎src/Usage/Adapter.php‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,26 @@ abstract public function purge(string $tenant, array $queries = [], ?string $typ
102102
*/
103103
abstract public function find(string $tenant, array $queries = [], ?string $type = null): array;
104104

105+
/**
106+
* Find metrics across every tenant in shared-tables mode.
107+
*
108+
* Deliberately crosses the per-tenant isolation every other read enforces,
109+
* so it is reserved for operator-side aggregation jobs that roll many
110+
* tenants up in one pass. Never reachable from a tenant-scoped request
111+
* path. Pair with `groupBy('tenant')` to keep the rows attributable.
112+
*
113+
* Adapters that cannot express an unscoped read leave this unsupported.
114+
*
115+
* @param array<\Utopia\Query\Query> $queries
116+
* @param string|null $type Metric type: 'event', 'gauge', or null (query both)
117+
* @return array<Metric>
118+
* @throws \Exception
119+
*/
120+
public function findAcrossTenants(array $queries = [], ?string $type = null): array
121+
{
122+
throw new \Exception($this->getName() . ' does not support cross-tenant reads');
123+
}
124+
105125
/**
106126
* Count metrics using Query objects.
107127
*

‎src/Usage/Adapter/ClickHouse.php‎

Lines changed: 104 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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;

‎src/Usage/Adapter/Database.php‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ protected function getColumnDefinition(string $id, string $type = 'event'): stri
142142
* Each metric carries its own `tenant` (shared-tables mode), so a single
143143
* batch may span multiple tenants.
144144
*
145-
* @param array<array{tenant: string, metric: string, value: int, tags?: array<string,mixed>}> $metrics
145+
* @param array<array{tenant: string, metric: string, value: int, tags?: array<string,mixed>, allowNegative?: bool}> $metrics
146146
* @param string $type Metric type: 'event' or 'gauge'
147147
* @param int $batchSize
148148
* @return bool
@@ -157,7 +157,13 @@ public function addBatch(array $metrics, string $type, int $batchSize = 1000): b
157157
throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: event, gauge");
158158
}
159159

160-
if ($metric['value'] < 0) {
160+
// Negatives are rejected by default so a buggy count or
161+
// bandwidth figure is still caught. A row carrying a genuine
162+
// signed delta opts in, the same contract the accumulator sets
163+
// and the ClickHouse adapter honours - an adapter that ignored
164+
// it would reject the row on every flush, and a failed batch
165+
// keeps its entries buffered.
166+
if ($metric['value'] < 0 && !($metric['allowNegative'] ?? false)) {
161167
throw new \InvalidArgumentException('Value cannot be negative');
162168
}
163169

‎src/Usage/Usage.php‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,24 @@ public function find(string $tenant, array $queries = [], ?string $type = null):
154154
return $this->adapter->find($tenant, $queries, $type);
155155
}
156156

157+
/**
158+
* Find metrics across every tenant in shared-tables mode.
159+
*
160+
* Unlike find(), this applies no tenant filter — it is for operator-side
161+
* aggregation jobs that need one pass over all tenants instead of N
162+
* per-tenant queries. Keep it out of request-scoped code paths, and add
163+
* `groupBy('tenant')` so the returned rows stay attributable.
164+
*
165+
* @param array<\Utopia\Query\Query> $queries
166+
* @param string|null $type Metric type: 'event', 'gauge', or null (query both)
167+
* @return array<Metric>
168+
* @throws \Exception
169+
*/
170+
public function findAcrossTenants(array $queries = [], ?string $type = null): array
171+
{
172+
return $this->adapter->findAcrossTenants($queries, $type);
173+
}
174+
157175
/**
158176
* Count metrics using Query objects.
159177
*

0 commit comments

Comments
 (0)