Skip to content

feat(usage): signed deltas, cross-tenant reads and max aggregation (CLO-4542) - #22

Merged
lohanidamodar merged 9 commits into
mainfrom
clo-4542
Aug 5, 2026
Merged

feat(usage): signed deltas, cross-tenant reads and max aggregation (CLO-4542)#22
lohanidamodar merged 9 commits into
mainfrom
clo-4542

Conversation

@lohanidamodar

@lohanidamodar lohanidamodar commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

CLO-4542 — support for peak concurrent realtime connections

realtime.connections is a delta metric: +1 on connect, -1 on disconnect, emitted by every realtime pod. Concurrency is therefore the cumulative sum of those deltas — summing them over a window gives the net change (roughly zero for a balanced window), and MAX(value) gives 1.

Cloud pre-computes that level in StatsResources and stores it as a gauge (appwrite-labs/cloud#4662). This PR is the library support that job needs.

History note: this branch originally implemented a request-time aggregate('peak') running-sum max. That approach was dropped in favour of pre-computing the level — see Superseded below. Reviewing the branch as a whole is easier than reading it commit by commit.

Changes

Signed deltas, strict by defaultAccumulator::collect() and ClickHouse::validateMetricData() still reject negative values for every metric, so a buggy negative count or bandwidth figure is still caught. A caller emitting a genuine signed delta opts in per row with allowNegative: true. The flag is validation-only and never stored as a column; the library stays generic and holds no metric names.

Cross-tenant readsfindAcrossTenants(array $queries, ?string $type) applies no tenant filter, so an operator-side aggregation job can roll every tenant up in one pass instead of issuing N per-tenant queries. Shared tables only. tenant is now accepted as a groupBy dimension so the returned rows stay attributable.

This is deliberately a separate method rather than a nullable $tenant on find(). find() keeps its non-nullable signature, and only the internals (findScoped, findFromTable, parseQueries) take ?string, so the unscoped path can't be reached without naming it. A nullable public parameter would mean a null arriving from an uninitialised project id silently returns every tenant's rows; as a named method it can't happen by accident and every cross-tenant read is greppable. Note parseQueries still rejects '' — an empty tenant remains a fail-fast error, not a wildcard.

aggregate('max') — a query hint that overrides the per-type default value expression. Gauges default to argMax(value, time), the latest reading in the bucket. That's right for a snapshot such as storage, but wrong for a sampled level series, where the bucket's highest reading is the answer. max composes, so the max of 5-minute samples is the hourly, daily or billing-period peak.

Superseded

findPeakFromTable, aggregate('peak') and the per-second fold are gone.

The fold existed to keep intra-flush bursts visible to a request-time running-sum max. With the level pre-computed into a gauge and sampled every five minutes, per-second rows cost roughly 20× the storage to sharpen a resolution the bucketing discards — so nothing passed it, and an unused parameter on the hottest write path of a shared library is not worth carrying. One commit back in history if a caller ever wants it.

On the query side, nothing reads a running-sum max at request time once the level is pre-computed, and the query was expensive on the path that would have used it — it needed every delta since the project began.

Removing it also removed a defect. The pre-window baseline was a single non-correlated scalar:

ifNull((SELECT sum(value) FROM t WHERE {nonTimeFilters} AND time < {start}), 0)

added to a running sum partitioned by metric[, tenant][, dims]. Those only agree when the window covers exactly one series. Any dimension break-down — aggregate('peak') with groupBy('country') — had every series inflated by the combined baseline of all of them. The peak tests only covered groupByInterval, never a dimension, so nothing caught it.

Tests

ClickHouseGaugeMaxTest replaces ClickHousePeakTest: last vs max on the same data, per-bucket maxima, the flat single-row shape billing uses, tenant-scoped max under shared tables, findAcrossTenants returning a row per tenant, and the non-shared-tables rejection. AccumulatorTest covers negatives rejected by default through both collect() and addBatch, persisted and netted when opted in, and gauges still rejecting. UsageQueryTest covers the aggregate hint and helpers.

Not yet run locally — no composer on this machine, and the ClickHouse suite needs a live instance. Relying on CI.

Merge order

Cloud depends on a tagged release of this. Draft until then.

Realtime connections are stored as +1/-1 event deltas aggregated with
SUM, so a plain MAX(value) is meaningless. Add an `aggregate` query hint
(sum | peak) mirroring the groupBy pattern. `peak` computes the peak
concurrent value as max(running_sum(value)) ordered by time, cross-pod
correct with no producer change.

- UsageQuery: TYPE_AGGREGATE, VALID_AGGREGATES, aggregate() plus
  isAggregate/extractAggregate/removeAggregate helpers.
- ClickHouse: parseQueries splits time vs non-time filters and records
  the window-start param for peak; findFromTable routes peak to a new
  findPeakFromTable that builds a windowed running-sum with a pre-window
  baseline subquery (connections still open at start). Honours interval
  bucketing, dimensions, limit/offset/orderBy. The sum/default path is
  unchanged.
- Tests: flat + interval peak, pre-window baseline, interleaved-producer
  sum-before-max, and UsageQuery unit coverage.
Make the peak path correct end-to-end for delta metrics like realtime
connections, where the write side previously dropped -1 disconnects and
folded sub-flush bursts away.

- Reject negative values by default for every metric (events included) so
  a buggy negative count/bandwidth is still caught. Callers emitting a
  genuine signed delta opt in per row via allowNegative:
  Accumulator::collect(..., bool $allowNegative = false) carries the flag
  onto the buffered entry and hands it to addBatch;
  ClickHouse::validateMetricData(..., bool $allowNegative = false) gates
  the guard, read from each row's `allowNegative` in validateMetricsBatch.
  The flag is validation-only — never written as a column. The library
  stays generic; the caller decides which metrics may be negative.
- Add optional foldSeconds to Accumulator::collect(). When set, the second
  bucket (floor(ts / foldSeconds) * foldSeconds) joins the fold key and
  becomes the entry time, so events fold only within the same bucket and
  intra-flush peaks survive. When null, behaviour is unchanged.
- Tests: negatives rejected by default (collect + addBatch), persisted and
  netted when opted in, gauges still reject; per-second fold groups/splits
  by second; peak over per-second net rows captures a burst a per-flush
  net hides.
findAcrossTenants() applies no tenant filter so an aggregation job can roll
every tenant up in one pass instead of issuing N per-tenant queries. Shared
tables only; groupBy('tenant') keeps the rows attributable.

parseQueries() now takes a nullable tenant - null is the explicit cross-tenant
read, '' still fails fast so an empty scope can never be read silently.
The peak concurrency figure is now pre-computed into a gauge level series by
an operator-side job, so nothing reads a running-sum max at request time. Drop
findPeakFromTable and its baseline plumbing, and keep the aggregate hint for
what does need it: rolling a gauge series up to a coarser interval.

Gauges default to argMax(value, time) - the latest reading in the bucket, right
for a snapshot but wrong for a sampled level series, where the bucket's highest
sample is the answer. aggregate('max') selects that.

Removing the peak path also removes its baseline defect: the pre-window
baseline was a single non-correlated scalar added to a running sum partitioned
by metric[, tenant][, dims], so any dimension break-down had every series
inflated by the combined baseline of all of them.
@lohanidamodar lohanidamodar changed the title feat(usage): peak concurrent connections aggregation (CLO-4542) feat(usage): signed deltas, cross-tenant reads and max aggregation (CLO-4542) Aug 4, 2026
findFromTable() only checked for groupByInterval or groupBy, so an
aggregate('max') with neither fell through and returned raw rows instead of one
aggregated row. That is exactly the flat 'highest value over this window' shape
billing reads, which would have taken an arbitrary row's value for the peak.
The removed peak branch used to catch this case before the check.

Also declare aggregate on the parsed-array shape so PHPStan sees the offset,
fix a test that asserted a rejection while passing a valid function, and assert
the tenant is present before using it as an array key.
@lohanidamodar
lohanidamodar marked this pull request as ready for review August 5, 2026 03:36
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds explicit support for signed delta rows, operator-only cross-tenant ClickHouse reads, and maximum aggregation for sampled gauge levels.

  • Carries a validation-only allowNegative flag through accumulator folding and both persistence adapters.
  • Adds shared-table cross-tenant reads with tenant grouping support.
  • Adds aggregate('max') for flat and interval-based peak gauge queries while removing the meaningless sum hint.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported folded-row, Database signed-delta, and gauge-sum issues are resolved in the current code.

Important Files Changed

Filename Overview
src/Usage/Accumulator.php Signed-delta authorization is retained across folded event rows and propagated to batch writes.
src/Usage/Adapter/Database.php Database batch validation now honors opted-in signed values without persisting the validation flag.
src/Usage/Adapter/ClickHouse.php Adds cross-tenant shared-table querying and max aggregation while consistently validating supported aggregate hints.
src/Usage/UsageQuery.php Defines max as the sole explicit aggregate override and provides query construction and inspection helpers.
tests/Usage/AccumulatorTest.php Covers strict negative-value defaults, signed event opt-in, net folding, and both folded-row call orders.
tests/Usage/Adapter/ClickHouseGaugeMaxTest.php Covers latest-versus-maximum gauge semantics, interval and flat maxima, tenant scoping, and cross-tenant grouping.
tests/Usage/Adapter/DatabaseTest.php Covers default rejection and successful persistence of opted-in signed event values.

Reviews (4): Last reviewed commit: "Honour the negative opt-in in the Databa..." | Re-trigger Greptile

Comment thread src/Usage/Accumulator.php
Comment thread src/Usage/Adapter/ClickHouse.php
foldSeconds was added for a request-time peak that no longer exists: with the
concurrency level pre-computed into a gauge and sampled every five minutes,
per-second rows cost roughly twenty times the storage to sharpen a resolution
the bucketing discards. Nothing passed it.

Leaving it in would have meant an unused parameter on the hottest write path of
a shared library, on a branch nothing exercises. It is one commit back in
history if a caller ever wants it, and then it arrives with the caller that
justifies it.

allowNegative stays - the concurrency level is the cumulative sum of the +1/-1s,
so without it there is nothing to fold.
Two findings from review.

The opt-in was stored only when a buffer entry was created, so folding a signed
delta into an entry opened by a plain positive left the net row looking
unauthorised. It would be rejected at write time, and because a failed batch
keeps its entries buffered, every later flush would retry the same rejection -
a stuck buffer rather than one lost row. The flag now ORs across the fold, with
tests in both call orders.

Unreachable from cloud today, since the flag is derived from the metric name
and the fold key includes it. But the fold key uses the name as written while
the flag is keyed on the source, so a mirrored metric that was also a delta
would mix them. Cheap to make the invariant hold on its own rather than resting
on a caller-side coincidence.

aggregate('sum') was accepted but left gauges on argMax, returning the latest
sample rather than the documented total. Rather than make it sum, it is gone:
it is already the default for events, and on gauges it would total
point-in-time snapshots, which this library elsewhere refuses to do. max is now
the only selectable aggregate - it exists to override the per-type default.
Comment thread src/Usage/Accumulator.php
Accumulator marks a signed-delta row allowNegative and the ClickHouse adapter
honours it, but Database::addBatch() rejected the value regardless. The opt-in
is part of the row contract, so an adapter that ignores it turns a valid metric
into a permanently stuck buffer: the batch throws, flush() keeps unwritten
entries, and every later flush retries the same rejection.

Not reachable from cloud, which runs ClickHouse, but the two adapters have to
agree about what a row means.
@lohanidamodar
lohanidamodar merged commit baeef33 into main Aug 5, 2026
4 checks passed
@lohanidamodar
lohanidamodar deleted the clo-4542 branch August 5, 2026 04:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant