Skip to content

Make strict deserialization mode fatal for IOException-wrapped failures - #9159

Open
rzo1 wants to merge 3 commits into
apache:masterfrom
rzo1:9157-strict-mode-io-failures
Open

rzo1 wants to merge 3 commits into
apache:masterfrom
rzo1:9157-strict-mode-io-failures

Conversation

@rzo1

@rzo1 rzo1 commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #9157.

Follow-up to #9094.

Problem

In strict mode, DeserializingConnectionCallback.recv rethrows the decode failure, and it reaches StormServerHandler.exceptionCaught. That method calls Utils.handleUncaughtException with IOException as the only allowed exception, and the check walks the cause chain. ZstdUtils.decompress and KryoTupleDeserializer.deserializeTuple both wrap an IOException, so a corrupt or oversized compressed frame closed the connection and discarded the batch, and the worker kept running.

Change

  • In strict mode, DeserializingConnectionCallback wraps the failure in a new FatalDeserializationException, keeping the original as the cause.
  • StormServerHandler does not apply the IOException exemption when a FatalDeserializationException is in the chain, so every strict-mode failure terminates the worker. Genuine connection IOExceptions are handled as before.
  • Default (non-strict) mode is unchanged.
  • The Config javadoc, docs/Serialization.md and docs/Metrics.md no longer describe the IOException case as an exception to strict mode.

deserializationFailures is still not incremented on the strict path, since the worker exits.

Tests

  • DeserializingConnectionCallbackTest: the strict-mode tests expect FatalDeserializationException with the original cause, plus a new strict-mode test for a corrupt zstd frame.
  • New StormServerHandlerTest: a plain IOException-wrapping failure is swallowed, and one wrapped in FatalDeserializationException is not.

With topology.tuple.deserialization.strict.enable set, a decode failure is
rethrown from DeserializingConnectionCallback.recv and reaches
StormServerHandler.exceptionCaught, which swallows anything with an
IOException in its cause chain. Failures from a corrupt or oversized
compressed frame wrap an IOException, so they closed the connection and
dropped the batch instead of terminating the worker.

Strict-mode failures are now wrapped in FatalDeserializationException, and
StormServerHandler does not apply the IOException exemption to it. Genuine
connection errors keep the existing handling.

Fixes apache#9157
@rzo1
rzo1 force-pushed the 9157-strict-mode-io-failures branch from 6088ef9 to 6ae03e4 Compare October 11, 2026 17:41
@rzo1
rzo1 marked this pull request as ready for review October 11, 2026 17:41
@rzo1 rzo1 added this to the 3.2.0 milestone Oct 11, 2026
@rzo1
rzo1 requested review from GGraziadei and reiabreu and a balanced review from Copilot and removed request for Copilot October 11, 2026 17:42
@rzo1 rzo1 self-assigned this Oct 11, 2026

@GGraziadei GGraziadei left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @rzo1, thanks!
Just some small changes required. Some of them identified with the use of Opus, others manually.

Comment thread storm-client/src/jvm/org/apache/storm/messaging/netty/StormServerHandler.java Outdated
Comment thread storm-client/src/jvm/org/apache/storm/messaging/netty/StormServerHandler.java Outdated
FatalDeserializationException is now checked at the top of
StormServerHandler.exceptionCaught instead of going through an empty
allowed-exception set and the catch (Error) block.

The worker is terminated from a separate thread. Calling exit() on the
netty event loop thread blocks forever, because the worker shutdown hook
closes the netty server and waits for that same event loop to finish.

The tests now call exceptionCaught itself, with the terminator injected.
@rzo1
rzo1 requested a review from GGraziadei October 12, 2026 18:54

@GGraziadei GGraziadei left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @rzo1 thanks, some changes required.


@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (cause instanceof FatalDeserializationException) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @rzo1 what do you think about using the Utils API?
Utils.exceptionCauseIsInstanceOf(FatalDeserializationException.class, cause)
This API checks on the whole chain, internally has an unwrap method.

public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (cause instanceof FatalDeserializationException) {
// Strict deserialization mode: fatal even if the failure wraps an IOException.
LOG.error("Strict deserialization failure, terminating worker", cause);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about extending the diagnostic in this way?

    try {
        LOG.error("Strict deserialization failure from {}, terminating worker",
                  ctx.channel().remoteAddress(), cause);
    } catch (Throwable err) {
        // nop
    }

// Exit from a separate thread: the worker shutdown hook closes the netty server, which waits for this
// event loop to finish, so calling exit() on the event loop thread itself would never return.
private static void terminateWorker() {
Thread exitThread = new Thread(() -> Runtime.getRuntime().exit(1), "netty-server-exit");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about adding an atomic barrier to prevent multiple termination threads from being spawned?
A CAS operation could be used coupled with an atomic isTerminating flag.

if (! isTerminating.compareAndSet(false, true)) { 
    return;                  
}

} catch (Exception e) {
if (strictMode || !isToleratedDeserializationFailure(e)) {
if (strictMode) {
throw new FatalDeserializationException("Failed to deserialize a message of " + message.message().length

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please prevent NPE on message.message()

byte[] payload = message.message();
try {
    // deserialize the payload ...
} catch (Exception e) {
    if (strictMode) {
        int size = payload == null ? -1 : payload.length;
        throw new FatalDeserializationException("Failed to deserialize a message of " + size
                                                + " bytes destined for task " + message.task(), e);
    }
...
}

@rzo1

rzo1 commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

Maybe just push your changes on the branch @GGraziadei ;-)

@rzo1

rzo1 commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @GGraziadei ! Changes lgtm

@rzo1
rzo1 requested a review from GGraziadei October 14, 2026 09:32

@GGraziadei GGraziadei left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

topology.tuple.deserialization.strict.enable does not terminate the worker for failures wrapping an IOException

2 participants