When current: include is used on an aggregation that has no dimensions, and no unrolled source rows match the query's filters, graph-node emits a current bucket whose id, timestamp and @aggregate fields are all null. Those fields are non-null in the generated schema, so the query fails with Null value resolved for non-null field, and because a non-null violation propagates to the parent, the entire data becomes null — one empty bucket discards an otherwise valid response.
Introduced by #6293.
Reproduction
Any dimensionless aggregation. A far-future timestamp_gte deterministically guarantees zero matching rows:
{ myCandles(interval: "hour", current: include, where: { timestamp_gte: "9999999999000000" }) { id } }
{ "data": null, "errors": [{ "message": "Null value resolved for non-null field `id`" }] }
Selecting sum or timestamp instead fails on those fields. But the same query asking only for count succeeds, which shows the row really is emitted:
{ myCandles(interval: "hour", current: include, where: { timestamp_gte: "9999999999000000" }) { count } }
{ "data": { "myCandles": [{ "count": "0" }] } }
Root cause
select_current_bucket in store/postgres/src/relational/rollup.rs emits group by only when the aggregation has dimensions:
if !self.dimensions.is_empty() {
write!(w, " group by ")?;
write_dims(self.dimensions, w, false)?;
}
Without dimensions the generated query is a bare aggregate — see the repo's own fixture COUNT_ONLY_CURRENT_SQL, which ends at /*FILTERS*/) c with no GROUP BY. A bare aggregate always returns exactly one row, and over an empty input max()/sum() return NULL while count(*) returns 0. That is precisely the observed split. Aggregations with dimensions (STATS_HOUR_CURRENT_SQL) end in group by "token", and a GROUP BY over empty input yields zero rows, so they are unaffected.
Query filters are spliced in at the /*FILTERS*/ marker, which sits inside the inner subquery over the source timeseries table, so they run before aggregation and cannot suppress the synthesized row. I confirmed id_gte, id_gt and timestamp_gt all still fail.
This also contradicts docs/aggregations.md, which states the current bucket "covers the time period from the end of the last completed bucket up to the most recent data point" — with no data point there is no such period, and no bucket to return.
Expected
The current bucket should be omitted when no unrolled rows match, leaving only the completed buckets (or []).
Suggested fix
Emit having count(*) > 0 when self.dimensions.is_empty(), matching the zero-row behaviour GROUP BY already gives the dimensioned case.
Impact
This does not need a contrived filter. Any dimensionless aggregation queried with current: include over a wide range fails whenever the source timeseries has had no writes since the last rollup — e.g. an oracle that posts a few times an hour breaks every consumer for the first minutes of each hour. Clients see the whole response nulled, so a chart renders nothing rather than degrading to complete buckets.
When
current: includeis used on an aggregation that has no dimensions, and no unrolled source rows match the query's filters, graph-node emits a current bucket whoseid,timestampand@aggregatefields are allnull. Those fields are non-null in the generated schema, so the query fails withNull value resolved for non-null field, and because a non-null violation propagates to the parent, the entiredatabecomesnull— one empty bucket discards an otherwise valid response.Introduced by #6293.
Reproduction
Any dimensionless aggregation. A far-future
timestamp_gtedeterministically guarantees zero matching rows:{ myCandles(interval: "hour", current: include, where: { timestamp_gte: "9999999999000000" }) { id } }{ "data": null, "errors": [{ "message": "Null value resolved for non-null field `id`" }] }Selecting
sumortimestampinstead fails on those fields. But the same query asking only forcountsucceeds, which shows the row really is emitted:{ myCandles(interval: "hour", current: include, where: { timestamp_gte: "9999999999000000" }) { count } }{ "data": { "myCandles": [{ "count": "0" }] } }Root cause
select_current_bucketinstore/postgres/src/relational/rollup.rsemitsgroup byonly when the aggregation has dimensions:Without dimensions the generated query is a bare aggregate — see the repo's own fixture
COUNT_ONLY_CURRENT_SQL, which ends at/*FILTERS*/) cwith noGROUP BY. A bare aggregate always returns exactly one row, and over an empty inputmax()/sum()returnNULLwhilecount(*)returns0. That is precisely the observed split. Aggregations with dimensions (STATS_HOUR_CURRENT_SQL) end ingroup by "token", and aGROUP BYover empty input yields zero rows, so they are unaffected.Query filters are spliced in at the
/*FILTERS*/marker, which sits inside the inner subquery over the source timeseries table, so they run before aggregation and cannot suppress the synthesized row. I confirmedid_gte,id_gtandtimestamp_gtall still fail.This also contradicts
docs/aggregations.md, which states the current bucket "covers the time period from the end of the last completed bucket up to the most recent data point" — with no data point there is no such period, and no bucket to return.Expected
The current bucket should be omitted when no unrolled rows match, leaving only the completed buckets (or
[]).Suggested fix
Emit
having count(*) > 0whenself.dimensions.is_empty(), matching the zero-row behaviourGROUP BYalready gives the dimensioned case.Impact
This does not need a contrived filter. Any dimensionless aggregation queried with
current: includeover a wide range fails whenever the source timeseries has had no writes since the last rollup — e.g. an oracle that posts a few times an hour breaks every consumer for the first minutes of each hour. Clients see the whole response nulled, so a chart renders nothing rather than degrading to complete buckets.