Skip to content

Return one NULL row for last_by over a never-written device (#16985)#18267

Open
PDGGK wants to merge 1 commit into
apache:masterfrom
PDGGK:fix/last-by-never-had-data
Open

Return one NULL row for last_by over a never-written device (#16985)#18267
PDGGK wants to merge 1 commit into
apache:masterfrom
PDGGK:fix/last-by-never-had-data

Conversation

@PDGGK

@PDGGK PDGGK commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Description

Per @Wei-hao-Li's clarification in #16985: an aggregate function should always return NULL when there is no input data (except count), consistent with SQL-standard engines such as Trino. So select last_by(s0, time) from t where deviceId = 'never' — where the device was never written — must return exactly one row whose value is NULL, not zero rows.

Root cause: for the last-cache-optimized last / last_by path, a device set that resolves to zero devices leaves LastQueryAggTableScanOperator with allDeviceCount == 0, so the device loop never runs and the operator returns no rows.

How

Following review feedback, rather than special-casing the last-cache operator, the empty-device case is routed to the default aggregation operator, which already emits the single all-NULL row required for a global aggregation over empty input. One change in DataNodeTableOperatorGenerator.visitAggregationTableScan:

OptimizeType optimizeType =
    node.getDeviceEntries().isEmpty() && node.getGroupingKeys().isEmpty()
        ? OptimizeType.NOOP
        : canUseLastCacheOptimize(
            parameter.getTableAggregators(), node, parameter.getTimeColumnName());

When the device set resolves to zero devices and there are no grouping keys, the last-cache optimization is skipped (NOOP) and planning falls to DefaultAggTableScanOperator. That operator builds an EMPTY_DEVICE_ID entry for an empty device set and its hasNext() is time-range-based, so the global aggregation still runs its time range once and each aggregator's no-input evaluate() writes NULL. The change in LastQueryAggTableScanOperator is reverted (net zero there).

Guards:

  • GROUP BY over an empty device set is intentionally left on the last-cache path, which already (correctly) returns no rows — the one-NULL-row rule is for no-GROUP-BY global aggregation only.
  • The deleted-device case is untouched: the device still exists, so deviceEntries is non-empty and planning is identical to before.

Tests

IoTDBDeletionTableIT: a never-written device queried with 2-arg and 3-arg last_by returns one NULL row; multiple last_by aggregates return one all-NULL row; GROUP BY over a never-written device returns no rows; and a regression check that a deleted device still returns one NULL row (both the last-value-cache and recomputed paths).

Scope

Scoped to the last-cache last / last_by path, which is where the zero-row behavior originated. Other aggregations (max / min / sum / avg) do not use the last-cache optimization, so their planning is unchanged by this guard.

Relates to #16985.

Comment on lines +78 to +83
// A no-GROUP-BY global aggregation whose device set resolves to zero devices
// (e.g. last_by on a device that was never written) has nothing for the device
// loop to iterate, so without this it would emit zero rows. Per SQL semantics a
// global aggregate over empty input must return exactly one row whose value is
// NULL (except count()); this one-shot flag drives that single row.
private boolean emptyGlobalResultEmitted = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is better to use default Agg operator when the DeviceEntries is empty when generate Operator Tree. The empty process logic has been there.

Just modify one line in DataNodeTableOperatorGenerator is OK:
DataNodeTableOperatorGenerator -> visitAggregationTableScan ->

OptimizeType optimizeType = node.getDeviceEntries().isEmpty() ? OptimizeType.NOOP : canUseLastCacheOptimize( parameter.getTableAggregators(), node, parameter.getTimeColumnName());

…6985)

A global aggregation without GROUP BY always produces exactly one row; over
empty input every aggregate except count() evaluates to NULL. On the
last-cache-optimized last/last_by path, a device set that resolves to zero
devices (e.g. a filter on a device that was never written) left the operator
with nothing to iterate, so it returned zero rows instead of the single NULL
row required by SQL semantics (consistent with engines such as Trino).

Route the empty-device case to the default aggregation operator, which already
emits the single all-NULL row for a global aggregation over empty input: in
DataNodeTableOperatorGenerator.visitAggregationTableScan, use OptimizeType.NOOP
when the device set is empty so the last-cache optimization (which has no
device to read) is skipped. The deleted-device case (the device still exists,
so the operator iterates and already emits the NULL row) is unchanged; GROUP BY
over an empty group still returns no rows.

Signed-off-by: Zihan Dai <99155080+PDGGK@users.noreply.github.com>
@PDGGK
PDGGK force-pushed the fix/last-by-never-had-data branch from 8c7ddfc to d2ad541 Compare July 22, 2026 09:48
@PDGGK

PDGGK commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Wei-hao-Li — that's cleaner, adopted. I switched to routing the empty device set to the default aggregation operator in DataNodeTableOperatorGenerator.visitAggregationTableScan, and reverted the flag in LastQueryAggTableScanOperator entirely (net zero there now). As you said, the empty-process logic is already there: AbstractAggTableScanOperator builds an EMPTY_DEVICE_ID entry for an empty device set, and the default operator's hasNext() is time-range-based, so the global aggregation over empty input still runs its time range and emits the single all-NULL row.

One edge I wanted to flag: a plain GROUP BY tag last_by also qualifies for the last-cache path (canUseLastCacheOptimize only NOOPs the date_bin case, not a plain grouping key), so an unconditional deviceEntries.isEmpty() ? NOOP would reroute GROUP BY-over-an-empty-device-set to the default operator too — and that case was already returning 0 rows correctly via the last-cache op. To leave that path untouched I scoped the NOOP to both conditions:

OptimizeType optimizeType =
    node.getDeviceEntries().isEmpty() && node.getGroupingKeys().isEmpty()
        ? OptimizeType.NOOP
        : canUseLastCacheOptimize(
            parameter.getTableAggregators(), node, parameter.getTimeColumnName());

So the global (no-GROUP-BY) empty case falls to the default op and gets the single NULL row, while GROUP BY over an empty device set stays on the last-cache path (0 rows, unchanged). Happy to use the unconditional form if you'd prefer it. The IoTDBDeletionTableIT cases (never-written device → one NULL row, GROUP BY over empty → no rows, deleted-device unchanged) are kept and exercise both paths. Updated the PR — thanks for the pointer.

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.

2 participants