From 807bffe9aa6c27404f8f7e1cc74b1ffde500273a Mon Sep 17 00:00:00 2001 From: Sushant Pangeni Date: Tue, 5 Aug 2025 19:30:23 +0545 Subject: [PATCH] feat: add more Redis configuration options (#564) --- .../william278/husksync/config/Settings.java | 38 ++++++ .../husksync/redis/RedisManager.java | 120 +++++++++--------- 2 files changed, 100 insertions(+), 58 deletions(-) diff --git a/common/src/main/java/net/william278/husksync/config/Settings.java b/common/src/main/java/net/william278/husksync/config/Settings.java index 9ca3a45c..620b8f1c 100644 --- a/common/src/main/java/net/william278/husksync/config/Settings.java +++ b/common/src/main/java/net/william278/husksync/config/Settings.java @@ -174,7 +174,45 @@ public class Settings { private int database = 0; private String user = ""; private String password = ""; + + @Comment("Use SSL/TLS for encrypted connections.") private boolean useSsl = false; + + @Comment("Connection timeout in milliseconds.") + private int connectionTimeout = 2000; + + @Comment("Socket (read/write) timeout in milliseconds.") + private int socketTimeout = 2000; + + @Comment("Max number of connections in the pool.") + private int maxTotalConnections = 50; + + @Comment("Max number of idle connections in the pool.") + private int maxIdleConnections = 8; + + @Comment("Min number of idle connections in the pool.") + private int minIdleConnections = 2; + + @Comment("Enable health checks when borrowing connections from the pool.") + private boolean testOnBorrow = true; + + @Comment("Enable health checks when returning connections to the pool.") + private boolean testOnReturn = true; + + @Comment("Enable periodic idle connection health checks.") + private boolean testWhileIdle = true; + + @Comment("Min evictable idle time (ms) before a connection is eligible for eviction.") + private long minEvictableIdleTimeMillis = 60000; + + @Comment("Time (ms) between eviction runs.") + private long timeBetweenEvictionRunsMillis = 30000; + + @Comment("Number of retries for commands when connection fails.") + private int maxRetries = 3; + + @Comment("Base backoff time in ms for retries (exponential backoff multiplier).") + private int retryBackoffMillis = 200; } @Comment("Options for if you're using Redis sentinel. Don't modify this unless you know what you're doing!") diff --git a/common/src/main/java/net/william278/husksync/redis/RedisManager.java b/common/src/main/java/net/william278/husksync/redis/RedisManager.java index bfc64119..1e844e61 100644 --- a/common/src/main/java/net/william278/husksync/redis/RedisManager.java +++ b/common/src/main/java/net/william278/husksync/redis/RedisManager.java @@ -62,9 +62,11 @@ public class RedisManager extends JedisPubSub { /** * Initialize Redis connection pool */ + @Blocking public void initialize() throws IllegalStateException { final Settings.RedisSettings.RedisCredentials credentials = plugin.getSettings().getRedis().getCredentials(); + final String user = credentials.getUser(); final String password = credentials.getPassword(); final String host = credentials.getHost(); @@ -72,44 +74,50 @@ public class RedisManager extends JedisPubSub { final int database = credentials.getDatabase(); final boolean useSSL = credentials.isUseSsl(); - // Create the jedis pool + // Configure JedisPoolConfig final JedisPoolConfig config = new JedisPoolConfig(); - config.setMaxIdle(0); - config.setTestOnBorrow(true); - config.setTestOnReturn(true); + config.setMaxTotal(credentials.getMaxTotalConnections()); + config.setMaxIdle(credentials.getMaxIdleConnections()); + config.setMinIdle(credentials.getMinIdleConnections()); + config.setTestOnBorrow(credentials.isTestOnBorrow()); + config.setTestOnReturn(credentials.isTestOnReturn()); + config.setTestWhileIdle(credentials.isTestWhileIdle()); + config.setMinEvictableIdleTimeMillis(credentials.getMinEvictableIdleTimeMillis()); + config.setTimeBetweenEvictionRunsMillis(credentials.getTimeBetweenEvictionRunsMillis()); final Settings.RedisSettings.RedisSentinel sentinel = plugin.getSettings().getRedis().getSentinel(); Set redisSentinelNodes = new HashSet<>(sentinel.getNodes()); + if (redisSentinelNodes.isEmpty()) { + // Standalone Redis setup DefaultJedisClientConfig.Builder clientConfigBuilder = DefaultJedisClientConfig.builder() .ssl(useSSL) .database(database) - .timeoutMillis(0); - - if (!user.isEmpty()) { - clientConfigBuilder.user(user); - } - - if (!password.isEmpty()) { - clientConfigBuilder.password(password); - } + .timeoutMillis(credentials.getConnectionTimeout()) // connection and socket timeout combined + .user(user.isEmpty() ? null : user) + .password(password.isEmpty() ? null : password); this.jedisPool = new JedisPool(config, new HostAndPort(host, port), clientConfigBuilder.build()); } else { final String sentinelPassword = sentinel.getPassword(); - this.jedisPool = new JedisSentinelPool(sentinel.getMaster(), redisSentinelNodes, password.isEmpty() - ? null : password, sentinelPassword.isEmpty() ? null : sentinelPassword); + this.jedisPool = new JedisSentinelPool( + sentinel.getMaster(), + redisSentinelNodes, + config, + credentials.getConnectionTimeout(), + credentials.getSocketTimeout(), + password.isEmpty() ? null : password, + sentinelPassword.isEmpty() ? null : sentinelPassword, + database); } - // Ping the server to check the connection - try { - jedisPool.getResource().ping(); + try (var jedis = jedisPool.getResource()) { + jedis.ping(); } catch (JedisException e) { - throw new IllegalStateException("Failed to establish connection with Redis. " - + "Please check the supplied credentials in the config file", e); + throw new IllegalStateException("Failed to establish connection with Redis. " + + "Please check the supplied credentials in the config file", e); } - // Subscribe using a thread (rather than a task) enabled = true; new Thread(this::subscribe, "husksync:redis_subscriber").start(); } @@ -126,8 +134,7 @@ public class RedisManager extends JedisPubSub { this, Arrays.stream(RedisMessage.Type.values()) .map(type -> type.getMessageChannel(clusterId)) - .toArray(String[]::new) - ); + .toArray(String[]::new)); } catch (Throwable t) { // Thread was unlocked due error onThreadUnlock(t); @@ -175,20 +182,19 @@ public class RedisManager extends JedisPubSub { user -> { plugin.lockPlayer(user.getUuid()); try { - final DataSnapshot.Packed data = DataSnapshot.deserialize(plugin, redisMessage.getPayload()); + final DataSnapshot.Packed data = DataSnapshot.deserialize(plugin, + redisMessage.getPayload()); user.applySnapshot(data, DataSnapshot.UpdateCause.UPDATED); } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred updating user data from Redis", e); user.completeSync(false, DataSnapshot.UpdateCause.UPDATED, plugin); } - } - ); + }); case REQUEST_USER_DATA -> redisMessage.getTargetUser(plugin).ifPresent( user -> RedisMessage.create( UUID.fromString(new String(redisMessage.getPayload(), StandardCharsets.UTF_8)), - user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin) - ).dispatch(plugin, RedisMessage.Type.RETURN_USER_DATA) - ); + user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin)) + .dispatch(plugin, RedisMessage.Type.RETURN_USER_DATA)); case CHECK_IN_PETITION -> { if (!redisMessage.isTargetServer(plugin) || !plugin.getSettings().getSynchronization().isCheckinPetitions()) { @@ -199,7 +205,8 @@ public class RedisManager extends JedisPubSub { boolean online = plugin.getDisconnectingPlayers().contains(user.getUuid()) || plugin.getOnlineUser(user.getUuid()).isEmpty(); if (!online && !plugin.isLocked(user.getUuid())) { - plugin.debug("[%s] Received check-in petition for online/unlocked user, ignoring".formatted(user.getName())); + plugin.debug("[%s] Received check-in petition for online/unlocked user, ignoring" + .formatted(user.getName())); return; } plugin.getRedisManager().setUserCheckedOut(user, false); @@ -252,31 +259,30 @@ public class RedisManager extends JedisPubSub { redisMessage.dispatch(plugin, RedisMessage.Type.CHECK_IN_PETITION); } - public CompletableFuture> getOnlineUserData(@NotNull UUID requestId, @NotNull User user, - @NotNull DataSnapshot.SaveCause saveCause) { + public CompletableFuture> getOnlineUserData(@NotNull UUID requestId, + @NotNull User user, + @NotNull DataSnapshot.SaveCause saveCause) { return plugin.getOnlineUser(user.getUuid()) .map(online -> CompletableFuture.completedFuture( - Optional.of(online.createSnapshot(saveCause))) - ) + Optional.of(online.createSnapshot(saveCause)))) .orElse(this.getNetworkedUserData(requestId, user)); } // Request a user's dat x-server - private CompletableFuture> getNetworkedUserData(@NotNull UUID requestId, @NotNull User user) { + private CompletableFuture> getNetworkedUserData(@NotNull UUID requestId, + @NotNull User user) { final CompletableFuture> future = new CompletableFuture<>(); pendingRequests.put(requestId, future); plugin.runAsync(() -> { final RedisMessage redisMessage = RedisMessage.create( user.getUuid(), - requestId.toString().getBytes(StandardCharsets.UTF_8) - ); + requestId.toString().getBytes(StandardCharsets.UTF_8)); redisMessage.dispatch(plugin, RedisMessage.Type.REQUEST_USER_DATA); }); return future .orTimeout( plugin.getSettings().getSynchronization().getNetworkLatencyMilliseconds(), - TimeUnit.MILLISECONDS - ) + TimeUnit.MILLISECONDS) .exceptionally(throwable -> { pendingRequests.remove(requestId); return Optional.empty(); @@ -290,8 +296,7 @@ public class RedisManager extends JedisPubSub { jedis.setex( getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId), RedisKeyType.TTL_1_YEAR, - data.asBytes(plugin) - ); + data.asBytes(plugin)); plugin.debug(String.format("[%s] Set %s key on Redis", user.getName(), RedisKeyType.LATEST_SNAPSHOT)); } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred setting user data on Redis", e); @@ -302,8 +307,7 @@ public class RedisManager extends JedisPubSub { public void clearUserData(@NotNull User user) { try (Jedis jedis = jedisPool.getResource()) { jedis.del( - getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId) - ); + getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId)); plugin.debug(String.format("[%s] Cleared %s on Redis", user.getName(), RedisKeyType.LATEST_SNAPSHOT)); } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred clearing user data on Redis", e); @@ -317,8 +321,7 @@ public class RedisManager extends JedisPubSub { if (checkedOut) { jedis.set( key.getBytes(StandardCharsets.UTF_8), - plugin.getServerName().getBytes(StandardCharsets.UTF_8) - ); + plugin.getServerName().getBytes(StandardCharsets.UTF_8)); } else { if (jedis.del(key.getBytes(StandardCharsets.UTF_8)) == 0) { plugin.debug(String.format("[%s] %s key not set on Redis when attempting removal (%s)", @@ -382,8 +385,7 @@ public class RedisManager extends JedisPubSub { jedis.setex( getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId), RedisKeyType.TTL_10_SECONDS, - new byte[0] - ); + new byte[0]); plugin.debug(String.format("[%s] Set %s key to Redis", user.getName(), RedisKeyType.SERVER_SWITCH)); } catch (Throwable e) { @@ -395,7 +397,8 @@ public class RedisManager extends JedisPubSub { * Fetch a user's data from Redis and consume the key if found * * @param user The user to fetch data for - * @return The user's data, if it's present on the database. Otherwise, an empty optional. + * @return The user's data, if it's present on the database. Otherwise, an empty + * optional. */ @Blocking public Optional getUserData(@NotNull User user) { @@ -476,13 +479,11 @@ public class RedisManager extends JedisPubSub { jedis.setex( getMapIdKey(fromServer, fromId, toServer, clusterId), RedisKeyType.TTL_1_YEAR, - String.valueOf(toId).getBytes(StandardCharsets.UTF_8) - ); + String.valueOf(toId).getBytes(StandardCharsets.UTF_8)); jedis.setex( getReversedMapIdKey(toServer, toId, clusterId), RedisKeyType.TTL_1_YEAR, - String.format("%s:%s", fromServer, fromId).getBytes(StandardCharsets.UTF_8) - ); + String.format("%s:%s", fromServer, fromId).getBytes(StandardCharsets.UTF_8)); plugin.debug(String.format("Bound map %s:%s -> %s:%s on Redis", fromServer, fromId, toServer, toId)); } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred binding map ids on Redis", e); @@ -534,8 +535,7 @@ public class RedisManager extends JedisPubSub { jedis.setex( getMapDataKey(serverName, mapId, clusterId), RedisKeyType.TTL_1_YEAR, - data - ); + data); plugin.debug(String.format("Set map data %s:%s on Redis", serverName, mapId)); } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred setting map data on Redis", e); @@ -581,16 +581,20 @@ public class RedisManager extends JedisPubSub { return String.format("%s:%s", keyType.getKeyPrefix(clusterId), uuid); } - private static byte[] getMapIdKey(@NotNull String fromServer, int fromId, @NotNull String toServer, @NotNull String clusterId) { - return String.format("%s:%s:%s:%s", RedisKeyType.MAP_ID.getKeyPrefix(clusterId), fromServer, fromId, toServer).getBytes(StandardCharsets.UTF_8); + private static byte[] getMapIdKey(@NotNull String fromServer, int fromId, @NotNull String toServer, + @NotNull String clusterId) { + return String.format("%s:%s:%s:%s", RedisKeyType.MAP_ID.getKeyPrefix(clusterId), fromServer, fromId, toServer) + .getBytes(StandardCharsets.UTF_8); } private static byte[] getReversedMapIdKey(@NotNull String toServer, int toId, @NotNull String clusterId) { - return String.format("%s:%s:%s", RedisKeyType.MAP_ID_REVERSED.getKeyPrefix(clusterId), toServer, toId).getBytes(StandardCharsets.UTF_8); + return String.format("%s:%s:%s", RedisKeyType.MAP_ID_REVERSED.getKeyPrefix(clusterId), toServer, toId) + .getBytes(StandardCharsets.UTF_8); } private static byte[] getMapDataKey(@NotNull String serverName, int mapId, @NotNull String clusterId) { - return String.format("%s:%s:%s", RedisKeyType.MAP_DATA.getKeyPrefix(clusterId), serverName, mapId).getBytes(StandardCharsets.UTF_8); + return String.format("%s:%s:%s", RedisKeyType.MAP_DATA.getKeyPrefix(clusterId), serverName, mapId) + .getBytes(StandardCharsets.UTF_8); } }