This is an automated email from the ASF dual-hosted git repository.
rzo1 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/storm.git
The following commit(s) were added to refs/heads/master by this push:
new 118240016 Throw a typed exception for tuples with unknown task or
stream ids and add a deserialization strict mode (#9094)
118240016 is described below
commit 118240016a3f555db35710a5b5bb41b834673ef6
Author: L1nq <[email protected]>
AuthorDate: Mon Oct 12 01:36:37 2026 +0800
Throw a typed exception for tuples with unknown task or stream ids and add
a deserialization strict mode (#9094)
* Throw a typed exception for tuples with unknown task or stream ids and
add a deserialization strict mode
* Make TupleDeserializationException extend IllegalArgumentException and
document the strict mode
The exception thrown for unknown task or stream ids now extends
IllegalArgumentException, so handlers written against 3.1.0 keep matching,
and it gains a (String, Throwable) constructor. The strict mode flag is
documented in Serialization.md and Metrics.md, and the config javadoc spells
out that a persistent bad frame puts the worker in a restart loop. The
unknown-stream test no longer pins the exact message text and the strict
mode is now covered for the typed exception as well.
* Return null for unknown components in IdDictionary and document the
strict-mode boundary for IOException-wrapped failures
The tolerated-failures set matches TupleDeserializationException through its
IllegalArgumentException supertype, so the explicit entry and the unused
two-argument constructor are removed. IdDictionary.getStreamName returns
null
when the component is unknown, so both unresolved-routing cases reach the
typed
exception instead of a NullPointerException that stays fatal in default
mode.
The strict-mode documentation now separates the two outcomes: decode
failures
exit the worker, while failures wrapping an IOException close the
connection and
drop the batch with the worker kept running. The unknown-stream test
derives its
id from the declared stream count instead of a hardcoded constant.
---
conf/defaults.yaml | 1 +
docs/Metrics.md | 2 +-
docs/Serialization.md | 1 +
storm-client/src/jvm/org/apache/storm/Config.java | 13 +++++
.../messaging/DeserializingConnectionCallback.java | 8 ++-
.../storm/serialization/KryoTupleDeserializer.java | 5 +-
.../storm/serialization/SerializationFactory.java | 3 +-
.../TupleDeserializationException.java | 22 ++++++++
.../DeserializingConnectionCallbackTest.java | 66 ++++++++++++++++++++--
.../serialization/SerializationFactoryTest.java | 12 ++++
10 files changed, 123 insertions(+), 10 deletions(-)
diff --git a/conf/defaults.yaml b/conf/defaults.yaml
index 6fd7a04b9..939d79b1e 100644
--- a/conf/defaults.yaml
+++ b/conf/defaults.yaml
@@ -61,6 +61,7 @@ storm.compression.zstd.level: 3
storm.compression.zstd.max.decompressed.bytes: 104857600
storm.compression.gzip.max.decompressed.bytes: 104857600
topology.tuple.compression.max.decompressed.bytes: 10485760
+topology.tuple.deserialization.strict.enable: false
storm.codedistributor.class:
"org.apache.storm.codedistributor.LocalFileSystemCodeDistributor"
storm.workers.artifacts.dir: "workers-artifacts"
storm.health.check.dir: "healthchecks"
diff --git a/docs/Metrics.md b/docs/Metrics.md
index e7349f771..9a3a9fc83 100644
--- a/docs/Metrics.md
+++ b/docs/Metrics.md
@@ -302,7 +302,7 @@ Be aware that the `__system` bolt is an actual bolt so
regular bolt metrics desc
`dequeuedMessages` is a throwback to older code where there was an internal
queue between the server and the bolts/spouts. That is no longer the case and
the value can be ignored.
`enqueued` is a map between the address of the remote worker and the number of
tuples that were sent from it to this worker.
-`deserializationFailures` is the number of incoming messages that failed to
deserialize and were dropped.
+`deserializationFailures` is the number of incoming messages that failed to
deserialize and were dropped. When
`topology.tuple.deserialization.strict.enable` is set, deserialization failures
are not dropped or counted; they propagate instead. Non-IO failures terminate
the worker, while failures caused by an `IOException`, such as a corrupt
compressed frame, close the connection and leave the worker running.
##### Send (Netty Client)
diff --git a/docs/Serialization.md b/docs/Serialization.md
index b8701e8aa..4d3841ad8 100644
--- a/docs/Serialization.md
+++ b/docs/Serialization.md
@@ -131,6 +131,7 @@ Be aware that the topology-wide form enables compression
for *every* remote-boun
| `topology.tuple.compression.threshold` | `1460` | Minimum serialized tuple
size, in bytes, before compression is attempted. Tuples at or below this size
are sent uncompressed. The default matches the typical Ethernet TCP MSS, so
payloads that already fit in a single network frame are never compressed. |
| `storm.compression.zstd.level` | `3` | Zstd compression level. Supported
range is 1–19; levels 20–22 (ultra mode) are prohibited because of their memory
requirements. |
| `topology.tuple.compression.max.decompressed.bytes` | `10485760` (10 MB) |
Upper bound on the decompressed size of a single tuple. Decompression that
would exceed this limit fails, guarding against malicious or corrupt payloads. |
+| `topology.tuple.deserialization.strict.enable` | `false` | Makes tuple
deserialization failures fatal: an incoming message that fails to decode kills
the receiving worker instead of being dropped and counted. This restores the
pre-3.1.0 behavior and is mainly useful for debugging, because a persistent bad
frame will put the worker in a restart loop. A failure rooted in an
`IOException`, such as a corrupt or oversized compressed frame, is an
exception: the receiving worker closes that c [...]
#### How decompression works
diff --git a/storm-client/src/jvm/org/apache/storm/Config.java
b/storm-client/src/jvm/org/apache/storm/Config.java
index 519b8d943..9c275f82c 100644
--- a/storm-client/src/jvm/org/apache/storm/Config.java
+++ b/storm-client/src/jvm/org/apache/storm/Config.java
@@ -1648,6 +1648,19 @@ public class Config extends HashMap<String, Object> {
*/
@IsPositiveNumber(includeZero = false)
public static final String
TOPOLOGY_TUPLE_COMPRESSION_MAX_DECOMPRESSED_BYTES =
"topology.tuple.compression.max.decompressed.bytes";
+ /**
+ * Topology configuration to make tuple deserialization failures fatal
instead of dropping the undecodable message.
+ * By default a message that fails to decode on the receiving worker is
dropped and counted, and the worker keeps
+ * running. When set to {@code true}, any deserialization failure
propagates and the worker exits, restoring the
+ * pre-3.1.0 behavior. Be aware that a single corrupt frame from a peer
then kills the worker, and the supervisor
+ * restarts it into the same failure, so a persistent bad frame results in
a restart loop. Failures that wrap an
+ * {@code IOException}, such as a broken or oversized compressed frame, do
not reach the exit path: the messaging
+ * layer treats them like a connection error, drops the batch being read,
and leaves the worker running while the
+ * peer reconnects.
+ * Default: {@code false}.
+ */
+ @IsBoolean
+ public static final String TOPOLOGY_TUPLE_DESERIALIZATION_STRICT_ENABLE =
"topology.tuple.deserialization.strict.enable";
/**
* Configure the topology metrics reporters to be used on workers.
*/
diff --git
a/storm-client/src/jvm/org/apache/storm/messaging/DeserializingConnectionCallback.java
b/storm-client/src/jvm/org/apache/storm/messaging/DeserializingConnectionCallback.java
index 6a8464f43..cee8776a1 100644
---
a/storm-client/src/jvm/org/apache/storm/messaging/DeserializingConnectionCallback.java
+++
b/storm-client/src/jvm/org/apache/storm/messaging/DeserializingConnectionCallback.java
@@ -43,7 +43,8 @@ public class DeserializingConnectionCallback implements
IConnectionCallback, IMe
private static final Logger LOG =
LoggerFactory.getLogger(DeserializingConnectionCallback.class);
// A tuple that cannot be decoded is dropped instead of killing the
worker; anything outside this set keeps
- // the fatal handling in StormServerHandler.
+ // the fatal handling in StormServerHandler. TupleDeserializationException
is thrown by KryoTupleDeserializer
+ // for unknown task or stream ids.
private static final Set<Class<?>> TOLERATED_DESERIALIZATION_FAILURES =
new HashSet<>(Arrays.asList(
IOException.class,
KryoException.class,
@@ -71,6 +72,8 @@ public class DeserializingConnectionCallback implements
IConnectionCallback, IMe
}
};
+ private final boolean strictMode;
+
// Track serialized size of messages.
private final boolean sizeMetricsEnabled;
private final ConcurrentHashMap<String, AtomicLong> byteCounts = new
ConcurrentHashMap<>();
@@ -87,6 +90,7 @@ public class DeserializingConnectionCallback implements
IConnectionCallback, IMe
this.context = context;
cb = callback;
sizeMetricsEnabled =
ObjectReader.getBoolean(conf.get(Config.TOPOLOGY_SERIALIZED_MESSAGE_SIZE_METRICS),
false);
+ strictMode =
ObjectReader.getBoolean(conf.get(Config.TOPOLOGY_TUPLE_DESERIALIZATION_STRICT_ENABLE),
false);
}
@@ -104,7 +108,7 @@ public class DeserializingConnectionCallback implements
IConnectionCallback, IMe
try {
tuple = des.deserialize(message.message());
} catch (Exception e) {
- if (!isToleratedDeserializationFailure(e)) {
+ if (strictMode || !isToleratedDeserializationFailure(e)) {
throw e;
}
deserializationFailures.incrementAndGet();
diff --git
a/storm-client/src/jvm/org/apache/storm/serialization/KryoTupleDeserializer.java
b/storm-client/src/jvm/org/apache/storm/serialization/KryoTupleDeserializer.java
index 301a8ca96..e433603f1 100644
---
a/storm-client/src/jvm/org/apache/storm/serialization/KryoTupleDeserializer.java
+++
b/storm-client/src/jvm/org/apache/storm/serialization/KryoTupleDeserializer.java
@@ -79,9 +79,12 @@ public class KryoTupleDeserializer implements
ITupleDeserializer {
int streamId = kryoInput.readInt(true);
String componentName = context.getComponentId(taskId);
if (componentName == null) {
- throw new IllegalArgumentException("Received a tuple from
unknown task " + taskId);
+ throw new TupleDeserializationException("Received a tuple from
unknown task " + taskId);
}
String streamName = ids.getStreamName(componentName, streamId);
+ if (streamName == null) {
+ throw new TupleDeserializationException("Component " +
componentName + " has no stream with id " + streamId);
+ }
MessageId id = MessageId.deserialize(kryoInput);
List<Object> values = kryo.deserializeFrom(kryoInput);
return new TupleImpl(context, values, componentName, taskId,
streamName, id);
diff --git
a/storm-client/src/jvm/org/apache/storm/serialization/SerializationFactory.java
b/storm-client/src/jvm/org/apache/storm/serialization/SerializationFactory.java
index 18b471c99..9180978ea 100644
---
a/storm-client/src/jvm/org/apache/storm/serialization/SerializationFactory.java
+++
b/storm-client/src/jvm/org/apache/storm/serialization/SerializationFactory.java
@@ -259,7 +259,8 @@ public class SerializationFactory {
}
public String getStreamName(String component, int stream) {
- return streamIdToName.get(component).get(stream);
+ Map<Integer, String> streamNames = streamIdToName.get(component);
+ return streamNames == null ? null : streamNames.get(stream);
}
}
}
diff --git
a/storm-client/src/jvm/org/apache/storm/serialization/TupleDeserializationException.java
b/storm-client/src/jvm/org/apache/storm/serialization/TupleDeserializationException.java
new file mode 100644
index 000000000..8329ea198
--- /dev/null
+++
b/storm-client/src/jvm/org/apache/storm/serialization/TupleDeserializationException.java
@@ -0,0 +1,22 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership. The
ASF licenses this file to you under the Apache License, Version
+ * 2.0 (the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions
+ * and limitations under the License.
+ */
+
+package org.apache.storm.serialization;
+
+/**
+ * Thrown when a serialized tuple names a source task or stream that the
receiving topology cannot resolve.
+ */
+public class TupleDeserializationException extends IllegalArgumentException {
+ public TupleDeserializationException(String message) {
+ super(message);
+ }
+}
diff --git
a/storm-client/test/jvm/org/apache/storm/messaging/DeserializingConnectionCallbackTest.java
b/storm-client/test/jvm/org/apache/storm/messaging/DeserializingConnectionCallbackTest.java
index 2a982b3a3..de8bf62b2 100644
---
a/storm-client/test/jvm/org/apache/storm/messaging/DeserializingConnectionCallbackTest.java
+++
b/storm-client/test/jvm/org/apache/storm/messaging/DeserializingConnectionCallbackTest.java
@@ -27,6 +27,7 @@ import org.apache.storm.Config;
import org.apache.storm.daemon.worker.WorkerState;
import org.apache.storm.serialization.KryoTupleDeserializer;
import org.apache.storm.serialization.KryoTupleSerializer;
+import org.apache.storm.serialization.TupleDeserializationException;
import org.apache.storm.task.GeneralTopologyContext;
import org.apache.storm.testing.TestWordCounter;
import org.apache.storm.testing.TestWordSpout;
@@ -132,14 +133,62 @@ public class DeserializingConnectionCallbackTest {
@Test
public void testUnknownSourceTaskDroppedAndBatchContinues() {
Map<String, Object> conf = baseConf();
+
+ TupleDeserializationException thrown =
assertThrows(TupleDeserializationException.class,
+ () -> new
KryoTupleDeserializer(conf, context).deserialize(unknownSourceTaskTuple()));
+ assertTrue(thrown.getMessage().contains("9999"),
+ "expected the task id in the message but was: " +
thrown.getMessage());
+
+ assertBatchDeliversOnlyValidMessages(conf, unknownSourceTaskTuple());
+ }
+
+ @Test
+ public void testUnknownStreamIdDroppedAndBatchContinues() {
+ Map<String, Object> conf = baseConf();
+ // IdDictionary assigns ids 1..n to the component's n declared
streams, so the first id past that count is undeclared.
+ int undeclaredStreamId =
Utils.getComponentCommon(context.getRawTopology(),
SOURCE_COMPONENT).get_streams().size() + 1;
Output out = new Output(16, 32);
- out.writeInt(9999, true); // source task that does not exist in the
topology
- out.writeInt(1, true); // default stream id
- byte[] unknownTask = out.toBytes();
+ out.writeInt(SOURCE_TASK_ID, true); // source task that exists in the
topology
+ out.writeInt(undeclaredStreamId, true);
+ byte[] unknownStream = out.toBytes();
- assertThrows(IllegalArgumentException.class, () -> new
KryoTupleDeserializer(conf, context).deserialize(unknownTask));
+ TupleDeserializationException thrown =
assertThrows(TupleDeserializationException.class,
+ () -> new
KryoTupleDeserializer(conf, context).deserialize(unknownStream));
+ assertTrue(thrown.getMessage().contains(SOURCE_COMPONENT),
+ "expected the component name in the message but was: " +
thrown.getMessage());
- assertBatchDeliversOnlyValidMessages(conf, unknownTask);
+ assertBatchDeliversOnlyValidMessages(conf, unknownStream);
+ }
+
+ @Test
+ public void testStrictModeMakesFailuresFatal() {
+ Map<String, Object> conf = baseConf();
+ conf.put(Config.TOPOLOGY_TUPLE_DESERIALIZATION_STRICT_ENABLE, true);
+ byte[] full = serializedTuple(conf, new
Values("a-string-long-enough-to-survive-truncation", 7));
+ byte[] truncated = Arrays.copyOf(full, full.length - 10);
+
+ WorkerState.ILocalTransferCallback transfer =
mock(WorkerState.ILocalTransferCallback.class);
+ DeserializingConnectionCallback callback = new
DeserializingConnectionCallback(conf, context, transfer);
+
+ assertThrows(KryoException.class, () ->
callback.recv(Collections.singletonList(taskMessage(truncated))));
+
+ verify(transfer, never()).transfer(any());
+ assertEquals(0L, callback.getAndResetDeserializationFailures());
+ }
+
+ @Test
+ public void testStrictModeMakesUnknownTaskFailureFatal() {
+ Map<String, Object> conf = baseConf();
+ conf.put(Config.TOPOLOGY_TUPLE_DESERIALIZATION_STRICT_ENABLE, true);
+
+ WorkerState.ILocalTransferCallback transfer =
mock(WorkerState.ILocalTransferCallback.class);
+ DeserializingConnectionCallback callback = new
DeserializingConnectionCallback(conf, context, transfer);
+
+ assertThrows(TupleDeserializationException.class,
+ () ->
callback.recv(Collections.singletonList(taskMessage(unknownSourceTaskTuple()))));
+
+ verify(transfer, never()).transfer(any());
+ assertEquals(0L, callback.getAndResetDeserializationFailures());
}
@Test
@@ -269,6 +318,13 @@ public class DeserializingConnectionCallbackTest {
assertNull(callback.getValueAndReset());
}
+ private static byte[] unknownSourceTaskTuple() {
+ Output out = new Output(16, 32);
+ out.writeInt(9999, true); // source task that does not exist in the
topology
+ out.writeInt(1, true); // default stream id
+ return out.toBytes();
+ }
+
private Map<String, Object> baseConf() {
Map<String, Object> conf = new HashMap<>(Utils.readStormConfig());
return conf;
diff --git
a/storm-client/test/jvm/org/apache/storm/serialization/SerializationFactoryTest.java
b/storm-client/test/jvm/org/apache/storm/serialization/SerializationFactoryTest.java
index d7e7bfcd4..998098643 100644
---
a/storm-client/test/jvm/org/apache/storm/serialization/SerializationFactoryTest.java
+++
b/storm-client/test/jvm/org/apache/storm/serialization/SerializationFactoryTest.java
@@ -16,11 +16,14 @@ import com.esotericsoftware.kryo.Kryo;
import java.util.Map;
import org.apache.storm.Config;
import org.apache.storm.security.serialization.BlowfishTupleSerializer;
+import org.apache.storm.testing.TestWordSpout;
+import org.apache.storm.topology.TopologyBuilder;
import org.apache.storm.utils.ListDelegate;
import org.apache.storm.utils.Utils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class SerializationFactoryTest {
@@ -65,6 +68,15 @@ public class SerializationFactoryTest {
kryo.getSerializer(MockSerObject.class).getClass());
}
+ @Test
+ public void test_get_stream_name_null_for_unknown_component() {
+ TopologyBuilder builder = new TopologyBuilder();
+ builder.setSpout("spout", new TestWordSpout(true), 1);
+ SerializationFactory.IdDictionary ids = new
SerializationFactory.IdDictionary(builder.createTopology());
+
+ assertNull(ids.getStreamName("no-such-component", 1));
+ }
+
public static class MockKryoDecorator implements IKryoDecorator {
@Override
public void decorate(Kryo k, Map<String, Object> conf) {