IGNITE-27977 Refactor bytes serialization for DataStreamerRequest - #13454
Conversation
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>
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>
|
/runall 🚀 RunAll queued — build 9267111 · live progress & verdict: Ignite PR Checker. The verdict lands here when the run finishes. |
|
Ignite PR Checker verdict · RunAll build 9267111 · 147 suites ran, 0 reused
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; |
There was a problem hiding this comment.
Should StreamReceiverMessage be a MarshallableMessage?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Serialized {@link #rcvr} is enough
There was a problem hiding this comment.
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.
# 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>
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>
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>
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>
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>
@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()), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
The only spot I see is merging somehow DataStreamerBuiltInUpdater and DataStreamerCacheUpdaters. The look too similarly.
There was a problem hiding this comment.
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>
Possible compatibility issues. Please, check rolling upgrade casesThis PR modifies protected classes (with Order annotation). Affected files:
|
DataStreamerRequest.updaterByteswas a hand-marshalled blob:DataStreamerImplproducedthe bytes while building the request, and
DataStreamProcessorunmarshalled them with aclass 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.
updaterByteswas the last such blob on this path.Change
The updater travels in
DataStreamerReceiverMessage, which carries it in whichever formsuits it — the same way
GridTopicMessageholds either the ordinal of a known topic or aserialized arbitrary one:
DataStreamerBuiltInUpdatercoversISOLATED,INDIVIDUAL,BATCHEDandBATCHED_SORTED, and the receiving node uses itsown instance. Every node has these classes, so serializing one into each request only to
deserialize an identical copy was pure waste;
@Marshalledpair of the object and its bytes.The carrier is marked
@UseBinaryMarshaller: it is the one holding a user class, and thehand-written call used
ctx.marshaller()— the very marshaller that annotation selects, sothe bytes are produced exactly as before.
DataStreamProcessorreads the message throughMessageMarshalling.unmarshaland drops its ownMarshallerfield.Why the read stays with the consumer
The message remains a
DeferredUnmarshalMessage. Its class loader does not follow from acarried 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 deploymentis 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 forStreamTransformer.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
allowOverwriteback and forthallocates nothing, and a streamer that never touches the setting builds no carrier either.
DataStreamerImplSelfTestcovers all three:testReceiverMarshalledOncePerStreamer(everyrequest carries the same bytes),
testBuiltInUpdaterIsNotSent(the default receiver isnamed, and the data still lands) and
testBuiltInReceiverIsNotSent(same for one setexplicitly).
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.
DataStreamerRequesttravels between Java nodes only: the thin clienthas its own
ClientDataStreamerRequestwith its own protocol, and the platform clients gothrough 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 otherhand-marshalled blob here, with a nested message, and the TODO naming this ticket sat on
that very field.
Along the way
sending ones without being
volatile; holding it in one carrier fixes that publication.Its bytes are
volatilefor the same reason: the batch that marshals first writes themand the others read them.
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.
allowOverwriteis the receiver choice it always was: the getter compares against theisolated updater, the setter goes through
receiver(...), and the field is empty untilsomeone sets one, so "nothing set means isolated" is stated once rather than assigned up
front.
@GridToStringExcludein the request: it holds a user object, the requestis printed under debug logging on the sending side, and
GridToStringBuilderrethrowswhatever a field
toStringthrows.GridJobExecuteRequestexcludes its user objects thesame way.
What this changes for the entries
Reading the message reads all of it, so
entriesnow pass through the generated marshalleron the receiving side too, with the cache object context resolved from
cacheIdand thedeployment class loader.
DataStreamerUpdateJobstill unmarshals them once more under theglobal loader; that pass is now a no-op, since
CacheObject.unmarshalonly acts when thevalue 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
cacheIdand would skip thework if the cache were destroyed in between.
Verified
129 tests, no failures: the whole
processors.datastreamerpackage — withIgniteDataStreamerPerformanceTestleft out as an endless benchmark that always times out —plus
P2PStreamingClassLoaderTest,P2PClassLoadingFailureHandlingTest,GridP2PContinuousDeploymentSelfTest,ClassLoadingProblemExceptionTest,MessageMarshalOnceTest,IgniteCoreMessagesSerializationTest,DirectMarshallingMessagesTestandMessageProcessorTest. Alsomvn checkstyle:check -Pcheckstyle -pl modules/core.🤖 Generated with Claude Code