Skip to content

IGNITE-27977 Refactor bytes serialization for DataStreamerRequest - #13454

Open
anton-vinogradov wants to merge 10 commits into
apache:masterfrom
anton-vinogradov:ignite-27977
Open

IGNITE-27977 Refactor bytes serialization for DataStreamerRequest#13454
anton-vinogradov wants to merge 10 commits into
apache:masterfrom
anton-vinogradov:ignite-27977

Conversation

@anton-vinogradov

@anton-vinogradov anton-vinogradov commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

DataStreamerRequest.updaterBytes was a hand-marshalled blob: DataStreamerImpl produced
the bytes while building the request, and DataStreamProcessor unmarshalled them with a
class loader it had just resolved.

Carrying the receiver is not new here. StreamReceiver extends Serializable because it
runs on the node that owns the data, and its bytes already travelled in this very message,
in every batch. What changes is who marshals them.

Why this matters

A field that marshals itself picks its own marshaller, so it stays outside whatever the
transport decides — which is what IGNITE-28940 has to decide in one place. The generated
marshaller is that place. updaterBytes was the last such blob on this path.

Change

The receiver travels in StreamReceiverMessage: an @Marshalled pair of the user object
and its bytes, marked @UseBinaryMarshaller since it is the one holding a user class. The
hand-written call used ctx.marshaller() — the very marshaller that annotation selects
(IgniteKernal passes it as the schema-aware one), so the bytes are produced as before.

Putting the two in one carrier is what keeps the object and its serialized form on the same
lifetime. A request is one batch; the receiver lives for the whole stream. The streamer
holds one carrier and puts it into every request, so the generated marshaller fills the
bytes for the first batch and the rest find them in place. Replacing the receiver builds
another carrier, which invalidates the old bytes by construction — there is no cache to
reset, and no window where a reset races with a send.

DataStreamProcessor reads the message through MessageMarshalling.unmarshal and drops its
own Marshaller field.

Why not marshal per batch

Measured on one node: 281 ns to marshal IsolatedUpdater (12 B), 810 ns for
StreamTransformer.from(ep) (299 B), 9.3 µs for a receiver holding 10K of state (10270 B),
against 76 µs to build and marshal a batch of 512 entries. Marshalling per batch would cost
up to 12% of a full batch and a larger share of a small one, so the saving the old cache
gave is kept — it just no longer picks the marshaller.

DataStreamerImplSelfTest#testReceiverMarshalledOncePerStreamer pins this down: it collects
the carrier of every request that leaves through the communication SPI, where the message is
already marshalled, and requires all of them to share one byte array. Building a carrier per
request makes it fail.

Why the read stays with the consumer

The message remains a DeferredUnmarshalMessage. Its class loader does not follow from a
carried deployment alone: with forced local deployment it is the grid class loader, and
otherwise it comes from the global deployment of the sender. So the processor passes the
loader explicitly and keeps the read inside its existing try, where a missing deployment
is reported back to the sender instead of leaving it waiting for a timeout.

Wire format

Field 3 of the request becomes a nested message instead of a byte array, and the carrier
takes the next free id in the datastreamer group.

Along the way

  • The receiver field of the streamer was mutated from the user thread and read by the
    sending ones without being volatile; holding it in one carrier fixes that publication.
    The bytes inside the carrier are volatile for the same reason: the batch that marshals
    first writes them, the others read them, and a reader seeing the reference before the
    contents would skip the marshalling and send a half-written array.
  • The serialized receiver used to be cached without ever being invalidated, so a receiver
    replaced mid-stream took effect locally, where the live field is read, but not remotely,
    where the stale bytes kept going. Both paths now see the same object.
  • The carrier is @GridToStringExclude in the request: it holds a user object, the request
    is printed under debug logging on the sending side, and GridToStringBuilder rethrows
    whatever a field toString throws. GridJobExecuteRequest excludes its user objects the
    same way.

What this changes for the entries

Reading the message reads all of it, so entries now pass through the generated marshaller
on the receiving side too, with the cache object context resolved from cacheId and the
deployment class loader. DataStreamerUpdateJob still unmarshals them once more under the
global loader; that pass is now a no-op, since CacheObject.unmarshal only acts when the
value is absent. It is kept because the job also runs on the local path, where no message
was ever unmarshalled, and because the same loop carries the security permission checks.

The streamer also still marshals keys and values by hand before sending. That is not a
duplicate of the generated marshal: it uses the cache object context the streamer holds from
its creation, whereas the generated code resolves the context by cacheId and would skip the
work if the cache were destroyed in between.

Verified

123 tests, no failures: the whole processors.datastreamer package — with
IgniteDataStreamerPerformanceTest left out as an endless benchmark that always times out —
plus P2PStreamingClassLoaderTest, P2PClassLoadingFailureHandlingTest,
GridP2PContinuousDeploymentSelfTest, ClassLoadingProblemExceptionTest,
MessageMarshalOnceTest, IgniteCoreMessagesSerializationTest,
DirectMarshallingMessagesTest and MessageProcessorTest. Also
mvn checkstyle:check -Pcheckstyle -pl modules/core.

🤖 Generated with Claude Code

The stream receiver was marshalled by hand: the streamer produced the
blob, and the processor unmarshalled it with a class loader it had just
built. The pair is now an @Marshalled field, so the generated marshaller
owns both directions.

The class is marked @UseBinaryMarshaller: the receiver is a user class,
and the hand-written call used ctx.marshaller(), which is the same
schema-aware marshaller the annotation selects. The wire format is
unchanged - updaterBytes stays @order(3).

The message stays a DeferredUnmarshalMessage. Its class loader does not
come from a carried deployment alone: with forced local deployment it is
the grid class loader. The processor therefore passes the loader
explicitly and keeps the read inside its own try, so a missing
deployment is still answered to the sender instead of leaving it waiting
for a timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
anton-vinogradov and others added 9 commits August 9, 2026 03:15
The class javadoc and the comment at the read said the same thing twice.
The javadoc now states what the message is, and the comment states why
the read waits for this point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The field holds a user object, and the request is printed on the sending
side under debug logging. The blob it replaced printed as bytes, and
GridToStringBuilder rethrows whatever a field toString throws, so a
user toString could now break the logging path. GridJobExecuteRequest
excludes its user objects the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Handing the message the receiver instead of its bytes cost a marshal per
batch, where the streamer used to marshal once and reuse the result.
Measured on one node: 281 ns for IsolatedUpdater, 810 ns for
StreamTransformer.from(ep), 9.3 us for a receiver holding 10K of state,
against 76 us to build and marshal a batch of 512 entries - up to 12% of
a batch, and a larger share of a small one.

The streamer keeps the bytes the generated marshaller produced for the
first request and hands them to the next one, which the marshaller then
keeps instead of producing its own. It reuses a result rather than
deciding how to obtain it, so the marshaller stays the one codegen picks.

The bytes are paired with the receiver they belong to, so a receiver
replaced mid-stream invalidates them by itself - no separate cache reset
that a concurrent send could race with. This also closes the older
mismatch, where the cache was never invalidated at all and a replaced
receiver took effect locally but not remotely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Holding the receiver in the request meant its serialized form belonged to
the request, while the object belonged to the streamer. A request is one
batch, the receiver lives for the whole stream, so keeping the bytes cost
either a marshal per batch or a cache beside the streamer - a cache that
had to be invalidated by hand and published safely.

The receiver now travels in StreamReceiverMessage, where the object and
its bytes sit together and live exactly as long as the receiver does. The
streamer holds one instance and puts it into every request, so the
generated marshaller fills the bytes for the first batch and the rest
find them already there. Replacing the receiver builds another instance,
which invalidates the old bytes by construction.

This also removes an older race: the receiver field was mutated from the
user thread and read by the sending ones without being volatile.

The wire format of the request changes: field 3 is now a nested message
rather than a byte array.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The streamer exposes receiver(StreamReceiver) yet read the field back
through rcvr(), and the request named its carrier field after what the
getter returns rather than after what it holds. Paired accessors in these
classes share a name - allowOverwrite(), skipStore(), keepBinary() - so
the getter is receiver() now, and the carrier is updaterMsg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The field is written by the batch that is marshalled first and read by
the rest, and those batches leave on different threads. Without the
keyword a reader could see the reference before the contents, skip the
marshalling and send a half-written array.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The collection was held in a local only because the test cleared the
static field in a finally block, before the assertions ran. Clearing it
in afterTest, next to the other static cleanup of this class, removes
both the local and the try/finally.

The local inside the SPI stays and is now explained: it reads the
volatile field once, since the field is cleared while nodes that are
still stopping keep sending through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clearing after the test made the field nullable, which cost a null check
and a local copy in the SPI, and my comment there claimed a race that did
not exist - afterTest clears the field once the grids are already
stopped. A final collection cleared in beforeTest gives each test the
same clean start with none of that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read the receiver once where the deployment aware is built, instead of
calling the getter three times in a row; keep the explicit type argument
on individual() that the rewrite had dropped; unwrap the sent message
once in the test SPI; and say in the request javadoc that the excluded
field carries a user object rather than being one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

Possible compatibility issues. Please, check rolling upgrade cases

This PR modifies protected classes (with Order annotation).
Changes to these classes can break rolling upgrade compatibility.

Affected files:

  • modules/core/src/main/java/org/apache/ignite/internal/processors/datastreamer/DataStreamerRequest.java
  • modules/core/src/main/java/org/apache/ignite/internal/processors/datastreamer/StreamReceiverMessage.java

@anton-vinogradov

anton-vinogradov commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

/runall


🚀 RunAll queuedbuild 9267111 · live progress & verdict: Ignite PR Checker. The verdict lands here when the run finishes.
🏁 Run finished — the verdict comment has the full story.

@anton-vinogradov

Copy link
Copy Markdown
Contributor Author

Ignite PR Checker verdict · RunAll build 9267111 · 147 suites ran, 0 reused

⚠️ This run doesn't cover the PR fully:

  • a newer run is still going — its unfinished suites can still fail

Everything below is what it did manage to say.

🔎 No blockers found — but the run above can't prove the PR is clean. 16 pre-existing/flaky tests filtered out. Re-run once the above is sorted out.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant