diff --git a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java index 167fa29268..a26c66ddba 100644 --- a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java +++ b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java @@ -81,6 +81,16 @@ import io.lettuce.core.search.arguments.SugAddArgs; import io.lettuce.core.search.arguments.SugGetArgs; import io.lettuce.core.search.arguments.SynUpdateArgs; +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; import io.lettuce.core.vector.RawVector; import io.lettuce.core.vector.VSimScoreAttribs; import io.lettuce.core.vector.VectorMetadata; @@ -123,7 +133,8 @@ public abstract class AbstractRedisAsyncCommands implements RedisAclAsyncC RedisHLLAsyncCommands, BaseRedisAsyncCommands, RedisTransactionalAsyncCommands, RedisGeoAsyncCommands, RedisClusterAsyncCommands, RedisJsonAsyncCommands, RedisVectorSetAsyncCommands, RediSearchAsyncCommands, RedisArrayAsyncCommands, - RedisBloomFilterAsyncCommands, RedisCuckooFilterAsyncCommands, RedisTopKAsyncCommands { + RedisBloomFilterAsyncCommands, RedisCuckooFilterAsyncCommands, RedisTopKAsyncCommands, + RedisTimeSeriesAsyncCommands { private final StatefulConnection connection; @@ -143,6 +154,8 @@ public abstract class AbstractRedisAsyncCommands implements RedisAclAsyncC private final RedisTopKCommandBuilder topKCommandBuilder; + private final RedisTimeSeriesCommandBuilder timeSeriesCommandBuilder; + private final Supplier parser; /** @@ -164,6 +177,7 @@ public AbstractRedisAsyncCommands(StatefulConnection connection, RedisCode this.bloomFilterCommandBuilder = new RedisBloomFilterCommandBuilder<>(codec); this.cuckooFilterCommandBuilder = new RedisCuckooFilterCommandBuilder<>(codec); this.topKCommandBuilder = new RedisTopKCommandBuilder<>(codec); + this.timeSeriesCommandBuilder = new RedisTimeSeriesCommandBuilder<>(codec); } /** @@ -4483,4 +4497,138 @@ public RedisFuture topKReserve(K key, long k, TopKReserveArgs args) { return dispatch(topKCommandBuilder.topKReserve(key, k, args)); } + // --- Redis Time Series Commands --- + + @Override + public RedisFuture tsCreate(K key) { + return dispatch(timeSeriesCommandBuilder.tsCreate(key)); + } + + @Override + public RedisFuture tsCreate(K key, TsCreateArgs createArgs) { + return dispatch(timeSeriesCommandBuilder.tsCreate(key, createArgs)); + } + + @Override + public RedisFuture tsAlter(K key, TsAlterArgs alterArgs) { + return dispatch(timeSeriesCommandBuilder.tsAlter(key, alterArgs)); + } + + @Override + public RedisFuture tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration) { + return dispatch(timeSeriesCommandBuilder.tsCreateRule(sourceKey, destKey, aggregationType, bucketDuration)); + } + + @Override + public RedisFuture tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration, + long alignTimestamp) { + return dispatch( + timeSeriesCommandBuilder.tsCreateRule(sourceKey, destKey, aggregationType, bucketDuration, alignTimestamp)); + } + + @Override + public RedisFuture tsDeleteRule(K sourceKey, K destKey) { + return dispatch(timeSeriesCommandBuilder.tsDeleteRule(sourceKey, destKey)); + } + + @Override + public RedisFuture tsDel(K key, long fromTimestamp, long toTimestamp) { + return dispatch(timeSeriesCommandBuilder.tsDel(key, fromTimestamp, toTimestamp)); + } + + @Override + public RedisFuture tsAdd(K key, long timestamp, double value) { + return dispatch(timeSeriesCommandBuilder.tsAdd(key, timestamp, value)); + } + + @Override + public RedisFuture tsAdd(K key, long timestamp, double value, TsAddArgs addArgs) { + return dispatch(timeSeriesCommandBuilder.tsAdd(key, timestamp, value, addArgs)); + } + + @Override + public RedisFuture tsAdd(K key, double value) { + return dispatch(timeSeriesCommandBuilder.tsAdd(key, value)); + } + + @Override + public RedisFuture> tsMAdd(Map.Entry... entries) { + return dispatch(timeSeriesCommandBuilder.tsMAdd(entries)); + } + + @Override + public RedisFuture> tsMAdd(Map.Entry entry) { + return dispatch(timeSeriesCommandBuilder.tsMAdd(entry)); + } + + @Override + public RedisFuture tsIncrBy(K key, double value) { + return dispatch(timeSeriesCommandBuilder.tsIncrBy(key, value)); + } + + @Override + public RedisFuture tsIncrBy(K key, double value, TsIncrByArgs incrByArgs) { + return dispatch(timeSeriesCommandBuilder.tsIncrBy(key, value, incrByArgs)); + } + + @Override + public RedisFuture tsDecrBy(K key, double value) { + return dispatch(timeSeriesCommandBuilder.tsDecrBy(key, value)); + } + + @Override + public RedisFuture tsDecrBy(K key, double value, TsIncrByArgs decrByArgs) { + return dispatch(timeSeriesCommandBuilder.tsDecrBy(key, value, decrByArgs)); + } + + @Override + public RedisFuture tsGet(K key) { + return dispatch(timeSeriesCommandBuilder.tsGet(key)); + } + + @Override + public RedisFuture tsGet(K key, TsGetArgs getArgs) { + return dispatch(timeSeriesCommandBuilder.tsGet(key, getArgs)); + } + + @Override + public RedisFuture>> tsMGet(V... filters) { + return dispatch(timeSeriesCommandBuilder.tsMGet(filters)); + } + + @Override + public RedisFuture>> tsMGet(V filter) { + return dispatch(timeSeriesCommandBuilder.tsMGet(filter)); + } + + @Override + public RedisFuture>> tsMGet(TsMGetArgs mGetArgs, V... filters) { + return dispatch(timeSeriesCommandBuilder.tsMGet(mGetArgs, filters)); + } + + @Override + public RedisFuture>> tsMGet(TsMGetArgs mGetArgs, V filter) { + return dispatch(timeSeriesCommandBuilder.tsMGet(mGetArgs, filter)); + } + + @Override + public RedisFuture> tsInfo(K key) { + return dispatch(timeSeriesCommandBuilder.tsInfo(key)); + } + + @Override + public RedisFuture> tsInfoDebug(K key) { + return dispatch(timeSeriesCommandBuilder.tsInfoDebug(key)); + } + + @Override + public RedisFuture> tsQueryIndex(V... filters) { + return dispatch(timeSeriesCommandBuilder.tsQueryIndex(filters)); + } + + @Override + public RedisFuture> tsQueryIndex(V filter) { + return dispatch(timeSeriesCommandBuilder.tsQueryIndex(filter)); + } + } diff --git a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java index 9c104e4bef..5b931a0e94 100644 --- a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java +++ b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java @@ -81,6 +81,16 @@ import io.lettuce.core.search.arguments.SugAddArgs; import io.lettuce.core.search.arguments.SugGetArgs; import io.lettuce.core.search.arguments.SynUpdateArgs; +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; import io.lettuce.core.tracing.TraceContext; import io.lettuce.core.tracing.TraceContextProvider; import io.lettuce.core.vector.RawVector; @@ -123,14 +133,14 @@ * @author dae won * @since 4.0 */ -public abstract class AbstractRedisReactiveCommands - implements RedisAclReactiveCommands, RedisHashReactiveCommands, RedisKeyReactiveCommands, - RedisStringReactiveCommands, RedisListReactiveCommands, RedisSetReactiveCommands, - RedisSortedSetReactiveCommands, RedisScriptingReactiveCommands, RedisServerReactiveCommands, - RedisHLLReactiveCommands, BaseRedisReactiveCommands, RedisTransactionalReactiveCommands, - RedisGeoReactiveCommands, RedisClusterReactiveCommands, RedisJsonReactiveCommands, - RedisVectorSetReactiveCommands, RediSearchReactiveCommands, RedisArrayReactiveCommands, - RedisBloomFilterReactiveCommands, RedisCuckooFilterReactiveCommands, RedisTopKReactiveCommands { +public abstract class AbstractRedisReactiveCommands implements RedisAclReactiveCommands, + RedisHashReactiveCommands, RedisKeyReactiveCommands, RedisStringReactiveCommands, + RedisListReactiveCommands, RedisSetReactiveCommands, RedisSortedSetReactiveCommands, + RedisScriptingReactiveCommands, RedisServerReactiveCommands, RedisHLLReactiveCommands, + BaseRedisReactiveCommands, RedisTransactionalReactiveCommands, RedisGeoReactiveCommands, + RedisClusterReactiveCommands, RedisJsonReactiveCommands, RedisVectorSetReactiveCommands, + RediSearchReactiveCommands, RedisArrayReactiveCommands, RedisBloomFilterReactiveCommands, + RedisCuckooFilterReactiveCommands, RedisTopKReactiveCommands, RedisTimeSeriesReactiveCommands { private final StatefulConnection connection; @@ -150,6 +160,8 @@ public abstract class AbstractRedisReactiveCommands private final RedisTopKCommandBuilder topKCommandBuilder; + private final RedisTimeSeriesCommandBuilder timeSeriesCommandBuilder; + private final Supplier parser; private final ClientResources clientResources; @@ -177,6 +189,7 @@ public AbstractRedisReactiveCommands(StatefulConnection connection, RedisC this.bloomFilterCommandBuilder = new RedisBloomFilterCommandBuilder<>(codec); this.cuckooFilterCommandBuilder = new RedisCuckooFilterCommandBuilder<>(codec); this.topKCommandBuilder = new RedisTopKCommandBuilder<>(codec); + this.timeSeriesCommandBuilder = new RedisTimeSeriesCommandBuilder<>(codec); this.clientResources = connection.getResources(); this.tracingEnabled = clientResources.tracing().isEnabled(); } @@ -4575,4 +4588,138 @@ public Mono topKReserve(K key, long k, TopKReserveArgs args) { return createMono(() -> topKCommandBuilder.topKReserve(key, k, args)); } + // --- Redis Time Series Commands --- + + @Override + public Mono tsCreate(K key) { + return createMono(() -> timeSeriesCommandBuilder.tsCreate(key)); + } + + @Override + public Mono tsCreate(K key, TsCreateArgs createArgs) { + return createMono(() -> timeSeriesCommandBuilder.tsCreate(key, createArgs)); + } + + @Override + public Mono tsAlter(K key, TsAlterArgs alterArgs) { + return createMono(() -> timeSeriesCommandBuilder.tsAlter(key, alterArgs)); + } + + @Override + public Mono tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration) { + return createMono(() -> timeSeriesCommandBuilder.tsCreateRule(sourceKey, destKey, aggregationType, bucketDuration)); + } + + @Override + public Mono tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration, + long alignTimestamp) { + return createMono(() -> timeSeriesCommandBuilder.tsCreateRule(sourceKey, destKey, aggregationType, bucketDuration, + alignTimestamp)); + } + + @Override + public Mono tsDeleteRule(K sourceKey, K destKey) { + return createMono(() -> timeSeriesCommandBuilder.tsDeleteRule(sourceKey, destKey)); + } + + @Override + public Mono tsDel(K key, long fromTimestamp, long toTimestamp) { + return createMono(() -> timeSeriesCommandBuilder.tsDel(key, fromTimestamp, toTimestamp)); + } + + @Override + public Mono tsAdd(K key, long timestamp, double value) { + return createMono(() -> timeSeriesCommandBuilder.tsAdd(key, timestamp, value)); + } + + @Override + public Mono tsAdd(K key, long timestamp, double value, TsAddArgs addArgs) { + return createMono(() -> timeSeriesCommandBuilder.tsAdd(key, timestamp, value, addArgs)); + } + + @Override + public Mono tsAdd(K key, double value) { + return createMono(() -> timeSeriesCommandBuilder.tsAdd(key, value)); + } + + @Override + public Flux tsMAdd(Map.Entry... entries) { + return createDissolvingFlux(() -> timeSeriesCommandBuilder.tsMAdd(entries)); + } + + @Override + public Flux tsMAdd(Map.Entry entry) { + return createDissolvingFlux(() -> timeSeriesCommandBuilder.tsMAdd(entry)); + } + + @Override + public Mono tsIncrBy(K key, double value) { + return createMono(() -> timeSeriesCommandBuilder.tsIncrBy(key, value)); + } + + @Override + public Mono tsIncrBy(K key, double value, TsIncrByArgs incrByArgs) { + return createMono(() -> timeSeriesCommandBuilder.tsIncrBy(key, value, incrByArgs)); + } + + @Override + public Mono tsDecrBy(K key, double value) { + return createMono(() -> timeSeriesCommandBuilder.tsDecrBy(key, value)); + } + + @Override + public Mono tsDecrBy(K key, double value, TsIncrByArgs decrByArgs) { + return createMono(() -> timeSeriesCommandBuilder.tsDecrBy(key, value, decrByArgs)); + } + + @Override + public Mono tsGet(K key) { + return createMono(() -> timeSeriesCommandBuilder.tsGet(key)); + } + + @Override + public Mono tsGet(K key, TsGetArgs getArgs) { + return createMono(() -> timeSeriesCommandBuilder.tsGet(key, getArgs)); + } + + @Override + public Flux> tsMGet(V... filters) { + return createDissolvingFlux(() -> timeSeriesCommandBuilder.tsMGet(filters)); + } + + @Override + public Flux> tsMGet(V filter) { + return createDissolvingFlux(() -> timeSeriesCommandBuilder.tsMGet(filter)); + } + + @Override + public Flux> tsMGet(TsMGetArgs mGetArgs, V... filters) { + return createDissolvingFlux(() -> timeSeriesCommandBuilder.tsMGet(mGetArgs, filters)); + } + + @Override + public Flux> tsMGet(TsMGetArgs mGetArgs, V filter) { + return createDissolvingFlux(() -> timeSeriesCommandBuilder.tsMGet(mGetArgs, filter)); + } + + @Override + public Mono> tsInfo(K key) { + return createMono(() -> timeSeriesCommandBuilder.tsInfo(key)); + } + + @Override + public Mono> tsInfoDebug(K key) { + return createMono(() -> timeSeriesCommandBuilder.tsInfoDebug(key)); + } + + @Override + public Flux tsQueryIndex(V... filters) { + return createDissolvingFlux(() -> timeSeriesCommandBuilder.tsQueryIndex(filters)); + } + + @Override + public Flux tsQueryIndex(V filter) { + return createDissolvingFlux(() -> timeSeriesCommandBuilder.tsQueryIndex(filter)); + } + } diff --git a/src/main/java/io/lettuce/core/RedisTimeSeriesCommandBuilder.java b/src/main/java/io/lettuce/core/RedisTimeSeriesCommandBuilder.java new file mode 100644 index 0000000000..37a362cc9d --- /dev/null +++ b/src/main/java/io/lettuce/core/RedisTimeSeriesCommandBuilder.java @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core; + +import java.util.List; +import java.util.Map; + +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.output.EncodedComplexOutput; +import io.lettuce.core.output.IntegerListOutput; +import io.lettuce.core.output.IntegerOutput; +import io.lettuce.core.output.KeyListOutput; +import io.lettuce.core.output.StatusOutput; +import io.lettuce.core.protocol.BaseRedisCommandBuilder; +import io.lettuce.core.protocol.Command; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.protocol.CommandKeyword; +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsInfoValueParser; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsMGetValueParser; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.TsSampleParser; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; + +import static io.lettuce.core.protocol.CommandType.*; + +/** + * Implementation of the {@link BaseRedisCommandBuilder} handling RedisTimeSeries commands. + * + * @param Key type. + * @param Value type. + * @author Gyumin Hwang + * @since 7.7 + */ +class RedisTimeSeriesCommandBuilder extends BaseRedisCommandBuilder { + + RedisTimeSeriesCommandBuilder(RedisCodec codec) { + super(codec); + } + + Command tsCreate(K key) { + notNullKey(key); + + return createCommand(TS_CREATE, new StatusOutput<>(codec), key); + } + + Command tsCreate(K key, TsCreateArgs createArgs) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key); + createArgs.build(args); + + return createCommand(TS_CREATE, new StatusOutput<>(codec), args); + } + + Command tsAlter(K key, TsAlterArgs alterArgs) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key); + alterArgs.build(args); + + return createCommand(TS_ALTER, new StatusOutput<>(codec), args); + } + + Command tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration) { + notNullKey(sourceKey); + notNullKey(destKey); + + CommandArgs args = new CommandArgs<>(codec).addKey(sourceKey).addKey(destKey).add(CommandKeyword.AGGREGATION) + .add(aggregationType).add(bucketDuration); + + return createCommand(TS_CREATERULE, new StatusOutput<>(codec), args); + } + + Command tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration, + long alignTimestamp) { + notNullKey(sourceKey); + notNullKey(destKey); + + CommandArgs args = new CommandArgs<>(codec).addKey(sourceKey).addKey(destKey).add(CommandKeyword.AGGREGATION) + .add(aggregationType).add(bucketDuration).add(alignTimestamp); + + return createCommand(TS_CREATERULE, new StatusOutput<>(codec), args); + } + + Command tsDeleteRule(K sourceKey, K destKey) { + notNullKey(sourceKey); + notNullKey(destKey); + + CommandArgs args = new CommandArgs<>(codec).addKey(sourceKey).addKey(destKey); + + return createCommand(TS_DELETERULE, new StatusOutput<>(codec), args); + } + + Command tsDel(K key, long fromTimestamp, long toTimestamp) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key).add(fromTimestamp).add(toTimestamp); + + return createCommand(TS_DEL, new IntegerOutput<>(codec), args); + } + + Command tsAdd(K key, long timestamp, double value) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key).add(timestamp).add(value); + + return createCommand(TS_ADD, new IntegerOutput<>(codec), args); + } + + Command tsAdd(K key, long timestamp, double value, TsAddArgs addArgs) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key).add(timestamp).add(value); + addArgs.build(args); + + return createCommand(TS_ADD, new IntegerOutput<>(codec), args); + } + + Command tsAdd(K key, double value) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key).add("*").add(value); + + return createCommand(TS_ADD, new IntegerOutput<>(codec), args); + } + + @SafeVarargs + final Command> tsMAdd(Map.Entry... entries) { + notEmpty(entries); + + CommandArgs args = new CommandArgs<>(codec); + for (Map.Entry entry : entries) { + addMAddEntry(args, entry); + } + + return createCommand(TS_MADD, new IntegerListOutput<>(codec), args); + } + + Command> tsMAdd(Map.Entry entry) { + CommandArgs args = new CommandArgs<>(codec); + addMAddEntry(args, entry); + + return createCommand(TS_MADD, new IntegerListOutput<>(codec), args); + } + + private void addMAddEntry(CommandArgs args, Map.Entry entry) { + TsSample sample = entry.getValue(); + if (sample.getValues().size() != 1) { + throw new IllegalArgumentException("TS.MADD does not support samples with more than one value"); + } + args.addKey(entry.getKey()).add(sample.getTimestamp()).add(sample.getValue()); + } + + Command tsIncrBy(K key, double value) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key).add(value); + + return createCommand(TS_INCRBY, new IntegerOutput<>(codec), args); + } + + Command tsIncrBy(K key, double value, TsIncrByArgs incrByArgs) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key).add(value); + incrByArgs.build(args); + + return createCommand(TS_INCRBY, new IntegerOutput<>(codec), args); + } + + Command tsDecrBy(K key, double value) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key).add(value); + + return createCommand(TS_DECRBY, new IntegerOutput<>(codec), args); + } + + Command tsDecrBy(K key, double value, TsIncrByArgs decrByArgs) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key).add(value); + decrByArgs.build(args); + + return createCommand(TS_DECRBY, new IntegerOutput<>(codec), args); + } + + Command tsGet(K key) { + notNullKey(key); + + return createCommand(TS_GET, new EncodedComplexOutput<>(codec, TsSampleParser.INSTANCE), key); + } + + Command tsGet(K key, TsGetArgs getArgs) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key); + getArgs.build(args); + + return createCommand(TS_GET, new EncodedComplexOutput<>(codec, TsSampleParser.INSTANCE), args); + } + + Command> tsInfo(K key) { + notNullKey(key); + + return createCommand(TS_INFO, new EncodedComplexOutput<>(codec, new TsInfoValueParser<>(codec)), key); + } + + Command> tsInfoDebug(K key) { + notNullKey(key); + + CommandArgs args = new CommandArgs<>(codec).addKey(key).add("DEBUG"); + + return createCommand(TS_INFO, new EncodedComplexOutput<>(codec, new TsInfoValueParser<>(codec)), args); + } + + @SafeVarargs + final Command>> tsMGet(V... filters) { + notEmptyValues(filters); + + CommandArgs args = new CommandArgs<>(codec).add(CommandKeyword.FILTER).addValues(filters); + + return createCommand(TS_MGET, new EncodedComplexOutput<>(codec, new TsMGetValueParser<>(codec)), args); + } + + Command>> tsMGet(V filter) { + CommandArgs args = new CommandArgs<>(codec).add(CommandKeyword.FILTER).addValue(filter); + + return createCommand(TS_MGET, new EncodedComplexOutput<>(codec, new TsMGetValueParser<>(codec)), args); + } + + @SafeVarargs + final Command>> tsMGet(TsMGetArgs mGetArgs, V... filters) { + notEmptyValues(filters); + + CommandArgs args = new CommandArgs<>(codec); + mGetArgs.build(args); + args.add(CommandKeyword.FILTER).addValues(filters); + + return createCommand(TS_MGET, new EncodedComplexOutput<>(codec, new TsMGetValueParser<>(codec)), args); + } + + Command>> tsMGet(TsMGetArgs mGetArgs, V filter) { + CommandArgs args = new CommandArgs<>(codec); + mGetArgs.build(args); + args.add(CommandKeyword.FILTER).addValue(filter); + + return createCommand(TS_MGET, new EncodedComplexOutput<>(codec, new TsMGetValueParser<>(codec)), args); + } + + @SafeVarargs + final Command> tsQueryIndex(V... filters) { + notEmptyValues(filters); + + CommandArgs args = new CommandArgs<>(codec).addValues(filters); + + return createCommand(TS_QUERYINDEX, new KeyListOutput<>(codec), args); + } + + Command> tsQueryIndex(V filter) { + CommandArgs args = new CommandArgs<>(codec).addValue(filter); + + return createCommand(TS_QUERYINDEX, new KeyListOutput<>(codec), args); + } + +} diff --git a/src/main/java/io/lettuce/core/api/async/RedisAsyncCommands.java b/src/main/java/io/lettuce/core/api/async/RedisAsyncCommands.java index 835ea3ea83..acbd84a712 100644 --- a/src/main/java/io/lettuce/core/api/async/RedisAsyncCommands.java +++ b/src/main/java/io/lettuce/core/api/async/RedisAsyncCommands.java @@ -42,7 +42,7 @@ public interface RedisAsyncCommands extends BaseRedisAsyncCommands, RedisSortedSetAsyncCommands, RedisStreamAsyncCommands, RedisStringAsyncCommands, RedisTransactionalAsyncCommands, RedisJsonAsyncCommands, RedisVectorSetAsyncCommands, RediSearchAsyncCommands, RedisArrayAsyncCommands, RedisBloomFilterAsyncCommands, - RedisCuckooFilterAsyncCommands, RedisTopKAsyncCommands { + RedisCuckooFilterAsyncCommands, RedisTimeSeriesAsyncCommands, RedisTopKAsyncCommands { /** * Authenticate to the server. diff --git a/src/main/java/io/lettuce/core/api/async/RedisTimeSeriesAsyncCommands.java b/src/main/java/io/lettuce/core/api/async/RedisTimeSeriesAsyncCommands.java new file mode 100644 index 0000000000..510d30dd10 --- /dev/null +++ b/src/main/java/io/lettuce/core/api/async/RedisTimeSeriesAsyncCommands.java @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.api.async; + +import java.util.List; +import java.util.Map; + +import io.lettuce.core.RedisFuture; +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; + +/** + * Asynchronous executed commands for RedisTimeSeries. + * + * @author Gyumin Hwang + * @param Key type. + * @param Value type. + * @see Redis Time Series + * @since 7.7 + * @generated by io.lettuce.apigenerator.CreateAsyncApi + */ +public interface RedisTimeSeriesAsyncCommands { + + /** + * Create a new time series. + * + * @param key the key. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + RedisFuture tsCreate(K key); + + /** + * Create a new time series. + * + * @param key the key. + * @param createArgs the create arguments. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + RedisFuture tsCreate(K key, TsCreateArgs createArgs); + + /** + * Update the retention, chunk size, duplicate policy, and/or labels of an existing time series. + * + * @param key the key. + * @param alterArgs the alter arguments. + * @return String simple-string-reply {@code OK} if {@code TS.ALTER} was executed correctly. + */ + RedisFuture tsAlter(K key, TsAlterArgs alterArgs); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + RedisFuture tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @param alignTimestamp ensures that there is a bucket that starts exactly at this timestamp, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + RedisFuture tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration, + long alignTimestamp); + + /** + * Delete a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key. + * @return String simple-string-reply {@code OK} if {@code TS.DELETERULE} was executed correctly. + */ + RedisFuture tsDeleteRule(K sourceKey, K destKey); + + /** + * Delete all samples between two timestamps for a given time series. + * + * @param key the key. + * @param fromTimestamp start timestamp, in milliseconds. + * @param toTimestamp end timestamp, in milliseconds. + * @return Long integer-reply the number of samples that were removed. + */ + RedisFuture tsDel(K key, long fromTimestamp, long toTimestamp); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + RedisFuture tsAdd(K key, long timestamp, double value); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @param addArgs the add arguments. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + RedisFuture tsAdd(K key, long timestamp, double value, TsAddArgs addArgs); + + /** + * Append a sample to a time series, letting the server assign the current time as the sample timestamp, creating the series + * automatically if it does not yet exist. + * + * @param key the key. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + RedisFuture tsAdd(K key, double value); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entries the (key, sample) entries to append; each {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if any {@link TsSample} carries more than one value. + */ + RedisFuture> tsMAdd(Map.Entry... entries); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entry the (key, sample) entry to append; the {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if the {@link TsSample} carries more than one value. + */ + RedisFuture> tsMAdd(Map.Entry entry); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + RedisFuture tsIncrBy(K key, double value); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @param incrByArgs the increment-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + RedisFuture tsIncrBy(K key, double value, TsIncrByArgs incrByArgs); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + RedisFuture tsDecrBy(K key, double value); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @param decrByArgs the decrement-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + RedisFuture tsDecrBy(K key, double value, TsIncrByArgs decrByArgs); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + RedisFuture tsGet(K key); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @param getArgs the get arguments. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + RedisFuture tsGet(K key, TsGetArgs getArgs); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + RedisFuture>> tsMGet(V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + RedisFuture>> tsMGet(V filter); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + RedisFuture>> tsMGet(TsMGetArgs mGetArgs, V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + RedisFuture>> tsMGet(TsMGetArgs mGetArgs, V filter); + + /** + * Get metadata about a time series. + * + * @param key the key. + * @return TsInfoValue metadata about the time series. + */ + RedisFuture> tsInfo(K key); + + /** + * Get metadata about a time series, including chunk-level debug information. + * + * @param key the key. + * @return TsInfoValue metadata about the time series, including chunk-level debug information. + */ + RedisFuture> tsInfoDebug(K key); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + RedisFuture> tsQueryIndex(V... filters); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + RedisFuture> tsQueryIndex(V filter); + +} diff --git a/src/main/java/io/lettuce/core/api/reactive/RedisReactiveCommands.java b/src/main/java/io/lettuce/core/api/reactive/RedisReactiveCommands.java index 4198c95e12..b522353549 100644 --- a/src/main/java/io/lettuce/core/api/reactive/RedisReactiveCommands.java +++ b/src/main/java/io/lettuce/core/api/reactive/RedisReactiveCommands.java @@ -34,14 +34,15 @@ * @author Yordan Tsintsov * @since 5.0 */ -public interface RedisReactiveCommands extends BaseRedisReactiveCommands, RedisAclReactiveCommands, - RedisClusterReactiveCommands, RedisFunctionReactiveCommands, RedisGeoReactiveCommands, - RedisHashReactiveCommands, RedisHLLReactiveCommands, RedisKeyReactiveCommands, - RedisListReactiveCommands, RedisScriptingReactiveCommands, RedisServerReactiveCommands, - RedisSetReactiveCommands, RedisSortedSetReactiveCommands, RedisStreamReactiveCommands, - RedisStringReactiveCommands, RedisTransactionalReactiveCommands, RedisJsonReactiveCommands, - RedisVectorSetReactiveCommands, RediSearchReactiveCommands, RedisArrayReactiveCommands, - RedisBloomFilterReactiveCommands, RedisCuckooFilterReactiveCommands, RedisTopKReactiveCommands { +public interface RedisReactiveCommands + extends BaseRedisReactiveCommands, RedisAclReactiveCommands, RedisClusterReactiveCommands, + RedisFunctionReactiveCommands, RedisGeoReactiveCommands, RedisHashReactiveCommands, + RedisHLLReactiveCommands, RedisKeyReactiveCommands, RedisListReactiveCommands, + RedisScriptingReactiveCommands, RedisServerReactiveCommands, RedisSetReactiveCommands, + RedisSortedSetReactiveCommands, RedisStreamReactiveCommands, RedisStringReactiveCommands, + RedisTransactionalReactiveCommands, RedisJsonReactiveCommands, RedisVectorSetReactiveCommands, + RediSearchReactiveCommands, RedisArrayReactiveCommands, RedisBloomFilterReactiveCommands, + RedisCuckooFilterReactiveCommands, RedisTimeSeriesReactiveCommands, RedisTopKReactiveCommands { /** * Authenticate to the server. diff --git a/src/main/java/io/lettuce/core/api/reactive/RedisTimeSeriesReactiveCommands.java b/src/main/java/io/lettuce/core/api/reactive/RedisTimeSeriesReactiveCommands.java new file mode 100644 index 0000000000..4fcf177e0b --- /dev/null +++ b/src/main/java/io/lettuce/core/api/reactive/RedisTimeSeriesReactiveCommands.java @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.api.reactive; + +import java.util.List; +import java.util.Map; + +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Reactive executed commands for RedisTimeSeries. + * + * @author Gyumin Hwang + * @param Key type. + * @param Value type. + * @see Redis Time Series + * @since 7.7 + * @generated by io.lettuce.apigenerator.CreateReactiveApi + */ +public interface RedisTimeSeriesReactiveCommands { + + /** + * Create a new time series. + * + * @param key the key. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + Mono tsCreate(K key); + + /** + * Create a new time series. + * + * @param key the key. + * @param createArgs the create arguments. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + Mono tsCreate(K key, TsCreateArgs createArgs); + + /** + * Update the retention, chunk size, duplicate policy, and/or labels of an existing time series. + * + * @param key the key. + * @param alterArgs the alter arguments. + * @return String simple-string-reply {@code OK} if {@code TS.ALTER} was executed correctly. + */ + Mono tsAlter(K key, TsAlterArgs alterArgs); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + Mono tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @param alignTimestamp ensures that there is a bucket that starts exactly at this timestamp, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + Mono tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration, + long alignTimestamp); + + /** + * Delete a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key. + * @return String simple-string-reply {@code OK} if {@code TS.DELETERULE} was executed correctly. + */ + Mono tsDeleteRule(K sourceKey, K destKey); + + /** + * Delete all samples between two timestamps for a given time series. + * + * @param key the key. + * @param fromTimestamp start timestamp, in milliseconds. + * @param toTimestamp end timestamp, in milliseconds. + * @return Long integer-reply the number of samples that were removed. + */ + Mono tsDel(K key, long fromTimestamp, long toTimestamp); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Mono tsAdd(K key, long timestamp, double value); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @param addArgs the add arguments. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Mono tsAdd(K key, long timestamp, double value, TsAddArgs addArgs); + + /** + * Append a sample to a time series, letting the server assign the current time as the sample timestamp, creating the series + * automatically if it does not yet exist. + * + * @param key the key. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Mono tsAdd(K key, double value); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entries the (key, sample) entries to append; each {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if any {@link TsSample} carries more than one value. + */ + Flux tsMAdd(Map.Entry... entries); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entry the (key, sample) entry to append; the {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if the {@link TsSample} carries more than one value. + */ + Flux tsMAdd(Map.Entry entry); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + Mono tsIncrBy(K key, double value); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @param incrByArgs the increment-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + Mono tsIncrBy(K key, double value, TsIncrByArgs incrByArgs); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + Mono tsDecrBy(K key, double value); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @param decrByArgs the decrement-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + Mono tsDecrBy(K key, double value, TsIncrByArgs decrByArgs); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + Mono tsGet(K key); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @param getArgs the get arguments. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + Mono tsGet(K key, TsGetArgs getArgs); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Flux> tsMGet(V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Flux> tsMGet(V filter); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Flux> tsMGet(TsMGetArgs mGetArgs, V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Flux> tsMGet(TsMGetArgs mGetArgs, V filter); + + /** + * Get metadata about a time series. + * + * @param key the key. + * @return TsInfoValue metadata about the time series. + */ + Mono> tsInfo(K key); + + /** + * Get metadata about a time series, including chunk-level debug information. + * + * @param key the key. + * @return TsInfoValue metadata about the time series, including chunk-level debug information. + */ + Mono> tsInfoDebug(K key); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Flux tsQueryIndex(V... filters); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Flux tsQueryIndex(V filter); + +} diff --git a/src/main/java/io/lettuce/core/api/sync/RedisCommands.java b/src/main/java/io/lettuce/core/api/sync/RedisCommands.java index b7eaea9e7f..b98d5f075f 100644 --- a/src/main/java/io/lettuce/core/api/sync/RedisCommands.java +++ b/src/main/java/io/lettuce/core/api/sync/RedisCommands.java @@ -39,7 +39,8 @@ public interface RedisCommands extends BaseRedisCommands, RedisAclCo RedisKeyCommands, RedisListCommands, RedisScriptingCommands, RedisServerCommands, RedisSetCommands, RedisSortedSetCommands, RedisStreamCommands, RedisStringCommands, RedisTransactionalCommands, RedisJsonCommands, RedisVectorSetCommands, RediSearchCommands, - RedisArrayCommands, RedisBloomFilterCommands, RedisCuckooFilterCommands, RedisTopKCommands { + RedisArrayCommands, RedisBloomFilterCommands, RedisCuckooFilterCommands, + RedisTimeSeriesCommands, RedisTopKCommands { /** * Authenticate to the server. diff --git a/src/main/java/io/lettuce/core/api/sync/RedisTimeSeriesCommands.java b/src/main/java/io/lettuce/core/api/sync/RedisTimeSeriesCommands.java new file mode 100644 index 0000000000..a6f548b76c --- /dev/null +++ b/src/main/java/io/lettuce/core/api/sync/RedisTimeSeriesCommands.java @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.api.sync; + +import java.util.List; +import java.util.Map; + +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; + +/** + * Synchronous executed commands for RedisTimeSeries. + * + * @author Gyumin Hwang + * @param Key type. + * @param Value type. + * @see Redis Time Series + * @since 7.7 + * @generated by io.lettuce.apigenerator.CreateSyncApi + */ +public interface RedisTimeSeriesCommands { + + /** + * Create a new time series. + * + * @param key the key. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + String tsCreate(K key); + + /** + * Create a new time series. + * + * @param key the key. + * @param createArgs the create arguments. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + String tsCreate(K key, TsCreateArgs createArgs); + + /** + * Update the retention, chunk size, duplicate policy, and/or labels of an existing time series. + * + * @param key the key. + * @param alterArgs the alter arguments. + * @return String simple-string-reply {@code OK} if {@code TS.ALTER} was executed correctly. + */ + String tsAlter(K key, TsAlterArgs alterArgs); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + String tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @param alignTimestamp ensures that there is a bucket that starts exactly at this timestamp, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + String tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration, long alignTimestamp); + + /** + * Delete a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key. + * @return String simple-string-reply {@code OK} if {@code TS.DELETERULE} was executed correctly. + */ + String tsDeleteRule(K sourceKey, K destKey); + + /** + * Delete all samples between two timestamps for a given time series. + * + * @param key the key. + * @param fromTimestamp start timestamp, in milliseconds. + * @param toTimestamp end timestamp, in milliseconds. + * @return Long integer-reply the number of samples that were removed. + */ + Long tsDel(K key, long fromTimestamp, long toTimestamp); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Long tsAdd(K key, long timestamp, double value); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @param addArgs the add arguments. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Long tsAdd(K key, long timestamp, double value, TsAddArgs addArgs); + + /** + * Append a sample to a time series, letting the server assign the current time as the sample timestamp, creating the series + * automatically if it does not yet exist. + * + * @param key the key. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Long tsAdd(K key, double value); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entries the (key, sample) entries to append; each {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if any {@link TsSample} carries more than one value. + */ + List tsMAdd(Map.Entry... entries); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entry the (key, sample) entry to append; the {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if the {@link TsSample} carries more than one value. + */ + List tsMAdd(Map.Entry entry); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + Long tsIncrBy(K key, double value); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @param incrByArgs the increment-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + Long tsIncrBy(K key, double value, TsIncrByArgs incrByArgs); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + Long tsDecrBy(K key, double value); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @param decrByArgs the decrement-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + Long tsDecrBy(K key, double value, TsIncrByArgs decrByArgs); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + TsSample tsGet(K key); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @param getArgs the get arguments. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + TsSample tsGet(K key, TsGetArgs getArgs); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List> tsMGet(V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List> tsMGet(V filter); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List> tsMGet(TsMGetArgs mGetArgs, V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List> tsMGet(TsMGetArgs mGetArgs, V filter); + + /** + * Get metadata about a time series. + * + * @param key the key. + * @return TsInfoValue metadata about the time series. + */ + TsInfoValue tsInfo(K key); + + /** + * Get metadata about a time series, including chunk-level debug information. + * + * @param key the key. + * @return TsInfoValue metadata about the time series, including chunk-level debug information. + */ + TsInfoValue tsInfoDebug(K key); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List tsQueryIndex(V... filters); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List tsQueryIndex(V filter); + +} diff --git a/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionAsyncCommands.java index 0f0f4ad0c4..f8606005f8 100644 --- a/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionAsyncCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionAsyncCommands.java @@ -17,5 +17,5 @@ public interface NodeSelectionAsyncCommands extends BaseNodeSelectionAsync NodeSelectionSortedSetAsyncCommands, NodeSelectionStreamCommands, NodeSelectionStringAsyncCommands, NodeSelectionJsonAsyncCommands, NodeSelectionVectorSetAsyncCommands, NodeSelectionSearchAsyncCommands, NodeSelectionBloomFilterAsyncCommands, NodeSelectionCuckooFilterAsyncCommands, - NodeSelectionTopKAsyncCommands { + NodeSelectionTimeSeriesAsyncCommands, NodeSelectionTopKAsyncCommands { } diff --git a/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionTimeSeriesAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionTimeSeriesAsyncCommands.java new file mode 100644 index 0000000000..b966fd6997 --- /dev/null +++ b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionTimeSeriesAsyncCommands.java @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.cluster.api.async; + +import java.util.List; +import java.util.Map; + +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; + +/** + * Asynchronous executed commands on a node selection for RedisTimeSeries. + * + * @author Gyumin Hwang + * @param Key type. + * @param Value type. + * @see Redis Time Series + * @since 7.7 + * @generated by io.lettuce.apigenerator.CreateAsyncNodeSelectionClusterApi + */ +public interface NodeSelectionTimeSeriesAsyncCommands { + + /** + * Create a new time series. + * + * @param key the key. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + AsyncExecutions tsCreate(K key); + + /** + * Create a new time series. + * + * @param key the key. + * @param createArgs the create arguments. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + AsyncExecutions tsCreate(K key, TsCreateArgs createArgs); + + /** + * Update the retention, chunk size, duplicate policy, and/or labels of an existing time series. + * + * @param key the key. + * @param alterArgs the alter arguments. + * @return String simple-string-reply {@code OK} if {@code TS.ALTER} was executed correctly. + */ + AsyncExecutions tsAlter(K key, TsAlterArgs alterArgs); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + AsyncExecutions tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @param alignTimestamp ensures that there is a bucket that starts exactly at this timestamp, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + AsyncExecutions tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration, + long alignTimestamp); + + /** + * Delete a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key. + * @return String simple-string-reply {@code OK} if {@code TS.DELETERULE} was executed correctly. + */ + AsyncExecutions tsDeleteRule(K sourceKey, K destKey); + + /** + * Delete all samples between two timestamps for a given time series. + * + * @param key the key. + * @param fromTimestamp start timestamp, in milliseconds. + * @param toTimestamp end timestamp, in milliseconds. + * @return Long integer-reply the number of samples that were removed. + */ + AsyncExecutions tsDel(K key, long fromTimestamp, long toTimestamp); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + AsyncExecutions tsAdd(K key, long timestamp, double value); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @param addArgs the add arguments. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + AsyncExecutions tsAdd(K key, long timestamp, double value, TsAddArgs addArgs); + + /** + * Append a sample to a time series, letting the server assign the current time as the sample timestamp, creating the series + * automatically if it does not yet exist. + * + * @param key the key. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + AsyncExecutions tsAdd(K key, double value); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entries the (key, sample) entries to append; each {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if any {@link TsSample} carries more than one value. + */ + AsyncExecutions> tsMAdd(Map.Entry... entries); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entry the (key, sample) entry to append; the {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if the {@link TsSample} carries more than one value. + */ + AsyncExecutions> tsMAdd(Map.Entry entry); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + AsyncExecutions tsIncrBy(K key, double value); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @param incrByArgs the increment-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + AsyncExecutions tsIncrBy(K key, double value, TsIncrByArgs incrByArgs); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + AsyncExecutions tsDecrBy(K key, double value); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @param decrByArgs the decrement-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + AsyncExecutions tsDecrBy(K key, double value, TsIncrByArgs decrByArgs); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + AsyncExecutions tsGet(K key); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @param getArgs the get arguments. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + AsyncExecutions tsGet(K key, TsGetArgs getArgs); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + AsyncExecutions>> tsMGet(V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + AsyncExecutions>> tsMGet(V filter); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + AsyncExecutions>> tsMGet(TsMGetArgs mGetArgs, V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + AsyncExecutions>> tsMGet(TsMGetArgs mGetArgs, V filter); + + /** + * Get metadata about a time series. + * + * @param key the key. + * @return TsInfoValue metadata about the time series. + */ + AsyncExecutions> tsInfo(K key); + + /** + * Get metadata about a time series, including chunk-level debug information. + * + * @param key the key. + * @return TsInfoValue metadata about the time series, including chunk-level debug information. + */ + AsyncExecutions> tsInfoDebug(K key); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + AsyncExecutions> tsQueryIndex(V... filters); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + AsyncExecutions> tsQueryIndex(V filter); + +} diff --git a/src/main/java/io/lettuce/core/cluster/api/async/RedisClusterAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/api/async/RedisClusterAsyncCommands.java index 4b129b9391..c70d11ee31 100644 --- a/src/main/java/io/lettuce/core/cluster/api/async/RedisClusterAsyncCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/async/RedisClusterAsyncCommands.java @@ -48,7 +48,7 @@ public interface RedisClusterAsyncCommands RedisSetAsyncCommands, RedisSortedSetAsyncCommands, RedisStreamAsyncCommands, RedisStringAsyncCommands, RedisJsonAsyncCommands, RedisVectorSetAsyncCommands, RediSearchAsyncCommands, RedisArrayAsyncCommands, RedisBloomFilterAsyncCommands, - RedisCuckooFilterAsyncCommands, RedisTopKAsyncCommands { + RedisCuckooFilterAsyncCommands, RedisTimeSeriesAsyncCommands, RedisTopKAsyncCommands { /** * Set the default timeout for operations. A zero timeout value indicates to not time out. diff --git a/src/main/java/io/lettuce/core/cluster/api/reactive/RedisClusterReactiveCommands.java b/src/main/java/io/lettuce/core/cluster/api/reactive/RedisClusterReactiveCommands.java index ebe4a3c402..bb49bacc2b 100644 --- a/src/main/java/io/lettuce/core/cluster/api/reactive/RedisClusterReactiveCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/reactive/RedisClusterReactiveCommands.java @@ -42,14 +42,14 @@ * @author Yordan Tsintsov * @since 5.0 */ -public interface RedisClusterReactiveCommands - extends BaseRedisReactiveCommands, RedisAclReactiveCommands, RedisFunctionReactiveCommands, - RedisGeoReactiveCommands, RedisHashReactiveCommands, RedisHLLReactiveCommands, - RedisKeyReactiveCommands, RedisListReactiveCommands, RedisScriptingReactiveCommands, - RedisServerReactiveCommands, RedisSetReactiveCommands, RedisSortedSetReactiveCommands, - RedisStreamReactiveCommands, RedisStringReactiveCommands, RedisJsonReactiveCommands, - RedisVectorSetReactiveCommands, RediSearchReactiveCommands, RedisArrayReactiveCommands, - RedisBloomFilterReactiveCommands, RedisCuckooFilterReactiveCommands, RedisTopKReactiveCommands { +public interface RedisClusterReactiveCommands extends BaseRedisReactiveCommands, RedisAclReactiveCommands, + RedisFunctionReactiveCommands, RedisGeoReactiveCommands, RedisHashReactiveCommands, + RedisHLLReactiveCommands, RedisKeyReactiveCommands, RedisListReactiveCommands, + RedisScriptingReactiveCommands, RedisServerReactiveCommands, RedisSetReactiveCommands, + RedisSortedSetReactiveCommands, RedisStreamReactiveCommands, RedisStringReactiveCommands, + RedisJsonReactiveCommands, RedisVectorSetReactiveCommands, RediSearchReactiveCommands, + RedisArrayReactiveCommands, RedisBloomFilterReactiveCommands, RedisCuckooFilterReactiveCommands, + RedisTimeSeriesReactiveCommands, RedisTopKReactiveCommands { /** * Set the default timeout for operations. A zero timeout value indicates to not time out. diff --git a/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionCommands.java b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionCommands.java index c1d331ff06..a98178e706 100644 --- a/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionCommands.java @@ -14,5 +14,5 @@ public interface NodeSelectionCommands extends BaseNodeSelectionCommands, NodeSelectionSetCommands, NodeSelectionSortedSetCommands, NodeSelectionStreamCommands, NodeSelectionStringCommands, NodeSelectionJsonCommands, NodeSelectionVectorSetCommands, NodeSelectionSearchCommands, NodeSelectionBloomFilterCommands, - NodeSelectionCuckooFilterCommands, NodeSelectionTopKCommands { + NodeSelectionCuckooFilterCommands, NodeSelectionTimeSeriesCommands, NodeSelectionTopKCommands { } diff --git a/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionTimeSeriesCommands.java b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionTimeSeriesCommands.java new file mode 100644 index 0000000000..6ff3ad7b55 --- /dev/null +++ b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionTimeSeriesCommands.java @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.cluster.api.sync; + +import java.util.List; +import java.util.Map; + +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; + +/** + * Synchronous executed commands on a node selection for RedisTimeSeries. + * + * @author Gyumin Hwang + * @param Key type. + * @param Value type. + * @see Redis Time Series + * @since 7.7 + * @generated by io.lettuce.apigenerator.CreateSyncNodeSelectionClusterApi + */ +public interface NodeSelectionTimeSeriesCommands { + + /** + * Create a new time series. + * + * @param key the key. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + Executions tsCreate(K key); + + /** + * Create a new time series. + * + * @param key the key. + * @param createArgs the create arguments. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + Executions tsCreate(K key, TsCreateArgs createArgs); + + /** + * Update the retention, chunk size, duplicate policy, and/or labels of an existing time series. + * + * @param key the key. + * @param alterArgs the alter arguments. + * @return String simple-string-reply {@code OK} if {@code TS.ALTER} was executed correctly. + */ + Executions tsAlter(K key, TsAlterArgs alterArgs); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + Executions tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @param alignTimestamp ensures that there is a bucket that starts exactly at this timestamp, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + Executions tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration, + long alignTimestamp); + + /** + * Delete a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key. + * @return String simple-string-reply {@code OK} if {@code TS.DELETERULE} was executed correctly. + */ + Executions tsDeleteRule(K sourceKey, K destKey); + + /** + * Delete all samples between two timestamps for a given time series. + * + * @param key the key. + * @param fromTimestamp start timestamp, in milliseconds. + * @param toTimestamp end timestamp, in milliseconds. + * @return Long integer-reply the number of samples that were removed. + */ + Executions tsDel(K key, long fromTimestamp, long toTimestamp); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Executions tsAdd(K key, long timestamp, double value); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @param addArgs the add arguments. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Executions tsAdd(K key, long timestamp, double value, TsAddArgs addArgs); + + /** + * Append a sample to a time series, letting the server assign the current time as the sample timestamp, creating the series + * automatically if it does not yet exist. + * + * @param key the key. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Executions tsAdd(K key, double value); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entries the (key, sample) entries to append; each {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if any {@link TsSample} carries more than one value. + */ + Executions> tsMAdd(Map.Entry... entries); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entry the (key, sample) entry to append; the {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if the {@link TsSample} carries more than one value. + */ + Executions> tsMAdd(Map.Entry entry); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + Executions tsIncrBy(K key, double value); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @param incrByArgs the increment-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + Executions tsIncrBy(K key, double value, TsIncrByArgs incrByArgs); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + Executions tsDecrBy(K key, double value); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @param decrByArgs the decrement-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + Executions tsDecrBy(K key, double value, TsIncrByArgs decrByArgs); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + Executions tsGet(K key); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @param getArgs the get arguments. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + Executions tsGet(K key, TsGetArgs getArgs); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Executions>> tsMGet(V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Executions>> tsMGet(V filter); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Executions>> tsMGet(TsMGetArgs mGetArgs, V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Executions>> tsMGet(TsMGetArgs mGetArgs, V filter); + + /** + * Get metadata about a time series. + * + * @param key the key. + * @return TsInfoValue metadata about the time series. + */ + Executions> tsInfo(K key); + + /** + * Get metadata about a time series, including chunk-level debug information. + * + * @param key the key. + * @return TsInfoValue metadata about the time series, including chunk-level debug information. + */ + Executions> tsInfoDebug(K key); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Executions> tsQueryIndex(V... filters); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + Executions> tsQueryIndex(V filter); + +} diff --git a/src/main/java/io/lettuce/core/cluster/api/sync/RedisClusterCommands.java b/src/main/java/io/lettuce/core/cluster/api/sync/RedisClusterCommands.java index 74b924d5c3..7bad5feedd 100644 --- a/src/main/java/io/lettuce/core/cluster/api/sync/RedisClusterCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/sync/RedisClusterCommands.java @@ -41,12 +41,13 @@ * @author Yordan Tsintsov * @since 4.0 */ -public interface RedisClusterCommands extends BaseRedisCommands, RedisAclCommands, - RedisFunctionCommands, RedisGeoCommands, RedisHashCommands, RedisHLLCommands, - RedisKeyCommands, RedisListCommands, RedisScriptingCommands, RedisServerCommands, - RedisSetCommands, RedisSortedSetCommands, RedisStreamCommands, RedisStringCommands, - RedisJsonCommands, RedisVectorSetCommands, RediSearchCommands, RedisArrayCommands, - RedisBloomFilterCommands, RedisCuckooFilterCommands, RedisTopKCommands { +public interface RedisClusterCommands + extends BaseRedisCommands, RedisAclCommands, RedisFunctionCommands, RedisGeoCommands, + RedisHashCommands, RedisHLLCommands, RedisKeyCommands, RedisListCommands, + RedisScriptingCommands, RedisServerCommands, RedisSetCommands, RedisSortedSetCommands, + RedisStreamCommands, RedisStringCommands, RedisJsonCommands, RedisVectorSetCommands, + RediSearchCommands, RedisArrayCommands, RedisBloomFilterCommands, RedisCuckooFilterCommands, + RedisTimeSeriesCommands, RedisTopKCommands { /** * Set the default timeout for operations. A zero timeout value indicates to not time out. diff --git a/src/main/java/io/lettuce/core/protocol/CommandKeyword.java b/src/main/java/io/lettuce/core/protocol/CommandKeyword.java index 2465b77b80..a5045bc014 100644 --- a/src/main/java/io/lettuce/core/protocol/CommandKeyword.java +++ b/src/main/java/io/lettuce/core/protocol/CommandKeyword.java @@ -71,7 +71,10 @@ CAS, EF, ELE, SETATTR, M, NOQUANT, BIN, Q8, FILTER, FILTER_EF( // INCREX keywords BYFLOAT, BYINT, ENX, LBOUND, SATURATE, UBOUND, - CAPACITY, SIZE, FILTERS, ITEMS, EXPANSION, NONSCALING, ERROR, NOCREATE, BUCKETSIZE, MAXITERATIONS; + CAPACITY, SIZE, FILTERS, ITEMS, EXPANSION, NONSCALING, ERROR, NOCREATE, BUCKETSIZE, MAXITERATIONS, + + // RedisTimeSeries keywords + RETENTION, CHUNK_SIZE, DUPLICATE_POLICY, LABELS, IGNORE, AGGREGATION, ON_DUPLICATE, TIMESTAMP, LATEST, WITHLABELS, SELECTED_LABELS; public final byte[] bytes; diff --git a/src/main/java/io/lettuce/core/protocol/CommandType.java b/src/main/java/io/lettuce/core/protocol/CommandType.java index 706f41ed2b..dab7912ebd 100644 --- a/src/main/java/io/lettuce/core/protocol/CommandType.java +++ b/src/main/java/io/lettuce/core/protocol/CommandType.java @@ -143,6 +143,11 @@ public enum CommandType implements ProtocolKeyword { TOPK_ADD("TOPK.ADD"), TOPK_INCRBY("TOPK.INCRBY"), TOPK_INFO("TOPK.INFO"), TOPK_LIST("TOPK.LIST"), TOPK_QUERY( "TOPK.QUERY"), TOPK_RESERVE("TOPK.RESERVE"), + // TimeSeries + TS_CREATE("TS.CREATE"), TS_ALTER("TS.ALTER"), TS_CREATERULE("TS.CREATERULE"), TS_DELETERULE("TS.DELETERULE"), TS_DEL( + "TS.DEL"), TS_ADD("TS.ADD"), TS_MADD("TS.MADD"), TS_INCRBY("TS.INCRBY"), TS_DECRBY( + "TS.DECRBY"), TS_GET("TS.GET"), TS_MGET("TS.MGET"), TS_INFO("TS.INFO"), TS_QUERYINDEX("TS.QUERYINDEX"), + // Others TIME, WAIT, diff --git a/src/main/java/io/lettuce/core/protocol/ReadOnlyCommands.java b/src/main/java/io/lettuce/core/protocol/ReadOnlyCommands.java index ab48ea37e0..9887702525 100644 --- a/src/main/java/io/lettuce/core/protocol/ReadOnlyCommands.java +++ b/src/main/java/io/lettuce/core/protocol/ReadOnlyCommands.java @@ -88,7 +88,9 @@ enum CommandName { // Cuckoo Filter read-only commands CF_EXISTS, CF_MEXISTS, CF_COUNT, CF_INFO, CF_SCANDUMP, // Top-K read-only commands - TOPK_INFO, TOPK_LIST, TOPK_QUERY // + TOPK_INFO, TOPK_LIST, TOPK_QUERY, // + // TimeSeries read-only commands + TS_GET, TS_MGET, TS_INFO, TS_QUERYINDEX } /** diff --git a/src/main/java/io/lettuce/core/timeseries/TsAggregationType.java b/src/main/java/io/lettuce/core/timeseries/TsAggregationType.java new file mode 100644 index 0000000000..48aa552936 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsAggregationType.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.nio.charset.StandardCharsets; + +import io.lettuce.core.protocol.ProtocolKeyword; + +/** + * Aggregation types used by the Redis TS.CREATERULE command. + *

+ * Some constants (e.g. {@link #STD_P}) cannot be represented as a plain Java identifier because their wire value contains a + * dot. These constants use the {@link #TsAggregationType(String)} constructor to override the wire value. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public enum TsAggregationType implements ProtocolKeyword { + + AVG, + + SUM, + + MIN, + + MAX, + + RANGE, + + COUNT, + + FIRST, + + LAST, + + STD_P("STD.P"), + + STD_S("STD.S"), + + VAR_P("VAR.P"), + + VAR_S("VAR.S"), + + TWA, + + COUNTNAN, + + COUNTALL; + + private final byte[] bytes; + + private final String value; + + TsAggregationType() { + this.value = name(); + this.bytes = value.getBytes(StandardCharsets.US_ASCII); + } + + TsAggregationType(String value) { + this.value = value; + this.bytes = value.getBytes(StandardCharsets.US_ASCII); + } + + @Override + public byte[] getBytes() { + return bytes; + } + + @Override + public String toString() { + return value; + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/TsDuplicatePolicy.java b/src/main/java/io/lettuce/core/timeseries/TsDuplicatePolicy.java new file mode 100644 index 0000000000..7dfd1990ec --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsDuplicatePolicy.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.nio.charset.StandardCharsets; + +import io.lettuce.core.protocol.ProtocolKeyword; + +/** + * Duplicate sample handling policies used by the Redis TS.CREATE and + * TS.ALTER {@code DUPLICATE_POLICY} option. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public enum TsDuplicatePolicy implements ProtocolKeyword { + + BLOCK, + + FIRST, + + LAST, + + MIN, + + MAX, + + SUM; + + private final byte[] bytes; + + TsDuplicatePolicy() { + this.bytes = name().getBytes(StandardCharsets.US_ASCII); + } + + @Override + public byte[] getBytes() { + return bytes; + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/TsEncodingFormat.java b/src/main/java/io/lettuce/core/timeseries/TsEncodingFormat.java new file mode 100644 index 0000000000..e0eb25dfd8 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsEncodingFormat.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.nio.charset.StandardCharsets; + +import io.lettuce.core.protocol.ProtocolKeyword; + +/** + * Chunk encoding formats used by the Redis TS.CREATE {@code ENCODING} + * option. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public enum TsEncodingFormat implements ProtocolKeyword { + + COMPRESSED, + + UNCOMPRESSED; + + private final byte[] bytes; + + TsEncodingFormat() { + this.bytes = name().getBytes(StandardCharsets.US_ASCII); + } + + @Override + public byte[] getBytes() { + return bytes; + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/TsInfoValue.java b/src/main/java/io/lettuce/core/timeseries/TsInfoValue.java new file mode 100644 index 0000000000..a2b49e6862 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsInfoValue.java @@ -0,0 +1,367 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Represents the result of the Redis TS.INFO command. + * + * @param Key type + * @author Gyumin Hwang + * @since 7.7 + */ +public class TsInfoValue { + + private final Map rawInfo; + + private final Long totalSamples; + + private final Long memoryUsage; + + private final Long firstTimestamp; + + private final Long lastTimestamp; + + private final Long retentionTime; + + private final Long chunkCount; + + private final Long chunkSize; + + private final String chunkType; + + private final String duplicatePolicy; + + private final Map labels; + + private final K sourceKey; + + private final List> rules; + + private final Long ignoreMaxTimeDiff; + + private final Double ignoreMaxValDiff; + + private final K keySelfName; + + private final List chunks; + + @SuppressWarnings("unchecked") + public TsInfoValue(Map rawInfo) { + this.rawInfo = rawInfo; + this.totalSamples = (Long) rawInfo.get("totalSamples"); + this.memoryUsage = (Long) rawInfo.get("memoryUsage"); + this.firstTimestamp = (Long) rawInfo.get("firstTimestamp"); + this.lastTimestamp = (Long) rawInfo.get("lastTimestamp"); + this.retentionTime = (Long) rawInfo.get("retentionTime"); + this.chunkCount = (Long) rawInfo.get("chunkCount"); + this.chunkSize = (Long) rawInfo.get("chunkSize"); + this.chunkType = (String) rawInfo.get("chunkType"); + this.duplicatePolicy = (String) rawInfo.get("duplicatePolicy"); + this.labels = (Map) rawInfo.get("labels"); + this.sourceKey = (K) rawInfo.get("sourceKey"); + this.rules = (List>) rawInfo.get("rules"); + this.ignoreMaxTimeDiff = (Long) rawInfo.get("ignoreMaxTimeDiff"); + this.ignoreMaxValDiff = (Double) rawInfo.get("ignoreMaxValDiff"); + this.keySelfName = (K) rawInfo.get("keySelfName"); + this.chunks = (List) rawInfo.get("Chunks"); + } + + /** + * Returns the raw info map returned by the Redis server. + * + * @return the raw info map returned by the Redis server + */ + public Map getRawInfo() { + return rawInfo; + } + + /** + * Returns the total number of samples in the series. + * + * @return the total number of samples in the series + */ + public Long getTotalSamples() { + return totalSamples; + } + + /** + * Returns the total memory usage of the series in bytes. + * + * @return the total memory usage of the series in bytes + */ + public Long getMemoryUsage() { + return memoryUsage; + } + + /** + * Returns the first timestamp present in the series. + * + * @return the first timestamp present in the series + */ + public Long getFirstTimestamp() { + return firstTimestamp; + } + + /** + * Returns the last timestamp present in the series. + * + * @return the last timestamp present in the series + */ + public Long getLastTimestamp() { + return lastTimestamp; + } + + /** + * Returns the retention time, in milliseconds, for the series. + * + * @return the retention time, in milliseconds, for the series + */ + public Long getRetentionTime() { + return retentionTime; + } + + /** + * Returns the number of chunks used by the series. + * + * @return the number of chunks used by the series + */ + public Long getChunkCount() { + return chunkCount; + } + + /** + * Returns the initial chunk size, in bytes, used by the series. + * + * @return the initial chunk size, in bytes, used by the series + */ + public Long getChunkSize() { + return chunkSize; + } + + /** + * Returns the chunk encoding used by the series ({@code "compressed"} or {@code "uncompressed"}). + * + * @return the chunk encoding used by the series + */ + public String getChunkType() { + return chunkType; + } + + /** + * Returns the duplicate sample handling policy configured for the series, or {@code null} if none was configured. + * + * @return the duplicate sample handling policy configured for the series, or {@code null} if none was configured + */ + public String getDuplicatePolicy() { + return duplicatePolicy; + } + + /** + * Returns the labels associated with the series. + * + * @return the labels associated with the series, never {@code null} + */ + public Map getLabels() { + return labels; + } + + /** + * Returns the source key of the series, if it is the destination of a compaction rule, or {@code null} otherwise. + * + * @return the source key of the series, or {@code null} otherwise + */ + public K getSourceKey() { + return sourceKey; + } + + /** + * Returns the compaction rules attached to the series. + * + * @return the compaction rules attached to the series, never {@code null} + */ + public List> getRules() { + return rules; + } + + /** + * Returns the maximum time difference, in milliseconds, allowed for out-of-order samples. + * + * @return the maximum time difference, in milliseconds, allowed for out-of-order samples + */ + public Long getIgnoreMaxTimeDiff() { + return ignoreMaxTimeDiff; + } + + /** + * Returns the maximum value difference allowed for out-of-order samples. + * + * @return the maximum value difference allowed for out-of-order samples + */ + public Double getIgnoreMaxValDiff() { + return ignoreMaxValDiff; + } + + /** + * Returns the key name of the series, as reported by {@code TS.INFO ... DEBUG}, or {@code null} if the reply was not + * produced by the {@code DEBUG} modifier. + * + * @return the key name of the series, or {@code null} if not available + */ + public K getKeySelfName() { + return keySelfName; + } + + /** + * Returns the chunks of the series, as reported by {@code TS.INFO ... DEBUG}, or {@code null} if the reply was not produced + * by the {@code DEBUG} modifier. + * + * @return the chunks of the series, or {@code null} if not available + */ + public List getChunks() { + return chunks; + } + + /** + * Represents a single compaction rule attached to a series, as reported by {@code TS.INFO}. + * + * @param Key type + * @author Gyumin Hwang + * @since 7.7 + */ + public static class Rule { + + private final K destKey; + + private final long bucketDuration; + + private final TsAggregationType aggregationType; + + private final long timestampAlignment; + + public Rule(K destKey, long bucketDuration, TsAggregationType aggregationType, long timestampAlignment) { + this.destKey = destKey; + this.bucketDuration = bucketDuration; + this.aggregationType = aggregationType; + this.timestampAlignment = timestampAlignment; + } + + /** + * Returns the destination key of the compaction rule. + * + * @return the destination key of the compaction rule + */ + public K getDestKey() { + return destKey; + } + + /** + * Returns the bucket duration, in milliseconds, of the compaction rule. + * + * @return the bucket duration, in milliseconds, of the compaction rule + */ + public long getBucketDuration() { + return bucketDuration; + } + + /** + * Returns the aggregation type of the compaction rule. + * + * @return the aggregation type of the compaction rule + */ + public TsAggregationType getAggregationType() { + return aggregationType; + } + + /** + * Returns the timestamp alignment of the compaction rule. + * + * @return the timestamp alignment of the compaction rule + */ + public long getTimestampAlignment() { + return timestampAlignment; + } + + } + + /** + * Represents a single chunk of a series, as reported by {@code TS.INFO ... DEBUG}. + * + * @author Gyumin Hwang + * @since 7.7 + */ + public static class Chunk { + + private final long startTimestamp; + + private final long endTimestamp; + + private final long samples; + + private final long size; + + private final double bytesPerSample; + + public Chunk(long startTimestamp, long endTimestamp, long samples, long size, double bytesPerSample) { + this.startTimestamp = startTimestamp; + this.endTimestamp = endTimestamp; + this.samples = samples; + this.size = size; + this.bytesPerSample = bytesPerSample; + } + + /** + * Returns the start timestamp of the chunk. + * + * @return the start timestamp of the chunk + */ + public long getStartTimestamp() { + return startTimestamp; + } + + /** + * Returns the end timestamp of the chunk. + * + * @return the end timestamp of the chunk + */ + public long getEndTimestamp() { + return endTimestamp; + } + + /** + * Returns the number of samples in the chunk. + * + * @return the number of samples in the chunk + */ + public long getSamples() { + return samples; + } + + /** + * Returns the size, in bytes, of the chunk. + * + * @return the size, in bytes, of the chunk + */ + public long getSize() { + return size; + } + + /** + * Returns the average bytes per sample of the chunk. + * + * @return the average bytes per sample of the chunk + */ + public double getBytesPerSample() { + return bytesPerSample; + } + + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/TsInfoValueParser.java b/src/main/java/io/lettuce/core/timeseries/TsInfoValueParser.java new file mode 100644 index 0000000000..e947ab316c --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsInfoValueParser.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.output.ComplexData; +import io.lettuce.core.output.ComplexDataParser; + +/** + * Parser for Redis TS.INFO command output, including the {@code DEBUG} + * modifier. + *

+ * The RESP2 and RESP3 replies of {@code TS.INFO} differ in shape (flat key-value array vs. native map, and 4- vs 3-element + * {@code rules} tuples), but this parser normalizes both into the same {@link TsInfoValue}. + * + * @param Key type + * @param Value type + * @author Gyumin Hwang + * @since 7.7 + */ +public final class TsInfoValueParser implements ComplexDataParser> { + + private final RedisCodec codec; + + public TsInfoValueParser(RedisCodec codec) { + this.codec = codec; + } + + @Override + public TsInfoValue parse(ComplexData data) { + if (data == null) { + throw new IllegalArgumentException("Failed parsing TS.INFO: data must not be null"); + } + + Map info = new LinkedHashMap<>(); + for (Map.Entry entry : data.getDynamicMap().entrySet()) { + info.put(decodeUtf8(entry.getKey()), entry.getValue()); + } + + info.put("chunkType", decodeUtf8(info.get("chunkType"))); + info.put("duplicatePolicy", decodeUtf8(info.get("duplicatePolicy"))); + info.put("sourceKey", decodeKey(info.get("sourceKey"))); + info.put("labels", TsLabelsParser.decode(info.get("labels"))); + info.put("rules", decodeRules(info.get("rules"))); + info.put("ignoreMaxValDiff", toNullableDouble(info.get("ignoreMaxValDiff"))); + info.put("keySelfName", decodeKey(info.get("keySelfName"))); + info.put("Chunks", decodeChunks(info.get("Chunks"))); + + return new TsInfoValue<>(info); + } + + private List> decodeRules(Object rawRules) { + List> rules = new ArrayList<>(); + if (rawRules == null) { + return rules; + } + + ComplexData rulesData = (ComplexData) rawRules; + if (rulesData.isMap()) { + // RESP3: destKey is the map key, value is a 3-element [bucketDuration, aggType, timestampAlignment] tuple + for (Map.Entry entry : rulesData.getDynamicMap().entrySet()) { + K destKey = codec.decodeKey((ByteBuffer) entry.getKey()); + List tuple = ((ComplexData) entry.getValue()).getDynamicList(); + rules.add(buildRule(destKey, tuple.get(0), tuple.get(1), tuple.get(2))); + } + } else { + // RESP2: each rule is a 4-element [destKey, bucketDuration, aggType, timestampAlignment] tuple + for (Object ruleObj : rulesData.getDynamicList()) { + List tuple = ((ComplexData) ruleObj).getDynamicList(); + K destKey = codec.decodeKey((ByteBuffer) tuple.get(0)); + rules.add(buildRule(destKey, tuple.get(1), tuple.get(2), tuple.get(3))); + } + } + return rules; + } + + private TsInfoValue.Rule buildRule(K destKey, Object bucketDuration, Object aggregationType, Object timestampAlignment) { + return new TsInfoValue.Rule<>(destKey, ((Number) bucketDuration).longValue(), decodeAggregationType(aggregationType), + ((Number) timestampAlignment).longValue()); + } + + private TsAggregationType decodeAggregationType(Object value) { + String raw = decodeUtf8(value); + if (raw == null) { + return null; + } + try { + return TsAggregationType.valueOf(raw.replace('.', '_').toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private List decodeChunks(Object rawChunks) { + if (rawChunks == null) { + return null; + } + + List chunks = new ArrayList<>(); + for (Object chunkObj : ((ComplexData) rawChunks).getDynamicList()) { + Map chunk = new LinkedHashMap<>(); + for (Map.Entry entry : ((ComplexData) chunkObj).getDynamicMap().entrySet()) { + chunk.put(decodeUtf8(entry.getKey()), entry.getValue()); + } + chunks.add(new TsInfoValue.Chunk(((Number) chunk.get("startTimestamp")).longValue(), + ((Number) chunk.get("endTimestamp")).longValue(), ((Number) chunk.get("samples")).longValue(), + ((Number) chunk.get("size")).longValue(), toNullableDouble(chunk.get("bytesPerSample")))); + } + return chunks; + } + + private K decodeKey(Object value) { + if (value == null) { + return null; + } + return codec.decodeKey((ByteBuffer) value); + } + + private static Double toNullableDouble(Object value) { + if (value == null) { + return null; + } + if (value instanceof Double) { + return (Double) value; + } + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return Double.valueOf(StringCodec.UTF8.decodeValue((ByteBuffer) value)); + } + + private static String decodeUtf8(Object value) { + if (value == null) { + return null; + } + if (value instanceof String) { + return (String) value; + } + return StringCodec.UTF8.decodeValue((ByteBuffer) value); + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/TsLabelsParser.java b/src/main/java/io/lettuce/core/timeseries/TsLabelsParser.java new file mode 100644 index 0000000000..4db311fc89 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsLabelsParser.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.nio.ByteBuffer; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.output.ComplexData; + +/** + * Decodes the {@code labels} field shared by the {@code TS.INFO} and {@code TS.MGET} command outputs. + *

+ * The wire shape differs between protocols: RESP3 returns a native map, while RESP2 returns a nested array of + * {@code [key, value]} pairs, not a flat key-value array. Calling {@link ComplexData#getDynamicMap()} directly on the RESP2 + * shape is incorrect: its odd/even pairing heuristic treats each nested pair as a single element, silently dropping a single + * label or mis-pairing multiple labels. This class branches on {@link ComplexData#isMap()} to normalize both shapes into the + * same {@link Map}. + * + * @author Gyumin Hwang + * @since 7.7 + */ +final class TsLabelsParser { + + private TsLabelsParser() { + } + + /** + * Decodes the {@code labels} field into a {@code key -> value} map. + * + * @param rawLabels the raw {@link ComplexData} value stored under the {@code labels} key, or {@code null} if absent. + * @return the decoded labels, or an empty {@link Map} if {@code rawLabels} is {@code null}. + */ + static Map decode(Object rawLabels) { + Map labels = new LinkedHashMap<>(); + if (rawLabels == null) { + return labels; + } + + ComplexData labelsData = (ComplexData) rawLabels; + if (labelsData.isMap()) { + // RESP3: native map, key -> value directly + for (Map.Entry entry : labelsData.getDynamicMap().entrySet()) { + labels.put(decodeUtf8(entry.getKey()), decodeUtf8(entry.getValue())); + } + } else { + // RESP2: nested array of [key, value] pairs + for (Object pairObj : labelsData.getDynamicList()) { + List pair = ((ComplexData) pairObj).getDynamicList(); + labels.put(decodeUtf8(pair.get(0)), decodeUtf8(pair.get(1))); + } + } + return labels; + } + + private static String decodeUtf8(Object value) { + if (value == null) { + return null; + } + if (value instanceof String) { + return (String) value; + } + return StringCodec.UTF8.decodeValue((ByteBuffer) value); + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/TsMGetValue.java b/src/main/java/io/lettuce/core/timeseries/TsMGetValue.java new file mode 100644 index 0000000000..a4404c1493 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsMGetValue.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.util.Collections; +import java.util.Map; + +/** + * Represents a single entry of the result of the Redis TS.MGET command. + * + * @param Key type + * @author Gyumin Hwang + * @since 7.7 + */ +public class TsMGetValue { + + private final K key; + + private final Map labels; + + private final TsSample sample; + + public TsMGetValue(K key, Map labels, TsSample sample) { + this.key = key; + this.labels = labels == null ? Collections.emptyMap() : Collections.unmodifiableMap(labels); + this.sample = sample; + } + + /** + * Returns the key of the series this sample belongs to. + * + * @return the key of the series this sample belongs to + */ + public K getKey() { + return key; + } + + /** + * Returns the labels of the series this sample belongs to. + * + * @return the labels of the series this sample belongs to, never {@code null} + */ + public Map getLabels() { + return labels; + } + + /** + * Returns the last sample of the series, or {@code null} if the series has no samples. + * + * @return the last sample of the series, or {@code null} if the series has no samples + */ + public TsSample getSample() { + return sample; + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/TsMGetValueParser.java b/src/main/java/io/lettuce/core/timeseries/TsMGetValueParser.java new file mode 100644 index 0000000000..99ab5dc8e3 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsMGetValueParser.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.output.ComplexData; +import io.lettuce.core.output.ComplexDataParser; + +/** + * Parser for Redis TS.MGET command output. + *

+ * The top-level container differs between protocols: RESP2 returns an array of {@code [key, labels, sample]} triples, while + * RESP3 returns a native map of key to a {@code [labels, sample]} pair. This parser branches on {@link ComplexData#isMap()} to + * normalize both into the same {@link List} of {@link TsMGetValue}. + * + * @param Key type + * @param Value type + * @author Gyumin Hwang + * @since 7.7 + */ +public final class TsMGetValueParser implements ComplexDataParser>> { + + private final RedisCodec codec; + + public TsMGetValueParser(RedisCodec codec) { + this.codec = codec; + } + + @Override + public List> parse(ComplexData data) { + if (data == null) { + throw new IllegalArgumentException("Failed parsing TS.MGET: data must not be null"); + } + + List> result = new ArrayList<>(); + if (data.isMap()) { + // RESP3: key -> [labels, sample] + for (Map.Entry entry : data.getDynamicMap().entrySet()) { + K key = codec.decodeKey((ByteBuffer) entry.getKey()); + List valueList = ((ComplexData) entry.getValue()).getDynamicList(); + result.add(buildValue(key, valueList.get(0), valueList.get(1))); + } + } else { + // RESP2: [key, labels, sample] + for (Object entryObj : data.getDynamicList()) { + List entry = ((ComplexData) entryObj).getDynamicList(); + K key = codec.decodeKey((ByteBuffer) entry.get(0)); + result.add(buildValue(key, entry.get(1), entry.get(2))); + } + } + return result; + } + + private TsMGetValue buildValue(K key, Object labelsRaw, Object sampleRaw) { + return new TsMGetValue<>(key, TsLabelsParser.decode(labelsRaw), decodeSample(sampleRaw)); + } + + private TsSample decodeSample(Object rawSample) { + return rawSample == null ? null : TsSampleParser.decode((ComplexData) rawSample); + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/TsSample.java b/src/main/java/io/lettuce/core/timeseries/TsSample.java new file mode 100644 index 0000000000..83dc963f79 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsSample.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.util.Collections; +import java.util.List; + +/** + * Represents a single sample of a RedisTimeSeries series, as returned by + * TS.GET, TS.RANGE, + * TS.REVRANGE and + * TS.MGET. + *

+ * Most queries return one value per sample, in which case {@link #getValue()} returns it directly. Queries issued with multiple + * aggregators (e.g. {@code AGGREGATION avg,min}) return one value per aggregator per bucket; in that case all values are + * accessible, in declaration order, via {@link #getValues()}. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public class TsSample { + + private final long timestamp; + + private final List values; + + public TsSample(long timestamp, List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("Failed constructing TsSample: values must not be null or empty"); + } + this.timestamp = timestamp; + this.values = Collections.unmodifiableList(values); + } + + /** + * Returns the timestamp of this sample. + * + * @return the timestamp of this sample + */ + public long getTimestamp() { + return timestamp; + } + + /** + * Returns all values of this sample, in the order in which they were returned by the server. Contains a single element + * unless the query was issued with multiple aggregators. + * + * @return all values of this sample, never {@code null} or empty + */ + public List getValues() { + return values; + } + + /** + * Returns the first value of this sample. Equivalent to {@code getValues().get(0)}. + * + * @return the first value of this sample + */ + public double getValue() { + return values.get(0); + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/TsSampleParser.java b/src/main/java/io/lettuce/core/timeseries/TsSampleParser.java new file mode 100644 index 0000000000..42483b4551 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/TsSampleParser.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.output.ComplexData; +import io.lettuce.core.output.ComplexDataParser; + +/** + * Parser for Redis TS.GET command output. + *

+ * The reply is a flat {@code [timestamp, value]} tuple, or an empty array (not {@code nil}) when the series has no samples. + * {@link #decode(ComplexData)} carries the actual tuple-to-{@link TsSample} conversion as a package-visible static method so + * that a future list-returning parser (for {@code TS.RANGE}/{@code TS.REVRANGE}, which reuse the same per-sample tuple shape + * nested inside an outer array) can call it per element instead of duplicating this logic; {@link TsSampleParser} itself only + * ever produces a single, possibly {@code null}, {@link TsSample} for the single-sample {@code TS.GET} reply. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public final class TsSampleParser implements ComplexDataParser { + + public static final TsSampleParser INSTANCE = new TsSampleParser(); + + private TsSampleParser() { + } + + @Override + public TsSample parse(ComplexData data) { + if (data == null) { + throw new IllegalArgumentException("Failed parsing TS.GET: data must not be null"); + } + return decode(data); + } + + /** + * Decodes a single {@code [timestamp, v0, v1, ...]} tuple into a {@link TsSample}, or {@code null} if the tuple is empty. + * + * @param data the tuple to decode, must not be {@code null}. + * @return the decoded {@link TsSample}, or {@code null} if {@code data} is an empty array. + */ + static TsSample decode(ComplexData data) { + List tuple = data.getDynamicList(); + if (tuple == null || tuple.isEmpty()) { + return null; + } + + long timestamp = ((Number) tuple.get(0)).longValue(); + List values = new ArrayList<>(tuple.size() - 1); + for (int i = 1; i < tuple.size(); i++) { + values.add(toDouble(tuple.get(i))); + } + return new TsSample(timestamp, values); + } + + private static Double toDouble(Object value) { + if (value instanceof Double) { + return (Double) value; + } + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return Double.valueOf(StringCodec.UTF8.decodeValue((ByteBuffer) value)); + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/arguments/TsAddArgs.java b/src/main/java/io/lettuce/core/timeseries/arguments/TsAddArgs.java new file mode 100644 index 0000000000..d1f7d21031 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/arguments/TsAddArgs.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.util.LinkedHashMap; +import java.util.Map; + +import io.lettuce.core.CompositeArgument; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.protocol.CommandKeyword; +import io.lettuce.core.timeseries.TsDuplicatePolicy; +import io.lettuce.core.timeseries.TsEncodingFormat; + +/** + * Argument list builder for the Redis TS.ADD command. + *

+ * {@code TS.ADD} recognizes two distinct duplicate-policy keywords depending on whether the key already exists: when the series + * does not exist yet and is created inline by this call, {@code DUPLICATE_POLICY} configures the policy stored on the newly + * created series (the same keyword {@code TS.CREATE}/{@code TS.ALTER} use); when the series already exists, + * {@code ON_DUPLICATE} overrides the policy for this single sample only, without persisting it on the series. Both keywords are + * therefore exposed here as separate methods, {@link #duplicatePolicy(TsDuplicatePolicy)} and + * {@link #onDuplicate(TsDuplicatePolicy)}. + *

+ * {@link TsAddArgs} is a mutable object and instances should be used only once to avoid shared mutable state. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public class TsAddArgs implements CompositeArgument { + + private Long retention; + + private TsEncodingFormat encoding; + + private Long chunkSize; + + private TsDuplicatePolicy duplicatePolicy; + + private TsDuplicatePolicy onDuplicate; + + private Long ignoreMaxTimeDiff; + + private Double ignoreMaxValDiff; + + private final Map labels = new LinkedHashMap<>(); + + /** + * Builder entry points for {@link TsAddArgs}. + */ + public static class Builder { + + /** + * Utility constructor. + */ + private Builder() { + } + + /** + * Creates a new {@link TsAddArgs} and sets the retention period. + * + * @return a new {@link TsAddArgs} with retention period configured. + */ + public static TsAddArgs retention(long retention) { + return new TsAddArgs().retention(retention); + } + + /** + * Creates a new {@link TsAddArgs} and sets the chunk encoding. + * + * @return a new {@link TsAddArgs} with encoding configured. + */ + public static TsAddArgs encoding(TsEncodingFormat encoding) { + return new TsAddArgs().encoding(encoding); + } + + /** + * Creates a new {@link TsAddArgs} and sets the chunk size. + * + * @return a new {@link TsAddArgs} with chunk size configured. + */ + public static TsAddArgs chunkSize(long chunkSize) { + return new TsAddArgs().chunkSize(chunkSize); + } + + /** + * Creates a new {@link TsAddArgs} and sets the duplicate sample policy to apply if the series is created by this call. + * + * @return a new {@link TsAddArgs} with duplicate policy configured. + */ + public static TsAddArgs duplicatePolicy(TsDuplicatePolicy duplicatePolicy) { + return new TsAddArgs().duplicatePolicy(duplicatePolicy); + } + + /** + * Creates a new {@link TsAddArgs} and sets the duplicate sample policy override for an existing series. + * + * @return a new {@link TsAddArgs} with the duplicate policy override configured. + */ + public static TsAddArgs onDuplicate(TsDuplicatePolicy onDuplicate) { + return new TsAddArgs().onDuplicate(onDuplicate); + } + + /** + * Creates a new {@link TsAddArgs} and sets the ignore thresholds. + * + * @return a new {@link TsAddArgs} with ignore thresholds configured. + */ + public static TsAddArgs ignore(long maxTimeDiff, double maxValDiff) { + return new TsAddArgs().ignore(maxTimeDiff, maxValDiff); + } + + /** + * Creates a new {@link TsAddArgs} and adds a single label. + * + * @return a new {@link TsAddArgs} with the given label configured. + */ + public static TsAddArgs label(String label, String value) { + return new TsAddArgs().label(label, value); + } + + /** + * Creates a new {@link TsAddArgs} and sets the labels. + * + * @return a new {@link TsAddArgs} with labels configured. + */ + public static TsAddArgs labels(Map labels) { + return new TsAddArgs().labels(labels); + } + + } + + /** + * Set the retention period, in milliseconds. + * + * @return {@code this} {@link TsAddArgs}. + */ + public TsAddArgs retention(long retention) { + this.retention = retention; + return this; + } + + /** + * Set the chunk encoding. + * + * @return {@code this} {@link TsAddArgs}. + */ + public TsAddArgs encoding(TsEncodingFormat encoding) { + this.encoding = encoding; + return this; + } + + /** + * Set the chunk size, in bytes. + * + * @return {@code this} {@link TsAddArgs}. + */ + public TsAddArgs chunkSize(long chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + /** + * Set the duplicate sample policy to apply if the series is created by this call. + * + * @return {@code this} {@link TsAddArgs}. + */ + public TsAddArgs duplicatePolicy(TsDuplicatePolicy duplicatePolicy) { + this.duplicatePolicy = duplicatePolicy; + return this; + } + + /** + * Override the duplicate sample policy for this sample when the series already exists. + * + * @return {@code this} {@link TsAddArgs}. + */ + public TsAddArgs onDuplicate(TsDuplicatePolicy onDuplicate) { + this.onDuplicate = onDuplicate; + return this; + } + + /** + * Set the maximum time and value difference under which a duplicate sample is ignored instead of applying the duplicate + * policy. + * + * @return {@code this} {@link TsAddArgs}. + */ + public TsAddArgs ignore(long maxTimeDiff, double maxValDiff) { + this.ignoreMaxTimeDiff = maxTimeDiff; + this.ignoreMaxValDiff = maxValDiff; + return this; + } + + /** + * Add a single label. Repeated calls accumulate labels in call order; combine freely with {@link #labels(Map)}. + * + * @return {@code this} {@link TsAddArgs}. + */ + public TsAddArgs label(String label, String value) { + this.labels.put(label, value); + return this; + } + + /** + * Replace the labels with the given map, preserving its iteration order. + * + * @return {@code this} {@link TsAddArgs}. + */ + public TsAddArgs labels(Map labels) { + this.labels.clear(); + this.labels.putAll(labels); + return this; + } + + @Override + public void build(CommandArgs args) { + + if (retention != null) { + args.add(CommandKeyword.RETENTION).add(retention); + } + if (encoding != null) { + args.add(CommandKeyword.ENCODING).add(encoding); + } + if (chunkSize != null) { + args.add(CommandKeyword.CHUNK_SIZE).add(chunkSize); + } + if (duplicatePolicy != null) { + args.add(CommandKeyword.DUPLICATE_POLICY).add(duplicatePolicy); + } + if (onDuplicate != null) { + args.add(CommandKeyword.ON_DUPLICATE).add(onDuplicate); + } + if (ignoreMaxTimeDiff != null) { + args.add(CommandKeyword.IGNORE).add(ignoreMaxTimeDiff).add(ignoreMaxValDiff); + } + if (!labels.isEmpty()) { + args.add(CommandKeyword.LABELS); + labels.forEach((label, value) -> args.add(label).add(value)); + } + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/arguments/TsAlterArgs.java b/src/main/java/io/lettuce/core/timeseries/arguments/TsAlterArgs.java new file mode 100644 index 0000000000..7d8dc98bf2 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/arguments/TsAlterArgs.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.lettuce.core.CompositeArgument; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.protocol.CommandKeyword; +import io.lettuce.core.timeseries.TsDuplicatePolicy; + +/** + * Argument list builder for the Redis TS.ALTER command. + *

+ * Mirrors {@link TsCreateArgs} except that {@code ENCODING} is omitted, as the chunk encoding of an existing time series cannot + * be changed. + *

+ * {@link TsAlterArgs} is a mutable object and instances should be used only once to avoid shared mutable state. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public class TsAlterArgs implements CompositeArgument { + + private Long retention; + + private Long chunkSize; + + private TsDuplicatePolicy duplicatePolicy; + + private Long ignoreMaxTimeDiff; + + private Double ignoreMaxValDiff; + + private final Map labels = new LinkedHashMap<>(); + + private boolean labelsSet; + + /** + * Builder entry points for {@link TsAlterArgs}. + */ + public static class Builder { + + /** + * Utility constructor. + */ + private Builder() { + } + + /** + * Creates a new {@link TsAlterArgs} and sets the retention period. + * + * @return a new {@link TsAlterArgs} with retention period configured. + */ + public static TsAlterArgs retention(long retention) { + return new TsAlterArgs().retention(retention); + } + + /** + * Creates a new {@link TsAlterArgs} and sets the chunk size. + * + * @return a new {@link TsAlterArgs} with chunk size configured. + */ + public static TsAlterArgs chunkSize(long chunkSize) { + return new TsAlterArgs().chunkSize(chunkSize); + } + + /** + * Creates a new {@link TsAlterArgs} and sets the duplicate sample policy. + * + * @return a new {@link TsAlterArgs} with duplicate policy configured. + */ + public static TsAlterArgs duplicatePolicy(TsDuplicatePolicy duplicatePolicy) { + return new TsAlterArgs().duplicatePolicy(duplicatePolicy); + } + + /** + * Creates a new {@link TsAlterArgs} and sets the ignore thresholds. + * + * @return a new {@link TsAlterArgs} with ignore thresholds configured. + */ + public static TsAlterArgs ignore(long maxTimeDiff, double maxValDiff) { + return new TsAlterArgs().ignore(maxTimeDiff, maxValDiff); + } + + /** + * Creates a new {@link TsAlterArgs} and adds a single label. + * + * @return a new {@link TsAlterArgs} with the given label configured. + */ + public static TsAlterArgs label(String label, String value) { + return new TsAlterArgs().label(label, value); + } + + /** + * Creates a new {@link TsAlterArgs} and sets the labels. + * + * @return a new {@link TsAlterArgs} with labels configured. + */ + public static TsAlterArgs labels(Map labels) { + return new TsAlterArgs().labels(labels); + } + + /** + * Creates a new {@link TsAlterArgs} that clears all existing labels on the series. + * + * @return a new {@link TsAlterArgs} with an empty label set configured. + */ + public static TsAlterArgs labelsReset() { + return new TsAlterArgs().labelsReset(); + } + + } + + /** + * Set the retention period, in milliseconds. + * + * @return {@code this} {@link TsAlterArgs}. + */ + public TsAlterArgs retention(long retention) { + this.retention = retention; + return this; + } + + /** + * Set the chunk size, in bytes. + * + * @return {@code this} {@link TsAlterArgs}. + */ + public TsAlterArgs chunkSize(long chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + /** + * Set the duplicate sample policy. + * + * @return {@code this} {@link TsAlterArgs}. + */ + public TsAlterArgs duplicatePolicy(TsDuplicatePolicy duplicatePolicy) { + this.duplicatePolicy = duplicatePolicy; + return this; + } + + /** + * Set the maximum time and value difference under which a duplicate sample is ignored instead of applying the duplicate + * policy. + * + * @return {@code this} {@link TsAlterArgs}. + */ + public TsAlterArgs ignore(long maxTimeDiff, double maxValDiff) { + this.ignoreMaxTimeDiff = maxTimeDiff; + this.ignoreMaxValDiff = maxValDiff; + return this; + } + + /** + * Add a single label. Repeated calls accumulate labels in call order; combine freely with {@link #labels(Map)}. + * + * @return {@code this} {@link TsAlterArgs}. + */ + public TsAlterArgs label(String label, String value) { + this.labels.put(label, value); + this.labelsSet = true; + return this; + } + + /** + * Replace the labels with the given map, preserving its iteration order. + * + * @return {@code this} {@link TsAlterArgs}. + */ + public TsAlterArgs labels(Map labels) { + this.labels.clear(); + this.labels.putAll(labels); + this.labelsSet = true; + return this; + } + + /** + * Clear all existing labels on the series by sending an empty {@code LABELS} keyword. + * + * @return {@code this} {@link TsAlterArgs}. + */ + public TsAlterArgs labelsReset() { + return labels(Collections.emptyMap()); + } + + @Override + public void build(CommandArgs args) { + + if (retention != null) { + args.add(CommandKeyword.RETENTION).add(retention); + } + if (chunkSize != null) { + args.add(CommandKeyword.CHUNK_SIZE).add(chunkSize); + } + if (duplicatePolicy != null) { + args.add(CommandKeyword.DUPLICATE_POLICY).add(duplicatePolicy); + } + if (ignoreMaxTimeDiff != null) { + args.add(CommandKeyword.IGNORE).add(ignoreMaxTimeDiff).add(ignoreMaxValDiff); + } + if (labelsSet) { + args.add(CommandKeyword.LABELS); + labels.forEach((label, value) -> args.add(label).add(value)); + } + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/arguments/TsCreateArgs.java b/src/main/java/io/lettuce/core/timeseries/arguments/TsCreateArgs.java new file mode 100644 index 0000000000..cb07caec3e --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/arguments/TsCreateArgs.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.util.LinkedHashMap; +import java.util.Map; + +import io.lettuce.core.CompositeArgument; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.protocol.CommandKeyword; +import io.lettuce.core.timeseries.TsDuplicatePolicy; +import io.lettuce.core.timeseries.TsEncodingFormat; + +/** + * Argument list builder for the Redis TS.CREATE command. + *

+ * {@link TsCreateArgs} is a mutable object and instances should be used only once to avoid shared mutable state. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public class TsCreateArgs implements CompositeArgument { + + private Long retention; + + private TsEncodingFormat encoding; + + private Long chunkSize; + + private TsDuplicatePolicy duplicatePolicy; + + private Long ignoreMaxTimeDiff; + + private Double ignoreMaxValDiff; + + private final Map labels = new LinkedHashMap<>(); + + /** + * Builder entry points for {@link TsCreateArgs}. + */ + public static class Builder { + + /** + * Utility constructor. + */ + private Builder() { + } + + /** + * Creates a new {@link TsCreateArgs} and sets the retention period. + * + * @return a new {@link TsCreateArgs} with retention period configured. + */ + public static TsCreateArgs retention(long retention) { + return new TsCreateArgs().retention(retention); + } + + /** + * Creates a new {@link TsCreateArgs} and sets the chunk encoding. + * + * @return a new {@link TsCreateArgs} with encoding configured. + */ + public static TsCreateArgs encoding(TsEncodingFormat encoding) { + return new TsCreateArgs().encoding(encoding); + } + + /** + * Creates a new {@link TsCreateArgs} and sets the chunk size. + * + * @return a new {@link TsCreateArgs} with chunk size configured. + */ + public static TsCreateArgs chunkSize(long chunkSize) { + return new TsCreateArgs().chunkSize(chunkSize); + } + + /** + * Creates a new {@link TsCreateArgs} and sets the duplicate sample policy. + * + * @return a new {@link TsCreateArgs} with duplicate policy configured. + */ + public static TsCreateArgs duplicatePolicy(TsDuplicatePolicy duplicatePolicy) { + return new TsCreateArgs().duplicatePolicy(duplicatePolicy); + } + + /** + * Creates a new {@link TsCreateArgs} and sets the ignore thresholds. + * + * @return a new {@link TsCreateArgs} with ignore thresholds configured. + */ + public static TsCreateArgs ignore(long maxTimeDiff, double maxValDiff) { + return new TsCreateArgs().ignore(maxTimeDiff, maxValDiff); + } + + /** + * Creates a new {@link TsCreateArgs} and adds a single label. + * + * @return a new {@link TsCreateArgs} with the given label configured. + */ + public static TsCreateArgs label(String label, String value) { + return new TsCreateArgs().label(label, value); + } + + /** + * Creates a new {@link TsCreateArgs} and sets the labels. + * + * @return a new {@link TsCreateArgs} with labels configured. + */ + public static TsCreateArgs labels(Map labels) { + return new TsCreateArgs().labels(labels); + } + + } + + /** + * Set the retention period, in milliseconds. + * + * @return {@code this} {@link TsCreateArgs}. + */ + public TsCreateArgs retention(long retention) { + this.retention = retention; + return this; + } + + /** + * Set the chunk encoding. + * + * @return {@code this} {@link TsCreateArgs}. + */ + public TsCreateArgs encoding(TsEncodingFormat encoding) { + this.encoding = encoding; + return this; + } + + /** + * Set the chunk size, in bytes. + * + * @return {@code this} {@link TsCreateArgs}. + */ + public TsCreateArgs chunkSize(long chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + /** + * Set the duplicate sample policy. + * + * @return {@code this} {@link TsCreateArgs}. + */ + public TsCreateArgs duplicatePolicy(TsDuplicatePolicy duplicatePolicy) { + this.duplicatePolicy = duplicatePolicy; + return this; + } + + /** + * Set the maximum time and value difference under which a duplicate sample is ignored instead of applying the duplicate + * policy. + * + * @return {@code this} {@link TsCreateArgs}. + */ + public TsCreateArgs ignore(long maxTimeDiff, double maxValDiff) { + this.ignoreMaxTimeDiff = maxTimeDiff; + this.ignoreMaxValDiff = maxValDiff; + return this; + } + + /** + * Add a single label. Repeated calls accumulate labels in call order; combine freely with {@link #labels(Map)}. + * + * @return {@code this} {@link TsCreateArgs}. + */ + public TsCreateArgs label(String label, String value) { + this.labels.put(label, value); + return this; + } + + /** + * Replace the labels with the given map, preserving its iteration order. + * + * @return {@code this} {@link TsCreateArgs}. + */ + public TsCreateArgs labels(Map labels) { + this.labels.clear(); + this.labels.putAll(labels); + return this; + } + + @Override + public void build(CommandArgs args) { + + if (retention != null) { + args.add(CommandKeyword.RETENTION).add(retention); + } + if (encoding != null) { + args.add(CommandKeyword.ENCODING).add(encoding); + } + if (chunkSize != null) { + args.add(CommandKeyword.CHUNK_SIZE).add(chunkSize); + } + if (duplicatePolicy != null) { + args.add(CommandKeyword.DUPLICATE_POLICY).add(duplicatePolicy); + } + if (ignoreMaxTimeDiff != null) { + args.add(CommandKeyword.IGNORE).add(ignoreMaxTimeDiff).add(ignoreMaxValDiff); + } + if (!labels.isEmpty()) { + args.add(CommandKeyword.LABELS); + labels.forEach((label, value) -> args.add(label).add(value)); + } + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/arguments/TsGetArgs.java b/src/main/java/io/lettuce/core/timeseries/arguments/TsGetArgs.java new file mode 100644 index 0000000000..e96c07c03f --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/arguments/TsGetArgs.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import io.lettuce.core.CompositeArgument; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.protocol.CommandKeyword; + +/** + * Argument list builder for the Redis TS.GET command. + *

+ * {@link TsGetArgs} is a mutable object and instances should be used only once to avoid shared mutable state. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public class TsGetArgs implements CompositeArgument { + + private boolean latest; + + /** + * Builder entry points for {@link TsGetArgs}. + */ + public static class Builder { + + /** + * Utility constructor. + */ + private Builder() { + } + + /** + * Creates a new {@link TsGetArgs} and requests the compacted value of the latest, possibly partial, bucket. + * + * @return a new {@link TsGetArgs} with {@code LATEST} configured. + */ + public static TsGetArgs latest() { + return new TsGetArgs().latest(); + } + + } + + /** + * Request the compacted value of the latest, possibly partial, bucket. Only meaningful when the key is the destination of a + * compaction rule; ignored otherwise. + * + * @return {@code this} {@link TsGetArgs}. + */ + public TsGetArgs latest() { + this.latest = true; + return this; + } + + @Override + public void build(CommandArgs args) { + + if (latest) { + args.add(CommandKeyword.LATEST); + } + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/arguments/TsIncrByArgs.java b/src/main/java/io/lettuce/core/timeseries/arguments/TsIncrByArgs.java new file mode 100644 index 0000000000..da76cffa61 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/arguments/TsIncrByArgs.java @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.util.LinkedHashMap; +import java.util.Map; + +import io.lettuce.core.CompositeArgument; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.protocol.CommandKeyword; +import io.lettuce.core.timeseries.TsDuplicatePolicy; +import io.lettuce.core.timeseries.TsEncodingFormat; + +/** + * Argument list builder for the Redis TS.INCRBY and + * TS.DECRBY commands. + *

+ * {@code TS.INCRBY} and {@code TS.DECRBY} are registered by the server against the very same handler and accept an identical + * option signature, so a single argument type covers both. Unlike {@code TS.ADD}, neither command recognizes + * {@code ON_DUPLICATE}: the duplicate-policy override only applies to samples added to an already-existing series, and + * {@code TS.INCRBY}/{@code TS.DECRBY} always resolve their own sample with the {@code LAST} policy internally. The + * {@code DUPLICATE_POLICY} keyword is still accepted (and exposed here via {@link #duplicatePolicy(TsDuplicatePolicy)}), but + * only takes effect when the series does not exist yet and is created inline by this call. + *

+ * {@link TsIncrByArgs} is a mutable object and instances should be used only once to avoid shared mutable state. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public class TsIncrByArgs implements CompositeArgument { + + private Long timestamp; + + private Long retention; + + private TsEncodingFormat encoding; + + private Long chunkSize; + + private TsDuplicatePolicy duplicatePolicy; + + private Long ignoreMaxTimeDiff; + + private Double ignoreMaxValDiff; + + private final Map labels = new LinkedHashMap<>(); + + /** + * Builder entry points for {@link TsIncrByArgs}. + */ + public static class Builder { + + /** + * Utility constructor. + */ + private Builder() { + } + + /** + * Creates a new {@link TsIncrByArgs} and sets the sample timestamp. + * + * @return a new {@link TsIncrByArgs} with the timestamp configured. + */ + public static TsIncrByArgs timestamp(long timestamp) { + return new TsIncrByArgs().timestamp(timestamp); + } + + /** + * Creates a new {@link TsIncrByArgs} and sets the retention period. + * + * @return a new {@link TsIncrByArgs} with retention period configured. + */ + public static TsIncrByArgs retention(long retention) { + return new TsIncrByArgs().retention(retention); + } + + /** + * Creates a new {@link TsIncrByArgs} and sets the chunk encoding. + * + * @return a new {@link TsIncrByArgs} with encoding configured. + */ + public static TsIncrByArgs encoding(TsEncodingFormat encoding) { + return new TsIncrByArgs().encoding(encoding); + } + + /** + * Creates a new {@link TsIncrByArgs} and sets the chunk size. + * + * @return a new {@link TsIncrByArgs} with chunk size configured. + */ + public static TsIncrByArgs chunkSize(long chunkSize) { + return new TsIncrByArgs().chunkSize(chunkSize); + } + + /** + * Creates a new {@link TsIncrByArgs} and sets the duplicate sample policy to apply if the series is created by this + * call. + * + * @return a new {@link TsIncrByArgs} with duplicate policy configured. + */ + public static TsIncrByArgs duplicatePolicy(TsDuplicatePolicy duplicatePolicy) { + return new TsIncrByArgs().duplicatePolicy(duplicatePolicy); + } + + /** + * Creates a new {@link TsIncrByArgs} and sets the ignore thresholds. + * + * @return a new {@link TsIncrByArgs} with ignore thresholds configured. + */ + public static TsIncrByArgs ignore(long maxTimeDiff, double maxValDiff) { + return new TsIncrByArgs().ignore(maxTimeDiff, maxValDiff); + } + + /** + * Creates a new {@link TsIncrByArgs} and adds a single label. + * + * @return a new {@link TsIncrByArgs} with the given label configured. + */ + public static TsIncrByArgs label(String label, String value) { + return new TsIncrByArgs().label(label, value); + } + + /** + * Creates a new {@link TsIncrByArgs} and sets the labels. + * + * @return a new {@link TsIncrByArgs} with labels configured. + */ + public static TsIncrByArgs labels(Map labels) { + return new TsIncrByArgs().labels(labels); + } + + } + + /** + * Set the sample timestamp, in milliseconds. Defaults to the server's current time when omitted. + * + * @return {@code this} {@link TsIncrByArgs}. + */ + public TsIncrByArgs timestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + /** + * Set the retention period, in milliseconds. + * + * @return {@code this} {@link TsIncrByArgs}. + */ + public TsIncrByArgs retention(long retention) { + this.retention = retention; + return this; + } + + /** + * Set the chunk encoding. + * + * @return {@code this} {@link TsIncrByArgs}. + */ + public TsIncrByArgs encoding(TsEncodingFormat encoding) { + this.encoding = encoding; + return this; + } + + /** + * Set the chunk size, in bytes. + * + * @return {@code this} {@link TsIncrByArgs}. + */ + public TsIncrByArgs chunkSize(long chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + /** + * Set the duplicate sample policy to apply if the series is created by this call. + * + * @return {@code this} {@link TsIncrByArgs}. + */ + public TsIncrByArgs duplicatePolicy(TsDuplicatePolicy duplicatePolicy) { + this.duplicatePolicy = duplicatePolicy; + return this; + } + + /** + * Set the maximum time and value difference under which a duplicate sample is ignored instead of applying the duplicate + * policy. + * + * @return {@code this} {@link TsIncrByArgs}. + */ + public TsIncrByArgs ignore(long maxTimeDiff, double maxValDiff) { + this.ignoreMaxTimeDiff = maxTimeDiff; + this.ignoreMaxValDiff = maxValDiff; + return this; + } + + /** + * Add a single label. Repeated calls accumulate labels in call order; combine freely with {@link #labels(Map)}. + * + * @return {@code this} {@link TsIncrByArgs}. + */ + public TsIncrByArgs label(String label, String value) { + this.labels.put(label, value); + return this; + } + + /** + * Replace the labels with the given map, preserving its iteration order. + * + * @return {@code this} {@link TsIncrByArgs}. + */ + public TsIncrByArgs labels(Map labels) { + this.labels.clear(); + this.labels.putAll(labels); + return this; + } + + @Override + public void build(CommandArgs args) { + + if (timestamp != null) { + args.add(CommandKeyword.TIMESTAMP).add(timestamp); + } + if (retention != null) { + args.add(CommandKeyword.RETENTION).add(retention); + } + if (encoding != null) { + args.add(CommandKeyword.ENCODING).add(encoding); + } + if (chunkSize != null) { + args.add(CommandKeyword.CHUNK_SIZE).add(chunkSize); + } + if (duplicatePolicy != null) { + args.add(CommandKeyword.DUPLICATE_POLICY).add(duplicatePolicy); + } + if (ignoreMaxTimeDiff != null) { + args.add(CommandKeyword.IGNORE).add(ignoreMaxTimeDiff).add(ignoreMaxValDiff); + } + if (!labels.isEmpty()) { + args.add(CommandKeyword.LABELS); + labels.forEach((label, value) -> args.add(label).add(value)); + } + } + +} diff --git a/src/main/java/io/lettuce/core/timeseries/arguments/TsMGetArgs.java b/src/main/java/io/lettuce/core/timeseries/arguments/TsMGetArgs.java new file mode 100644 index 0000000000..3c7e541803 --- /dev/null +++ b/src/main/java/io/lettuce/core/timeseries/arguments/TsMGetArgs.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import io.lettuce.core.CompositeArgument; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.protocol.CommandKeyword; + +/** + * Argument list builder for the Redis TS.MGET command. + *

+ * {@code WITHLABELS} and {@code SELECTED_LABELS} are mutually exclusive; the server itself rejects the combination + * ({@code TSDB: cannot accept WITHLABELS and SELECT_LABELS together}), and {@link #build(CommandArgs)} rejects it client-side + * for the same reason. On the wire, {@code SELECTED_LABELS} greedily consumes tokens up to the next recognized keyword, so it + * must be emitted before the {@code FILTER} keyword that the caller appends after this builder's output; {@link #build} never + * emits {@code FILTER} itself. + *

+ * {@link TsMGetArgs} is a mutable object and instances should be used only once to avoid shared mutable state. + * + * @author Gyumin Hwang + * @since 7.7 + */ +public class TsMGetArgs implements CompositeArgument { + + private boolean latest; + + private boolean withLabels; + + private final List selectedLabels = new ArrayList<>(); + + /** + * Builder entry points for {@link TsMGetArgs}. + */ + public static class Builder { + + /** + * Utility constructor. + */ + private Builder() { + } + + /** + * Creates a new {@link TsMGetArgs} and requests the compacted value of the latest, possibly partial, bucket. + * + * @return a new {@link TsMGetArgs} with {@code LATEST} configured. + */ + public static TsMGetArgs latest() { + return new TsMGetArgs().latest(); + } + + /** + * Creates a new {@link TsMGetArgs} and requests that all labels of each matching series be included in the reply. + * + * @return a new {@link TsMGetArgs} with {@code WITHLABELS} configured. + */ + public static TsMGetArgs withLabels() { + return new TsMGetArgs().withLabels(); + } + + /** + * Creates a new {@link TsMGetArgs} and requests that only the given labels of each matching series be included in the + * reply. + * + * @return a new {@link TsMGetArgs} with {@code SELECTED_LABELS} configured. + */ + public static TsMGetArgs selectedLabels(String... labels) { + return new TsMGetArgs().selectedLabels(labels); + } + + } + + /** + * Request the compacted value of the latest, possibly partial, bucket. Only meaningful for series that are the destination + * of a compaction rule; ignored otherwise. + * + * @return {@code this} {@link TsMGetArgs}. + */ + public TsMGetArgs latest() { + this.latest = true; + return this; + } + + /** + * Request that all labels of each matching series be included in the reply. Mutually exclusive with + * {@link #selectedLabels(String...)}. + * + * @return {@code this} {@link TsMGetArgs}. + */ + public TsMGetArgs withLabels() { + this.withLabels = true; + return this; + } + + /** + * Request that only the given labels of each matching series be included in the reply. Mutually exclusive with + * {@link #withLabels()}. + * + * @return {@code this} {@link TsMGetArgs}. + */ + public TsMGetArgs selectedLabels(String... labels) { + this.selectedLabels.clear(); + this.selectedLabels.addAll(Arrays.asList(labels)); + return this; + } + + @Override + public void build(CommandArgs args) { + + if (withLabels && !selectedLabels.isEmpty()) { + throw new IllegalArgumentException("TS.MGET does not accept WITHLABELS and SELECTED_LABELS together"); + } + + if (latest) { + args.add(CommandKeyword.LATEST); + } + if (withLabels) { + args.add(CommandKeyword.WITHLABELS); + } else if (!selectedLabels.isEmpty()) { + args.add(CommandKeyword.SELECTED_LABELS); + selectedLabels.forEach(args::add); + } + } + +} diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommands.kt index a2530d3751..b889d83135 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommands.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommands.kt @@ -54,6 +54,7 @@ interface RedisCoroutinesCommands : RedisArrayCoroutinesCommands, RedisBloomFilterCoroutinesCommands, RedisCuckooFilterCoroutinesCommands, + RedisTimeSeriesCoroutinesCommands, RedisTopKCoroutinesCommands { /** diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommandsImpl.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommandsImpl.kt index f27dbab341..0a0a3bd2c5 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommandsImpl.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisCoroutinesCommandsImpl.kt @@ -60,6 +60,7 @@ open class RedisCoroutinesCommandsImpl( RedisArrayCoroutinesCommands by RedisArrayCoroutinesCommandsImpl(ops), RedisBloomFilterCoroutinesCommands by RedisBloomFilterCoroutinesCommandsImpl(ops), RedisCuckooFilterCoroutinesCommands by RedisCuckooFilterCoroutinesCommandsImpl(ops), + RedisTimeSeriesCoroutinesCommands by RedisTimeSeriesCoroutinesCommandsImpl(ops), RedisTopKCoroutinesCommands by RedisTopKCoroutinesCommandsImpl(ops) { diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisTimeSeriesCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisTimeSeriesCoroutinesCommands.kt new file mode 100644 index 0000000000..4b12d7bdb8 --- /dev/null +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisTimeSeriesCoroutinesCommands.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ + +package io.lettuce.core.api.coroutines + +import io.lettuce.core.ExperimentalLettuceCoroutinesApi +import io.lettuce.core.timeseries.TsAggregationType +import io.lettuce.core.timeseries.TsInfoValue +import io.lettuce.core.timeseries.TsMGetValue +import io.lettuce.core.timeseries.TsSample +import io.lettuce.core.timeseries.arguments.TsAddArgs +import io.lettuce.core.timeseries.arguments.TsAlterArgs +import io.lettuce.core.timeseries.arguments.TsCreateArgs +import io.lettuce.core.timeseries.arguments.TsGetArgs +import io.lettuce.core.timeseries.arguments.TsIncrByArgs +import io.lettuce.core.timeseries.arguments.TsMGetArgs + +/** + * Coroutine executed commands for RedisTimeSeries. + * + * @author Gyumin Hwang + * @param Key type. + * @param Value type. + * @see Redis Time Series + * @since 7.7 + * @generated by io.lettuce.apigenerator.CreateKotlinCoroutinesApi + */ +@ExperimentalLettuceCoroutinesApi +interface RedisTimeSeriesCoroutinesCommands { + + /** + * Create a new time series. + * + * @param key the key. + * @return String simple-string-reply `OK` if `TS.CREATE` was executed correctly. + */ + suspend fun tsCreate(key: K): String? + + /** + * Create a new time series. + * + * @param key the key. + * @param createArgs the create arguments. + * @return String simple-string-reply `OK` if `TS.CREATE` was executed correctly. + */ + suspend fun tsCreate(key: K, createArgs: TsCreateArgs): String? + + /** + * Update the retention, chunk size, duplicate policy, and/or labels of an existing time series. + * + * @param key the key. + * @param alterArgs the alter arguments. + * @return String simple-string-reply `OK` if `TS.ALTER` was executed correctly. + */ + suspend fun tsAlter(key: K, alterArgs: TsAlterArgs): String? + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @return String simple-string-reply `OK` if `TS.CREATERULE` was executed correctly. + */ + suspend fun tsCreateRule(sourceKey: K, destKey: K, aggregationType: TsAggregationType, bucketDuration: Long): String? + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @param alignTimestamp ensures that there is a bucket that starts exactly at this timestamp, in milliseconds. + * @return String simple-string-reply `OK` if `TS.CREATERULE` was executed correctly. + */ + suspend fun tsCreateRule( + sourceKey: K, + destKey: K, + aggregationType: TsAggregationType, + bucketDuration: Long, + alignTimestamp: Long + ): String? + + /** + * Delete a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key. + * @return String simple-string-reply `OK` if `TS.DELETERULE` was executed correctly. + */ + suspend fun tsDeleteRule(sourceKey: K, destKey: K): String? + + /** + * Delete all samples between two timestamps for a given time series. + * + * @param key the key. + * @param fromTimestamp start timestamp, in milliseconds. + * @param toTimestamp end timestamp, in milliseconds. + * @return Long integer-reply the number of samples that were removed. + */ + suspend fun tsDel(key: K, fromTimestamp: Long, toTimestamp: Long): Long? + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + suspend fun tsAdd(key: K, timestamp: Long, value: Double): Long? + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @param addArgs the add arguments. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + suspend fun tsAdd(key: K, timestamp: Long, value: Double, addArgs: TsAddArgs): Long? + + /** + * Append a sample to a time series, letting the server assign the current time as the sample timestamp, creating the series + * automatically if it does not yet exist. + * + * @param key the key. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + suspend fun tsAdd(key: K, value: Double): Long? + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entries the (key, sample) entries to append; each [TsSample] must carry a single value. + * @return List the timestamps that were ultimately used, in the same order as `entries`. + * @throws IllegalArgumentException if any [TsSample] carries more than one value. + */ + suspend fun tsMAdd(vararg entries: Map.Entry): List + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entry the (key, sample) entry to append; the [TsSample] must carry a single value. + * @return List the timestamps that were ultimately used, in the same order as `entries`. + * @throws IllegalArgumentException if the [TsSample] carries more than one value. + */ + suspend fun tsMAdd(entry: Map.Entry): List + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + suspend fun tsIncrBy(key: K, value: Double): Long? + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @param incrByArgs the increment-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + suspend fun tsIncrBy(key: K, value: Double, incrByArgs: TsIncrByArgs): Long? + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + suspend fun tsDecrBy(key: K, value: Double): Long? + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @param decrByArgs the decrement-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + suspend fun tsDecrBy(key: K, value: Double, decrByArgs: TsIncrByArgs): Long? + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @return TsSample the last sample of the time series, or `null` if the series has no samples. + */ + suspend fun tsGet(key: K): TsSample? + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @param getArgs the get arguments. + * @return TsSample the last sample of the time series, or `null` if the series has no samples. + */ + suspend fun tsGet(key: K, getArgs: TsGetArgs): TsSample? + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List> the last sample and, depending on [TsMGetArgs], the labels of each matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + suspend fun tsMGet(vararg filters: V): List> + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List> the last sample and, depending on [TsMGetArgs], the labels of each matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + suspend fun tsMGet(filter: V): List> + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List> the last sample and, depending on [TsMGetArgs], the labels of each matching time series. + * @throws IllegalArgumentException if `mGetArgs` combines `WITHLABELS` and `SELECTED_LABELS`. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + suspend fun tsMGet(mGetArgs: TsMGetArgs, vararg filters: V): List> + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List> the last sample and, depending on [TsMGetArgs], the labels of each matching time series. + * @throws IllegalArgumentException if `mGetArgs` combines `WITHLABELS` and `SELECTED_LABELS`. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + suspend fun tsMGet(mGetArgs: TsMGetArgs, filter: V): List> + + /** + * Get metadata about a time series. + * + * @param key the key. + * @return TsInfoValue metadata about the time series. + */ + suspend fun tsInfo(key: K): TsInfoValue? + + /** + * Get metadata about a time series, including chunk-level debug information. + * + * @param key the key. + * @return TsInfoValue metadata about the time series, including chunk-level debug information. + */ + suspend fun tsInfoDebug(key: K): TsInfoValue? + + /** + * Get all time series keys matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + suspend fun tsQueryIndex(vararg filters: V): List + + /** + * Get all time series keys matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + suspend fun tsQueryIndex(filter: V): List + +} diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisTimeSeriesCoroutinesCommandsImpl.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisTimeSeriesCoroutinesCommandsImpl.kt new file mode 100644 index 0000000000..f862925c2b --- /dev/null +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisTimeSeriesCoroutinesCommandsImpl.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.api.coroutines + +import io.lettuce.core.ExperimentalLettuceCoroutinesApi +import io.lettuce.core.api.reactive.RedisTimeSeriesReactiveCommands +import io.lettuce.core.timeseries.TsAggregationType +import io.lettuce.core.timeseries.TsInfoValue +import io.lettuce.core.timeseries.TsMGetValue +import io.lettuce.core.timeseries.TsSample +import io.lettuce.core.timeseries.arguments.TsAddArgs +import io.lettuce.core.timeseries.arguments.TsAlterArgs +import io.lettuce.core.timeseries.arguments.TsCreateArgs +import io.lettuce.core.timeseries.arguments.TsGetArgs +import io.lettuce.core.timeseries.arguments.TsIncrByArgs +import io.lettuce.core.timeseries.arguments.TsMGetArgs +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactive.awaitFirstOrNull + +/** + * Coroutine executed commands (based on reactive commands) for RedisTimeSeries commands. + * + * @param Key type. + * @param Value type. + * @author Gyumin Hwang + * @since 7.7 + */ +@ExperimentalLettuceCoroutinesApi +internal class RedisTimeSeriesCoroutinesCommandsImpl( + internal val ops: RedisTimeSeriesReactiveCommands +) : RedisTimeSeriesCoroutinesCommands { + + override suspend fun tsCreate(key: K): String? = + ops.tsCreate(key).awaitFirstOrNull() + + override suspend fun tsCreate(key: K, createArgs: TsCreateArgs): String? = + ops.tsCreate(key, createArgs).awaitFirstOrNull() + + override suspend fun tsAlter(key: K, alterArgs: TsAlterArgs): String? = + ops.tsAlter(key, alterArgs).awaitFirstOrNull() + + override suspend fun tsCreateRule(sourceKey: K, destKey: K, aggregationType: TsAggregationType, bucketDuration: Long): String? = + ops.tsCreateRule(sourceKey, destKey, aggregationType, bucketDuration).awaitFirstOrNull() + + override suspend fun tsCreateRule( + sourceKey: K, + destKey: K, + aggregationType: TsAggregationType, + bucketDuration: Long, + alignTimestamp: Long + ): String? = + ops.tsCreateRule(sourceKey, destKey, aggregationType, bucketDuration, alignTimestamp).awaitFirstOrNull() + + override suspend fun tsDeleteRule(sourceKey: K, destKey: K): String? = + ops.tsDeleteRule(sourceKey, destKey).awaitFirstOrNull() + + override suspend fun tsDel(key: K, fromTimestamp: Long, toTimestamp: Long): Long? = + ops.tsDel(key, fromTimestamp, toTimestamp).awaitFirstOrNull() + + override suspend fun tsAdd(key: K, timestamp: Long, value: Double): Long? = + ops.tsAdd(key, timestamp, value).awaitFirstOrNull() + + override suspend fun tsAdd(key: K, timestamp: Long, value: Double, addArgs: TsAddArgs): Long? = + ops.tsAdd(key, timestamp, value, addArgs).awaitFirstOrNull() + + override suspend fun tsAdd(key: K, value: Double): Long? = + ops.tsAdd(key, value).awaitFirstOrNull() + + override suspend fun tsMAdd(vararg entries: Map.Entry): List = + ops.tsMAdd(*entries).asFlow().toList() + + override suspend fun tsMAdd(entry: Map.Entry): List = + ops.tsMAdd(entry).asFlow().toList() + + override suspend fun tsIncrBy(key: K, value: Double): Long? = + ops.tsIncrBy(key, value).awaitFirstOrNull() + + override suspend fun tsIncrBy(key: K, value: Double, incrByArgs: TsIncrByArgs): Long? = + ops.tsIncrBy(key, value, incrByArgs).awaitFirstOrNull() + + override suspend fun tsDecrBy(key: K, value: Double): Long? = + ops.tsDecrBy(key, value).awaitFirstOrNull() + + override suspend fun tsDecrBy(key: K, value: Double, decrByArgs: TsIncrByArgs): Long? = + ops.tsDecrBy(key, value, decrByArgs).awaitFirstOrNull() + + override suspend fun tsGet(key: K): TsSample? = + ops.tsGet(key).awaitFirstOrNull() + + override suspend fun tsGet(key: K, getArgs: TsGetArgs): TsSample? = + ops.tsGet(key, getArgs).awaitFirstOrNull() + + override suspend fun tsMGet(vararg filters: V): List> = + ops.tsMGet(*filters).asFlow().toList() + + override suspend fun tsMGet(filter: V): List> = + ops.tsMGet(filter).asFlow().toList() + + override suspend fun tsMGet(mGetArgs: TsMGetArgs, vararg filters: V): List> = + ops.tsMGet(mGetArgs, *filters).asFlow().toList() + + override suspend fun tsMGet(mGetArgs: TsMGetArgs, filter: V): List> = + ops.tsMGet(mGetArgs, filter).asFlow().toList() + + override suspend fun tsInfo(key: K): TsInfoValue? = + ops.tsInfo(key).awaitFirstOrNull() + + override suspend fun tsInfoDebug(key: K): TsInfoValue? = + ops.tsInfoDebug(key).awaitFirstOrNull() + + override suspend fun tsQueryIndex(vararg filters: V): List = + ops.tsQueryIndex(*filters).asFlow().toList() + + override suspend fun tsQueryIndex(filter: V): List = + ops.tsQueryIndex(filter).asFlow().toList() + +} diff --git a/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommands.kt index c49da571b5..57de2aaba8 100644 --- a/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommands.kt +++ b/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommands.kt @@ -47,6 +47,7 @@ interface RedisClusterCoroutinesCommands : RedisStringCoroutinesCommands, RedisBloomFilterCoroutinesCommands, RedisCuckooFilterCoroutinesCommands, + RedisTimeSeriesCoroutinesCommands, RedisTopKCoroutinesCommands { /** diff --git a/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommandsImpl.kt b/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommandsImpl.kt index 0eba4a424d..390a87ae4a 100644 --- a/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommandsImpl.kt +++ b/src/main/kotlin/io/lettuce/core/cluster/api/coroutines/RedisClusterCoroutinesCommandsImpl.kt @@ -55,6 +55,7 @@ internal class RedisClusterCoroutinesCommandsImpl( RedisStringCoroutinesCommands by RedisStringCoroutinesCommandsImpl(ops), RedisBloomFilterCoroutinesCommands by RedisBloomFilterCoroutinesCommandsImpl(ops), RedisCuckooFilterCoroutinesCommands by RedisCuckooFilterCoroutinesCommandsImpl(ops), + RedisTimeSeriesCoroutinesCommands by RedisTimeSeriesCoroutinesCommandsImpl(ops), RedisTopKCoroutinesCommands by RedisTopKCoroutinesCommandsImpl(ops) { /** diff --git a/src/main/templates/io/lettuce/core/api/RedisTimeSeriesCommands.java b/src/main/templates/io/lettuce/core/api/RedisTimeSeriesCommands.java new file mode 100644 index 0000000000..1bf9a5f55c --- /dev/null +++ b/src/main/templates/io/lettuce/core/api/RedisTimeSeriesCommands.java @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.api; + +import java.util.List; +import java.util.Map; + +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; + +/** + * ${intent} for RedisTimeSeries. + * + * @author Gyumin Hwang + * @param Key type. + * @param Value type. + * @see Redis Time Series + * @since 7.7 + */ +public interface RedisTimeSeriesCommands { + + /** + * Create a new time series. + * + * @param key the key. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + String tsCreate(K key); + + /** + * Create a new time series. + * + * @param key the key. + * @param createArgs the create arguments. + * @return String simple-string-reply {@code OK} if {@code TS.CREATE} was executed correctly. + */ + String tsCreate(K key, TsCreateArgs createArgs); + + /** + * Update the retention, chunk size, duplicate policy, and/or labels of an existing time series. + * + * @param key the key. + * @param alterArgs the alter arguments. + * @return String simple-string-reply {@code OK} if {@code TS.ALTER} was executed correctly. + */ + String tsAlter(K key, TsAlterArgs alterArgs); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + String tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration); + + /** + * Create a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key, that should be already created. + * @param aggregationType the aggregation type. + * @param bucketDuration the bucket duration, in milliseconds. + * @param alignTimestamp ensures that there is a bucket that starts exactly at this timestamp, in milliseconds. + * @return String simple-string-reply {@code OK} if {@code TS.CREATERULE} was executed correctly. + */ + String tsCreateRule(K sourceKey, K destKey, TsAggregationType aggregationType, long bucketDuration, long alignTimestamp); + + /** + * Delete a compaction rule. + * + * @param sourceKey the source key. + * @param destKey the destination key. + * @return String simple-string-reply {@code OK} if {@code TS.DELETERULE} was executed correctly. + */ + String tsDeleteRule(K sourceKey, K destKey); + + /** + * Delete all samples between two timestamps for a given time series. + * + * @param key the key. + * @param fromTimestamp start timestamp, in milliseconds. + * @param toTimestamp end timestamp, in milliseconds. + * @return Long integer-reply the number of samples that were removed. + */ + Long tsDel(K key, long fromTimestamp, long toTimestamp); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Long tsAdd(K key, long timestamp, double value); + + /** + * Append a sample to a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param timestamp the sample timestamp, in milliseconds. + * @param value the sample value. + * @param addArgs the add arguments. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Long tsAdd(K key, long timestamp, double value, TsAddArgs addArgs); + + /** + * Append a sample to a time series, letting the server assign the current time as the sample timestamp, creating the series + * automatically if it does not yet exist. + * + * @param key the key. + * @param value the sample value. + * @return Long integer-reply the timestamp that was ultimately used for the sample. + */ + Long tsAdd(K key, double value); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entries the (key, sample) entries to append; each {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if any {@link TsSample} carries more than one value. + */ + List tsMAdd(Map.Entry... entries); + + /** + * Append samples to multiple time series at once, creating any of the series automatically if it does not yet exist. + * + * @param entry the (key, sample) entry to append; the {@link TsSample} must carry a single value. + * @return List<Long> the timestamps that were ultimately used, in the same order as {@code entries}. + * @throws IllegalArgumentException if the {@link TsSample} carries more than one value. + */ + List tsMAdd(Map.Entry entry); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + Long tsIncrBy(K key, double value); + + /** + * Increment the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to add to the last sample. + * @param incrByArgs the increment-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + Long tsIncrBy(K key, double value, TsIncrByArgs incrByArgs); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @return Long integer-reply the timestamp of the updated sample. + */ + Long tsDecrBy(K key, double value); + + /** + * Decrement the value of the last sample of a time series, creating the series automatically if it does not yet exist. + * + * @param key the key. + * @param value the value to subtract from the last sample. + * @param decrByArgs the decrement-by arguments. + * @return Long integer-reply the timestamp of the updated sample. + */ + Long tsDecrBy(K key, double value, TsIncrByArgs decrByArgs); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + TsSample tsGet(K key); + + /** + * Get the last sample of a time series. + * + * @param key the key. + * @param getArgs the get arguments. + * @return TsSample the last sample of the time series, or {@code null} if the series has no samples. + */ + TsSample tsGet(K key, TsGetArgs getArgs); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List> tsMGet(V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List> tsMGet(V filter); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List> tsMGet(TsMGetArgs mGetArgs, V... filters); + + /** + * Get the last samples of multiple time series matching one or more label filters. + * + * @param mGetArgs the get arguments. + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<TsMGetValue<K>> the last sample and, depending on {@link TsMGetArgs}, the labels of each + * matching time series. + * @throws IllegalArgumentException if {@code mGetArgs} combines {@code WITHLABELS} and {@code SELECTED_LABELS}. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List> tsMGet(TsMGetArgs mGetArgs, V filter); + + /** + * Get metadata about a time series. + * + * @param key the key. + * @return TsInfoValue metadata about the time series. + */ + TsInfoValue tsInfo(K key); + + /** + * Get metadata about a time series, including chunk-level debug information. + * + * @param key the key. + * @return TsInfoValue metadata about the time series, including chunk-level debug information. + */ + TsInfoValue tsInfoDebug(K key); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filters one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List tsQueryIndex(V... filters); + + /** + * Get all time series keys matching one or more label filters. + * + * @param filter one or more label filters, at least one of which must be an equality filter. + * @return List<K> the keys of the matching time series; order is not guaranteed. + * @throws io.lettuce.core.RedisCommandExecutionException if no filter is an equality filter. + */ + List tsQueryIndex(V filter); + +} diff --git a/src/test/java/io/lettuce/apigenerator/Constants.java b/src/test/java/io/lettuce/apigenerator/Constants.java index a01d86aca2..bf1a019a10 100644 --- a/src/test/java/io/lettuce/apigenerator/Constants.java +++ b/src/test/java/io/lettuce/apigenerator/Constants.java @@ -32,7 +32,7 @@ class Constants { "RedisScriptingCommands", "RedisSentinelCommands", "RedisServerCommands", "RedisSetCommands", "RedisSortedSetCommands", "RedisStreamCommands", "RedisStringCommands", "RedisTransactionalCommands", "RedisJsonCommands", "RedisVectorSetCommands", "RediSearchCommands", "RedisArrayCommands", - "RedisBloomFilterCommands", "RedisCuckooFilterCommands", "RedisTopKCommands" }; + "RedisBloomFilterCommands", "RedisCuckooFilterCommands", "RedisTimeSeriesCommands", "RedisTopKCommands" }; public static final File TEMPLATES = new File("src/main/templates"); diff --git a/src/test/java/io/lettuce/core/RedisTimeSeriesCommandBuilderUnitTests.java b/src/test/java/io/lettuce/core/RedisTimeSeriesCommandBuilderUnitTests.java new file mode 100644 index 0000000000..05d703d9c8 --- /dev/null +++ b/src/test/java/io/lettuce/core/RedisTimeSeriesCommandBuilderUnitTests.java @@ -0,0 +1,411 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.protocol.Command; +import io.lettuce.core.timeseries.TsAggregationType; +import io.lettuce.core.timeseries.TsDuplicatePolicy; +import io.lettuce.core.timeseries.TsInfoValue; +import io.lettuce.core.timeseries.TsMGetValue; +import io.lettuce.core.timeseries.TsSample; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsGetArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.AbstractMap; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link RedisTimeSeriesCommandBuilder}. + * + *

+ * PLAN (Given/When/Then): + *

    + *
  • Given a key with no options, when {@code tsCreate(key)} is built, then the wire is {@code TS.CREATE key} with no + * options.
  • + *
  • Given a key with {@code TsCreateArgs} (retention + a label), when {@code tsCreate(key, args)} is built, then the wire is + * {@code TS.CREATE key RETENTION 1000 LABELS a b} with {@code LABELS} emitted last (server consumes argv to the end for + * LABELS).
  • + *
  • Given a key with {@code TsAlterArgs}, when {@code tsAlter(key, args)} is built, then the wire is + * {@code TS.ALTER key CHUNK_SIZE 4096}.
  • + *
  • Given a source/dest key pair and an aggregation type/bucket duration, when {@code tsCreateRule(...)} is built without + * {@code alignTimestamp}, then the wire is {@code TS.CREATERULE src dst AGGREGATION AVG 60000}.
  • + *
  • Given the same inputs plus an {@code alignTimestamp}, when the overload is built, then the wire is + * {@code TS.CREATERULE src dst AGGREGATION AVG 60000 0}.
  • + *
  • Given a source/dest key pair, when {@code tsDeleteRule(...)} is built, then the wire is + * {@code TS.DELETERULE src dst}.
  • + *
  • Given a key and a [from, to] timestamp range, when {@code tsDel(...)} is built, then the wire is + * {@code TS.DEL key 100 200}.
  • + *
  • Given a key, timestamp and value, when {@code tsAdd(key, ts, value)} is built, then the wire is + * {@code TS.ADD key 1000 23.5}.
  • + *
  • Given a key, timestamp, value and {@code TsAddArgs}, when {@code tsAdd(key, ts, value, args)} is built, then the wire is + * {@code TS.ADD key 1000 23.5 ON_DUPLICATE LAST LABELS a b} with {@code LABELS} emitted last.
  • + *
  • Given a key and value with no timestamp, when {@code tsAdd(key, value)} is built, then the wire is + * {@code TS.ADD key * 23.5} (server auto-assigns the timestamp).
  • + *
  • Given two (key, {@link TsSample}) entries, when {@code tsMAdd(...)} is built, then the wire is + * {@code TS.MADD src 1000 23.5 dst 2000 24.5}.
  • + *
  • Given a single (key, {@link TsSample}) entry, when the non-varargs {@code tsMAdd(entry)} overload is built, then the wire + * is {@code TS.MADD src 1000 23.5}.
  • + *
  • Given an entry whose {@link TsSample} carries more than one value, when {@code tsMAdd(...)} (either overload) is built, + * then an {@link IllegalArgumentException} is thrown instead of silently dropping the extra values.
  • + *
  • Given a key and an addend, when {@code tsIncrBy(key, value)} is built, then the wire is {@code TS.INCRBY key 1.5}.
  • + *
  • Given a key, addend and {@code TsIncrByArgs}, when {@code tsIncrBy(key, value, args)} is built, then the wire is + * {@code TS.INCRBY key 1.5 RETENTION 1000}.
  • + *
  • Given a key and a subtrahend, when {@code tsDecrBy(key, value)} is built, then the wire is + * {@code TS.DECRBY key 1.5}.
  • + *
  • Given a key, subtrahend and {@code TsIncrByArgs}, when {@code tsDecrBy(key, value, args)} is built, then the wire is + * {@code TS.DECRBY key 1.5 CHUNK_SIZE 4096}.
  • + *
  • Given a key, when {@code tsGet(key)} is built, then the wire is {@code TS.GET key}.
  • + *
  • Given a key and {@code TsGetArgs.latest()}, when {@code tsGet(key, args)} is built, then the wire is + * {@code TS.GET key LATEST}.
  • + *
  • Given a key, when {@code tsInfo(key)} is built, then the wire is {@code TS.INFO key}.
  • + *
  • Given a key, when {@code tsInfoDebug(key)} is built, then the wire is {@code TS.INFO key DEBUG}.
  • + *
  • Given a single filter, when the non-varargs {@code tsMGet(filter)} overload is built, then the wire is + * {@code TS.MGET FILTER a=1}.
  • + *
  • Given two or more filters, when the varargs {@code tsMGet(filters)} overload is built, then the wire is + * {@code TS.MGET FILTER a=1 b=2}.
  • + *
  • Given {@code TsMGetArgs.withLabels()} and a single filter, when the non-varargs {@code tsMGet(args, filter)} overload is + * built, then the wire is {@code TS.MGET WITHLABELS FILTER a=1} with {@code WITHLABELS} preceding {@code FILTER} (the server's + * greedy {@code SELECTED_LABELS}/{@code WITHLABELS} scan must stop at {@code FILTER}).
  • + *
  • Given {@code TsMGetArgs.withLabels()} and two or more filters, when the varargs {@code tsMGet(args, filters)} overload is + * built, then the wire is {@code TS.MGET WITHLABELS FILTER a=1 b=2}.
  • + *
  • Given two or more filters, when the varargs {@code tsQueryIndex(filters)} overload is built, then the wire is + * {@code TS.QUERYINDEX a=1 b=2} (keyless).
  • + *
  • Given a single filter, when the non-varargs {@code tsQueryIndex(filter)} overload is built, then the wire is + * {@code TS.QUERYINDEX a=1} (keyless).
  • + *
+ * Each assertion also verifies the exact {@link io.lettuce.core.protocol.CommandType} used for encoding, via the RESP + * command-name bulk string in the wire output (custom {@code CommandType}/{@code ProtocolKeyword} render correctly through + * {@code encode(ByteBuf)}; {@code toCommandString()} is avoided per the known Base64 debug-rendering issue for + * non-{@code CommandKeyword}/{@code CommandType} {@code ProtocolKeyword}s). + */ +@Tag(UNIT_TEST) +class RedisTimeSeriesCommandBuilderUnitTests { + + private static final String SOURCE_KEY = "temperature:raw"; + + private static final String DEST_KEY = "temperature:hourly"; + + private final RedisTimeSeriesCommandBuilder builder = new RedisTimeSeriesCommandBuilder<>(StringCodec.UTF8); + + @Test + void shouldCorrectlyConstructTsCreateCommand() { + Command command = builder.tsCreate(SOURCE_KEY); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*2\r\n" + "$9\r\nTS.CREATE\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n"); + } + + @Test + void shouldCorrectlyConstructTsCreateCommandWithArgs() { + TsCreateArgs args = TsCreateArgs.Builder.retention(1000).label("a", "b"); + Command command = builder.tsCreate(SOURCE_KEY, args); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo("*7\r\n" + "$9\r\nTS.CREATE\r\n" + "$15\r\n" + SOURCE_KEY + + "\r\n" + "$9\r\nRETENTION\r\n" + "$4\r\n1000\r\n" + "$6\r\nLABELS\r\n" + "$1\r\na\r\n" + "$1\r\nb\r\n"); + } + + @Test + void shouldCorrectlyConstructTsAlterCommand() { + TsAlterArgs args = TsAlterArgs.Builder.chunkSize(4096); + Command command = builder.tsAlter(SOURCE_KEY, args); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo( + "*4\r\n" + "$8\r\nTS.ALTER\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n" + "$10\r\nCHUNK_SIZE\r\n" + "$4\r\n4096\r\n"); + } + + @Test + void shouldCorrectlyConstructTsCreateRuleCommand() { + Command command = builder.tsCreateRule(SOURCE_KEY, DEST_KEY, TsAggregationType.AVG, 60000); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo("*6\r\n" + "$13\r\nTS.CREATERULE\r\n" + "$" + + SOURCE_KEY.length() + "\r\n" + SOURCE_KEY + "\r\n" + "$" + DEST_KEY.length() + "\r\n" + DEST_KEY + "\r\n" + + "$11\r\nAGGREGATION\r\n" + "$3\r\nAVG\r\n" + "$5\r\n60000\r\n"); + } + + @Test + void shouldCorrectlyConstructTsCreateRuleCommandWithAlignTimestamp() { + Command command = builder.tsCreateRule(SOURCE_KEY, DEST_KEY, TsAggregationType.AVG, 60000, 0); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo("*7\r\n" + "$13\r\nTS.CREATERULE\r\n" + "$" + + SOURCE_KEY.length() + "\r\n" + SOURCE_KEY + "\r\n" + "$" + DEST_KEY.length() + "\r\n" + DEST_KEY + "\r\n" + + "$11\r\nAGGREGATION\r\n" + "$3\r\nAVG\r\n" + "$5\r\n60000\r\n" + "$1\r\n0\r\n"); + } + + @Test + void shouldCorrectlyConstructTsDeleteRuleCommand() { + Command command = builder.tsDeleteRule(SOURCE_KEY, DEST_KEY); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo("*3\r\n" + "$13\r\nTS.DELETERULE\r\n" + "$" + + SOURCE_KEY.length() + "\r\n" + SOURCE_KEY + "\r\n" + "$" + DEST_KEY.length() + "\r\n" + DEST_KEY + "\r\n"); + } + + @Test + void shouldCorrectlyConstructTsDelCommand() { + Command command = builder.tsDel(SOURCE_KEY, 100, 200); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*4\r\n" + "$6\r\nTS.DEL\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n" + "$3\r\n100\r\n" + "$3\r\n200\r\n"); + } + + @Test + void shouldCorrectlyConstructTsAddCommand() { + Command command = builder.tsAdd(SOURCE_KEY, 1000, 23.5); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo( + "*4\r\n" + "$6\r\nTS.ADD\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n" + "$4\r\n1000\r\n" + "$4\r\n23.5\r\n"); + } + + @Test + void shouldCorrectlyConstructTsAddCommandWithArgs() { + TsAddArgs args = TsAddArgs.Builder.onDuplicate(TsDuplicatePolicy.LAST).label("a", "b"); + Command command = builder.tsAdd(SOURCE_KEY, 1000, 23.5, args); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*9\r\n" + "$6\r\nTS.ADD\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n" + "$4\r\n1000\r\n" + "$4\r\n23.5\r\n" + + "$12\r\nON_DUPLICATE\r\n" + "$4\r\nLAST\r\n" + "$6\r\nLABELS\r\n" + "$1\r\na\r\n" + "$1\r\nb\r\n"); + } + + @Test + void shouldCorrectlyConstructTsAddCommandWithAutoTimestamp() { + Command command = builder.tsAdd(SOURCE_KEY, 23.5); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*4\r\n" + "$6\r\nTS.ADD\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n" + "$1\r\n*\r\n" + "$4\r\n23.5\r\n"); + } + + @Test + void shouldCorrectlyConstructTsMAddCommand() { + Map.Entry first = new AbstractMap.SimpleEntry<>(SOURCE_KEY, + new TsSample(1000, Collections.singletonList(23.5))); + Map.Entry second = new AbstractMap.SimpleEntry<>(DEST_KEY, + new TsSample(2000, Collections.singletonList(24.5))); + + Command> command = builder.tsMAdd(first, second); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo("*7\r\n" + "$7\r\nTS.MADD\r\n" + "$" + SOURCE_KEY.length() + + "\r\n" + SOURCE_KEY + "\r\n" + "$4\r\n1000\r\n" + "$4\r\n23.5\r\n" + "$" + DEST_KEY.length() + "\r\n" + + DEST_KEY + "\r\n" + "$4\r\n2000\r\n" + "$4\r\n24.5\r\n"); + } + + @Test + void shouldCorrectlyConstructTsMAddCommandWithSingleEntry() { + Map.Entry entry = new AbstractMap.SimpleEntry<>(SOURCE_KEY, + new TsSample(1000, Collections.singletonList(23.5))); + + Command> command = builder.tsMAdd(entry); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo("*4\r\n" + "$7\r\nTS.MADD\r\n" + "$" + SOURCE_KEY.length() + + "\r\n" + SOURCE_KEY + "\r\n" + "$4\r\n1000\r\n" + "$4\r\n23.5\r\n"); + } + + @Test + void shouldRejectTsMAddSingleEntryWithMultiValueSample() { + Map.Entry entry = new AbstractMap.SimpleEntry<>(SOURCE_KEY, + new TsSample(1000, Arrays.asList(23.5, 24.5))); + + assertThatThrownBy(() -> builder.tsMAdd(entry)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TS.MADD"); + } + + @Test + void shouldRejectTsMAddVarargsWithMultiValueSample() { + Map.Entry first = new AbstractMap.SimpleEntry<>(SOURCE_KEY, + new TsSample(1000, Collections.singletonList(23.5))); + Map.Entry second = new AbstractMap.SimpleEntry<>(DEST_KEY, + new TsSample(2000, Arrays.asList(24.5, 25.5))); + + assertThatThrownBy(() -> builder.tsMAdd(first, second)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TS.MADD"); + } + + @Test + void shouldCorrectlyConstructTsIncrByCommand() { + Command command = builder.tsIncrBy(SOURCE_KEY, 1.5); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*3\r\n" + "$9\r\nTS.INCRBY\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n" + "$3\r\n1.5\r\n"); + } + + @Test + void shouldCorrectlyConstructTsIncrByCommandWithArgs() { + TsIncrByArgs args = TsIncrByArgs.Builder.retention(1000); + Command command = builder.tsIncrBy(SOURCE_KEY, 1.5, args); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo("*5\r\n" + "$9\r\nTS.INCRBY\r\n" + "$15\r\n" + SOURCE_KEY + + "\r\n" + "$3\r\n1.5\r\n" + "$9\r\nRETENTION\r\n" + "$4\r\n1000\r\n"); + } + + @Test + void shouldCorrectlyConstructTsDecrByCommand() { + Command command = builder.tsDecrBy(SOURCE_KEY, 1.5); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*3\r\n" + "$9\r\nTS.DECRBY\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n" + "$3\r\n1.5\r\n"); + } + + @Test + void shouldCorrectlyConstructTsDecrByCommandWithArgs() { + TsIncrByArgs args = TsIncrByArgs.Builder.chunkSize(4096); + Command command = builder.tsDecrBy(SOURCE_KEY, 1.5, args); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo("*5\r\n" + "$9\r\nTS.DECRBY\r\n" + "$15\r\n" + SOURCE_KEY + + "\r\n" + "$3\r\n1.5\r\n" + "$10\r\nCHUNK_SIZE\r\n" + "$4\r\n4096\r\n"); + } + + @Test + void shouldCorrectlyConstructTsGetCommand() { + Command command = builder.tsGet(SOURCE_KEY); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*2\r\n" + "$6\r\nTS.GET\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n"); + } + + @Test + void shouldCorrectlyConstructTsGetCommandWithArgs() { + TsGetArgs args = TsGetArgs.Builder.latest(); + Command command = builder.tsGet(SOURCE_KEY, args); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*3\r\n" + "$6\r\nTS.GET\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n" + "$6\r\nLATEST\r\n"); + } + + @Test + void shouldCorrectlyConstructTsInfoCommand() { + Command> command = builder.tsInfo(SOURCE_KEY); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*2\r\n" + "$7\r\nTS.INFO\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n"); + } + + @Test + void shouldCorrectlyConstructTsInfoDebugCommand() { + Command> command = builder.tsInfoDebug(SOURCE_KEY); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*3\r\n" + "$7\r\nTS.INFO\r\n" + "$15\r\n" + SOURCE_KEY + "\r\n" + "$5\r\nDEBUG\r\n"); + } + + @Test + void shouldCorrectlyConstructTsMGetCommand() { + Command>> command = builder.tsMGet("region=us"); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*3\r\n" + "$7\r\nTS.MGET\r\n" + "$6\r\nFILTER\r\n" + "$9\r\nregion=us\r\n"); + } + + @Test + void shouldCorrectlyConstructTsMGetCommandWithMultipleFilters() { + Command>> command = builder.tsMGet("region=us", "env=prod"); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*4\r\n" + "$7\r\nTS.MGET\r\n" + "$6\r\nFILTER\r\n" + "$9\r\nregion=us\r\n" + "$8\r\nenv=prod\r\n"); + } + + @Test + void shouldCorrectlyConstructTsMGetCommandWithArgs() { + TsMGetArgs args = TsMGetArgs.Builder.withLabels(); + Command>> command = builder.tsMGet(args, "region=us"); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo( + "*4\r\n" + "$7\r\nTS.MGET\r\n" + "$10\r\nWITHLABELS\r\n" + "$6\r\nFILTER\r\n" + "$9\r\nregion=us\r\n"); + } + + @Test + void shouldCorrectlyConstructTsMGetCommandWithArgsAndMultipleFilters() { + TsMGetArgs args = TsMGetArgs.Builder.withLabels(); + Command>> command = builder.tsMGet(args, "region=us", "env=prod"); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)).isEqualTo("*5\r\n" + "$7\r\nTS.MGET\r\n" + "$10\r\nWITHLABELS\r\n" + + "$6\r\nFILTER\r\n" + "$9\r\nregion=us\r\n" + "$8\r\nenv=prod\r\n"); + } + + @Test + void shouldCorrectlyConstructTsQueryIndexCommand() { + Command> command = builder.tsQueryIndex("region=us", "env=prod"); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*3\r\n" + "$13\r\nTS.QUERYINDEX\r\n" + "$9\r\nregion=us\r\n" + "$8\r\nenv=prod\r\n"); + } + + @Test + void shouldCorrectlyConstructTsQueryIndexCommandWithSingleFilter() { + Command> command = builder.tsQueryIndex("region=us"); + ByteBuf buff = Unpooled.buffer(); + command.encode(buff); + + assertThat(buff.toString(StandardCharsets.UTF_8)) + .isEqualTo("*2\r\n" + "$13\r\nTS.QUERYINDEX\r\n" + "$9\r\nregion=us\r\n"); + } + +} diff --git a/src/test/java/io/lettuce/core/cluster/ClusterReadOnlyCommandsUnitTests.java b/src/test/java/io/lettuce/core/cluster/ClusterReadOnlyCommandsUnitTests.java index e57cae6bb8..90040bdbe4 100644 --- a/src/test/java/io/lettuce/core/cluster/ClusterReadOnlyCommandsUnitTests.java +++ b/src/test/java/io/lettuce/core/cluster/ClusterReadOnlyCommandsUnitTests.java @@ -20,7 +20,7 @@ class ClusterReadOnlyCommandsUnitTests { @Test void testCount() { - assertThat(ClusterReadOnlyCommands.getReadOnlyCommands()).hasSize(135); + assertThat(ClusterReadOnlyCommands.getReadOnlyCommands()).hasSize(139); } @Test diff --git a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesCharsetIntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesCharsetIntegrationTests.java new file mode 100644 index 0000000000..3f20218b43 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesCharsetIntegrationTests.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import javax.inject.Inject; + +import io.lettuce.core.RedisCommandExecutionException; +import io.lettuce.core.api.sync.RedisCommands; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.test.LettuceExtension; +import io.lettuce.test.condition.EnabledOnCommand; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; + +import static io.lettuce.TestTags.INTEGRATION_TEST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for special-character and boundary-value handling of + * {@link io.lettuce.core.api.sync.RedisTimeSeriesCommands}: forbidden {@code LABELS} characters, UTF-8 label/key + * round-tripping, {@code timestamp} boundaries and {@code CHUNK_SIZE} boundaries. + *

+ * Expectations below (server error messages and boundary values) were confirmed directly against a live server + * ({@code redis-cli}) before being encoded as assertions; see the class-level PLAN comments on each test. + * + * @author Gyumin Hwang + * @since 7.7 + */ +@Tag(INTEGRATION_TEST) +@ExtendWith(LettuceExtension.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@EnabledOnCommand("TS.CREATE") +public class RedisTimeSeriesCharsetIntegrationTests { + + private static final String MY_KEY = "charset:1"; + + protected final RedisCommands redis; + + @Inject + protected RedisTimeSeriesCharsetIntegrationTests(RedisCommands redis) { + this.redis = redis; + } + + @BeforeEach + void prepare() { + redis.flushall(); + } + + @AfterAll + void teardown() { + redis.flushall(); + } + + // ------------------------------------------------------------------------------------------------------------ + // B-1: label value forbidden characters -- server rejects "(", ")", "," and empty key/value. + // Confirmed live: `TS.CREATE b1 LABELS region "us,west"` -> "ERR TSDB: Couldn't parse LABELS" (same message for + // "(x)" and for an empty label key/value). + // ------------------------------------------------------------------------------------------------------------ + + @Test + void labelValueWithCommaIsRejected() { + TsCreateArgs args = TsCreateArgs.Builder.label("region", "us,west"); + + assertThatThrownBy(() -> redis.tsCreate(MY_KEY, args)).isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("TSDB: Couldn't parse LABELS"); + } + + @Test + void labelValueWithParenthesesIsRejected() { + TsCreateArgs args = TsCreateArgs.Builder.label("k", "(x)"); + + assertThatThrownBy(() -> redis.tsCreate(MY_KEY, args)).isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("TSDB: Couldn't parse LABELS"); + } + + @Test + void emptyLabelKeyIsRejected() { + TsCreateArgs args = TsCreateArgs.Builder.label("", "val"); + + assertThatThrownBy(() -> redis.tsCreate(MY_KEY, args)).isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("TSDB: Couldn't parse LABELS"); + } + + @Test + void emptyLabelValueIsRejected() { + TsCreateArgs args = TsCreateArgs.Builder.label("k", ""); + + assertThatThrownBy(() -> redis.tsCreate(MY_KEY, args)).isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("TSDB: Couldn't parse LABELS"); + } + + // ------------------------------------------------------------------------------------------------------------ + // B-2: UTF-8 label/key round trip. Confirmed live: `TS.CREATE 온도:1 LABELS city 서울 emoji 🌡` succeeds and + // `TS.INFO` echoes back the exact same bytes for both the label values and (via key redirection) the key. + // ------------------------------------------------------------------------------------------------------------ + + @Test + void unicodeLabelsAndKeyRoundTripThroughInfo() { + String key = "온도:1"; + TsCreateArgs args = TsCreateArgs.Builder.label("city", "서울").label("emoji", "🌡"); + + assertThat(redis.tsCreate(key, args)).isEqualTo("OK"); + + TsInfoValue info = redis.tsInfo(key); + assertThat(info.getLabels()).containsEntry("city", "서울").containsEntry("emoji", "🌡"); + } + + // ------------------------------------------------------------------------------------------------------------ + // B-3: timestamp boundaries. Confirmed live: + // `TS.ADD k 0 5.0` -> succeeds, `TS.GET` == (0, 5.0); + // `TS.ADD k -1 5.0` -> "ERR TSDB: invalid timestamp, must be a nonnegative integer"; + // `TS.ADD k 9223372036854775807 5.0` -> succeeds, `TS.GET` == (Long.MAX_VALUE, 5.0). + // ------------------------------------------------------------------------------------------------------------ + + @Test + void timestampZeroIsValid() { + assertThat(redis.tsAdd(MY_KEY, 0, 5.0)).isEqualTo(0L); + + TsSample sample = redis.tsGet(MY_KEY); + assertThat(sample.getTimestamp()).isEqualTo(0L); + assertThat(sample.getValue()).isEqualTo(5.0); + } + + @Test + void negativeTimestampIsRejected() { + assertThatThrownBy(() -> redis.tsAdd(MY_KEY, -1, 5.0)).isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("TSDB: invalid timestamp, must be a nonnegative integer"); + } + + @Test + void maxLongTimestampIsValid() { + assertThat(redis.tsAdd(MY_KEY, Long.MAX_VALUE, 5.0)).isEqualTo(Long.MAX_VALUE); + + TsSample sample = redis.tsGet(MY_KEY); + assertThat(sample.getTimestamp()).isEqualTo(Long.MAX_VALUE); + assertThat(sample.getValue()).isEqualTo(5.0); + } + + // ------------------------------------------------------------------------------------------------------------ + // B-4: CHUNK_SIZE boundaries. Confirmed live: 48 and 1048576 (the documented [48..1048576] range ends) succeed; + // 47 and 50 (not a multiple of 8) both fail with + // "ERR TSDB: CHUNK_SIZE value must be a multiple of 8 in the range [48 .. 1048576]". + // ------------------------------------------------------------------------------------------------------------ + + @Test + void chunkSizeAtLowerBoundIsValid() { + assertThat(redis.tsCreate(MY_KEY, TsCreateArgs.Builder.chunkSize(48))).isEqualTo("OK"); + } + + @Test + void chunkSizeAtUpperBoundIsValid() { + assertThat(redis.tsCreate(MY_KEY, TsCreateArgs.Builder.chunkSize(1048576))).isEqualTo("OK"); + } + + @Test + void chunkSizeBelowLowerBoundIsRejected() { + assertThatThrownBy(() -> redis.tsCreate(MY_KEY, TsCreateArgs.Builder.chunkSize(47))) + .isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("TSDB: CHUNK_SIZE value must be a multiple of 8 in the range [48 .. 1048576]"); + } + + @Test + void chunkSizeNotAMultipleOfEightIsRejected() { + assertThatThrownBy(() -> redis.tsCreate(MY_KEY, TsCreateArgs.Builder.chunkSize(50))) + .isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("TSDB: CHUNK_SIZE value must be a multiple of 8 in the range [48 .. 1048576]"); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesClusterIntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesClusterIntegrationTests.java new file mode 100644 index 0000000000..78b704468a --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesClusterIntegrationTests.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import javax.inject.Inject; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import io.lettuce.core.cluster.ClusterTestUtil; +import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; + +import static io.lettuce.TestTags.INTEGRATION_TEST; + +/** + * Integration tests for Redis TimeSeries commands using Redis Cluster. + * + * @author Gyumin Hwang + * @since 7.7 + */ +@Tag(INTEGRATION_TEST) +public class RedisTimeSeriesClusterIntegrationTests extends RedisTimeSeriesIntegrationTests { + + private static final String FILTER_ACROSS_SLOTS_REASON = "TS.MGET/TS.QUERYINDEX FILTER matches series by label across " + + "the whole keyspace; on Redis Cluster a single-node connection can only see the series hashed to that node's " + + "slots, so a filter that matches keys spread across multiple slots cannot be fully resolved"; + + @Inject + RedisTimeSeriesClusterIntegrationTests(StatefulRedisClusterConnection connection) { + super(ClusterTestUtil.redisCommandsOverCluster(connection)); + } + + @Disabled(FILTER_ACROSS_SLOTS_REASON) + @Test + @Override + void tsMGetWithoutLabelsOptionReturnsEmptyLabelMap() { + } + + @Disabled(FILTER_ACROSS_SLOTS_REASON) + @Test + @Override + void tsMGetWithLabelsIncludesAllLabels() { + } + + @Disabled(FILTER_ACROSS_SLOTS_REASON) + @Test + @Override + void tsMGetIncludesSeriesWithNoSamples() { + } + + @Disabled(FILTER_ACROSS_SLOTS_REASON) + @Test + @Override + void tsQueryIndexReturnsMatchingKeys() { + } + + @Disabled(FILTER_ACROSS_SLOTS_REASON) + @Test + @Override + void tsQueryIndexWithMultipleFiltersNarrowsResults() { + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesEdgeCaseIntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesEdgeCaseIntegrationTests.java new file mode 100644 index 0000000000..4b6add9256 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesEdgeCaseIntegrationTests.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import io.lettuce.core.RedisCommandExecutionException; +import io.lettuce.core.RedisFuture; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.async.RedisAsyncCommands; +import io.lettuce.core.api.sync.RedisCommands; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.test.LettuceExtension; +import io.lettuce.test.condition.EnabledOnCommand; +import io.lettuce.test.condition.RedisConditions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; + +import static io.lettuce.TestTags.INTEGRATION_TEST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Edge-case integration tests for Redis TimeSeries commands, split out from {@link RedisTimeSeriesIntegrationTests} because + * these round-trips assert against a live server rather than just a round-trip of client-supplied values: {@code Double} + * special values ({@code Infinity}/{@code NaN}) sent through {@code TS.ADD}, the (undocumented) interaction between a + * {@code NaN} value and each {@code DUPLICATE_POLICY}, and async error propagation on a server-rejected command. + * + * @author Gyumin Hwang + */ +@Tag(INTEGRATION_TEST) +@ExtendWith(LettuceExtension.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@EnabledOnCommand("TS.CREATE") +class RedisTimeSeriesEdgeCaseIntegrationTests { + + private static final String MY_KEY = "temperature:sensor1"; + + private final RedisCommands redis; + + private final RedisAsyncCommands async; + + @Inject + RedisTimeSeriesEdgeCaseIntegrationTests(StatefulRedisConnection connection) { + this.redis = connection.sync(); + this.async = connection.async(); + } + + @BeforeEach + void prepare() { + redis.flushall(); + } + + @AfterAll + void teardown() { + redis.flushall(); + } + + // ------------------------------------------------------------------------------------------------------------ + // P1-2: Infinity value rejection / NaN acceptance (asymmetric) + // ------------------------------------------------------------------------------------------------------------ + + @Test + void tsAddRejectsPositiveInfinity() { + assertThatThrownBy(() -> redis.tsAdd(MY_KEY, 1000, Double.POSITIVE_INFINITY)) + .isInstanceOf(RedisCommandExecutionException.class).hasMessageContaining("TSDB: invalid value"); + } + + @Test + void tsAddRejectsNegativeInfinity() { + assertThatThrownBy(() -> redis.tsAdd(MY_KEY, 1000, Double.NEGATIVE_INFINITY)) + .isInstanceOf(RedisCommandExecutionException.class).hasMessageContaining("TSDB: invalid value"); + } + + /** + * Unlike {@code Infinity}, {@code NaN} is accepted by {@code TS.ADD} and stored as-is: an asymmetry in the server's value + * validation that a Java caller passing a {@code double} through unchanged can easily trip over. + */ + @Test + void tsAddAcceptsNaNAndStoresIt() { + assumeTrue(RedisConditions.of(redis).hasVersionGreaterOrEqualsTo("8.6")); + + Long timestamp = redis.tsAdd(MY_KEY, 1000, Double.NaN); + + assertThat(timestamp).isEqualTo(1000L); + assertThat(redis.tsGet(MY_KEY).getValue()).isNaN(); + } + + // ------------------------------------------------------------------------------------------------------------ + // P1-3: NaN x DUPLICATE_POLICY matrix (undocumented server behavior) + // ------------------------------------------------------------------------------------------------------------ + + /** + * With {@code DUPLICATE_POLICY=LAST}, overwriting an existing valid sample with a {@code NaN} value at the same timestamp + * does not take effect: the server silently keeps the existing valid value instead of replacing it with {@code NaN}. + * This behavior is not documented on redis.io; confirmed directly against a live server. + */ + @Test + void tsAddWithNaNUnderDuplicatePolicyLastPreservesExistingValue() { + assumeTrue(RedisConditions.of(redis).hasVersionGreaterOrEqualsTo("8.6")); + + redis.tsCreate(MY_KEY, TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.LAST)); + redis.tsAdd(MY_KEY, 1000, 5.0); + + redis.tsAdd(MY_KEY, 1000, Double.NaN); + + assertThat(redis.tsGet(MY_KEY).getValue()).isEqualTo(5.0); + } + + @Test + void tsAddWithNaNUnderDuplicatePolicyMaxFails() { + assertDuplicateNaNRejected(TsDuplicatePolicy.MAX); + } + + @Test + void tsAddWithNaNUnderDuplicatePolicyMinFails() { + assertDuplicateNaNRejected(TsDuplicatePolicy.MIN); + } + + @Test + void tsAddWithNaNUnderDuplicatePolicySumFails() { + assertDuplicateNaNRejected(TsDuplicatePolicy.SUM); + } + + @Test + void tsAddWithNaNUnderDuplicatePolicyBlockFails() { + assertDuplicateNaNRejected(TsDuplicatePolicy.BLOCK); + } + + /** + * Shared assertion for every {@code DUPLICATE_POLICY} other than {@code LAST}/{@code FIRST}: re-adding a {@code NaN} value + * at a timestamp that already holds a valid sample is rejected, whatever the reason (merge policy vs. {@code BLOCK} mode), + * because the server reports both conditions through the identical error message. + */ + private void assertDuplicateNaNRejected(TsDuplicatePolicy policy) { + assumeTrue(RedisConditions.of(redis).hasVersionGreaterOrEqualsTo("8.6")); + + redis.tsCreate(MY_KEY, TsCreateArgs.Builder.duplicatePolicy(policy)); + redis.tsAdd(MY_KEY, 1000, 5.0); + + assertThatThrownBy(() -> redis.tsAdd(MY_KEY, 1000, Double.NaN)).isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("DUPLICATE_POLICY is MAX/MIN/SUM"); + } + + // ------------------------------------------------------------------------------------------------------------ + // P1-1: async error propagation on a server-rejected command (contrast with the reactive API's hang, see + // RedisTimeSeriesReactiveIntegrationTests#tsMGetWithoutEqualityFilterFails) + // ------------------------------------------------------------------------------------------------------------ + + @Test + void tsInfoOnMissingKeyCompletesExceptionallyInsteadOfHanging() { + RedisFuture> future = async.tsInfo("no-such-key"); + + assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(RedisCommandExecutionException.class).cause() + .hasMessageContaining("TSDB: the key does not exist"); + } + + @Test + void tsMGetWithoutEqualityFilterCompletesExceptionallyInsteadOfHanging() { + RedisFuture future = async.tsMGet("type!=temp"); + + assertThatThrownBy(() -> future.get(5, TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(RedisCommandExecutionException.class).cause() + .hasMessageContaining("TSDB: please provide at least one matcher"); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesIntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesIntegrationTests.java new file mode 100644 index 0000000000..7ca8cb59b6 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesIntegrationTests.java @@ -0,0 +1,515 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.util.AbstractMap; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + +import io.lettuce.core.RedisCommandExecutionException; +import io.lettuce.core.api.sync.RedisCommands; +import io.lettuce.core.timeseries.arguments.TsAddArgs; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsMGetArgs; +import io.lettuce.test.LettuceExtension; +import io.lettuce.test.condition.EnabledOnCommand; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; + +import static io.lettuce.TestTags.INTEGRATION_TEST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.data.Offset.offset; + +/** + * Integration tests for {@link io.lettuce.core.api.sync.RedisTimeSeriesCommands}. + *

+ * These are round-trip tests: every write is followed by a read (mostly {@code TS.INFO}/{@code TS.GET}/{@code TS.MGET}) that + * verifies server-side state, not just that the write returned {@code OK}. + * + * @author Gyumin Hwang + */ +@Tag(INTEGRATION_TEST) +@ExtendWith(LettuceExtension.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@EnabledOnCommand("TS.CREATE") +public class RedisTimeSeriesIntegrationTests { + + private static final String MY_KEY = "temperature:sensor1"; + + // hash-tagged so both keys route to the same cluster slot for TS.CREATERULE/TS.DELETERULE + private static final String SOURCE_KEY = "{ts-rule}:raw"; + + private static final String DEST_KEY = "{ts-rule}:hourly"; + + private static final String DEST_KEY2 = "{ts-rule}:daily"; + + protected final RedisCommands redis; + + @Inject + protected RedisTimeSeriesIntegrationTests(RedisCommands redis) { + this.redis = redis; + } + + @BeforeEach + void prepare() { + redis.flushall(); + } + + @AfterAll + void teardown() { + redis.flushall(); + } + + // ------------------------------------------------------------------------------------------------------------ + // TS.CREATE / TS.ALTER / TS.INFO + // ------------------------------------------------------------------------------------------------------------ + + @Test + void tsCreate() { + String result = redis.tsCreate(MY_KEY); + + assertThat(result).isEqualTo("OK"); + // TS.ALTER only succeeds against a series that actually exists on the server. + assertThat(redis.tsAlter(MY_KEY, TsAlterArgs.Builder.retention(1000))).isEqualTo("OK"); + } + + /** + * Create -> Read round trip. Verifies every {@code TS.CREATE} option actually persisted server-side, including the + * {@code IGNORE} thresholds and that {@code LABELS} never absorbs {@code IGNORE}'s values as a label pair. + */ + @Test + void tsCreateWithAllOptionsRoundTripsThroughInfo() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + labels.put("type", "temp"); + + TsCreateArgs args = TsCreateArgs.Builder.retention(86400000).chunkSize(4096).duplicatePolicy(TsDuplicatePolicy.LAST) + .ignore(5, 0.1).labels(labels); + + assertThat(redis.tsCreate(MY_KEY, args)).isEqualTo("OK"); + + TsInfoValue info = redis.tsInfo(MY_KEY); + assertThat(info.getRetentionTime()).isEqualTo(86400000L); + assertThat(info.getChunkSize()).isEqualTo(4096L); + assertThat(info.getDuplicatePolicy()).isEqualToIgnoringCase("LAST"); + assertThat(info.getIgnoreMaxTimeDiff()).isEqualTo(5L); + assertThat(info.getIgnoreMaxValDiff()).isEqualTo(0.1); + assertThat(info.getTotalSamples()).isEqualTo(0L); + // The critical regression check: LABELS must never absorb IGNORE's own arguments as a label pair. + assertThat(info.getLabels()).containsExactlyInAnyOrderEntriesOf(labels); + assertThat(info.getLabels()).doesNotContainKeys("IGNORE", "5", "0.1"); + } + + @Test + void tsCreateWithUncompressedEncoding() { + TsCreateArgs args = TsCreateArgs.Builder.encoding(TsEncodingFormat.UNCOMPRESSED); + + String result = redis.tsCreate(MY_KEY, args); + + assertThat(result).isEqualTo("OK"); + } + + /** + * When {@code DUPLICATE_POLICY} is never configured on the series, {@code TS.INFO} does not report an absent/{@code null} + * value: it reports the module's effective default policy (the {@code ts-duplicate-policy} server config, {@code block} by + * default), lowercased. Confirmed directly against a live server; the module source's {@code DP_NONE} internal state never + * reaches the client as a blank/absent value. + */ + @Test + void tsCreateWithoutDuplicatePolicyReportsServerDefaultPolicy() { + redis.tsCreate(MY_KEY); + + assertThat(redis.tsInfo(MY_KEY).getDuplicatePolicy()).isEqualToIgnoringCase("block"); + } + + /** + * Verifies that {@code LABELS} reaches the server last on the wire even when {@code IGNORE} is configured after + * {@code LABELS} on the builder. The RedisTimeSeries module consumes every remaining token after {@code LABELS} as a label + * pair, so if {@code IGNORE} were emitted after {@code LABELS} the server would misinterpret it as a label key. + */ + @Test + void tsCreateWithIgnoreAfterLabelsOnBuilderStillSucceeds() { + Map labels = new LinkedHashMap<>(); + labels.put("sensor", "1"); + + TsCreateArgs args = TsCreateArgs.Builder.labels(labels).ignore(100, 0.1); + + String result = redis.tsCreate(MY_KEY, args); + + assertThat(result).isEqualTo("OK"); + assertThat(redis.tsInfo(MY_KEY).getLabels()).containsExactlyInAnyOrderEntriesOf(labels); + assertThat(redis.tsInfo(MY_KEY).getLabels()).doesNotContainKeys("IGNORE", "100", "0.1"); + } + + @Test + void tsAlter() { + redis.tsCreate(MY_KEY); + + String result = redis.tsAlter(MY_KEY, TsAlterArgs.Builder.retention(5000).duplicatePolicy(TsDuplicatePolicy.MAX)); + + assertThat(result).isEqualTo("OK"); + } + + /** + * Update -> Read round trip. {@code TS.ALTER}'s {@code LABELS} replaces the entire label set, not merges into it. + */ + @Test + void tsAlterRoundTripReplacesLabels() { + Map initialLabels = new LinkedHashMap<>(); + initialLabels.put("region", "us"); + initialLabels.put("type", "temp"); + redis.tsCreate(MY_KEY, TsCreateArgs.Builder.labels(initialLabels)); + + String result = redis.tsAlter(MY_KEY, TsAlterArgs.Builder.retention(3600000).label("region", "eu")); + + assertThat(result).isEqualTo("OK"); + TsInfoValue info = redis.tsInfo(MY_KEY); + assertThat(info.getRetentionTime()).isEqualTo(3600000L); + assertThat(info.getLabels()).containsExactly(new AbstractMap.SimpleEntry<>("region", "eu")); + } + + @Test + void tsAlterLabelsReset() { + Map labels = new LinkedHashMap<>(); + labels.put("sensor", "1"); + redis.tsCreate(MY_KEY, TsCreateArgs.Builder.labels(labels)); + + String result = redis.tsAlter(MY_KEY, TsAlterArgs.Builder.labelsReset()); + + assertThat(result).isEqualTo("OK"); + assertThat(redis.tsInfo(MY_KEY).getLabels()).isEmpty(); + } + + @Test + void tsAlterOnNonExistentKeyFails() { + assertThatThrownBy(() -> redis.tsAlter("does-not-exist", TsAlterArgs.Builder.retention(1000))) + .isInstanceOf(RedisCommandExecutionException.class).hasMessageContaining("TSDB: the key does not exist"); + } + + @Test + void tsCreateOnDuplicateKeyFails() { + redis.tsCreate(MY_KEY); + + assertThatThrownBy(() -> redis.tsCreate(MY_KEY)).isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("TSDB: key already exists"); + } + + // ------------------------------------------------------------------------------------------------------------ + // TS.CREATERULE / TS.DELETERULE / TS.INFO rules + // ------------------------------------------------------------------------------------------------------------ + + @Test + void tsCreateRuleAndDeleteRule() { + redis.tsCreate(SOURCE_KEY); + redis.tsCreate(DEST_KEY); + + assertThat(redis.tsCreateRule(SOURCE_KEY, DEST_KEY, TsAggregationType.AVG, 60000)).isEqualTo("OK"); + + List> rules = redis.tsInfo(SOURCE_KEY).getRules(); + assertThat(rules).hasSize(1); + assertThat(rules.get(0).getDestKey()).isEqualTo(DEST_KEY); + assertThat(rules.get(0).getBucketDuration()).isEqualTo(60000L); + assertThat(rules.get(0).getAggregationType()).isEqualTo(TsAggregationType.AVG); + assertThat(redis.tsInfo(DEST_KEY).getSourceKey()).isEqualTo(SOURCE_KEY); + + assertThat(redis.tsDeleteRule(SOURCE_KEY, DEST_KEY)).isEqualTo("OK"); + assertThat(redis.tsInfo(SOURCE_KEY).getRules()).isEmpty(); + } + + @Test + void tsCreateRuleWithAlignTimestampAndMultipleRulesShrinksOnDelete() { + redis.tsCreate(SOURCE_KEY); + redis.tsCreate(DEST_KEY); + redis.tsCreate(DEST_KEY2); + + assertThat(redis.tsCreateRule(SOURCE_KEY, DEST_KEY, TsAggregationType.SUM, 60000)).isEqualTo("OK"); + assertThat(redis.tsCreateRule(SOURCE_KEY, DEST_KEY2, TsAggregationType.STD_P, 60000, 0)).isEqualTo("OK"); + + List> rules = redis.tsInfo(SOURCE_KEY).getRules(); + assertThat(rules).hasSize(2); + assertThat(rules).extracting(TsInfoValue.Rule::getAggregationType).containsExactlyInAnyOrder(TsAggregationType.SUM, + TsAggregationType.STD_P); + + assertThat(redis.tsDeleteRule(SOURCE_KEY, DEST_KEY)).isEqualTo("OK"); + assertThat(redis.tsInfo(SOURCE_KEY).getRules()).hasSize(1); + } + + /** + * {@link TsAggregationType#STD_P} encodes to the wire value {@code STD.P}, which cannot be represented as a plain Java enum + * constant name. A unit test cannot catch a wrong wire value here; only a real server round-trip can. + */ + @Test + void tsCreateRuleWithDottedAggregationType() { + redis.tsCreate(SOURCE_KEY); + redis.tsCreate(DEST_KEY); + + assertThat(redis.tsCreateRule(SOURCE_KEY, DEST_KEY, TsAggregationType.STD_P, 60000)).isEqualTo("OK"); + } + + // ------------------------------------------------------------------------------------------------------------ + // TS.ADD / TS.MADD / TS.INCRBY / TS.DECRBY / TS.GET round trips + // ------------------------------------------------------------------------------------------------------------ + + @Test + void tsAddAndGetRoundTrip() { + assertThat(redis.tsAdd(MY_KEY, 1000, 23.5)).isEqualTo(1000L); + assertSample(redis.tsGet(MY_KEY), 1000L, 23.5); + + assertThat(redis.tsAdd(MY_KEY, 2000, 24.5)).isEqualTo(2000L); + assertSample(redis.tsGet(MY_KEY), 2000L, 24.5); + + long before = System.currentTimeMillis(); + Long autoTimestamp = redis.tsAdd(MY_KEY, 30.0); + assertThat(autoTimestamp).isCloseTo(before, offset(60000L)); + assertSample(redis.tsGet(MY_KEY), autoTimestamp, 30.0); + + // TS.INCRBY/TS.DECRBY without an explicit timestamp stamp the sample with the server's current time, not the + // timestamp of the previous sample, so this is a fresh timestamp, not necessarily equal to autoTimestamp. + Long incrByTimestamp = redis.tsIncrBy(MY_KEY, 1.5); + assertThat(incrByTimestamp).isCloseTo(before, offset(60000L)); + assertSample(redis.tsGet(MY_KEY), incrByTimestamp, 31.5); + + Long decrByTimestamp = redis.tsDecrBy(MY_KEY, 0.5); + assertThat(decrByTimestamp).isCloseTo(before, offset(60000L)); + assertSample(redis.tsGet(MY_KEY), decrByTimestamp, 31.0); + } + + /** + * Unlike {@code TS.ADD}, {@code TS.MADD} does not auto-create missing series: the server rejects it with + * {@code TSDB: the key is not a TSDB key} if any of the target keys does not already exist. This contradicts the + * {@code tsMAdd} Javadoc's claim of implicit creation; see the discovered-bug notes in issues.md. + *

+ * The keys are hash-tagged so both route to the same cluster slot: {@code TS.MADD} sends a single command carrying multiple + * keys, which Redis Cluster rejects with {@code CROSSSLOT} unless they all hash to the same slot. + */ + @Test + void tsMAddAndGetRoundTrip() { + String key1 = "{ts-madd}:k1"; + String key2 = "{ts-madd}:k2"; + redis.tsCreate(key1); + redis.tsCreate(key2); + + List timestamps = redis.tsMAdd(mAddEntry(key1, 1000, 10.0), mAddEntry(key2, 1000, 20.0)); + + assertThat(timestamps).containsExactly(1000L, 1000L); + assertSample(redis.tsGet(key1), 1000L, 10.0); + assertSample(redis.tsGet(key2), 1000L, 20.0); + } + + @Test + void tsMAddOnMissingKeyFailsInsteadOfAutoCreating() { + assertThatThrownBy(() -> redis.tsMAdd(mAddEntry("series:does-not-exist", 1000, 1.0))) + .isInstanceOf(RedisCommandExecutionException.class).hasMessageContaining("TSDB: the key is not a TSDB key"); + } + + /** + * {@code DUPLICATE_POLICY} only applies when {@code TS.ADD} implicitly creates the series, but it then persists on the + * series like a {@code TS.CREATE}/{@code TS.ALTER} configured policy would. + */ + @Test + void tsAddDuplicatePolicyPersistsOnImplicitCreate() { + String newKey = "series:implicit-create"; + + redis.tsAdd(newKey, 1000, 1.0, TsAddArgs.Builder.duplicatePolicy(TsDuplicatePolicy.MAX)); + + assertThat(redis.tsInfo(newKey).getDuplicatePolicy()).isEqualToIgnoringCase("MAX"); + } + + /** + * {@code ON_DUPLICATE} is a one-shot override for a single {@code TS.ADD} call against an already-existing series: it never + * changes the policy persisted on the series (compared against whatever {@code TS.CREATE} left it at, which per + * {@link #tsCreateWithoutDuplicatePolicyReportsServerDefaultPolicy()} is the server's configured default, not + * {@code null}), but it does take effect for the sample it was passed with. + */ + @Test + void tsAddOnDuplicateIsOneShotOverrideNotPersistedButEffective() { + String key = "series:on-duplicate"; + redis.tsCreate(key); + String policyAfterCreate = redis.tsInfo(key).getDuplicatePolicy(); + + redis.tsAdd(key, 1000, 5.0, TsAddArgs.Builder.onDuplicate(TsDuplicatePolicy.MIN)); + assertThat(redis.tsInfo(key).getDuplicatePolicy()).isEqualTo(policyAfterCreate); + + redis.tsAdd(key, 1000, 3.0, TsAddArgs.Builder.onDuplicate(TsDuplicatePolicy.MIN)); + assertSample(redis.tsGet(key), 1000L, 3.0); + assertThat(redis.tsInfo(key).getDuplicatePolicy()).isEqualTo(policyAfterCreate); + + redis.tsAdd(key, 1000, 9.0, TsAddArgs.Builder.onDuplicate(TsDuplicatePolicy.MAX)); + assertSample(redis.tsGet(key), 1000L, 9.0); + assertThat(redis.tsInfo(key).getDuplicatePolicy()).isEqualTo(policyAfterCreate); + } + + // ------------------------------------------------------------------------------------------------------------ + // TS.DEL + // ------------------------------------------------------------------------------------------------------------ + + @Test + void tsDelRemovesSamplesInRange() { + redis.tsCreate(MY_KEY); + redis.tsAdd(MY_KEY, 100, 1.0); + redis.tsAdd(MY_KEY, 200, 2.0); + redis.tsAdd(MY_KEY, 300, 3.0); + + Long deleted = redis.tsDel(MY_KEY, 100, 200); + + assertThat(deleted).isEqualTo(2L); + assertThat(redis.tsInfo(MY_KEY).getTotalSamples()).isEqualTo(1L); + assertSample(redis.tsGet(MY_KEY), 300L, 3.0); + } + + @Test + void tsDelOnEmptySeriesReturnsZero() { + redis.tsCreate(MY_KEY); + + Long deleted = redis.tsDel(MY_KEY, 0, Long.MAX_VALUE); + + assertThat(deleted).isEqualTo(0L); + } + + // ------------------------------------------------------------------------------------------------------------ + // TS.GET on an empty series + // ------------------------------------------------------------------------------------------------------------ + + @Test + void tsGetOnEmptySeriesReturnsNull() { + redis.tsCreate(MY_KEY); + + assertThat(redis.tsGet(MY_KEY)).isNull(); + } + + // ------------------------------------------------------------------------------------------------------------ + // TS.MGET + // ------------------------------------------------------------------------------------------------------------ + + void prepareMGetFixture() { + redis.tsCreate("mget:us:temp", TsCreateArgs.Builder.label("region", "us").label("type", "temp")); + redis.tsAdd("mget:us:temp", 1000, 10.0); + + redis.tsCreate("mget:us:humid", TsCreateArgs.Builder.label("region", "us").label("type", "humid")); + redis.tsAdd("mget:us:humid", 1000, 20.0); + + redis.tsCreate("mget:eu:temp", TsCreateArgs.Builder.label("region", "eu").label("type", "temp")); + redis.tsAdd("mget:eu:temp", 1000, 30.0); + } + + @Test + void tsMGetWithoutLabelsOptionReturnsEmptyLabelMap() { + prepareMGetFixture(); + + List> result = redis.tsMGet("region=us"); + + assertThat(result).extracting(TsMGetValue::getKey).containsExactlyInAnyOrder("mget:us:temp", "mget:us:humid"); + assertThat(result).allSatisfy(value -> assertThat(value.getLabels()).isEmpty()); + } + + @Test + void tsMGetWithLabelsIncludesAllLabels() { + prepareMGetFixture(); + + List> result = redis.tsMGet(TsMGetArgs.Builder.withLabels(), "region=us"); + + TsMGetValue temp = result.stream().filter(v -> v.getKey().equals("mget:us:temp")).findFirst().get(); + assertThat(temp.getLabels()).containsExactly(new AbstractMap.SimpleEntry<>("region", "us"), + new AbstractMap.SimpleEntry<>("type", "temp")); + assertSample(temp.getSample(), 1000L, 10.0); + } + + @Test + void tsMGetWithSelectedLabelsIncludesOnlyRequestedLabels() { + prepareMGetFixture(); + + List> result = redis.tsMGet(TsMGetArgs.Builder.selectedLabels("type"), "region=us"); + + assertThat(result).allSatisfy(value -> assertThat(value.getLabels()).containsOnlyKeys("type")); + } + + @Test + void tsMGetWithNonMatchingFilterReturnsEmptyList() { + prepareMGetFixture(); + + List> result = redis.tsMGet("region=nonexistent"); + + assertThat(result).isEmpty(); + } + + /** + * Checks whether a series with no samples that still matches the label filter is included by {@code TS.MGET} (with a + * {@code null} sample slot) or excluded entirely. This was extrapolated, not independently confirmed, when the read-domain + * types were first built. + */ + @Test + void tsMGetIncludesSeriesWithNoSamples() { + prepareMGetFixture(); + redis.tsCreate("mget:us:empty", TsCreateArgs.Builder.label("region", "us")); + + List> result = redis.tsMGet("region=us"); + + assertThat(result).extracting(TsMGetValue::getKey).contains("mget:us:empty"); + TsMGetValue empty = result.stream().filter(v -> v.getKey().equals("mget:us:empty")).findFirst().get(); + assertThat(empty.getSample()).isNull(); + } + + @Test + void tsMGetWithoutEqualityFilterFails() { + prepareMGetFixture(); + + assertThatThrownBy(() -> redis.tsMGet("type!=temp")).isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("TSDB: please provide at least one matcher"); + } + + // ------------------------------------------------------------------------------------------------------------ + // TS.QUERYINDEX + // ------------------------------------------------------------------------------------------------------------ + + @Test + void tsQueryIndexReturnsMatchingKeys() { + prepareMGetFixture(); + + List result = redis.tsQueryIndex("region=us"); + + assertThat(result).containsExactlyInAnyOrder("mget:us:temp", "mget:us:humid"); + } + + @Test + void tsQueryIndexWithMultipleFiltersNarrowsResults() { + prepareMGetFixture(); + + List result = redis.tsQueryIndex("region=us", "type=temp"); + + assertThat(result).containsExactly("mget:us:temp"); + } + + @Test + void tsQueryIndexWithNonMatchingFilterReturnsEmptyList() { + prepareMGetFixture(); + + List result = redis.tsQueryIndex("region=nonexistent"); + + assertThat(result).isEmpty(); + } + + // ------------------------------------------------------------------------------------------------------------ + // Test helpers + // ------------------------------------------------------------------------------------------------------------ + + static void assertSample(TsSample sample, long expectedTimestamp, double expectedValue) { + assertThat(sample).isNotNull(); + assertThat(sample.getTimestamp()).isEqualTo(expectedTimestamp); + assertThat(sample.getValue()).isEqualTo(expectedValue); + } + + static Map.Entry mAddEntry(String key, long timestamp, double value) { + return new AbstractMap.SimpleEntry<>(key, new TsSample(timestamp, Collections.singletonList(value))); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesPolicyIntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesPolicyIntegrationTests.java new file mode 100644 index 0000000000..7f83aa1a43 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesPolicyIntegrationTests.java @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.util.List; + +import javax.inject.Inject; + +import io.lettuce.core.RedisCommandExecutionException; +import io.lettuce.core.api.sync.RedisCommands; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.core.timeseries.arguments.TsIncrByArgs; +import io.lettuce.test.LettuceExtension; +import io.lettuce.test.condition.EnabledOnCommand; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; + +import static io.lettuce.TestTags.INTEGRATION_TEST; +import static io.lettuce.core.timeseries.RedisTimeSeriesIntegrationTests.assertSample; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for the "practically common" edge cases of {@code DUPLICATE_POLICY}, {@code IGNORE}, + * {@code TS.INCRBY}/{@code TS.DECRBY} and compaction timing. + *

+ * These scenarios reproduce shapes real callers hit in production (retried writes, near-duplicate ingestion, and + * createrule-then-add ordering) that {@link RedisTimeSeriesIntegrationTests} does not cover: it only exercises + * {@code ON_DUPLICATE} as a one-shot override, never the create-time {@code DUPLICATE_POLICY} merge effect for every policy, + * and never {@code IGNORE}'s actual suppression behavior. + * + * @author Gyumin Hwang + * @since 7.7 + */ +@Tag(INTEGRATION_TEST) +@ExtendWith(LettuceExtension.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@EnabledOnCommand("TS.CREATE") +public class RedisTimeSeriesPolicyIntegrationTests { + + // hash-tagged so both keys route to the same cluster slot for TS.CREATERULE/TS.DELETERULE + private static final String SOURCE_KEY = "{ts-policy-rule}:raw"; + + private static final String DEST_KEY = "{ts-policy-rule}:hourly"; + + protected final RedisCommands redis; + + @Inject + protected RedisTimeSeriesPolicyIntegrationTests(RedisCommands redis) { + this.redis = redis; + } + + @BeforeEach + void prepare() { + redis.flushall(); + } + + @AfterAll + void teardown() { + redis.flushall(); + } + + // ------------------------------------------------------------------------------------------------------------ + // P2-1: DUPLICATE_POLICY, all six values, reinserting at the same timestamp + // ------------------------------------------------------------------------------------------------------------ + + /** + * Given a series created with {@code DUPLICATE_POLICY=BLOCK}, when a second sample is added at a timestamp that already has + * a sample, then the server rejects the write instead of silently keeping or replacing the existing value. + */ + @Test + void tsDuplicatePolicyBlockRejectsReinsertAtSameTimestamp() { + String key = "policy:block"; + redis.tsCreate(key, TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.BLOCK)); + redis.tsAdd(key, 1000, 5.0); + + assertThatThrownBy(() -> redis.tsAdd(key, 1000, 10.0)).isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("BLOCK mode"); + assertSample(redis.tsGet(key), 1000L, 5.0); + } + + /** + * Given a series created with {@code DUPLICATE_POLICY=FIRST}, when a second sample is added at an existing timestamp, then + * the original value is kept. Our existing suite never exercises {@code FIRST} at all. + */ + @Test + void tsDuplicatePolicyFirstKeepsExistingValueOnReinsert() { + String key = "policy:first"; + redis.tsCreate(key, TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.FIRST)); + redis.tsAdd(key, 1000, 5.0); + + redis.tsAdd(key, 1000, 10.0); + + assertSample(redis.tsGet(key), 1000L, 5.0); + } + + /** + * Given a series created with {@code DUPLICATE_POLICY=LAST}, when a second sample is added at an existing timestamp, then + * the new value overwrites the old one. + */ + @Test + void tsDuplicatePolicyLastOverwritesValueOnReinsert() { + String key = "policy:last"; + redis.tsCreate(key, TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.LAST)); + redis.tsAdd(key, 1000, 5.0); + + redis.tsAdd(key, 1000, 10.0); + + assertSample(redis.tsGet(key), 1000L, 10.0); + } + + /** + * Given a series created with {@code DUPLICATE_POLICY=MIN}, when a second, larger sample is added at an existing timestamp, + * then the smaller of the two values is kept. + */ + @Test + void tsDuplicatePolicyMinKeepsSmallerValueOnReinsert() { + String key = "policy:min"; + redis.tsCreate(key, TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.MIN)); + redis.tsAdd(key, 1000, 5.0); + + redis.tsAdd(key, 1000, 10.0); + + assertSample(redis.tsGet(key), 1000L, 5.0); + } + + /** + * Given a series created with {@code DUPLICATE_POLICY=MAX}, when a second, larger sample is added at an existing timestamp, + * then the larger of the two values is kept. + */ + @Test + void tsDuplicatePolicyMaxKeepsLargerValueOnReinsert() { + String key = "policy:max"; + redis.tsCreate(key, TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.MAX)); + redis.tsAdd(key, 1000, 5.0); + + redis.tsAdd(key, 1000, 10.0); + + assertSample(redis.tsGet(key), 1000L, 10.0); + } + + /** + * Given a series created with {@code DUPLICATE_POLICY=SUM}, when repeated samples are added at the same timestamp, then + * every reinsertion accumulates onto the stored value. This is not idempotent: retrying the exact same write (as a + * caller recovering from a timeout might) inflates the stored value every time, which is the realistic incident shape this + * test locks in. + */ + @Test + void tsDuplicatePolicySumAccumulatesAndIsNotIdempotentOnRetry() { + String key = "policy:sum"; + redis.tsCreate(key, TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.SUM)); + redis.tsAdd(key, 1000, 5.0); + + redis.tsAdd(key, 1000, 10.0); + assertSample(redis.tsGet(key), 1000L, 15.0); + + // A third write at the very same timestamp keeps accumulating instead of converging - a naive retry doubles the + // damage rather than being a safe no-op. + redis.tsAdd(key, 1000, 3.0); + assertSample(redis.tsGet(key), 1000L, 18.0); + } + + // ------------------------------------------------------------------------------------------------------------ + // P2-2: IGNORE, only active under DUPLICATE_POLICY=LAST, suppressed writes return the previous timestamp + // ------------------------------------------------------------------------------------------------------------ + + /** + * Given a series created with {@code DUPLICATE_POLICY=LAST} and {@code IGNORE 5 10.0}, when a sample arrives within both + * the time and value thresholds of the last stored sample, then the server silently discards it and reports the + * previous sample's timestamp back to the caller - not the timestamp that was just submitted. A caller checking only + * "did the call throw?" will believe the write succeeded and lose the sample. + *

+ * Both thresholds are an AND: violating either one (a large enough time gap, or a large enough value gap) still inserts. + */ + @Test + void tsIgnoreSuppressesNearDuplicateAndReturnsPreviousTimestamp() { + String key = "ignore:last-policy"; + redis.tsCreate(key, TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.LAST).ignore(5, 10.0)); + + assertThat(redis.tsAdd(key, 1000, 1.0)).isEqualTo(1000L); + // time gap 10 > 5: inserted normally. + assertThat(redis.tsAdd(key, 1010, 11.0)).isEqualTo(1010L); + // time gap 3 <= 5 AND value gap |10.0 - 11.0| = 1.0 <= 10.0: both thresholds satisfied, so this sample is ignored and + // the previous timestamp (1010) is echoed back instead of the new one (1013). + assertThat(redis.tsAdd(key, 1013, 10.0)).isEqualTo(1010L); + assertSample(redis.tsGet(key), 1010L, 11.0); + + // time gap 10 > 5: inserted normally even though close in value. + assertThat(redis.tsAdd(key, 1020, 11.5)).isEqualTo(1020L); + // time gap 1 <= 5 BUT value gap |22.0 - 11.5| = 10.5 > 10.0: one threshold violated is enough to force insertion. + assertThat(redis.tsAdd(key, 1021, 22.0)).isEqualTo(1021L); + assertSample(redis.tsGet(key), 1021L, 22.0); + } + + /** + * Given a series created with {@code DUPLICATE_POLICY=BLOCK} (not {@code LAST}) and {@code IGNORE 5 10.0}, when a sample + * arrives within both thresholds of the previous sample, then {@code IGNORE} does not activate at all: the sample is + * inserted as a normal, distinct data point. {@code IGNORE} is not a general near-duplicate filter; it is a behavior + * specific to {@code DUPLICATE_POLICY=LAST}. + */ + @Test + void tsIgnoreDoesNotActivateUnlessDuplicatePolicyIsLast() { + String key = "ignore:block-policy"; + redis.tsCreate(key, TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.BLOCK).ignore(5, 10.0)); + redis.tsAdd(key, 1000, 1.0); + + // Within both IGNORE thresholds of the previous sample, but the policy is BLOCK, so IGNORE never engages and this is + // inserted as an ordinary new sample rather than being silently dropped. + Long timestamp = redis.tsAdd(key, 1002, 1.5); + + assertThat(timestamp).isEqualTo(1002L); + assertThat(redis.tsInfo(key).getTotalSamples()).isEqualTo(2L); + assertSample(redis.tsGet(key), 1002L, 1.5); + } + + // ------------------------------------------------------------------------------------------------------------ + // P2-3: TS.INCRBY/TS.DECRBY reject past timestamps on an existing series, unlike TS.ADD's upsert semantics + // ------------------------------------------------------------------------------------------------------------ + + /** + * Given an existing series, when {@code TS.INCRBY} is called with a timestamp older than the series' current maximum + * timestamp, then the server rejects the call. {@code TS.ADD} allows exactly this (an upsert into the past); treating + * {@code INCRBY}/{@code DECRBY} as "the same write family" as {@code TS.ADD} is the trap this test guards against. + */ + @Test + void tsIncrByRejectsTimestampOlderThanExistingMaximumOnExistingSeries() { + String key = "incrby:existing-series"; + redis.tsCreate(key); + redis.tsIncrBy(key, 1.0, TsIncrByArgs.Builder.timestamp(1000)); + + assertThatThrownBy(() -> redis.tsIncrBy(key, 1.0, TsIncrByArgs.Builder.timestamp(500))) + .isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("must be equal to or higher than the maximum existing timestamp"); + assertSample(redis.tsGet(key), 1000L, 1.0); + } + + /** + * Given an existing series, when {@code TS.DECRBY} is called with a timestamp older than the series' current maximum + * timestamp, then the server rejects it exactly like {@code TS.INCRBY} does; both commands share the same handler. + */ + @Test + void tsDecrByRejectsTimestampOlderThanExistingMaximumOnExistingSeries() { + String key = "decrby:existing-series"; + redis.tsCreate(key); + redis.tsDecrBy(key, 1.0, TsIncrByArgs.Builder.timestamp(1000)); + + assertThatThrownBy(() -> redis.tsDecrBy(key, 1.0, TsIncrByArgs.Builder.timestamp(500))) + .isInstanceOf(RedisCommandExecutionException.class) + .hasMessageContaining("must be equal to or higher than the maximum existing timestamp"); + assertSample(redis.tsGet(key), 1000L, -1.0); + } + + // ------------------------------------------------------------------------------------------------------------ + // P2-4: compaction only reflects closed buckets; a backfilled sample never re-triggers compaction + // ------------------------------------------------------------------------------------------------------------ + + /** + * Given a source series with a compaction rule to a destination series, when a sample lands inside the still-open bucket, + * then the destination is not updated until a later sample closes that bucket by crossing its boundary. Once the bucket + * closes, a further backfilled sample landing in an earlier, already-closed bucket does not retroactively update the + * destination: compaction is only ever driven forward by new writes, never re-evaluated for the past. + */ + @Test + void tsCreateRuleReflectsOnlyClosedBucketsAndIgnoresBackfill() { + redis.tsCreate(SOURCE_KEY); + redis.tsCreate(DEST_KEY); + redis.tsCreateRule(SOURCE_KEY, DEST_KEY, TsAggregationType.AVG, 1000); + + // Both timestamps fall in different 1000ms buckets (5000-5999 and 6500-7499), so writing the second one closes the + // first bucket and its average (10) is flushed to the destination. + redis.tsAdd(SOURCE_KEY, 5000, 10); + redis.tsAdd(SOURCE_KEY, 6500, 20); + assertSample(redis.tsGet(DEST_KEY), 5000L, 10.0); + + // A backfilled sample landing in a bucket that is already far in the past does not retrigger compaction: the + // destination still reflects only the bucket that was actually closed above. + redis.tsAdd(SOURCE_KEY, 3000, 99); + assertSample(redis.tsGet(DEST_KEY), 5000L, 10.0); + } + + // ------------------------------------------------------------------------------------------------------------ + // P2-6: TS.QUERYINDEX filter syntax, all six forms, including the two that read backwards + // ------------------------------------------------------------------------------------------------------------ + + void prepareFilterSyntaxFixture() { + redis.tsCreate("filter:us-temp", TsCreateArgs.Builder.label("region", "us").label("type", "temp")); + redis.tsCreate("filter:eu-temp", TsCreateArgs.Builder.label("region", "eu").label("type", "temp")); + redis.tsCreate("filter:humid-only", TsCreateArgs.Builder.label("type", "humid")); + } + + /** + * Given three series with different label shapes, when each of the six {@code TS.QUERYINDEX} filter grammar forms is used, + * then every form matches exactly what the RedisTimeSeries filter grammar defines - including the two forms whose meaning + * reads backwards from the equals/not-equals operator: {@code label=} means the label is absent, and {@code label!=} + * means the label is present. Every query still includes at least one {@code EQ}/{@code LIST_MATCH} filter, since + * the server requires one. + */ + @Test + void tsQueryIndexSupportsAllSixFilterSyntaxForms() { + prepareFilterSyntaxFixture(); + + // l=v (EQ): matches series whose label equals the given value. + List eq = redis.tsQueryIndex("region=us"); + assertThat(eq).containsExactly("filter:us-temp"); + + // l= (label absent): counter-intuitively means the label does not exist on the series at all, not "equals empty + // string". + List labelAbsent = redis.tsQueryIndex("type=humid", "region="); + assertThat(labelAbsent).containsExactly("filter:humid-only"); + + // l!=v (NEQ): matches series where the label exists and differs from the given value. + List neq = redis.tsQueryIndex("type=temp", "region!=us"); + assertThat(neq).containsExactly("filter:eu-temp"); + + // l!= (label present): counter-intuitively means the label exists on the series, not "not equal to empty string". + List labelPresent = redis.tsQueryIndex("type=temp", "region!="); + assertThat(labelPresent).containsExactlyInAnyOrder("filter:us-temp", "filter:eu-temp"); + + // l=(v1,v2) (LIST_MATCH): matches series whose label equals any value in the list. + List listMatch = redis.tsQueryIndex("region=(us,eu)"); + assertThat(listMatch).containsExactlyInAnyOrder("filter:us-temp", "filter:eu-temp"); + + // l!=(v1,v2) (LIST_NOTMATCH): excludes series whose label equals any value in the list. + List listNotMatch = redis.tsQueryIndex("type=temp", "region!=(us,eu)"); + assertThat(listNotMatch).isEmpty(); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesReactiveIntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesReactiveIntegrationTests.java new file mode 100644 index 0000000000..1dc387d7f9 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesReactiveIntegrationTests.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import javax.inject.Inject; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.reactive.RedisReactiveCommands; +import io.lettuce.core.timeseries.arguments.TsAlterArgs; +import io.lettuce.core.timeseries.arguments.TsCreateArgs; +import io.lettuce.test.ReactiveSyncInvocationHandler; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static io.lettuce.TestTags.INTEGRATION_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Reactive integration tests for Redis TimeSeries commands. Re-runs all tests from {@link RedisTimeSeriesIntegrationTests} + * routing every call through the reactive API via {@link ReactiveSyncInvocationHandler}. + * + *

+ * Overrides verify the two scenarios that a wrong wire encoding could break in a way a unit test cannot catch: the + * {@code LABELS} keyword being emitted last, and {@link TsAggregationType#STD_P} encoding to {@code STD.P}. Both are exercised + * here directly against {@link RedisReactiveCommands} using {@link StepVerifier}. + * + * @author Gyumin Hwang + * @since 7.7 + */ +@Tag(INTEGRATION_TEST) +public class RedisTimeSeriesReactiveIntegrationTests extends RedisTimeSeriesIntegrationTests { + + private static final String MY_KEY = "temperature:sensor1"; + + private static final String SOURCE_KEY = "{ts-rule}:raw"; + + private static final String DEST_KEY = "{ts-rule}:hourly"; + + private final RedisReactiveCommands reactive; + + @Inject + public RedisTimeSeriesReactiveIntegrationTests(StatefulRedisConnection connection) { + super(ReactiveSyncInvocationHandler.sync(connection)); + this.reactive = connection.reactive(); + } + + @Test + @Override + void tsCreate() { + StepVerifier.create(reactive.tsCreate(MY_KEY)).expectNext("OK").verifyComplete(); + StepVerifier.create(reactive.tsAlter(MY_KEY, TsAlterArgs.Builder.retention(1000))).expectNext("OK").verifyComplete(); + } + + @Test + @Override + void tsCreateWithIgnoreAfterLabelsOnBuilderStillSucceeds() { + Map labels = new LinkedHashMap<>(); + labels.put("sensor", "1"); + TsCreateArgs args = TsCreateArgs.Builder.labels(labels).ignore(100, 0.1); + + StepVerifier.create(reactive.tsCreate(MY_KEY, args)).expectNext("OK").verifyComplete(); + + assertThat(redis.tsInfo(MY_KEY).getLabels()).containsExactlyInAnyOrderEntriesOf(labels); + } + + @Test + @Override + void tsCreateRuleWithDottedAggregationType() { + StepVerifier.create(reactive.tsCreate(SOURCE_KEY)).expectNext("OK").verifyComplete(); + StepVerifier.create(reactive.tsCreate(DEST_KEY)).expectNext("OK").verifyComplete(); + + StepVerifier.create(reactive.tsCreateRule(SOURCE_KEY, DEST_KEY, TsAggregationType.STD_P, 60000)).expectNext("OK") + .verifyComplete(); + } + + @Test + @Override + void tsDelRemovesSamplesInRange() { + StepVerifier.create(reactive.tsCreate(MY_KEY)).expectNext("OK").verifyComplete(); + redis.tsAdd(MY_KEY, 100, 1.0); + redis.tsAdd(MY_KEY, 200, 2.0); + redis.tsAdd(MY_KEY, 300, 3.0); + + StepVerifier.create(reactive.tsDel(MY_KEY, 100, 200)).expectNext(2L).verifyComplete(); + assertThat(redis.tsInfo(MY_KEY).getTotalSamples()).isEqualTo(1L); + assertSample(redis.tsGet(MY_KEY), 300L, 3.0); + } + + /** + * Overridden with a bounded {@link StepVerifier} timeout to document, rather than hang on, a pre-existing bug in + * {@code io.lettuce.core.RedisPublisher.SubscriptionCommand#doOnComplete()}: it calls {@code getOutput().get()} + * before checking {@code getOutput().hasError()}. For any {@code EncodedComplexOutput}-backed command whose + * {@code ComplexDataParser} throws on a {@code null} {@link io.lettuce.core.output.ComplexData} (every parser in this + * codebase does, including the already-shipped {@code CfInfoValueParser}), that throw happens inside {@code doOnComplete()} + * itself and is never converted into {@code onError}, so the + * {@link reactor.core.publisher.Flux}/{@link reactor.core.publisher.Mono} never terminates on a server error reply. + * Confirmed independently against {@code + * reactive().cfInfo("does-not-exist")}, which exhibits the identical hang, so this is not specific to {@code TS.MGET} or to + * this PR. Fixed in {@code redis/lettuce} PR #3851, which reorders {@code doOnComplete()} to check {@code hasError()} + * before {@code get()}; this test is disabled until that PR merges. + */ + @Test + @Disabled("Blocked by RedisPublisher reactive error-handling bug; fixed in redis/lettuce#3851. " + + "Re-enable once that fix is merged.") + @Override + void tsMGetWithoutEqualityFilterFails() { + prepareMGetFixture(); + + StepVerifier.create(reactive.tsMGet("type!=temp")).expectErrorMessage("ERR TSDB: please provide at least one matcher") + .verify(Duration.ofSeconds(5)); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesResp2IntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesResp2IntegrationTests.java new file mode 100644 index 0000000000..341e739099 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesResp2IntegrationTests.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.timeseries; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.RedisClient; +import io.lettuce.core.api.sync.RedisCommands; +import io.lettuce.core.protocol.ProtocolVersion; +import org.junit.jupiter.api.Tag; + +import javax.inject.Inject; + +import static io.lettuce.TestTags.INTEGRATION_TEST; + +/** + * RESP2 integration tests for Redis TimeSeries commands. Re-runs all tests from {@link RedisTimeSeriesIntegrationTests} using + * RESP2. + * + * @author Gyumin Hwang + * @since 7.7 + */ +@Tag(INTEGRATION_TEST) +public class RedisTimeSeriesResp2IntegrationTests extends RedisTimeSeriesIntegrationTests { + + @Inject + RedisTimeSeriesResp2IntegrationTests(RedisClient client) { + super(connectWithResp2(client)); + } + + private static RedisCommands connectWithResp2(RedisClient client) { + client.setOptions(ClientOptions.builder().protocolVersion(ProtocolVersion.RESP2).build()); + return client.connect().sync(); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/TsAggregationTypeUnitTests.java b/src/test/java/io/lettuce/core/timeseries/TsAggregationTypeUnitTests.java new file mode 100644 index 0000000000..5651bc8c73 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/TsAggregationTypeUnitTests.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link TsAggregationType}. + *

+ * PLAN: + *

    + *
  • Given a plain aggregator (e.g. {@code AVG}), when {@link TsAggregationType#getBytes()}/{@code toString()} are read, then + * the wire value equals the enum constant name.
  • + *
  • Given a dotted aggregator (e.g. {@code STD_P}), when read, then the wire value is the dotted form ({@code STD.P}), not + * the Java identifier.
  • + *
  • All 15 values from the design contract must be present.
  • + *
+ */ +@Tag(UNIT_TEST) +class TsAggregationTypeUnitTests { + + @Test + void shouldExposeFifteenValues() { + assertThat(TsAggregationType.values()).hasSize(15); + } + + @Test + void shouldRenderPlainNameAsWireValue() { + assertThat(TsAggregationType.AVG.toString()).isEqualTo("AVG"); + assertThat(new String(TsAggregationType.AVG.getBytes(), StandardCharsets.US_ASCII)).isEqualTo("AVG"); + } + + @Test + void shouldRenderDottedWireValueForStdP() { + assertThat(TsAggregationType.STD_P.toString()).isEqualTo("STD.P"); + assertThat(new String(TsAggregationType.STD_P.getBytes(), StandardCharsets.US_ASCII)).isEqualTo("STD.P"); + } + + @Test + void shouldRenderDottedWireValueForStdS() { + assertThat(TsAggregationType.STD_S.toString()).isEqualTo("STD.S"); + } + + @Test + void shouldRenderDottedWireValueForVarP() { + assertThat(TsAggregationType.VAR_P.toString()).isEqualTo("VAR.P"); + } + + @Test + void shouldRenderDottedWireValueForVarS() { + assertThat(TsAggregationType.VAR_S.toString()).isEqualTo("VAR.S"); + } + + @Test + void shouldRenderRemainingPlainValues() { + assertThat(TsAggregationType.SUM.toString()).isEqualTo("SUM"); + assertThat(TsAggregationType.MIN.toString()).isEqualTo("MIN"); + assertThat(TsAggregationType.MAX.toString()).isEqualTo("MAX"); + assertThat(TsAggregationType.RANGE.toString()).isEqualTo("RANGE"); + assertThat(TsAggregationType.COUNT.toString()).isEqualTo("COUNT"); + assertThat(TsAggregationType.FIRST.toString()).isEqualTo("FIRST"); + assertThat(TsAggregationType.LAST.toString()).isEqualTo("LAST"); + assertThat(TsAggregationType.TWA.toString()).isEqualTo("TWA"); + assertThat(TsAggregationType.COUNTNAN.toString()).isEqualTo("COUNTNAN"); + assertThat(TsAggregationType.COUNTALL.toString()).isEqualTo("COUNTALL"); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/TsDuplicatePolicyUnitTests.java b/src/test/java/io/lettuce/core/timeseries/TsDuplicatePolicyUnitTests.java new file mode 100644 index 0000000000..4e71279355 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/TsDuplicatePolicyUnitTests.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link TsDuplicatePolicy}. + *

+ * PLAN: Given each of the 6 documented policies, when {@code toString()} is read, then the wire value equals the enum constant + * name (no dotted/aliased values here, unlike {@link TsAggregationType}). + */ +@Tag(UNIT_TEST) +class TsDuplicatePolicyUnitTests { + + @Test + void shouldExposeSixValues() { + assertThat(TsDuplicatePolicy.values()).hasSize(6); + } + + @Test + void shouldRenderWireValues() { + assertThat(TsDuplicatePolicy.BLOCK.toString()).isEqualTo("BLOCK"); + assertThat(TsDuplicatePolicy.FIRST.toString()).isEqualTo("FIRST"); + assertThat(TsDuplicatePolicy.LAST.toString()).isEqualTo("LAST"); + assertThat(TsDuplicatePolicy.MIN.toString()).isEqualTo("MIN"); + assertThat(TsDuplicatePolicy.MAX.toString()).isEqualTo("MAX"); + assertThat(TsDuplicatePolicy.SUM.toString()).isEqualTo("SUM"); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/TsEncodingFormatUnitTests.java b/src/test/java/io/lettuce/core/timeseries/TsEncodingFormatUnitTests.java new file mode 100644 index 0000000000..bc3e3e9c48 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/TsEncodingFormatUnitTests.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link TsEncodingFormat}. + *

+ * PLAN: Given each of the 2 documented encodings, when {@code toString()} is read, then the wire value equals the enum constant + * name. + */ +@Tag(UNIT_TEST) +class TsEncodingFormatUnitTests { + + @Test + void shouldExposeTwoValues() { + assertThat(TsEncodingFormat.values()).hasSize(2); + } + + @Test + void shouldRenderWireValues() { + assertThat(TsEncodingFormat.COMPRESSED.toString()).isEqualTo("COMPRESSED"); + assertThat(TsEncodingFormat.UNCOMPRESSED.toString()).isEqualTo("UNCOMPRESSED"); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/TsInfoValueParserUnitTests.java b/src/test/java/io/lettuce/core/timeseries/TsInfoValueParserUnitTests.java new file mode 100644 index 0000000000..133cd4942c --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/TsInfoValueParserUnitTests.java @@ -0,0 +1,390 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.output.ComplexData; + +/** + * Unit tests for {@link TsInfoValueParser}. + * + * @author Gyumin Hwang + * @since 7.7 + */ +class TsInfoValueParserUnitTests { + + private final TsInfoValueParser parser = new TsInfoValueParser<>(StringCodec.UTF8); + + // --------------------------------------------------------------------------- + // Test data builders + // --------------------------------------------------------------------------- + + private static ByteBuffer buf(String s) { + return ByteBuffer.wrap(s.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Simulates a RESP2 flat key-value array: {@code isList()==true}, but {@code getDynamicMap()} is still available via the + * odd/even heuristic (matching {@code ArrayComplexData}). + */ + private static ComplexData flatMapData(Object... pairs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < pairs.length - 1; i += 2) { + map.put(pairs[i], pairs[i + 1]); + } + List list = Arrays.asList(pairs); + return new ComplexData() { + + @Override + public void storeObject(Object value) { + } + + @Override + public List getDynamicList() { + return list; + } + + @Override + public Map getDynamicMap() { + return map; + } + + @Override + public boolean isList() { + return true; + } + + }; + } + + /** + * Simulates a RESP3 native map: {@code isMap()==true}. + */ + private static ComplexData mapData(Object... pairs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < pairs.length - 1; i += 2) { + map.put(pairs[i], pairs[i + 1]); + } + return new ComplexData() { + + @Override + public void storeObject(Object value) { + } + + @Override + public Map getDynamicMap() { + return map; + } + + @Override + public boolean isMap() { + return true; + } + + }; + } + + /** + * Simulates a nested array (used for RESP2 rule tuples and RESP3 rule value tuples): {@code isList()==true}. + */ + private static ComplexData listData(Object... items) { + List list = Arrays.asList(items); + return new ComplexData() { + + @Override + public void storeObject(Object value) { + } + + @Override + public List getDynamicList() { + return list; + } + + @Override + public boolean isList() { + return true; + } + + }; + } + + // --------------------------------------------------------------------------- + // Given: null data, When: parse, Then: reject + // --------------------------------------------------------------------------- + + @Test + void parseNullThrows() { + assertThatThrownBy(() -> parser.parse(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TS.INFO"); + } + + // --------------------------------------------------------------------------- + // Given: RESP2 flat array, When: parse, Then: same TsInfoValue as RESP3 map + // --------------------------------------------------------------------------- + + @Test + void parsesResp2FlatArray() { + ComplexData data = flatMapData(buf("totalSamples"), 100L, buf("memoryUsage"), 4096L, buf("firstTimestamp"), 1000L, + buf("lastTimestamp"), 2000L, buf("retentionTime"), 60000L, buf("chunkCount"), 2L, buf("chunkSize"), 4096L, + buf("chunkType"), buf("compressed"), buf("duplicatePolicy"), buf("last"), buf("labels"), + listData(listData(buf("region"), buf("us"))), buf("sourceKey"), buf("src-key"), buf("rules"), listData(), + buf("ignoreMaxTimeDiff"), 10L, buf("ignoreMaxValDiff"), buf("0.5")); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getTotalSamples()).isEqualTo(100L); + assertThat(value.getMemoryUsage()).isEqualTo(4096L); + assertThat(value.getFirstTimestamp()).isEqualTo(1000L); + assertThat(value.getLastTimestamp()).isEqualTo(2000L); + assertThat(value.getRetentionTime()).isEqualTo(60000L); + assertThat(value.getChunkCount()).isEqualTo(2L); + assertThat(value.getChunkSize()).isEqualTo(4096L); + assertThat(value.getChunkType()).isEqualTo("compressed"); + assertThat(value.getDuplicatePolicy()).isEqualTo("last"); + assertThat(value.getLabels()).containsEntry("region", "us"); + assertThat(value.getSourceKey()).isEqualTo("src-key"); + assertThat(value.getRules()).isEmpty(); + assertThat(value.getIgnoreMaxTimeDiff()).isEqualTo(10L); + assertThat(value.getIgnoreMaxValDiff()).isEqualTo(0.5); + } + + @Test + void parsesResp3NativeMap() { + ComplexData data = mapData(buf("totalSamples"), 100L, buf("memoryUsage"), 4096L, buf("firstTimestamp"), 1000L, + buf("lastTimestamp"), 2000L, buf("retentionTime"), 60000L, buf("chunkCount"), 2L, buf("chunkSize"), 4096L, + buf("chunkType"), buf("compressed"), buf("duplicatePolicy"), buf("last"), buf("labels"), + mapData(buf("region"), buf("us")), buf("sourceKey"), buf("src-key"), buf("rules"), mapData(), + buf("ignoreMaxTimeDiff"), 10L, buf("ignoreMaxValDiff"), 0.5d); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getTotalSamples()).isEqualTo(100L); + assertThat(value.getChunkType()).isEqualTo("compressed"); + assertThat(value.getDuplicatePolicy()).isEqualTo("last"); + assertThat(value.getLabels()).containsEntry("region", "us"); + assertThat(value.getSourceKey()).isEqualTo("src-key"); + assertThat(value.getRules()).isEmpty(); + assertThat(value.getIgnoreMaxValDiff()).isEqualTo(0.5); + } + + // --------------------------------------------------------------------------- + // Given: duplicatePolicy DP_NONE (server sends real null), Then: getDuplicatePolicy() is null + // --------------------------------------------------------------------------- + + @Test + void duplicatePolicyNullWhenDpNone() { + ComplexData data = flatMapData(buf("duplicatePolicy"), null); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getDuplicatePolicy()).isNull(); + } + + // --------------------------------------------------------------------------- + // Given: sourceKey absent (series is not a compaction destination), Then: getSourceKey() is null + // --------------------------------------------------------------------------- + + @Test + void sourceKeyNullWhenNotACompactionDestination() { + ComplexData data = flatMapData(buf("sourceKey"), null); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getSourceKey()).isNull(); + } + + // --------------------------------------------------------------------------- + // labels: RESP2 nested array of [key, value] pairs vs RESP3 native map + // --------------------------------------------------------------------------- + + @Test + void labelsSingleResp2NestedPair() { + ComplexData labels = listData(listData(buf("region"), buf("us"))); + ComplexData data = flatMapData(buf("labels"), labels); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getLabels()).containsExactly(entry("region", "us")); + } + + @Test + void labelsMultipleResp2NestedPairs() { + ComplexData labels = listData(listData(buf("region"), buf("us")), listData(buf("type"), buf("temp"))); + ComplexData data = flatMapData(buf("labels"), labels); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getLabels()).containsExactly(entry("region", "us"), entry("type", "temp")); + } + + @Test + void labelsResp3NativeMap() { + ComplexData labels = mapData(buf("region"), buf("us"), buf("type"), buf("temp")); + ComplexData data = flatMapData(buf("labels"), labels); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getLabels()).containsExactly(entry("region", "us"), entry("type", "temp")); + } + + @Test + void labelsEmptyResp2() { + ComplexData labels = listData(); + ComplexData data = flatMapData(buf("labels"), labels); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getLabels()).isEmpty(); + } + + @Test + void labelsEmptyResp3() { + ComplexData labels = mapData(); + ComplexData data = flatMapData(buf("labels"), labels); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getLabels()).isEmpty(); + } + + @Test + void labelsAbsentYieldsEmptyMap() { + ComplexData data = flatMapData(); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getLabels()).isEmpty(); + } + + // --------------------------------------------------------------------------- + // rules: empty (no compaction rules) on both protocols + // --------------------------------------------------------------------------- + + @Test + void rulesEmptyResp2() { + ComplexData data = flatMapData(buf("rules"), listData()); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getRules()).isNotNull().isEmpty(); + } + + @Test + void rulesEmptyResp3() { + ComplexData data = flatMapData(buf("rules"), mapData()); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getRules()).isNotNull().isEmpty(); + } + + // --------------------------------------------------------------------------- + // rules: present, RESP2 4-element tuple [destKey, bucketDuration, aggType, timestampAlignment] + // --------------------------------------------------------------------------- + + @Test + void rulesPresentResp2FourElementTuple() { + ComplexData rules = listData(listData(buf("dest-key"), 60000L, buf("avg"), 0L)); + ComplexData data = flatMapData(buf("rules"), rules); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getRules()).hasSize(1); + TsInfoValue.Rule rule = value.getRules().get(0); + assertThat(rule.getDestKey()).isEqualTo("dest-key"); + assertThat(rule.getBucketDuration()).isEqualTo(60000L); + assertThat(rule.getAggregationType()).isEqualTo(TsAggregationType.AVG); + assertThat(rule.getTimestampAlignment()).isEqualTo(0L); + } + + // --------------------------------------------------------------------------- + // rules: present, RESP3 map with 3-element value tuple [bucketDuration, aggType, timestampAlignment] + // --------------------------------------------------------------------------- + + @Test + void rulesPresentResp3ThreeElementTuple() { + ComplexData rules = mapData(buf("dest-key"), listData(60000L, buf("std.p"), 5L)); + ComplexData data = flatMapData(buf("rules"), rules); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getRules()).hasSize(1); + TsInfoValue.Rule rule = value.getRules().get(0); + assertThat(rule.getDestKey()).isEqualTo("dest-key"); + assertThat(rule.getBucketDuration()).isEqualTo(60000L); + assertThat(rule.getAggregationType()).isEqualTo(TsAggregationType.STD_P); + assertThat(rule.getTimestampAlignment()).isEqualTo(5L); + } + + // --------------------------------------------------------------------------- + // ignoreMaxValDiff: RESP2 (bulk/simple string) vs RESP3 (native double) + // --------------------------------------------------------------------------- + + @Test + void ignoreMaxValDiffAsString() { + ComplexData data = flatMapData(buf("ignoreMaxValDiff"), buf("1.5")); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getIgnoreMaxValDiff()).isEqualTo(1.5); + } + + @Test + void ignoreMaxValDiffAsDouble() { + ComplexData data = flatMapData(buf("ignoreMaxValDiff"), 1.5d); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getIgnoreMaxValDiff()).isEqualTo(1.5); + } + + // --------------------------------------------------------------------------- + // DEBUG fields: keySelfName and Chunks + // --------------------------------------------------------------------------- + + @Test + void debugFieldsAbsentByDefault() { + ComplexData data = flatMapData(buf("totalSamples"), 1L); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getKeySelfName()).isNull(); + assertThat(value.getChunks()).isNull(); + } + + @Test + void debugFieldsParsedWhenPresent() { + ComplexData chunks = listData(flatMapData(buf("startTimestamp"), 0L, buf("endTimestamp"), 1000L, buf("samples"), 10L, + buf("size"), 256L, buf("bytesPerSample"), buf("25.6"))); + ComplexData data = flatMapData(buf("keySelfName"), buf("self-key"), buf("Chunks"), chunks); + + TsInfoValue value = parser.parse(data); + + assertThat(value.getKeySelfName()).isEqualTo("self-key"); + assertThat(value.getChunks()).hasSize(1); + TsInfoValue.Chunk chunk = value.getChunks().get(0); + assertThat(chunk.getStartTimestamp()).isEqualTo(0L); + assertThat(chunk.getEndTimestamp()).isEqualTo(1000L); + assertThat(chunk.getSamples()).isEqualTo(10L); + assertThat(chunk.getSize()).isEqualTo(256L); + assertThat(chunk.getBytesPerSample()).isEqualTo(25.6); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/TsMGetValueParserUnitTests.java b/src/test/java/io/lettuce/core/timeseries/TsMGetValueParserUnitTests.java new file mode 100644 index 0000000000..4a10c89d7b --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/TsMGetValueParserUnitTests.java @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.output.ComplexData; + +/** + * Unit tests for {@link TsMGetValueParser}. + * + * @author Gyumin Hwang + * @since 7.7 + */ +class TsMGetValueParserUnitTests { + + private final TsMGetValueParser parser = new TsMGetValueParser<>(StringCodec.UTF8); + + // --------------------------------------------------------------------------- + // Test data builders + // --------------------------------------------------------------------------- + + private static ByteBuffer buf(String s) { + return ByteBuffer.wrap(s.getBytes(StandardCharsets.UTF_8)); + } + + private static ComplexData listData(Object... items) { + List list = Arrays.asList(items); + return new ComplexData() { + + @Override + public void storeObject(Object value) { + } + + @Override + public List getDynamicList() { + return list; + } + + @Override + public boolean isList() { + return true; + } + + }; + } + + private static ComplexData mapData(Object... pairs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < pairs.length - 1; i += 2) { + map.put(pairs[i], pairs[i + 1]); + } + return new ComplexData() { + + @Override + public void storeObject(Object value) { + } + + @Override + public Map getDynamicMap() { + return map; + } + + @Override + public boolean isMap() { + return true; + } + + }; + } + + // --------------------------------------------------------------------------- + // Given: null data, When: parse, Then: reject + // --------------------------------------------------------------------------- + + @Test + void parseNullThrows() { + assertThatThrownBy(() -> parser.parse(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TS.MGET"); + } + + // --------------------------------------------------------------------------- + // Given: RESP2 array-of-triples [key, labels, sample], When: parse, Then: same result as RESP3 map + // --------------------------------------------------------------------------- + + @Test + void parsesResp2ArrayOfTriples() { + ComplexData entry = listData(buf("key1"), listData(listData(buf("region"), buf("us"))), listData(1000L, buf("1.5"))); + ComplexData data = listData(entry); + + List> result = parser.parse(data); + + assertThat(result).hasSize(1); + TsMGetValue value = result.get(0); + assertThat(value.getKey()).isEqualTo("key1"); + assertThat(value.getLabels()).containsEntry("region", "us"); + assertThat(value.getSample()).isNotNull(); + assertThat(value.getSample().getTimestamp()).isEqualTo(1000L); + assertThat(value.getSample().getValue()).isEqualTo(1.5); + } + + // --------------------------------------------------------------------------- + // labels: RESP2 nested array of [key, value] pairs vs RESP3 native map + // --------------------------------------------------------------------------- + + @Test + void labelsSingleResp2NestedPair() { + ComplexData entry = listData(buf("key1"), listData(listData(buf("region"), buf("us"))), listData()); + ComplexData data = listData(entry); + + List> result = parser.parse(data); + + assertThat(result.get(0).getLabels()).containsExactly(entry("region", "us")); + } + + @Test + void labelsMultipleResp2NestedPairs() { + ComplexData entry = listData(buf("key1"), + listData(listData(buf("region"), buf("us")), listData(buf("type"), buf("temp"))), listData()); + ComplexData data = listData(entry); + + List> result = parser.parse(data); + + assertThat(result.get(0).getLabels()).containsExactly(entry("region", "us"), entry("type", "temp")); + } + + @Test + void labelsResp3NativeMap() { + ComplexData value = listData(mapData(buf("region"), buf("us"), buf("type"), buf("temp")), listData()); + ComplexData data = mapData(buf("key1"), value); + + List> result = parser.parse(data); + + assertThat(result.get(0).getLabels()).containsExactly(entry("region", "us"), entry("type", "temp")); + } + + // --------------------------------------------------------------------------- + // Given: RESP3 map key -> [labels, sample], When: parse, Then: same result as RESP2 + // --------------------------------------------------------------------------- + + @Test + void parsesResp3Map() { + ComplexData value = listData(mapData(buf("region"), buf("us")), listData(1000L, 1.5d)); + ComplexData data = mapData(buf("key1"), value); + + List> result = parser.parse(data); + + assertThat(result).hasSize(1); + TsMGetValue mGetValue = result.get(0); + assertThat(mGetValue.getKey()).isEqualTo("key1"); + assertThat(mGetValue.getLabels()).containsEntry("region", "us"); + assertThat(mGetValue.getSample().getTimestamp()).isEqualTo(1000L); + assertThat(mGetValue.getSample().getValue()).isEqualTo(1.5); + } + + // --------------------------------------------------------------------------- + // Given: empty top-level container, Then: empty result list (no matching keys) + // --------------------------------------------------------------------------- + + @Test + void emptyResp2ListYieldsEmptyResult() { + ComplexData data = listData(); + + assertThat(parser.parse(data)).isEmpty(); + } + + @Test + void emptyResp3MapYieldsEmptyResult() { + ComplexData data = mapData(); + + assertThat(parser.parse(data)).isEmpty(); + } + + // --------------------------------------------------------------------------- + // Given: a series with no samples (TS.GET-style empty array, not nil), Then: sample is null + // --------------------------------------------------------------------------- + + @Test + void emptySampleYieldsNullSample() { + ComplexData entry = listData(buf("key1"), listData(), listData()); + ComplexData data = listData(entry); + + List> result = parser.parse(data); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getSample()).isNull(); + } + + // --------------------------------------------------------------------------- + // Given: a sample with multiple aggregator values (N-tuple), Then: TsSample carries all values + // --------------------------------------------------------------------------- + + @Test + void multiAggregatorSampleIsPreserved() { + ComplexData entry = listData(buf("key1"), listData(), listData(1000L, 10.0d, 1.0d, 20.0d)); + ComplexData data = listData(entry); + + List> result = parser.parse(data); + + assertThat(result.get(0).getSample().getValues()).containsExactly(10.0, 1.0, 20.0); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/TsParserBranchCoverageUnitTests.java b/src/test/java/io/lettuce/core/timeseries/TsParserBranchCoverageUnitTests.java new file mode 100644 index 0000000000..eae1c25a1a --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/TsParserBranchCoverageUnitTests.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.output.ComplexData; + +/** + * Unit tests that close the defensive-branch coverage gaps identified for {@link TsInfoValueParser} and {@link TsSampleParser}: + * branches that are logically correct but were never exercised by the existing parser unit tests because those tests only ever + * fed {@code Double}/{@code String} encoded values. + *

+ * PLAN (Given/When/Then): + *

    + *
  • Given {@code ignoreMaxValDiff} arrives as a {@code Long} (not {@code Double}, not a bulk string), when + * {@code TsInfoValueParser} parses it, then the {@code Number && !Double} branch of {@code toNullableDouble} converts it via + * {@code doubleValue()}.
  • + *
  • Given an N-tuple value arrives as {@code Long} (not {@code Double}, not a bulk string), when {@code TsSampleParser} + * parses it, then the {@code Number && !Double} branch of {@code toDouble} converts it via {@code doubleValue()}.
  • + *
  • Given a {@code rules} aggregation type string that does not match any {@link TsAggregationType} constant, when + * {@code TsInfoValueParser} parses it, then {@code decodeAggregationType} silently returns {@code null} instead of + * throwing.
  • + *
+ * + * @author Gyumin Hwang + * @since 7.7 + */ +class TsParserBranchCoverageUnitTests { + + private final TsInfoValueParser infoParser = new TsInfoValueParser<>(StringCodec.UTF8); + + private final TsSampleParser sampleParser = TsSampleParser.INSTANCE; + + private static ByteBuffer buf(String s) { + return ByteBuffer.wrap(s.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Simulates a RESP2 flat key-value array: {@code isList()==true}, but {@code getDynamicMap()} is still available via the + * odd/even heuristic (matching {@code ArrayComplexData}). Copied from {@code TsInfoValueParserUnitTests} since the builder + * is {@code private static} there. + */ + private static ComplexData flatMapData(Object... pairs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < pairs.length - 1; i += 2) { + map.put(pairs[i], pairs[i + 1]); + } + List list = Arrays.asList(pairs); + return new ComplexData() { + + @Override + public void storeObject(Object value) { + } + + @Override + public List getDynamicList() { + return list; + } + + @Override + public Map getDynamicMap() { + return map; + } + + @Override + public boolean isList() { + return true; + } + + }; + } + + /** + * Simulates a nested array (used for the {@code rules} tuple and for {@code TS.GET}-shaped N-tuples): {@code + * isList()==true}. Copied from {@code TsInfoValueParserUnitTests}/{@code TsSampleParserUnitTests} since the builder is + * {@code private static} there. + */ + private static ComplexData listData(Object... items) { + List list = Arrays.asList(items); + return new ComplexData() { + + @Override + public void storeObject(Object value) { + } + + @Override + public List getDynamicList() { + return list; + } + + @Override + public boolean isList() { + return true; + } + + }; + } + + // --------------------------------------------------------------------------- + // A-1: TsInfoValueParser#toNullableDouble, Number-but-not-Double (Long) branch + // --------------------------------------------------------------------------- + + @Test + void ignoreMaxValDiffAsLongHitsNumberNotDoubleBranch() { + ComplexData data = flatMapData(buf("ignoreMaxValDiff"), 0L); + + TsInfoValue value = infoParser.parse(data); + + assertThat(value.getIgnoreMaxValDiff()).isEqualTo(0.0); + } + + // --------------------------------------------------------------------------- + // A-2: TsSampleParser#toDouble, Number-but-not-Double (Long) branch + // --------------------------------------------------------------------------- + + @Test + void multiAggregatorValuesAsLongHitNumberNotDoubleBranch() { + TsSample sample = sampleParser.parse(listData(1000L, 10L, 20L)); + + assertThat(sample.getValues()).containsExactly(10.0, 20.0); + } + + // --------------------------------------------------------------------------- + // A-3: TsInfoValueParser#decodeAggregationType, unknown enum string -> silent null + // --------------------------------------------------------------------------- + + @Test + void unknownAggregationTypeDecodesToNullInsteadOfThrowing() { + ComplexData rules = listData(listData(buf("dest-key"), 60000L, buf("UNKNOWN_AGG"), 0L)); + ComplexData data = flatMapData(buf("rules"), rules); + + TsInfoValue value = infoParser.parse(data); + + assertThat(value.getRules()).hasSize(1); + TsInfoValue.Rule rule = value.getRules().get(0); + assertThat(rule.getDestKey()).isEqualTo("dest-key"); + assertThat(rule.getAggregationType()).isNull(); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/TsSampleParserUnitTests.java b/src/test/java/io/lettuce/core/timeseries/TsSampleParserUnitTests.java new file mode 100644 index 0000000000..4ead5d0593 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/TsSampleParserUnitTests.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TsSampleParser}. + *

+ * PLAN (Given/When/Then): + *

    + *
  • Given a {@code null} top-level reply, when {@code parse}, then reject (a {@code null} reply should never occur for + * {@code TS.GET}; an empty series is an empty array, not nil, per the server source).
  • + *
  • Given a 2-element {@code [timestamp, value]} tuple, when {@code parse}, then a single-value {@link TsSample} is + * produced.
  • + *
  • Given an empty array (no samples), when {@code parse}, then {@code null} is returned.
  • + *
  • Given an N-tuple {@code [timestamp, v0, v1, ...]} (multiple aggregators), when {@code parse}, then all values are + * preserved in declaration order.
  • + *
+ */ +class TsSampleParserUnitTests { + + private final TsSampleParser parser = TsSampleParser.INSTANCE; + + private static ByteBuffer buf(String s) { + return ByteBuffer.wrap(s.getBytes(StandardCharsets.UTF_8)); + } + + private static io.lettuce.core.output.ComplexData listData(Object... items) { + List list = Arrays.asList(items); + return new io.lettuce.core.output.ComplexData() { + + @Override + public void storeObject(Object value) { + } + + @Override + public List getDynamicList() { + return list; + } + + @Override + public boolean isList() { + return true; + } + + }; + } + + @Test + void parseNullThrows() { + assertThatThrownBy(() -> parser.parse(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TS.GET"); + } + + @Test + void parsesSingleValueSample() { + TsSample sample = parser.parse(listData(1000L, buf("1.5"))); + + assertThat(sample).isNotNull(); + assertThat(sample.getTimestamp()).isEqualTo(1000L); + assertThat(sample.getValue()).isEqualTo(1.5); + } + + @Test + void emptyArrayYieldsNull() { + assertThat(parser.parse(listData())).isNull(); + } + + @Test + void multiAggregatorSampleIsPreserved() { + TsSample sample = parser.parse(listData(1000L, 10.0d, 1.0d, 20.0d)); + + assertThat(sample.getValues()).containsExactly(10.0, 1.0, 20.0); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/TsSampleUnitTests.java b/src/test/java/io/lettuce/core/timeseries/TsSampleUnitTests.java new file mode 100644 index 0000000000..c90bb96c0e --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/TsSampleUnitTests.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TsSample}. + * + * @author Gyumin Hwang + * @since 7.7 + */ +class TsSampleUnitTests { + + // --------------------------------------------------------------------------- + // Given a single-value sample (the common case, one value per aggregator) + // --------------------------------------------------------------------------- + + @Test + void singleValueSampleExposesGetValueAndGetValues() { + TsSample sample = new TsSample(1000L, Collections.singletonList(42.5)); + + assertThat(sample.getTimestamp()).isEqualTo(1000L); + assertThat(sample.getValue()).isEqualTo(42.5); + assertThat(sample.getValues()).containsExactly(42.5); + } + + // --------------------------------------------------------------------------- + // Given a multi-aggregator N-tuple sample (e.g. AGGREGATION avg,min) + // --------------------------------------------------------------------------- + + @Test + void multiValueSamplePreservesDeclarationOrder() { + List values = Arrays.asList(10.0, 1.0, 20.0); + + TsSample sample = new TsSample(2000L, values); + + assertThat(sample.getTimestamp()).isEqualTo(2000L); + assertThat(sample.getValue()).isEqualTo(10.0); + assertThat(sample.getValues()).containsExactly(10.0, 1.0, 20.0); + } + + // --------------------------------------------------------------------------- + // Given null or empty values, When constructing, Then reject (invariant: values.size() >= 1) + // --------------------------------------------------------------------------- + + @Test + void nullValuesThrows() { + assertThatThrownBy(() -> new TsSample(1L, null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void emptyValuesThrows() { + assertThatThrownBy(() -> new TsSample(1L, Collections.emptyList())).isInstanceOf(IllegalArgumentException.class); + } + + // --------------------------------------------------------------------------- + // Values list is immutable + // --------------------------------------------------------------------------- + + @Test + void valuesListIsUnmodifiable() { + TsSample sample = new TsSample(1L, Arrays.asList(1.0, 2.0)); + + assertThatThrownBy(() -> sample.getValues().add(3.0)).isInstanceOf(UnsupportedOperationException.class); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/arguments/TsAddArgsUnitTests.java b/src/test/java/io/lettuce/core/timeseries/arguments/TsAddArgsUnitTests.java new file mode 100644 index 0000000000..ba6aad4ac5 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/arguments/TsAddArgsUnitTests.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.timeseries.TsDuplicatePolicy; +import io.lettuce.core.timeseries.TsEncodingFormat; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link TsAddArgs}. + *

+ * Assertions are made against the raw RESP bulk-string encoding produced by {@link CommandArgs#encode(ByteBuf)} rather than + * {@link CommandArgs#toCommandString()}: the latter renders custom {@code ProtocolKeyword} enum values (that are not + * {@code CommandType}/{@code CommandKeyword}) as Base64 instead of their wire value, a pre-existing quirk in + * {@code CommandArgs.ProtocolKeywordArgument} unrelated to this change. The actual wire bytes written by {@code encode()} are + * unaffected and are what this test verifies. + *

+ * PLAN (Given/When/Then): + *

    + *
  • Given no options set, when {@code build()}, then no tokens are emitted.
  • + *
  • Given a single option set, when {@code build()}, then exactly that option's tokens are emitted.
  • + *
  • Given {@code onDuplicate(policy)}, when {@code build()}, then the token emitted is {@code ON_DUPLICATE}, never + * {@code DUPLICATE_POLICY}.
  • + *
  • Given {@code duplicatePolicy(policy)}, when {@code build()}, then the token emitted is {@code DUPLICATE_POLICY}.
  • + *
  • Given {@code ignore(a, b)}, when {@code build()}, then both values are emitted after {@code IGNORE}.
  • + *
  • Given {@code label(k, v)} called repeatedly, when {@code build()}, then all pairs are emitted in call order under a + * single {@code LABELS} keyword.
  • + *
  • Given {@code labels(Map)}, when {@code build()}, then the map's iteration order is preserved.
  • + *
  • Given every option combined, when {@code build()}, then tokens appear in the wire-contract order: RETENTION, ENCODING, + * CHUNK_SIZE, DUPLICATE_POLICY, ON_DUPLICATE, IGNORE, LABELS (LABELS last).
  • + *
+ */ +@Tag(UNIT_TEST) +class TsAddArgsUnitTests { + + private static String bulk(String value) { + return "$" + value.getBytes(StandardCharsets.UTF_8).length + "\r\n" + value + "\r\n"; + } + + private static String encode(TsAddArgs addArgs) { + CommandArgs args = new CommandArgs<>(StringCodec.UTF8); + addArgs.build(args); + ByteBuf buf = Unpooled.buffer(); + args.encode(buf); + return buf.toString(StandardCharsets.UTF_8); + } + + @Test + void shouldRenderNoArgs() { + assertThat(encode(new TsAddArgs())).isEmpty(); + } + + @Test + void shouldRenderRetention() { + assertThat(encode(TsAddArgs.Builder.retention(1000))).isEqualTo(bulk("RETENTION") + bulk("1000")); + } + + @Test + void shouldRenderEncoding() { + assertThat(encode(TsAddArgs.Builder.encoding(TsEncodingFormat.COMPRESSED))) + .isEqualTo(bulk("ENCODING") + bulk("COMPRESSED")); + } + + @Test + void shouldRenderChunkSize() { + assertThat(encode(TsAddArgs.Builder.chunkSize(4096))).isEqualTo(bulk("CHUNK_SIZE") + bulk("4096")); + } + + @Test + void shouldRenderOnDuplicateNotDuplicatePolicy() { + assertThat(encode(TsAddArgs.Builder.onDuplicate(TsDuplicatePolicy.LAST))) + .isEqualTo(bulk("ON_DUPLICATE") + bulk("LAST")); + } + + @Test + void shouldRenderDuplicatePolicy() { + assertThat(encode(TsAddArgs.Builder.duplicatePolicy(TsDuplicatePolicy.MAX))) + .isEqualTo(bulk("DUPLICATE_POLICY") + bulk("MAX")); + } + + @Test + void shouldRenderIgnore() { + assertThat(encode(TsAddArgs.Builder.ignore(100, 5.5))).isEqualTo(bulk("IGNORE") + bulk("100") + bulk("5.5")); + } + + @Test + void shouldRenderSingleLabel() { + assertThat(encode(TsAddArgs.Builder.label("region", "us"))).isEqualTo(bulk("LABELS") + bulk("region") + bulk("us")); + } + + @Test + void shouldRenderChainedLabels() { + assertThat(encode(TsAddArgs.Builder.label("region", "us").label("env", "prod"))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldRenderLabelsFromMapPreservingOrder() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + labels.put("env", "prod"); + + assertThat(encode(TsAddArgs.Builder.labels(labels))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldAppendChainedLabelAfterLabelsMap() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + + assertThat(encode(TsAddArgs.Builder.labels(labels).label("env", "prod"))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldRenderFullCombinationInWireOrder() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + + TsAddArgs addArgs = TsAddArgs.Builder.retention(1000).encoding(TsEncodingFormat.UNCOMPRESSED).chunkSize(4096) + .duplicatePolicy(TsDuplicatePolicy.LAST).onDuplicate(TsDuplicatePolicy.MAX).ignore(100, 5.5).labels(labels); + + assertThat(encode(addArgs)).isEqualTo(bulk("RETENTION") + bulk("1000") + bulk("ENCODING") + bulk("UNCOMPRESSED") + + bulk("CHUNK_SIZE") + bulk("4096") + bulk("DUPLICATE_POLICY") + bulk("LAST") + bulk("ON_DUPLICATE") + + bulk("MAX") + bulk("IGNORE") + bulk("100") + bulk("5.5") + bulk("LABELS") + bulk("region") + bulk("us")); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/arguments/TsAlterArgsUnitTests.java b/src/test/java/io/lettuce/core/timeseries/arguments/TsAlterArgsUnitTests.java new file mode 100644 index 0000000000..3a03acdece --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/arguments/TsAlterArgsUnitTests.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.timeseries.TsDuplicatePolicy; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link TsAlterArgs}. + *

+ * See {@link TsCreateArgsUnitTests} for why assertions target {@link CommandArgs#encode(ByteBuf)} rather than + * {@link CommandArgs#toCommandString()}. + *

+ * PLAN (Given/When/Then): mirrors {@link TsCreateArgsUnitTests}, minus {@code ENCODING} (TS.ALTER cannot change the encoding of + * an existing series). + *

    + *
  • Given no options set, when {@code build()}, then no tokens are emitted.
  • + *
  • Given each option individually, when {@code build()}, then exactly that option's tokens are emitted.
  • + *
  • Given every option combined, when {@code build()}, then tokens appear in wire-contract order: RETENTION, CHUNK_SIZE, + * DUPLICATE_POLICY, IGNORE, LABELS.
  • + *
  • Given {@code labelsReset()}, when {@code build()}, then an empty {@code LABELS} keyword is emitted (clears existing + * labels server-side).
  • + *
+ */ +@Tag(UNIT_TEST) +class TsAlterArgsUnitTests { + + private static String bulk(String value) { + return "$" + value.getBytes(StandardCharsets.UTF_8).length + "\r\n" + value + "\r\n"; + } + + private static String encode(TsAlterArgs alterArgs) { + CommandArgs args = new CommandArgs<>(StringCodec.UTF8); + alterArgs.build(args); + ByteBuf buf = Unpooled.buffer(); + args.encode(buf); + return buf.toString(StandardCharsets.UTF_8); + } + + @Test + void shouldRenderNoArgs() { + assertThat(encode(new TsAlterArgs())).isEmpty(); + } + + @Test + void shouldRenderRetention() { + assertThat(encode(TsAlterArgs.Builder.retention(1000))).isEqualTo(bulk("RETENTION") + bulk("1000")); + } + + @Test + void shouldRenderChunkSize() { + assertThat(encode(TsAlterArgs.Builder.chunkSize(4096))).isEqualTo(bulk("CHUNK_SIZE") + bulk("4096")); + } + + @Test + void shouldRenderDuplicatePolicy() { + assertThat(encode(TsAlterArgs.Builder.duplicatePolicy(TsDuplicatePolicy.MIN))) + .isEqualTo(bulk("DUPLICATE_POLICY") + bulk("MIN")); + } + + @Test + void shouldRenderIgnore() { + assertThat(encode(TsAlterArgs.Builder.ignore(50, 1.25))).isEqualTo(bulk("IGNORE") + bulk("50") + bulk("1.25")); + } + + @Test + void shouldRenderSingleLabel() { + assertThat(encode(TsAlterArgs.Builder.label("region", "us"))).isEqualTo(bulk("LABELS") + bulk("region") + bulk("us")); + } + + @Test + void shouldRenderLabelsFromMapPreservingOrder() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + labels.put("env", "prod"); + + assertThat(encode(TsAlterArgs.Builder.labels(labels))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldRenderLabelsResetAsEmptyLabelsKeyword() { + assertThat(encode(TsAlterArgs.Builder.labelsReset())).isEqualTo(bulk("LABELS")); + } + + @Test + void shouldRenderFullCombinationInWireOrderWithoutEncoding() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + + TsAlterArgs alterArgs = TsAlterArgs.Builder.retention(1000).chunkSize(4096).duplicatePolicy(TsDuplicatePolicy.LAST) + .ignore(100, 5.5).labels(labels); + + assertThat(encode(alterArgs)).isEqualTo( + bulk("RETENTION") + bulk("1000") + bulk("CHUNK_SIZE") + bulk("4096") + bulk("DUPLICATE_POLICY") + bulk("LAST") + + bulk("IGNORE") + bulk("100") + bulk("5.5") + bulk("LABELS") + bulk("region") + bulk("us")); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/arguments/TsCreateArgsUnitTests.java b/src/test/java/io/lettuce/core/timeseries/arguments/TsCreateArgsUnitTests.java new file mode 100644 index 0000000000..d3b7de5353 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/arguments/TsCreateArgsUnitTests.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.timeseries.TsDuplicatePolicy; +import io.lettuce.core.timeseries.TsEncodingFormat; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link TsCreateArgs}. + *

+ * Assertions are made against the raw RESP bulk-string encoding produced by {@link CommandArgs#encode(ByteBuf)} rather than + * {@link CommandArgs#toCommandString()}: the latter renders custom {@code ProtocolKeyword} enum values (that are not + * {@code CommandType}/{@code CommandKeyword}) as Base64 instead of their wire value, a pre-existing quirk in + * {@code CommandArgs.ProtocolKeywordArgument} unrelated to this change. The actual wire bytes written by {@code encode()} are + * unaffected and are what this test verifies. + *

+ * PLAN (Given/When/Then): + *

    + *
  • Given no options set, when {@code build()}, then no tokens are emitted.
  • + *
  • Given a single option set, when {@code build()}, then exactly that option's tokens are emitted.
  • + *
  • Given {@code label(k, v)} called repeatedly, when {@code build()}, then all pairs are emitted in call order under a + * single {@code LABELS} keyword.
  • + *
  • Given {@code labels(Map)}, when {@code build()}, then the map's iteration order is preserved.
  • + *
  • Given {@code ignore(maxTimeDiff, maxValDiff)}, when {@code build()}, then both values are emitted after + * {@code IGNORE}.
  • + *
  • Given every option combined, when {@code build()}, then tokens appear in the wire-contract order: RETENTION, ENCODING, + * CHUNK_SIZE, DUPLICATE_POLICY, IGNORE, LABELS.
  • + *
+ */ +@Tag(UNIT_TEST) +class TsCreateArgsUnitTests { + + private static String bulk(String value) { + return "$" + value.getBytes(StandardCharsets.UTF_8).length + "\r\n" + value + "\r\n"; + } + + private static String encode(TsCreateArgs createArgs) { + CommandArgs args = new CommandArgs<>(StringCodec.UTF8); + createArgs.build(args); + ByteBuf buf = Unpooled.buffer(); + args.encode(buf); + return buf.toString(StandardCharsets.UTF_8); + } + + @Test + void shouldRenderNoArgs() { + assertThat(encode(new TsCreateArgs())).isEmpty(); + } + + @Test + void shouldRenderRetention() { + assertThat(encode(TsCreateArgs.Builder.retention(1000))).isEqualTo(bulk("RETENTION") + bulk("1000")); + } + + @Test + void shouldRenderEncoding() { + assertThat(encode(TsCreateArgs.Builder.encoding(TsEncodingFormat.COMPRESSED))) + .isEqualTo(bulk("ENCODING") + bulk("COMPRESSED")); + } + + @Test + void shouldRenderChunkSize() { + assertThat(encode(TsCreateArgs.Builder.chunkSize(4096))).isEqualTo(bulk("CHUNK_SIZE") + bulk("4096")); + } + + @Test + void shouldRenderDuplicatePolicy() { + assertThat(encode(TsCreateArgs.Builder.duplicatePolicy(TsDuplicatePolicy.LAST))) + .isEqualTo(bulk("DUPLICATE_POLICY") + bulk("LAST")); + } + + @Test + void shouldRenderIgnore() { + assertThat(encode(TsCreateArgs.Builder.ignore(100, 5.5))).isEqualTo(bulk("IGNORE") + bulk("100") + bulk("5.5")); + } + + @Test + void shouldRenderSingleLabel() { + assertThat(encode(TsCreateArgs.Builder.label("region", "us"))).isEqualTo(bulk("LABELS") + bulk("region") + bulk("us")); + } + + @Test + void shouldRenderChainedLabels() { + assertThat(encode(TsCreateArgs.Builder.label("region", "us").label("env", "prod"))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldRenderLabelsFromMapPreservingOrder() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + labels.put("env", "prod"); + + assertThat(encode(TsCreateArgs.Builder.labels(labels))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldAppendChainedLabelAfterLabelsMap() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + + assertThat(encode(TsCreateArgs.Builder.labels(labels).label("env", "prod"))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldRenderFullCombinationInWireOrder() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + + TsCreateArgs createArgs = TsCreateArgs.Builder.retention(1000).encoding(TsEncodingFormat.UNCOMPRESSED).chunkSize(4096) + .duplicatePolicy(TsDuplicatePolicy.LAST).ignore(100, 5.5).labels(labels); + + assertThat(encode(createArgs)).isEqualTo(bulk("RETENTION") + bulk("1000") + bulk("ENCODING") + bulk("UNCOMPRESSED") + + bulk("CHUNK_SIZE") + bulk("4096") + bulk("DUPLICATE_POLICY") + bulk("LAST") + bulk("IGNORE") + bulk("100") + + bulk("5.5") + bulk("LABELS") + bulk("region") + bulk("us")); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/arguments/TsGetArgsUnitTests.java b/src/test/java/io/lettuce/core/timeseries/arguments/TsGetArgsUnitTests.java new file mode 100644 index 0000000000..f4451f4598 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/arguments/TsGetArgsUnitTests.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.nio.charset.StandardCharsets; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.protocol.CommandArgs; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link TsGetArgs}. + *

+ * PLAN (Given/When/Then): + *

    + *
  • Given no options set, when {@code build()}, then no tokens are emitted.
  • + *
  • Given {@code latest()}, when {@code build()}, then exactly {@code LATEST} is emitted.
  • + *
+ */ +@Tag(UNIT_TEST) +class TsGetArgsUnitTests { + + private static String bulk(String value) { + return "$" + value.getBytes(StandardCharsets.UTF_8).length + "\r\n" + value + "\r\n"; + } + + private static String encode(TsGetArgs getArgs) { + CommandArgs args = new CommandArgs<>(StringCodec.UTF8); + getArgs.build(args); + ByteBuf buf = Unpooled.buffer(); + args.encode(buf); + return buf.toString(StandardCharsets.UTF_8); + } + + @Test + void shouldRenderNoArgs() { + assertThat(encode(new TsGetArgs())).isEmpty(); + } + + @Test + void shouldRenderLatest() { + assertThat(encode(TsGetArgs.Builder.latest())).isEqualTo(bulk("LATEST")); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/arguments/TsIncrByArgsUnitTests.java b/src/test/java/io/lettuce/core/timeseries/arguments/TsIncrByArgsUnitTests.java new file mode 100644 index 0000000000..b9a0a22438 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/arguments/TsIncrByArgsUnitTests.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.timeseries.TsDuplicatePolicy; +import io.lettuce.core.timeseries.TsEncodingFormat; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link TsIncrByArgs}, shared by {@code TS.INCRBY} and {@code TS.DECRBY}. + *

+ * Assertions are made against the raw RESP bulk-string encoding produced by {@link CommandArgs#encode(ByteBuf)} rather than + * {@link CommandArgs#toCommandString()}: the latter renders custom {@code ProtocolKeyword} enum values (that are not + * {@code CommandType}/{@code CommandKeyword}) as Base64 instead of their wire value, a pre-existing quirk in + * {@code CommandArgs.ProtocolKeywordArgument} unrelated to this change. The actual wire bytes written by {@code encode()} are + * unaffected and are what this test verifies. + *

+ * PLAN (Given/When/Then): + *

    + *
  • Given no options set, when {@code build()}, then no tokens are emitted.
  • + *
  • Given a single option set, when {@code build()}, then exactly that option's tokens are emitted.
  • + *
  • Given {@code timestamp(ts)}, when {@code build()}, then {@code TIMESTAMP ts} is emitted.
  • + *
  • Given {@code ignore(a, b)}, when {@code build()}, then both values are emitted after {@code IGNORE}.
  • + *
  • Given {@code label(k, v)} called repeatedly, when {@code build()}, then all pairs are emitted in call order under a + * single {@code LABELS} keyword.
  • + *
  • Given {@code labels(Map)}, when {@code build()}, then the map's iteration order is preserved.
  • + *
  • Given every option combined, when {@code build()}, then tokens appear in the wire-contract order: TIMESTAMP, RETENTION, + * ENCODING, CHUNK_SIZE, DUPLICATE_POLICY, IGNORE, LABELS (LABELS last).
  • + *
+ */ +@Tag(UNIT_TEST) +class TsIncrByArgsUnitTests { + + private static String bulk(String value) { + return "$" + value.getBytes(StandardCharsets.UTF_8).length + "\r\n" + value + "\r\n"; + } + + private static String encode(TsIncrByArgs incrByArgs) { + CommandArgs args = new CommandArgs<>(StringCodec.UTF8); + incrByArgs.build(args); + ByteBuf buf = Unpooled.buffer(); + args.encode(buf); + return buf.toString(StandardCharsets.UTF_8); + } + + @Test + void shouldRenderNoArgs() { + assertThat(encode(new TsIncrByArgs())).isEmpty(); + } + + @Test + void shouldRenderTimestamp() { + assertThat(encode(TsIncrByArgs.Builder.timestamp(1000))).isEqualTo(bulk("TIMESTAMP") + bulk("1000")); + } + + @Test + void shouldRenderRetention() { + assertThat(encode(TsIncrByArgs.Builder.retention(1000))).isEqualTo(bulk("RETENTION") + bulk("1000")); + } + + @Test + void shouldRenderEncoding() { + assertThat(encode(TsIncrByArgs.Builder.encoding(TsEncodingFormat.COMPRESSED))) + .isEqualTo(bulk("ENCODING") + bulk("COMPRESSED")); + } + + @Test + void shouldRenderChunkSize() { + assertThat(encode(TsIncrByArgs.Builder.chunkSize(4096))).isEqualTo(bulk("CHUNK_SIZE") + bulk("4096")); + } + + @Test + void shouldRenderDuplicatePolicy() { + assertThat(encode(TsIncrByArgs.Builder.duplicatePolicy(TsDuplicatePolicy.LAST))) + .isEqualTo(bulk("DUPLICATE_POLICY") + bulk("LAST")); + } + + @Test + void shouldRenderIgnore() { + assertThat(encode(TsIncrByArgs.Builder.ignore(100, 5.5))).isEqualTo(bulk("IGNORE") + bulk("100") + bulk("5.5")); + } + + @Test + void shouldRenderSingleLabel() { + assertThat(encode(TsIncrByArgs.Builder.label("region", "us"))).isEqualTo(bulk("LABELS") + bulk("region") + bulk("us")); + } + + @Test + void shouldRenderChainedLabels() { + assertThat(encode(TsIncrByArgs.Builder.label("region", "us").label("env", "prod"))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldRenderLabelsFromMapPreservingOrder() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + labels.put("env", "prod"); + + assertThat(encode(TsIncrByArgs.Builder.labels(labels))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldAppendChainedLabelAfterLabelsMap() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + + assertThat(encode(TsIncrByArgs.Builder.labels(labels).label("env", "prod"))) + .isEqualTo(bulk("LABELS") + bulk("region") + bulk("us") + bulk("env") + bulk("prod")); + } + + @Test + void shouldRenderFullCombinationInWireOrder() { + Map labels = new LinkedHashMap<>(); + labels.put("region", "us"); + + TsIncrByArgs incrByArgs = TsIncrByArgs.Builder.timestamp(2000).retention(1000).encoding(TsEncodingFormat.UNCOMPRESSED) + .chunkSize(4096).duplicatePolicy(TsDuplicatePolicy.LAST).ignore(100, 5.5).labels(labels); + + assertThat(encode(incrByArgs)).isEqualTo(bulk("TIMESTAMP") + bulk("2000") + bulk("RETENTION") + bulk("1000") + + bulk("ENCODING") + bulk("UNCOMPRESSED") + bulk("CHUNK_SIZE") + bulk("4096") + bulk("DUPLICATE_POLICY") + + bulk("LAST") + bulk("IGNORE") + bulk("100") + bulk("5.5") + bulk("LABELS") + bulk("region") + bulk("us")); + } + +} diff --git a/src/test/java/io/lettuce/core/timeseries/arguments/TsMGetArgsUnitTests.java b/src/test/java/io/lettuce/core/timeseries/arguments/TsMGetArgsUnitTests.java new file mode 100644 index 0000000000..98f69a6218 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/arguments/TsMGetArgsUnitTests.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026-Present, Redis Ltd. + * All rights reserved. + * + * SPDX-License-Identifier: MIT + */ +package io.lettuce.core.timeseries.arguments; + +import java.nio.charset.StandardCharsets; + +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.protocol.CommandArgs; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link TsMGetArgs}. + *

+ * PLAN (Given/When/Then): + *

    + *
  • Given no options set, when {@code build()}, then no tokens are emitted (the caller appends {@code FILTER} + * separately).
  • + *
  • Given {@code latest()}, when {@code build()}, then exactly {@code LATEST} is emitted.
  • + *
  • Given {@code withLabels()}, when {@code build()}, then exactly {@code WITHLABELS} is emitted.
  • + *
  • Given {@code selectedLabels(a, b)}, when {@code build()}, then {@code SELECTED_LABELS a b} is emitted.
  • + *
  • Given both {@code withLabels()} and {@code selectedLabels(...)}, when {@code build()}, then an + * {@link IllegalArgumentException} is thrown (server rejects the combination, {@code query_language.c:851-853}).
  • + *
  • Given {@code latest()} combined with {@code selectedLabels(...)}, when {@code build()}, then {@code LATEST} precedes + * {@code SELECTED_LABELS} so a subsequent {@code FILTER} keyword still terminates the label scan on the wire.
  • + *
+ */ +@Tag(UNIT_TEST) +class TsMGetArgsUnitTests { + + private static String bulk(String value) { + return "$" + value.getBytes(StandardCharsets.UTF_8).length + "\r\n" + value + "\r\n"; + } + + private static String encode(TsMGetArgs mGetArgs) { + CommandArgs args = new CommandArgs<>(StringCodec.UTF8); + mGetArgs.build(args); + ByteBuf buf = Unpooled.buffer(); + args.encode(buf); + return buf.toString(StandardCharsets.UTF_8); + } + + @Test + void shouldRenderNoArgs() { + assertThat(encode(new TsMGetArgs())).isEmpty(); + } + + @Test + void shouldRenderLatest() { + assertThat(encode(TsMGetArgs.Builder.latest())).isEqualTo(bulk("LATEST")); + } + + @Test + void shouldRenderWithLabels() { + assertThat(encode(TsMGetArgs.Builder.withLabels())).isEqualTo(bulk("WITHLABELS")); + } + + @Test + void shouldRenderSelectedLabels() { + assertThat(encode(TsMGetArgs.Builder.selectedLabels("region", "env"))) + .isEqualTo(bulk("SELECTED_LABELS") + bulk("region") + bulk("env")); + } + + @Test + void shouldRenderLatestBeforeSelectedLabels() { + TsMGetArgs args = TsMGetArgs.Builder.latest().selectedLabels("region"); + + assertThat(encode(args)).isEqualTo(bulk("LATEST") + bulk("SELECTED_LABELS") + bulk("region")); + } + + @Test + void shouldRejectWithLabelsAndSelectedLabelsTogether() { + TsMGetArgs args = TsMGetArgs.Builder.withLabels().selectedLabels("region"); + + assertThatThrownBy(() -> encode(args)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("WITHLABELS") + .hasMessageContaining("SELECTED_LABELS"); + } + +}