From 31e84b6e8053dea5b614cbaaae9e229e3f00afba Mon Sep 17 00:00:00 2001 From: HwangRock Date: Sat, 18 Jul 2026 10:48:15 +0900 Subject: [PATCH 1/3] Add RedisTimeSeries (TS.*) support #2716 Implements 13 RedisTimeSeries commands across the sync, async, reactive, cluster, and coroutine APIs: TS.CREATE, TS.ALTER, TS.CREATERULE, TS.DELETERULE, TS.DEL, TS.ADD, TS.MADD, TS.INCRBY, TS.DECRBY, TS.GET, TS.MGET, TS.INFO, TS.QUERYINDEX This covers the create/read/update/delete surface. Range queries (TS.RANGE/REVRANGE/MRANGE/MREVRANGE) are left for a follow-up, since their GROUPBY/aggregation response shapes need separate handling. The command set mirrors the existing Bloom/Cuckoo Filter structure: a package-private RedisTimeSeriesCommandBuilder, a template-driven interface generating the sync/async/reactive/node-selection variants, argument builders under io.lettuce.core.timeseries.arguments, and value/parser types under io.lettuce.core.timeseries. TS.INFO/TS.MGET parse RESP2 and RESP3 into the same domain objects; the parsers branch on the actual reply type (isMap()) rather than the negotiated protocol version, because the server falls back to arrays on older module APIs even under RESP3. Verified against redis:8. Read-only commands (TS.GET/MGET/INFO/QUERYINDEX) are registered for replica routing. Includes unit tests for the builder, args, and parsers, plus integration tests covering CRUD round-trips and edge cases (duplicate policies, IGNORE filters, NaN/Infinity values, compaction timing, label charset limits). One reactive integration test is @Disabled pending redis/lettuce#3851, which fixes an unrelated pre-existing hang in RedisPublisher on server errors. --- .../core/AbstractRedisAsyncCommands.java | 150 ++++- .../core/AbstractRedisReactiveCommands.java | 163 +++++- .../core/RedisTimeSeriesCommandBuilder.java | 278 ++++++++++ .../core/api/async/RedisAsyncCommands.java | 2 +- .../async/RedisTimeSeriesAsyncCommands.java | 287 ++++++++++ .../api/reactive/RedisReactiveCommands.java | 17 +- .../RedisTimeSeriesReactiveCommands.java | 288 ++++++++++ .../lettuce/core/api/sync/RedisCommands.java | 3 +- .../api/sync/RedisTimeSeriesCommands.java | 285 ++++++++++ .../api/async/NodeSelectionAsyncCommands.java | 2 +- .../NodeSelectionTimeSeriesAsyncCommands.java | 286 ++++++++++ .../api/async/RedisClusterAsyncCommands.java | 2 +- .../RedisClusterReactiveCommands.java | 16 +- .../api/sync/NodeSelectionCommands.java | 2 +- .../sync/NodeSelectionTimeSeriesCommands.java | 286 ++++++++++ .../api/sync/RedisClusterCommands.java | 13 +- .../lettuce/core/protocol/CommandKeyword.java | 5 +- .../io/lettuce/core/protocol/CommandType.java | 5 + .../core/protocol/ReadOnlyCommands.java | 4 +- .../core/timeseries/TsAggregationType.java | 78 +++ .../core/timeseries/TsDuplicatePolicy.java | 45 ++ .../core/timeseries/TsEncodingFormat.java | 37 ++ .../lettuce/core/timeseries/TsInfoValue.java | 367 +++++++++++++ .../core/timeseries/TsInfoValueParser.java | 154 ++++++ .../core/timeseries/TsLabelsParser.java | 72 +++ .../lettuce/core/timeseries/TsMGetValue.java | 60 ++ .../core/timeseries/TsMGetValueParser.java | 71 +++ .../io/lettuce/core/timeseries/TsSample.java | 67 +++ .../core/timeseries/TsSampleParser.java | 74 +++ .../core/timeseries/arguments/TsAddArgs.java | 246 +++++++++ .../timeseries/arguments/TsAlterArgs.java | 216 ++++++++ .../timeseries/arguments/TsCreateArgs.java | 215 ++++++++ .../core/timeseries/arguments/TsGetArgs.java | 66 +++ .../timeseries/arguments/TsIncrByArgs.java | 248 +++++++++ .../core/timeseries/arguments/TsMGetArgs.java | 132 +++++ .../api/coroutines/RedisCoroutinesCommands.kt | 1 + .../coroutines/RedisCoroutinesCommandsImpl.kt | 1 + .../RedisTimeSeriesCoroutinesCommands.kt | 287 ++++++++++ .../RedisTimeSeriesCoroutinesCommandsImpl.kt | 122 +++++ .../RedisClusterCoroutinesCommands.kt | 1 + .../RedisClusterCoroutinesCommandsImpl.kt | 1 + .../core/api/RedisTimeSeriesCommands.java | 284 ++++++++++ .../io/lettuce/apigenerator/Constants.java | 2 +- ...edisTimeSeriesCommandBuilderUnitTests.java | 411 ++++++++++++++ .../ClusterReadOnlyCommandsUnitTests.java | 2 +- ...edisTimeSeriesCharsetIntegrationTests.java | 178 ++++++ ...edisTimeSeriesClusterIntegrationTests.java | 32 ++ ...disTimeSeriesEdgeCaseIntegrationTests.java | 172 ++++++ .../RedisTimeSeriesIntegrationTests.java | 512 ++++++++++++++++++ ...RedisTimeSeriesPolicyIntegrationTests.java | 332 ++++++++++++ ...disTimeSeriesReactiveIntegrationTests.java | 122 +++++ .../RedisTimeSeriesResp2IntegrationTests.java | 39 ++ .../TsAggregationTypeUnitTests.java | 78 +++ .../TsDuplicatePolicyUnitTests.java | 39 ++ .../timeseries/TsEncodingFormatUnitTests.java | 35 ++ .../TsInfoValueParserUnitTests.java | 390 +++++++++++++ .../TsMGetValueParserUnitTests.java | 219 ++++++++ .../TsParserBranchCoverageUnitTests.java | 155 ++++++ .../timeseries/TsSampleParserUnitTests.java | 89 +++ .../core/timeseries/TsSampleUnitTests.java | 79 +++ .../arguments/TsAddArgsUnitTests.java | 145 +++++ .../arguments/TsAlterArgsUnitTests.java | 115 ++++ .../arguments/TsCreateArgsUnitTests.java | 137 +++++ .../arguments/TsGetArgsUnitTests.java | 55 ++ .../arguments/TsIncrByArgsUnitTests.java | 142 +++++ .../arguments/TsMGetArgsUnitTests.java | 89 +++ 66 files changed, 8468 insertions(+), 40 deletions(-) create mode 100644 src/main/java/io/lettuce/core/RedisTimeSeriesCommandBuilder.java create mode 100644 src/main/java/io/lettuce/core/api/async/RedisTimeSeriesAsyncCommands.java create mode 100644 src/main/java/io/lettuce/core/api/reactive/RedisTimeSeriesReactiveCommands.java create mode 100644 src/main/java/io/lettuce/core/api/sync/RedisTimeSeriesCommands.java create mode 100644 src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionTimeSeriesAsyncCommands.java create mode 100644 src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionTimeSeriesCommands.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsAggregationType.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsDuplicatePolicy.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsEncodingFormat.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsInfoValue.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsInfoValueParser.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsLabelsParser.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsMGetValue.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsMGetValueParser.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsSample.java create mode 100644 src/main/java/io/lettuce/core/timeseries/TsSampleParser.java create mode 100644 src/main/java/io/lettuce/core/timeseries/arguments/TsAddArgs.java create mode 100644 src/main/java/io/lettuce/core/timeseries/arguments/TsAlterArgs.java create mode 100644 src/main/java/io/lettuce/core/timeseries/arguments/TsCreateArgs.java create mode 100644 src/main/java/io/lettuce/core/timeseries/arguments/TsGetArgs.java create mode 100644 src/main/java/io/lettuce/core/timeseries/arguments/TsIncrByArgs.java create mode 100644 src/main/java/io/lettuce/core/timeseries/arguments/TsMGetArgs.java create mode 100644 src/main/kotlin/io/lettuce/core/api/coroutines/RedisTimeSeriesCoroutinesCommands.kt create mode 100644 src/main/kotlin/io/lettuce/core/api/coroutines/RedisTimeSeriesCoroutinesCommandsImpl.kt create mode 100644 src/main/templates/io/lettuce/core/api/RedisTimeSeriesCommands.java create mode 100644 src/test/java/io/lettuce/core/RedisTimeSeriesCommandBuilderUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesCharsetIntegrationTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesClusterIntegrationTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesEdgeCaseIntegrationTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesIntegrationTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesPolicyIntegrationTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesReactiveIntegrationTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesResp2IntegrationTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/TsAggregationTypeUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/TsDuplicatePolicyUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/TsEncodingFormatUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/TsInfoValueParserUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/TsMGetValueParserUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/TsParserBranchCoverageUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/TsSampleParserUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/TsSampleUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/arguments/TsAddArgsUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/arguments/TsAlterArgsUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/arguments/TsCreateArgsUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/arguments/TsGetArgsUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/arguments/TsIncrByArgsUnitTests.java create mode 100644 src/test/java/io/lettuce/core/timeseries/arguments/TsMGetArgsUnitTests.java 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..6db5e1890d --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesClusterIntegrationTests.java @@ -0,0 +1,32 @@ +/* + * 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.Tag; + +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 { + + @Inject + RedisTimeSeriesClusterIntegrationTests(StatefulRedisClusterConnection connection) { + super(ClusterTestUtil.redisCommandsOverCluster(connection)); + } + +} 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..d4dbfcbfa7 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesEdgeCaseIntegrationTests.java @@ -0,0 +1,172 @@ +/* + * 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 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; + +/** + * 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() { + 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() { + 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) { + 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..18de19b5c5 --- /dev/null +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesIntegrationTests.java @@ -0,0 +1,512 @@ +/* + * 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. + */ + @Test + void tsMAddAndGetRoundTrip() { + String key1 = "series:k1"; + String key2 = "series: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"); + } + +} From 16f2f04a15851e4303a5dde8c1ed9dfa476ed6da Mon Sep 17 00:00:00 2001 From: HwangRock Date: Sat, 18 Jul 2026 11:17:27 +0900 Subject: [PATCH 2/3] Fix TS.* cluster integration tests #2716 The cluster suite inherits the standalone tests over a cluster connection. Six of them cannot pass unchanged on a cluster: - tsMAddAndGetRoundTrip sends multiple keys in one TS.MADD; without a hash tag they land on different slots and the server returns CROSSSLOT. Tagged the keys ({ts-madd}:k1/k2) so they share a slot; the braces are inert on standalone. - TS.MGET/TS.QUERYINDEX with a label FILTER match series across the whole keyspace, which a single-node connection can't cover on a cluster. Disabled those five via @Override, matching how Geo/Stream cluster suites handle keyless commands. Verified against a real 7-node cluster (test-cluster) plus standalone: 124 tests, 0 failures, 5 skipped. --- ...edisTimeSeriesClusterIntegrationTests.java | 36 +++++++++++++++++++ .../RedisTimeSeriesIntegrationTests.java | 7 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesClusterIntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesClusterIntegrationTests.java index 6db5e1890d..78b704468a 100644 --- a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesClusterIntegrationTests.java +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesClusterIntegrationTests.java @@ -8,7 +8,9 @@ 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; @@ -24,9 +26,43 @@ @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/RedisTimeSeriesIntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesIntegrationTests.java index 18de19b5c5..7ca8cb59b6 100644 --- a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesIntegrationTests.java +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesIntegrationTests.java @@ -288,11 +288,14 @@ void tsAddAndGetRoundTrip() { * 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 = "series:k1"; - String key2 = "series:k2"; + String key1 = "{ts-madd}:k1"; + String key2 = "{ts-madd}:k2"; redis.tsCreate(key1); redis.tsCreate(key2); From e745b62626c7a0f36de8d258906fa527c14ed9bd Mon Sep 17 00:00:00 2001 From: HwangRock Date: Sat, 18 Jul 2026 11:35:44 +0900 Subject: [PATCH 3/3] Gate TS.* NaN edge-case tests on Redis 8.6 #2716 RedisTimeSeries only accepts NaN values from Redis 8.6 onward; 8.2 and 8.4 reject TS.ADD ... nan with "ERR TSDB: invalid value". The NaN tests assumed 8.8 behaviour and failed on the 8.2 CI matrix job. Guard the six NaN tests with assumeTrue(RedisConditions ... "8.6"), matching the assumeTrue/RedisConditions pattern already used for version-gated Vector Set tests. The Infinity and async tests in the same class stay unguarded since they pass on every version. Verified locally: redis:8.4 skips the six (10 run, 6 skipped), redis:8.8 runs all ten, the rest of the TS suite is unaffected. --- .../RedisTimeSeriesEdgeCaseIntegrationTests.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesEdgeCaseIntegrationTests.java b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesEdgeCaseIntegrationTests.java index d4dbfcbfa7..4b6add9256 100644 --- a/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesEdgeCaseIntegrationTests.java +++ b/src/test/java/io/lettuce/core/timeseries/RedisTimeSeriesEdgeCaseIntegrationTests.java @@ -19,6 +19,7 @@ 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; @@ -29,6 +30,7 @@ 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 @@ -88,6 +90,8 @@ void tsAddRejectsNegativeInfinity() { */ @Test void tsAddAcceptsNaNAndStoresIt() { + assumeTrue(RedisConditions.of(redis).hasVersionGreaterOrEqualsTo("8.6")); + Long timestamp = redis.tsAdd(MY_KEY, 1000, Double.NaN); assertThat(timestamp).isEqualTo(1000L); @@ -105,6 +109,8 @@ void tsAddAcceptsNaNAndStoresIt() { */ @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); @@ -139,6 +145,8 @@ void tsAddWithNaNUnderDuplicatePolicyBlockFails() { * 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);