This is an automated email from the ASF dual-hosted git repository.
yashmayya pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 2103aa27006 Preserve millisecond precision for TIMESTAMP comparisons
in the multi-stage engine (#18883)
2103aa27006 is described below
commit 2103aa27006bcfe63490623063de952cb424c46f
Author: Yash Mayya <[email protected]>
AuthorDate: Mon Jun 29 16:14:23 2026 -0700
Preserve millisecond precision for TIMESTAMP comparisons in the multi-stage
engine (#18883)
---
.../tests/MultiStageEngineIntegrationTest.java | 10 +++----
.../tests/OfflineClusterIntegrationTest.java | 6 ++--
.../integration/tests/custom/TimestampTest.java | 26 ++++++++++++++++++
.../org/apache/pinot/query/type/TypeSystem.java | 15 +++++++++-
.../query/validate/PinotTypeCoercionTest.java | 32 ++++++++++++++++++++--
.../resources/queries/LiteralEvaluationPlans.json | 6 ++--
6 files changed, 81 insertions(+), 14 deletions(-)
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
index c7183143d22..479c537d25d 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/MultiStageEngineIntegrationTest.java
@@ -1003,9 +1003,9 @@ public class MultiStageEngineIntegrationTest extends
BaseClusterIntegrationTestS
JsonNode results = resultTable.get("rows").get(0);
assertEquals(results.get(0).asInt(), 1);
long nowResult = results.get(1).asLong();
- // Timestamp granularity is seconds
- assertTrue(nowResult >= ((queryStartTimeMs / 1000) * 1000));
- assertTrue(nowResult <= ((queryEndTimeMs / 1000) * 1000));
+ // now() returns millisecond-precision epoch millis, consistent with the
single-stage engine (issue #18881)
+ assertTrue(nowResult >= queryStartTimeMs);
+ assertTrue(nowResult <= queryEndTimeMs);
long oneHourAgoResult = results.get(2).asLong();
assertTrue(oneHourAgoResult >= queryStartTimeMs -
TimeUnit.HOURS.toMillis(1));
assertTrue(oneHourAgoResult <= queryEndTimeMs -
TimeUnit.HOURS.toMillis(1));
@@ -1017,8 +1017,8 @@ public class MultiStageEngineIntegrationTest extends
BaseClusterIntegrationTestS
String dateTimeResult = results.get(4).asText();
assertTrue(dateTimeResult.equals(queryStartTimeDay) ||
dateTimeResult.equals(queryEndTimeDay));
nowResult = results.get(5).asLong();
- assertTrue(nowResult >= ((queryStartTimeMs / 1000) * 1000));
- assertTrue(nowResult <= ((queryEndTimeMs / 1000) * 1000));
+ assertTrue(nowResult >= queryStartTimeMs);
+ assertTrue(nowResult <= queryEndTimeMs);
oneHourAgoResult = results.get(6).asLong();
assertTrue(oneHourAgoResult >= queryStartTimeMs -
TimeUnit.HOURS.toMillis(1));
assertTrue(oneHourAgoResult <= queryEndTimeMs -
TimeUnit.HOURS.toMillis(1));
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/OfflineClusterIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/OfflineClusterIntegrationTest.java
index fc92bdd18a5..f68d9ef01c9 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/OfflineClusterIntegrationTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/OfflineClusterIntegrationTest.java
@@ -1440,9 +1440,9 @@ public class OfflineClusterIntegrationTest extends
BaseClusterIntegrationTestSet
JsonNode results = resultTable.get("rows").get(0);
assertEquals(results.get(0).asInt(), 1);
long nowResult = results.get(1).asLong();
- // Timestamp granularity is seconds
- assertTrue(nowResult >= ((queryStartTimeMs / 1000) * 1000));
- assertTrue(nowResult <= ((queryEndTimeMs / 1000) * 1000));
+ // now() returns millisecond-precision epoch millis, consistent with the
single-stage engine (issue #18881)
+ assertTrue(nowResult >= queryStartTimeMs);
+ assertTrue(nowResult <= queryEndTimeMs);
long oneHourAgoResult = results.get(2).asLong();
assertTrue(oneHourAgoResult >= queryStartTimeMs -
TimeUnit.HOURS.toMillis(1));
assertTrue(oneHourAgoResult <= queryEndTimeMs -
TimeUnit.HOURS.toMillis(1));
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/TimestampTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/TimestampTest.java
index f218edf6423..9578e516c48 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/TimestampTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/TimestampTest.java
@@ -51,6 +51,7 @@ public class TimestampTest extends
CustomDataQueryClusterIntegrationTest {
private static final String TIMESTAMP_ONE_MONTH_AFTER = "tsOneMonthAfter";
// 1 month after TIMESTAMP_BASE
private static final String TIMESTAMP_ONE_QUARTER_AFTER =
"tsOneQuarterAfter"; // 1 quarter after TIMESTAMP_BASE
private static final String TIMESTAMP_ONE_YEAR_AFTER = "tsOneYearAfter"; //
1 year after TIMESTAMP_BASE
+ private static final String TIMESTAMP_SUB_SECOND = "tsSubSecond"; //
TIMESTAMP_BASE + 482 millis (sub-second)
private static final String LONG_BASE = "longBase";
private static final String LONG_HALF_DAY_AFTER = "longHalfDayAfter";
private static final String LONG_ONE_DAY_AFTER = "longOneDayAfter";
@@ -433,6 +434,27 @@ public class TimestampTest extends
CustomDataQueryClusterIntegrationTest {
assertEquals(jsonNode.get("resultTable").get("rows").get(0).get(1).longValue(),
1546300800000L);
}
+ @Test(dataProvider = "useBothQueryEngines")
+ public void testSubSecondTimestampEqualityQueries(boolean
useMultiStageQueryEngine)
+ throws Exception {
+ setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+ // Regression test for issue #18881: equality between a TIMESTAMP column
and a sub-second epoch-millis literal must
+ // match rows. 1546300800482 == 2019-01-01 00:00:00.482 UTC, the
tsSubSecond value of the first ingested row
+ // (tsBase + 482 ms). Before the fix, the multi-stage planner cast the
literal to a precision-0 TIMESTAMP and
+ // constant folding truncated it to whole seconds (1546300800000), so this
query returned 0 rows.
+ String matchQuery =
+ String.format("SELECT COUNT(*) FROM %s WHERE %s = 1546300800482",
getTableName(), TIMESTAMP_SUB_SECOND);
+ JsonNode jsonNode = postQuery(matchQuery);
+
assertEquals(jsonNode.get("resultTable").get("rows").get(0).get(0).asLong(),
1L);
+
+ // The whole-second (truncated) literal must NOT match the sub-second row
- guards against a regression to the old
+ // precision-0 truncation behavior.
+ String truncatedQuery =
+ String.format("SELECT COUNT(*) FROM %s WHERE %s = 1546300800000",
getTableName(), TIMESTAMP_SUB_SECOND);
+ jsonNode = postQuery(truncatedQuery);
+
assertEquals(jsonNode.get("resultTable").get("rows").get(0).get(0).asLong(),
0L);
+ }
+
@Override
public String getTableName() {
return DEFAULT_TABLE_NAME;
@@ -448,6 +470,7 @@ public class TimestampTest extends
CustomDataQueryClusterIntegrationTest {
.addSingleValueDimension(TIMESTAMP_ONE_MONTH_AFTER,
FieldSpec.DataType.TIMESTAMP)
.addSingleValueDimension(TIMESTAMP_ONE_QUARTER_AFTER,
FieldSpec.DataType.TIMESTAMP)
.addSingleValueDimension(TIMESTAMP_ONE_YEAR_AFTER,
FieldSpec.DataType.TIMESTAMP)
+ .addSingleValueDimension(TIMESTAMP_SUB_SECOND,
FieldSpec.DataType.TIMESTAMP)
.addSingleValueDimension(LONG_BASE, FieldSpec.DataType.LONG)
.addSingleValueDimension(LONG_HALF_DAY_AFTER, FieldSpec.DataType.LONG)
.addSingleValueDimension(LONG_ONE_DAY_AFTER, FieldSpec.DataType.LONG)
@@ -472,6 +495,7 @@ public class TimestampTest extends
CustomDataQueryClusterIntegrationTest {
new Field(TIMESTAMP_ONE_MONTH_AFTER, create(Type.LONG), null, null),
new Field(TIMESTAMP_ONE_QUARTER_AFTER, create(Type.LONG), null, null),
new Field(TIMESTAMP_ONE_YEAR_AFTER, create(Type.LONG), null, null),
+ new Field(TIMESTAMP_SUB_SECOND, create(Type.LONG), null, null),
new Field(LONG_BASE, create(Type.LONG), null, null),
new Field(LONG_HALF_DAY_AFTER, create(Type.LONG), null, null),
new Field(LONG_ONE_DAY_AFTER, create(Type.LONG), null, null),
@@ -510,6 +534,8 @@ public class TimestampTest extends
CustomDataQueryClusterIntegrationTest {
record.put(TIMESTAMP_ONE_MONTH_AFTER, tsOneMonthAfter);
record.put(TIMESTAMP_ONE_QUARTER_AFTER, tsOneQuarterAfter);
record.put(TIMESTAMP_ONE_YEAR_AFTER, tsOneYearAfter);
+ // Sub-second value (tsBase + 482 ms) for the issue #18881
millisecond-precision regression test.
+ record.put(TIMESTAMP_SUB_SECOND, tsBaseLong + 482);
record.put(LONG_BASE, tsBaseLong);
record.put(LONG_HALF_DAY_AFTER, tsHalfDayAfter);
record.put(LONG_ONE_DAY_AFTER, tsOneDayAfter);
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/type/TypeSystem.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/type/TypeSystem.java
index 814f1b4bf5c..b078ddabfce 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/type/TypeSystem.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/type/TypeSystem.java
@@ -34,6 +34,10 @@ public class TypeSystem extends RelDataTypeSystemImpl {
private static final int MAX_DECIMAL_SCALE = 1000;
private static final int MAX_DECIMAL_PRECISION = 2000;
+ // Pinot stores TIMESTAMP as epoch-millis (LONG), i.e. millisecond precision
(3 fractional digits). This is also
+ // Calcite's MAX_DATETIME_PRECISION, so 3 is both the default and the max
for TIMESTAMP and no clamping occurs.
+ private static final int TIMESTAMP_PRECISION_MILLIS = 3;
+
/**
* Default precision for derived arithmetic decimal
types(plus/multiply/divide/mod). We won't allow the return
* precision to be larger than this value majorly due to the following
reasons:
@@ -84,7 +88,16 @@ public class TypeSystem extends RelDataTypeSystemImpl {
@Override
public int getDefaultPrecision(SqlTypeName typeName) {
- return typeName == SqlTypeName.DECIMAL ? MAX_DECIMAL_PRECISION :
super.getDefaultPrecision(typeName);
+ if (typeName == SqlTypeName.DECIMAL) {
+ return MAX_DECIMAL_PRECISION;
+ }
+ if (typeName == SqlTypeName.TIMESTAMP) {
+ // Calcite's default TIMESTAMP precision is 0 (whole seconds), which
truncates sub-second epoch-millis literals
+ // during constant folding (RexBuilder.clean -> TimestampString.round).
Pin it to millisecond precision so folded
+ // TIMESTAMP literals preserve millis and round-trip Pinot's LONG
storage. See issue #18881.
+ return TIMESTAMP_PRECISION_MILLIS;
+ }
+ return super.getDefaultPrecision(typeName);
}
@Override
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/validate/PinotTypeCoercionTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/validate/PinotTypeCoercionTest.java
index 89285a1a0bc..6a771e266f3 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/validate/PinotTypeCoercionTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/validate/PinotTypeCoercionTest.java
@@ -120,7 +120,11 @@ public class PinotTypeCoercionTest extends
QueryEnvironmentTestBase {
String plan = explain("SELECT ts_timestamp FROM a WHERE ts_timestamp >
NOW() - 1000");
assertFalse(plan.contains("CAST(" + TS_TIMESTAMP_ORD + ")"),
"TIMESTAMP column should not be wrapped in CAST when right-hand side
is constant. Got:\n" + plan);
- assertTrue(plan.matches("(?s).*>\\(\\" + TS_TIMESTAMP_ORD + ",
\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\).*"),
+ // The fractional part is optional here: NOW()/ago() produce
non-deterministic millis that may land on a whole
+ // second (rendered without a fractional part). The deterministic
millis-preservation guard is in
+ // testTimestampColumnVsSubSecondLiteralPreservesMillis below.
+ assertTrue(
+ plan.matches("(?s).*>\\(\\" + TS_TIMESTAMP_ORD + ",
\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\).*"),
"Right-hand side should be constant-folded to a TIMESTAMP literal.
Got:\n" + plan);
}
@@ -174,7 +178,11 @@ public class PinotTypeCoercionTest extends
QueryEnvironmentTestBase {
String plan = explain("SELECT ts_timestamp FROM a WHERE ts_timestamp >
ago('PT5M')");
assertFalse(plan.contains("CAST(" + TS_TIMESTAMP_ORD + ")"),
"TIMESTAMP column should not be wrapped in CAST when compared to
ago(...). Got:\n" + plan);
- assertTrue(plan.matches("(?s).*>\\(\\" + TS_TIMESTAMP_ORD + ",
\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\).*"),
+ // The fractional part is optional here: NOW()/ago() produce
non-deterministic millis that may land on a whole
+ // second (rendered without a fractional part). The deterministic
millis-preservation guard is in
+ // testTimestampColumnVsSubSecondLiteralPreservesMillis below.
+ assertTrue(
+ plan.matches("(?s).*>\\(\\" + TS_TIMESTAMP_ORD + ",
\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}(\\.\\d+)?\\).*"),
"Right-hand side should be constant-folded to a TIMESTAMP literal.
Got:\n" + plan);
}
@@ -190,4 +198,24 @@ public class PinotTypeCoercionTest extends
QueryEnvironmentTestBase {
assertTrue(plan.contains("CAST(" + TS_TIMESTAMP_ORD + "):BIGINT"),
"TIMESTAMP column must be cast to BIGINT for binary arithmetic.
Got:\n" + plan);
}
+
+ /**
+ * Regression test for issue #18881: a TIMESTAMP column compared to a
sub-second epoch-millis literal must preserve
+ * the millisecond component. With Calcite's default TIMESTAMP precision of
0, the implicit literal-to-TIMESTAMP cast
+ * added by {@link PinotTypeCoercion#binaryComparisonCoercion} folded
through {@code RexBuilder.clean ->
+ * TimestampString.round(0)}, truncating the literal to whole seconds — so
{@code ts = 1761667561482} silently became
+ * {@code ts = 1761667561000} and matched no rows. Pinot's {@code
TypeSystem} now pins TIMESTAMP to precision 3, so
+ * the folded literal keeps its millis. The column must also stay unwrapped
(the property #18396 added).
+ */
+ @Test
+ public void testTimestampColumnVsSubSecondLiteralPreservesMillis() {
+ // 1761667561482 ms has a non-zero sub-second component (.482). The
rendered wall-clock date/seconds depend on the
+ // JVM default time zone, but the fractional-second part is time-zone
invariant, so we assert only on ".482".
+ String plan = explain("SELECT ts_timestamp FROM a WHERE ts_timestamp =
1761667561482");
+ assertFalse(plan.contains("CAST(" + TS_TIMESTAMP_ORD + ")"),
+ "TIMESTAMP column should not be wrapped in CAST. Got:\n" + plan);
+ assertTrue(
+ plan.matches("(?s).*=\\(\\" + TS_TIMESTAMP_ORD + ",
\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.482\\).*"),
+ "Sub-second literal must fold preserving millis (.482), not truncate
to whole seconds. Got:\n" + plan);
+ }
}
diff --git
a/pinot-query-planner/src/test/resources/queries/LiteralEvaluationPlans.json
b/pinot-query-planner/src/test/resources/queries/LiteralEvaluationPlans.json
index e63b4aab8a6..5decbcd0408 100644
--- a/pinot-query-planner/src/test/resources/queries/LiteralEvaluationPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/LiteralEvaluationPlans.json
@@ -25,7 +25,7 @@
"sql": "EXPLAIN PLAN FOR SELECT FROMDATETIME( '1997-02-01 00:00:00',
'yyyy-MM-dd HH:mm:ss') FROM d",
"output": [
"Execution Plan",
- "\nLogicalProject(EXPR$0=[1997-02-01 00:00:00])",
+ "\nLogicalProject(EXPR$0=[1997-02-01 00:00:00:TIMESTAMP(3)])",
"\n PinotLogicalTableScan(table=[[default, d]])",
"\n"
]
@@ -45,7 +45,7 @@
"sql": "EXPLAIN PLAN FOR SELECT timestampDiff(DAY, CAST(ts as
TIMESTAMP), CAST(dateTrunc('MONTH', FROMDATETIME('1997-02-01 00:00:00',
'yyyy-MM-dd HH:mm:ss')) as TIMESTAMP)) FROM d",
"output": [
"Execution Plan",
- "\nLogicalProject(EXPR$0=[TIMESTAMPDIFF(FLAG(DAY),
CAST($7):TIMESTAMP(0) NOT NULL, 1997-02-01 00:00:00)])",
+ "\nLogicalProject(EXPR$0=[TIMESTAMPDIFF(FLAG(DAY),
CAST($7):TIMESTAMP(3) NOT NULL, 1997-02-01 00:00:00:TIMESTAMP(3))])",
"\n PinotLogicalTableScan(table=[[default, d]])",
"\n"
]
@@ -55,7 +55,7 @@
"sql": "EXPLAIN PLAN FOR SELECT * FROM d WHERE CAST(ts AS TIMESTAMP) =
FROMDATETIME('2019-01-01 00:00:00', 'yyyy-MM-dd HH:mm:ss')",
"output": [
"Execution Plan",
- "\nLogicalFilter(condition=[=(CAST($7):TIMESTAMP(0) NOT NULL,
2019-01-01 00:00:00)])",
+ "\nLogicalFilter(condition=[=(CAST($7):TIMESTAMP(3) NOT NULL,
2019-01-01 00:00:00)])",
"\n PinotLogicalTableScan(table=[[default, d]])",
"\n"
]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]