Drop malformed tuple payloads instead of killing the receiving worker - #9076
Conversation
A tuple payload that cannot be decoded escaped recv() into the Netty fatal handler, terminating the worker. The supervisor restarted the worker, and the same poison message terminated it again. recv() now catches per-message deserialization failures whose cause chain contains one of the exceptions raised by undecodable payloads (IOException, KryoException, IllegalArgumentException, NegativeArraySizeException, ClassCastException, ArrayIndexOutOfBoundsException, BufferUnderflowException, NullPointerException, ClassNotFoundException). The offending message is dropped, the failure is logged with the destination task and payload size, the count is exposed as a deserializationFailures metric next to the message size metrics, and the rest of the batch is delivered. Any other Exception still propagates unchanged, and Errors are not caught. apache#9074
rzo1
left a comment
There was a problem hiding this comment.
Thanks — this is a well-scoped change and the reasoning in the description is sound. I built the branch and ran the new tests locally on JDK 25: 9/9 pass. Note that CI has not run on this PR yet (fork workflows need approval here); I'll get that triggered.
Two things I want to endorse explicitly, because they are the parts someone will inevitably want to change:
Not widening StormServerHandler.ALLOWED_EXCEPTIONS is correct. That whitelist is about transport-level channel errors, and handling a decode failure where the decode happens is what keeps the rest of the batch alive. MessageDecoder-level failures already land in MessageDecoder.exceptionCaught → ctx.close(), so recv() really is the only path that kills the worker. Please keep it as is.
Continuing the loop after a mid-stream failure is safe, which was my main correctness worry — the thread-local KryoTupleDeserializer is reused for the next message in the batch. I checked: Kryo 5.6.2 resets in a finally (Kryo.java:629), so the class resolver isn't left dirty. Your testJavaFallbackMissingClassDroppedAndBatchContinues case exercises exactly the name-based path where that would have bitten. Good test choice.
Now the review proper.
1. The remaining DoS is log volume
"One frame kills the worker" becomes "one ERROR with a full stack trace per frame". An attacker who can reach the worker port now fills the log volume instead of killing the process — better, but still an unauthenticated remote knob on a shared disk.
Please rate-limit: log the first N and then periodically with a running total. There's precedent in WorkerState.dropMessage (WorkerState.java:622), which carries a running dropCount in the message.
The same applies to the non-hostile case, which I think matters more day to day: a class genuinely missing from the worker classpath now produces ClassNotFoundException on every tuple. The topology silently makes no progress while emitting one stack trace per tuple, where before it failed loudly and obviously. A WARN on N consecutive failures would keep that visible in an operator's face.
2. NullPointerException in the tolerated set is papering over a missing check
The failure mode you map it to — a bogus task id inside a tuple — comes from KryoTupleDeserializer.deserializeTuple (KryoTupleDeserializer.java:79-80): context.getComponentId(taskId) returns null for an unknown id, and then ids.getStreamName(null, streamId) NPEs. That's two lines of validation at the source throwing a typed exception, rather than blanket-tolerating NPE at the callback.
It matters because Utils.exceptionCauseIsInstanceOf walks the entire cause chain, and user-supplied serializers registered via topology.kryo.register run inside des.deserialize. A real bug in one of those that surfaces as an NPE (or IllegalArgumentException) is now silently dropped data rather than a loud failure — the opposite of what you want for a topology-logic bug.
The durable shape is a dedicated TupleDeserializationException wrapping everything the decode path can legitimately throw, caught narrowly here. If that's more than you want to take on in this PR, dropping NullPointerException from the set and adding the explicit task-id check would get most of the way.
3. The metric lands in the wrong namespace
deserializationFailures is merged into the same map as the "srcTask-destTask" byte counts, and Server.getState() publishes that whole map under "messageBytes" (Server.java:245-249). So it surfaces as __recv-iconnection.messageBytes.deserializationFailures, inside a namespace whose keys are otherwise src-dest pairs. Nothing in-tree parses those keys, but external metrics consumers do.
Put it at the top level of Server.getState() instead (ret.put("deserializationFailures", ...)) and leave getValueAndReset() returning null when size metrics are off — that keeps the existing contract untouched.
Related: emitting the key only when failures > 0 makes it appear and disappear between reporting buckets, and most backends want a stable 0. Moving it out of messageBytes lets you always emit it and solves both at once.
Also __recv-iconnection is documented at docs/Metrics.md:291; a new user-visible counter should get a line there.
Smaller things
- Import order:
com.esotericsoftware.kryo.KryoExceptionsits after thejava.*block inDeserializingConnectionCallback.javabut correctly before it in the test.CustomImportOrderis severity=warning so it won't fail the build, just inconsistent within the one PR. - The test reflectively writes the
private final ThreadLocal desfield. It works on JDK 25 — I ran it — but final-field reflection is exactly what JEP 500's integrity-by-default is aimed at, and this will become a maintenance problem. A package-private constructor taking the deserializer would outlive it. isToleratedDeserializationFailurewalks the full cause chain once per entry in the set, so nine passes per failure. Irrelevant at current volumes, and if you rate-limit the logging it stays irrelevant — mentioning it only because a single pass over the chain testinginstanceofagainst the list would be simpler to read.
Merge order
I'd like to land this before #9075. That PR's filter rejects a payload with an InvalidClassException, which on master propagates up through recv() and kills the worker — turning an RCE into a remote worker-kill. InvalidClassException extends IOException, so with this change in first, a filtered payload is dropped and counted instead. The two compose nicely in that order.
|
Thanks for the careful review, and for independently checking the two spots I was most concerned about: the Kryo reset on a mid-stream failure and the MessageDecoder path. Your read matches what I found, so it's good to have it confirmed. On the numbered points:
Smaller things, all accepted: import order fixed to match the test file, the reflective write to the ThreadLocal replaced by a package-private constructor taking the deserializer, and the tolerance check rewritten as a single pass over the cause chain testing instanceof against the list. Merge order: agreed. This PR stays independent of #9075, and I'll rebase #9075 onto it after it lands. Thanks also for triggering the workflows. |
…erver
A topology stuck receiving poison payloads would flood the worker log with
one ERROR per dropped message. recv() now logs the first 10 failures
individually, then one summary ERROR per 100 further failures carrying the
running total, in the WorkerState "Total Drop Count= {}" style. 1000
consecutive failures without a success log a single WARN pointing at a
persistent fault; any successful deserialization resets that counter.
NullPointerException stays outside the tolerated set: it usually signals a
bug rather than a malformed payload. The case a bad tuple could trigger,
an unknown source task, is rejected up front in KryoTupleDeserializer
with IllegalArgumentException naming the task; that lookup NPEd during
stream resolution before this change.
Server.getState() publishes deserializationFailures as a top-level key,
always present, including when it is 0, read through
getAndResetDeserializationFailures() on the callback. getValueAndReset()
reports only the size metrics, null when they are disabled.
isToleratedDeserializationFailure walks the exception cause chain once and
checks every tolerated type per frame, instead of once per type.
Tests inject a replacement deserializer through a package-private setter,
and a new ServerTest covers the top-level key.
apache#9074
|
The revisions are pushed. Drop logging is now rate-limited: the first 10 failures log individually, then one NullPointerException is out of the tolerated set, and KryoTupleDeserializer rejects deserializationFailures is a top-level key of __recv-iconnection now, always present The full storm-client suite is green locally (680 tests) with no new checkstyle |
|
Note: drafted with the help of an LLM. First of all, thank you for this submission. It's a great addition. Mostly additions to rzo1's review:
Thanks for the careful work. |
…agate Tuple addressing and the metrics update run after the tuple has decoded; a failure there is not a deserialization failure and must not drop a decoded tuple or inflate the count.
|
@reiabreu Thanks for this review, and especially for working through the two interaction cases I had not pushed to the end, the allocation path and the at-least-once spout behavior. Both are now stated in the description as limitations rather than left implicit. The large-positive-length OOM: agreed, and agreed that catching Error is not an answer. The description now carries it as a limitation, unchanged from master. The bounding work, kryo input-side limits and the maxarray limit the fallback bridge gets in #9075, is where the actual fix belongs; I have it on the follow-up list rather than in this PR. The spout interaction: agreed with the mechanism as you describe it, and the description now states it. One addition worth making explicit: on master the same deterministically undecodable message does not stall the partition quietly. It kills the worker, the supervisor restarts it, the spout re-emits after the tuple times out, and it kills it again, a crash loop that also never commits the offset. So this PR trades a loud crash loop for a quiet stall, which is the same trade it makes everywhere else, and the real fix, an eventual permanent drop or dead-letter so the offset can advance, is the second follow-up I want to file. The generic-type concern and the TupleDeserializationException sketch: I agree with the concern, and I want to push back on one boundary in the sketch. In kryo 5.6.2, when registration is required and a name reference resolves to a class with no registration, the IllegalArgumentException ("Class is not registered: ...") is thrown directly by Kryo.getRegistration(Class), not wrapped in a KryoException. DefaultClassResolver wraps only the unknown-class-id and name-lookup failures. That raw IllegalArgumentException is exactly what the 27-byte frame in the description produces on the live cluster (Storm 3.x requires registration unless the java serialization fallback is enabled), and it is the vector #9074 was filed on. With the sketch leaving IllegalArgumentException unwrapped by design, that frame goes back to killing the worker, which reopens the remote worker-kill this PR closes and also undercuts the compose-with-#9075 ordering rzo1 described, since a payload the serial filter rejects with an InvalidClassException needs the drop path to survive. So the trade I would rather take: keep IllegalArgumentException tolerated in this PR, accept that a user serializer bug that raises one of the generic types is dropped and counted rather than loud, and keep the signal that is already built in, the WARN at 1000 consecutive failures and the always-present counter. Then do the exception-type redesign properly in a follow-up issue where the wrap boundary is the actual design question. Your sketch's structural validation and single intentional exception type are the right shape, and the problem you flag, that kryo.deserializeFrom runs the framework decode and user serializers with nothing between them, is exactly why I do not want to rush that boundary inside this review cycle. I will file that issue and link it here; it can also carry a strict mode that turns drops back into fatal errors if there is appetite for such a knob. The minor points are both accepted and pushed. The try in recv() now covers des.deserialize only, so updateMetrics and the batch add run outside it and a post-decode failure propagates without being counted; testPostDecodeFailurePropagatesAndIsNotCounted pins this by throwing a tolerated-type exception from updateMetrics and asserting the exception escapes with the failure counter at zero. I have also rewritten the description to match the code: 8 types with no NullPointerException, the rate-limited logging, and the top-level deserializationFailures key. The full storm-client suite is green locally (662 tests) with no checkstyle findings. |
|
Thanks for the contribution @L1nq0 and the reviews. |
Closes #9074
Upgrade note: a malformed message body no longer kills the receiving worker, including the IOException case. Previously a channel-level IOException closed the connection and lost every message still queued on it; now the one undecodable message is dropped and the connection, along with the rest of the batch, keeps going. Payloads that used to tear down a connection mid-stream will instead show up as deserializationFailures counts, rate-limited ERROR logs, and a WARN when failures persist.
What this changes
DeserializingConnectionCallback.recv() wraps only the deserialization of each message in a try/catch. When the failure matches a known decode-time exception type, the message is dropped and the loop continues with the next message in the batch. Tuple addressing and metrics updates run after the try, so a failure there propagates rather than dropping a validly decoded tuple or inflating the count. Anything outside the tolerated set is rethrown and keeps today's behavior: StormServerHandler.exceptionCaught, then Utils.handleUncaughtException, then worker exit. Errors are never caught.
The tolerated set is the set of exception types a garbage or hostile byte stream can already produce during tuple decode, matched by walking each failure's cause chain:
IOException, KryoException, IllegalArgumentException, NegativeArraySizeException, ClassCastException, ArrayIndexOutOfBoundsException, BufferUnderflowException, ClassNotFoundException
Mapping to wire-level failure modes: truncated or negative length fields (ArrayIndexOutOfBoundsException, NegativeArraySizeException, BufferUnderflowException), unregistered classes under topology.kryo.register with registration required (IllegalArgumentException, thrown directly by Kryo.getRegistration when a name reference resolves to a class with no registration), classes present only on the sending side (ClassNotFoundException), wrong runtime types (ClassCastException), and kryo's own decode failures (KryoException) plus underlying stream problems (IOException). A tuple whose source task id does not exist in the topology is rejected up front by KryoTupleDeserializer with an IllegalArgumentException naming the task, instead of coming out of the stream lookup as a NullPointerException.
I did not broaden StormServerHandler.ALLOWED_EXCEPTIONS. That whitelist is about transport-level channel errors and closing the connection; a payload that fails to decode is an application-level event, and handling it where the decode happens is what keeps the rest of the batch alive. The whitelist stays as the backstop for anything that still escapes.
Observability
Drop logging is rate-limited. The first 10 failures are logged in full; after that, one summary line per 100 further failures carries the running total. A run of 1000 consecutive failures without a successful deserialization additionally logs a WARN, and the consecutive count resets on the next success. This keeps a hostile peer from filling the log volume, and keeps a real problem such as a class missing from the worker classpath visible as a WARN rather than silent no-progress with a stack trace per tuple. Failures are also counted in a deserializationFailures counter published as a top-level key of __recv-iconnection through Server.getState(), always present including 0, and documented in docs/Metrics.md.
What this does not fix
A frame declaring a huge positive array or collection length can still kill the worker: kryo pre-allocates from the length field, the allocation fails with an OutOfMemoryError, and recv() catches Exception, not Error. Catching Error would be wrong. NegativeArraySizeException, the wrap-around case, is tolerated; the large-positive case is not, and this is unchanged from master. Bounding allocations on the kryo path, along the lines of the maxarray and maxbytes limits the java-serialization fallback bridge gets in #9075, is follow-up work.
Interaction with at-least-once spouts
A message that deterministically fails to decode is dropped but never acked. With the kafka spout in at-least-once mode the offset never commits, uncommitted offsets on that partition climb to maxUncommittedOffsets (default 10,000,000), and the spout stops polling the partition, so the topology makes no progress on it and the symptom is climbing lag. On master the same input produces a worker crash loop that also never commits the offset. The real fix is an eventual permanent drop or dead-letter so the offset can advance; that is follow-up work as well.
Tests
8 cases in DeserializingConnectionCallbackTest, driving recv() through the real deserializer where possible: one tolerant drop per interesting exception type (verify log, counter, and batch continuation), an IOException mid-batch that skips exactly the bad message while a good message later in the same batch still lands, rethrow of a non-tolerated exception, a failure thrown after a successful decode propagating without being dropped or counted as a deserialization failure, and the failure counter staying separate from the size metrics and resetting after a read.
Also verified on a live 3.0.1-SNAPSHOT cluster: replaying a 27-byte frame carrying an unregistered class against a worker port kills the worker on master (terminating server, then process exit and supervisor restart) and on this branch produces the log line "Failed to deserialize a message of 27 bytes destined for task 2, dropping it" with the worker surviving and the topology staying active.