This is an automated email from the ASF dual-hosted git repository.
CritasWang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb-extras.git
The following commit(s) were added to refs/heads/master by this push:
new e46e63b Keep MAX buckets whose values are all non-positive (#116)
e46e63b is described below
commit e46e63b4226d59b778b37191097176eeaace4376
Author: ZIHAN DAI <[email protected]>
AuthorDate: Mon Aug 3 12:00:19 2026 +1000
Keep MAX buckets whose values are all non-positive (#116)
The milliseconds aggregation path projected MAX over the numeric COALESCE
expression. IoTDB's grouped max accumulator seeds FLOAT/DOUBLE state with
Float/Double.MIN_VALUE -- the smallest positive value, not the most
negative one -- and only marks a group initialized when value >= state, so
a bucket whose numeric maximum is zero or negative comes back NULL. This
DAO reads a NULL aggregate as an empty bucket and skips it, so a MAX
downsampling query over, for example, a sub-zero sensor silently lost
whole buckets instead of failing.
Project -MIN(-x) instead: the grouped min accumulator seeds with MAX_VALUE
and is not affected, and IEEE-754 negation is exact, so the result is
identical over finite values on servers with and without the upstream fix.
The projection is shared with the calendar path, whose non-grouped
accumulators track an explicit initialized flag rather than a sentinel, so
the substitution is exact there too and an empty bucket still yields NULL.
The long and string channels are already correct and keep using MAX.
Verified against apache/iotdb 2.0.8 and 2.0.10 containers. See
apache/iotdb#18300, fixed on master but not yet in a released version.
Signed-off-by: Zihan Dai <[email protected]>
---
.../thingsboard/table/IoTDBTableTimeseriesDao.java | 18 +++-
.../table/IoTDBTableTimeseriesAggregationIT.java | 98 ++++++++++++++++++++++
.../table/IoTDBTableTimeseriesDaoTest.java | 10 ++-
3 files changed, 123 insertions(+), 3 deletions(-)
diff --git
a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDao.java
b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDao.java
index 216ffcd..37c244c 100644
---
a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDao.java
+++
b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDao.java
@@ -652,10 +652,24 @@ public class IoTDBTableTimeseriesDao extends
IoTDBTableBaseDao
+ AGG_STR_COLUMN
+ ", "
+ numericCountProjection();
+ // The numeric MAX is projected as -MIN(-x) rather than MAX(x).
IoTDB's GROUPED max
+ // accumulator seeds FLOAT/DOUBLE state with Float/Double.MIN_VALUE --
the smallest
+ // POSITIVE value, not the most negative one -- and only marks a group
initialized when
+ // `value >= state`, so a bucket whose numeric maximum is zero or
negative comes back
+ // NULL. This DAO reads a NULL aggregate as "empty bucket" and skips
it, so a MAX
+ // downsampling query over e.g. a sub-zero sensor would silently lose
whole buckets
+ // instead of failing. Every release up to and including 2.0.10 is
affected; fixed on
+ // master by apache/iotdb#18300, which is not in a released version
yet. The grouped MIN
+ // accumulator seeds with MAX_VALUE and is not affected, and IEEE-754
negation is exact,
+ // so -MIN(-x) is an exact substitute for MAX(x) over finite values,
on affected and
+ // fixed servers alike. This projection is shared with the calendar
path, whose
+ // non-grouped accumulators track an explicit initialized flag rather
than a sentinel, so
+ // the substitution is exact there too and an empty bucket still
yields NULL. MAX(long_v)
+ // and MAX(str_v) are already correct (true Long.MIN_VALUE seed /
flag-based) and stay.
case MAX ->
- "MAX("
+ "-1 * MIN(-1 * ("
+ NUMERIC_VALUE
- + ") AS "
+ + ")) AS "
+ AGG_NUM_COLUMN
+ ", MAX(long_v) AS "
+ MAX_LONG_COLUMN
diff --git
a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesAggregationIT.java
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesAggregationIT.java
index 3e65e50..302f4fa 100644
---
a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesAggregationIT.java
+++
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesAggregationIT.java
@@ -694,6 +694,104 @@ class IoTDBTableTimeseriesAggregationIT {
}
}
+ @Test
+ void maxKeepsBucketsWhoseValuesAreAllNonPositiveAgainstRealIoTDB() throws
Exception {
+ TestScope scope =
+ scope(
+ "agg_nonpositive",
+ "55555555-5555-5555-5555-555555555511",
+ "66666666-6666-6666-6666-666666666611");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(8);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool,
config);
+ IoTDBTableTimeseriesDao dao = new IoTDBTableTimeseriesDao(pool, writer,
config);
+ try {
+ // Regression for apache/iotdb#18300 against REAL IoTDB. The server's
GROUPED max
+ // accumulator seeds FLOAT/DOUBLE state with Double.MIN_VALUE -- the
smallest POSITIVE
+ // value -- so on every release up to and including 2.0.10 a bucket
whose maximum is zero
+ // or negative makes MAX(<expression>) return NULL. This DAO reads a
NULL aggregate as an
+ // empty bucket and skips it, so such buckets used to VANISH from the
series: a MAX
+ // downsampling query over a sub-zero sensor silently returned fewer
points, with no
+ // error anywhere. The projection computes -MIN(-x) instead, which the
unaffected grouped
+ // MIN accumulator evaluates exactly. Every bucket here has a
non-positive maximum, so on
+ // the old projection this test fails with "bucket count expected 2
but was 0".
+ // Bucket [1000,2000): doubles -5.0, -3.0 -> midpoint 1500, MAX =
-3.0, MIN = -5.0
+ // Bucket [2000,3000): doubles 0.0, 0.0 -> midpoint 2500, MAX =
0.0, MIN = 0.0
+ saveAll(
+ dao,
+ scope,
+ List.of(
+ entry(1000L, "np", DataType.DOUBLE, -5.0D),
+ entry(1500L, "np", DataType.DOUBLE, -3.0D),
+ entry(2100L, "np", DataType.DOUBLE, 0.0D),
+ entry(2400L, "np", DataType.DOUBLE, 0.0D)));
+
+ ReadTsKvQueryResult max = aggregate(dao, scope, "np", Aggregation.MAX);
+ assertNumericBuckets(
+ max,
+ new long[] {1500L, 2500L},
+ new DataType[] {DataType.DOUBLE, DataType.DOUBLE},
+ new double[] {-3.0D, 0.0D},
+ 2400L);
+
+ // MIN is not affected by the upstream bug; it must be byte-for-byte
unchanged.
+ ReadTsKvQueryResult min = aggregate(dao, scope, "np", Aggregation.MIN);
+ assertNumericBuckets(
+ min,
+ new long[] {1500L, 2500L},
+ new DataType[] {DataType.DOUBLE, DataType.DOUBLE},
+ new double[] {-5.0D, 0.0D},
+ 2400L);
+ } finally {
+ dao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
+ @Test
+ void
maxOverNonPositiveLongOnlyAndMixedBucketsKeepsResultTypeAgainstRealIoTDB()
throws Exception {
+ TestScope scope =
+ scope(
+ "agg_nonpositive_types",
+ "55555555-5555-5555-5555-555555555512",
+ "66666666-6666-6666-6666-666666666612");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(8);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool,
config);
+ IoTDBTableTimeseriesDao dao = new IoTDBTableTimeseriesDao(pool, writer,
config);
+ try {
+ // Same regression across the two typed channels, since they take
different code paths:
+ // Bucket [1000,2000): LONG-ONLY -5, -3 -> midpoint 1500, MAX = -3
kept LONG-typed via
+ // the direct MAX(long_v) channel (LongBigArray seeds with the
true Long.MIN_VALUE,
+ // so that channel was always correct).
+ // Bucket [2000,3000): MIXED long -5 + double -3.0 -> midpoint 2500,
MAX = -3.0 promoted
+ // to DOUBLE, which reads the numeric channel and therefore
exercises the fix.
+ saveAll(
+ dao,
+ scope,
+ List.of(
+ entry(1000L, "nt", DataType.LONG, -5L),
+ entry(1500L, "nt", DataType.LONG, -3L),
+ entry(2100L, "nt", DataType.LONG, -5L),
+ entry(2400L, "nt", DataType.DOUBLE, -3.0D)));
+
+ ReadTsKvQueryResult max = aggregate(dao, scope, "nt", Aggregation.MAX);
+ assertNumericBuckets(
+ max,
+ new long[] {1500L, 2500L},
+ new DataType[] {DataType.LONG, DataType.DOUBLE},
+ new double[] {-3.0D, -3.0D},
+ 2400L);
+ } finally {
+ dao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
private ReadTsKvQueryResult calendarAggregate(
IoTDBTableTimeseriesDao dao,
TestScope scope,
diff --git
a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDaoTest.java
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDaoTest.java
index 9672f50..66fa857 100644
---
a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDaoTest.java
+++
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDaoTest.java
@@ -1737,8 +1737,16 @@ class IoTDBTableTimeseriesDaoTest {
"CAST(SUM(CASE WHEN double_v IS NOT NULL THEN 1 ELSE 0
END) AS INT64) "
+ "AS count_double"),
statements.get(2));
+ // The numeric MAX is projected as -MIN(-x): IoTDB's grouped max
accumulator seeds
+ // FLOAT/DOUBLE state with Double.MIN_VALUE (the smallest POSITIVE value),
so MAX over a
+ // bucket whose maximum is zero or negative returns NULL and the bucket
would be dropped
+ // (apache/iotdb#18300). The grouped MIN accumulator is unaffected and
IEEE-754 negation is
+ // exact, so this is an exact substitute over finite values. The long and
string channels are
+ // already correct and keep using MAX.
assertTrue(
- statements.get(3).contains("MAX(COALESCE(double_v, CAST(long_v AS
DOUBLE))) AS agg_num")
+ statements
+ .get(3)
+ .contains("-1 * MIN(-1 * (COALESCE(double_v, CAST(long_v AS
DOUBLE)))) AS agg_num")
&& statements.get(3).contains("MAX(long_v) AS max_long")
&& statements.get(3).contains("MAX(str_v) AS agg_str")
&& statements