Skip to content

Commit 39dc9f1

Browse files
authored
Merge pull request #15 from utopia-php/feat/coroutine-safe-multitenancy-2
refactor(usage): extract buffering into a standalone Accumulator
2 parents c534bfc + 3715eaf commit 39dc9f1

20 files changed

Lines changed: 1473 additions & 983 deletions

README.md

Lines changed: 81 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ composer require utopia-php/usage
3636
use Utopia\Usage\Usage;
3737
use Utopia\Usage\Adapter\ClickHouse;
3838

39-
// Configuration is fixed at construction; only the tenant changes per request.
39+
// Configuration is fixed at construction. The adapter is fully stateless —
40+
// the tenant is passed explicitly on every call (see below).
4041
$adapter = new ClickHouse(
4142
host: 'clickhouse-server',
4243
username: 'default',
@@ -46,7 +47,6 @@ $adapter = new ClickHouse(
4647
namespace: 'my_app',
4748
sharedTables: true,
4849
);
49-
$adapter->setTenant('project_123');
5050

5151
$usage = new Usage($adapter);
5252
$usage->setup(); // Creates events, gauges, and daily MV tables
@@ -65,6 +65,35 @@ $usage = new Usage($adapter);
6565
$usage->setup();
6666
```
6767

68+
## Multi-tenancy
69+
70+
`Usage` is stateless: every query/mutation takes the tenant as its first
71+
argument, and `addBatch` carries a `tenant` on each metric row (so one batch can
72+
span tenants). This makes a single `Usage` instance safe to share across
73+
tenants and coroutines.
74+
75+
```php
76+
$usage->getTotal('project_123', 'bandwidth'); // read for one tenant
77+
$usage->addBatch([
78+
['tenant' => 'project_123', 'metric' => 'bandwidth', 'value' => 5000, 'tags' => []],
79+
['tenant' => 'project_456', 'metric' => 'bandwidth', 'value' => 2000, 'tags' => []],
80+
], Usage::TYPE_EVENT);
81+
```
82+
83+
Callers that only ever touch one tenant can bind it once with the `Tenant`
84+
decorator, which forwards to `Usage` with the tenant pre-filled (and stamps it
85+
onto every `addBatch` row):
86+
87+
```php
88+
use Utopia\Usage\Tenant;
89+
90+
$tenant = new Tenant($usage, 'project_123');
91+
$tenant->getTotal('bandwidth'); // no tenant argument
92+
$tenant->addBatch([
93+
['metric' => 'bandwidth', 'value' => 5000, 'tags' => []], // tenant stamped automatically
94+
], Usage::TYPE_EVENT);
95+
```
96+
6897
## Metric Types
6998

7099
### Events (Additive)
@@ -79,8 +108,13 @@ Event-specific columns (see `Metric::EVENT_COLUMNS`): `path`, `method`, `status`
79108
`deviceModel`.
80109

81110
```php
82-
// Collect events — values accumulate in-memory buffer (summed per metric)
83-
$usage->collect('bandwidth', 5000, Usage::TYPE_EVENT, [
111+
use Utopia\Usage\Accumulator;
112+
113+
// Buffer metrics in memory and flush them in batch
114+
$accumulator = new Accumulator($usage);
115+
116+
// Collect events — tenant first; values accumulate in-memory (summed per tenant+metric)
117+
$accumulator->collect('project_123', 'bandwidth', 5000, Usage::TYPE_EVENT, [
84118
'path' => '/v1/storage/files',
85119
'method' => 'POST',
86120
'status' => '201',
@@ -114,9 +148,9 @@ Gauge-specific columns (see `Metric::GAUGE_COLUMNS`): `teamId`,
114148
`teamInternalId`, `resourceId`, `resourceInternalId`.
115149

116150
```php
117-
// Collect gauges — last value wins per metric in buffer
118-
$usage->collect('users', 1500, Usage::TYPE_GAUGE);
119-
$usage->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [
151+
// Collect gauges — last value wins per tenant+metric in buffer
152+
$accumulator->collect('project_123', 'users', 1500, Usage::TYPE_GAUGE);
153+
$accumulator->collect('project_123', 'storage.size', 1048576, Usage::TYPE_GAUGE, [
120154
'teamId' => 'team_x',
121155
'teamInternalId' => '7',
122156
'resourceId' => 'abc123',
@@ -126,28 +160,28 @@ $usage->collect('storage.size', 1048576, Usage::TYPE_GAUGE, [
126160

127161
### Flushing
128162

163+
The accumulator exposes raw signals — `count()` (buffered entries) and
164+
`elapsedSeconds()` (seconds since last flush) — and leaves the flush policy to
165+
the caller.
166+
129167
```php
130-
// Check if flush is recommended (threshold or interval reached)
131-
if ($usage->shouldFlush()) {
132-
$usage->flush(); // Writes events to events table, gauges to gauges table
168+
// Flush when the buffer grows large or enough time has passed
169+
if ($accumulator->count() >= 5000 || $accumulator->elapsedSeconds() >= 10) {
170+
$accumulator->flush(); // Writes events to events table, gauges to gauges table
133171
}
134-
135-
// Configure thresholds
136-
$usage->setFlushThreshold(5000); // Flush after 5000 collect() calls (default: 10,000)
137-
$usage->setFlushInterval(10); // Flush after 10 seconds (default: 20)
138172
```
139173

140174
### Batch Writes
141175

142176
```php
143-
// Write directly without buffering
177+
// Write directly without buffering — each row carries its tenant
144178
$usage->addBatch([
145-
['metric' => 'requests', 'value' => 100, 'tags' => ['path' => '/v1/users', 'method' => 'GET']],
146-
['metric' => 'bandwidth', 'value' => 50000, 'tags' => ['country' => 'DE', 'region' => 'fra']],
179+
['tenant' => 'project_123', 'metric' => 'requests', 'value' => 100, 'tags' => ['path' => '/v1/users', 'method' => 'GET']],
180+
['tenant' => 'project_123', 'metric' => 'bandwidth', 'value' => 50000, 'tags' => ['country' => 'DE', 'region' => 'fra']],
147181
], Usage::TYPE_EVENT);
148182

149183
$usage->addBatch([
150-
['metric' => 'users', 'value' => 42, 'tags' => ['teamId' => 'team_x']],
184+
['tenant' => 'project_123', 'metric' => 'users', 'value' => 42, 'tags' => ['teamId' => 'team_x']],
151185
], Usage::TYPE_GAUGE);
152186
```
153187

@@ -158,8 +192,8 @@ $usage->addBatch([
158192
```php
159193
use Utopia\Query\Query;
160194

161-
// Find events filtered by metric and time range
162-
$metrics = $usage->find([
195+
// Find events filtered by metric and time range (tenant first)
196+
$metrics = $usage->find('project_123', [
163197
Query::equal('metric', ['bandwidth']),
164198
Query::equal('country', ['US']),
165199
Query::greaterThanEqual('time', '2026-01-01'),
@@ -168,12 +202,12 @@ $metrics = $usage->find([
168202
], Usage::TYPE_EVENT);
169203

170204
// Find gauges
171-
$gauges = $usage->find([
205+
$gauges = $usage->find('project_123', [
172206
Query::equal('metric', ['users', 'storage.size']),
173207
], Usage::TYPE_GAUGE);
174208

175209
// Query both tables (type = null)
176-
$all = $usage->find([
210+
$all = $usage->find('project_123', [
177211
Query::equal('metric', ['bandwidth']),
178212
]);
179213
```
@@ -182,12 +216,13 @@ $all = $usage->find([
182216

183217
```php
184218
// Get total for a single metric (SUM for events, latest for gauges)
185-
$total = $usage->getTotal('bandwidth', [
219+
$total = $usage->getTotal('project_123', 'bandwidth', [
186220
Query::greaterThanEqual('time', '2026-03-01'),
187221
], Usage::TYPE_EVENT);
188222

189223
// Batch totals — single query with GROUP BY
190224
$totals = $usage->getTotalBatch(
225+
'project_123',
191226
['bandwidth', 'executions', 'requests'],
192227
[Query::greaterThanEqual('time', '2026-03-01')],
193228
Usage::TYPE_EVENT
@@ -200,6 +235,7 @@ $totals = $usage->getTotalBatch(
200235
// Get time series with query-time aggregation
201236
// Events: SUM per bucket, Gauges: argMax per bucket
202237
$series = $usage->getTimeSeries(
238+
tenant: 'project_123',
203239
metrics: ['bandwidth', 'requests'],
204240
interval: '1d', // '1h' or '1d'
205241
startDate: '2026-03-01',
@@ -217,20 +253,21 @@ The daily materialized view pre-aggregates events by `metric + tenant + day` for
217253

218254
```php
219255
// Sum a single metric from the daily table
220-
$total = $usage->sumDaily([
256+
$total = $usage->sumDaily('project_123', [
221257
Query::equal('metric', ['bandwidth']),
222258
Query::between('time', '2026-03-01', '2026-04-01'),
223259
]);
224260

225261
// Sum multiple metrics in one query
226262
$totals = $usage->sumDailyBatch(
263+
'project_123',
227264
['bandwidth', 'executions', 'storage.size'],
228265
[Query::between('time', '2026-03-01', '2026-04-01')]
229266
);
230267
// Returns: ['bandwidth' => 5000000, 'executions' => 12345, 'storage.size' => 0]
231268

232269
// Find daily aggregated rows
233-
$rows = $usage->findDaily([
270+
$rows = $usage->findDaily('project_123', [
234271
Query::equal('metric', ['bandwidth']),
235272
Query::orderDesc('time'),
236273
Query::limit(30),
@@ -241,12 +278,12 @@ $rows = $usage->findDaily([
241278

242279
```php
243280
// Purge all event metrics older than 90 days
244-
$usage->purge([
281+
$usage->purge('project_123', [
245282
Query::lessThan('time', '2026-01-01'),
246283
], Usage::TYPE_EVENT);
247284

248285
// Purge all gauge metrics
249-
$usage->purge([], Usage::TYPE_GAUGE);
286+
$usage->purge('project_123', [], Usage::TYPE_GAUGE);
250287
```
251288

252289
## Architecture
@@ -321,22 +358,23 @@ $usage->purge([], Usage::TYPE_GAUGE);
321358
Extend `Utopia\Usage\Adapter` and implement:
322359

323360
- `getName()`, `setup()`, `healthCheck()`
324-
- `addBatch(array $metrics, string $type, int $batchSize): bool`
325-
- `find(array $queries, ?string $type): array`
326-
- `count(array $queries, ?string $type): int`
327-
- `sum(array $queries, string $attribute, ?string $type): int`
328-
- `getTimeSeries(array $metrics, string $interval, string $startDate, string $endDate, array $queries, bool $zeroFill, ?string $type): array`
329-
- `getTotal(string $metric, array $queries, ?string $type): int`
330-
- `getTotalBatch(array $metrics, array $queries, ?string $type): array`
331-
- `findDaily(array $queries): array`
332-
- `sumDaily(array $queries, string $attribute): int`
333-
- `sumDailyBatch(array $metrics, array $queries): array`
334-
- `purge(array $queries, ?string $type): bool`
335-
- `setTenant(?string $tenant): self`
336-
337-
Namespace, database, shared-tables mode, async inserts, query logging and the
338-
dual-read sample rate are set once via the adapter constructor (see "Using
339-
ClickHouse Adapter" above). Only the tenant changes over an instance's lifetime.
361+
- `addBatch(array $metrics, string $type, int $batchSize): bool` (each metric carries its own `tenant`)
362+
- `find(string $tenant, array $queries, ?string $type): array`
363+
- `count(string $tenant, array $queries, ?string $type, ?int $max): int`
364+
- `sum(string $tenant, array $queries, string $attribute, string $type): int`
365+
- `getTimeSeries(string $tenant, array $metrics, string $interval, string $startDate, string $endDate, array $queries, bool $zeroFill, ?string $type): array`
366+
- `getTotal(string $tenant, string $metric, array $queries, ?string $type): int`
367+
- `getTotalBatch(string $tenant, array $metrics, array $queries, ?string $type): array`
368+
- `findDaily(string $tenant, array $queries): array`
369+
- `sumDaily(string $tenant, array $queries, string $attribute): int`
370+
- `sumDailyBatch(string $tenant, array $metrics, array $queries): array`
371+
- `purge(string $tenant, array $queries, ?string $type): bool`
372+
373+
All configuration — namespace, database, shared-tables mode, async inserts,
374+
query logging — is set once via the adapter constructor (see "Using ClickHouse
375+
Adapter" above). Adapters hold no per-request state; the tenant is passed
376+
explicitly on every call, so one instance is safe to share across tenants and
377+
coroutines.
340378

341379
## System Requirements
342380

0 commit comments

Comments
 (0)