From 103636ce76e8fc6413f2c5b997b0d678807d9688 Mon Sep 17 00:00:00 2001 From: Tihomir Krasimirov Mateev Date: Thu, 8 Aug 2024 14:42:25 +0300 Subject: [PATCH] Add support for CLIENT TRACKINGINFO (#2862) * Initial commit * Added the CLIENT TRACKINGINFO command * Implement the final version of the command, add parser utility * Add some more unit tests. Polishing. * Implemented dynamic parsing of the result returned from CLIENT TRACKINGINFO * Fixed conflixt issues * Addressed Ali's comment on calling the .getDynamicMap only once * Circle back to using domain objects * Missed several crucial files * Remove part of the license that is not needed --- .../core/AbstractRedisAsyncCommands.java | 5 + .../core/AbstractRedisReactiveCommands.java | 5 + .../io/lettuce/core/RedisCommandBuilder.java | 6 + .../java/io/lettuce/core/TrackingInfo.java | 165 ++++++++++++++++++ .../api/async/RedisServerAsyncCommands.java | 9 + .../reactive/RedisServerReactiveCommands.java | 9 + .../core/api/sync/RedisServerCommands.java | 9 + .../NodeSelectionServerAsyncCommands.java | 14 +- .../api/sync/NodeSelectionServerCommands.java | 11 +- .../lettuce/core/output/ArrayComplexData.java | 80 +++++++++ .../io/lettuce/core/output/ComplexData.java | 118 +++++++++++++ .../core/output/ComplexDataParser.java | 31 ++++ .../io/lettuce/core/output/ComplexOutput.java | 142 +++++++++++++++ .../lettuce/core/output/MapComplexData.java | 48 +++++ .../lettuce/core/output/SetComplexData.java | 50 ++++++ .../core/output/TrackingInfoParser.java | 83 +++++++++ .../lettuce/core/protocol/CommandKeyword.java | 8 +- .../RedisServerCoroutinesCommands.kt | 11 +- .../RedisServerCoroutinesCommandsImpl.kt | 3 + .../lettuce/core/api/RedisServerCommands.java | 9 + .../core/RedisCommandBuilderUnitTests.java | 11 ++ .../ServerCommandIntegrationTests.java | 68 ++++++-- .../core/output/TrackingInfoParserTest.java | 93 ++++++++++ 23 files changed, 964 insertions(+), 24 deletions(-) create mode 100644 src/main/java/io/lettuce/core/TrackingInfo.java create mode 100644 src/main/java/io/lettuce/core/output/ArrayComplexData.java create mode 100644 src/main/java/io/lettuce/core/output/ComplexData.java create mode 100644 src/main/java/io/lettuce/core/output/ComplexDataParser.java create mode 100644 src/main/java/io/lettuce/core/output/ComplexOutput.java create mode 100644 src/main/java/io/lettuce/core/output/MapComplexData.java create mode 100644 src/main/java/io/lettuce/core/output/SetComplexData.java create mode 100644 src/main/java/io/lettuce/core/output/TrackingInfoParser.java create mode 100644 src/test/java/io/lettuce/core/output/TrackingInfoParserTest.java diff --git a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java index 3c891850c0..d72accd63d 100644 --- a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java +++ b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java @@ -389,6 +389,11 @@ public RedisFuture clientTracking(TrackingArgs args) { return dispatch(commandBuilder.clientTracking(args)); } + @Override + public RedisFuture clientTrackinginfo() { + return dispatch(commandBuilder.clientTrackinginfo()); + } + @Override public RedisFuture clientUnblock(long id, UnblockType type) { return dispatch(commandBuilder.clientUnblock(id, type)); diff --git a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java index 25c4cc66f7..675bc31ba3 100644 --- a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java +++ b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java @@ -407,6 +407,11 @@ public Mono clientTracking(TrackingArgs args) { return createMono(() -> commandBuilder.clientTracking(args)); } + @Override + public Mono clientTrackinginfo() { + return createMono(commandBuilder::clientTrackinginfo); + } + @Override public Mono clientUnblock(long id, UnblockType type) { return createMono(() -> commandBuilder.clientUnblock(id, type)); diff --git a/src/main/java/io/lettuce/core/RedisCommandBuilder.java b/src/main/java/io/lettuce/core/RedisCommandBuilder.java index 939929494d..46e5cb809b 100644 --- a/src/main/java/io/lettuce/core/RedisCommandBuilder.java +++ b/src/main/java/io/lettuce/core/RedisCommandBuilder.java @@ -528,6 +528,12 @@ Command clientTracking(TrackingArgs trackingArgs) { return createCommand(CLIENT, new StatusOutput<>(codec), args); } + Command clientTrackinginfo() { + CommandArgs args = new CommandArgs<>(codec).add(TRACKINGINFO); + + return new Command<>(CLIENT, new ComplexOutput<>(codec, TrackingInfoParser.INSTANCE), args); + } + Command clientUnblock(long id, UnblockType type) { LettuceAssert.notNull(type, "UnblockType " + MUST_NOT_BE_NULL); diff --git a/src/main/java/io/lettuce/core/TrackingInfo.java b/src/main/java/io/lettuce/core/TrackingInfo.java new file mode 100644 index 0000000000..1d69dcd06a --- /dev/null +++ b/src/main/java/io/lettuce/core/TrackingInfo.java @@ -0,0 +1,165 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.List; + +/** + * Contains the output of a CLIENT TRACKINGINFO + * command. + * + * @author Tihomir Mateev + * @since 6.5 + */ +public class TrackingInfo { + + private final Set flags = new HashSet<>(); + + private final long redirect; + + private final List prefixes = new ArrayList<>(); + + /** + * Constructor + * + * @param flags a {@link Set} of {@link TrackingFlag}s that the command returned + * @param redirect the client ID used for notification redirection, -1 when none + * @param prefixes a {@link List} of key prefixes for which notifications are sent to the client + * + * @see TrackingFlag + */ + public TrackingInfo(Set flags, long redirect, List prefixes) { + this.flags.addAll(flags); + this.redirect = redirect; + this.prefixes.addAll(prefixes); + } + + /** + * @return set of all the {@link TrackingFlag}s currently enabled on the client connection + */ + public Set getFlags() { + return Collections.unmodifiableSet(flags); + } + + /** + * @return the client ID used for notification redirection, -1 when none + */ + public long getRedirect() { + return redirect; + } + + /** + * @return a {@link List} of key prefixes for which notifications are sent to the client + */ + public List getPrefixes() { + return Collections.unmodifiableList(prefixes); + } + + @Override + public String toString() { + return "TrackingInfo{" + "flags=" + flags + ", redirect=" + redirect + ", prefixes=" + prefixes + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + TrackingInfo that = (TrackingInfo) o; + return redirect == that.redirect && Objects.equals(flags, that.flags) && Objects.equals(prefixes, that.prefixes); + } + + @Override + public int hashCode() { + return Objects.hash(flags, redirect, prefixes); + } + + /** + * CLIENT TRACKINGINFO flags + * + * @see CLIENT TRACKINGINFO + */ + public enum TrackingFlag { + + /** + * The connection isn't using server assisted client side caching. + */ + OFF, + /** + * Server assisted client side caching is enabled for the connection. + */ + ON, + /** + * The client uses broadcasting mode. + */ + BCAST, + /** + * The client does not cache keys by default. + */ + OPTIN, + /** + * The client caches keys by default. + */ + OPTOUT, + /** + * The next command will cache keys (exists only together with optin). + */ + CACHING_YES, + /** + * The next command won't cache keys (exists only together with optout). + */ + CACHING_NO, + /** + * The client isn't notified about keys modified by itself. + */ + NOLOOP, + /** + * The client ID used for redirection isn't valid anymore. + */ + BROKEN_REDIRECT; + + /** + * Convert a given {@link String} flag to the corresponding {@link TrackingFlag} + * + * @param flag a {@link String} representation of the flag + * @return the resulting {@link TrackingFlag} or {@link IllegalArgumentException} if unrecognized + */ + public static TrackingFlag from(String flag) { + switch (flag.toLowerCase()) { + case "off": + return OFF; + case "on": + return ON; + case "bcast": + return BCAST; + case "optin": + return OPTIN; + case "optout": + return OPTOUT; + case "caching-yes": + return CACHING_YES; + case "caching-no": + return CACHING_NO; + case "noloop": + return NOLOOP; + case "broken_redirect": + return BROKEN_REDIRECT; + default: + throw new RuntimeException("Unsupported flag: " + flag); + } + } + + } + +} diff --git a/src/main/java/io/lettuce/core/api/async/RedisServerAsyncCommands.java b/src/main/java/io/lettuce/core/api/async/RedisServerAsyncCommands.java index 86714aab9a..a4245c2d54 100644 --- a/src/main/java/io/lettuce/core/api/async/RedisServerAsyncCommands.java +++ b/src/main/java/io/lettuce/core/api/async/RedisServerAsyncCommands.java @@ -30,6 +30,7 @@ import io.lettuce.core.ShutdownArgs; import io.lettuce.core.TrackingArgs; import io.lettuce.core.UnblockType; +import io.lettuce.core.TrackingInfo; import io.lettuce.core.protocol.CommandType; /** @@ -177,6 +178,14 @@ public interface RedisServerAsyncCommands { */ RedisFuture clientTracking(TrackingArgs args); + /** + * Returns information about the current client connection's use of the server assisted client side caching feature. + * + * @return {@link TrackingInfo}, for more information check the documentation + * @since 6.5 + */ + RedisFuture clientTrackinginfo(); + /** * Unblock the specified blocked client. * diff --git a/src/main/java/io/lettuce/core/api/reactive/RedisServerReactiveCommands.java b/src/main/java/io/lettuce/core/api/reactive/RedisServerReactiveCommands.java index 3e2d0addcc..e8f9c070fd 100644 --- a/src/main/java/io/lettuce/core/api/reactive/RedisServerReactiveCommands.java +++ b/src/main/java/io/lettuce/core/api/reactive/RedisServerReactiveCommands.java @@ -28,6 +28,7 @@ import io.lettuce.core.ShutdownArgs; import io.lettuce.core.TrackingArgs; import io.lettuce.core.UnblockType; +import io.lettuce.core.TrackingInfo; import io.lettuce.core.protocol.CommandType; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -177,6 +178,14 @@ public interface RedisServerReactiveCommands { */ Mono clientTracking(TrackingArgs args); + /** + * Returns information about the current client connection's use of the server assisted client side caching feature. + * + * @return {@link TrackingInfo}, for more information check the documentation + * @since 6.5 + */ + Mono clientTrackinginfo(); + /** * Unblock the specified blocked client. * diff --git a/src/main/java/io/lettuce/core/api/sync/RedisServerCommands.java b/src/main/java/io/lettuce/core/api/sync/RedisServerCommands.java index 7454ce2e6a..28b0530448 100644 --- a/src/main/java/io/lettuce/core/api/sync/RedisServerCommands.java +++ b/src/main/java/io/lettuce/core/api/sync/RedisServerCommands.java @@ -28,6 +28,7 @@ import io.lettuce.core.KillArgs; import io.lettuce.core.ShutdownArgs; import io.lettuce.core.TrackingArgs; +import io.lettuce.core.TrackingInfo; import io.lettuce.core.UnblockType; import io.lettuce.core.protocol.CommandType; @@ -176,6 +177,14 @@ public interface RedisServerCommands { */ String clientTracking(TrackingArgs args); + /** + * Returns information about the current client connection's use of the server assisted client side caching feature. + * + * @return {@link TrackingInfo}, for more information check the documentation + * @since 6.5 + */ + TrackingInfo clientTrackinginfo(); + /** * Unblock the specified blocked client. * diff --git a/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionServerAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionServerAsyncCommands.java index 42b308d470..f3b7b877c0 100644 --- a/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionServerAsyncCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/async/NodeSelectionServerAsyncCommands.java @@ -19,15 +19,15 @@ */ package io.lettuce.core.cluster.api.async; -import java.util.Date; -import java.util.List; import java.util.Map; - +import java.util.List; +import java.util.Date; import io.lettuce.core.ClientListArgs; import io.lettuce.core.FlushMode; import io.lettuce.core.KillArgs; import io.lettuce.core.TrackingArgs; import io.lettuce.core.UnblockType; +import io.lettuce.core.TrackingInfo; import io.lettuce.core.protocol.CommandType; /** @@ -175,6 +175,14 @@ public interface NodeSelectionServerAsyncCommands { */ AsyncExecutions clientTracking(TrackingArgs args); + /** + * Returns information about the current client connection's use of the server assisted client side caching feature. + * + * @return {@link TrackingInfo}, for more information check the documentation + * @since 6.5 + */ + AsyncExecutions clientTrackinginfo(); + /** * Unblock the specified blocked client. * diff --git a/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionServerCommands.java b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionServerCommands.java index b8179a7d0f..bd6b2e13d3 100644 --- a/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionServerCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/sync/NodeSelectionServerCommands.java @@ -28,6 +28,7 @@ import io.lettuce.core.KillArgs; import io.lettuce.core.TrackingArgs; import io.lettuce.core.UnblockType; +import io.lettuce.core.TrackingInfo; import io.lettuce.core.protocol.CommandType; /** @@ -163,7 +164,7 @@ public interface NodeSelectionServerCommands { * @return simple-string-reply {@code OK} if the connection name was successfully set. * @since 6.3 */ - Executions clientSetinfo(String key, V value); + Executions clientSetinfo(String key, String value); /** * Enables the tracking feature of the Redis server, that is used for server assisted client side caching. Tracking messages @@ -175,6 +176,14 @@ public interface NodeSelectionServerCommands { */ Executions clientTracking(TrackingArgs args); + /** + * Returns information about the current client connection's use of the server assisted client side caching feature. + * + * @return {@link TrackingInfo}, for more information check the documentation + * @since 6.5 + */ + Executions clientTrackinginfo(); + /** * Unblock the specified blocked client. * diff --git a/src/main/java/io/lettuce/core/output/ArrayComplexData.java b/src/main/java/io/lettuce/core/output/ArrayComplexData.java new file mode 100644 index 0000000000..d53b21a555 --- /dev/null +++ b/src/main/java/io/lettuce/core/output/ArrayComplexData.java @@ -0,0 +1,80 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.output; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * An implementation of the {@link ComplexData} that handles arrays. + *

+ * For RESP2 calling the {@link ComplexData#getDynamicMap()} would heuristically go over the list of elements assuming every odd + * element is a key and every even object is the value and then adding them to an {@link Map}. The logic would follow the same + * order that was used when the elements were added to the {@link ArrayComplexData}. Similarly calling the + * {@link ComplexData#getDynamicSet()} would return a set of all the elements, adding them in the same order. If - for some + * reason - duplicate elements exist they would be overwritten. + *

+ * All data structures that the implementation returns are unmodifiable + * + * @see ComplexData + * @author Tihomir Mateev + * @since 6.5 + */ +class ArrayComplexData extends ComplexData { + + private final List data; + + public ArrayComplexData(int count) { + data = new ArrayList<>(count); + } + + @Override + public void storeObject(Object value) { + data.add(value); + } + + @Override + public List getDynamicList() { + return Collections.unmodifiableList(data); + } + + @Override + public Set getDynamicSet() { + // RESP2 compatibility mode - assuming the caller is aware that the array really contains a set (because in RESP2 we + // lack support for this data type) we make the conversion here + Set set = new LinkedHashSet<>(data); + return Collections.unmodifiableSet(set); + } + + @Override + public Map getDynamicMap() { + // RESP2 compatibility mode - assuming the caller is aware that the array really contains a map (because in RESP2 we + // lack support for this data type) we make the conversion here + Map map = new LinkedHashMap<>(); + final Boolean[] isKey = { true }; + final Object[] key = new Object[1]; + + data.forEach(element -> { + if (isKey[0]) { + key[0] = element; + isKey[0] = false; + } else { + map.put(key[0], element); + isKey[0] = true; + } + }); + + return Collections.unmodifiableMap(map); + } + +} diff --git a/src/main/java/io/lettuce/core/output/ComplexData.java b/src/main/java/io/lettuce/core/output/ComplexData.java new file mode 100644 index 0000000000..08ef81a20f --- /dev/null +++ b/src/main/java/io/lettuce/core/output/ComplexData.java @@ -0,0 +1,118 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.output; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The base type of all complex data, collected by a {@link ComplexOutput} + *

+ * Commands typically result in simple types, however some of the commands could return complex nested structures. In these + * cases, and with the help of a {@link ComplexDataParser}, the data gathered by the {@link ComplexOutput} could be parsed to a + * domain object. + *

+ * An complex data object could only be an aggregate data type as per the + * RESP2 and + * RESP3 protocol definitions. Its + * contents, however, could be both the simple and aggregate data types. + *

+ * For RESP2 the only possible aggregation is an array. RESP2 commands could also return sets (obviously, by simply making sure + * the elements of the array are unique) or maps (by sending the keys as odd elements and their values as even elements in the + * right order one after another. + *

+ * For RESP3 all the three aggregate types are supported (and indicated with special characters when the result is returned by + * the server). + *

+ * Aggregate data types could also be nested by using the {@link ComplexData#storeObject(Object)} call. + *

+ * Implementations of this class override the {@link ComplexData#getDynamicSet()}, {@link ComplexData#getDynamicList()} and + * {@link ComplexData#getDynamicMap()} methods to return the data received in the server in a implementation of the Collections + * framework. If a given implementation could not do the conversion in a meaningful way an {@link UnsupportedOperationException} + * would be thrown. + * + * @see ComplexOutput + * @see ArrayComplexData + * @see SetComplexData + * @see MapComplexData + * @author Tihomir Mateev + * @since 6.5 + */ +public abstract class ComplexData { + + /** + * Store a long value in the underlying data structure + * + * @param value the value to store + */ + public void store(long value) { + storeObject(value); + } + + /** + * Store a double value in the underlying data structure + * + * @param value the value to store + */ + public void store(double value) { + storeObject(value); + } + + /** + * Store a boolean value in the underlying data structure + * + * @param value the value to store + */ + public void store(boolean value) { + storeObject(value); + } + + /** + * Store an {@link Object} value in the underlying data structure. This method should be used when nesting one instance of + * {@link ComplexData} inside another + * + * @param value the value to store + */ + public abstract void storeObject(Object value); + + public void store(String value) { + storeObject(value); + } + + /** + * Returns the aggregate data in a {@link List} form. If the underlying implementation could not convert the data structure + * to a {@link List} then an {@link UnsupportedOperationException} would be thrown. + * + * @return a {@link List} of {@link Object} values + */ + public List getDynamicList() { + throw new UnsupportedOperationException("The type of data stored in this dynamic object is not a list"); + } + + /** + * Returns the aggregate data in a {@link Set} form. If the underlying implementation could not convert the data structure + * to a {@link Set} then an {@link UnsupportedOperationException} would be thrown. + * + * @return a {@link Set} of {@link Object} values + */ + public Set getDynamicSet() { + throw new UnsupportedOperationException("The type of data stored in this dynamic object is not a set"); + } + + /** + * Returns the aggregate data in a {@link Map} form. If the underlying implementation could not convert the data structure + * to a {@link Map} then an {@link UnsupportedOperationException} would be thrown. + * + * @return a {@link Map} of {@link Object} values + */ + public Map getDynamicMap() { + throw new UnsupportedOperationException("The type of data stored in this dynamic object is not a map"); + } + +} diff --git a/src/main/java/io/lettuce/core/output/ComplexDataParser.java b/src/main/java/io/lettuce/core/output/ComplexDataParser.java new file mode 100644 index 0000000000..332fb61a4b --- /dev/null +++ b/src/main/java/io/lettuce/core/output/ComplexDataParser.java @@ -0,0 +1,31 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.output; + +/** + * Any usage of the {@link ComplexOutput} comes hand in hand with a respective {@link ComplexDataParser} that is able to parse + * the data extracted from the server to a meaningful Java object. + * + * @param the type of the parsed object + * @author Tihomir Mateev + * @see ComplexData + * @see ComplexOutput + * @since 6.5 + */ +public interface ComplexDataParser { + + /** + * Parse the data extracted from the server to a specific domain object. + * + * @param data the data to parse + * @return the parsed object + * @since 6.5 + */ + T parse(ComplexData data); + +} diff --git a/src/main/java/io/lettuce/core/output/ComplexOutput.java b/src/main/java/io/lettuce/core/output/ComplexOutput.java new file mode 100644 index 0000000000..86ab339bdb --- /dev/null +++ b/src/main/java/io/lettuce/core/output/ComplexOutput.java @@ -0,0 +1,142 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.output; + +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.internal.LettuceFactories; + +import java.nio.ByteBuffer; +import java.util.Deque; + +/** + * An implementation of the {@link CommandOutput} that is used in combination with a given {@link ComplexDataParser} to produce + * a domain object from the data extracted from the server. Since there already are implementations of the {@link CommandOutput} + * interface for most simple types, this implementation is better suited to parse complex, often nested, data structures, for + * example a map containing other maps, arrays or sets as values for one or more of its keys. + *

+ * The implementation of the {@link ComplexDataParser} is responsible for mapping the data from the result to meaningful + * properties that the user of the LEttuce driver could then use in a statically typed manner. + * + * @see ComplexDataParser + * @author Tihomir Mateev + * @since 6.5 + */ +public class ComplexOutput extends CommandOutput { + + private final Deque dataStack; + + private final ComplexDataParser parser; + + private ComplexData data; + + /** + * Constructs a new instance of the {@link ComplexOutput} + * + * @param codec the {@link RedisCodec} to be applied + */ + public ComplexOutput(RedisCodec codec, ComplexDataParser parser) { + super(codec, null); + dataStack = LettuceFactories.newSpScQueue(); + this.parser = parser; + } + + @Override + public T get() { + return parser.parse(data); + } + + @Override + public void set(long integer) { + if (data == null) { + throw new RuntimeException("Invalid output received for dynamic aggregate output." + + "Integer value should have been preceded by some sort of aggregation."); + } + + data.store(integer); + } + + @Override + public void set(double number) { + if (data == null) { + throw new RuntimeException("Invalid output received for dynamic aggregate output." + + "Double value should have been preceded by some sort of aggregation."); + } + + data.store(number); + } + + @Override + public void set(boolean value) { + if (data == null) { + throw new RuntimeException("Invalid output received for dynamic aggregate output." + + "Double value should have been preceded by some sort of aggregation."); + } + + data.store(value); + } + + @Override + public void set(ByteBuffer bytes) { + if (data == null) { + throw new RuntimeException("Invalid output received for dynamic aggregate output." + + "ByteBuffer value should have been preceded by some sort of aggregation."); + } + + data.storeObject(bytes == null ? null : codec.decodeValue(bytes)); + } + + @Override + public void setSingle(ByteBuffer bytes) { + if (data == null) { + throw new RuntimeException("Invalid output received for dynamic aggregate output." + + "String value should have been preceded by some sort of aggregation."); + } + + data.store(bytes == null ? null : StringCodec.UTF8.decodeValue(bytes)); + } + + @Override + public void complete(int depth) { + if (!dataStack.isEmpty() && depth == dataStack.size()) { + data = dataStack.pop(); + } + } + + private void multi(ComplexData newData) { + // if there is no data set, then we are at the root object + if (data == null) { + data = newData; + return; + } + + // otherwise we need to nest the provided structure + data.storeObject(newData); + dataStack.push(data); + data = newData; + } + + @Override + public void multiSet(int count) { + SetComplexData dynamicData = new SetComplexData(count); + multi(dynamicData); + } + + @Override + public void multiArray(int count) { + ArrayComplexData dynamicData = new ArrayComplexData(count); + multi(dynamicData); + } + + @Override + public void multiMap(int count) { + MapComplexData dynamicData = new MapComplexData(count); + multi(dynamicData); + } + +} diff --git a/src/main/java/io/lettuce/core/output/MapComplexData.java b/src/main/java/io/lettuce/core/output/MapComplexData.java new file mode 100644 index 0000000000..f2f2b29a70 --- /dev/null +++ b/src/main/java/io/lettuce/core/output/MapComplexData.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.output; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * An implementation of the {@link ComplexData} that handles maps. + *

+ * All data structures that the implementation returns are unmodifiable + * + * @see ComplexData + * @author Tihomir Mateev + * @since 6.5 + */ +class MapComplexData extends ComplexData { + + private final Map data; + + private Object key; + + public MapComplexData(int count) { + data = new HashMap<>(count); + } + + @Override + public void storeObject(Object value) { + if (key == null) { + key = value; + } else { + data.put(key, value); + key = null; + } + } + + @Override + public Map getDynamicMap() { + return Collections.unmodifiableMap(data); + } + +} diff --git a/src/main/java/io/lettuce/core/output/SetComplexData.java b/src/main/java/io/lettuce/core/output/SetComplexData.java new file mode 100644 index 0000000000..0d95afdd45 --- /dev/null +++ b/src/main/java/io/lettuce/core/output/SetComplexData.java @@ -0,0 +1,50 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.output; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * An implementation of the {@link ComplexData} that handles maps. + *

+ * All data structures that the implementation returns are unmodifiable + * + * @see ComplexData + * @author Tihomir Mateev + * @since 6.5 + */ +public class SetComplexData extends ComplexData { + + private final Set data; + + public SetComplexData(int count) { + data = new HashSet<>(count); + } + + @Override + public void storeObject(Object value) { + data.add(value); + } + + @Override + public Set getDynamicSet() { + return Collections.unmodifiableSet(data); + } + + @Override + public List getDynamicList() { + List list = new ArrayList<>(data.size()); + list.addAll(data); + return Collections.unmodifiableList(list); + } + +} diff --git a/src/main/java/io/lettuce/core/output/TrackingInfoParser.java b/src/main/java/io/lettuce/core/output/TrackingInfoParser.java new file mode 100644 index 0000000000..4239692dc8 --- /dev/null +++ b/src/main/java/io/lettuce/core/output/TrackingInfoParser.java @@ -0,0 +1,83 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.output; + +import io.lettuce.core.TrackingInfo; +import io.lettuce.core.protocol.CommandKeyword; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Parser for Redis CLIENT TRACKINGINFO command output. + * + * @author Tihomir Mateev + * @since 6.5 + */ +public class TrackingInfoParser implements ComplexDataParser { + + public static final TrackingInfoParser INSTANCE = new TrackingInfoParser(); + + /** + * Utility constructor. + */ + private TrackingInfoParser() { + } + + /** + * Parse the output of the Redis CLIENT TRACKINGINFO command and convert it to a {@link TrackingInfo} + * + * @param dynamicData output of CLIENT TRACKINGINFO command + * @return an {@link TrackingInfo} instance + */ + public TrackingInfo parse(ComplexData dynamicData) { + Map data = verifyStructure(dynamicData); + Set flags = ((ComplexData) data.get(CommandKeyword.FLAGS.toString().toLowerCase())).getDynamicSet(); + Long clientId = (Long) data.get(CommandKeyword.REDIRECT.toString().toLowerCase()); + List prefixes = ((ComplexData) data.get(CommandKeyword.PREFIXES.toString().toLowerCase())).getDynamicList(); + + Set parsedFlags = new HashSet<>(); + List parsedPrefixes = new ArrayList<>(); + + for (Object flag : flags) { + String toParse = (String) flag; + parsedFlags.add(TrackingInfo.TrackingFlag.from(toParse)); + } + + for (Object prefix : prefixes) { + parsedPrefixes.add((String) prefix); + } + + return new TrackingInfo(parsedFlags, clientId, parsedPrefixes); + } + + private Map verifyStructure(ComplexData trackinginfoOutput) { + + if (trackinginfoOutput == null) { + throw new IllegalArgumentException("Failed while parsing CLIENT TRACKINGINFO: trackinginfoOutput must not be null"); + } + + Map data = trackinginfoOutput.getDynamicMap(); + if (data == null || data.isEmpty()) { + throw new IllegalArgumentException("Failed while parsing CLIENT TRACKINGINFO: data must not be null or empty"); + } + + if (!data.containsKey(CommandKeyword.FLAGS.toString().toLowerCase()) + || !data.containsKey(CommandKeyword.REDIRECT.toString().toLowerCase()) + || !data.containsKey(CommandKeyword.PREFIXES.toString().toLowerCase())) { + throw new IllegalArgumentException( + "Failed while parsing CLIENT TRACKINGINFO: trackinginfoOutput has missing flags"); + } + + return data; + } + +} diff --git a/src/main/java/io/lettuce/core/protocol/CommandKeyword.java b/src/main/java/io/lettuce/core/protocol/CommandKeyword.java index 6e1af5429f..a422ad1f1d 100644 --- a/src/main/java/io/lettuce/core/protocol/CommandKeyword.java +++ b/src/main/java/io/lettuce/core/protocol/CommandKeyword.java @@ -37,17 +37,17 @@ public enum CommandKeyword implements ProtocolKeyword { BY, BYLEX, BYSCORE, CACHING, CAT, CH, CHANNELS, COPY, COUNT, COUNTKEYSINSLOT, CONSUMERS, CREATE, DB, DELSLOTS, DELSLOTSRANGE, DELUSER, DESC, DRYRUN, SOFT, HARD, ENCODING, - FAILOVER, FORGET, FIELDS, FLUSH, FORCE, FREQ, FLUSHSLOTS, GENPASS, GETNAME, GETUSER, GETKEYSINSLOT, GETREDIR, GROUP, GROUPS, HTSTATS, ID, IDLE, INFO, + FAILOVER, FORGET, FIELDS, FLAGS, FLUSH, FORCE, FREQ, FLUSHSLOTS, GENPASS, GETNAME, GETUSER, GETKEYSINSLOT, GETREDIR, GROUP, GROUPS, HTSTATS, ID, IDLE, INFO, IDLETIME, JUSTID, KILL, KEYSLOT, LEFT, LEN, LIMIT, LIST, LOAD, LOG, MATCH, - MAX, MAXLEN, MEET, MIN, MINID, MOVED, NO, NOACK, NOCOMMANDS, NODE, NODES, NOMKSTREAM, NOPASS, NOSAVE, NOT, NOVALUES, NUMSUB, SHARDCHANNELS, SHARDNUMSUB, NUMPAT, NX, OFF, ON, ONE, OR, PAUSE, + MAX, MAXLEN, MEET, MIN, MINID, MOVED, NO, NOACK, NOCOMMANDS, NODE, NODES, NOMKSTREAM, NOPASS, NOSAVE, NOT, NOVALUES, NUMSUB, SHARDCHANNELS, SHARDNUMSUB, NUMPAT, NX, OFF, ON, ONE, OR, PAUSE, PREFIXES, - REFCOUNT, REMOVE, RELOAD, REPLACE, REPLICATE, REPLICAS, REV, RESET, RESETCHANNELS, RESETKEYS, RESETPASS, + REFCOUNT, REMOVE, RELOAD, REPLACE, REDIRECT, REPLICATE, REPLICAS, REV, RESET, RESETCHANNELS, RESETKEYS, RESETPASS, RESETSTAT, RESTART, RETRYCOUNT, REWRITE, RIGHT, SAVECONFIG, SDSLEN, SETINFO, SETNAME, SETSLOT, SHARDS, SLOTS, STABLE, - MIGRATING, IMPORTING, SAVE, SKIPME, SLAVES, STREAM, STORE, SUM, SEGFAULT, SETUSER, TAKEOVER, TRACKING, TYPE, UNBLOCK, USERS, USAGE, WEIGHTS, WHOAMI, + MIGRATING, IMPORTING, SAVE, SKIPME, SLAVES, STREAM, STORE, SUM, SEGFAULT, SETUSER, TAKEOVER, TRACKING, TRACKINGINFO, TYPE, UNBLOCK, USERS, USAGE, WEIGHTS, WHOAMI, WITHSCORE, WITHSCORES, WITHVALUES, XOR, XX, YES; diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisServerCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisServerCoroutinesCommands.kt index a0524be3d6..b5a92e53c4 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisServerCoroutinesCommands.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisServerCoroutinesCommands.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020-Present, Redis Ltd. and Contributors + * Copyright 2017-Present, Redis Ltd. and Contributors * All rights reserved. * * Licensed under the MIT License. @@ -21,6 +21,7 @@ package io.lettuce.core.api.coroutines import io.lettuce.core.* +import io.lettuce.core.TrackingInfo import io.lettuce.core.protocol.CommandType import java.util.* @@ -169,6 +170,14 @@ interface RedisServerCoroutinesCommands { */ suspend fun clientTracking(args: TrackingArgs): String? + /** + * Returns information about the current client connection's use of the server assisted client side caching feature. + * + * @return @link TrackingInfo}, for more information check the documentation + * @since 6.5 + */ + suspend fun clientTrackinginfo(): TrackingInfo? + /** * Unblock the specified blocked client. * diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisServerCoroutinesCommandsImpl.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisServerCoroutinesCommandsImpl.kt index 3427baa780..ae0ef25481 100644 --- a/src/main/kotlin/io/lettuce/core/api/coroutines/RedisServerCoroutinesCommandsImpl.kt +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RedisServerCoroutinesCommandsImpl.kt @@ -21,6 +21,7 @@ package io.lettuce.core.api.coroutines import io.lettuce.core.* +import io.lettuce.core.TrackingInfo import io.lettuce.core.api.reactive.RedisServerReactiveCommands import io.lettuce.core.protocol.CommandType import kotlinx.coroutines.flow.toList @@ -75,6 +76,8 @@ internal class RedisServerCoroutinesCommandsImpl(internal val override suspend fun clientTracking(args: TrackingArgs): String? = ops.clientTracking(args).awaitFirstOrNull() + override suspend fun clientTrackinginfo(): TrackingInfo? = ops.clientTrackinginfo().awaitFirstOrNull() + override suspend fun clientUnblock(id: Long, type: UnblockType): Long? = ops.clientUnblock(id, type).awaitFirstOrNull() override suspend fun command(): List = ops.command().asFlow().toList() diff --git a/src/main/templates/io/lettuce/core/api/RedisServerCommands.java b/src/main/templates/io/lettuce/core/api/RedisServerCommands.java index 6775494a61..486443173a 100644 --- a/src/main/templates/io/lettuce/core/api/RedisServerCommands.java +++ b/src/main/templates/io/lettuce/core/api/RedisServerCommands.java @@ -29,6 +29,7 @@ import io.lettuce.core.ShutdownArgs; import io.lettuce.core.TrackingArgs; import io.lettuce.core.UnblockType; +import io.lettuce.core.TrackingInfo; import io.lettuce.core.protocol.CommandType; /** @@ -175,6 +176,14 @@ public interface RedisServerCommands { */ String clientTracking(TrackingArgs args); + /** + * Returns information about the current client connection's use of the server assisted client side caching feature. + * + * @return {@link TrackingInfo}, for more information check the documentation + * @since 6.5 + */ + TrackingInfo clientTrackinginfo(); + /** * Unblock the specified blocked client. * diff --git a/src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java b/src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java index 1657e70293..f99393cc18 100644 --- a/src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java +++ b/src/test/java/io/lettuce/core/RedisCommandBuilderUnitTests.java @@ -146,6 +146,17 @@ void shouldCorrectlyConstructHpttl() { + "3\r\n" + "$7\r\n" + "hField1\r\n" + "$7\r\n" + "hField2\r\n" + "$7\r\n" + "hField3\r\n"); } + @Test + void shouldCorrectlyConstructClientTrackinginfo() { + + Command command = sut.clientTrackinginfo(); + ByteBuf buf = Unpooled.directBuffer(); + command.encode(buf); + + assertThat(buf.toString(StandardCharsets.UTF_8)) + .isEqualTo("*2\r\n" + "$6\r\n" + "CLIENT\r\n" + "$12\r\n" + "TRACKINGINFO\r\n"); + } + @Test void shouldCorrectlyConstructClusterMyshardid() { diff --git a/src/test/java/io/lettuce/core/commands/ServerCommandIntegrationTests.java b/src/test/java/io/lettuce/core/commands/ServerCommandIntegrationTests.java index a64414edf4..3fed145c32 100644 --- a/src/test/java/io/lettuce/core/commands/ServerCommandIntegrationTests.java +++ b/src/test/java/io/lettuce/core/commands/ServerCommandIntegrationTests.java @@ -19,9 +19,6 @@ */ package io.lettuce.core.commands; -import static org.assertj.core.api.Assertions.*; -import static org.junit.jupiter.api.Assumptions.*; - import java.util.Date; import java.util.HashMap; import java.util.List; @@ -57,6 +54,11 @@ import io.lettuce.test.condition.EnabledOnCommand; import io.lettuce.test.settings.TestSettings; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + /** * Integration tests for {@link io.lettuce.core.api.sync.RedisServerCommands}. * @@ -118,6 +120,30 @@ void clientCaching() { } } + @Test + void clientTrackinginfoDefaults() { + TrackingInfo info = redis.clientTrackinginfo(); + + assertThat(info.getFlags()).contains(TrackingInfo.TrackingFlag.OFF); + assertThat(info.getRedirect()).isEqualTo(-1L); + assertThat(info.getPrefixes()).isEmpty(); + } + + @Test + void clientTrackinginfo() { + try { + redis.clientTracking(TrackingArgs.Builder.enabled(true).bcast().prefixes("usr:", "grp:")); + TrackingInfo info = redis.clientTrackinginfo(); + + assertThat(info.getFlags()).contains(TrackingInfo.TrackingFlag.ON); + assertThat(info.getFlags()).contains(TrackingInfo.TrackingFlag.BCAST); + assertThat(info.getRedirect()).isEqualTo(0L); + assertThat(info.getPrefixes()).contains("usr:", "grp:"); + } finally { + redis.clientTracking(TrackingArgs.Builder.enabled(false)); + } + } + @Test void clientGetSetname() { assertThat(redis.clientGetname()).isNull(); @@ -180,7 +206,8 @@ void clientKillExtended() { } @Test - @EnabledOnCommand("XAUTOCLAIM") // Redis 6.2 + @EnabledOnCommand("XAUTOCLAIM") + // Redis 6.2 void clientKillUser() { RedisCommands connection2 = client.connect().sync(); redis.aclSetuser("test_kill", AclSetuserArgs.Builder.addPassword("password1").on().addCommand(CommandType.ACL)); @@ -218,7 +245,8 @@ void clientList() { } @Test - @EnabledOnCommand("WAITAOF") // Redis 7.2 + @EnabledOnCommand("WAITAOF") + // Redis 7.2 void clientListExtended() { Long clientId = redis.clientId(); @@ -229,7 +257,8 @@ void clientListExtended() { } @Test - @EnabledOnCommand("EVAL_RO") // Redis 7.0 + @EnabledOnCommand("EVAL_RO") + // Redis 7.0 void clientNoEvict() { assertThat(redis.clientNoEvict(true)).isEqualTo("OK"); assertThat(redis.clientNoEvict(false)).isEqualTo("OK"); @@ -359,7 +388,8 @@ void configGet() { } @Test - @EnabledOnCommand("EVAL_RO") // Redis 7.0 + @EnabledOnCommand("EVAL_RO") + // Redis 7.0 void configGetMultipleParameters() { assertThat(redis.configGet("maxmemory", "*max-*-entries*")).containsEntry("maxmemory", "0") .containsEntry("hash-max-listpack-entries", "512"); @@ -382,7 +412,8 @@ void configSet() { } @Test - @EnabledOnCommand("EVAL_RO") // Redis 7.0 + @EnabledOnCommand("EVAL_RO") + // Redis 7.0 void configSetMultipleParameters() { Map original = redis.configGet("maxmemory", "hash-max-listpack-entries"); Map config = new HashMap<>(); @@ -416,7 +447,8 @@ void flushall() { } @Test - @EnabledOnCommand("MEMORY") // Redis 4.0 + @EnabledOnCommand("MEMORY") + // Redis 4.0 void flushallAsync() { redis.set(key, value); assertThat(redis.flushallAsync()).isEqualTo("OK"); @@ -424,7 +456,8 @@ void flushallAsync() { } @Test - @EnabledOnCommand("XAUTOCLAIM") // Redis 6.2 + @EnabledOnCommand("XAUTOCLAIM") + // Redis 6.2 void flushallSync() { redis.set(key, value); assertThat(redis.flushall(FlushMode.SYNC)).isEqualTo("OK"); @@ -439,7 +472,8 @@ void flushdb() { } @Test - @EnabledOnCommand("MEMORY") // Redis 4.0 + @EnabledOnCommand("MEMORY") + // Redis 4.0 void flushdbAsync() { redis.set(key, value); redis.select(1); @@ -451,7 +485,8 @@ void flushdbAsync() { } @Test - @EnabledOnCommand("XAUTOCLAIM") // Redis 6.2 + @EnabledOnCommand("XAUTOCLAIM") + // Redis 6.2 void flushdbSync() { redis.set(key, value); assertThat(redis.flushdb(FlushMode.SYNC)).isEqualTo("OK"); @@ -575,19 +610,22 @@ void swapdb() { } @Test - @Disabled("Run me manually") // Redis 7.0 + @Disabled("Run me manually") + // Redis 7.0 void shutdown() { redis.shutdown(new ShutdownArgs().save(true).now()); } @Test - @EnabledOnCommand("WAITAOF") // Redis 7.2 + @EnabledOnCommand("WAITAOF") + // Redis 7.2 void clientInfo() { assertThat(redis.clientInfo().contains("addr=")).isTrue(); } @Test - @EnabledOnCommand("WAITAOF") // Redis 7.2 + @EnabledOnCommand("WAITAOF") + // Redis 7.2 void clientSetinfo() { redis.clientSetinfo("lib-name", "lettuce"); diff --git a/src/test/java/io/lettuce/core/output/TrackingInfoParserTest.java b/src/test/java/io/lettuce/core/output/TrackingInfoParserTest.java new file mode 100644 index 0000000000..9ac5945381 --- /dev/null +++ b/src/test/java/io/lettuce/core/output/TrackingInfoParserTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.output; + +import io.lettuce.core.TrackingInfo; +import io.lettuce.core.protocol.CommandKeyword; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TrackingInfoParserTest { + + @Test + void parseResp3() { + ComplexData flags = new SetComplexData(2); + flags.store(TrackingInfo.TrackingFlag.ON.toString()); + flags.store(TrackingInfo.TrackingFlag.OPTIN.toString()); + + ComplexData prefixes = new ArrayComplexData(0); + + ComplexData input = new MapComplexData(3); + input.store(CommandKeyword.FLAGS.toString().toLowerCase()); + input.storeObject(flags); + input.store(CommandKeyword.REDIRECT.toString().toLowerCase()); + input.store(0L); + input.store(CommandKeyword.PREFIXES.toString().toLowerCase()); + input.storeObject(prefixes); + + TrackingInfo info = TrackingInfoParser.INSTANCE.parse(input); + + assertThat(info.getFlags()).contains(TrackingInfo.TrackingFlag.ON, TrackingInfo.TrackingFlag.OPTIN); + assertThat(info.getRedirect()).isEqualTo(0L); + assertThat(info.getPrefixes()).isEmpty(); + } + + @Test + void parseFailEmpty() { + ComplexData input = new MapComplexData(0); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + TrackingInfo info = TrackingInfoParser.INSTANCE.parse(input); + }); + } + + @Test + void parseFailNumberOfElements() { + ComplexData flags = new SetComplexData(2); + flags.store(TrackingInfo.TrackingFlag.ON.toString()); + flags.store(TrackingInfo.TrackingFlag.OPTIN.toString()); + + ComplexData prefixes = new ArrayComplexData(0); + + ComplexData input = new MapComplexData(3); + input.store(CommandKeyword.FLAGS.toString().toLowerCase()); + input.storeObject(flags); + input.store(CommandKeyword.REDIRECT.toString().toLowerCase()); + input.store(-1L); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + TrackingInfo info = TrackingInfoParser.INSTANCE.parse(input); + }); + } + + @Test + void parseResp2Compatibility() { + ComplexData flags = new ArrayComplexData(2); + flags.store(TrackingInfo.TrackingFlag.ON.toString()); + flags.store(TrackingInfo.TrackingFlag.OPTIN.toString()); + + ComplexData prefixes = new ArrayComplexData(0); + + ComplexData input = new ArrayComplexData(3); + input.store(CommandKeyword.FLAGS.toString().toLowerCase()); + input.storeObject(flags); + input.store(CommandKeyword.REDIRECT.toString().toLowerCase()); + input.store(0L); + input.store(CommandKeyword.PREFIXES.toString().toLowerCase()); + input.storeObject(prefixes); + + TrackingInfo info = TrackingInfoParser.INSTANCE.parse(input); + + assertThat(info.getFlags()).contains(TrackingInfo.TrackingFlag.ON, TrackingInfo.TrackingFlag.OPTIN); + assertThat(info.getRedirect()).isEqualTo(0L); + assertThat(info.getPrefixes()).isEmpty(); + } + +}