This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 09e68277b2 [#12598] fix(common): Reject out-of-range numeric statistic
values (#12599)
09e68277b2 is described below
commit 09e68277b2d8df18b397c2373a2ada2aef8ce04f
Author: YangJie <[email protected]>
AuthorDate: Thu Aug 27 02:50:39 2026 -0400
[#12598] fix(common): Reject out-of-range numeric statistic values (#12599)
### What changes were proposed in this pull request?
Add two range guards to `JsonUtils.getStatisticValue`:
- integral branch: `JsonNode.canConvertToLong()` before `asLong()`
- floating-point branch: `Double.isFinite()` on the result of
`asDouble()`
Both reject through `Preconditions.checkArgument`, matching how the
other read paths in this file signal bad input (`readFunctionArg` and
the partition reader both throw `IllegalArgumentException`).
The change also drops the checked-exception plumbing around the terminal
branch. It threw `UnsupportedEncodingException`, a character-encoding
error used to report a bad JSON node type, which forced
`getStatisticValue` to declare `throws IOException`, which in turn
forced the object branch to launder that exception out of a lambda
through a bare `RuntimeException`. None of it was ever reachable, before
or after this change: JSON text can only produce node types the method
already handles. The branch is reachable through
`ObjectMapper.convertValue` with an embedded binary node, which is what
the new test for it uses. It now throws `IllegalArgumentException`, and
the `throws` clause and the `try/catch` are gone.
Two `if (value != null)` checks in the recursive branches are removed as
well. `getStatisticValue` never returns null: every branch either
returns a `StatisticValues` instance or throws.
### Why are the changes needed?
A statistic value past the 64-bit range was silently replaced by a
different number, with the sign flipped in some cases.
`9223372036854775808` was stored as `-9223372036854775808`, and
`123456789012345678901234567890` as `-4362896299872285998`. An
out-of-range floating-point literal became `Infinity`, which the
serializer writes back out as the JSON string `"Infinity"`, so the value
returned as a `StringValue` on the next round trip.
`StatisticsUpdateRequest.validate()` cannot catch this: it only checks
for a null value, and it runs after Jackson has built the map, by which
point the truncated `long` is all that is left. The deserializer is the
only place where the information needed to detect the loss still exists.
`StatisticValue` has no BigInteger or BigDecimal type, so there is no
lossless representation to fall back to, and failing the request is
better than storing a wrong number.
Fix: #12598
### Does this PR introduce _any_ user-facing change?
Yes. `PUT /metalakes/{metalake}/objects/{type}/{fullName}/statistics`
and its `/partitions` variant now return 400 for a numeric statistic
value outside the `long` range, or a floating-point value that is not
finite. They previously returned 200 and stored a wrong number.
No stored data becomes unreadable. The serializer can only emit in-range
longs and finite doubles (a non-finite double goes out as the quoted
string `"Infinity"`), so nothing already persisted trips the new guards.
Rows already corrupted by the old behaviour keep their wrong value; this
change does not repair them.
No API signatures, property keys, or configuration change.
### How was this patch tested?
Four test methods in `TestJsonUtils`. All four were confirmed to fail
against the pre-fix code by reverting `JsonUtils.java` and re-running,
not by inspection:
- `testStatisticValueRejectsOutOfRangeIntegral` — both 64-bit boundaries
are still accepted; one past each boundary and two far outside are
rejected, including nested inside a list and inside an object.
- `testStatisticValueRejectsNonFiniteFloatingPoint` — `±1.5E400`,
asserting the full message including the rendered `Infinity` /
`-Infinity`. The message reports the parsed double rather than echoing
the literal, because the node Jackson hands the deserializer already
holds the infinity.
- `testStatisticsUpdateRequestRejectsOutOfRangeValue` — the real
request-body shape, where the value is `Map` content and Jackson wraps
the rejection into `JsonMappingException`, which the server maps to 400.
It uses a bare `ObjectMapper` so the assertion rests on the DTO's
`@JsonDeserialize(contentUsing = ...)` annotation rather than on a
module the test registered; removing that annotation makes the test
fail.
- `testStatisticValueRejectsUnsupportedNodeType` — the terminal branch.
It asserts `assertNull(e.getCause())`, because
`ObjectMapper.convertValue` relaunders a deserializer `IOException` into
an `IllegalArgumentException` carrying the same message, so only the
cause distinguishes our own rejection from the old checked exception.
```
./gradlew :common:test :core:test :server:test :common:javadoc
:common:spotlessCheck -PskipITs
```
passes.
Follow-ups found while working on this, not included here to keep the
change to one concern:
- `StatisticValues.doubleValue(double)` accepts `Infinity` and `NaN`, so
the write side can still produce a value this change now refuses to read
back as a double. The root fix belongs in `api` and carries its own
compatibility discussion.
- `PartitionStatisticsUpdateDTO.validate()` has no per-entry null check,
unlike `StatisticsUpdateRequest.validate()`. Jackson's `MapDeserializer`
does not invoke a `contentUsing` deserializer for a `VALUE_NULL` content
token, so a top-level JSON null reaches storage on that route.
- `JdbcPartitionStatisticStorage.parseResultSet` catches
`JsonProcessingException` to log the partition and statistic name; an
unchecked `IllegalArgumentException` bypasses that handler.
---
.../java/org/apache/gravitino/json/JsonUtils.java | 38 ++++-----
.../org/apache/gravitino/json/TestJsonUtils.java | 93 ++++++++++++++++++++++
2 files changed, 112 insertions(+), 19 deletions(-)
diff --git a/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
b/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
index ad71feb557..e98bc7e387 100644
--- a/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
+++ b/common/src/main/java/org/apache/gravitino/json/JsonUtils.java
@@ -49,7 +49,6 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import java.io.IOException;
-import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
@@ -1388,13 +1387,27 @@ public class JsonUtils {
}
}
- private static StatisticValue<?> getStatisticValue(JsonNode node) throws
IOException {
+ private static StatisticValue<?> getStatisticValue(JsonNode node) {
Preconditions.checkArgument(
node != null && !node.isNull(), "Cannot parse statistic value from
invalid JSON: %s", node);
if (node.isIntegralNumber()) {
+ // BigInteger nodes are integral as well, and asLong() would wrap them
around silently.
+ Preconditions.checkArgument(
+ node.canConvertToLong(),
+ "Statistic value is out of the range of a 64-bit signed integer: %s",
+ node);
return StatisticValues.longValue(node.asLong());
} else if (node.isFloatingPointNumber()) {
- return StatisticValues.doubleValue(node.asDouble());
+ // Jackson parses a literal past the double range into a DoubleNode that
already holds
+ // infinity (USE_BIG_DECIMAL_FOR_FLOATS is off), and the serializer
would write that back
+ // out as the JSON string "Infinity", so the value would come back as a
string.
+ double doubleValue = node.asDouble();
+ Preconditions.checkArgument(
+ Double.isFinite(doubleValue),
+ "Statistic value is out of the range of a 64-bit floating point
number, the literal"
+ + " parsed to %s",
+ doubleValue);
+ return StatisticValues.doubleValue(doubleValue);
} else if (node.isTextual()) {
return StatisticValues.stringValue(node.asText());
} else if (node.isBoolean()) {
@@ -1403,10 +1416,7 @@ public class JsonUtils {
ArrayNode arrayNode = (ArrayNode) node;
List<StatisticValue<Object>> values =
Lists.newArrayListWithCapacity(arrayNode.size());
for (JsonNode arrayElement : arrayNode) {
- StatisticValue<?> value = getStatisticValue(arrayElement);
- if (value != null) {
- values.add((StatisticValue<Object>) value);
- }
+ values.add((StatisticValue<Object>) getStatisticValue(arrayElement));
}
return StatisticValues.listValue(values);
} else if (node.isObject()) {
@@ -1414,20 +1424,10 @@ public class JsonUtils {
Map<String, StatisticValue<?>> map = Maps.newHashMap();
objectNode
.fields()
- .forEachRemaining(
- entry -> {
- try {
- StatisticValue<?> value =
getStatisticValue(entry.getValue());
- if (value != null) {
- map.put(entry.getKey(), value);
- }
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- });
+ .forEachRemaining(entry -> map.put(entry.getKey(),
getStatisticValue(entry.getValue())));
return StatisticValues.objectValue(map);
} else {
- throw new UnsupportedEncodingException(
+ throw new IllegalArgumentException(
String.format("Don't support json node type %s",
node.getNodeType()));
}
}
diff --git a/common/src/test/java/org/apache/gravitino/json/TestJsonUtils.java
b/common/src/test/java/org/apache/gravitino/json/TestJsonUtils.java
index 84497a0505..2649667c4b 100644
--- a/common/src/test/java/org/apache/gravitino/json/TestJsonUtils.java
+++ b/common/src/test/java/org/apache/gravitino/json/TestJsonUtils.java
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
@@ -36,6 +37,7 @@ import
org.apache.gravitino.dto.rel.partitions.IdentityPartitionDTO;
import org.apache.gravitino.dto.rel.partitions.ListPartitionDTO;
import org.apache.gravitino.dto.rel.partitions.PartitionDTO;
import org.apache.gravitino.dto.rel.partitions.RangePartitionDTO;
+import org.apache.gravitino.dto.requests.StatisticsUpdateRequest;
import org.apache.gravitino.rel.indexes.Index;
import org.apache.gravitino.rel.types.Type;
import org.apache.gravitino.rel.types.Types;
@@ -47,6 +49,11 @@ import org.junit.jupiter.api.Test;
public class TestJsonUtils {
+ private static final String INTEGRAL_RANGE_MESSAGE =
+ "out of the range of a 64-bit signed integer";
+ private static final String FLOATING_RANGE_MESSAGE =
+ "out of the range of a 64-bit floating point number";
+
private static ObjectMapper objectMapper;
@BeforeAll
@@ -560,4 +567,90 @@ public class TestJsonUtils {
objectMapper.readValue(expectJson, StatisticValue.class),
objectMapper.readValue(objectValue, StatisticValue.class));
}
+
+ @Test
+ void testStatisticValueRejectsOutOfRangeIntegral() throws
JsonProcessingException {
+ // The 64-bit boundaries themselves must still be accepted.
+ Assertions.assertEquals(
+ StatisticValues.longValue(Long.MAX_VALUE),
+ objectMapper.readValue("9223372036854775807", StatisticValue.class));
+ Assertions.assertEquals(
+ StatisticValues.longValue(Long.MIN_VALUE),
+ objectMapper.readValue("-9223372036854775808", StatisticValue.class));
+
+ // Anything past a boundary has no lossless long representation and must
be rejected rather
+ // than wrapped around.
+ assertRejected("9223372036854775808", INTEGRAL_RANGE_MESSAGE);
+ assertRejected("-9223372036854775809", INTEGRAL_RANGE_MESSAGE);
+ assertRejected("123456789012345678901234567890", INTEGRAL_RANGE_MESSAGE);
+ assertRejected("-123456789012345678901234567890", INTEGRAL_RANGE_MESSAGE);
+
+ // The deserializer reads the whole tree and recurses in plain Java, so an
element nested in a
+ // list or an object is rejected at the same level as a scalar. This
covers those two branches,
+ // not Jackson's own nesting - see
testStatisticsUpdateRequestRejectsOutOfRangeValue for that.
+ assertRejected("[1,123456789012345678901234567890]",
INTEGRAL_RANGE_MESSAGE);
+ assertRejected("{\"key\":123456789012345678901234567890}",
INTEGRAL_RANGE_MESSAGE);
+ }
+
+ @Test
+ void testStatisticValueRejectsNonFiniteFloatingPoint() {
+ // A magnitude beyond the double range is only representable as an
infinity, which the
+ // serializer writes back out as the JSON string "Infinity" - the value
would come back as a
+ // string on the next round trip. The message reports the parsed double
rather than echoing the
+ // literal, because the node Jackson hands us already holds the infinity;
assert it in full so
+ // that stays visible.
+ assertRejected(
+ "1.5E400", FLOATING_RANGE_MESSAGE + ", the literal parsed to " +
Double.POSITIVE_INFINITY);
+ assertRejected(
+ "-1.5E400", FLOATING_RANGE_MESSAGE + ", the literal parsed to " +
Double.NEGATIVE_INFINITY);
+ }
+
+ @Test
+ void testStatisticsUpdateRequestRejectsOutOfRangeValue() {
+ // The shape the REST layer actually deserializes: the value is Map
content, so Jackson wraps
+ // the rejection into a JsonMappingException, which the server maps to
400. Use a bare mapper
+ // rather than the shared one, because setUp registers a StatisticValue
deserializer onto that
+ // singleton and the assertion would then hold even without the DTO's
@JsonDeserialize
+ // annotation. The server's ObjectMapperProvider registers no such module,
so the annotation is
+ // what has to carry the deserializer here.
+ ObjectMapper mapper = new ObjectMapper();
+ JsonMappingException e =
+ Assertions.assertThrows(
+ JsonMappingException.class,
+ () ->
+ mapper.readValue(
+ "{\"updates\":{\"rowCount\":9223372036854775808}}",
+ StatisticsUpdateRequest.class));
+
+ Assertions.assertInstanceOf(IllegalArgumentException.class, e.getCause());
+
Assertions.assertTrue(e.getCause().getMessage().contains(INTEGRAL_RANGE_MESSAGE));
+ }
+
+ @Test
+ void testStatisticValueRejectsUnsupportedNodeType() {
+ // A BINARY node cannot come from JSON text, but convertValue reaches the
terminal branch
+ // through an embedded-object token. Note that convertValue itself wraps
any IOException the
+ // deserializer throws into an IllegalArgumentException carrying the same
message, so the cause
+ // is what distinguishes our own rejection from a laundered checked
exception.
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> objectMapper.convertValue(new byte[] {1, 2},
StatisticValue.class));
+
+ Assertions.assertTrue(
+ e.getMessage().contains("Don't support json node type BINARY"),
+ () -> "Unexpected rejection reason: " + e.getMessage());
+ Assertions.assertNull(e.getCause());
+ }
+
+ private static void assertRejected(String json, String expectedMessagePart) {
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> objectMapper.readValue(json, StatisticValue.class));
+
+ Assertions.assertTrue(
+ e.getMessage().contains(expectedMessagePart),
+ () -> "Unexpected rejection reason: " + e.getMessage());
+ }
}