diff --git a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java index fc235b271..86591311d 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java @@ -3651,6 +3651,24 @@ public class DefaultStringRedisConnection implements StringRedisConnection, Deco return convertAndReturn(delegate.xAdd(record.serialize(serializer)), identityConverter); } + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.StringRedisConnection#xClaimJustId(java.lang.String, java.lang.String, java.lang.String, org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions) + */ + @Override + public List xClaimJustId(String key, String group, String consumer, XClaimOptions options) { + return convertAndReturn(delegate.xClaimJustId(serialize(key), group, consumer, options), identityConverter); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.StringRedisConnection#xClaim(java.lang.String, java.lang.String, java.lang.String, org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions) + */ + @Override + public List xClaim(String key, String group, String consumer, XClaimOptions options) { + return convertAndReturn(delegate.xClaim(serialize(key), group, consumer, options), listByteMapRecordToStringMapRecordConverter); + } + /* * (non-Javadoc) * @see org.springframework.data.redis.connection.StringRedisConnection#xDel(java.lang.String, java.lang.String[]) @@ -3803,6 +3821,24 @@ public class DefaultStringRedisConnection implements StringRedisConnection, Deco return delegate.xAdd(record); } + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands#xClaimJustId(byte[], java.lang.String, java.lag.String, org.springframework.data.redis.connection.RedisStreamCommands.XCLaimOptions) + */ + @Override + public List xClaimJustId(byte[] key, String group, String newOwner, XClaimOptions options) { + return delegate.xClaimJustId(key, group, newOwner, options); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands#xClaim(byte[], java.lang.String, java.lag.String, org.springframework.data.redis.connection.RedisStreamCommands.XCLaimOptions) + */ + @Override + public List xClaim(byte[] key, String group, String newOwner, XClaimOptions options) { + return delegate.xClaim(key, group, newOwner, options); + } + /* * (non-Javadoc) * @see org.springframework.data.redis.connection.RedisStreamCommands#xDel(byte[], RecordId) diff --git a/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java index 5ebef5397..3657a8076 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java @@ -453,6 +453,20 @@ public interface DefaultedRedisConnection extends RedisConnection { return streamCommands().xAdd(record); } + /** @deprecated in favor of {@link RedisConnection#streamCommands()}}. */ + @Override + @Deprecated + default List xClaimJustId(byte[] key, String group, String newOwner, XClaimOptions options) { + return streamCommands().xClaimJustId(key, group, newOwner, options); + } + + /** @deprecated in favor of {@link RedisConnection#streamCommands()}}. */ + @Override + @Deprecated + default List xClaim(byte[] key, String group, String newOwner, XClaimOptions options) { + return streamCommands().xClaim(key, group, newOwner, options); + } + /** @deprecated in favor of {@link RedisConnection#streamCommands()}}. */ @Override @Deprecated diff --git a/src/main/java/org/springframework/data/redis/connection/ReactiveStreamCommands.java b/src/main/java/org/springframework/data/redis/connection/ReactiveStreamCommands.java index b8b1cb531..4f76a0735 100644 --- a/src/main/java/org/springframework/data/redis/connection/ReactiveStreamCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/ReactiveStreamCommands.java @@ -19,6 +19,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.ByteBuffer; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -30,6 +31,7 @@ import org.springframework.data.domain.Range; import org.springframework.data.redis.connection.ReactiveRedisConnection.CommandResponse; import org.springframework.data.redis.connection.ReactiveRedisConnection.KeyCommand; import org.springframework.data.redis.connection.ReactiveRedisConnection.NumericResponse; +import org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions; import org.springframework.data.redis.connection.RedisStreamCommands.XPendingOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; import org.springframework.data.redis.connection.stream.ByteBufferRecord; @@ -285,6 +287,112 @@ public interface ReactiveStreamCommands { */ Flux> xAdd(Publisher commands); + /** + * Change the ownership of a pending message to the given new {@literal consumer} without increasing the delivered + * count. + * + * @param key the {@literal key} the stream is stored at. + * @param group the name of the {@literal consumer group}. + * @param newOwner the name of the new {@literal consumer}. + * @param options must not be {@literal null}. + * @return a {@link Flux} emitting {@link RecordId is} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + default Flux xClaimJustId(ByteBuffer key, String group, String newOwner, XClaimOptions options) { + + return xClaimJustId(Mono.just(new XClaimCommand(key, group, newOwner, options))).next() + .flatMapMany(CommandResponse::getOutput); + } + + /** + * Change the ownership of a pending message to the given new {@literal consumer} without increasing the delivered + * count. + * + * @param commands must not be {@literal null}. + * @return a {@link Flux} emitting {@link RecordId is} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + Flux>> xClaimJustId(Publisher commands); + + /** + * Change the ownership of a pending message to the given new {@literal consumer}. + * + * @param key the {@literal key} the stream is stored at. + * @param group the name of the {@literal consumer group}. + * @param newOwner the name of the new {@literal consumer}. + * @param minIdleTime must not be {@literal null}. + * @param recordIds must not be {@literal null}. + * @return a {@link Flux} emitting {@link ByteBufferRecord} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + default Flux xClaim(ByteBuffer key, String group, String newOwner, Duration minIdleTime, + RecordId... recordIds) { + + return xClaim(key, group, newOwner, XClaimOptions.minIdle(minIdleTime).ids(recordIds)); + } + + /** + * Change the ownership of a pending message to the given new {@literal consumer}. + * + * @param key the {@literal key} the stream is stored at. + * @param group the name of the {@literal consumer group}. + * @param newOwner the name of the new {@literal consumer}. + * @param options must not be {@literal null}. + * @return a {@link Flux} emitting {@link ByteBufferRecord} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + default Flux xClaim(ByteBuffer key, String group, String newOwner, XClaimOptions options) { + + return xClaim(Mono.just(new XClaimCommand(key, group, newOwner, options))).next() + .flatMapMany(CommandResponse::getOutput); + } + + /** + * Change the ownership of a pending message to the given new {@literal consumer}. + * + * @param commands must not be {@literal null}. + * @return + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + Flux>> xClaim(Publisher commands); + + /** + * {@code XCLAIM} command parameters. + * + * @see Redis Documentation: XCLAIM + */ + class XClaimCommand extends KeyCommand { + + private final String groupName; + private final String consumerName; + private final XClaimOptions options; + + private XClaimCommand(@Nullable ByteBuffer key, String groupName, String consumerName, XClaimOptions options) { + + super(key); + this.groupName = groupName; + this.consumerName = consumerName; + this.options = options; + } + + public XClaimOptions getOptions() { + return options; + } + + public String getConsumerName() { + return consumerName; + } + + public String getGroupName() { + return groupName; + } + } + /** * {@code XDEL} command parameters. * diff --git a/src/main/java/org/springframework/data/redis/connection/RedisStreamCommands.java b/src/main/java/org/springframework/data/redis/connection/RedisStreamCommands.java index e775754cf..a2984b6e9 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisStreamCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisStreamCommands.java @@ -15,9 +15,13 @@ */ package org.springframework.data.redis.connection; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.springframework.data.domain.Range; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; @@ -84,6 +88,249 @@ public interface RedisStreamCommands { */ RecordId xAdd(MapRecord record); + /** + * Change the ownership of a pending message to the given new {@literal consumer} without increasing the delivered + * count. + * + * @param key the {@literal key} the stream is stored at. + * @param group the name of the {@literal consumer group}. + * @param newOwner the name of the new {@literal consumer}. + * @param options must not be {@literal null}. + * @return list of {@link RecordId ids} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + List xClaimJustId(byte[] key, String group, String newOwner, XClaimOptions options); + + /** + * Change the ownership of a pending message to the given new {@literal consumer}. + * + * @param key the {@literal key} the stream is stored at. + * @param group the name of the {@literal consumer group}. + * @param newOwner the name of the new {@literal consumer}. + * @param minIdleTime must not be {@literal null}. + * @param recordIds must not be {@literal null}. + * @return list of {@link ByteRecord} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + default List xClaim(byte[] key, String group, String newOwner, Duration minIdleTime, + RecordId... recordIds) { + return xClaim(key, group, newOwner, XClaimOptions.minIdle(minIdleTime).ids(recordIds)); + } + + /** + * Change the ownership of a pending message to the given new {@literal consumer}. + * + * @param key the {@literal key} the stream is stored at. + * @param group the name of the {@literal consumer group}. + * @param newOwner the name of the new {@literal consumer}. + * @param options must not be {@literal null}. + * @return list of {@link ByteRecord} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + List xClaim(byte[] key, String group, String newOwner, XClaimOptions options); + + /** + * @author Christoph Strobl + * @since 2.3 + */ + class XClaimOptions { + + private final List ids; + private final Duration minIdleTime; + private final @Nullable Duration idleTime; + private final @Nullable Instant unixTime; + private final @Nullable Long retryCount; + private final boolean force; + + private XClaimOptions(List ids, Duration minIdleTime, Duration idleTime, Instant unixTime, + Long retryCount, boolean force) { + + this.ids = new ArrayList<>(ids); + this.minIdleTime = minIdleTime; + this.idleTime = idleTime; + this.unixTime = unixTime; + this.retryCount = retryCount; + this.force = force; + } + + /** + * Set the {@literal min-idle-time} to limit the command to messages that have been idle for at at least the given + * {@link Duration}. + * + * @param minIdleTime must not be {@literal null}. + * @return new instance of {@link XClaimOptions}. + */ + public static XClaimOptionsBuilder minIdle(Duration minIdleTime) { + return new XClaimOptionsBuilder(minIdleTime); + } + + /** + * Set the {@literal min-idle-time} to limit the command to messages that have been idle for at at least the given + * {@literal milliseconds}. + * + * @param millis + * @return new instance of {@link XClaimOptions}. + */ + public static XClaimOptionsBuilder minIdleMs(long millis) { + return minIdle(Duration.ofMillis(millis)); + } + + /** + * Set the idle time since last delivery of a message. To specify a specific point in time use + * {@link #time(Instant)}. + * + * @param idleTime idle time. + * @return {@code this}. + */ + public XClaimOptions idle(Duration idleTime) { + return new XClaimOptions(ids, minIdleTime, idleTime, unixTime, retryCount, force); + } + + /** + * Sets the idle time to a specific unix time (in milliseconds). To define a relative idle time use + * {@link #idle(Duration)}. + * + * @param unixTime idle time. + * @return {@code this}. + */ + public XClaimOptions time(Instant unixTime) { + return new XClaimOptions(ids, minIdleTime, idleTime, unixTime, retryCount, force); + } + + /** + * Set the retry counter to the specified value. + * + * @param retryCount can be {@literal null}. If {@literal null} no change to the retry counter will be made. + * @return new instance of {@link XClaimOptions}. + */ + public XClaimOptions retryCount(@Nullable Long retryCount) { + return new XClaimOptions(ids, minIdleTime, idleTime, unixTime, retryCount, force); + } + + /** + * Forces creation of a pending message entry in the PEL even if it does not already exist as long a the given + * stream record id is valid. + * + * @return new instance of {@link XClaimOptions}. + */ + public XClaimOptions force() { + return new XClaimOptions(ids, minIdleTime, idleTime, unixTime, retryCount, true); + } + + /** + * Get the {@link List} of {@literal ID}. + * + * @return never {@literal null}. + */ + public List getIds() { + return ids; + } + + /** + * Get the {@literal ID} array as {@link String strings}. + * + * @return never {@literal null}. + */ + public String[] getIdsAsStringArray() { + return getIds().stream().map(RecordId::getValue).toArray(String[]::new); + } + + /** + * Get the {@literal min-idle-time}. + * + * @return never {@literal null}. + */ + public Duration getMinIdleTime() { + return minIdleTime; + } + + /** + * Get the {@literal IDLE ms} time. + * + * @return can be {@literal null}. + */ + @Nullable + public Duration getIdleTime() { + return idleTime; + } + + /** + * Get the {@literal TIME ms-unix-time} + * + * @return + */ + @Nullable + public Instant getUnixTime() { + return unixTime; + } + + /** + * Get the {@literal RETRYCOUNT count}. + * + * @return + */ + @Nullable + public Long getRetryCount() { + return retryCount; + } + + /** + * Get the {@literal FORCE} flag. + * + * @return + */ + public boolean isForce() { + return force; + } + + public static class XClaimOptionsBuilder { + + private Duration minIdleTime; + + XClaimOptionsBuilder(Duration minIdleTime) { + this.minIdleTime = minIdleTime; + } + + /** + * Set the {@literal ID}s to claim. + * + * @param ids must not be {@literal null}. + * @return + */ + public XClaimOptions ids(List ids) { + + List idList = ids.stream() + .map(it -> it instanceof RecordId ? (RecordId) it : RecordId.of(it.toString())) + .collect(Collectors.toList()); + + return new XClaimOptions(idList, minIdleTime, null, null, null, false); + } + + /** + * Set the {@literal ID}s to claim. + * + * @param ids must not be {@literal null}. + * @return + */ + public XClaimOptions ids(RecordId... ids) { + return ids(Arrays.asList(ids)); + } + + /** + * Set the {@literal ID}s to claim. + * + * @param ids must not be {@literal null}. + * @return + */ + public XClaimOptions ids(String... ids) { + return ids(Arrays.asList(ids)); + } + } + } + /** * Removes the records with the given id's from the stream. Returns the number of items deleted, that may be different * from the number of id's passed in case certain id's do not exist. diff --git a/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java index 9d6fdcfaf..2039caa2d 100644 --- a/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java @@ -23,7 +23,6 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; -import org.springframework.data.domain.Range; import org.springframework.data.geo.Circle; import org.springframework.data.geo.Distance; import org.springframework.data.geo.GeoResults; @@ -2013,6 +2012,50 @@ public interface StringRedisConnection extends RedisConnection { RecordId xAdd(StringRecord record); + /** + * Change the ownership of a pending message to the given new {@literal consumer} without increasing the delivered + * count. + * + * @param key the {@literal key} the stream is stored at. + * @param group the name of the {@literal consumer group}. + * @param newOwner the name of the new {@literal consumer}. + * @param options must not be {@literal null}. + * @return list of {@link RecordId ids} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + List xClaimJustId(String key, String group, String newOwner, XClaimOptions options); + + /** + * Change the ownership of a pending message to the given new {@literal consumer}. + * + * @param key the {@literal key} the stream is stored at. + * @param group the name of the {@literal consumer group}. + * @param newOwner the name of the new {@literal consumer}. + * @param minIdleTime must not be {@literal null}. + * @param recordIds must not be {@literal null}. + * @return list of {@link StringRecord} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + default List xClaim(String key, String group, String newOwner, Duration minIdleTime, + RecordId... recordIds) { + return xClaim(key, group, newOwner, XClaimOptions.minIdle(minIdleTime).ids(recordIds)); + } + + /** + * Change the ownership of a pending message to the given new {@literal consumer}. + * + * @param key the {@literal key} the stream is stored at. + * @param group the name of the {@literal consumer group}. + * @param newOwner the name of the new {@literal consumer}. + * @param options must not be {@literal null}. + * @return list of {@link StringRecord} that changed user. + * @see Redis Documentation: XCLAIM + * @since 2.3 + */ + List xClaim(String key, String group, String newOwner, XClaimOptions options); + /** * Removes the specified entries from the stream. Returns the number of items deleted, that may be different from the * number of IDs passed in case certain IDs do not exist. @@ -2082,15 +2125,15 @@ public interface StringRedisConnection extends RedisConnection { * @param groupName the name of the {@literal consumer group}. Must not be {@literal null}. * @return a summary of pending messages within the given {@literal consumer group} or {@literal null} when used in * pipeline / transaction. - * @see Redis Documentation: xpending + * @see Redis Documentation: xpending * @since 2.3 */ @Nullable PendingMessagesSummary xPending(String key, String groupName); /** - * Obtain detailed information about pending {@link PendingMessage messages} for a given {@link org.springframework.data.domain.Range} within a - * {@literal consumer group}. + * Obtain detailed information about pending {@link PendingMessage messages} for a given + * {@link org.springframework.data.domain.Range} within a {@literal consumer group}. * * @param key the {@literal key} the stream is stored at. Must not be {@literal null}. * @param groupName the name of the {@literal consumer group}. Must not be {@literal null}. @@ -2099,15 +2142,16 @@ public interface StringRedisConnection extends RedisConnection { * @param count limit the number of results. Must not be {@literal null}. * @return pending messages for the given {@literal consumer group} or {@literal null} when used in pipeline / * transaction. - * @see Redis Documentation: xpending + * @see Redis Documentation: xpending * @since 2.3 */ @Nullable - PendingMessages xPending(String key, String groupName, String consumerName, org.springframework.data.domain.Range range, Long count); + PendingMessages xPending(String key, String groupName, String consumerName, + org.springframework.data.domain.Range range, Long count); /** - * Obtain detailed information about pending {@link PendingMessage messages} for a given {@link org.springframework.data.domain.Range} within a - * {@literal consumer group}. + * Obtain detailed information about pending {@link PendingMessage messages} for a given + * {@link org.springframework.data.domain.Range} within a {@literal consumer group}. * * @param key the {@literal key} the stream is stored at. Must not be {@literal null}. * @param groupName the name of the {@literal consumer group}. Must not be {@literal null}. @@ -2115,12 +2159,12 @@ public interface StringRedisConnection extends RedisConnection { * @param count limit the number of results. Must not be {@literal null}. * @return pending messages for the given {@literal consumer group} or {@literal null} when used in pipeline / * transaction. - * @see Redis Documentation: xpending + * @see Redis Documentation: xpending * @since 2.3 */ @Nullable - PendingMessages xPending(String key, String groupName, org.springframework.data.domain.Range range, Long count); - + PendingMessages xPending(String key, String groupName, org.springframework.data.domain.Range range, + Long count); /** * Obtain detailed information about pending {@link PendingMessage messages} applying given {@link XPendingOptions diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommands.java index 13213ae05..67c5a8392 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommands.java @@ -16,6 +16,7 @@ package org.springframework.data.redis.connection.lettuce; import io.lettuce.core.XAddArgs; +import io.lettuce.core.XClaimArgs; import io.lettuce.core.XReadArgs; import io.lettuce.core.XReadArgs.StreamOffset; import io.lettuce.core.cluster.api.reactive.RedisClusterReactiveCommands; @@ -105,6 +106,50 @@ class LettuceReactiveStreamCommands implements ReactiveStreamCommands { })); } + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.ReactiveStreamCommands#xClaimJustId(byte[], java.lang.String, java.lang.String, org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions) + */ + @Override + public Flux>> xClaimJustId(Publisher commands) { + + if (true /* TODO: set the JUSTID flag */ ) { + throw new UnsupportedOperationException("Lettuce does not support XCLAIM with JUSTID. (Ref: lettuce-io#1233)"); + } + + return connection.execute(cmd -> Flux.from(commands).map(command -> { + + String[] ids = command.getOptions().getIdsAsStringArray(); + io.lettuce.core.Consumer from = io.lettuce.core.Consumer + .from(ByteUtils.getByteBuffer(command.getGroupName()), ByteUtils.getByteBuffer(command.getConsumerName())); + XClaimArgs args = StreamConverters.toXClaimArgs(command.getOptions()); + + Flux result = cmd.xclaim(command.getKey(), from, args, ids).map(it -> RecordId.of(it.getId())); + return new CommandResponse<>(command, result); + })); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.ReactiveStreamCommands#xClaim(byte[], java.lang.String, java.lang.String, org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions) + */ + @Override + public Flux>> xClaim(Publisher commands) { + + return connection.execute(cmd -> Flux.from(commands).map(command -> { + + String[] ids = command.getOptions().getIdsAsStringArray(); + io.lettuce.core.Consumer from = io.lettuce.core.Consumer + .from(ByteUtils.getByteBuffer(command.getGroupName()), ByteUtils.getByteBuffer(command.getConsumerName())); + XClaimArgs args = StreamConverters.toXClaimArgs(command.getOptions()); + + Flux result = cmd.xclaim(command.getKey(), from, args, ids) + .map(it -> StreamRecords.newRecord().in(it.getStream()).withId(it.getId()).ofBuffer(it.getBody())); + return new CommandResponse<>(command, result); + + })); + } + /* * (non-Javadoc) * @see org.springframework.data.redis.connection.ReactiveStreamCommands#xDel(org.reactivestreams.Publisher) diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceStreamCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceStreamCommands.java index 9f3d13c22..fd26ed339 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceStreamCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceStreamCommands.java @@ -16,6 +16,7 @@ package org.springframework.data.redis.connection.lettuce; import io.lettuce.core.XAddArgs; +import io.lettuce.core.XClaimArgs; import io.lettuce.core.XReadArgs; import io.lettuce.core.cluster.api.async.RedisClusterAsyncCommands; import io.lettuce.core.cluster.api.sync.RedisClusterCommands; @@ -111,6 +112,72 @@ class LettuceStreamCommands implements RedisStreamCommands { } } + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands#xClaimJustId(byte[], java.lang.String, java.lang.String, org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions) + */ + @Override + public List xClaimJustId(byte[] key, String group, String newOwner, XClaimOptions options) { + + String[] ids = options.getIdsAsStringArray(); + io.lettuce.core.Consumer from = io.lettuce.core.Consumer.from(LettuceConverters.toBytes(group), + LettuceConverters.toBytes(newOwner)); + XClaimArgs args = StreamConverters.toXClaimArgs(options); + + if (true /* TODO: set the JUSTID flag */ ) { + throw new UnsupportedOperationException("Lettuce does not support XCLAIM with JUSTID. (Ref: lettuce-io#1233)"); + } + + try { + if (isPipelined()) { + + pipeline(connection.newLettuceResult(getAsyncConnection().xclaim(key, from, args, ids), + StreamConverters.messagesToIds())); + return null; + } + if (isQueueing()) { + + transaction(connection.newLettuceResult(getAsyncConnection().xclaim(key, from, args, ids), + StreamConverters.messagesToIds())); + return null; + } + + } catch (Exception ex) { + throw convertLettuceAccessException(ex); + } + + return StreamConverters.messagesToIds().convert(getConnection().xclaim(key, from, args, ids)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.RedisStreamCommands#xClaim(byte[], java.lang.String, java.lang.String, org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions) + */ + @Override + public List xClaim(byte[] key, String group, String newOwner, XClaimOptions options) { + + String[] ids = options.getIdsAsStringArray(); + io.lettuce.core.Consumer from = io.lettuce.core.Consumer.from(LettuceConverters.toBytes(group), + LettuceConverters.toBytes(newOwner)); + XClaimArgs args = StreamConverters.toXClaimArgs(options); + + try { + if (isPipelined()) { + pipeline(connection.newLettuceResult(getAsyncConnection().xclaim(key, from, args, ids), + StreamConverters.byteRecordListConverter())); + return null; + } + if (isQueueing()) { + transaction(connection.newLettuceResult(getAsyncConnection().xclaim(key, from, args, ids), + StreamConverters.byteRecordListConverter())); + return null; + } + return StreamConverters.byteRecordListConverter().convert(getConnection().xclaim(key, from, args, ids)); + } catch (Exception ex) { + throw convertLettuceAccessException(ex); + } + } + /* * (non-Javadoc) * @see org.springframework.data.redis.connection.RedisStreamCommands#xDel(byte[], java.lang.String[]) diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/StreamConverters.java b/src/main/java/org/springframework/data/redis/connection/lettuce/StreamConverters.java index d42d8b299..77bbc8298 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/StreamConverters.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/StreamConverters.java @@ -16,15 +16,19 @@ package org.springframework.data.redis.connection.lettuce; import io.lettuce.core.StreamMessage; +import io.lettuce.core.XClaimArgs; import io.lettuce.core.XReadArgs; import java.util.List; import org.springframework.core.convert.converter.Converter; +import org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions; +import org.springframework.data.redis.connection.convert.ListConverter; import org.springframework.data.redis.connection.stream.ByteRecord; +import org.springframework.data.redis.connection.stream.RecordId; import org.springframework.data.redis.connection.stream.StreamReadOptions; import org.springframework.data.redis.connection.stream.StreamRecords; -import org.springframework.data.redis.connection.convert.ListConverter; +import org.springframework.lang.Nullable; /** * Converters for Redis Stream-specific types. @@ -39,6 +43,9 @@ import org.springframework.data.redis.connection.convert.ListConverter; @SuppressWarnings({ "unchecked", "rawtypes" }) class StreamConverters { + private static final Converter>, List> MESSAGEs_TO_IDs = new ListConverter<>( + messageToIdConverter()); + /** * Convert {@link StreamReadOptions} to Lettuce's {@link XReadArgs}. * @@ -49,14 +56,33 @@ class StreamConverters { return StreamReadOptionsToXReadArgsConverter.INSTANCE.convert(readOptions); } - public static Converter, ByteRecord> byteRecordConverter() { + /** + * Convert {@link XClaimOptions} to Lettuce's {@link XClaimArgs}. + * + * @param options must not be {@literal null}. + * @return the converted {@link XClaimArgs}. + * @since 2.3 + */ + static XClaimArgs toXClaimArgs(XClaimOptions options) { + return XClaimOptionsToXClaimArgsConverter.INSTANCE.convert(options); + } + + static Converter, ByteRecord> byteRecordConverter() { return (it) -> StreamRecords.newRecord().in(it.getStream()).withId(it.getId()).ofBytes(it.getBody()); } - public static Converter>, List> byteRecordListConverter() { + static Converter>, List> byteRecordListConverter() { return new ListConverter<>(byteRecordConverter()); } + static Converter, RecordId> messageToIdConverter() { + return (it) -> RecordId.of(it.getId()); + } + + static Converter>, List> messagesToIds() { + return MESSAGEs_TO_IDs; + } + /** * {@link Converter} to convert {@link StreamReadOptions} to Lettuce's {@link XReadArgs}. */ @@ -87,4 +113,35 @@ class StreamConverters { return args; } } + + /** + * {@link Converter} to convert {@link XClaimOptions} to Lettuce's {@link XClaimArgs}. + * + * @since 2.3 + */ + enum XClaimOptionsToXClaimArgsConverter implements Converter { + INSTANCE; + + @Nullable + @Override + public XClaimArgs convert(XClaimOptions source) { + + XClaimArgs args = XClaimArgs.Builder.minIdleTime(source.getMinIdleTime()); + args.minIdleTime(source.getMinIdleTime()); + args.force(source.isForce()); + + if (source.getIdleTime() != null) { + args.idle(source.getIdleTime()); + } + if (source.getRetryCount() != null) { + args.retryCount(source.getRetryCount()); + } + if (source.getUnixTime() != null) { + args.time(source.getUnixTime()); + } + + return args; + + } + } } diff --git a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java index 39dd728c7..df348758c 100644 --- a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java @@ -57,6 +57,7 @@ import org.springframework.data.redis.RedisVersionUtils; import org.springframework.data.redis.TestCondition; import org.springframework.data.redis.connection.RedisGeoCommands.GeoLocation; import org.springframework.data.redis.connection.RedisListCommands.Position; +import org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions; import org.springframework.data.redis.connection.RedisStringCommands.BitOperation; import org.springframework.data.redis.connection.RedisStringCommands.SetOption; import org.springframework.data.redis.connection.RedisZSetCommands.Aggregate; @@ -3137,89 +3138,110 @@ public abstract class AbstractConnectionIntegrationTests { assertThat(info.getPendingMessagesPerConsumer()).isEmpty(); } - @Test // DATAREDIS-1084 - @IfProfileValue(name = "redisVersion", value = "5.0") - @WithRedisDriver({ RedisDriver.LETTUCE }) - public void xPendingShouldLoadPendingMessages() { + @Test // DATAREDIS-1084 + @IfProfileValue(name = "redisVersion", value = "5.0") + @WithRedisDriver({ RedisDriver.LETTUCE }) + public void xPendingShouldLoadPendingMessages() { - actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2))); - actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group")); - actual.add(connection.xReadGroupAsString(Consumer.from("my-group", "my-consumer"), - StreamOffset.create(KEY_1, ReadOffset.lastConsumed()))); + actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2))); + actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group")); + actual.add(connection.xReadGroupAsString(Consumer.from("my-group", "my-consumer"), + StreamOffset.create(KEY_1, ReadOffset.lastConsumed()))); - actual.add(connection.xPending(KEY_1, "my-group", org.springframework.data.domain.Range.open("-", "+"), 10L)); + actual.add(connection.xPending(KEY_1, "my-group", org.springframework.data.domain.Range.open("-", "+"), 10L)); - List results = getResults(); - assertThat(results).hasSize(4); - PendingMessages pending = (PendingMessages) results.get(3); + List results = getResults(); + assertThat(results).hasSize(4); + PendingMessages pending = (PendingMessages) results.get(3); - assertThat(pending.size()).isOne(); - assertThat(pending.get(0).getConsumerName()).isEqualTo("my-consumer"); - assertThat(pending.get(0).getGroupName()).isEqualTo("my-group"); - assertThat(pending.get(0).getTotalDeliveryCount()).isOne(); - assertThat(pending.get(0).getStringId()).isNotNull(); + assertThat(pending.size()).isOne(); + assertThat(pending.get(0).getConsumerName()).isEqualTo("my-consumer"); + assertThat(pending.get(0).getGroupName()).isEqualTo("my-group"); + assertThat(pending.get(0).getTotalDeliveryCount()).isOne(); + assertThat(pending.get(0).getStringId()).isNotNull(); - } + } - @Test // DATAREDIS-1084 - @IfProfileValue(name = "redisVersion", value = "5.0") - @WithRedisDriver({ RedisDriver.LETTUCE }) - public void xPendingShouldLoadPendingMessagesForConsumer() { + @Test // DATAREDIS-1084 + @IfProfileValue(name = "redisVersion", value = "5.0") + @WithRedisDriver({ RedisDriver.LETTUCE }) + public void xPendingShouldLoadPendingMessagesForConsumer() { - actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2))); - actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group")); - actual.add(connection.xReadGroupAsString(Consumer.from("my-group", "my-consumer"), - StreamOffset.create(KEY_1, ReadOffset.lastConsumed()))); + actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2))); + actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group")); + actual.add(connection.xReadGroupAsString(Consumer.from("my-group", "my-consumer"), + StreamOffset.create(KEY_1, ReadOffset.lastConsumed()))); - actual.add(connection.xPending(KEY_1, "my-group", "my-consumer", org.springframework.data.domain.Range.open("-", "+"), 10L)); + actual.add(connection.xPending(KEY_1, "my-group", "my-consumer", + org.springframework.data.domain.Range.open("-", "+"), 10L)); - List results = getResults(); - assertThat(results).hasSize(4); - PendingMessages pending = (PendingMessages) results.get(3); + List results = getResults(); + assertThat(results).hasSize(4); + PendingMessages pending = (PendingMessages) results.get(3); - assertThat(pending.size()).isOne(); - assertThat(pending.get(0).getConsumerName()).isEqualTo("my-consumer"); - assertThat(pending.get(0).getGroupName()).isEqualTo("my-group"); - assertThat(pending.get(0).getTotalDeliveryCount()).isOne(); - assertThat(pending.get(0).getStringId()).isNotNull(); - } + assertThat(pending.size()).isOne(); + assertThat(pending.get(0).getConsumerName()).isEqualTo("my-consumer"); + assertThat(pending.get(0).getGroupName()).isEqualTo("my-group"); + assertThat(pending.get(0).getTotalDeliveryCount()).isOne(); + assertThat(pending.get(0).getStringId()).isNotNull(); + } - @Test // DATAREDIS-1084 - @IfProfileValue(name = "redisVersion", value = "5.0") - @WithRedisDriver({ RedisDriver.LETTUCE }) - public void xPendingShouldLoadPendingMessagesForNonExistingConsumer() { + @Test // DATAREDIS-1084 + @IfProfileValue(name = "redisVersion", value = "5.0") + @WithRedisDriver({ RedisDriver.LETTUCE }) + public void xPendingShouldLoadPendingMessagesForNonExistingConsumer() { - actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2))); - actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group")); - actual.add(connection.xReadGroupAsString(Consumer.from("my-group", "my-consumer"), - StreamOffset.create(KEY_1, ReadOffset.lastConsumed()))); + actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2))); + actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group")); + actual.add(connection.xReadGroupAsString(Consumer.from("my-group", "my-consumer"), + StreamOffset.create(KEY_1, ReadOffset.lastConsumed()))); - actual.add(connection.xPending(KEY_1, "my-group", "my-consumer-2", org.springframework.data.domain.Range.open("-", "+"), 10L)); + actual.add(connection.xPending(KEY_1, "my-group", "my-consumer-2", + org.springframework.data.domain.Range.open("-", "+"), 10L)); - List results = getResults(); - assertThat(results).hasSize(4); - PendingMessages pending = (PendingMessages) results.get(3); + List results = getResults(); + assertThat(results).hasSize(4); + PendingMessages pending = (PendingMessages) results.get(3); - assertThat(pending.size()).isZero(); - } + assertThat(pending.size()).isZero(); + } - @Test // DATAREDIS-1084 - @IfProfileValue(name = "redisVersion", value = "5.0") - @WithRedisDriver({ RedisDriver.LETTUCE }) - public void xPendingShouldLoadEmptyPendingMessages() { + @Test // DATAREDIS-1084 + @IfProfileValue(name = "redisVersion", value = "5.0") + @WithRedisDriver({ RedisDriver.LETTUCE }) + public void xPendingShouldLoadEmptyPendingMessages() { - actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2))); - actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group")); + actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2))); + actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group")); - actual.add(connection.xPending(KEY_1, "my-group", org.springframework.data.domain.Range.open("-", "+"), 10L)); + actual.add(connection.xPending(KEY_1, "my-group", org.springframework.data.domain.Range.open("-", "+"), 10L)); + List results = getResults(); + assertThat(results).hasSize(3); + PendingMessages pending = (PendingMessages) results.get(2); - List results = getResults(); - assertThat(results).hasSize(3); - PendingMessages pending = (PendingMessages) results.get(2); + assertThat(pending.size()).isZero(); + } - assertThat(pending.size()).isZero(); - } + @Test // DATAREDIS-1084 + @IfProfileValue(name = "redisVersion", value = "5.0") + @WithRedisDriver({ RedisDriver.LETTUCE }) + public void xClaim() throws InterruptedException { + + actual.add(connection.xAdd(KEY_1, Collections.singletonMap(KEY_2, VALUE_2))); + actual.add(connection.xGroupCreate(KEY_1, ReadOffset.from("0"), "my-group")); + actual.add(connection.xReadGroupAsString(Consumer.from("my-group", "my-consumer"), + StreamOffset.create(KEY_1, ReadOffset.lastConsumed()))); + + List> messages = (List>) getResults().get(2); + TimeUnit.MILLISECONDS.sleep(5); + + actual.add(connection.xClaim(KEY_1, "my-group", "my-consumer", + XClaimOptions.minIdle(Duration.ofMillis(1)).ids(messages.get(0).getId()))); + + List> claimed = (List>) getResults().get(3); + assertThat(claimed).containsAll(messages); + } protected void verifyResults(List expected) { assertThat(getResults()).isEqualTo(expected); diff --git a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionPipelineIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionPipelineIntegrationTests.java index 479fcdb4e..6cd014e67 100644 --- a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionPipelineIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionPipelineIntegrationTests.java @@ -126,6 +126,12 @@ abstract public class AbstractConnectionPipelineIntegrationTests extends Abstrac super.scanShouldReadEntireValueRangeWhenIdividualScanIterationsReturnEmptyCollection(); } + @Test + @Ignore + public void xClaim() throws InterruptedException { + super.xClaim(); + } + protected void initConnection() { connection.openPipeline(); } diff --git a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionTransactionIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionTransactionIntegrationTests.java index 4560f0578..2cef28669 100644 --- a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionTransactionIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionTransactionIntegrationTests.java @@ -114,6 +114,12 @@ abstract public class AbstractConnectionTransactionIntegrationTests extends Abst super.scanShouldReadEntireValueRangeWhenIdividualScanIterationsReturnEmptyCollection(); } + @Test + @Ignore + public void xClaim() throws InterruptedException { + super.xClaim(); + } + protected void initConnection() { connection.multi(); } diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommandsTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommandsTests.java index cd02d840b..82946f18d 100644 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommandsTests.java +++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceReactiveStreamCommandsTests.java @@ -21,6 +21,7 @@ import static org.junit.Assume.*; import io.lettuce.core.XReadArgs; import reactor.test.StepVerifier; +import java.time.Duration; import java.util.Collections; import org.junit.Before; @@ -28,6 +29,7 @@ import org.junit.Ignore; import org.junit.Test; import org.springframework.data.domain.Range; import org.springframework.data.redis.RedisTestProfileValueSource; +import org.springframework.data.redis.connection.RedisStreamCommands.XClaimOptions; import org.springframework.data.redis.connection.RedisZSetCommands.Limit; import org.springframework.data.redis.connection.stream.Consumer; import org.springframework.data.redis.connection.stream.ReadOffset; @@ -291,8 +293,8 @@ public class LettuceReactiveStreamCommandsTests extends LettuceReactiveCommandsT .then().as(StepVerifier::create) // .verifyComplete(); - connection.streamCommands().xPending(KEY_1_BBUFFER, "my-group", "my-consumer", Range.open("-", "+"), 10L).as(StepVerifier::create) - .assertNext(it -> { + connection.streamCommands().xPending(KEY_1_BBUFFER, "my-group", "my-consumer", Range.open("-", "+"), 10L) + .as(StepVerifier::create).assertNext(it -> { assertThat(it.size()).isOne(); assertThat(it.get(0).getConsumerName()).isEqualTo("my-consumer"); @@ -316,8 +318,8 @@ public class LettuceReactiveStreamCommandsTests extends LettuceReactiveCommandsT .then().as(StepVerifier::create) // .verifyComplete(); - connection.streamCommands().xPending(KEY_1_BBUFFER, "my-group", "my-consumer-2", Range.open("-", "+"), 10L).as(StepVerifier::create) - .assertNext(it -> { + connection.streamCommands().xPending(KEY_1_BBUFFER, "my-group", "my-consumer-2", Range.open("-", "+"), 10L) + .as(StepVerifier::create).assertNext(it -> { assertThat(it.size()).isZero(); }).verifyComplete(); @@ -337,4 +339,26 @@ public class LettuceReactiveStreamCommandsTests extends LettuceReactiveCommandsT }).verifyComplete(); } + @Test // DATAREDIS-1084 + public void xClaim() { + + String initialMessage = nativeCommands.xadd(KEY_1, KEY_1, VALUE_1); + nativeCommands.xgroupCreate(XReadArgs.StreamOffset.from(KEY_1, initialMessage), "my-group"); + + String expected = nativeCommands.xadd(KEY_1, KEY_2, VALUE_2); + + connection.streamCommands() + .xReadGroup(Consumer.from("my-group", "my-consumer"), + StreamOffset.create(KEY_1_BBUFFER, ReadOffset.lastConsumed())) // + .delayElements(Duration.ofMillis(5)).next() // + .flatMapMany(record -> { + return connection.streamCommands().xClaim(KEY_1_BBUFFER, "my-group", "my-consumer", + XClaimOptions.minIdle(Duration.ofMillis(1)).ids(record.getId())); + } + + ).as(StepVerifier::create) // + .assertNext(it -> assertThat(it.getId().getValue()).isEqualTo(expected)) // + .verifyComplete(); + } + }