Skip to content

Commit fe1776f

Browse files
bsboddenclaude
andcommitted
feat: add Redis Sentinel URL support (#213)
Implements redis+sentinel:// URL scheme for high availability Redis deployments with automatic failover via Sentinel. URL format: redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/database] Features: - URL parsing with SentinelConfig.fromUrl() - Support for multiple comma-separated Sentinel hosts - IPv6 address handling in bracket notation - Authentication (username/password) - Database selection - Default values (port 26379, service "mymaster") - Integration with RedisConnectionManager via JedisSentinelPool Implementation details: - SentinelConfig: Parse and store Sentinel connection parameters - RedisConnectionManager: Detect redis+sentinel:// URLs and create JedisSentinelPool instead of standard JedisPool - Pool<Jedis> interface allows transparent handling of both pool types Test coverage: - 11 unit tests for URL parsing (all edge cases covered) - Tests match Python implementation from PR #385 Python reference: redisvl/redis/connection.py - _parse_sentinel_url() Java files: - core/src/main/java/com/redis/vl/redis/SentinelConfig.java - core/src/main/java/com/redis/vl/redis/RedisConnectionManager.java:55-58 - core/src/test/java/com/redis/vl/redis/SentinelUrlParsingTest.java Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 379fa2b commit fe1776f

File tree

3 files changed

+448
-2
lines changed

3 files changed

+448
-2
lines changed

core/src/main/java/com/redis/vl/redis/RedisConnectionManager.java

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
import java.io.Closeable;
44
import java.net.URI;
5+
import java.util.Set;
56
import java.util.function.Function;
7+
import java.util.stream.Collectors;
68
import lombok.extern.slf4j.Slf4j;
79
import redis.clients.jedis.Jedis;
810
import redis.clients.jedis.JedisPool;
911
import redis.clients.jedis.JedisPoolConfig;
12+
import redis.clients.jedis.JedisSentinelPool;
13+
import redis.clients.jedis.util.Pool;
1014

1115
/** Manages Redis connections and provides connection pooling. */
1216
@Slf4j
1317
public class RedisConnectionManager implements Closeable {
1418

15-
private final JedisPool jedisPool;
19+
private final Pool<Jedis> jedisPool;
1620

1721
/**
1822
* Create a new connection manager with the given configuration.
@@ -24,13 +28,34 @@ public RedisConnectionManager(RedisConnectionConfig config) {
2428
log.info("Redis connection manager initialized");
2529
}
2630

31+
/**
32+
* Create a new connection manager with Sentinel configuration.
33+
*
34+
* @param config The Sentinel connection configuration
35+
*/
36+
public RedisConnectionManager(SentinelConfig config) {
37+
this.jedisPool = createJedisSentinelPool(config);
38+
log.info("Redis Sentinel connection manager initialized");
39+
}
40+
2741
/**
2842
* Create a connection manager from a URI.
2943
*
30-
* @param uri The Redis connection URI (e.g., redis://localhost:6379)
44+
* <p>Supports both standard Redis URLs and Sentinel URLs:
45+
*
46+
* <ul>
47+
* <li>redis://[username:password@]host:port[/database] - Standard Redis connection
48+
* <li>redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/database] -
49+
* Sentinel connection
50+
* </ul>
51+
*
52+
* @param uri The Redis connection URI
3153
* @return A new RedisConnectionManager instance
3254
*/
3355
public static RedisConnectionManager from(String uri) {
56+
if (uri != null && uri.startsWith("redis+sentinel://")) {
57+
return new RedisConnectionManager(SentinelConfig.fromUrl(uri));
58+
}
3459
return new RedisConnectionManager(RedisConnectionConfig.fromUri(uri));
3560
}
3661

@@ -72,6 +97,34 @@ private JedisPool createJedisPool(RedisConnectionConfig config) {
7297
}
7398
}
7499

100+
/** Create JedisSentinelPool from Sentinel configuration */
101+
private JedisSentinelPool createJedisSentinelPool(SentinelConfig config) {
102+
// Convert HostPort list to Set<String> in "host:port" format
103+
Set<String> sentinelHosts =
104+
config.getSentinelHosts().stream()
105+
.map(hp -> hp.getHost() + ":" + hp.getPort())
106+
.collect(Collectors.toSet());
107+
108+
// Create pool config with defaults
109+
JedisPoolConfig poolConfig = new JedisPoolConfig();
110+
poolConfig.setMaxTotal(10);
111+
poolConfig.setMaxIdle(5);
112+
poolConfig.setMinIdle(1);
113+
poolConfig.setTestOnBorrow(true);
114+
115+
// Create Sentinel pool
116+
return new JedisSentinelPool(
117+
config.getServiceName(),
118+
sentinelHosts,
119+
poolConfig,
120+
config.getConnectionTimeout(),
121+
config.getSocketTimeout(),
122+
config.getUsername(),
123+
config.getPassword(),
124+
config.getDatabase() != null ? config.getDatabase() : 0,
125+
null); // clientName
126+
}
127+
75128
/**
76129
* Check if the connection manager is connected.
77130
*
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package com.redis.vl.redis;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
8+
/**
9+
* Configuration for Redis Sentinel connections.
10+
*
11+
* <p>Supports the redis+sentinel:// URL scheme for high availability Redis deployments:
12+
* redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/db]
13+
*
14+
* <p>Python reference: redisvl/redis/connection.py - _parse_sentinel_url
15+
*/
16+
@Getter
17+
@Builder
18+
public class SentinelConfig {
19+
20+
/** List of Sentinel host:port pairs */
21+
private final List<HostPort> sentinelHosts;
22+
23+
/** Sentinel service/master name (default: "mymaster") */
24+
@Builder.Default private final String serviceName = "mymaster";
25+
26+
/** Redis database number (optional) */
27+
private final Integer database;
28+
29+
/** Username for authentication (optional) */
30+
private final String username;
31+
32+
/** Password for authentication (optional) */
33+
private final String password;
34+
35+
/** Connection timeout in milliseconds */
36+
@Builder.Default private final int connectionTimeout = 2000;
37+
38+
/** Socket timeout in milliseconds */
39+
@Builder.Default private final int socketTimeout = 2000;
40+
41+
/**
42+
* Parse a Sentinel URL into a SentinelConfig.
43+
*
44+
* <p>URL format: redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/db]
45+
*
46+
* @param url Sentinel URL to parse
47+
* @return Parsed SentinelConfig
48+
* @throws IllegalArgumentException if URL is invalid
49+
*/
50+
public static SentinelConfig fromUrl(String url) {
51+
if (url == null || !url.startsWith("redis+sentinel://")) {
52+
throw new IllegalArgumentException(
53+
"URL must start with redis+sentinel:// scheme. Got: " + url);
54+
}
55+
56+
try {
57+
// Remove scheme prefix
58+
String remaining = url.substring("redis+sentinel://".length());
59+
60+
// Extract username and password from userInfo (before @)
61+
String username = null;
62+
String password = null;
63+
String hostsString;
64+
65+
int atIndex = remaining.indexOf("@");
66+
if (atIndex > 0) {
67+
String userInfo = remaining.substring(0, atIndex);
68+
remaining = remaining.substring(atIndex + 1);
69+
70+
String[] userInfoParts = userInfo.split(":", 2);
71+
if (userInfoParts.length == 2) {
72+
username = userInfoParts[0].isEmpty() ? null : userInfoParts[0];
73+
password = userInfoParts[1].isEmpty() ? null : userInfoParts[1];
74+
} else if (userInfoParts.length == 1 && !userInfoParts[0].isEmpty()) {
75+
username = userInfoParts[0];
76+
}
77+
}
78+
79+
// Extract hosts (before first /)
80+
int slashIndex = remaining.indexOf("/");
81+
if (slashIndex > 0) {
82+
hostsString = remaining.substring(0, slashIndex);
83+
remaining = remaining.substring(slashIndex);
84+
} else if (slashIndex == 0) {
85+
// No hosts before slash
86+
throw new IllegalArgumentException(
87+
"Sentinel hosts cannot be empty. URL must contain at least one host:port pair.");
88+
} else {
89+
// No path - everything is hosts
90+
hostsString = remaining;
91+
remaining = "";
92+
}
93+
94+
if (hostsString.trim().isEmpty()) {
95+
throw new IllegalArgumentException(
96+
"Sentinel hosts cannot be empty. URL must contain at least one host:port pair.");
97+
}
98+
99+
// Parse sentinel hosts (comma-separated)
100+
List<HostPort> sentinelHosts = parseSentinelHosts(hostsString);
101+
102+
// Parse path for service name and database
103+
String serviceName = "mymaster"; // default
104+
Integer database = null;
105+
106+
if (!remaining.isEmpty() && !remaining.equals("/")) {
107+
// Remove leading slash
108+
String path = remaining.substring(1);
109+
String[] pathParts = path.split("/");
110+
111+
if (pathParts.length > 0 && !pathParts[0].isEmpty()) {
112+
serviceName = pathParts[0];
113+
}
114+
115+
if (pathParts.length > 1 && !pathParts[1].isEmpty()) {
116+
try {
117+
database = Integer.parseInt(pathParts[1]);
118+
} catch (NumberFormatException e) {
119+
throw new IllegalArgumentException("Invalid database number: " + pathParts[1], e);
120+
}
121+
}
122+
}
123+
124+
return SentinelConfig.builder()
125+
.sentinelHosts(sentinelHosts)
126+
.serviceName(serviceName)
127+
.database(database)
128+
.username(username)
129+
.password(password)
130+
.build();
131+
132+
} catch (IllegalArgumentException e) {
133+
throw e;
134+
} catch (Exception e) {
135+
throw new IllegalArgumentException("Failed to parse Sentinel URL: " + url, e);
136+
}
137+
}
138+
139+
/**
140+
* Parse comma-separated sentinel hosts into HostPort list.
141+
*
142+
* <p>Supports formats: - host:port - host (uses default port 26379) - [ipv6]:port - [ipv6] (uses
143+
* default port 26379)
144+
*
145+
* @param hostsString Comma-separated host:port pairs
146+
* @return List of HostPort objects
147+
*/
148+
private static List<HostPort> parseSentinelHosts(String hostsString) {
149+
List<HostPort> hosts = new ArrayList<>();
150+
String[] hostParts = hostsString.split(",");
151+
152+
for (String hostPart : hostParts) {
153+
hostPart = hostPart.trim();
154+
if (hostPart.isEmpty()) {
155+
continue;
156+
}
157+
158+
hosts.add(parseHostPort(hostPart));
159+
}
160+
161+
if (hosts.isEmpty()) {
162+
throw new IllegalArgumentException(
163+
"Sentinel hosts cannot be empty. URL must contain at least one host:port pair.");
164+
}
165+
166+
return hosts;
167+
}
168+
169+
/**
170+
* Parse a single host:port pair.
171+
*
172+
* <p>Handles IPv6 addresses in brackets: [::1]:26379
173+
*
174+
* @param hostPort Host and optional port
175+
* @return HostPort object
176+
*/
177+
private static HostPort parseHostPort(String hostPort) {
178+
String host;
179+
int port = 26379; // default Sentinel port
180+
181+
// Handle IPv6: [::1]:26379 or [::1]
182+
if (hostPort.startsWith("[")) {
183+
int closeBracket = hostPort.indexOf("]");
184+
if (closeBracket == -1) {
185+
throw new IllegalArgumentException("Invalid IPv6 address format: " + hostPort);
186+
}
187+
host = hostPort.substring(1, closeBracket);
188+
189+
// Check for port after bracket
190+
if (closeBracket + 1 < hostPort.length()) {
191+
if (hostPort.charAt(closeBracket + 1) == ':') {
192+
try {
193+
port = Integer.parseInt(hostPort.substring(closeBracket + 2));
194+
} catch (NumberFormatException e) {
195+
throw new IllegalArgumentException("Invalid port number in: " + hostPort, e);
196+
}
197+
}
198+
}
199+
} else {
200+
// Handle regular host:port or just host
201+
int colonIndex = hostPort.lastIndexOf(":");
202+
if (colonIndex > 0) {
203+
host = hostPort.substring(0, colonIndex);
204+
try {
205+
port = Integer.parseInt(hostPort.substring(colonIndex + 1));
206+
} catch (NumberFormatException e) {
207+
throw new IllegalArgumentException("Invalid port number in: " + hostPort, e);
208+
}
209+
} else {
210+
host = hostPort;
211+
}
212+
}
213+
214+
return new HostPort(host, port);
215+
}
216+
217+
/** Represents a host:port pair for Sentinel nodes */
218+
@Getter
219+
public static class HostPort {
220+
private final String host;
221+
private final int port;
222+
223+
public HostPort(String host, int port) {
224+
if (host == null || host.trim().isEmpty()) {
225+
throw new IllegalArgumentException("Host cannot be null or empty");
226+
}
227+
if (port <= 0 || port > 65535) {
228+
throw new IllegalArgumentException("Port must be between 1 and 65535, got: " + port);
229+
}
230+
this.host = host.trim();
231+
this.port = port;
232+
}
233+
}
234+
}

0 commit comments

Comments
 (0)