Skip to content

IGNITE-27977 Refactor bytes serialization for DataStreamerRequest - #13454

Merged
anton-vinogradov merged 38 commits into
apache:masterfrom
anton-vinogradov:ignite-27977
Aug 10, 2026
Merged

IGNITE-27977 Refactor bytes serialization for DataStreamerRequest#13454
anton-vinogradov merged 38 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.

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 updater travels in DataStreamerReceiverMessage, which carries it in whichever form
suits it — the same way GridTopicMessage holds either the ordinal of a known topic or a
serialized arbitrary one:

  • an updater the streamer ships with is named: DataStreamerBuiltInUpdater covers
    ISOLATED, INDIVIDUAL, BATCHED and BATCHED_SORTED, and the receiving node uses its
    own instance. Every node has these classes, so serializing one into each request only to
    deserialize an identical copy was pure waste;
  • a user updater is carried as an @Marshalled pair of the object and its bytes.

The carrier is marked @UseBinaryMarshaller: it is the one holding a user class, and the
hand-written call used ctx.marshaller() — the very marshaller that annotation selects, so
the bytes are produced exactly as before. DataStreamProcessor reads the message through
MessageMarshalling.unmarshal and drops its own Marshaller field.

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.

Marshalling a user updater once

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, so the streamer keeps one carrier per receiver and the generated
marshaller fills its bytes once. Nothing is allocated for the built-in updaters at all:
their carriers are constants of the enum, so flipping allowOverwrite back and forth
allocates nothing, and a streamer that never touches the setting builds no carrier either.

DataStreamerImplSelfTest covers all three: testReceiverMarshalledOncePerStreamer (every
request carries the same bytes), testBuiltInUpdaterIsNotSent (the default receiver is
named, and the data still lands) and testBuiltInReceiverIsNotSent (same for one set
explicitly).

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. The carrier itself has two fields: the
serialized user updater, and the name of a built-in one.

No client sees this. DataStreamerRequest travels between Java nodes only: the thin client
has its own ClientDataStreamerRequest with its own protocol, and the platform clients go
through the embedded node. Within 2.19 the layout of this message has already been reshaped
three times — IGNITE-27862, IGNITE-27824, IGNITE-28719 — and IGNITE-28528 reshaped it again.
IGNITE-27824 is the same move on the same class: it replaced resTopicBytes, the other
hand-marshalled blob here, with a nested message, and the TODO naming this ticket sat on
that very field.

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.
    Its bytes are volatile for the same reason: the batch that marshals first writes them
    and the others read them.
  • 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.
  • allowOverwrite is the receiver choice it always was: the getter compares against the
    isolated updater, the setter goes through receiver(...), and the field is empty until
    someone sets one, so "nothing set means isolated" is stated once rather than assigned up
    front.
  • 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

129 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>
@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.

* skip the marshalling and send a half-written array.
*/
@Order(0)
volatile byte[] rcvrBytes;

@Vladsz83 Vladsz83 Aug 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should StreamReceiverMessage be a MarshallableMessage?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think so: the marker means the opposite of what this class does. MarshallableMessage declares marshal(Marshaller) and unmarshal(Marshaller, ClassLoader) — it says "I carry a hand-written marshalling step, call it", and the generated marshaller does exactly that, on top of the fields. Implementing it here would mean writing that step by hand again, which is what this ticket removes.

StreamReceiverMessage has no such step: the @Marshalled pair is what makes the generator produce one. The other @Marshalled messages are the same — GridEventStorageRequest, GridJobExecuteRequest, StartRequestData, GenericValueMessage — none of them implements the interface.

If what you had in mind is the side effects the marker brings — the marshaller becoming mandatory at registration, and MessageUnmarshalOnceCheck covering the message — those would apply to every @Marshalled message equally, so it reads as a codegen-level decision rather than a property of this class. Happy to file it separately if you think the check should cover them.

StreamReceiver<?, ?> rcvr;

/**
* Serialized {@link #rcvr}, written by whichever batch is marshalled first and read by the rest. Those batches

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Serialized {@link #rcvr} is enough

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Cut it down, but kept one clause for the volatile:

/** Serialized {@link #rcvr}. Volatile: the batches sharing it are marshalled on different threads. */

Without it the modifier reads as unexplained — the field is written by whichever batch is marshalled first and read by the others, and dropping volatile would let a reader see the reference before the contents. If you would rather keep the javadoc to Serialized {@link #rcvr} and leave the reasoning to the reviewer of the modifier, say so and I will trim it.

anton-vinogradov and others added 2 commits August 10, 2026 14:42
# Conflicts:
#	modules/core/src/main/java/org/apache/ignite/internal/processors/datastreamer/DataStreamerRequest.java
- StreamReceiverMessage -> DataStreamerReceiverMessage, so the name says
  which streamer it belongs to.
- Drop the claim that the updater is a user class: it may as well be one
  of the updaters Ignite ships.
- Shorten the carrier javadoc and stop explaining the data streamer in it.
- Say "cache updater" for the request field, and mark the getter @nullable.
- Read rcvrMsg directly instead of through a local.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
anton-vinogradov and others added 4 commits August 10, 2026 16:45
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It ships with every node, so serializing it into each request only to
deserialize an identical instance on the other side is pure waste - the
receiving node can use its own. Requests now carry no updater at all in
that case, and the processor falls back to the local ISOLATED_UPDATER.

The marshal-once test needed a receiver that actually travels, and both
streamer tests now wait for the partition map, since with allowOverwrite
a batch only goes to the primary and an unfinished exchange keeps every
primary local.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since the isolated updater is no longer sent, wrapping it into a carrier
served nothing: null now means the same thing in the field as it does on
the wire - the updater the node ships with. Which also spares the default
streamer an object it never uses.

The field is read once per request, so the request and its stripe agree
on the receiver even if it is replaced mid-stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
anton-vinogradov and others added 3 commits August 10, 2026 17:42
Why the read happens in the processor is what DeferredUnmarshalMessage
documents; what a null updater means now sits on the getter and the field
instead of at the call site; and locals named xxx0 are the idiom of this
class already (jobPda0 sits two lines above). The rest is shortened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
isolated() reads as what it tests, so the places asking "is the default
receiver in use" no longer need a note explaining that null means
ISOLATED_UPDATER. allowOverwrite() is now its negation, which is what it
always was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
anton-vinogradov and others added 8 commits August 10, 2026 18:27
The isolated one was only half the story: individual, batched and
batchedSorted ship with every node too, yet each request serialized one
of them and the receiving node deserialized an identical copy.

A request now names the built-in updater it wants and carries a serialized
one only when it belongs to the user. DataStreamerBuiltInUpdater is that
name, and it maps back to the local instance on the other side.

The marshal-once test needed a receiver of its own, since the built-in one
it used no longer travels, and a new test covers a built-in receiver set
explicitly rather than by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The caller was choosing between a copy and a name and explaining that
choice in a comment. The request now takes the receiver and stores it the
way it travels best: as a name when the streamer ships with it, as a
serialized copy otherwise. The comment and the invariant assert went away
with the choice.

Also narrowed DataStreamerBuiltInUpdater to package-private and named the
check the processor makes on the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flag was a third way of saying which receiver is in use: the getter
compared against ISOLATED_UPDATER through a private helper, the setter
built the carrier by hand, and the warning site asked the helper. All
three now go through receiver(), so the state has a single owner and the
flag reads as what it means - "the receiver is not the Isolated one".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The updaters the streamer ships with never change, so their messages are
built once, with the enum constant that names them. Setting a receiver
picks the matching one, and only a user receiver gets a message of its
own - switching allowOverwrite back and forth allocates nothing, and the
initial state is one of the constants rather than a special case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ssage does

The request held the carrier and the name in two fields; now the carrier
holds both, the way GridTopicMessage keeps either a topic ordinal or a
serialized topic. The request is back to one field, and asking "did the
receiver travel or was it named" is a question to the carrier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The field no longer starts out holding the Isolated updater. It is empty
until someone sets a receiver, and the rule "nothing set means Isolated"
lives in one place, where the receiver is read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The carrier has two constructors and the invariant between them lived
only in the caller. Now the user one states it, and a receiver that
should have travelled by name fails where the mistake is made rather
than silently costing a serialization per batch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both the updater a constant stands for and its message are built on
demand now: a streamer that keeps the default receiver never loads
DataStreamerCacheUpdaters, and a node that does not stream builds neither.

Wording follows the review: "built-in" for the updaters every node has -
only the Isolated one belongs to the streamer, the rest are external to
it - and "custom" for the user's, so user() became custom() and
hasUserUpdater() became hasCustomUpdater(). Both nullable fields of the
carrier are marked, and the warning site says which updater it is about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
anton-vinogradov and others added 2 commits August 10, 2026 20:27
The two built-in tests differed only in how the receiver was set, so they
became one loop over DataStreamerBuiltInUpdater.values(). What the SPI
collects is now the set of distinct updaters that left the node - the
serialized bytes, or the constant - so a test states its expectation as
one equality instead of walking a list.

The collection is a test instance field, the helper moved below the tests
that use it and says why it waits for the partition map, and it is named
startGridsAndStream, since that is what it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The updaters are singletons that already exist, so resolving them through
a supplier bought nothing and cost a level of indirection. Reverted to
holding them directly; the message naming a constant is still built on
first use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
anton-vinogradov and others added 2 commits August 10, 2026 20:38
@nullable now sits on the field declarations, including the bytes; the
javadocs say built-in/custom and sent/named everywhere; and the boolean
getter is customUpdater(), like skipStore() and keepBinary() next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A constant hands out a new message instead of caching one, so there is no
shared field to publish and no race to reason about. The streamer keeps
the message it builds for a receiver, so the only allocation left is one
per batch while the default receiver is in use - reading the receiver
itself no longer goes through a message at all, since allowOverwrite() is
asked once per entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wording is custom/built-in and sent-as-a-name everywhere, in the carrier,
the streamer and the tests. The streamer field says what null means and
is marked, and the serialized bytes say when they are absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ISOLATED(DataStreamerImpl.ISOLATED_UPDATER),

/** {@link DataStreamerCacheUpdaters#individual()}. */
INDIVIDUAL(DataStreamerCacheUpdaters.individual()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need to keep instances in DataStreamerCacheUpdaters? Like DataStreamerCacheUpdaters#BATCHED_SORTED is in so nessesary? Maybe we should simplify code and return new BatcherSorted() (and others) and used the on demant as the message. WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch on the coupling — the lookup no longer depends on it:

// Matches by class, so an updater built anew is still recognized as built-in: these updaters hold no state.
if (builtIn.updater.getClass() == updater.getClass())

So whatever DataStreamerCacheUpdaters decides to do with its instances, a built-in updater keeps travelling as a name.

On the instances themselves I would leave them as they are, and not in this ticket:

  • they are handed out by public factories, so individual() and friends returning a new object each time changes what callers get — anyone comparing receivers, ours included until this commit, would notice;
  • a receiver is typically set once per streamer, but nothing stops a caller from doing it per streamer in a loop, and allocating a stateless object for that is a step back;
  • it is unrelated to what this ticket does, and the gain is three objects per JVM.

Happy to file it separately if you think the factories should change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The only spot I see is merging somehow DataStreamerBuiltInUpdater and DataStreamerCacheUpdaters. The look too similarly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They do sit close, but they answer different questions, and I am not sure which way you want them merged — so let me lay out what I see.

DataStreamerCacheUpdaters is public API: factories a user calls to pick an updater, plus the implementations behind them. DataStreamerBuiltInUpdater is a wire-level registry: it exists so a request can name an updater instead of sending it, and it has to cover the Isolated one too, which lives in DataStreamerImpl and is not part of that public class.

So a full merge would mean moving IsolatedUpdater out of DataStreamerImpl into the public class — a bigger change than this ticket, and one that touches what the class means.

What is cheap is nesting: make the enum DataStreamerCacheUpdaters.BuiltIn, package-private, so there is one top-level name instead of two similar ones. It still points at DataStreamerImpl.ISOLATED_UPDATER for that constant.

Tell me if the nested variant is what you meant and I will do it; if you had the full merge in mind, I would rather file it separately.

These updaters hold no state, so any instance of one is that updater. The
lookup no longer depends on DataStreamerCacheUpdaters handing out the same
object every time, which is a decision that class should be free to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
needStaleTop is static already, so the SPI stays a static class and the
collected updaters go back to a static field, named as one.

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

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/DataStreamerReceiverMessage.java
  • modules/core/src/main/java/org/apache/ignite/internal/processors/datastreamer/DataStreamerRequest.java

@anton-vinogradov
anton-vinogradov merged commit 5415973 into apache:master Aug 10, 2026
8 checks passed
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.

2 participants