Skip to content

Commit d6ee1ac

Browse files
lohanidamodarclaude
andcommitted
fix: instanceof bug, partial flush clearing, strict type params, events-only sum
- extractGroupByInterval: match by method string, not instanceof (parsed queries are base Query) - flush(): selectively clear buffer on per-batch success (retry preserved on failure) - collect(): use TYPE_EVENT constant instead of string literal - addBatch(): require explicit \$type param (no default) - sum(): events-only by default (summing gauges is meaningless) - sumDaily*: document as events-only (daily MV has only events) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4c98ab5 commit d6ee1ac

6 files changed

Lines changed: 108 additions & 37 deletions

File tree

src/Usage/Adapter.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,18 +106,23 @@ abstract public function count(array $queries = [], ?string $type = null): int;
106106
/**
107107
* Sum metric values using Query objects.
108108
*
109+
* Events-only by default because summing gauges is semantically meaningless
110+
* (adding point-in-time snapshots doesn't produce a useful total).
111+
*
109112
* @param array<\Utopia\Query\Query> $queries
110113
* @param string $attribute Attribute to sum (default: 'value')
111-
* @param string|null $type Metric type: 'event', 'gauge', or null (sum both)
114+
* @param string $type Metric type: 'event' or 'gauge'
112115
* @return int
113116
*/
114-
abstract public function sum(array $queries = [], string $attribute = 'value', ?string $type = null): int;
117+
abstract public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int;
115118

116119
/**
117-
* Sum event metrics from the pre-aggregated daily table.
120+
* Find event metrics from the pre-aggregated daily table.
118121
*
119122
* Queries the SummingMergeTree daily materialized view for fast billing/analytics.
120-
* Only works for event metrics (gauges are not pre-aggregated).
123+
*
124+
* Note: Daily MV only stores event metrics. This method always queries
125+
* the daily events table — gauges are never pre-aggregated.
121126
*
122127
* @param array<\Utopia\Query\Query> $queries Filters (metric, time range, resource, etc.)
123128
* @return array<Metric>
@@ -127,6 +132,9 @@ abstract public function findDaily(array $queries = []): array;
127132
/**
128133
* Sum event metric values from the pre-aggregated daily table.
129134
*
135+
* Note: Daily MV only stores event metrics. This method always queries
136+
* the daily events table — gauges are never pre-aggregated.
137+
*
130138
* @param array<\Utopia\Query\Query> $queries
131139
* @param string $attribute Attribute to sum (default: 'value')
132140
* @return int
@@ -136,6 +144,9 @@ abstract public function sumDaily(array $queries = [], string $attribute = 'valu
136144
/**
137145
* Sum multiple event metrics from the pre-aggregated daily table in one query.
138146
*
147+
* Note: Daily MV only stores event metrics. This method always queries
148+
* the daily events table — gauges are never pre-aggregated.
149+
*
139150
* @param array<string> $metrics List of metric names
140151
* @param array<\Utopia\Query\Query> $queries Additional filters (e.g. date range)
141152
* @return array<string, int> Metric name => sum value

src/Usage/Adapter/ClickHouse.php

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,7 +1334,7 @@ private function validateMetricsBatch(array $metrics, string $type): void
13341334
* @param int $batchSize Maximum number of metrics per INSERT statement
13351335
* @throws Exception
13361336
*/
1337-
public function addBatch(array $metrics, string $type = Usage::TYPE_EVENT, int $batchSize = self::INSERT_BATCH_SIZE): bool
1337+
public function addBatch(array $metrics, string $type, int $batchSize = self::INSERT_BATCH_SIZE): bool
13381338
{
13391339
if (empty($metrics)) {
13401340
return true;
@@ -1683,23 +1683,19 @@ private function countFromTable(array $queries, string $type): int
16831683
/**
16841684
* Sum metric values using Query objects.
16851685
*
1686+
* Events-only by default — summing gauges is semantically meaningless.
1687+
*
16861688
* @param array<Query> $queries
16871689
* @param string $attribute Attribute to sum (default: 'value')
1688-
* @param string|null $type 'event', 'gauge', or null (both)
1690+
* @param string $type 'event' or 'gauge'
16891691
* @return int
16901692
* @throws Exception
16911693
*/
1692-
public function sum(array $queries = [], string $attribute = 'value', ?string $type = null): int
1694+
public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int
16931695
{
16941696
$this->setOperationContext('sum()');
16951697

1696-
if ($type !== null) {
1697-
return $this->sumFromTable($queries, $attribute, $type);
1698-
}
1699-
1700-
// Sum from both tables
1701-
return $this->sumFromTable($queries, $attribute, Usage::TYPE_EVENT)
1702-
+ $this->sumFromTable($queries, $attribute, Usage::TYPE_GAUGE);
1698+
return $this->sumFromTable($queries, $attribute, $type);
17031699
}
17041700

17051701
/**

src/Usage/Adapter/Database.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ protected function getColumnDefinition(string $id, string $type = 'event'): stri
108108
* @return bool
109109
* @throws \Exception
110110
*/
111-
public function addBatch(array $metrics, string $type = Usage::TYPE_EVENT, int $batchSize = 1000): bool
111+
public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool
112112
{
113113
$this->db->getAuthorization()->skip(function () use ($metrics, $type, $batchSize) {
114114
$documents = [];
@@ -256,12 +256,14 @@ public function getTotalBatch(array $metrics, array $queries = [], ?string $type
256256
/**
257257
* Sum metric values.
258258
*
259+
* Events-only by default — summing gauges is semantically meaningless.
260+
*
259261
* @param array<Query> $queries
260262
* @param string $attribute
261-
* @param string|null $type
263+
* @param string $type 'event' or 'gauge'
262264
* @return int
263265
*/
264-
public function sum(array $queries = [], string $attribute = 'value', ?string $type = null): int
266+
public function sum(array $queries = [], string $attribute = 'value', string $type = Usage::TYPE_EVENT): int
265267
{
266268
/** @var array<Metric> $results */
267269
$results = $this->find($queries, $type);

src/Usage/Usage.php

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,16 @@ public function setup(): void
8484
/**
8585
* Add metrics in batch (raw append).
8686
*
87+
* Callers must explicitly pass the metric type so event and gauge
88+
* writes are never confused at the call site.
89+
*
8790
* @param array<array{metric: string, value: int, tags?: array<string,mixed>}> $metrics
8891
* @param string $type Metric type: 'event' or 'gauge'
8992
* @param int $batchSize Maximum number of metrics per INSERT statement
9093
* @return bool
9194
* @throws \Exception
9295
*/
93-
public function addBatch(array $metrics, string $type = self::TYPE_EVENT, int $batchSize = 1000): bool
96+
public function addBatch(array $metrics, string $type, int $batchSize = 1000): bool
9497
{
9598
return $this->adapter->addBatch($metrics, $type, $batchSize);
9699
}
@@ -183,14 +186,23 @@ public function count(array $queries = [], ?string $type = null): int
183186
/**
184187
* Sum metric values using Query objects.
185188
*
189+
* Defaults to events because summing gauges (point-in-time snapshots)
190+
* is semantically meaningless — it averages/accumulates snapshots rather
191+
* than producing a useful total. Callers that truly want a gauge sum
192+
* must opt in explicitly.
193+
*
186194
* @param array<\Utopia\Query\Query> $queries
187195
* @param string $attribute Attribute to sum (default: 'value')
188-
* @param string|null $type Metric type: 'event', 'gauge', or null (sum both)
196+
* @param string $type Metric type: 'event' or 'gauge'
189197
* @return int
190198
* @throws \Exception
191199
*/
192-
public function sum(array $queries = [], string $attribute = 'value', ?string $type = null): int
200+
public function sum(array $queries = [], string $attribute = 'value', string $type = self::TYPE_EVENT): int
193201
{
202+
if ($type !== self::TYPE_EVENT && $type !== self::TYPE_GAUGE) {
203+
throw new \InvalidArgumentException("Invalid type '{$type}'. Allowed: " . self::TYPE_EVENT . ', ' . self::TYPE_GAUGE);
204+
}
205+
194206
return $this->adapter->sum($queries, $attribute, $type);
195207
}
196208

@@ -199,6 +211,9 @@ public function sum(array $queries = [], string $attribute = 'value', ?string $t
199211
*
200212
* Queries the SummingMergeTree daily MV for fast billing/analytics.
201213
*
214+
* Note: Daily MV only stores event metrics. This method always queries
215+
* the daily events table — gauges are never pre-aggregated.
216+
*
202217
* @param array<\Utopia\Query\Query> $queries
203218
* @return array<Metric>
204219
* @throws \Exception
@@ -214,6 +229,9 @@ public function findDaily(array $queries = []): array
214229
* Use this for billing queries — reads pre-aggregated daily rows
215230
* instead of scanning billions of raw events.
216231
*
232+
* Note: Daily MV only stores event metrics. This method always queries
233+
* the daily events table — gauges are never pre-aggregated.
234+
*
217235
* @param array<\Utopia\Query\Query> $queries
218236
* @param string $attribute Attribute to sum (default: 'value')
219237
* @return int
@@ -227,6 +245,9 @@ public function sumDaily(array $queries = [], string $attribute = 'value'): int
227245
/**
228246
* Sum multiple event metrics from the pre-aggregated daily table in one query.
229247
*
248+
* Note: Daily MV only stores event metrics. This method always queries
249+
* the daily events table — gauges are never pre-aggregated.
250+
*
230251
* @param array<string> $metrics List of metric names
231252
* @param array<\Utopia\Query\Query> $queries Additional filters (e.g. date range)
232253
* @return array<string, int> Metric name => sum value
@@ -302,7 +323,7 @@ public function collect(string $metric, int $value, string $type, array $tags =
302323
$tagsHash = !empty($tags) ? md5(json_encode($tags, JSON_THROW_ON_ERROR)) : '';
303324
$key = $metric . ':' . $type . ':' . $tagsHash;
304325

305-
if ($type === 'event') {
326+
if ($type === self::TYPE_EVENT) {
306327
// Additive: sum values for the same metric + tags combination
307328
if (isset($this->buffer[$key])) {
308329
$this->buffer[$key]['value'] += $value;
@@ -333,9 +354,15 @@ public function collect(string $metric, int $value, string $type, array $tags =
333354
* Flush the in-memory buffer to storage.
334355
*
335356
* Separates buffered metrics into events and gauges, then writes each batch
336-
* to the appropriate table via addBatch().
357+
* to the appropriate table via addBatch(). Only entries whose batch write
358+
* succeeds are removed from the buffer, so a partial failure preserves
359+
* the unwritten metrics for retry on the next flush.
337360
*
338-
* @return bool True if flush succeeded (or buffer was empty)
361+
* If addBatch() throws mid-flush, any earlier successful batches have
362+
* already been cleared from the buffer — the exception is allowed to
363+
* propagate so the caller can observe the failure.
364+
*
365+
* @return bool True if all batches succeeded (or buffer was empty)
339366
* @throws \Exception
340367
*/
341368
public function flush(): bool
@@ -345,35 +372,51 @@ public function flush(): bool
345372
return true;
346373
}
347374

348-
// Separate events and gauges
375+
// Separate events and gauges; keep track of buffer keys so we can
376+
// selectively unset only the entries whose write succeeded.
377+
$eventKeys = [];
378+
$gaugeKeys = [];
349379
$events = [];
350380
$gauges = [];
351381

352-
foreach ($this->buffer as $entry) {
382+
foreach ($this->buffer as $key => $entry) {
353383
if ($entry['type'] === self::TYPE_EVENT) {
354384
$events[] = $entry;
385+
$eventKeys[] = $key;
355386
} else {
356387
$gauges[] = $entry;
388+
$gaugeKeys[] = $key;
357389
}
358390
}
359391

360-
$result = true;
392+
$overallResult = true;
361393

362-
// Flush events to events table
394+
// Flush events — clear buffer entries only on success.
363395
if (!empty($events)) {
364-
$result = $this->adapter->addBatch($events, self::TYPE_EVENT);
396+
if ($this->adapter->addBatch($events, self::TYPE_EVENT)) {
397+
foreach ($eventKeys as $key) {
398+
unset($this->buffer[$key]);
399+
}
400+
} else {
401+
$overallResult = false;
402+
}
365403
}
366404

367-
// Flush gauges to gauges table
405+
// Flush gauges — clear buffer entries only on success.
368406
if (!empty($gauges)) {
369-
$result = $this->adapter->addBatch($gauges, self::TYPE_GAUGE) && $result;
407+
if ($this->adapter->addBatch($gauges, self::TYPE_GAUGE)) {
408+
foreach ($gaugeKeys as $key) {
409+
unset($this->buffer[$key]);
410+
}
411+
} else {
412+
$overallResult = false;
413+
}
370414
}
371415

372-
$this->buffer = [];
373-
$this->bufferCount = 0;
416+
$this->bufferCount = count($this->buffer);
374417
$this->lastFlushTime = microtime(true);
375418

376-
return $result;
419+
return $overallResult;
377420
}
378421

379422
/**

src/Usage/UsageQuery.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,16 @@ public static function isGroupByInterval(Query $query): bool
9191
/**
9292
* Extract the groupByInterval query from an array of queries, if present.
9393
*
94+
* Queries parsed via `Query::parse()` are base `Query` objects rather than
95+
* `UsageQuery` instances, so we match on the method string alone.
96+
*
9497
* @param array<Query> $queries
95-
* @return self|null The groupByInterval query, or null if not present
98+
* @return Query|null The groupByInterval query, or null if not present
9699
*/
97-
public static function extractGroupByInterval(array $queries): ?self
100+
public static function extractGroupByInterval(array $queries): ?Query
98101
{
99102
foreach ($queries as $query) {
100-
if ($query instanceof self && $query->getMethod() === self::TYPE_GROUP_BY_INTERVAL) {
103+
if ($query->getMethod() === self::TYPE_GROUP_BY_INTERVAL) {
101104
return $query;
102105
}
103106
}

tests/Usage/UsageQueryTest.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,23 @@ public function testExtractGroupByInterval(): void
6262
$extracted = UsageQuery::extractGroupByInterval($queries);
6363

6464
$this->assertNotNull($extracted);
65-
$this->assertInstanceOf(UsageQuery::class, $extracted);
65+
$this->assertInstanceOf(Query::class, $extracted);
66+
$this->assertEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $extracted->getMethod());
67+
$this->assertEquals('1h', $extracted->getValue());
68+
}
69+
70+
public function testExtractGroupByIntervalFromParsedQuery(): void
71+
{
72+
// Queries created via Query::parse() are base Query objects, not UsageQuery.
73+
$parsedGroupBy = new Query(UsageQuery::TYPE_GROUP_BY_INTERVAL, 'time', ['1h']);
74+
$equalQuery = Query::equal('metric', ['bandwidth']);
75+
76+
$queries = [$equalQuery, $parsedGroupBy];
77+
78+
$extracted = UsageQuery::extractGroupByInterval($queries);
79+
80+
$this->assertNotNull($extracted);
81+
$this->assertEquals(UsageQuery::TYPE_GROUP_BY_INTERVAL, $extracted->getMethod());
6682
$this->assertEquals('1h', $extracted->getValue());
6783
}
6884

0 commit comments

Comments
 (0)