Skip to content

Commit f01ed11

Browse files
committed
Detect BIGINT overflow in SUM and fix wrong AVG on the Calcite engine
SUM and AVG over a BIGINT (long) column near 2^63 silently produced wrong results on the Calcite engine: - SUM(long): the enumerable accumulator is a plain long, so a running sum past 2^63 wrapped to a negative value (e.g. SUM(UserID) returned -5594372458244005145 instead of erroring). - AVG(long): AVG reduces to SUM(field)/COUNT(field); the intermediate long SUM wrapped the same way, so AVG returned a wrong (often negative) result. Fix, applied in the SQL-plugin lowering so both the DSL-pushdown and the in-memory (no-pushdown) paths behave identically: - SUM(long): the aggregate argument (a bare BIGINT column) is summed in DECIMAL so it cannot wrap, and the result is narrowed back to BIGINT with CHECKED_LONG_NARROW. Narrowing errors (HTTP 4xx) when the sum clearly overflowed 2^63, and otherwise saturates. The output type stays bigint. - AVG(long): the bare BIGINT argument is averaged in DOUBLE, which holds the true average without an intermediate long wrap. The DOUBLE output type is unchanged. Only bare BIGINT column references are widened; expressions such as sum(field + 1) are left untouched so PPLAggregateConvertRule can still rewrite them to pushdown-friendly form. Narrower integer sums (byte/short/int) already widen to a BIGINT accumulator and cannot overflow, so they are unchanged. Boundary note: OpenSearch computes pushed-down sums in double, and (double) Long.MAX_VALUE rounds to exactly 2^63, indistinguishable from a small overflow. To avoid erroring on a legitimate near-Long.MAX_VALUE sum, only magnitudes strictly beyond 2^63 are treated as overflow; a genuine overflow lands far outside that boundary, and the in-memory path detects it exactly. AVG pushdown note: casting the AVG argument to DOUBLE keeps native avg pushdown in most cases, but an AVG with a preceding sort can fall back to an in-memory sum/count reduction instead of a pushed-down native avg. The result stays correct; only that narrow case loses aggregate pushdown. Adds CalcitePPLAggregationIT coverage (overflow -> 4xx, avg correct, in-range and Long.MAX sums), a REST yaml test (issues/5164_agg.yml), and regenerates the affected explain fixtures. Signed-off-by: Kai Huang <ahkcs@amazon.com>
1 parent 1102cb2 commit f01ed11

63 files changed

Lines changed: 708 additions & 280 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
import org.opensearch.sql.datasource.DataSourceService;
189189
import org.opensearch.sql.exception.CalciteUnsupportedException;
190190
import org.opensearch.sql.exception.SemanticCheckException;
191+
import org.opensearch.sql.executor.OpenSearchTypeSystem;
191192
import org.opensearch.sql.expression.HighlightExpression;
192193
import org.opensearch.sql.expression.function.BuiltinFunctionName;
193194
import org.opensearch.sql.expression.function.PPLBuiltinOperators;
@@ -1763,6 +1764,12 @@ private void visitAggregation(
17631764
// Add aggregation results first
17641765
List<RexNode> aggRexList =
17651766
outputFields.subList(numOfOutputFields - numOfAggList, numOfOutputFields);
1767+
// SUM(bigint) accumulates in DECIMAL (OpenSearchTypeSystem#deriveSumType) so a running sum near
1768+
// 2^63 cannot silently wrap to a negative long. Cast the user-visible result back to BIGINT:
1769+
// the cast errors (ArithmeticException -> 4xx) on genuine overflow while keeping the output
1770+
// type bigint. AVG is unaffected (its output stays DOUBLE and its reduced internal SUM is not
1771+
// surfaced here).
1772+
aggRexList = castBigintSumResults(context, aggExprList, aggRexList);
17661773
List<RexNode> aliasedGroupByList =
17671774
aggregationAttributes.getLeft().stream()
17681775
.map(this::extractAliasLiteral)
@@ -1812,6 +1819,60 @@ private static AggregateFunction extractAggregateFunction(UnresolvedExpression e
18121819
return null;
18131820
}
18141821

1822+
/**
1823+
* Narrow the results of {@code SUM(bigint)} aggregations back to BIGINT. {@code SUM} over a
1824+
* BIGINT column is accumulated in a wider type (its argument is cast to DECIMAL in {@code
1825+
* PPLFuncImpTable}) so a running sum near 2^63 cannot silently wrap to a negative long. This
1826+
* restores the declared BIGINT output type via {@link PPLBuiltinOperators#CHECKED_LONG_NARROW},
1827+
* which errors on a genuine overflow (surfaced as a 4xx) while saturating a value that merely
1828+
* rounds to 2^63 on the double-based pushdown path, so both execution paths behave identically.
1829+
* Non-SUM aggregations and sums whose accumulator is not the widened DECIMAL (e.g. {@code
1830+
* SUM(double)}, user {@code SUM(decimal)}) are returned unchanged.
1831+
*/
1832+
private static List<RexNode> castBigintSumResults(
1833+
CalcitePlanContext context,
1834+
List<UnresolvedExpression> aggExprList,
1835+
List<RexNode> aggRexList) {
1836+
if (aggExprList.size() != aggRexList.size()) {
1837+
return aggRexList;
1838+
}
1839+
List<RexNode> result = new ArrayList<>(aggRexList.size());
1840+
for (int i = 0; i < aggRexList.size(); i++) {
1841+
RexNode aggRex = aggRexList.get(i);
1842+
AggregateFunction aggFunc = extractAggregateFunction(aggExprList.get(i));
1843+
if (aggFunc != null
1844+
&& BuiltinFunctionName.ofAggregation(aggFunc.getFuncName())
1845+
.filter(name -> name == BuiltinFunctionName.SUM)
1846+
.isPresent()
1847+
&& isWidenedBigintSumType(aggRex.getType())) {
1848+
RexNode narrowed =
1849+
context.rexBuilder.makeCall(PPLBuiltinOperators.CHECKED_LONG_NARROW, aggRex);
1850+
// Wrapping in the narrowing UDF drops the aggregate's output name; restore it so downstream
1851+
// projection and the result schema keep the original "sum(...)" column name.
1852+
result.add(
1853+
aggRex instanceof RexInputRef ref
1854+
? context.relBuilder.alias(
1855+
narrowed,
1856+
context.relBuilder.peek().getRowType().getFieldNames().get(ref.getIndex()))
1857+
: narrowed);
1858+
} else {
1859+
result.add(aggRex);
1860+
}
1861+
}
1862+
return result;
1863+
}
1864+
1865+
/**
1866+
* Whether the type is the DECIMAL accumulator produced for {@code SUM(bigint)} (a scale-0 decimal
1867+
* at max precision, from the argument widening in {@code PPLFuncImpTable}), as opposed to a
1868+
* genuine user-supplied {@code SUM(decimal)} with a fractional scale.
1869+
*/
1870+
private static boolean isWidenedBigintSumType(RelDataType type) {
1871+
return type.getSqlTypeName() == SqlTypeName.DECIMAL
1872+
&& type.getScale() == 0
1873+
&& type.getPrecision() == OpenSearchTypeSystem.MAX_PRECISION;
1874+
}
1875+
18151876
private static Function extractFunction(UnresolvedExpression expr) {
18161877
if (expr instanceof Function f) return f;
18171878
if (expr instanceof Alias alias) return extractFunction(alias.getDelegated());

core/src/main/java/org/opensearch/sql/expression/function/PPLBuiltinOperators.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
import org.opensearch.sql.expression.function.jsonUDF.JsonSetFunctionImpl;
6666
import org.opensearch.sql.expression.function.udf.AutoConvertFunction;
6767
import org.opensearch.sql.expression.function.udf.CTimeConvertFunction;
68+
import org.opensearch.sql.expression.function.udf.CheckedLongNarrowFunction;
6869
import org.opensearch.sql.expression.function.udf.CryptographicFunction;
6970
import org.opensearch.sql.expression.function.udf.Dur2SecConvertFunction;
7071
import org.opensearch.sql.expression.function.udf.MemkConvertFunction;
@@ -436,6 +437,8 @@ public class PPLBuiltinOperators extends ReflectiveSqlOperatorTable {
436437
new NumberToStringFunction().toUDF("NUMBER_TO_STRING");
437438
public static final SqlOperator TONUMBER = new ToNumberFunction().toUDF("TONUMBER");
438439
public static final SqlOperator TOSTRING = new ToStringFunction().toUDF("TOSTRING");
440+
public static final SqlOperator CHECKED_LONG_NARROW =
441+
new CheckedLongNarrowFunction().toUDF("CHECKED_LONG_NARROW");
439442

440443
// PPL Convert command functions
441444
public static final SqlOperator AUTO = new AutoConvertFunction().toUDF("AUTO");

core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@
287287
import org.apache.calcite.rel.type.RelDataType;
288288
import org.apache.calcite.rex.RexBuilder;
289289
import org.apache.calcite.rex.RexCall;
290+
import org.apache.calcite.rex.RexInputRef;
290291
import org.apache.calcite.rex.RexLambda;
291292
import org.apache.calcite.rex.RexLambdaRef;
292293
import org.apache.calcite.rex.RexLiteral;
@@ -1537,10 +1538,68 @@ void registerOperator(BuiltinFunctionName functionName, SqlAggFunction aggFuncti
15371538
register(functionName, handler, typeChecker);
15381539
}
15391540

1541+
/**
1542+
* Registers {@code SUM} with the extra behaviour that a BIGINT (long) column is summed in
1543+
* DECIMAL rather than long. A running long sum near 2^63 silently wraps to a negative value
1544+
* (the enumerable {@code SumImplementor} generates a plain {@code long + long}); accumulating
1545+
* in DECIMAL is exact. {@link CalciteRelNodeVisitor} casts the DECIMAL result back to BIGINT so
1546+
* the output type is unchanged and a genuine overflow surfaces as a client error instead of a
1547+
* wrong value.
1548+
*
1549+
* <p>Only a bare BIGINT column reference is widened. Expressions like {@code sum(field + 1)}
1550+
* are left as-is so {@link org.opensearch.sql.calcite.plan.rule.PPLAggregateConvertRule} can
1551+
* still rewrite them to pushdown-friendly {@code sum(field) OP literal} form.
1552+
*/
1553+
void registerSumOperator() {
1554+
SqlOperandTypeChecker innerTypeChecker = extractTypeCheckerFromUDF(SqlStdOperatorTable.SUM);
1555+
PPLTypeChecker typeChecker = wrapSqlOperandTypeChecker(innerTypeChecker, SUM.name(), true);
1556+
AggHandler handler =
1557+
(distinct, field, argList, ctx) -> {
1558+
List<RexNode> newArgList =
1559+
argList.stream().map(PlanUtils::derefMapCall).collect(Collectors.toList());
1560+
RexNode sumField = widenBigintColumnToDecimal(field, ctx);
1561+
return UserDefinedFunctionUtils.makeAggregateCall(
1562+
SqlStdOperatorTable.SUM, List.of(sumField), newArgList, ctx.relBuilder);
1563+
};
1564+
register(SUM, handler, typeChecker);
1565+
}
1566+
1567+
/**
1568+
* If {@code field} is a bare BIGINT column reference, cast it to DECIMAL so the aggregate that
1569+
* consumes it accumulates exactly instead of wrapping a long. Any other expression is returned
1570+
* unchanged.
1571+
*/
1572+
private static RexNode widenBigintColumnToDecimal(RexNode field, CalcitePlanContext ctx) {
1573+
if (field instanceof RexInputRef && field.getType().getSqlTypeName() == SqlTypeName.BIGINT) {
1574+
int maxPrecision = TYPE_FACTORY.getTypeSystem().getMaxNumericPrecision();
1575+
RelDataType decimalType =
1576+
TYPE_FACTORY.createTypeWithNullability(
1577+
TYPE_FACTORY.createSqlType(SqlTypeName.DECIMAL, maxPrecision, 0),
1578+
field.getType().isNullable());
1579+
return ctx.rexBuilder.makeCast(decimalType, field);
1580+
}
1581+
return field;
1582+
}
1583+
1584+
/**
1585+
* If {@code field} is a bare BIGINT column reference, cast it to DOUBLE so that AVG's reduced
1586+
* intermediate SUM accumulates in double rather than a long that wraps near 2^63. Any other
1587+
* expression is returned unchanged.
1588+
*/
1589+
private static RexNode widenBigintColumnToDouble(RexNode field, CalcitePlanContext ctx) {
1590+
if (field instanceof RexInputRef && field.getType().getSqlTypeName() == SqlTypeName.BIGINT) {
1591+
RelDataType doubleType =
1592+
TYPE_FACTORY.createTypeWithNullability(
1593+
TYPE_FACTORY.createSqlType(SqlTypeName.DOUBLE), field.getType().isNullable());
1594+
return ctx.rexBuilder.makeCast(doubleType, field);
1595+
}
1596+
return field;
1597+
}
1598+
15401599
void populate() {
15411600
registerOperator(MAX, SqlStdOperatorTable.MAX);
15421601
registerOperator(MIN, SqlStdOperatorTable.MIN);
1543-
registerOperator(SUM, SqlStdOperatorTable.SUM);
1602+
registerSumOperator();
15441603
registerOperator(VARSAMP, PPLBuiltinOperators.VAR_SAMP_NULLABLE);
15451604
registerOperator(VARPOP, PPLBuiltinOperators.VAR_POP_NULLABLE);
15461605
registerOperator(STDDEV_SAMP, PPLBuiltinOperators.STDDEV_SAMP_NULLABLE);
@@ -1559,7 +1618,11 @@ void populate() {
15591618

15601619
register(
15611620
AVG,
1562-
(distinct, field, argList, ctx) -> ctx.relBuilder.avg(distinct, null, field),
1621+
(distinct, field, argList, ctx) ->
1622+
// AVG reduces to SUM(field)/COUNT(field); over a bare BIGINT column the intermediate
1623+
// long SUM wraps near 2^63 (returning a wrong negative average). Averaging in DOUBLE
1624+
// avoids the wrap and keeps the DOUBLE output type unchanged.
1625+
ctx.relBuilder.avg(distinct, null, widenBigintColumnToDouble(field, ctx)),
15631626
wrapSqlOperandTypeChecker(
15641627
SqlStdOperatorTable.AVG.getOperandTypeChecker(), AVG.name(), false));
15651628

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.sql.expression.function.udf;
7+
8+
import java.math.BigDecimal;
9+
import java.util.List;
10+
import org.apache.calcite.adapter.enumerable.NotNullImplementor;
11+
import org.apache.calcite.adapter.enumerable.NullPolicy;
12+
import org.apache.calcite.adapter.enumerable.RexToLixTranslator;
13+
import org.apache.calcite.linq4j.tree.Expression;
14+
import org.apache.calcite.linq4j.tree.Expressions;
15+
import org.apache.calcite.rex.RexCall;
16+
import org.apache.calcite.sql.type.ReturnTypes;
17+
import org.apache.calcite.sql.type.SqlReturnTypeInference;
18+
import org.opensearch.sql.calcite.utils.PPLOperandTypes;
19+
import org.opensearch.sql.expression.function.ImplementorUDF;
20+
import org.opensearch.sql.expression.function.UDFOperandMetadata;
21+
22+
/**
23+
* Narrows a numeric SUM accumulator (widened to DECIMAL/double so it does not wrap) back to BIGINT,
24+
* raising an error when the value overflowed the BIGINT range instead of wrapping to a negative
25+
* value.
26+
*
27+
* <p>SUM over a BIGINT column is accumulated as a wider type to avoid the silent {@code long}
28+
* two's-complement wrap near 2^63. This function restores the declared BIGINT output type. A value
29+
* whose magnitude clearly exceeds {@code 2^63} is treated as overflow and surfaces as a client
30+
* error. Because OpenSearch computes pushed-down sums in {@code double}, {@code (double)
31+
* Long.MAX_VALUE} rounds to exactly {@code 2^63} and cannot be distinguished from a small overflow;
32+
* to avoid erroring on a legitimate near-{@code Long.MAX_VALUE} sum, only magnitudes strictly
33+
* beyond {@code 2^63} are rejected, and in-range values saturate on narrowing rather than wrap.
34+
* This makes the pushdown and in-memory paths behave identically.
35+
*/
36+
public class CheckedLongNarrowFunction extends ImplementorUDF {
37+
public CheckedLongNarrowFunction() {
38+
super(new CheckedLongNarrowImplementor(), NullPolicy.ANY);
39+
}
40+
41+
@Override
42+
public SqlReturnTypeInference getReturnTypeInference() {
43+
return ReturnTypes.BIGINT_FORCE_NULLABLE;
44+
}
45+
46+
@Override
47+
public UDFOperandMetadata getOperandMetadata() {
48+
return PPLOperandTypes.NUMERIC;
49+
}
50+
51+
/** {@code 2^63}, the first magnitude beyond the signed BIGINT range. */
52+
private static final double TWO_POW_63 = 0x1p63;
53+
54+
/** Narrow a widened sum value to a long, erroring when it overflowed the BIGINT range. */
55+
public static long narrow(Object value) {
56+
double magnitude = ((Number) value).doubleValue();
57+
if (magnitude > TWO_POW_63 || magnitude < -TWO_POW_63) {
58+
throw new ArithmeticException("BIGINT overflow in SUM");
59+
}
60+
if (value instanceof BigDecimal decimal) {
61+
// Exact for the in-memory (DECIMAL) path; clamps a value that rounds to exactly 2^63.
62+
return decimal
63+
.min(BigDecimal.valueOf(Long.MAX_VALUE))
64+
.max(BigDecimal.valueOf(Long.MIN_VALUE))
65+
.longValue();
66+
}
67+
// double path (pushed-down sum): saturating narrow (JLS 5.1.3) clamps 2^63 to Long.MAX_VALUE.
68+
return (long) magnitude;
69+
}
70+
71+
public static class CheckedLongNarrowImplementor implements NotNullImplementor {
72+
@Override
73+
public Expression implement(
74+
RexToLixTranslator translator, RexCall call, List<Expression> translatedOperands) {
75+
return Expressions.call(CheckedLongNarrowFunction.class, "narrow", translatedOperands.get(0));
76+
}
77+
}
78+
}

integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLAggregationIT.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@
2020
import static org.opensearch.sql.util.MatcherUtils.verifyErrorMessageContains;
2121
import static org.opensearch.sql.util.MatcherUtils.verifySchema;
2222
import static org.opensearch.sql.util.MatcherUtils.verifySchemaInOrder;
23+
import static org.opensearch.sql.util.TestUtils.createIndexByRestClient;
24+
import static org.opensearch.sql.util.TestUtils.isIndexExist;
25+
import static org.opensearch.sql.util.TestUtils.performRequest;
2326

2427
import java.io.IOException;
2528
import java.util.Arrays;
2629
import java.util.List;
2730
import org.json.JSONObject;
2831
import org.junit.jupiter.api.Test;
32+
import org.opensearch.client.Request;
2933
import org.opensearch.sql.common.utils.StringUtils;
3034
import org.opensearch.sql.exception.SemanticCheckException;
3135
import org.opensearch.sql.ppl.PPLIntegTestCase;
@@ -93,6 +97,70 @@ public void testSumAvg() throws IOException {
9397
verifyDataRows(actual, rows(186973));
9498
}
9599

100+
@Test
101+
public void testSumAvgLongOverflow() throws IOException {
102+
String overflowIndex = "test_sum_long_overflow";
103+
if (!isIndexExist(client(), overflowIndex)) {
104+
createIndexByRestClient(
105+
client(), overflowIndex, "{\"mappings\":{\"properties\":{\"v\":{\"type\":\"long\"}}}}");
106+
Request bulk = new Request("POST", "/" + overflowIndex + "/_bulk?refresh=true");
107+
bulk.setJsonEntity(
108+
"{\"index\":{}}\n"
109+
+ "{\"v\":9223372036854775807}\n"
110+
+ "{\"index\":{}}\n"
111+
+ "{\"v\":9223372036854775807}\n"
112+
+ "{\"index\":{}}\n"
113+
+ "{\"v\":9223372036854775807}\n");
114+
performRequest(client(), bulk);
115+
}
116+
String inRangeIndex = "test_sum_long_in_range";
117+
if (!isIndexExist(client(), inRangeIndex)) {
118+
createIndexByRestClient(
119+
client(), inRangeIndex, "{\"mappings\":{\"properties\":{\"v\":{\"type\":\"long\"}}}}");
120+
Request bulk = new Request("POST", "/" + inRangeIndex + "/_bulk?refresh=true");
121+
bulk.setJsonEntity(
122+
"{\"index\":{}}\n"
123+
+ "{\"v\":1000000000000}\n"
124+
+ "{\"index\":{}}\n"
125+
+ "{\"v\":2000000000000}\n"
126+
+ "{\"index\":{}}\n"
127+
+ "{\"v\":3000000000000}\n");
128+
performRequest(client(), bulk);
129+
}
130+
String boundaryIndex = "test_sum_long_boundary";
131+
if (!isIndexExist(client(), boundaryIndex)) {
132+
createIndexByRestClient(
133+
client(), boundaryIndex, "{\"mappings\":{\"properties\":{\"v\":{\"type\":\"long\"}}}}");
134+
Request bulk = new Request("POST", "/" + boundaryIndex + "/_bulk?refresh=true");
135+
bulk.setJsonEntity("{\"index\":{}}\n" + "{\"v\":9223372036854775807}\n");
136+
performRequest(client(), bulk);
137+
}
138+
139+
// SUM overflows the BIGINT range (3 * (2^63 - 1)); surfaced as a client error rather than
140+
// silently wrapping to a negative value.
141+
Throwable error =
142+
assertThrowsWithReplace(
143+
RuntimeException.class,
144+
() -> executeQuery(String.format("source=%s | stats sum(v)", overflowIndex)));
145+
verifyErrorMessageContains(error, "verflow");
146+
147+
// AVG is averaged in DOUBLE, so it holds the true average (the shared value) without wrapping.
148+
JSONObject avg = executeQuery(String.format("source=%s | stats avg(v)", overflowIndex));
149+
verifySchema(avg, schema("avg(v)", "double"));
150+
verifyDataRows(avg, rows(9.223372036854776e18));
151+
152+
// A sum well within the BIGINT range returns the exact value with no error.
153+
JSONObject inRange = executeQuery(String.format("source=%s | stats sum(v)", inRangeIndex));
154+
verifySchema(inRange, schema("sum(v)", "bigint"));
155+
verifyDataRows(inRange, rows(6000000000000L));
156+
157+
// A single Long.MAX_VALUE is a valid (non-overflowing) sum and must not error, even though the
158+
// pushed-down double result rounds to exactly 2^63 — the narrow saturates instead of erroring.
159+
JSONObject boundary = executeQuery(String.format("source=%s | stats sum(v)", boundaryIndex));
160+
verifySchema(boundary, schema("sum(v)", "bigint"));
161+
verifyDataRows(boundary, rows(9223372036854775807L));
162+
}
163+
96164
@Test
97165
public void testAsExistedField() throws IOException {
98166
JSONObject actual =

0 commit comments

Comments
 (0)