Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 28 additions & 2 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`.
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
Expand Down
62 changes: 51 additions & 11 deletions docs/configuration/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,36 +172,76 @@ 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
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<T>`. 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<T> {
private String id;
private T payload;
// getters/setters ...
}

// Enqueue
Event<Order> event = new Event<>("evt-123", order);
rqueueMessageEnqueuer.enqueue("order-queue", event);

// Consume
@RqueueListener(value = "order-queue")
public void onEvent(Event<Order> 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<Order>`).
- 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<T>`, items must also be non-generic concrete classes. Envelopes like
`List<Event<Order>>` are not supported.
- Multi-level nesting (e.g. `Wrapper<Event<T>>`) is not supported.

## Additional Configuration

- **`rqueue.retry.per.poll`**: Determines how many times a polled message is retried
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<T>})
* where type parameters are non-generic and can be resolved from non-null field values.
*/
@Slf4j
public class GenericMessageConverter implements SmartMessageConverter {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,44 @@ void toAndFromMessageList() {
}

@Test
void genericMessageToReturnNull() {
void envelopeEventToAndFromMessage() {
Event<Comment> event = new Event<>("evt-1", comment);
Message message =
genericMessageConverter.toMessage(event, RqueueMessageHeaders.emptyMessageHeaders());
Event<Comment> fromMessage =
(Event<Comment>) genericMessageConverter.fromMessage(message, null);
assertEquals(event, fromMessage);
}

@Test
void envelopeEventWithNullPayloadToReturnNull() {
Event<Comment> 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<Notification> event = new Event<>("evt-2", notification);
Message message =
genericMessageConverter.toMessage(event, RqueueMessageHeaders.emptyMessageHeaders());
Event<Notification> fromMessage =
(Event<Notification>) genericMessageConverter.fromMessage(message, null);
assertEquals(event, fromMessage);
}

@Test
void genericEnvelopeToAndFromMessage() {
GenericTestData<Comment> data = new GenericTestData<>(10, comment);
Message message =
genericMessageConverter.toMessage(data, RqueueMessageHeaders.emptyMessageHeaders());
assertNull(message);
GenericTestData<Comment> fromMessage =
(GenericTestData<Comment>) genericMessageConverter.fromMessage(message, null);
assertEquals(data, fromMessage);
}

@Test
Expand Down Expand Up @@ -369,6 +402,15 @@ public static class MultiGenericTestDataSameType<T> extends MappingRegistrar {
private MultiGenericTestData<T, T> multiGenericTestData;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Event<T> {

private String id;
private T payload;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
Expand All @@ -377,4 +419,41 @@ public static class GenericTestDataWithPredefinedType {
private Integer index;
private MultiGenericTestData<Comment, Email> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ void buildMessageWithDelay() {

@Test
void buildMessageNull() {
// id is null so the type parameter T cannot be resolved, making conversion fail
GenericClass<String> genericClass = new GenericClass<>();
genericClass.id = UUID.randomUUID().toString();
try {
RqueueMessageUtils.buildMessage(
messageConverter,
Expand All @@ -155,8 +155,8 @@ void buildMessageNull() {

@Test
void buildPeriodicMessageNull() {
// id is null so the type parameter T cannot be resolved, making conversion fail
GenericClass<String> genericClass = new GenericClass<>();
genericClass.id = UUID.randomUUID().toString();
try {
RqueueMessageUtils.buildPeriodicMessage(
messageConverter,
Expand All @@ -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<String> genericClass = new GenericClass<>();
genericClass.id = UUID.randomUUID().toString();
try {
RqueueMessageUtils.buildMessage(
messageConverter2,
Expand All @@ -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<String> genericClass = new GenericClass<>();
genericClass.id = UUID.randomUUID().toString();
try {
RqueueMessageUtils.buildPeriodicMessage(
messageConverter2,
Expand Down
Loading