From e8a23b4a2f186993151162d16ac3dbeed19c146f Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Wed, 18 Mar 2026 15:22:29 +0530 Subject: [PATCH 1/3] add generic typed message --- .../converter/GenericMessageConverter.java | 46 ++++++++-- .../GenericMessageConverterTest.java | 83 ++++++++++++++++++- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/rqueue-core/src/main/java/com/github/sonus21/rqueue/converter/GenericMessageConverter.java b/rqueue-core/src/main/java/com/github/sonus21/rqueue/converter/GenericMessageConverter.java index 8a21ef5b..4d77eefc 100644 --- a/rqueue-core/src/main/java/com/github/sonus21/rqueue/converter/GenericMessageConverter.java +++ b/rqueue-core/src/main/java/com/github/sonus21/rqueue/converter/GenericMessageConverter.java @@ -19,6 +19,7 @@ import static org.springframework.util.Assert.notNull; import com.github.sonus21.rqueue.utils.SerializationUtils; +import java.lang.reflect.Field; import java.lang.reflect.TypeVariable; import java.util.Collection; import java.util.List; @@ -37,8 +38,8 @@ /** * A converter to turn the payload of a {@link Message} from serialized form to a typed String and - * vice versa. This class does not support generic class except {@link List},even for list the - * entries should be non generic. + * vice versa. Supports {@link List} and single-level generic envelope types (e.g. {@code Event}) + * where type parameters are non-generic and can be resolved from non-null field values. */ @Slf4j public class GenericMessageConverter implements SmartMessageConverter { @@ -137,7 +138,12 @@ private String getClassNameForCollection(String name, Collection payload) { if (payload.isEmpty()) { return null; } - String itemClassName = getClassName(((List) payload).get(0)); + Object firstItem = ((List) payload).get(0); + // Only support non-generic item classes in lists to avoid ambiguous encoding + if (firstItem.getClass().getTypeParameters().length > 0) { + return null; + } + String itemClassName = getClassName(firstItem); if (itemClassName == null) { return null; } @@ -146,12 +152,40 @@ private String getClassNameForCollection(String name, Collection payload) { return null; } - private String getGenericFieldBasedClassName(Class clazz) { + private Class resolveTypeVariable(Class clazz, TypeVariable tv, Object payload) { + // TypeVariable instances are scoped to the class that declares them, so + // field.getGenericType().equals(tv) can only match fields declared on clazz itself. + // Superclass fields reference their own TypeVariable instances, which are distinct objects. + for (Field field : clazz.getDeclaredFields()) { + if (field.getGenericType().equals(tv)) { + field.setAccessible(true); + try { + Object value = field.get(payload); + if (value != null) { + return value.getClass(); + } + } catch (IllegalAccessException e) { + log.debug("Cannot access field {}", field.getName(), e); + } + } + } + return null; + } + + private String getGenericFieldBasedClassName(Class clazz, Object payload) { TypeVariable[] typeVariables = clazz.getTypeParameters(); if (typeVariables.length == 0) { return clazz.getName(); } - return null; + StringBuilder sb = new StringBuilder(clazz.getName()); + for (TypeVariable tv : typeVariables) { + Class resolved = resolveTypeVariable(clazz, tv, payload); + if (resolved == null || resolved.getTypeParameters().length > 0) { + return null; + } + sb.append('#').append(resolved.getName()); + } + return sb.toString(); } private String getClassName(Object payload) { @@ -160,7 +194,7 @@ private String getClassName(Object payload) { if (payload instanceof Collection) { return getClassNameForCollection(name, (Collection) payload); } - return getGenericFieldBasedClassName(payloadClass); + return getGenericFieldBasedClassName(payloadClass, payload); } private JavaType getTargetType(Msg msg) throws ClassNotFoundException { diff --git a/rqueue-core/src/test/java/com/github/sonus21/rqueue/converter/GenericMessageConverterTest.java b/rqueue-core/src/test/java/com/github/sonus21/rqueue/converter/GenericMessageConverterTest.java index 0b096b49..1db2fc7d 100644 --- a/rqueue-core/src/test/java/com/github/sonus21/rqueue/converter/GenericMessageConverterTest.java +++ b/rqueue-core/src/test/java/com/github/sonus21/rqueue/converter/GenericMessageConverterTest.java @@ -116,11 +116,44 @@ void toAndFromMessageList() { } @Test - void genericMessageToReturnNull() { + void envelopeEventToAndFromMessage() { + Event event = new Event<>("evt-1", comment); + Message message = + genericMessageConverter.toMessage(event, RqueueMessageHeaders.emptyMessageHeaders()); + Event fromMessage = + (Event) genericMessageConverter.fromMessage(message, null); + assertEquals(event, fromMessage); + } + + @Test + void envelopeEventWithNullPayloadToReturnNull() { + Event event = new Event<>("evt-1", null); + Message message = + genericMessageConverter.toMessage(event, RqueueMessageHeaders.emptyMessageHeaders()); + assertNull(message); + } + + @Test + void envelopeEventWithInheritedTypeToAndFromMessage() { + // T=Notification extends Alert extends BaseAlert — runtime class is Notification, + // so the type parameter is resolved to Notification, not the base type. + Notification notification = new Notification("n-1", "hello", 42); + Event event = new Event<>("evt-2", notification); + Message message = + genericMessageConverter.toMessage(event, RqueueMessageHeaders.emptyMessageHeaders()); + Event fromMessage = + (Event) genericMessageConverter.fromMessage(message, null); + assertEquals(event, fromMessage); + } + + @Test + void genericEnvelopeToAndFromMessage() { GenericTestData data = new GenericTestData<>(10, comment); Message message = genericMessageConverter.toMessage(data, RqueueMessageHeaders.emptyMessageHeaders()); - assertNull(message); + GenericTestData fromMessage = + (GenericTestData) genericMessageConverter.fromMessage(message, null); + assertEquals(data, fromMessage); } @Test @@ -369,6 +402,15 @@ public static class MultiGenericTestDataSameType extends MappingRegistrar { private MultiGenericTestData multiGenericTestData; } + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Event { + + private String id; + private T payload; + } + @Data @NoArgsConstructor @AllArgsConstructor @@ -377,4 +419,41 @@ public static class GenericTestDataWithPredefinedType { private Integer index; private MultiGenericTestData data; } + + // Three-level hierarchy: Notification extends Alert extends BaseAlert + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class BaseAlert { + + private String id; + } + + @Data + @EqualsAndHashCode(callSuper = true) + @NoArgsConstructor + @AllArgsConstructor + public static class Alert extends BaseAlert { + + private String message; + + public Alert(String id, String message) { + super(id); + this.message = message; + } + } + + @Data + @EqualsAndHashCode(callSuper = true) + @NoArgsConstructor + @AllArgsConstructor + public static class Notification extends Alert { + + private int priority; + + public Notification(String id, String message, int priority) { + super(id, message); + this.priority = priority; + } + } } From 4e88a95b90c6a86d706b13608d2d8b6747677d09 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Wed, 18 Mar 2026 15:28:54 +0530 Subject: [PATCH 2/3] update doc --- build.gradle | 2 +- docs/CHANGELOG.md | 30 +++++++++++++- docs/configuration/configuration.md | 62 ++++++++++++++++++++++++----- 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/build.gradle b/build.gradle index 0974c07e..9244087c 100644 --- a/build.gradle +++ b/build.gradle @@ -83,7 +83,7 @@ ext { subprojects { group = "com.github.sonus21" - version = "4.0.0-RELEASE" + version = "4.0.0-RC1" dependencies { // https://mvnrepository.com/artifact/org.springframework/spring-messaging diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d328973d..b07204b3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,10 +8,36 @@ layout: default All notable user-facing changes to this project are documented in this file. +## Release [4.0.0.RC1] 18-Mar-2026 + +{: .highlight} +This is a release candidate for 4.0.0. It targets Spring Boot 4.x and Spring Framework 7.x. +Please test thoroughly before using in production. + +### Features +* **Spring Boot 4.x support** — compatible with Spring Boot 4.0.1 and above. +* **Spring Framework 7.x support** — built against Spring Framework 7.0.3, taking + advantage of the updated messaging and context APIs. +* **Java 21 baseline** — Java 21 is now the minimum supported runtime. +* **Jackson 3.x support** — updated serialization layer to use Jackson 3.x + (`tools.jackson` packages). +* **Lettuce 7.x support** — Redis client updated to Lettuce 7.2.x. +* `GenericMessageConverter` now supports generic envelope types such as `Event`. + The type parameter is resolved from the runtime class of the corresponding field + value, enabling transparent round-trip serialization without requiring a custom + message converter. + +### Migration Notes +* Requires Java 21+. +* Requires Spring Boot 4.x / Spring Framework 7.x. Not backward compatible with + Spring Boot 3.x — use the 3.x release line for older Spring Boot versions. +* Jackson package namespace changed from `com.fasterxml.jackson` to `tools.jackson` + in Jackson 3.x. Update any custom `ObjectMapper` configuration accordingly. + ## Release [3.4.0] 22-July-2025 ### Fixes -* Improved unique message enqueuing to reject duplicates upfront rather than during - processing. #259 +* Improved unique message enqueuing to reject duplicates upfront rather than during + processing. #259 ## Release [3.3.0] 29-June-2025 diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index b00b227a..c9851693 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -172,13 +172,13 @@ public class BootstrapController { ## Message Converter Configuration -To customize message conversion, set the property -`rqueue.message.converter.provider.class` to the fully qualified name of your provider -class. This class must implement the `MessageConverterProvider` interface and return +To customize message conversion, set the property +`rqueue.message.converter.provider.class` to the fully qualified name of your provider +class. This class must implement the `MessageConverterProvider` interface and return a Spring `MessageConverter`. {: .note} -Your custom provider must implement +Your custom provider must implement `com.github.sonus21.rqueue.converter.MessageConverterProvider`. ```java @@ -186,22 +186,62 @@ class MyMessageConverterProvider implements MessageConverterProvider { @Override public MessageConverter getConverter() { - // here any message converter can be returned except null + // here any message converter can be returned except null return new MyMessageConverter(); } } ``` -The `DefaultRqueueMessageConverter` handles serialization for most use cases, but it -may fail if classes are not shared between producing and consuming applications. To -avoid shared dependencies, consider using JSON-based converters like -`com.github.sonus21.rqueue.converter.JsonMessageConverter` or Spring's -`JacksonJsonMessageConverter`. These serialize payloads into JSON, improving +The `DefaultRqueueMessageConverter` handles serialization for most use cases, but it +may fail if classes are not shared between producing and consuming applications. To +avoid shared dependencies, consider using JSON-based converters like +`com.github.sonus21.rqueue.converter.JsonMessageConverter` or Spring's +`JacksonJsonMessageConverter`. These serialize payloads into JSON, improving interoperability. -Other serialization formats like MessagePack or Protocol Buffers (ProtoBuf) can also +Other serialization formats like MessagePack or Protocol Buffers (ProtoBuf) can also be implemented based on your requirements. +### Generic Envelope Types + +`GenericMessageConverter` (used by the default converter) supports **single-level +generic envelope types** such as `Event`. The type parameter is resolved at +serialization time by inspecting the runtime class of the field value that corresponds +to `T`. + +```java +// A generic envelope type +public class Event { + private String id; + private T payload; + // getters/setters ... +} + +// Enqueue +Event event = new Event<>("evt-123", order); +rqueueMessageEnqueuer.enqueue("order-queue", event); + +// Consume +@RqueueListener(value = "order-queue") +public void onEvent(Event event) { ... } +``` + +The serialized form encodes both the envelope class and the type parameter: + +``` +{"msg":"...","name":"com.example.Event#com.example.Order"} +``` + +**Constraints:** + +- The type parameter `T` must be a **non-generic** concrete class (e.g. `Order`, not + `List`). +- At least one field of type `T` on the envelope class must be **non-null** at + serialization time, so the runtime type can be determined. +- For `List`, items must also be non-generic concrete classes. Envelopes like + `List>` are not supported. +- Multi-level nesting (e.g. `Wrapper>`) is not supported. + ## Additional Configuration - **`rqueue.retry.per.poll`**: Determines how many times a polled message is retried From be3b6b79568b761f5110c63d184074d173aa294f Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Wed, 18 Mar 2026 15:32:35 +0530 Subject: [PATCH 3/3] fix: failing test --- .../rqueue/core/support/RqueueMessageUtilsTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/rqueue-core/src/test/java/com/github/sonus21/rqueue/core/support/RqueueMessageUtilsTest.java b/rqueue-core/src/test/java/com/github/sonus21/rqueue/core/support/RqueueMessageUtilsTest.java index 6550da3d..3fa593d9 100644 --- a/rqueue-core/src/test/java/com/github/sonus21/rqueue/core/support/RqueueMessageUtilsTest.java +++ b/rqueue-core/src/test/java/com/github/sonus21/rqueue/core/support/RqueueMessageUtilsTest.java @@ -136,8 +136,8 @@ void buildMessageWithDelay() { @Test void buildMessageNull() { + // id is null so the type parameter T cannot be resolved, making conversion fail GenericClass genericClass = new GenericClass<>(); - genericClass.id = UUID.randomUUID().toString(); try { RqueueMessageUtils.buildMessage( messageConverter, @@ -155,8 +155,8 @@ void buildMessageNull() { @Test void buildPeriodicMessageNull() { + // id is null so the type parameter T cannot be resolved, making conversion fail GenericClass genericClass = new GenericClass<>(); - genericClass.id = UUID.randomUUID().toString(); try { RqueueMessageUtils.buildPeriodicMessage( messageConverter, @@ -174,8 +174,9 @@ void buildPeriodicMessageNull() { @Test void buildMessageReturnInvalidType() { + // id is null so GenericMessageConverter returns null; falls through to NoMessageConverter + // which wraps the object in a GenericMessage with a non-String/non-byte[] payload GenericClass genericClass = new GenericClass<>(); - genericClass.id = UUID.randomUUID().toString(); try { RqueueMessageUtils.buildMessage( messageConverter2, @@ -193,8 +194,9 @@ void buildMessageReturnInvalidType() { @Test void buildPeriodicMessageReturnInvalidType() { + // id is null so GenericMessageConverter returns null; falls through to NoMessageConverter + // which wraps the object in a GenericMessage with a non-String/non-byte[] payload GenericClass genericClass = new GenericClass<>(); - genericClass.id = UUID.randomUUID().toString(); try { RqueueMessageUtils.buildPeriodicMessage( messageConverter2,