Motivation
Ignite use a few protocols of inter-node message exchange:
- Communication protocol
- Discovery protocol
All protocols have own serialization mechanisms and doesn't support message exchange with node of another version. For making Rolling upgrade feature possible we must make these protocols compatible between Ignite versions.
Prerequisites
- Communication protocol is peer-to-peer protocol: every message is sent between one node-sender and one node-receiver.
- Communication protocol is an internal protocol used for system messages exchange between Ignite nodes:
- There is a limited amount of peers, and all peers are aware of versions of each other.
- All peers are aware of all possible messages and their schemas.
- At most time peers are of same version. In short period of time (during RU) messags schemas might differ, but not much (few messages might differ with few fields only).
- It is proposed to provide , for example between 2.20.X and 2.19.X (but not between 2.20.X and 2.18.X).
- Messages can be pretty big, contains cache entries (putAll, historic rebalance).
- Users applications deployed on cluster (services, etc) must support compatibility by itself.
Current implementation
Communication protocol
- All peers hold predefined
- Messages follow schema in strict way - no fields are skipped and order of fields is guaranteed.
- Peers send messages as byte stream serializing fields one by one, no delimiters are used between fields and messages.
- For serializing POJO fields different techniques are used:
- Message consumes already serialized byte array (e.g. JdkMarshaller for IgniteDiagnosticMessage, SchemaOperationStatusMessage... BinaryMarshaller for GridJobExecuteRequest#jobBytes).
- User's BinaryObjects, Binarylizable are marshalled to byte array prior to serializing a message (the array is stored in cache structures).
- Some messages contain POJO field annotated with GridDirectTransient, and additional byte array field fill with GridCacheMessage#prepareMarshal (e.g. BinaryMarshaller for GridChangeGlobalStateMessageResponse).
- Some messages contains POJO that implements Message interface.
Discovery protocol
- All peers hold predefined messages schemas (described with java classes).
- Peers use JdkMarshaller (java serialization) to serialize and deserialize full message. Borders between messages are controlled by java serialization.
- Responses with status code or ResponseMessage.
Management command protocol
There is an additional serialization protocol between control.sh (thin client) and server nodes
- Management operates compute tasks for sending commands.
- Compute args are serialized with IgniteDataTransferObject (that is actually simple compacted Externalizable - fields without java staff).
This protocol is out of scope of this proposal.
Proposed changes
Validate joining node version
The joining node version must be checked versus actual Ignite versions in a cluster (within OnDiscoveryNodeValidationProcessor):
- If a cluster contains nodes of only version, then the joining node can be greater/less than this version by up to 1 minor version.
- If a cluster contains nodes of two versions, then joining node version must be one of them.
Message serialization framework
There should be one serialization framework for communication and discovery protocol.
MessageWriter, MessageReader logic is depends on a remote IgniteProductVersion.Message#writeTo, Message#readFrom is auto-generated and stored separately from Message classes.- Add
Message#writeObject - (java functions, and user objects) with BinaryMarshaller. Message fields may contain:- primitive classes.
- known collections.
- others
Message. - Users classes and java functions (e.g. ComputeJob).
- byte[] fields (objects serialized externally) must be avoided as much as possible, because their compatibility can't be guaranteed.
- Order of fields in Message is fixed with
@Order annotation. - Adding and removing fields must be go along with
@Since, annotations for Message classes and fields.
Code checks
- Ignite CI notifies for IF-clauses with condition based on IgniteVersion older than
(curVer - 1). - Ignite CI forbids code changes if Message fields changed without corresponding @Since, @Until annotations.
- Ignite CI forbids code changes if Message contains byte[] fields.
- Ignite CI forbids code changes if Message field changes type.
- Ignite CI checks @Order annotation of fields - starts with 0, no lags.
Nice to have (for later research)
These possible improvements can be implemented later. Ignite message code generator will support this features:
- Optional tagged fields. Such fields is not part of Message schema. The fields can be attached to any message. Cases: securityId, traceId, incremenalIndex, sessionAttributes, depInfo, etc.
Current approach - is creating a new message-wrapper (IncrementalSnapshotAwareMessage, TransactionAttributesAwareRequest) that wraps original Message with extra data. - Lazy deserialization/unmarshalling of specific fields. Cases: skip deserializing optional fields, transfer cache entries as byte arrays. It can be achieved by storing these fields as byte array.
- Compact length of varlen/collections - currently we use 4 bytes (int) to write length of collection or varlen type. In most cases this too much and the length can be encoded with less data (using 1-2 bytes instead).
Communication protocol
Communication protocol consist of 2 parts:
- Data - set of declared
Message classes, including ser/des algorithm. - Transport - algorithm of transport the
Messages to remote node.
Data compatibility
- Deprecate
Message#writeTo and Message#readFrom in favor generated MessageSerializer. MessageSerializer#writeTo consumes MessageWriter that stores IgniteProductVersion of a receiver node and use it for serializing data for this version (mostly, for ignoring some fields).MessageSerializer#readFrom consumes MessageReader that stores IgniteProductVersion of source node and use it for deserializing data (mostly, for setting default values of new fields).
public interface MessageSerializer {
public boolean writeTo(Message msg, ByteBuffer buf, MessageWriter writer);
public boolean readFrom(Message msg, ByteBuffer buf, MessageReader reader);
}
public interface MessageWriter {
public IgniteProductVersion receiverVersion();
}
public interface MessageReader {
public IgniteProductVersion senderVersion();
}
- annotations
@Since and @Until for Message classes and Message fields, to use it for for Message#writeTo and Message#readFrom:
// Package where the Message is defined.
package org.apache.ignite.internal.my.message;
@Since(version = "2.19.0")
public class MyMessage implements Message {
@Order(0)
private int id;
/** Remove field. */
@Until(version = "2.20.0")
@Order(1)
private String rmFld;
/** New field. */
@Since(version = "2.20.0")
@Order(2)
private String newFld;
// Message must have setters/getters for all @Ordered fields. Methods names are equal to a corresponding field name.
public void id(int id) {
this.id = id;
}
public int id() {
return id;
}
public void newFld(String newFld) {
this.newFld = newFld;
}
public String newFld() {
return newFld;
}
}
// Generated code from the message ^ for Ignite version 2.20.0.
// Use the same package as corresponding message.
package org.apache.ignite.internal.my.message;
class MyMessageSerializer {
public boolean writeTo(Message m, ByteBuffer buf, MessageWriter writer) {
MyMessage msg = (MyMessage)m;
IgniteProductVersion rcvVer = writer.receiverVersion();
writer.writeString(msg.id());
if (rcvVer.lessThan(2, 20, 0))
writer.writeString(msg.rmFld());
if (rcvVer.greaterThanEqual(2, 20, 0))
writer.writeString(msg.newFld());
return true;
}
public static boolean readFrom(Message m, ByteBuffer buf, MessageReader reader) {
MyMessage msg = (MyMessage)m;
IgniteProductVersion srcVer = reader.senderVersion();
msg.id(reader.readString());
if (srcVer.lessThan(2, 20, 0))
msg.rmFld(reader.readString());
if (srcVer.greaterThanEqual(2, 20, 0))
msg.newFld(reader.readString());
return true;
}
}
Rules to describe Message (must be automated and validated):
- Do not remove
Message class or Message fields, but annotate it with @Until. - Do not change types or
@Order of fields. - New fields must be annotated with
@Since. All such fields must be optional, default value is null. Handling the nulls is care of Message consumer on the reader side. - Setters and getters must follow name of the field.
Transport compatibility
Communication handshake
Settings that affects both communicating nodes must be same:
- usePairedConnections
- connectionsPerNode
Marshaller compatibility
- JdkMarshaller - Jdk serialization is compatible between JDK versions if serialVersionUID is specified. Still some messages use it (IgniteDiagnosticMessage - should replace it is much as possible).
- BinaryMarshaller - backward compatibility is guaranteed. Require API for getting marshaller for specific version.
Other implementations
Protobuf
https://protobuf.dev/programming-guides/encoding/
- Field numbers are serialized with data:
- Do not send null fields, ignore unknown fields.
- Order of fields isn't guaranteed: message can be concatenations of same fields in different order. For optimizations (compression)?
- Make possible easily change schema (but user must preserve field ids).
- Serializer must know len of varlen fields (Messages).
- It can be worked in streaming way using CodedOutputStream. To customize serialization, but it still requires len for Messages be written before.
- It allows deserialize only required fields using CodedInputStream. It requires writing a code for iterating over tags and skipping fields.
FlatBuffers
https://flatbuffers.dev/internals/
- Offsets are serialized with data:
- Order of fields is not guaranteed - for optimization like compaction.
- First 4bytes - offset to the root of vtable, that stores offsets to other fields. Vtable can be anywhere relative to fields.
- Size of data must be know before serializing to prepare the vtable.
- It starts serializing from nested objects, calculate it sizes and fill tables, and then write root object.
- No streaming is possible.
Avro
https://avro.apache.org/docs/
- Avro relies that schema is known on both sides. And avoid writing field numbers, offsets. Only data. And it can be done in streaming way.
- Schema resolution is based on field names.
Bson
- Writes field names like json. Suppose that received doesn't have a schema.
Kafka
https://kafka.apache.org/protocol.html
KIP-482: The Kafka Protocol should Support Optional Tagged Fields
- All messages are size delimited
- Fields order is preserved in serialization.
- Clients and brokers are aware of versions of each other and send messages in the form for specific versions known by each others.
- There are optional tagged fields beyond a message schema, that can be attached to messages.
7 Comments
Unknown User (nizhikov)
Apr 28, 2025MessageWriter? It seems we must have single writer for each supported product versionMaksim Timonin
May 12, 2025Actually, yes, we can store IgniteProductVersion inside MessageWriter. It will also use it for compatibility of MessageWriter itself (in case of any changes inside the writer).
Now we use single writer per node, because it stores a state of communication channel.
Unknown User (sergeychugunov)
May 20, 2025This document and its parent mostly focus on serdes part of compatibility, but I wonder if it should cover things like changes in communication/discovery protocols as well. At the end of the day, these changes are possible and are also part of RU process.
One possible use case here can be: insert a node into a middle of a ring for TcpDiscovery. Right now TcpDisco always inserts new server nodes to the end of the ring, however, having an ability to insert the node into an arbitraty position would allow useful network traffic optimizations. And I believe to imprement this feature we would need to change existing node join protocol, possibly adding new type of NodeAdd discovery message.
So RU mechanism has to be able to turn some pieces of functionality on and off when cluster is mixed versions mode.
Anothe point here is how to organize this from code point of view, as one will have to support two (or more?) versions of code of the same protocol in codebase and switch between them at the right moment. It is important to make this as clean as possible and avoid turning codebase into hard to support or even understand mess.
Unknown User (sergeychugunov)
Jun 04, 2025After some thinking I came to a conclusion that existing functionality of IgniteFeature is enough to support evolving features' implementations. Supporting different versions of code shouldn't be a problem too as only two versions of nodes are going to be supported by design. This allows us to delete old code right in the next version, so no messy code with a lot of if branches depending on product version is expected here.
Unknown User (sergeychugunov)
May 27, 2025Maksim Timonin, I spent some time to wrap my head around this concept of Since/Until annotations and I would suggest to express it with more explicit language.
How about adding a section like the following?
Describing schema evolution with annotations
Two annotations are used: Since(version) and Until(version). These annotations are used during code generation to guarantee that message writer of a specific version produces serialized forms of messages that message reades of another version is able to read.
Annotations' role in message exchange:
1. Since is for case when a node of newer version sends messages to a node of an older version. In that case new node needs to know which fields are supported by an old one. Since annotation provides that info.
2. Until is for case when an old node sends messages to a newer one. Old node needs to know which fields that are marked to be removed are available on new node.
Adding and deleting fields and annotations.
1. If a field doesn't have any annotations, it means that all nodes are aware of them and are able to deserialize them.
2. If a field has a Since(2.x.0) annotation than this annotation can be removed from the field right in 2.(x+1).0 version as only two consequitive versions of Ignite nodes are allowed in topology.
3. If a field has an Until(2.x.0) annotation than this field can be removed in 2.(x+1).0 version as annotation states. Old nodes won't send this field to 2.(x+1).0 version, new nodes won't know about it as we delete it in current (2.(x+1).0) version.
Unknown User (sergeychugunov)
May 27, 2025I also came up with another interesting scenario. Let's say during development of 2.x.0 we decided to remove a field from a message and marked it with Until(2.x.0) annotation (no more support right in the next version). Version 2.x.0 was successfully released. Then in version 2.(x+1).0 we did an additional testing and found that actual removing of the field could lead to a broken feature. Now we are in a situation when an old node doesn't send us an important field and our feature is broken. Does it make sense to implement a switch or something to force the old node to send the field? Or another way here could be to require that field is sent even to a version when it is declared EOL, and its up to the new code to decide what to do - drop it or use it.
Unknown User (sergeychugunov)
Jun 04, 2025In a direct discussion with design authors we resolved this concern as follows: proposed RU design provides a general framework enabling features to change over time, but it is a responsibility of a feature developer to make sure no piece of functionality breaks between versions. In case of such bugs it is a responsibility of the feature developer again to provide feature users with work-arounds. Loading RU framework with some tricks and hacks to break rules is wrong and could lead to misuse and more bugs. So no support for such cases by RU framework will be provided.