diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactory.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactory.java index 092fa4d391..463f00652c 100644 --- a/src/main/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactory.java +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactory.java @@ -15,6 +15,7 @@ */ package org.springframework.data.redis.connection.jedis; +import redis.clients.jedis.ClientSetInfoConfig; import redis.clients.jedis.Connection; import redis.clients.jedis.DefaultJedisClientConfig; import redis.clients.jedis.HostAndPort; @@ -59,10 +60,12 @@ import org.springframework.data.redis.connection.RedisConfiguration.WithDatabaseIndex; import org.springframework.data.redis.connection.RedisConfiguration.WithPassword; import org.springframework.data.redis.connection.jedis.JedisClusterConnection.JedisClusterTopologyProvider; +import org.springframework.data.redis.util.RedisClientLibraryInfo; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; /** * Connection factory creating Jedis based connections. @@ -130,6 +133,13 @@ public class JedisConnectionFactory private RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration("localhost", Protocol.DEFAULT_PORT); + /** + * Upstream framework library suffixes (without the Spring Data Redis entry). + * Values are collected from calls to {@link #addUpstreamLibNameSuffix(String)} and combined + * into the final CLIENT SETINFO LIB-NAME when configuring the client. + */ + private @Nullable String upstreamLibNameSuffix; + /** * Lifecycle state of this factory. */ @@ -654,6 +664,32 @@ public void setConvertPipelineAndTxResults(boolean convertPipelineAndTxResults) this.convertPipelineAndTxResults = convertPipelineAndTxResults; } + /** + * Add a library name suffix used for CLIENT SETINFO. + * This method is primarily intended for upstream framework integrations (for example, + * Spring Session Data Redis or Spring Security) to contribute their identifiers to the + * CLIENT SETINFO library name chain. + *

+ * The given value should contain framework identifiers without the core driver name, + * for example {@code "spring-session-data-redis_v3.0.0"}. Multiple calls will + * accumulate values; the final CLIENT SETINFO suffix is assembled by appending the + * Spring Data Redis entry via {@link RedisClientLibraryInfo#getLibNameSuffix(String)}. + * + * @param libNameSuffix the additional library name suffix to add; can be {@code null}. + * @since 4.0 + */ + public void addUpstreamLibNameSuffix(@Nullable String libNameSuffix) { + if (!StringUtils.hasText(libNameSuffix)) { + return; + } + if (!StringUtils.hasText(this.upstreamLibNameSuffix)) { + this.upstreamLibNameSuffix = libNameSuffix; + } + else if (!this.upstreamLibNameSuffix.contains(libNameSuffix)) { + this.upstreamLibNameSuffix = this.upstreamLibNameSuffix + ";" + libNameSuffix; + } + } + /** * @return true when {@link RedisSentinelConfiguration} is present. * @since 1.4 @@ -688,6 +724,9 @@ private JedisClientConfig createClientConfig(int database, @Nullable String user builder.connectionTimeoutMillis(getConnectTimeout()); builder.socketTimeoutMillis(getReadTimeout()); + String suffix = RedisClientLibraryInfo.getLibNameSuffix(this.upstreamLibNameSuffix); + builder.clientSetInfoConfig(new ClientSetInfoConfig(suffix)); + builder.database(database); if (!ObjectUtils.isEmpty(username)) { diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java index f7a8d12a6f..1e0a58e131 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java @@ -65,6 +65,7 @@ import org.springframework.data.redis.connection.RedisConfiguration.ClusterConfiguration; import org.springframework.data.redis.connection.RedisConfiguration.WithDatabaseIndex; import org.springframework.data.redis.connection.RedisConfiguration.WithPassword; +import org.springframework.data.redis.util.RedisClientLibraryInfo; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; @@ -154,6 +155,13 @@ public class LettuceConnectionFactory implements RedisConnectionFactory, Reactiv private RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration("localhost", 6379); + /** + * Upstream framework library suffixes (without the Spring Data Redis entry). + * Values are collected from calls to {@link #addUpstreamLibNameSuffix(String)} and combined + * into the final CLIENT SETINFO LIB-NAME when configuring the client. + */ + private @Nullable String upstreamLibNameSuffix; + private @Nullable SharedConnection connection; private @Nullable SharedConnection reactiveConnection; @@ -637,6 +645,32 @@ public void setClientName(@Nullable String clientName) { this.getMutableConfiguration().setClientName(clientName); } + /** + * Add a library name suffix used for CLIENT SETINFO. + * This method is primarily intended for upstream framework integrations (for example, + * Spring Session Data Redis) to contribute their identifiers to the + * CLIENT SETINFO library name chain. + *

+ * The given value should contain framework identifiers without the core driver name, + * for example {@code "spring-session-data-redis_v3.0.0"}. Multiple calls will + * accumulate values; the final CLIENT SETINFO suffix is assembled by appending the + * Spring Data Redis entry via {@link RedisClientLibraryInfo#getLibNameSuffix(String)}. + * + * @param libNameSuffix the additional library name suffix to add; can be {@code null}. + * @since 4.0 + */ + public void addUpstreamLibNameSuffix(@Nullable String libNameSuffix) { + if (!StringUtils.hasText(libNameSuffix)) { + return; + } + if (!StringUtils.hasText(this.upstreamLibNameSuffix)) { + this.upstreamLibNameSuffix = libNameSuffix; + } + else if (!this.upstreamLibNameSuffix.contains(libNameSuffix)) { + this.upstreamLibNameSuffix = this.upstreamLibNameSuffix + ";" + libNameSuffix; + } + } + /** * Returns the native {@link AbstractRedisClient} used by this instance. The client is initialized as part of * {@link #afterPropertiesSet() the bean initialization lifecycle} and only available when this connection factory is @@ -1482,6 +1516,10 @@ private RedisURI createRedisURIAndApplySettings(String host, int port) { builder.withStartTls(clientConfiguration.isStartTls()); builder.withTimeout(clientConfiguration.getCommandTimeout()); + String libName = RedisClientLibraryInfo.getLibName(RedisClientLibraryInfo.DRIVER_LETTUCE, + this.upstreamLibNameSuffix); + builder.withLibraryName(libName); + return builder.build(); } diff --git a/src/main/java/org/springframework/data/redis/util/RedisClientLibraryInfo.java b/src/main/java/org/springframework/data/redis/util/RedisClientLibraryInfo.java new file mode 100644 index 0000000000..a6f91b15ea --- /dev/null +++ b/src/main/java/org/springframework/data/redis/util/RedisClientLibraryInfo.java @@ -0,0 +1,130 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.util; + +import org.jspecify.annotations.Nullable; + +import org.springframework.util.StringUtils; + +/** + * Utility class for building Spring Data Redis client library identification + * strings for Redis CLIENT SETINFO. + *

+ * Supports the Redis CLIENT SETINFO custom suffix pattern: + * {@code (?[ -~]+) v(?[\d\.]+)}. + * Multiple suffixes can be delimited with semicolons. The recommended format + * for individual suffixes is {@code _v}. + * + * @author Viktoriya Kutsarova + * @since 4.0 + */ +public final class RedisClientLibraryInfo { + + /** + * Lettuce driver name constant for CLIENT SETINFO. + */ + public static final String DRIVER_LETTUCE = "lettuce"; + + /** + * Spring Data Redis framework name constant for CLIENT SETINFO. + */ + public static final String FRAMEWORK_NAME = "spring-data-redis"; + + private static final String SUFFIX_DELIMITER = ";"; + + private static final String VERSION_SEPARATOR = "_v"; + + private static final String UNKNOWN_VERSION = "unknown"; + + /** + * Get the Spring Data Redis version from the package manifest. + * Returns "unknown" if the version cannot be determined (for example when + * running from an IDE or tests without a populated Implementation-Version). + * + * @return the Spring Data Redis version, or "unknown" if not available + */ + public static String getVersion() { + Package pkg = RedisClientLibraryInfo.class.getPackage(); + String version = (pkg != null ? pkg.getImplementationVersion() : null); + return (version != null ? version : UNKNOWN_VERSION); + } + +private RedisClientLibraryInfo() { +} + + /** + * Build a library name suffix for CLIENT SETINFO in the format: + * {@code spring-data-redis_v} + *

+ * Note: The underscore before 'v' follows the Redis CLIENT SETINFO pattern recommendation. + * + * @return the library name suffix + */ + public static String getLibNameSuffix() { + return FRAMEWORK_NAME + VERSION_SEPARATOR + getVersion(); + } + + /** + * Build a library name suffix with additional framework suffix(es) for CLIENT SETINFO. + * This allows multiple higher-level frameworks to identify themselves in a chain. + *

+ * The {@code additionalSuffix} parameter should already be formatted according to the pattern + * and can contain multiple frameworks separated by semicolons. + *

+ * Format: {@code ;spring-data-redis_v} + *

+ * Example with multiple frameworks: + *

+	 * String suffix = RedisClientInfo.getLibNameSuffix(
+	 *     "spring-security_v6.0.0;spring-session-data-redis_v3.0.0"
+	 * );
+	 * // Returns: "spring-security_v6.0.0;spring-session-data-redis_v3.0.0;spring-data-redis_v4.0.0"
+	 * 
+ * + * @param additionalSuffix pre-formatted suffix string containing one or more framework identifiers, + * already in the format "name_version" and separated by semicolons if multiple + * @return the combined library name suffix with all frameworks and Spring Data Redis info + */ + public static String getLibNameSuffix(@Nullable String additionalSuffix) { + if (!StringUtils.hasText(additionalSuffix)) { + return getLibNameSuffix(); + } + return additionalSuffix + SUFFIX_DELIMITER + getLibNameSuffix(); + } + + /** + * Build a complete library name for CLIENT SETINFO by wrapping the suffix with the core driver name. + * This allows multiple higher-level frameworks to identify themselves in a chain. + *

+ * Format: {@code (;spring-data-redis_v)} + *

+ * Example: + *

+	 * String libName = RedisClientInfo.getLibName("lettuce",
+	 *     "spring-security_v6.0.0;spring-session-data-redis_v3.0.0");
+	 * // Returns: "lettuce(spring-security_v6.0.0;spring-session-data-redis_v3.0.0;spring-data-redis_v4.0.0)"
+	 * 
+ * + * @param driverName the core Redis driver name (e.g., "lettuce", "jedis") + * @param additionalSuffix pre-formatted suffix string containing one or more framework identifiers, + * already in the format "name_version" and separated by semicolons if multiple + * @return the complete library name in the format "driverName(additionalSuffix;spring-data-redis_version)" + */ + public static String getLibName(String driverName, @Nullable String additionalSuffix) { + return driverName + "(" + getLibNameSuffix(additionalSuffix) + ")"; + } +} + diff --git a/src/test/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactoryIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactoryIntegrationTests.java index 0dbffd22ad..f86dc8ad6e 100644 --- a/src/test/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactoryIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactoryIntegrationTests.java @@ -18,6 +18,9 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import java.util.List; +import org.springframework.data.redis.core.types.RedisClientInfo; + import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -74,6 +77,64 @@ void connectionAppliesClientName() { assertThat(connection.getClientName()).isEqualTo("clientName"); } + @Test // CLIENT SETINFO + void clientListReportsJedisLibNameWithSpringDataSuffix() { + + factory = new JedisConnectionFactory( + new RedisStandaloneConfiguration(SettingsUtils.getHost(), SettingsUtils.getPort()), + JedisClientConfiguration.builder().clientName("clientName").build()); + factory.afterPropertiesSet(); + factory.start(); + + try (RedisConnection connection = factory.getConnection()) { + + List clients = connection.serverCommands().getClientList(); + + RedisClientInfo self = clients.stream() + .filter(info -> "clientName".equals(info.getName())) + .findFirst() + .orElseThrow(); + + String libName = self.get("lib-name"); + + assertThat(libName).isNotNull(); + assertThat(libName).contains("jedis("); + assertThat(libName).contains("spring-data-redis_v"); + } + } + + @Test // CLIENT SETINFO + void clientListReportsJedisLibNameWithUpstreamSuffix() { + + String upstreamLibNameSuffix = "spring-session-data-redis_v3.0.0"; + + factory = new JedisConnectionFactory( + new RedisStandaloneConfiguration(SettingsUtils.getHost(), SettingsUtils.getPort()), + JedisClientConfiguration.builder().clientName("clientName").build()); + factory.addUpstreamLibNameSuffix(upstreamLibNameSuffix); + factory.afterPropertiesSet(); + factory.start(); + + try (RedisConnection connection = factory.getConnection()) { + + List clients = connection.serverCommands().getClientList(); + + RedisClientInfo self = clients.stream() + .filter(info -> "clientName".equals(info.getName())) + .findFirst() + .orElseThrow(); + + String libName = self.get("lib-name"); + + assertThat(libName).isNotNull(); + assertThat(libName).contains("jedis("); + assertThat(libName).contains("spring-data-redis_v"); + assertThat(libName).contains(upstreamLibNameSuffix); + } + } + + + @Test // GH-2503 void startStopStartConnectionFactory() { diff --git a/src/test/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactoryUnitTests.java b/src/test/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactoryUnitTests.java index 5eadfea82d..4134dfa0f4 100644 --- a/src/test/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactoryUnitTests.java +++ b/src/test/java/org/springframework/data/redis/connection/jedis/JedisConnectionFactoryUnitTests.java @@ -403,6 +403,47 @@ void earlyStartupDoesNotStartConnectionFactory() { assertThat(ReflectionTestUtils.getField(connectionFactory, "pool")).isNull(); } + @Test // CLIENT SETINFO + void addUpstreamLibNameSuffixShouldIgnoreNullAndBlankValues() { + + JedisConnectionFactory connectionFactory = new JedisConnectionFactory(); + + connectionFactory.addUpstreamLibNameSuffix(null); + connectionFactory.addUpstreamLibNameSuffix(""); + connectionFactory.addUpstreamLibNameSuffix(" "); + + Object upstreamLibNameSuffix = ReflectionTestUtils.getField(connectionFactory, "upstreamLibNameSuffix"); + + assertThat(upstreamLibNameSuffix).isNull(); + } + + @Test // CLIENT SETINFO + void addUpstreamLibNameSuffixShouldAccumulateValuesInOrder() { + + JedisConnectionFactory connectionFactory = new JedisConnectionFactory(); + + connectionFactory.addUpstreamLibNameSuffix("spring-session-data-redis_v3.0.0"); + connectionFactory.addUpstreamLibNameSuffix("spring-security_v6.0.0"); + + Object upstreamLibNameSuffix = ReflectionTestUtils.getField(connectionFactory, "upstreamLibNameSuffix"); + + assertThat(upstreamLibNameSuffix).isEqualTo("spring-session-data-redis_v3.0.0;spring-security_v6.0.0"); + } + + @Test // CLIENT SETINFO + void addUpstreamLibNameSuffixShouldNotAddDuplicates() { + + JedisConnectionFactory connectionFactory = new JedisConnectionFactory(); + + connectionFactory.addUpstreamLibNameSuffix("spring-session-data-redis_v3.0.0"); + connectionFactory.addUpstreamLibNameSuffix("spring-session-data-redis_v3.0.0"); + + Object upstreamLibNameSuffix = ReflectionTestUtils.getField(connectionFactory, "upstreamLibNameSuffix"); + + assertThat(upstreamLibNameSuffix).isEqualTo("spring-session-data-redis_v3.0.0"); + } + + private JedisConnectionFactory initSpyedConnectionFactory(RedisSentinelConfiguration sentinelConfiguration, @Nullable JedisPoolConfig poolConfig) { diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryTests.java index 585131f095..c5df46fb9d 100644 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryTests.java +++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryTests.java @@ -521,6 +521,73 @@ void connectionAppliesClientName() { connection.close(); } + @Test // CLIENT SETINFO + void clientListReportsLettuceLibNameWithSpringDataSuffix() { + + LettuceClientConfiguration configuration = LettuceTestClientConfiguration.builder().clientName("clientName") + .build(); + + LettuceConnectionFactory factory = new LettuceConnectionFactory(new RedisStandaloneConfiguration(), configuration); + factory.setShareNativeConnection(false); + factory.start(); + + ConnectionFactoryTracker.add(factory); + + try (RedisConnection connection = factory.getConnection()) { + java.util.List clients = connection.serverCommands() + .getClientList(); + + org.springframework.data.redis.core.types.RedisClientInfo self = clients.stream() + .filter(info -> "clientName".equals(info.getName())) + .findFirst() + .orElseThrow(); + + String libName = self.get("lib-name"); + + assertThat(libName).isNotNull(); + assertThat(libName).contains("lettuce("); + assertThat(libName).contains("spring-data-redis_v"); + } finally { + factory.destroy(); + } + } + + @Test // CLIENT SETINFO + void clientListReportsLettuceLibNameWithUpstreamSuffix() { + + String upstreamLibNameSuffix = "spring-session-data-redis_v3.0.0"; + + LettuceClientConfiguration configuration = LettuceTestClientConfiguration.builder().clientName("clientName") + .build(); + + LettuceConnectionFactory factory = new LettuceConnectionFactory(new RedisStandaloneConfiguration(), configuration); + factory.setShareNativeConnection(false); + factory.addUpstreamLibNameSuffix(upstreamLibNameSuffix); + factory.start(); + + ConnectionFactoryTracker.add(factory); + + try (RedisConnection connection = factory.getConnection()) { + java.util.List clients = connection.serverCommands() + .getClientList(); + + org.springframework.data.redis.core.types.RedisClientInfo self = clients.stream() + .filter(info -> "clientName".equals(info.getName())) + .findFirst() + .orElseThrow(); + + String libName = self.get("lib-name"); + + assertThat(libName).isNotNull(); + assertThat(libName).contains("lettuce("); + assertThat(libName).contains("spring-data-redis_v"); + assertThat(libName).contains(upstreamLibNameSuffix); + } finally { + factory.destroy(); + } + } + + @Test // DATAREDIS-576 void getClientNameShouldEqualWithFactorySetting() { diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryUnitTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryUnitTests.java index 8fd3f0c747..924d09185d 100644 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryUnitTests.java +++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryUnitTests.java @@ -1288,6 +1288,43 @@ void earlyStartupDoesNotStartConnectionFactory() { assertThat(client).isNull(); } + + @Test // CLIENT SETINFO + void addUpstreamLibNameSuffixShouldIgnoreNullAndBlankValues() { + + LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(); + + connectionFactory.addUpstreamLibNameSuffix(null); + connectionFactory.addUpstreamLibNameSuffix(""); + connectionFactory.addUpstreamLibNameSuffix(" "); + + assertThat(getField(connectionFactory, "upstreamLibNameSuffix")).isNull(); + } + + @Test // CLIENT SETINFO + void addUpstreamLibNameSuffixShouldAccumulateValuesInOrder() { + + LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(); + + connectionFactory.addUpstreamLibNameSuffix("spring-session-data-redis_v3.0.0"); + connectionFactory.addUpstreamLibNameSuffix("spring-security_v6.0.0"); + + assertThat(getField(connectionFactory, "upstreamLibNameSuffix")) + .isEqualTo("spring-session-data-redis_v3.0.0;spring-security_v6.0.0"); + } + + @Test // CLIENT SETINFO + void addUpstreamLibNameSuffixShouldNotAddDuplicates() { + + LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(); + + connectionFactory.addUpstreamLibNameSuffix("spring-session-data-redis_v3.0.0"); + connectionFactory.addUpstreamLibNameSuffix("spring-session-data-redis_v3.0.0"); + + assertThat(getField(connectionFactory, "upstreamLibNameSuffix")) + .isEqualTo("spring-session-data-redis_v3.0.0"); + } + static class CustomRedisConfiguration implements RedisConfiguration, WithHostAndPort { private String hostName;