├── .java-version ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── src ├── main │ ├── resources │ │ └── META-INF │ │ │ └── services │ │ │ └── io.vertx.core.spi.VertxServiceProvider │ └── java │ │ └── com │ │ └── retailsvc │ │ └── vertx │ │ └── spi │ │ └── cluster │ │ └── redis │ │ ├── config │ │ ├── EvictionMode.java │ │ ├── package-info.java │ │ ├── ClientType.java │ │ ├── RedisConfigProps.java │ │ ├── LockConfig.java │ │ ├── KeyConfig.java │ │ ├── MapConfig.java │ │ └── RedisConfig.java │ │ ├── TopicSubscriber.java │ │ ├── impl │ │ ├── NodeInfoCatalogListener.java │ │ ├── codec │ │ │ ├── ClassLoaderCodec.java │ │ │ ├── BooleanCodec.java │ │ │ ├── NullCodec.java │ │ │ ├── ClusterSerializableCodec.java │ │ │ └── RedisMapCodec.java │ │ ├── RedisKeyFactory.java │ │ ├── CloseableLock.java │ │ ├── RedisTopic.java │ │ ├── shareddata │ │ │ ├── RedisLock.java │ │ │ ├── RedisCounter.java │ │ │ └── RedisAsyncMap.java │ │ ├── SemaphoreWrapper.java │ │ ├── NodeInfoCatalog.java │ │ ├── RedissonContext.java │ │ ├── Throttling.java │ │ ├── RedissonRedisInstance.java │ │ └── SubscriptionCatalog.java │ │ ├── RedisInstance.java │ │ ├── ClusterHealthCheck.java │ │ ├── Topic.java │ │ ├── RedisDataGrid.java │ │ └── RedisClusterManager.java └── test │ ├── test-class │ └── CustomObject.class │ ├── java │ └── com │ │ └── retailsvc │ │ └── vertx │ │ ├── spi │ │ └── cluster │ │ │ └── redis │ │ │ ├── RedisTestContainerFactory.java │ │ │ ├── impl │ │ │ ├── codec │ │ │ │ ├── BooleanCodecTest.java │ │ │ │ ├── CodecTestBase.java │ │ │ │ ├── NullCodecTest.java │ │ │ │ ├── CustomObjectClassLoader.java │ │ │ │ ├── RedisMapCodecTest.java │ │ │ │ └── ClusterSerializableCodecTest.java │ │ │ ├── CloseableLockTest.java │ │ │ ├── ITRedisDataGrid.java │ │ │ ├── RedisKeyFactoryTest.java │ │ │ ├── RedissonContextTest.java │ │ │ ├── ThrottlingTest.java │ │ │ ├── ITSubscriptionCatalog.java │ │ │ └── ITRedisInstance.java │ │ │ ├── RedisClusterManagerTestFactory.java │ │ │ ├── ITClusterHealthCheck.java │ │ │ └── config │ │ │ └── RedisConfigTest.java │ │ ├── core │ │ ├── ITRedisClusteredHA.java │ │ ├── ITRedisClusteredComplexHA.java │ │ ├── eventbus │ │ │ ├── ITRedisNodeInfo.java │ │ │ ├── ITRedisClusteredEventBus.java │ │ │ └── ITRedisFaultTolerance.java │ │ └── shareddata │ │ │ ├── ITRedisClusteredAsyncMap.java │ │ │ ├── ITRedisClusteredSharedCounter.java │ │ │ ├── ITRedisClusteredAsynchronousLock.java │ │ │ └── ITCustomClassLoaderAsyncMap.java │ │ ├── ext │ │ └── web │ │ │ └── sstore │ │ │ └── ITRedisClusteredSessionHandler.java │ │ └── servicediscovery │ │ └── impl │ │ └── ITRedisDiscoveryImplClustered.java │ └── resources │ └── logback-test.xml ├── .github ├── dependabot.yml └── workflows │ ├── pre-commit.yaml │ ├── commit-msg.yaml │ └── commit.yaml ├── .editorconfig ├── .pre-commit-config.yaml ├── .gitignore ├── mvnw.cmd ├── README.md ├── mvnw └── LICENSE /.java-version: -------------------------------------------------------------------------------- 1 | 21 2 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wjw465150/vertx-redis-clustermanager/master/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/io.vertx.core.spi.VertxServiceProvider: -------------------------------------------------------------------------------- 1 | com.retailsvc.vertx.spi.cluster.redis.RedisClusterManager 2 | -------------------------------------------------------------------------------- /src/test/test-class/CustomObject.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wjw465150/vertx-redis-clustermanager/master/src/test/test-class/CustomObject.class -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/config/EvictionMode.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.config; 2 | 3 | /** 4 | * Eviction mode. 5 | * 6 | * @author sasjo 7 | */ 8 | public enum EvictionMode { 9 | 10 | /** Least Recently Used eviction algorithm. */ 11 | LRU, 12 | 13 | /** Least Frequently Used eviction algorithm. */ 14 | LFU, 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/config/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration data structures for the {@link 3 | * com.retailsvc.vertx.spi.cluster.redis.RedisClusterManager}. 4 | */ 5 | @ModuleGen(name = "config", groupPackage = "com.retailsvc.vertx.spi.cluster.redis") 6 | package com.retailsvc.vertx.spi.cluster.redis.config; 7 | 8 | import io.vertx.codegen.annotations.ModuleGen; 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: maven 4 | directory: '/' 5 | schedule: 6 | interval: weekly 7 | groups: 8 | maven: 9 | patterns: 10 | - '*' 11 | - package-ecosystem: github-actions 12 | directory: '/' 13 | schedule: 14 | interval: weekly 15 | groups: 16 | workflows: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/TopicSubscriber.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis; 2 | 3 | /** 4 | * A subscriber for a {@link Topic}. 5 | * 6 | * @param the type of message 7 | * @author sasjo 8 | */ 9 | @FunctionalInterface 10 | public interface TopicSubscriber { 11 | 12 | /** 13 | * Invoked for each message posted to the topic. 14 | * 15 | * @param message the message 16 | */ 17 | void onMessage(T message); 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | insert_final_newline = true 10 | continuation_indent_size=4 11 | 12 | [*.java] 13 | indent_style=space 14 | indent_size=2 15 | 16 | [*.launch] 17 | indent_style=space 18 | indent_size=2 19 | 20 | [{*.yml,*.yaml}] 21 | indent_style=space 22 | indent_size=2 23 | 24 | [*.properties] 25 | indent_style=space 26 | indent_size=2 27 | trim_trailing_whitespace=false 28 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/RedisTestContainerFactory.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis; 2 | 3 | import org.testcontainers.containers.GenericContainer; 4 | import org.testcontainers.utility.DockerImageName; 5 | 6 | public class RedisTestContainerFactory { 7 | public static GenericContainer newContainer() { 8 | return new GenericContainer<>(DockerImageName.parse("redis:6-alpine")) 9 | .withCommand("redis-server", "--save", "''") 10 | .withExposedPorts(6379); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d [%thread] %-5level %logger{36} - %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [commit] 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v1.1.1 6 | hooks: 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: https://github.com/Lucas-C/pre-commit-hooks 10 | rev: v1.1.4 11 | hooks: 12 | - id: remove-crlf 13 | - id: remove-tabs 14 | args: [ --whitespaces-count=2 ] 15 | - repo: https://github.com/extenda/pre-commit-hooks 16 | rev: v0.9.0 17 | hooks: 18 | - id: google-java-formatter 19 | - id: commitlint 20 | stages: [commit-msg] 21 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | on: pull_request 3 | 4 | jobs: 5 | pre-commit: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | fetch-depth: 0 11 | 12 | - uses: actions/setup-java@v4 13 | with: 14 | distribution: temurin 15 | java-version-file: .java-version 16 | 17 | - name: Setup Python 18 | uses: actions/setup-python@v5 19 | 20 | - name: Run pre-commit 21 | uses: pre-commit/actions@v3.0.1 22 | with: 23 | extra_args: --from-ref=${{ github.event.pull_request.base.sha }} --to-ref=${{ github.sha }} 24 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/NodeInfoCatalogListener.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | /** 4 | * Listen to changes in the {@link NodeInfoCatalog}. 5 | * 6 | * @author sasjo 7 | */ 8 | public interface NodeInfoCatalogListener { 9 | 10 | /** 11 | * Invoked when a member is added to the catalog. 12 | * 13 | * @param nodeId the UUID of the added node 14 | */ 15 | void memberAdded(String nodeId); 16 | 17 | /** 18 | * Invoked when a member is removed from the catalog. 19 | * 20 | * @param nodeId the UUID of the removed node 21 | */ 22 | void memberRemoved(String nodeId); 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/commit-msg.yaml: -------------------------------------------------------------------------------- 1 | name: commit-msg 2 | on: 3 | pull_request: 4 | types: 5 | - edited 6 | - opened 7 | - reopened 8 | - synchronize 9 | 10 | jobs: 11 | commitlint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Lint pull request title 19 | uses: extenda/actions/commitlint@v0 20 | with: 21 | message: ${{ github.event.pull_request.title }} 22 | 23 | - name: Lint commit messages 24 | if: always() 25 | uses: extenda/actions/commitlint@v0 26 | with: 27 | relaxed: ${{ contains(job.status, 'success') }} 28 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/core/ITRedisClusteredHA.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.core; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 4 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 5 | import io.vertx.core.HATest; 6 | import io.vertx.core.spi.cluster.ClusterManager; 7 | import org.junit.Rule; 8 | import org.testcontainers.containers.GenericContainer; 9 | 10 | public class ITRedisClusteredHA extends HATest { 11 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 12 | 13 | @Override 14 | protected ClusterManager getClusterManager() { 15 | return RedisClusterManagerTestFactory.newInstance(redis); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/BooleanCodecTest.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertNotSame; 5 | 6 | import org.junit.jupiter.api.Test; 7 | 8 | class BooleanCodecTest extends CodecTestBase { 9 | @Test 10 | void trueValue() { 11 | assertEquals(true, encodeDecode(BooleanCodec.INSTANCE, true)); 12 | } 13 | 14 | @Test 15 | void falseValue() { 16 | assertEquals(false, encodeDecode(BooleanCodec.INSTANCE, false)); 17 | } 18 | 19 | @Test 20 | void copyCodec() { 21 | assertNotSame(BooleanCodec.INSTANCE, copy(BooleanCodec.INSTANCE)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/core/ITRedisClusteredComplexHA.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.core; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 4 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 5 | import io.vertx.core.ComplexHATest; 6 | import io.vertx.core.spi.cluster.ClusterManager; 7 | import org.junit.Rule; 8 | import org.testcontainers.containers.GenericContainer; 9 | 10 | public class ITRedisClusteredComplexHA extends ComplexHATest { 11 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 12 | 13 | @Override 14 | protected ClusterManager getClusterManager() { 15 | return RedisClusterManagerTestFactory.newInstance(redis); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/core/eventbus/ITRedisNodeInfo.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.core.eventbus; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 4 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 5 | import io.vertx.core.eventbus.NodeInfoTest; 6 | import io.vertx.core.spi.cluster.ClusterManager; 7 | import org.junit.Rule; 8 | import org.testcontainers.containers.GenericContainer; 9 | 10 | public class ITRedisNodeInfo extends NodeInfoTest { 11 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 12 | 13 | @Override 14 | protected ClusterManager getClusterManager() { 15 | return RedisClusterManagerTestFactory.newInstance(redis); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/CloseableLockTest.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static org.junit.jupiter.api.Assertions.*; 4 | import static org.mockito.Mockito.mock; 5 | import static org.mockito.Mockito.verify; 6 | 7 | import java.util.concurrent.locks.Lock; 8 | import org.junit.jupiter.api.Test; 9 | 10 | class CloseableLockTest { 11 | 12 | @Test 13 | void tryWithResourceLock() { 14 | Lock lock = mock(Lock.class); 15 | try (var ignored = CloseableLock.lock(lock)) { 16 | verify(lock).lock(); 17 | } 18 | verify(lock).unlock(); 19 | } 20 | 21 | @Test 22 | void throwsNullPointerOnNullLock() { 23 | assertThrows(NullPointerException.class, () -> CloseableLock.lock(null)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/core/eventbus/ITRedisClusteredEventBus.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.core.eventbus; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 4 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 5 | import io.vertx.core.eventbus.ClusteredEventBusTest; 6 | import io.vertx.core.spi.cluster.ClusterManager; 7 | import org.junit.Rule; 8 | import org.testcontainers.containers.GenericContainer; 9 | 10 | public class ITRedisClusteredEventBus extends ClusteredEventBusTest { 11 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 12 | 13 | @Override 14 | protected ClusterManager getClusterManager() { 15 | return RedisClusterManagerTestFactory.newInstance(redis); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/core/shareddata/ITRedisClusteredAsyncMap.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.core.shareddata; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 4 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 5 | import io.vertx.core.shareddata.ClusteredAsyncMapTest; 6 | import io.vertx.core.spi.cluster.ClusterManager; 7 | import org.junit.Rule; 8 | import org.testcontainers.containers.GenericContainer; 9 | 10 | public class ITRedisClusteredAsyncMap extends ClusteredAsyncMapTest { 11 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 12 | 13 | @Override 14 | protected ClusterManager getClusterManager() { 15 | return RedisClusterManagerTestFactory.newInstance(redis); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/RedisClusterManagerTestFactory.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.config.RedisConfig; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.testcontainers.containers.GenericContainer; 7 | 8 | public class RedisClusterManagerTestFactory { 9 | 10 | private static final Logger LOG = LoggerFactory.getLogger(RedisClusterManagerTestFactory.class); 11 | 12 | public static RedisClusterManager newInstance(GenericContainer redis) { 13 | String redisUrl = "redis://" + redis.getHost() + ":" + redis.getFirstMappedPort(); 14 | LOG.info("Connect to {}", redisUrl); 15 | return new RedisClusterManager(new RedisConfig().addEndpoint(redisUrl)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/core/shareddata/ITRedisClusteredSharedCounter.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.core.shareddata; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 4 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 5 | import io.vertx.core.shareddata.ClusteredSharedCounterTest; 6 | import io.vertx.core.spi.cluster.ClusterManager; 7 | import org.junit.Rule; 8 | import org.testcontainers.containers.GenericContainer; 9 | 10 | public class ITRedisClusteredSharedCounter extends ClusteredSharedCounterTest { 11 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 12 | 13 | @Override 14 | protected ClusterManager getClusterManager() { 15 | return RedisClusterManagerTestFactory.newInstance(redis); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/ext/web/sstore/ITRedisClusteredSessionHandler.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.ext.web.sstore; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 4 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 5 | import io.vertx.core.spi.cluster.ClusterManager; 6 | import io.vertx.ext.web.sstore.ClusteredSessionHandlerTest; 7 | import org.junit.Rule; 8 | import org.testcontainers.containers.GenericContainer; 9 | 10 | public class ITRedisClusteredSessionHandler extends ClusteredSessionHandlerTest { 11 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 12 | 13 | @Override 14 | protected ClusterManager getClusterManager() { 15 | return RedisClusterManagerTestFactory.newInstance(redis); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/core/shareddata/ITRedisClusteredAsynchronousLock.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.core.shareddata; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 4 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 5 | import io.vertx.core.shareddata.ClusteredAsynchronousLockTest; 6 | import io.vertx.core.spi.cluster.ClusterManager; 7 | import org.junit.Rule; 8 | import org.testcontainers.containers.GenericContainer; 9 | 10 | public class ITRedisClusteredAsynchronousLock extends ClusteredAsynchronousLockTest { 11 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 12 | 13 | @Override 14 | protected ClusterManager getClusterManager() { 15 | return RedisClusterManagerTestFactory.newInstance(redis); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/CodecTestBase.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | 5 | import io.netty.buffer.ByteBuf; 6 | import org.redisson.client.codec.BaseCodec; 7 | import org.redisson.client.codec.Codec; 8 | import org.redisson.client.handler.State; 9 | 10 | abstract class CodecTestBase { 11 | 12 | T encodeDecode(Codec codec, T value) { 13 | ByteBuf buf = assertDoesNotThrow(() -> codec.getValueEncoder().encode(value)); 14 | Object decoded = assertDoesNotThrow(() -> codec.getValueDecoder().decode(buf, new State())); 15 | return (T) decoded; 16 | } 17 | 18 | Codec copy(Codec codec) { 19 | return assertDoesNotThrow(() -> BaseCodec.copy(codec.getClassLoader(), codec)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/NullCodecTest.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertNotSame; 4 | import static org.junit.jupiter.api.Assertions.assertNull; 5 | import static org.junit.jupiter.api.Assertions.assertThrows; 6 | 7 | import java.io.IOException; 8 | import org.junit.jupiter.api.Test; 9 | 10 | class NullCodecTest extends CodecTestBase { 11 | @Test 12 | void nullValue() { 13 | assertNull(encodeDecode(NullCodec.INSTANCE, null)); 14 | } 15 | 16 | @Test 17 | void nonNullValue() { 18 | assertThrows(IOException.class, () -> NullCodec.INSTANCE.getValueEncoder().encode("test")); 19 | } 20 | 21 | @Test 22 | void copyCodec() { 23 | assertNotSame(NullCodec.INSTANCE, copy(NullCodec.INSTANCE)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/ClassLoaderCodec.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import org.redisson.client.codec.BaseCodec; 4 | 5 | /** 6 | * Class loader aware codec. 7 | * 8 | * @author sasjo 9 | */ 10 | public abstract class ClassLoaderCodec extends BaseCodec { 11 | 12 | private final ClassLoader classLoader; 13 | 14 | /** Create a codec with a default class loader. */ 15 | protected ClassLoaderCodec() { 16 | this(null); 17 | } 18 | 19 | /** 20 | * Create a codec with a custom class loader 21 | * 22 | * @param classLoader the class loader to use 23 | */ 24 | protected ClassLoaderCodec(ClassLoader classLoader) { 25 | this.classLoader = classLoader; 26 | } 27 | 28 | @Override 29 | public final ClassLoader getClassLoader() { 30 | if (classLoader != null) { 31 | return classLoader; 32 | } 33 | return super.getClassLoader(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.5/apache-maven-3.8.5-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar 19 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/CustomObjectClassLoader.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import java.nio.file.Files; 4 | import java.nio.file.Paths; 5 | 6 | /** 7 | * Isolated class loader to load the CustomObject.class file. Used to test custom class 8 | * loaders during encoding and decoding of data. 9 | */ 10 | public class CustomObjectClassLoader extends ClassLoader { 11 | 12 | public static final String CUSTOM_OBJECT = "CustomObject"; 13 | 14 | public CustomObjectClassLoader(ClassLoader parent) { 15 | super(parent); 16 | } 17 | 18 | @Override 19 | protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { 20 | if (CUSTOM_OBJECT.equals(name)) { 21 | try { 22 | byte[] classBytes = 23 | Files.readAllBytes(Paths.get("src", "test", "test-class", CUSTOM_OBJECT + ".class")); 24 | return defineClass(CUSTOM_OBJECT, classBytes, 0, classBytes.length); 25 | } catch (Exception e) { 26 | throw new ClassNotFoundException("Failed to load " + CUSTOM_OBJECT, e); 27 | } 28 | } 29 | return super.loadClass(name, resolve); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/config/ClientType.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.config; 2 | 3 | /** 4 | * Redis client type. 5 | * 6 | * @author sasjo 7 | */ 8 | public enum ClientType { 9 | 10 | /** 11 | * The client should work in single server mode (the default). 12 | * 13 | *

Supports: 14 | * 15 | *

    16 | *
  • Redis Community Edition 17 | *
  • Google Cloud MemoryStore for Redis 18 | *
  • Azure Redis Cache 19 | *
20 | */ 21 | STANDALONE, 22 | 23 | /** 24 | * The client should work in cluster mode. 25 | * 26 | *

Supports: 27 | * 28 | *

    29 | *
  • AWS ElastiCache Cluster 30 | *
  • Amazon MemoryDB 31 | *
  • Azure Redis Cache 32 | *
33 | */ 34 | CLUSTER, 35 | 36 | /** 37 | * The client should work in replicated mode. With Replicated mode the role of each node is polled 38 | * to determine if a failover has occurred resulting in a new master. 39 | * 40 | *

Supports: 41 | * 42 | *

    43 | *
  • Google Cloud MemoryStore for Redis High Availability. 44 | *
  • AWS ElastiCache (non clustered) 45 | *
  • Azure Redis Cache (non clustered) 46 | *
47 | */ 48 | REPLICATED, 49 | } 50 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/core/eventbus/ITRedisFaultTolerance.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.core.eventbus; 2 | 3 | import static java.util.Arrays.asList; 4 | 5 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 6 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 7 | import io.vertx.core.eventbus.FaultToleranceTest; 8 | import io.vertx.core.spi.cluster.ClusterManager; 9 | import java.util.List; 10 | import org.junit.Rule; 11 | import org.testcontainers.containers.GenericContainer; 12 | 13 | public class ITRedisFaultTolerance extends FaultToleranceTest { 14 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 15 | 16 | @Override 17 | protected ClusterManager getClusterManager() { 18 | return RedisClusterManagerTestFactory.newInstance(redis); 19 | } 20 | 21 | @Override 22 | protected List getExternalNodeSystemProperties() { 23 | return asList( 24 | "-Dredis.connection.host=" + redis.getHost(), 25 | "-Dredis.connection.port=" + redis.getFirstMappedPort()); 26 | } 27 | 28 | @Override 29 | protected void afterNodesKilled() throws Exception { 30 | super.afterNodesKilled(); 31 | // Additional wait to make sure all nodes noticed the shutdowns 32 | Thread.sleep(30_000); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/RedisKeyFactory.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | /** 4 | * Create keys for Redis objects. 5 | * 6 | * @author sasjo 7 | */ 8 | public class RedisKeyFactory { 9 | 10 | private static final String VERTX = "__vertx"; 11 | private static final String DELIMITER = ":"; 12 | 13 | private final String namespace; 14 | private final boolean hasNamespace; 15 | 16 | /** 17 | * Create a key name factory for a namespace prefix. 18 | * 19 | * @param namespace the root namespace. 20 | */ 21 | public RedisKeyFactory(String namespace) { 22 | this.namespace = namespace; 23 | this.hasNamespace = namespace != null && !namespace.isEmpty(); 24 | } 25 | 26 | String build(String... path) { 27 | String name = String.join(DELIMITER, path); 28 | return hasNamespace ? namespace + DELIMITER + name : name; 29 | } 30 | 31 | String map(String name) { 32 | return build(name); 33 | } 34 | 35 | String lock(String name) { 36 | return build(VERTX, "locks", name); 37 | } 38 | 39 | String counter(String name) { 40 | return build(VERTX, "counters", name); 41 | } 42 | 43 | String topic(String name) { 44 | return build(VERTX, "topics", name); 45 | } 46 | 47 | String vertx(String name) { 48 | return build(VERTX, name); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/RedisInstance.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis; 2 | 3 | import io.vertx.core.Vertx; 4 | import io.vertx.core.impl.VertxInternal; 5 | import io.vertx.core.spi.cluster.ClusterManager; 6 | import java.util.Optional; 7 | 8 | /** 9 | * Low-level access to distributed data types backed by Redis. Prefer Vertx shared data types over 10 | * using this API. 11 | * 12 | * @author sasjo 13 | */ 14 | public interface RedisInstance extends RedisDataGrid { 15 | 16 | /** 17 | * Create a Redis instance from the {@link RedisClusterManager}. If Vertx is not using the Redis 18 | * cluster manager an empty optional will be returned. 19 | * 20 | * @param vertx the vertx instance 21 | * @return an optional with the Redis instance. 22 | */ 23 | static Optional create(Vertx vertx) { 24 | if (vertx instanceof VertxInternal vertxInternal) { 25 | ClusterManager clusterManager = vertxInternal.getClusterManager(); 26 | if (clusterManager instanceof RedisClusterManager rcm) { 27 | return rcm.getRedisInstance(); 28 | } 29 | } 30 | return Optional.empty(); 31 | } 32 | 33 | /** 34 | * Ping the Redis instance(s) to determine availability. 35 | * 36 | * @return true if ping is successful, otherwise false. 37 | */ 38 | boolean ping(); 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/CloseableLock.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import java.util.concurrent.locks.Lock; 4 | 5 | /** 6 | * A try-with-resource lock that will be claimed on creation and released with the resource block. 7 | * 8 | *
 9 |  *   Lock lock = new ReentrantLock();
10 |  *   // ...
11 |  *   try (var ignored = CloseableLock.lock(lock)) {
12 |  *     // critical section.
13 |  *   }
14 |  * 
15 | * 16 | * @author sasjo 17 | */ 18 | public final class CloseableLock implements AutoCloseable { 19 | 20 | private final Lock lock; 21 | 22 | /** 23 | * Claim the given lock. The returned resource can be used in a try-with-resource 24 | * block to automatically release the lock with the resource block. 25 | * 26 | *
27 |    *   try (var ignored = CloseableLock.lock(lock)) {
28 |    *     // critical section.
29 |    *   }
30 |    * 
31 | * 32 | * @param lock the lock to claim 33 | * @return an auto closeable lock. 34 | * @throws NullPointerException if lock is null 35 | */ 36 | public static CloseableLock lock(Lock lock) { 37 | return new CloseableLock(lock); 38 | } 39 | 40 | private CloseableLock(Lock lock) { 41 | this.lock = lock; 42 | this.lock.lock(); 43 | } 44 | 45 | /** Release the lock. */ 46 | @Override 47 | public void close() { 48 | lock.unlock(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/RedisTopic.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.Topic; 4 | import com.retailsvc.vertx.spi.cluster.redis.TopicSubscriber; 5 | import io.vertx.core.Future; 6 | import io.vertx.core.Vertx; 7 | import org.redisson.api.RTopic; 8 | 9 | /** 10 | * A Redisson topic with subscription support. 11 | * 12 | * @param the type of messages in the topic 13 | * @author sasjo 14 | */ 15 | class RedisTopic implements Topic { 16 | 17 | private final Vertx vertx; 18 | private final RTopic topic; 19 | private final Class type; 20 | 21 | RedisTopic(Vertx vertx, Class type, RTopic topic) { 22 | this.vertx = vertx; 23 | this.type = type; 24 | this.topic = topic; 25 | } 26 | 27 | @Override 28 | public Future subscribe(TopicSubscriber subscriber) { 29 | return Future.fromCompletionStage( 30 | topic.addListenerAsync(type, (channel, message) -> subscriber.onMessage(message)), 31 | vertx.getOrCreateContext()); 32 | } 33 | 34 | @Override 35 | public Future unsubscribe(int subscriberId) { 36 | return Future.fromCompletionStage( 37 | topic.removeListenerAsync(subscriberId), vertx.getOrCreateContext()); 38 | } 39 | 40 | @Override 41 | public Future publish(T message) { 42 | return Future.fromCompletionStage(topic.publishAsync(message), vertx.getOrCreateContext()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/ITRedisDataGrid.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 5 | import static org.junit.jupiter.api.Assertions.assertTrue; 6 | 7 | import com.retailsvc.vertx.spi.cluster.redis.RedisDataGrid; 8 | import com.retailsvc.vertx.spi.cluster.redis.RedisInstance; 9 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 10 | import com.retailsvc.vertx.spi.cluster.redis.config.RedisConfig; 11 | import io.vertx.core.Vertx; 12 | import org.junit.jupiter.api.BeforeEach; 13 | import org.junit.jupiter.api.Test; 14 | import org.testcontainers.containers.GenericContainer; 15 | import org.testcontainers.junit.jupiter.Container; 16 | import org.testcontainers.junit.jupiter.Testcontainers; 17 | 18 | @Testcontainers 19 | class ITRedisDataGrid { 20 | @Container public GenericContainer redis = RedisTestContainerFactory.newContainer(); 21 | 22 | private RedisConfig config; 23 | 24 | @BeforeEach 25 | void beforeEach() { 26 | String redisUrl = "redis://" + redis.getHost() + ":" + redis.getFirstMappedPort(); 27 | config = new RedisConfig().addEndpoint(redisUrl); 28 | } 29 | 30 | @Test 31 | void createDataGrid() { 32 | RedisDataGrid dataGrid = assertDoesNotThrow(() -> RedisDataGrid.create(Vertx.vertx(), config)); 33 | assertThat(dataGrid).isInstanceOf(RedisInstance.class); 34 | assertTrue(((RedisInstance) dataGrid).ping()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/ClusterHealthCheck.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis; 2 | 3 | import io.vertx.core.Handler; 4 | import io.vertx.core.Promise; 5 | import io.vertx.core.Vertx; 6 | import io.vertx.core.impl.VertxInternal; 7 | import io.vertx.ext.healthchecks.Status; 8 | import java.util.Objects; 9 | 10 | /** 11 | * A helper to create Vert.x cluster {@link io.vertx.ext.healthchecks.HealthChecks} procedures. 12 | * 13 | * @author sasjo 14 | */ 15 | public interface ClusterHealthCheck { 16 | 17 | /** 18 | * Creates a ready-to-use Vert.x cluster {@link io.vertx.ext.healthchecks.HealthChecks} procedure. 19 | * 20 | * @param vertx the instance of Vert.x, must not be {@code null} 21 | * @return a Vert.x cluster {@link io.vertx.ext.healthchecks.HealthChecks} procedure 22 | */ 23 | static Handler> createProcedure(Vertx vertx) { 24 | Objects.requireNonNull(vertx); 25 | return healthCheckPromise -> 26 | vertx.executeBlocking(ClusterHealthCheck::getStatus, false).onComplete(healthCheckPromise); 27 | } 28 | 29 | /** 30 | * Get the cluster manager status. 31 | * 32 | * @return the health status. 33 | */ 34 | private static Status getStatus() { 35 | VertxInternal vertxInternal = (VertxInternal) Vertx.currentContext().owner(); 36 | RedisClusterManager clusterManager = (RedisClusterManager) vertxInternal.getClusterManager(); 37 | boolean connected = clusterManager.getRedisInstance().map(RedisInstance::ping).orElse(false); 38 | return new Status().setOk(connected); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/Topic.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis; 2 | 3 | import io.vertx.core.Future; 4 | 5 | /** 6 | * Redis PubSub topic with subscription support. A topic can be used to send messages between 7 | * processes. It's possible to implement similar features as with the Vertx EventBus and can be 8 | * useful when broadcast behavior is desired or when cluster features are disabled. 9 | * 10 | * @param the type of messages in the topic 11 | * @author sasjo 12 | */ 13 | public interface Topic { 14 | /** 15 | * Add a subscription to the topic. The returned subscriber ID can be used to unsubscribe from the 16 | * topic. 17 | * 18 | * @param subscriber the subscriber callback 19 | * @return a future with the subscriber ID. The future completes when the subscriber is registered 20 | */ 21 | Future subscribe(TopicSubscriber subscriber); 22 | 23 | /** 24 | * Remove a subscription from the topic. If the subscriber isn't known this becomes a no-op. 25 | * 26 | * @param subscriberId the subscriber ID to remove 27 | * @return a future that completes when the subscriber is unregistered 28 | */ 29 | Future unsubscribe(int subscriberId); 30 | 31 | /** 32 | * Publish a message to the topic. All subscribers from all processes will be notified. 33 | * 34 | * @param message the message to publish. 35 | * @return a future that completes with the number of listeners that received the message 36 | * broadcast from Redis. 37 | */ 38 | Future publish(T message); 39 | } 40 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/servicediscovery/impl/ITRedisDiscoveryImplClustered.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.servicediscovery.impl; 2 | 3 | import static com.jayway.awaitility.Awaitility.await; 4 | 5 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManagerTestFactory; 6 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 7 | import io.vertx.core.Future; 8 | import io.vertx.core.Promise; 9 | import io.vertx.core.Vertx; 10 | import io.vertx.core.impl.VertxInternal; 11 | import io.vertx.servicediscovery.ServiceDiscoveryOptions; 12 | import io.vertx.servicediscovery.impl.DiscoveryImpl; 13 | import io.vertx.servicediscovery.impl.DiscoveryImplTestBase; 14 | import org.junit.Before; 15 | import org.junit.FixMethodOrder; 16 | import org.junit.Rule; 17 | import org.junit.runners.MethodSorters; 18 | import org.testcontainers.containers.GenericContainer; 19 | 20 | @FixMethodOrder(MethodSorters.NAME_ASCENDING) 21 | public class ITRedisDiscoveryImplClustered extends DiscoveryImplTestBase { 22 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 23 | 24 | @Before 25 | public void beforeEach() { 26 | Promise promise = Promise.promise(); 27 | Vertx.builder() 28 | .withClusterManager(RedisClusterManagerTestFactory.newInstance(redis)) 29 | .buildClustered(promise); 30 | 31 | Future future = promise.future().onSuccess(v -> vertx = v); 32 | await().until(future::succeeded); 33 | await().until(() -> ((VertxInternal) vertx).getClusterManager().isActive()); 34 | 35 | discovery = new DiscoveryImpl(vertx, new ServiceDiscoveryOptions()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/BooleanCodec.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import io.netty.buffer.ByteBuf; 4 | import io.netty.buffer.ByteBufAllocator; 5 | import org.redisson.client.codec.BaseCodec; 6 | import org.redisson.client.codec.Codec; 7 | import org.redisson.client.protocol.Decoder; 8 | import org.redisson.client.protocol.Encoder; 9 | 10 | /** 11 | * A Redisson codec for boolean values. 12 | * 13 | * @author sasjo 14 | */ 15 | public class BooleanCodec extends BaseCodec { 16 | 17 | /** Codec singleton. */ 18 | public static final Codec INSTANCE = new BooleanCodec(); 19 | 20 | private final Decoder decoder = (buf, state) -> buf.readBoolean(); 21 | 22 | private final Encoder encoder = 23 | in -> { 24 | ByteBuf out = ByteBufAllocator.DEFAULT.buffer(); 25 | out.writeBoolean((Boolean) in); 26 | return out; 27 | }; 28 | 29 | /** Create a BooleanCodec. */ 30 | public BooleanCodec() {} 31 | 32 | /** 33 | * Create a BooleanCodec. 34 | * 35 | * @param classLoader required by Codec contract 36 | */ 37 | public BooleanCodec(ClassLoader classLoader) { 38 | this(); 39 | } 40 | 41 | /** 42 | * Create a BooleanCodec. 43 | * 44 | * @param classLoader required by Codec contract 45 | * @param codec required by Codec contract 46 | */ 47 | public BooleanCodec(ClassLoader classLoader, BooleanCodec codec) { 48 | this(classLoader); 49 | } 50 | 51 | @Override 52 | public Decoder getValueDecoder() { 53 | return decoder; 54 | } 55 | 56 | @Override 57 | public Encoder getValueEncoder() { 58 | return encoder; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/RedisKeyFactoryTest.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | 5 | import org.junit.jupiter.api.Test; 6 | 7 | class RedisKeyFactoryTest { 8 | 9 | @Test 10 | void mapWithDefault() { 11 | assertEquals("test", new RedisKeyFactory("").map("test")); 12 | } 13 | 14 | @Test 15 | void mapWithNamespace() { 16 | assertEquals("namespace:test", new RedisKeyFactory("namespace").map("test")); 17 | } 18 | 19 | @Test 20 | void lockWithDefault() { 21 | assertEquals("__vertx:locks:test", new RedisKeyFactory("").lock("test")); 22 | } 23 | 24 | @Test 25 | void lockWithNamespace() { 26 | assertEquals("namespace:__vertx:locks:test", new RedisKeyFactory("namespace").lock("test")); 27 | } 28 | 29 | @Test 30 | void counterWithDefault() { 31 | assertEquals("__vertx:counters:test", new RedisKeyFactory("").counter("test")); 32 | } 33 | 34 | @Test 35 | void counterWithNamespace() { 36 | assertEquals( 37 | "namespace:__vertx:counters:test", new RedisKeyFactory("namespace").counter("test")); 38 | } 39 | 40 | @Test 41 | void topicWithDefault() { 42 | assertEquals("__vertx:topics:test", new RedisKeyFactory("").topic("test")); 43 | } 44 | 45 | @Test 46 | void topicWithNamespace() { 47 | assertEquals("namespace:__vertx:topics:test", new RedisKeyFactory("namespace").topic("test")); 48 | } 49 | 50 | @Test 51 | void vertxWithDefault() { 52 | assertEquals("__vertx:test", new RedisKeyFactory("").vertx("test")); 53 | } 54 | 55 | @Test 56 | void vertxWithNamespace() { 57 | assertEquals("namespace:__vertx:test", new RedisKeyFactory("namespace").vertx("test")); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/shareddata/RedisLock.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.shareddata; 2 | 3 | import io.vertx.core.shareddata.Lock; 4 | import java.util.concurrent.ExecutorService; 5 | import java.util.concurrent.atomic.AtomicBoolean; 6 | import org.redisson.api.RPermitExpirableSemaphore; 7 | import org.redisson.api.RSemaphore; 8 | 9 | /** 10 | * Lock implementation backed by Redis. 11 | * 12 | * @author sasjo 13 | */ 14 | public class RedisLock implements Lock { 15 | 16 | private final ExecutorService lockReleaseExec; 17 | private final AtomicBoolean released = new AtomicBoolean(); 18 | private final Runnable releaseLock; 19 | 20 | /** 21 | * Create a Redis distributed lock. 22 | * 23 | * @param semaphore the Redisson semaphore that backs the lock 24 | * @param lockReleaseExec an executor used to release the lock 25 | */ 26 | public RedisLock(RSemaphore semaphore, ExecutorService lockReleaseExec) { 27 | this.lockReleaseExec = lockReleaseExec; 28 | this.releaseLock = semaphore::release; 29 | } 30 | 31 | /** 32 | * Create a Redis distributed lock that can expire. 33 | * 34 | * @param semaphore the Redisson expirable semaphore that backs the lock 35 | * @param permitId the internal permit ID 36 | * @param lockReleaseExec an executor used to release the lock 37 | */ 38 | public RedisLock( 39 | RPermitExpirableSemaphore semaphore, String permitId, ExecutorService lockReleaseExec) { 40 | this.lockReleaseExec = lockReleaseExec; 41 | this.releaseLock = () -> semaphore.release(permitId); 42 | } 43 | 44 | @Override 45 | public void release() { 46 | if (released.compareAndSet(false, true)) { 47 | lockReleaseExec.execute(releaseLock); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/NullCodec.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import io.netty.buffer.ByteBuf; 4 | import io.netty.buffer.ByteBufAllocator; 5 | import java.io.IOException; 6 | import org.redisson.client.codec.BaseCodec; 7 | import org.redisson.client.codec.Codec; 8 | import org.redisson.client.protocol.Decoder; 9 | import org.redisson.client.protocol.Encoder; 10 | 11 | /** 12 | * A Redisson codec for null values. 13 | * 14 | * @author sasjo 15 | */ 16 | public class NullCodec extends BaseCodec { 17 | 18 | /** Codec singleton. */ 19 | public static final Codec INSTANCE = new NullCodec(); 20 | 21 | private static final Decoder DECODER = (buf, state) -> null; 22 | 23 | private final Encoder encoder = 24 | in -> { 25 | if (in != null) { 26 | throw new IOException("Unexpected non-null value: " + in); 27 | } 28 | ByteBuf out = ByteBufAllocator.DEFAULT.buffer(); 29 | out.writeByte(0); 30 | return out; 31 | }; 32 | 33 | /** Create a NullCodec. */ 34 | public NullCodec() {} 35 | 36 | /** 37 | * Create a NullCodec. 38 | * 39 | * @param classLoader required by Codec contract 40 | */ 41 | public NullCodec(ClassLoader classLoader) { 42 | this(); 43 | } 44 | 45 | /** 46 | * Create a NullCodec. 47 | * 48 | * @param classLoader required by Codec contract 49 | * @param codec required by Codec contract 50 | */ 51 | public NullCodec(ClassLoader classLoader, NullCodec codec) { 52 | this(classLoader); 53 | } 54 | 55 | @Override 56 | public Decoder getValueDecoder() { 57 | return DECODER; 58 | } 59 | 60 | @Override 61 | public Encoder getValueEncoder() { 62 | return encoder; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/SemaphoreWrapper.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static java.util.concurrent.TimeUnit.MILLISECONDS; 4 | 5 | import com.retailsvc.vertx.spi.cluster.redis.impl.shareddata.RedisLock; 6 | import java.time.Duration; 7 | import java.util.concurrent.ExecutorService; 8 | import org.redisson.api.RPermitExpirableSemaphore; 9 | import org.redisson.api.RSemaphore; 10 | 11 | /** 12 | * A redis semaphore wrapper that supports lock lease time when configured. 13 | * 14 | * @author sasjo 15 | */ 16 | class SemaphoreWrapper { 17 | private RPermitExpirableSemaphore permitSemaphore; 18 | private RSemaphore semaphore; 19 | private int leaseTime; 20 | 21 | SemaphoreWrapper(RSemaphore semaphore) { 22 | this.semaphore = semaphore; 23 | } 24 | 25 | SemaphoreWrapper(RPermitExpirableSemaphore semaphore, int leaseTime) { 26 | this.permitSemaphore = semaphore; 27 | this.leaseTime = leaseTime; 28 | } 29 | 30 | /** 31 | * Try to acquire a redis lock. 32 | * 33 | * @param waitTime max wait time in milliseconds 34 | * @param lockReleaseExec lock release thread 35 | * @return teh acquired lock or null if unsuccessful 36 | * @throws InterruptedException if interrupted while waiting for lock 37 | */ 38 | public RedisLock tryAcquire(long waitTime, ExecutorService lockReleaseExec) 39 | throws InterruptedException { 40 | RedisLock lock = null; 41 | if (semaphore != null) { 42 | if (semaphore.tryAcquire(Duration.ofMillis(waitTime))) { 43 | lock = new RedisLock(semaphore, lockReleaseExec); 44 | } 45 | } else { 46 | String permitId = permitSemaphore.tryAcquire(waitTime, leaseTime, MILLISECONDS); 47 | if (permitId != null) { 48 | lock = new RedisLock(permitSemaphore, permitId, lockReleaseExec); 49 | } 50 | } 51 | return lock; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/ITClusterHealthCheck.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis; 2 | 3 | import static com.jayway.awaitility.Awaitility.await; 4 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 5 | import static org.junit.jupiter.api.Assertions.assertFalse; 6 | import static org.junit.jupiter.api.Assertions.assertTrue; 7 | 8 | import io.vertx.core.Handler; 9 | import io.vertx.core.Promise; 10 | import io.vertx.core.Vertx; 11 | import io.vertx.ext.healthchecks.Status; 12 | import org.junit.jupiter.api.BeforeEach; 13 | import org.junit.jupiter.api.Test; 14 | import org.testcontainers.containers.GenericContainer; 15 | import org.testcontainers.junit.jupiter.Container; 16 | import org.testcontainers.junit.jupiter.Testcontainers; 17 | 18 | @Testcontainers 19 | class ITClusterHealthCheck { 20 | 21 | @Container public GenericContainer redis = RedisTestContainerFactory.newContainer(); 22 | 23 | private Vertx vertx; 24 | 25 | @BeforeEach 26 | void beforeEach() { 27 | RedisClusterManager clusterManager = RedisClusterManagerTestFactory.newInstance(redis); 28 | Vertx.builder().withClusterManager(clusterManager).buildClustered(ar -> vertx = ar.result()); 29 | await().until(() -> vertx != null); 30 | } 31 | 32 | @Test 33 | void checkHealthOK() { 34 | Handler> handler = ClusterHealthCheck.createProcedure(vertx); 35 | Promise promise = Promise.promise(); 36 | handler.handle(promise); 37 | Status status = 38 | assertDoesNotThrow(() -> promise.future().toCompletionStage().toCompletableFuture().get()); 39 | assertTrue(status.isOk()); 40 | } 41 | 42 | @Test 43 | void checkHealthNotOK() { 44 | redis.stop(); 45 | Handler> handler = ClusterHealthCheck.createProcedure(vertx); 46 | Promise promise = Promise.promise(); 47 | handler.handle(promise); 48 | Status status = 49 | assertDoesNotThrow(() -> promise.future().toCompletionStage().toCompletableFuture().get()); 50 | assertFalse(status.isOk()); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/shareddata/RedisCounter.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.shareddata; 2 | 3 | import static io.vertx.core.Future.fromCompletionStage; 4 | 5 | import io.vertx.core.Future; 6 | import io.vertx.core.Vertx; 7 | import io.vertx.core.shareddata.Counter; 8 | import org.redisson.api.RAtomicLong; 9 | 10 | /** 11 | * Counter implementation for Redis. 12 | * 13 | * @author sasjo 14 | */ 15 | public class RedisCounter implements Counter { 16 | 17 | private final Vertx vertx; 18 | private final RAtomicLong counter; 19 | 20 | /** 21 | * Create a Redis counter. 22 | * 23 | * @param vertx the vertx instance 24 | * @param counter the Redisson long backing the counter 25 | */ 26 | public RedisCounter(Vertx vertx, RAtomicLong counter) { 27 | this.vertx = vertx; 28 | this.counter = counter; 29 | } 30 | 31 | @Override 32 | public Future get() { 33 | return fromCompletionStage(counter.getAsync(), vertx.getOrCreateContext()); 34 | } 35 | 36 | @Override 37 | public Future incrementAndGet() { 38 | return fromCompletionStage(counter.incrementAndGetAsync(), vertx.getOrCreateContext()); 39 | } 40 | 41 | @Override 42 | public Future getAndIncrement() { 43 | return fromCompletionStage(counter.getAndIncrementAsync(), vertx.getOrCreateContext()); 44 | } 45 | 46 | @Override 47 | public Future decrementAndGet() { 48 | return fromCompletionStage(counter.decrementAndGetAsync(), vertx.getOrCreateContext()); 49 | } 50 | 51 | @Override 52 | public Future addAndGet(long value) { 53 | return fromCompletionStage(counter.addAndGetAsync(value), vertx.getOrCreateContext()); 54 | } 55 | 56 | @Override 57 | public Future getAndAdd(long value) { 58 | return fromCompletionStage(counter.getAndAddAsync(value), vertx.getOrCreateContext()); 59 | } 60 | 61 | @Override 62 | public Future compareAndSet(long expected, long value) { 63 | return fromCompletionStage( 64 | counter.compareAndSetAsync(expected, value), vertx.getOrCreateContext()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/commit.yaml: -------------------------------------------------------------------------------- 1 | name: commit 2 | on: push 3 | 4 | env: 5 | MAVEN_INIT: 'false' 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | 15 | - uses: actions/setup-java@v4 16 | with: 17 | distribution: temurin 18 | java-version-file: .java-version 19 | cache: maven 20 | 21 | - name: Run tests 22 | uses: extenda/actions/maven@v0 23 | with: 24 | service-account-key: ${{ secrets.SECRET_AUTH }} 25 | args: verify 26 | 27 | - name: Scan with SonarCloud 28 | uses: extenda/actions/sonar-scanner@v0 29 | with: 30 | sonar-host: https://sonarcloud.io 31 | service-account-key: ${{ secrets.SECRET_AUTH }} 32 | 33 | release: 34 | if: github.ref == 'refs/heads/master' 35 | runs-on: ubuntu-latest 36 | needs: 37 | - test 38 | steps: 39 | - uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | 43 | - uses: actions/setup-java@v4 44 | with: 45 | distribution: temurin 46 | java-version-file: .java-version 47 | cache: maven 48 | server-id: central 49 | server-username: MAVEN_CENTRAL_USERNAME 50 | server-password: MAVEN_CENTRAL_TOKEN 51 | gpg-private-key: ${{ secrets.MAVEN_CENTRAL_GPG_PRIVATE_KEY }} 52 | gpg-passphrase: MAVEN_GPG_PASSPHRASE 53 | 54 | - name: Create release 55 | uses: extenda/actions/conventional-release@v0 56 | id: release 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: Build release 61 | uses: extenda/actions/maven@v0 62 | with: 63 | args: deploy -DskipTests 64 | version: ${{ steps.release.outputs.version }} 65 | service-account-key: ${{ secrets.SECRET_AUTH }} 66 | env: 67 | MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} 68 | MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} 69 | MAVEN_GPG_KEY_NAME: ${{ secrets.MAVEN_CENTRAL_GPG_KEY_NAME }} 70 | MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_CENTRAL_GPG_PASSPHRASE }} 71 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/config/RedisConfigProps.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.config; 2 | 3 | import java.net.URI; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | 7 | /** 8 | * Utility to access configuration from system properties that fallback to environment variables. 9 | * 10 | * @author sasjo 11 | */ 12 | final class RedisConfigProps { 13 | 14 | private static final Logger log = LoggerFactory.getLogger(RedisConfigProps.class); 15 | 16 | private RedisConfigProps() { 17 | // Prevent initialization 18 | } 19 | 20 | /** 21 | * Read a system property value. If the property isn't found, this method will attempt to read it 22 | * from the system environment. Property names are transformed to env vars by replacing . 23 | * with _ and converting to uppercase. 24 | * 25 | * @param propertyName the property name 26 | * @return the property value or null if not set 27 | */ 28 | static String getPropertyValue(String propertyName) { 29 | String envName = propertyName.replace(".", "_").toUpperCase(); 30 | return System.getProperty(propertyName, System.getenv(envName)); 31 | } 32 | 33 | /** 34 | * Same as {@link #getPropertyValue(String)}, but returns a default value if property is not set. 35 | * 36 | * @param propertyName the property name 37 | * @param defaultValue the default fallback value 38 | * @return the property value or the fallback value 39 | */ 40 | static String getPropertyValue(String propertyName, String defaultValue) { 41 | String value = getPropertyValue(propertyName); 42 | return value == null ? defaultValue : value; 43 | } 44 | 45 | /** 46 | * Returns the Redis server default endpoint. 47 | * 48 | * @return the configured Redis server endpoint. 49 | */ 50 | static URI getDefaultEndpoint() { 51 | String scheme = getPropertyValue("redis.connection.scheme", "redis"); 52 | String host = getPropertyValue("redis.connection.host", "127.0.0.1"); 53 | String port = getPropertyValue("redis.connection.port", "6379"); 54 | 55 | String defaultAddress = scheme + "://" + host + ":" + port; 56 | String address = getPropertyValue("redis.connection.address", defaultAddress); 57 | 58 | log.debug("Redis endpoint: [{}]", address); 59 | return URI.create(address); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/RedisMapCodecTest.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | import static org.junit.jupiter.api.Assertions.assertNotSame; 6 | import static org.junit.jupiter.api.Assertions.assertNull; 7 | 8 | import io.vertx.core.json.JsonObject; 9 | import io.vertx.core.spi.cluster.NodeInfo; 10 | import java.util.Locale; 11 | import org.junit.jupiter.api.Test; 12 | import org.redisson.client.codec.Codec; 13 | 14 | class RedisMapCodecTest extends CodecTestBase { 15 | 16 | private final Codec codec = new RedisMapCodec(); 17 | 18 | @Test 19 | void stringValue() { 20 | assertEquals("test", encodeDecode(codec, "test")); 21 | } 22 | 23 | @Test 24 | void nullValue() { 25 | assertNull(encodeDecode(codec, null)); 26 | } 27 | 28 | @Test 29 | void booleanValue() { 30 | assertEquals(true, encodeDecode(codec, true)); 31 | } 32 | 33 | @Test 34 | void integerValue() { 35 | assertEquals(10, encodeDecode(codec, 10)); 36 | } 37 | 38 | @Test 39 | void longValue() { 40 | assertEquals(10L, encodeDecode(codec, 10L)); 41 | } 42 | 43 | @Test 44 | void serializableValue() { 45 | assertEquals(Locale.ENGLISH, encodeDecode(codec, Locale.ENGLISH)); 46 | } 47 | 48 | @Test 49 | void clusterSerializableValue() { 50 | NodeInfo info = new NodeInfo("localhost", 8080, new JsonObject().put("version", "1.0.0")); 51 | NodeInfo decoded = encodeDecode(codec, info); 52 | assertEquals(info, decoded); 53 | assertNotSame(info, decoded); 54 | } 55 | 56 | @Test 57 | void copyCodec() { 58 | assertNotSame(codec, copy(codec)); 59 | } 60 | 61 | @Test 62 | void customClassLoader() throws Exception { 63 | CustomObjectClassLoader classLoader = 64 | new CustomObjectClassLoader(ClassLoader.getSystemClassLoader()); 65 | RedisMapCodec codec = new RedisMapCodec(classLoader); 66 | Class objectClass = 67 | assertDoesNotThrow(() -> classLoader.loadClass(CustomObjectClassLoader.CUSTOM_OBJECT)); 68 | 69 | Object object = objectClass.getDeclaredConstructor().newInstance(); 70 | Object decoded = encodeDecode(codec, object); 71 | assertNotSame(object, decoded); 72 | assertEquals(object, decoded); 73 | assertEquals(classLoader, object.getClass().getClassLoader()); 74 | assertEquals(classLoader, decoded.getClass().getClassLoader()); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/RedissonContextTest.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | import static org.junit.jupiter.api.Assertions.assertNotNull; 6 | import static org.junit.jupiter.api.Assertions.assertNull; 7 | import static org.junit.jupiter.api.Assertions.assertTrue; 8 | 9 | import com.retailsvc.vertx.spi.cluster.redis.config.ClientType; 10 | import com.retailsvc.vertx.spi.cluster.redis.config.RedisConfig; 11 | import org.junit.jupiter.api.Test; 12 | 13 | class RedissonContextTest { 14 | 15 | @Test 16 | void standalone() { 17 | RedissonContext context = new RedissonContext(new RedisConfig()); 18 | assertTrue(context.getRedissonConfig().isSingleConfig()); 19 | var servers = context.getRedissonConfig().useSingleServer(); 20 | assertEquals("redis://127.0.0.1:6379", servers.getAddress()); 21 | assertNull(servers.getUsername()); 22 | assertNull(servers.getPassword()); 23 | } 24 | 25 | @Test 26 | void cluster() { 27 | RedissonContext context = 28 | new RedissonContext( 29 | new RedisConfig() 30 | .setClientType(ClientType.CLUSTER) 31 | .addEndpoint("redis://node1:6379") 32 | .addEndpoint("redis://node2:6379") 33 | .setUsername("username") 34 | .setPassword("password")); 35 | assertTrue(context.getRedissonConfig().isClusterConfig()); 36 | var servers = context.getRedissonConfig().useClusterServers(); 37 | assertThat(servers.getNodeAddresses()).containsOnly("redis://node1:6379", "redis://node2:6379"); 38 | assertEquals("username", servers.getUsername()); 39 | assertEquals("password", servers.getPassword()); 40 | } 41 | 42 | @Test 43 | void replicated() { 44 | RedissonContext context = 45 | new RedissonContext( 46 | new RedisConfig() 47 | .setClientType(ClientType.REPLICATED) 48 | .addEndpoint("redis://node1:6379") 49 | .addEndpoint("redis://node2:6379") 50 | .setResponseTimeout(100)); 51 | var servers = context.getRedissonConfig().useReplicatedServers(); 52 | assertNotNull(servers); 53 | assertThat(servers.getNodeAddresses()).containsOnly("redis://node1:6379", "redis://node2:6379"); 54 | assertNull(servers.getUsername()); 55 | assertNull(servers.getPassword()); 56 | assertEquals(100, servers.getTimeout()); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/config/LockConfig.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.config; 2 | 3 | import io.vertx.codegen.annotations.DataObject; 4 | import io.vertx.codegen.json.annotations.JsonGen; 5 | import io.vertx.core.json.JsonObject; 6 | import java.util.Objects; 7 | import java.util.regex.Pattern; 8 | 9 | /** 10 | * Redis configuration for locks and semaphores. 11 | * 12 | * @author sasjo 13 | */ 14 | @DataObject 15 | @JsonGen 16 | public final class LockConfig extends KeyConfig { 17 | 18 | /** The lease time. */ 19 | private int leaseTime = -1; 20 | 21 | /** Default constructor. */ 22 | public LockConfig() {} 23 | 24 | /** 25 | * Create a named lock config. 26 | * 27 | * @param name lock name 28 | */ 29 | public LockConfig(String name) { 30 | super(name); 31 | } 32 | 33 | /** 34 | * Create a pattern matching lock config. 35 | * 36 | * @param pattern lock name pattern 37 | */ 38 | public LockConfig(Pattern pattern) { 39 | super(pattern); 40 | } 41 | 42 | /** 43 | * Copy constructor. 44 | * 45 | * @param other object to clone 46 | */ 47 | public LockConfig(LockConfig other) { 48 | super(other); 49 | this.leaseTime = other.leaseTime; 50 | } 51 | 52 | /** 53 | * Copy from JSON constructor. 54 | * 55 | * @param json source JSON 56 | */ 57 | public LockConfig(JsonObject json) { 58 | LockConfigConverter.fromJson(json, this); 59 | } 60 | 61 | /** 62 | * Set the lease time for the lock. If set to -1 it means there's no lease time 63 | * specified and the lease will not expire. 64 | * 65 | * @param leaseTime lease time in milliseconds 66 | * @return fluent self 67 | */ 68 | public LockConfig setLeaseTime(int leaseTime) { 69 | this.leaseTime = leaseTime; 70 | return this; 71 | } 72 | 73 | /** 74 | * Returns the lease time for the lock. If -1 it means there's no lease time 75 | * specified and the lease will not expire. 76 | * 77 | * @return the lease time in milliseconds 78 | */ 79 | public int getLeaseTime() { 80 | return leaseTime; 81 | } 82 | 83 | @Override 84 | public String toString() { 85 | return String.format("leaseTime=%d", leaseTime); 86 | } 87 | 88 | /** 89 | * Converts this object to JSON notation. 90 | * 91 | * @return JSON 92 | */ 93 | public JsonObject toJson() { 94 | JsonObject json = new JsonObject(); 95 | LockConfigConverter.toJson(this, json); 96 | return json; 97 | } 98 | 99 | @Override 100 | public boolean equals(Object o) { 101 | if (this == o) return true; 102 | if (o == null || getClass() != o.getClass()) return false; 103 | if (!super.equals(o)) return false; 104 | LockConfig that = (LockConfig) o; 105 | return leaseTime == that.leaseTime; 106 | } 107 | 108 | @Override 109 | public int hashCode() { 110 | return Objects.hash(super.hashCode(), leaseTime); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/core/shareddata/ITCustomClassLoaderAsyncMap.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.core.shareddata; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | 6 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManager; 7 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 8 | import com.retailsvc.vertx.spi.cluster.redis.config.RedisConfig; 9 | import com.retailsvc.vertx.spi.cluster.redis.impl.codec.CustomObjectClassLoader; 10 | import io.vertx.core.spi.cluster.ClusterManager; 11 | import io.vertx.test.core.VertxTestBase; 12 | import org.junit.Rule; 13 | import org.junit.Test; 14 | import org.testcontainers.containers.GenericContainer; 15 | 16 | public class ITCustomClassLoaderAsyncMap extends VertxTestBase { 17 | 18 | @Rule public GenericContainer redis = RedisTestContainerFactory.newContainer(); 19 | 20 | private final CustomObjectClassLoader classLoader = 21 | new CustomObjectClassLoader(ClassLoader.getSystemClassLoader()); 22 | 23 | @Override 24 | protected ClusterManager getClusterManager() { 25 | String redisUrl = "redis://" + redis.getHost() + ":" + redis.getFirstMappedPort(); 26 | return new RedisClusterManager(new RedisConfig().addEndpoint(redisUrl), classLoader); 27 | } 28 | 29 | @Override 30 | public void setUp() throws Exception { 31 | super.setUp(); 32 | startNodes(2); 33 | } 34 | 35 | @Test 36 | public void customObjectNotOnPath() { 37 | assertThrows( 38 | ClassNotFoundException.class, () -> Class.forName(CustomObjectClassLoader.CUSTOM_OBJECT)); 39 | } 40 | 41 | @Test 42 | public void customObjectOnClassLoader() { 43 | assertDoesNotThrow( 44 | () -> Class.forName(CustomObjectClassLoader.CUSTOM_OBJECT, false, classLoader)); 45 | } 46 | 47 | @Test 48 | public void mapPutGetCustomObjectWithClassLoader() throws Exception { 49 | Class objectClass = 50 | assertDoesNotThrow(() -> classLoader.loadClass(CustomObjectClassLoader.CUSTOM_OBJECT)); 51 | Object value = objectClass.getDeclaredConstructor().newInstance(); 52 | assertEquals(classLoader, value.getClass().getClassLoader()); 53 | 54 | vertices[0] 55 | .sharedData() 56 | .getAsyncMap("foo") 57 | .onSuccess( 58 | map -> 59 | map.put("test", value) 60 | .onSuccess( 61 | vd -> 62 | vertices[1] 63 | .sharedData() 64 | .getAsyncMap("foo") 65 | .onSuccess( 66 | map2 -> 67 | map2.get("test") 68 | .onSuccess( 69 | res -> { 70 | assertEquals( 71 | classLoader, res.getClass().getClassLoader()); 72 | assertEquals(value, res); 73 | testComplete(); 74 | }) 75 | .onFailure(this::fail)))); 76 | await(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/config/KeyConfig.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.config; 2 | 3 | import io.vertx.codegen.annotations.GenIgnore; 4 | import io.vertx.codegen.annotations.Nullable; 5 | import java.util.Objects; 6 | import java.util.regex.Pattern; 7 | 8 | /** 9 | * Configuration applied to a named Redis key. 10 | * 11 | * @param the type of configuration 12 | * @author sasjo 13 | */ 14 | abstract class KeyConfig> { 15 | 16 | /** The key name. */ 17 | private String name; 18 | 19 | /** The key name pattern. */ 20 | private String pattern; 21 | 22 | /** Compiled pattern. */ 23 | @GenIgnore private Pattern regex; 24 | 25 | /** Default constructor. */ 26 | KeyConfig() {} 27 | 28 | /** 29 | * Create a named key config. 30 | * 31 | * @param name key name 32 | */ 33 | KeyConfig(String name) { 34 | this.name = name; 35 | } 36 | 37 | /** 38 | * Create a pattern matching key config. 39 | * 40 | * @param pattern key name pattern 41 | */ 42 | KeyConfig(Pattern pattern) { 43 | this.regex = pattern; 44 | this.pattern = pattern.pattern(); 45 | } 46 | 47 | /** 48 | * Copy constructor. 49 | * 50 | * @param other object to clone 51 | */ 52 | KeyConfig(KeyConfig other) { 53 | this.name = other.name; 54 | this.pattern = other.pattern; 55 | this.regex = other.regex; 56 | } 57 | 58 | /** 59 | * Returns the key name. 60 | * 61 | * @return the key name. 62 | */ 63 | public @Nullable String getName() { 64 | return name; 65 | } 66 | 67 | /** 68 | * Set the key name pattern to use. 69 | * 70 | * @param pattern the name pattern 71 | * @return fluent self 72 | */ 73 | @SuppressWarnings("unchecked") 74 | public T setPattern(String pattern) { 75 | this.pattern = pattern; 76 | this.regex = Pattern.compile(pattern); 77 | return (T) this; 78 | } 79 | 80 | /** 81 | * Set the key name. 82 | * 83 | * @param name the key name 84 | * @return fluent self 85 | */ 86 | @SuppressWarnings("unchecked") 87 | public T setName(String name) { 88 | this.name = name; 89 | return (T) this; 90 | } 91 | 92 | /** 93 | * Returns the key name pattern. 94 | * 95 | * @return the key name pattern 96 | */ 97 | public @Nullable String getPattern() { 98 | return pattern; 99 | } 100 | 101 | /** 102 | * Check if the given name matches this key by either name or name pattern. 103 | * 104 | * @param name the name to match against 105 | * @return true if this key configuration matches, otherwise false. 106 | */ 107 | public boolean matches(String name) { 108 | if (this.name == null && this.pattern == null) { 109 | return false; 110 | } else if (this.name != null) { 111 | return this.name.equals(name); 112 | } 113 | return this.regex.matcher(name).matches(); 114 | } 115 | 116 | @Override 117 | public boolean equals(Object o) { 118 | if (this == o) return true; 119 | if (o == null || getClass() != o.getClass()) return false; 120 | KeyConfig keyConfig = (KeyConfig) o; 121 | return Objects.equals(name, keyConfig.name) && Objects.equals(pattern, keyConfig.pattern); 122 | } 123 | 124 | @Override 125 | public int hashCode() { 126 | return Objects.hash(name, pattern); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/ThrottlingTest.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static com.jayway.awaitility.Awaitility.await; 4 | import static java.util.concurrent.TimeUnit.*; 5 | import static org.junit.Assert.assertTrue; 6 | 7 | import java.util.Collections; 8 | import java.util.LinkedList; 9 | import java.util.List; 10 | import java.util.concurrent.*; 11 | import org.junit.After; 12 | import org.junit.Before; 13 | import org.junit.Test; 14 | 15 | public class ThrottlingTest { 16 | 17 | int threadCount = 4; 18 | ExecutorService executorService; 19 | 20 | @Before 21 | public void setUp() { 22 | executorService = Executors.newFixedThreadPool(threadCount); 23 | } 24 | 25 | @Test 26 | public void testInterval() throws Exception { 27 | int duration = 5; 28 | String[] addresses = {"foo", "bar", "baz", "qux"}; 29 | 30 | ConcurrentMap> events = new ConcurrentHashMap<>(addresses.length); 31 | Throttling throttling = 32 | new Throttling( 33 | address -> { 34 | events.compute( 35 | address, 36 | (k, v) -> { 37 | if (v == null) { 38 | v = Collections.synchronizedList(new LinkedList<>()); 39 | } 40 | v.add(System.nanoTime()); 41 | return v; 42 | }); 43 | sleep(1); 44 | }); 45 | 46 | CountDownLatch latch = new CountDownLatch(threadCount); 47 | long start = System.nanoTime(); 48 | for (int i = 0; i < threadCount; i++) { 49 | executorService.submit( 50 | () -> { 51 | try { 52 | do { 53 | sleepMax(5); 54 | throttling.onEvent( 55 | addresses[ThreadLocalRandom.current().nextInt(addresses.length)]); 56 | } while (SECONDS.convert(System.nanoTime() - start, NANOSECONDS) < duration); 57 | } finally { 58 | latch.countDown(); 59 | } 60 | }); 61 | } 62 | latch.await(); 63 | 64 | await() 65 | .atMost(1, SECONDS) 66 | .pollDelay(10, MILLISECONDS) 67 | .until( 68 | () -> { 69 | if (events.size() != addresses.length) { 70 | return false; 71 | } 72 | for (List nanoTimes : events.values()) { 73 | Long previous = null; 74 | for (Long nanoTime : nanoTimes) { 75 | if (previous != null) { 76 | if (MILLISECONDS.convert(nanoTime - previous, NANOSECONDS) < 20) { 77 | return false; 78 | } 79 | } 80 | previous = nanoTime; 81 | } 82 | } 83 | return true; 84 | }); 85 | } 86 | 87 | private void sleepMax(long time) { 88 | sleep(ThreadLocalRandom.current().nextLong(time)); 89 | } 90 | 91 | private void sleep(long time) { 92 | try { 93 | MILLISECONDS.sleep(time); 94 | } catch (InterruptedException e) { 95 | Thread.currentThread().interrupt(); 96 | } 97 | } 98 | 99 | @After 100 | public void tearDown() throws Exception { 101 | executorService.shutdown(); 102 | assertTrue(executorService.awaitTermination(5, SECONDS)); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/ClusterSerializableCodec.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import io.netty.buffer.ByteBuf; 4 | import io.netty.buffer.ByteBufAllocator; 5 | import io.vertx.core.buffer.impl.BufferImpl; 6 | import io.vertx.core.shareddata.impl.ClusterSerializable; 7 | import java.io.IOException; 8 | import java.lang.reflect.InvocationTargetException; 9 | import java.nio.charset.StandardCharsets; 10 | import org.redisson.client.codec.Codec; 11 | import org.redisson.client.protocol.Decoder; 12 | import org.redisson.client.protocol.Encoder; 13 | 14 | /** 15 | * A Redisson codec for {@link io.vertx.core.shareddata.ClusterSerializable} shared data. 16 | * 17 | *

Note: The codec need to support the deprecated {@link 18 | * io.vertx.core.shareddata.impl.ClusterSerializable} until it is removed from Vertx. 19 | * 20 | * @author sasjo 21 | */ 22 | @SuppressWarnings("deprecation") 23 | public class ClusterSerializableCodec extends ClassLoaderCodec { 24 | 25 | /** Codec singleton. */ 26 | public static final Codec INSTANCE = new ClusterSerializableCodec(); 27 | 28 | private final Decoder decoder = 29 | (buf, state) -> { 30 | int classNameLength = buf.readInt(); 31 | String className = buf.readCharSequence(classNameLength, StandardCharsets.UTF_8).toString(); 32 | try { 33 | Object object = 34 | getClassLoader().loadClass(className).getDeclaredConstructor().newInstance(); 35 | if (!(object instanceof ClusterSerializable)) { 36 | throw new IOException( 37 | className + " does not implement " + ClusterSerializable.class.getName()); 38 | } 39 | ((ClusterSerializable) object).readFromBuffer(buf.readerIndex(), BufferImpl.buffer(buf)); 40 | return object; 41 | } catch (InstantiationException 42 | | IllegalAccessException 43 | | ClassNotFoundException 44 | | NoSuchMethodException 45 | | InvocationTargetException e) { 46 | throw new IOException("Failed to decode class " + className, e); 47 | } 48 | }; 49 | 50 | private final Encoder encoder = 51 | in -> { 52 | if (!(in instanceof ClusterSerializable)) { 53 | throw new IOException("Unsupported type: " + in.getClass()); 54 | } 55 | ByteBuf out = ByteBufAllocator.DEFAULT.buffer(); 56 | String className = in.getClass().getName(); 57 | out.writeInt(className.length()); 58 | out.writeCharSequence(className, StandardCharsets.UTF_8); 59 | ((ClusterSerializable) in).writeToBuffer(BufferImpl.buffer(out)); 60 | return out; 61 | }; 62 | 63 | /** Create a ClusterSerializableCodec. */ 64 | public ClusterSerializableCodec() {} 65 | 66 | /** 67 | * Create a ClusterSerializableCodec. 68 | * 69 | * @param classLoader required by Codec contract 70 | */ 71 | public ClusterSerializableCodec(ClassLoader classLoader) { 72 | super(classLoader); 73 | } 74 | 75 | /** 76 | * Create a ClusterSerializableCodec. 77 | * 78 | * @param classLoader required by Codec contract 79 | * @param codec required by Codec contract 80 | */ 81 | public ClusterSerializableCodec(ClassLoader classLoader, ClusterSerializableCodec codec) { 82 | this(classLoader); 83 | } 84 | 85 | @Override 86 | public Decoder getValueDecoder() { 87 | return decoder; 88 | } 89 | 90 | @Override 91 | public Encoder getValueEncoder() { 92 | return encoder; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/ClusterSerializableCodecTest.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 4 | import static org.junit.jupiter.api.Assertions.assertEquals; 5 | import static org.junit.jupiter.api.Assertions.assertInstanceOf; 6 | import static org.junit.jupiter.api.Assertions.assertNotSame; 7 | import static org.junit.jupiter.api.Assertions.assertThrows; 8 | 9 | import io.netty.buffer.ByteBuf; 10 | import io.netty.buffer.ByteBufAllocator; 11 | import io.vertx.core.buffer.Buffer; 12 | import io.vertx.core.buffer.impl.BufferImpl; 13 | import io.vertx.core.json.JsonObject; 14 | import io.vertx.core.shareddata.AsyncMapTest; 15 | import io.vertx.core.spi.cluster.NodeInfo; 16 | import java.io.IOException; 17 | import java.nio.charset.StandardCharsets; 18 | import org.junit.jupiter.api.Test; 19 | import org.redisson.client.codec.Codec; 20 | import org.redisson.client.handler.State; 21 | 22 | class ClusterSerializableCodecTest extends CodecTestBase { 23 | private final Codec codec = ClusterSerializableCodec.INSTANCE; 24 | private final NodeInfo info = 25 | new NodeInfo("localhost", 8080, new JsonObject().put("version", "1.0.0")); 26 | 27 | @Test 28 | void encode() { 29 | ByteBuf buf = assertDoesNotThrow(() -> codec.getValueEncoder().encode(info)); 30 | int length = NodeInfo.class.getName().length(); 31 | assertEquals(length, buf.readInt()); 32 | String encodedClassName = buf.readCharSequence(length, StandardCharsets.UTF_8).toString(); 33 | assertEquals(NodeInfo.class.getName(), encodedClassName); 34 | } 35 | 36 | @Test 37 | void encodeDecode() { 38 | ByteBuf buf = assertDoesNotThrow(() -> codec.getValueEncoder().encode(info)); 39 | Object decoded = assertDoesNotThrow(() -> codec.getValueDecoder().decode(buf, new State())); 40 | assertInstanceOf(NodeInfo.class, decoded); 41 | assertEquals(info, decoded); 42 | } 43 | 44 | @Test 45 | void decodeFailsIfMissingClassName() { 46 | ByteBuf byteBuf = ByteBufAllocator.DEFAULT.buffer(); 47 | Buffer buffer = BufferImpl.buffer(byteBuf); 48 | info.writeToBuffer(buffer); 49 | assertThrows(IOException.class, () -> codec.getValueDecoder().decode(byteBuf, new State())); 50 | } 51 | 52 | @Test 53 | void encodeFailsIfNotClusterSerializable() { 54 | assertThrows(IOException.class, () -> codec.getValueEncoder().encode("Test")); 55 | } 56 | 57 | @Test 58 | void encodeDecodeSomeSerializableClusterObject() { 59 | var original = new AsyncMapTest.SomeClusterSerializableObject("Test"); 60 | ByteBuf buf = assertDoesNotThrow(() -> codec.getValueEncoder().encode(original)); 61 | Object decoded = assertDoesNotThrow(() -> codec.getValueDecoder().decode(buf, new State())); 62 | assertInstanceOf(AsyncMapTest.SomeClusterSerializableObject.class, decoded); 63 | assertEquals(original, decoded); 64 | } 65 | 66 | @SuppressWarnings("deprecation") 67 | @Test 68 | void encodeDecodeSomeClusterSerializableImplObject() { 69 | var original = new AsyncMapTest.SomeClusterSerializableImplObject("Test"); 70 | ByteBuf buf = assertDoesNotThrow(() -> codec.getValueEncoder().encode(original)); 71 | Object decoded = assertDoesNotThrow(() -> codec.getValueDecoder().decode(buf, new State())); 72 | assertInstanceOf(AsyncMapTest.SomeClusterSerializableImplObject.class, decoded); 73 | assertEquals(original, decoded); 74 | } 75 | 76 | @Test 77 | void copyCodec() { 78 | assertNotSame(ClusterSerializableCodec.INSTANCE, copy(ClusterSerializableCodec.INSTANCE)); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/config/MapConfig.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.config; 2 | 3 | import io.vertx.codegen.annotations.DataObject; 4 | import io.vertx.codegen.json.annotations.JsonGen; 5 | import io.vertx.core.json.JsonObject; 6 | import java.util.Objects; 7 | import java.util.regex.Pattern; 8 | 9 | /** 10 | * Redis configuration for a map. 11 | * 12 | * @author sasjo 13 | */ 14 | @DataObject 15 | @JsonGen 16 | public final class MapConfig extends KeyConfig { 17 | /** The map max size. */ 18 | private int maxSize = 0; 19 | 20 | /** The map eviction mode. */ 21 | private EvictionMode evictionMode = EvictionMode.LRU; 22 | 23 | /** 24 | * Create a named map config. 25 | * 26 | * @param name map name 27 | */ 28 | public MapConfig(String name) { 29 | super(name); 30 | } 31 | 32 | /** 33 | * Create a pattern matching map config. 34 | * 35 | * @param pattern map name pattern 36 | */ 37 | public MapConfig(Pattern pattern) { 38 | super(pattern); 39 | } 40 | 41 | /** 42 | * Copy constructor. 43 | * 44 | * @param other object to clone 45 | */ 46 | public MapConfig(MapConfig other) { 47 | super(other); 48 | this.evictionMode = other.evictionMode; 49 | this.maxSize = other.maxSize; 50 | } 51 | 52 | /** 53 | * Copy from JSON constructor. 54 | * 55 | * @param json source JSON 56 | */ 57 | public MapConfig(JsonObject json) { 58 | MapConfigConverter.fromJson(json, this); 59 | } 60 | 61 | /** 62 | * Set the max size of the map. If set to 0, the map is unbounded. 63 | * 64 | * @param maxSize the maximum number of keys in the map 65 | * @return fluent self 66 | */ 67 | public MapConfig setMaxSize(int maxSize) { 68 | this.maxSize = maxSize; 69 | return this; 70 | } 71 | 72 | /** 73 | * Get the max size of the map. If set to (0), it means the map size is unbound. 74 | * 75 | * @return the max number of keys in the map. 76 | */ 77 | public int getMaxSize() { 78 | return maxSize; 79 | } 80 | 81 | /** 82 | * Set the map eviction mode. This is the algorithm used to evict keys when the max size has been 83 | * reached. 84 | * 85 | * @param evictionMode the eviction mode 86 | * @return fluent self 87 | */ 88 | public MapConfig setEvictionMode(EvictionMode evictionMode) { 89 | this.evictionMode = evictionMode; 90 | return this; 91 | } 92 | 93 | /** 94 | * Returns the map eviction mode. 95 | * 96 | * @return the map eviction mode. 97 | */ 98 | public EvictionMode getEvictionMode() { 99 | if (evictionMode == null) { 100 | return EvictionMode.LRU; 101 | } 102 | return evictionMode; 103 | } 104 | 105 | /** 106 | * Converts this object to JSON notation. 107 | * 108 | * @return JSON 109 | */ 110 | public JsonObject toJson() { 111 | JsonObject json = new JsonObject(); 112 | MapConfigConverter.toJson(this, json); 113 | return json; 114 | } 115 | 116 | @Override 117 | public String toString() { 118 | return String.format("maxSize=%d, evictionMode=%s", maxSize, getEvictionMode()); 119 | } 120 | 121 | @Override 122 | public boolean equals(Object o) { 123 | if (this == o) return true; 124 | if (o == null || getClass() != o.getClass()) return false; 125 | if (!super.equals(o)) return false; 126 | MapConfig mapConfig = (MapConfig) o; 127 | return maxSize == mapConfig.maxSize && evictionMode == mapConfig.evictionMode; 128 | } 129 | 130 | @Override 131 | public int hashCode() { 132 | return Objects.hash(super.hashCode(), maxSize, evictionMode); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/codec/RedisMapCodec.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.codec; 2 | 3 | import io.netty.buffer.ByteBuf; 4 | import io.netty.buffer.ByteBufAllocator; 5 | import io.netty.buffer.Unpooled; 6 | import io.vertx.core.shareddata.impl.ClusterSerializable; 7 | import java.io.IOException; 8 | import java.io.Serializable; 9 | import org.redisson.client.codec.BaseCodec; 10 | import org.redisson.client.codec.Codec; 11 | import org.redisson.client.codec.IntegerCodec; 12 | import org.redisson.client.codec.StringCodec; 13 | import org.redisson.client.protocol.Decoder; 14 | import org.redisson.client.protocol.Encoder; 15 | import org.redisson.codec.SerializationCodec; 16 | 17 | /** 18 | * A general purpose Redisson Codec for types supported by shared data maps. 19 | * 20 | * @author sasjo 21 | */ 22 | public class RedisMapCodec extends ClassLoaderCodec { 23 | 24 | private enum ValueCodec { 25 | // Enum is ordered according to codec priority 26 | STRING(String.class, StringCodec.INSTANCE, 0), 27 | INTEGER(Integer.class, IntegerCodec.INSTANCE, 1), 28 | BOOLEAN(Boolean.class, BooleanCodec.INSTANCE, 2), 29 | VERTX(ClusterSerializable.class, ClusterSerializableCodec.INSTANCE, 3), 30 | JDK(Serializable.class, new SerializationCodec(), 4), 31 | NULL(Void.class, NullCodec.INSTANCE, 5), 32 | ; 33 | 34 | private final Class type; 35 | private final Codec codec; 36 | private final int id; 37 | 38 | ValueCodec(Class type, Codec codec, int id) { 39 | this.type = type; 40 | this.codec = codec; 41 | this.id = id; 42 | } 43 | 44 | static ValueCodec forObject(Object value) { 45 | if (value == null) { 46 | return ValueCodec.NULL; 47 | } 48 | for (ValueCodec codec : ValueCodec.values()) { 49 | if (codec.type.isAssignableFrom(value.getClass())) { 50 | return codec; 51 | } 52 | } 53 | throw new IllegalArgumentException("No Codec found for type:" + value.getClass().getName()); 54 | } 55 | 56 | static Codec forEncoded(ByteBuf buf, ClassLoader classLoader) throws IOException { 57 | int id = buf.readInt(); 58 | for (ValueCodec codec : ValueCodec.values()) { 59 | if (codec.id == id) { 60 | try { 61 | return BaseCodec.copy(classLoader, codec.codec); 62 | } catch (ReflectiveOperationException e) { 63 | throw new IOException("Failed to copy Codec " + codec.codec.getClass().getName(), e); 64 | } 65 | } 66 | } 67 | throw new IllegalArgumentException("No Codec found for id:" + id); 68 | } 69 | } 70 | 71 | private final Decoder decoder = 72 | (buf, state) -> { 73 | Codec codec = ValueCodec.forEncoded(buf, getClassLoader()); 74 | return codec.getValueDecoder().decode(buf, state); 75 | }; 76 | 77 | private final Encoder encoder = 78 | in -> { 79 | ValueCodec vc = ValueCodec.forObject(in); 80 | ByteBuf header = ByteBufAllocator.DEFAULT.buffer().writeInt(vc.id); 81 | ByteBuf payload = vc.codec.getValueEncoder().encode(in); 82 | return Unpooled.wrappedBuffer(header, payload); 83 | }; 84 | 85 | /** Create a RedisMapCodec. */ 86 | public RedisMapCodec() {} 87 | 88 | /** 89 | * Create a RedisMapCodec. 90 | * 91 | * @param classLoader required by Codec contract 92 | */ 93 | public RedisMapCodec(ClassLoader classLoader) { 94 | super(classLoader); 95 | } 96 | 97 | /** 98 | * Create a RedisMapCodec. 99 | * 100 | * @param classLoader required by Codec contract 101 | * @param codec required by Codec contract 102 | */ 103 | public RedisMapCodec(ClassLoader classLoader, RedisMapCodec codec) { 104 | this(classLoader); 105 | } 106 | 107 | @Override 108 | public Decoder getValueDecoder() { 109 | return decoder; 110 | } 111 | 112 | @Override 113 | public Encoder getValueEncoder() { 114 | return encoder; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Specific ### 2 | 3 | !src/test/test-class/CustomObject.class 4 | 5 | ### Vert.x ### 6 | .vertx/ 7 | 8 | ### Eclipse ### 9 | 10 | .metadata 11 | bin/ 12 | tmp/ 13 | *.tmp 14 | *.bak 15 | *.swp 16 | *~.nib 17 | local.properties 18 | .settings/ 19 | .loadpath 20 | .recommenders 21 | 22 | # External tool builders 23 | .externalToolBuilders/ 24 | 25 | # Locally stored "Eclipse launch configurations" 26 | *.launch 27 | 28 | # PyDev specific (Python IDE for Eclipse) 29 | *.pydevproject 30 | 31 | # CDT-specific (C/C++ Development Tooling) 32 | .cproject 33 | 34 | # Java annotation processor (APT) 35 | .factorypath 36 | 37 | # PDT-specific (PHP Development Tools) 38 | .buildpath 39 | 40 | # sbteclipse plugin 41 | .target 42 | 43 | # Tern plugin 44 | .tern-project 45 | 46 | # TeXlipse plugin 47 | .texlipse 48 | 49 | # STS (Spring Tool Suite) 50 | .springBeans 51 | 52 | # Code Recommenders 53 | .recommenders/ 54 | 55 | # Scala IDE specific (Scala & Java development for Eclipse) 56 | .cache-main 57 | .scala_dependencies 58 | .worksheet 59 | 60 | ### Intellij+iml ### 61 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 62 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 63 | .idea 64 | 65 | # User-specific stuff: 66 | .idea/**/workspace.xml 67 | .idea/**/tasks.xml 68 | .idea/vcs.xml 69 | .idea/dictionaries 70 | .idea/sonarlint 71 | 72 | # Sensitive or high-churn files: 73 | .idea/**/dataSources/ 74 | .idea/**/dataSources.ids 75 | .idea/**/dataSources.xml 76 | .idea/**/dataSources.local.xml 77 | .idea/**/sqlDataSources.xml 78 | .idea/**/dynamic.xml 79 | .idea/**/uiDesigner.xml 80 | 81 | # Gradle: 82 | .idea/**/gradle.xml 83 | .idea/**/libraries 84 | 85 | # CMake 86 | cmake-buildTool-debug/ 87 | 88 | # Mongo Explorer plugin: 89 | .idea/**/mongoSettings.xml 90 | 91 | ## File-based project format: 92 | *.iws 93 | 94 | ## Plugin-specific files: 95 | 96 | # IntelliJ 97 | /out/ 98 | 99 | # mpeltonen/sbt-idea plugin 100 | .idea_modules/ 101 | 102 | # JIRA plugin 103 | atlassian-ide-plugin.xml 104 | 105 | # Cursive Clojure plugin 106 | .idea/replstate.xml 107 | 108 | # Crashlytics plugin (for Android Studio and IntelliJ) 109 | com_crashlytics_export_strings.xml 110 | crashlytics.properties 111 | crashlytics-buildTool.properties 112 | fabric.properties 113 | 114 | ### Intellij+iml Patch ### 115 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 116 | 117 | *.iml 118 | modules.xml 119 | .idea/misc.xml 120 | *.ipr 121 | 122 | ### macOS ### 123 | *.DS_Store 124 | .AppleDouble 125 | .LSOverride 126 | 127 | # Icon must end with two \r 128 | Icon 129 | 130 | # Thumbnails 131 | ._* 132 | 133 | # Files that might appear in the root of a volume 134 | .DocumentRevisions-V100 135 | .fseventsd 136 | .Spotlight-V100 137 | .TemporaryItems 138 | .Trashes 139 | .VolumeIcon.icns 140 | .com.apple.timemachine.donotpresent 141 | 142 | # Directories potentially created on remote AFP share 143 | .AppleDB 144 | .AppleDesktop 145 | Network Trash Folder 146 | Temporary Items 147 | .apdisk 148 | 149 | ### Maven ### 150 | target/ 151 | pom.xml.tag 152 | pom.xml.releaseBackup 153 | pom.xml.versionsBackup 154 | pom.xml.next 155 | release.properties 156 | dependency-reduced-pom.xml 157 | buildNumber.properties 158 | .mvn/timing.properties 159 | 160 | # Avoid ignoring Maven wrapper jar file (.jar files are usually ignored) 161 | !/.mvn/wrapper/maven-wrapper.jar 162 | 163 | ### Gradle ### 164 | .gradle 165 | /buildTool/ 166 | 167 | # Ignore Gradle GUI config 168 | gradle-app.setting 169 | 170 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 171 | !gradle-wrapper.jar 172 | 173 | # Cache of project 174 | .gradletasknamecache 175 | 176 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 177 | # gradle/wrapper/gradle-wrapper.properties 178 | 179 | ### NetBeans ### 180 | nbproject/private/ 181 | buildTool/ 182 | nbbuild/ 183 | dist/ 184 | nbdist/ 185 | .nb-gradle/ 186 | 187 | ### VisualStudioCode ### 188 | .vscode/* 189 | !.vscode/settings.json 190 | !.vscode/tasks.json 191 | !.vscode/launch.json 192 | !.vscode/extensions.json 193 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/RedisDataGrid.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis; 2 | 3 | import com.retailsvc.vertx.spi.cluster.redis.config.RedisConfig; 4 | import com.retailsvc.vertx.spi.cluster.redis.impl.RedissonContext; 5 | import com.retailsvc.vertx.spi.cluster.redis.impl.RedissonRedisInstance; 6 | import io.vertx.core.Future; 7 | import io.vertx.core.Vertx; 8 | import io.vertx.core.shareddata.AsyncMap; 9 | import io.vertx.core.shareddata.Counter; 10 | import io.vertx.core.shareddata.Lock; 11 | import java.util.Map; 12 | import java.util.concurrent.BlockingDeque; 13 | import java.util.concurrent.BlockingQueue; 14 | 15 | /** 16 | * Redis backed data grid. The data grid can be used without the {@link RedisClusterManager}. 17 | * 18 | * @author sasjo 19 | */ 20 | public interface RedisDataGrid { 21 | 22 | /** 23 | * Create a data grid backed by Redis. 24 | * 25 | * @param vertx the vertx context 26 | * @param config the redis configuration 27 | * @return a data grid backed by Redis. 28 | */ 29 | static RedisDataGrid create(Vertx vertx, RedisConfig config) { 30 | return create(vertx, config, RedisDataGrid.class.getClassLoader()); 31 | } 32 | 33 | /** 34 | * Create a data grid backed by Redis. 35 | * 36 | * @param vertx the vertx context 37 | * @param config the redis configuration 38 | * @param dataClassLoader class loader used to restore keys and values returned from Redis 39 | * @return a data grid backed by Redis. 40 | */ 41 | static RedisDataGrid create(Vertx vertx, RedisConfig config, ClassLoader dataClassLoader) { 42 | return new RedissonRedisInstance(vertx, new RedissonContext(config, dataClassLoader)); 43 | } 44 | 45 | /** 46 | * Get an {@link AsyncMap} with the specified name. The map is accessible to all nodes connected 47 | * to the same Redis instance and data put into the map from any node is visible to any other 48 | * node. 49 | * 50 | *

Warning: The map can only store data structures that can be serialized to 51 | * Redis. Also keep in mind that latency of a distributed map is slower than that of a local map. 52 | * 53 | * @param name the name of the map 54 | * @return the distributed async map. 55 | * @param the key type 56 | * @param the value type 57 | */ 58 | AsyncMap getAsyncMap(String name); 59 | 60 | /** 61 | * Like {@link #getAsyncMap(String)} but exposed as a regular {@link Map}. Prefer {@link 62 | * #getAsyncMap(String)} and use asynchronous API calls to not block on API calls. 63 | * 64 | * @param name the name of the map 65 | * @return the distributed map. 66 | * @param the key type 67 | * @param the value type 68 | */ 69 | Map getMap(String name); 70 | 71 | /** 72 | * Get a counter. The counter value is seen by all nodes connected to the same Redis and changes 73 | * to the counter are visible to in all nodes. 74 | * 75 | * @param name the name of the counter 76 | * @return the distributed counter. 77 | */ 78 | Counter getCounter(String name); 79 | 80 | /** 81 | * Get an asynchronous lock with the specified name. 82 | * 83 | * @param name the name of the lock 84 | * @return a future of the resulting lock. 85 | */ 86 | Future getLock(String name); 87 | 88 | /** 89 | * Like {@link #getLock(String)} but specifying a timeout. If the lock is not obtained within the 90 | * timeout a failure will be sent to the handler. 91 | * 92 | * @param name the name of the lock 93 | * @param timeout the timeout in ms 94 | * @return a future of the resulting lock 95 | */ 96 | Future getLockWithTimeout(String name, long timeout); 97 | 98 | /** 99 | * Returns an unbounded blocking queue backed by Redis. 100 | * 101 | * @param type of value 102 | * @param name name of the queue 103 | * @return A blocking queue instance. 104 | */ 105 | BlockingQueue getBlockingQueue(String name); 106 | 107 | /** 108 | * Returns an unbounded blocking deque backed by Redis. 109 | * 110 | * @param type of value 111 | * @param name name of the deque 112 | * @return a blocking deque instance. 113 | */ 114 | BlockingDeque getBlockingDeque(String name); 115 | 116 | /** 117 | * Returns a topic backed by Redis. 118 | * 119 | * @param type of messages in topic 120 | * @param type the message type of the topic 121 | * @param name the name of the topic 122 | * @return a topic instance, 123 | */ 124 | Topic getTopic(Class type, String name); 125 | 126 | /** 127 | * Shutdown the data grid connection with Redis. After this, future operations on data grid data 128 | * types will fail. Use this method to gracefully terminate the Redis connection as part of the 129 | * application shutdown sequence. 130 | */ 131 | void shutdown(); 132 | } 133 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/ITSubscriptionCatalog.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static java.util.Arrays.asList; 4 | import static java.util.Collections.singleton; 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 7 | import static org.mockito.ArgumentMatchers.any; 8 | import static org.mockito.ArgumentMatchers.anyString; 9 | import static org.mockito.Mockito.mock; 10 | import static org.mockito.Mockito.timeout; 11 | import static org.mockito.Mockito.verify; 12 | import static org.mockito.Mockito.when; 13 | 14 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 15 | import io.vertx.core.spi.cluster.NodeSelector; 16 | import io.vertx.core.spi.cluster.RegistrationInfo; 17 | import io.vertx.core.spi.cluster.RegistrationUpdateEvent; 18 | import org.junit.jupiter.api.BeforeEach; 19 | import org.junit.jupiter.api.Test; 20 | import org.redisson.Redisson; 21 | import org.redisson.api.RedissonClient; 22 | import org.redisson.config.Config; 23 | import org.testcontainers.containers.GenericContainer; 24 | import org.testcontainers.junit.jupiter.Container; 25 | import org.testcontainers.junit.jupiter.Testcontainers; 26 | 27 | @Testcontainers 28 | class ITSubscriptionCatalog { 29 | 30 | @Container public GenericContainer redis = RedisTestContainerFactory.newContainer(); 31 | 32 | private final RedisKeyFactory keyFactory = new RedisKeyFactory("test"); 33 | private NodeSelector nodeSelector; 34 | private SubscriptionCatalog subsCatalog; 35 | 36 | @BeforeEach 37 | void beforeEach() { 38 | nodeSelector = mock(NodeSelector.class); 39 | 40 | String redisUrl = "redis://" + redis.getHost() + ":" + redis.getFirstMappedPort(); 41 | Config config = new Config(); 42 | config.useSingleServer().setAddress(redisUrl); 43 | RedissonClient redisson = Redisson.create(config); 44 | subsCatalog = new SubscriptionCatalog(redisson, keyFactory, nodeSelector); 45 | when(nodeSelector.wantsUpdatesFor(anyString())).thenReturn(true); 46 | } 47 | 48 | private void putSubs() { 49 | subsCatalog.put("sub-1", new RegistrationInfo("node1", 1, false)); 50 | subsCatalog.put("sub-2", new RegistrationInfo("node1", 2, false)); 51 | subsCatalog.put("sub-1", new RegistrationInfo("node2", 3, false)); 52 | } 53 | 54 | @Test 55 | void put() { 56 | putSubs(); 57 | assertThat(subsCatalog.get("sub-1")).hasSize(2); 58 | verify(nodeSelector, timeout(100).atLeast(1)) 59 | .registrationsUpdated(any(RegistrationUpdateEvent.class)); 60 | } 61 | 62 | @Test 63 | void remove() { 64 | putSubs(); 65 | subsCatalog.remove("sub-1", new RegistrationInfo("node1", 1, false)); 66 | verify(nodeSelector, timeout(100).atLeast(2)) 67 | .registrationsUpdated(any(RegistrationUpdateEvent.class)); 68 | } 69 | 70 | @Test 71 | void removeLocal() { 72 | RegistrationInfo reg = new RegistrationInfo("node1", 1, true); 73 | subsCatalog.put("local-1", reg); 74 | assertThat(subsCatalog.get("local-1")).containsOnly(reg); 75 | 76 | subsCatalog.remove("local-1", reg); 77 | assertThat(subsCatalog.get("local-1")).isEmpty(); 78 | } 79 | 80 | @Test 81 | void republishOwn() { 82 | subsCatalog.put("local-1", new RegistrationInfo("node1", 1, true)); 83 | subsCatalog.put("remote-1", new RegistrationInfo("node1", 1, false)); 84 | subsCatalog.republishOwnSubs(); 85 | verify(nodeSelector, timeout(100).atLeast(2)) 86 | .registrationsUpdated(any(RegistrationUpdateEvent.class)); 87 | } 88 | 89 | @Test 90 | void removeUnknownSubs() { 91 | putSubs(); 92 | subsCatalog.put("sub-1", new RegistrationInfo("node3", 4, false)); 93 | subsCatalog.put("sub-1", new RegistrationInfo("node4", 5, false)); 94 | subsCatalog.removeUnknownSubs("node4", asList("node2", "node3")); 95 | assertThat(subsCatalog.get("sub-1")) 96 | .containsOnly( 97 | new RegistrationInfo("node2", 3, false), 98 | new RegistrationInfo("node3", 4, false), 99 | new RegistrationInfo("node4", 5, false)); 100 | assertThat(subsCatalog.get("sub-2")).isEmpty(); 101 | verify(nodeSelector, timeout(100).atLeast(1)) 102 | .registrationsUpdated(any(RegistrationUpdateEvent.class)); 103 | } 104 | 105 | @Test 106 | void removeUnknownSubsNoOp() { 107 | putSubs(); 108 | subsCatalog.removeUnknownSubs("node3", asList("node1", "node2")); 109 | assertThat(subsCatalog.get("sub-1")).hasSize(2); 110 | assertThat(subsCatalog.get("sub-2")).hasSize(1); 111 | } 112 | 113 | @Test 114 | void removeForAllNodes() { 115 | putSubs(); 116 | subsCatalog.removeAllForNodes(singleton("node1")); 117 | assertThat(subsCatalog.get("sub-1")).doesNotContain(new RegistrationInfo("node1", 1, false)); 118 | assertThat(subsCatalog.get("sub-2")).isEmpty(); 119 | } 120 | 121 | @Test 122 | void close() { 123 | assertDoesNotThrow(subsCatalog::close); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/NodeInfoCatalog.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static java.util.stream.Collectors.joining; 4 | 5 | import io.vertx.core.Vertx; 6 | import io.vertx.core.spi.cluster.NodeInfo; 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | import java.util.concurrent.ExecutorService; 10 | import java.util.concurrent.Executors; 11 | import java.util.concurrent.TimeUnit; 12 | import java.util.concurrent.atomic.AtomicReference; 13 | import org.redisson.api.RMapCache; 14 | import org.redisson.api.RedissonClient; 15 | import org.redisson.api.map.event.EntryCreatedListener; 16 | import org.redisson.api.map.event.EntryExpiredListener; 17 | import org.redisson.api.map.event.EntryRemovedListener; 18 | 19 | /** 20 | * Track Vert.x nodes registration in Redis. 21 | * 22 | * @author sasjo 23 | */ 24 | public class NodeInfoCatalog { 25 | 26 | /** Node time-to-live in Redis cache. */ 27 | private static final int TTL_SECONDS = 30; 28 | 29 | private final RMapCache nodeInfoMap; 30 | private final Vertx vertx; 31 | private final String nodeId; 32 | private final List listenerIds = new ArrayList<>(); 33 | private final long timerId; 34 | private final ExecutorService executor = 35 | Executors.newSingleThreadExecutor(r -> new Thread(r, "vertx-redis-nodeInfo-thread")); 36 | private final AtomicReference nodeInfo = new AtomicReference<>(); 37 | 38 | /** 39 | * Create the cluster node info catalog. 40 | * 41 | * @param vertx the Vertx instance 42 | * @param redisson the Redisson client 43 | * @param keyFactory the key factory 44 | * @param nodeId the unique node ID 45 | * @param listener a listener for node registration in the cluster 46 | */ 47 | public NodeInfoCatalog( 48 | Vertx vertx, 49 | RedissonClient redisson, 50 | RedisKeyFactory keyFactory, 51 | String nodeId, 52 | NodeInfoCatalogListener listener) { 53 | this.vertx = vertx; 54 | this.nodeId = nodeId; 55 | nodeInfoMap = redisson.getMapCache(keyFactory.vertx("nodeInfo")); 56 | 57 | // These listeners will detect map modifications from other nodes. 58 | EntryCreatedListener entryCreated = 59 | event -> executor.submit(() -> listener.memberAdded(event.getKey())); 60 | EntryRemovedListener entryRemoved = 61 | event -> executor.submit(() -> listener.memberRemoved(event.getKey())); 62 | EntryExpiredListener entryExpired = 63 | event -> executor.submit(() -> listener.memberRemoved(event.getKey())); 64 | 65 | listenerIds.add(nodeInfoMap.addListener(entryCreated)); 66 | listenerIds.add(nodeInfoMap.addListener(entryRemoved)); 67 | listenerIds.add(nodeInfoMap.addListener(entryExpired)); 68 | 69 | // This periodic timer will keep the node from expiring as long as the process is running. 70 | timerId = vertx.setPeriodic(TimeUnit.SECONDS.toMillis(TTL_SECONDS / 2), id -> registerNode()); 71 | } 72 | 73 | /** Register the node in the catalog. This will keep the node alive for TTL_SECONDS. */ 74 | private void registerNode() { 75 | executor.submit( 76 | () -> { 77 | NodeInfo info = nodeInfo.get(); 78 | if (info != null) { 79 | nodeInfoMap.fastPut(nodeId, info, TTL_SECONDS, TimeUnit.SECONDS); 80 | } 81 | }); 82 | } 83 | 84 | /** 85 | * Return information about a node in the cluster. 86 | * 87 | * @param nodeId the node ID 88 | * @return the node information or null if not a cluster member 89 | */ 90 | public NodeInfo get(String nodeId) { 91 | return nodeInfoMap.get(nodeId); 92 | } 93 | 94 | /** 95 | * Store the node information for this running node. 96 | * 97 | * @param nodeInfo the node information 98 | */ 99 | public void setNodeInfo(NodeInfo nodeInfo) { 100 | this.nodeInfo.set(nodeInfo); 101 | registerNode(); 102 | } 103 | 104 | /** 105 | * Remove a node from the cluster. 106 | * 107 | * @param nodeId the node ID to remove 108 | */ 109 | public void remove(String nodeId) { 110 | nodeInfoMap.fastRemove(nodeId); 111 | } 112 | 113 | /** 114 | * Return a list of node identifiers corresponding to the nodes in the cluster. 115 | * 116 | * @return a list of node identifiers. 117 | */ 118 | public List getNodes() { 119 | return new ArrayList<>(nodeInfoMap.readAllKeySet()); 120 | } 121 | 122 | /** Close the catalog. */ 123 | public void close() { 124 | listenerIds.forEach(nodeInfoMap::removeListener); 125 | setNodeInfo(null); 126 | vertx.cancelTimer(timerId); 127 | } 128 | 129 | @Override 130 | public String toString() { 131 | return nodeInfoMap.entrySet().stream() 132 | .map( 133 | entry -> { 134 | StringBuilder sb = 135 | new StringBuilder(" - [") 136 | .append(entry.getKey()) 137 | .append("]: ") 138 | .append(entry.getValue()); 139 | if (entry.getKey().equals(nodeId)) { 140 | sb.append(" (self)"); 141 | } 142 | return sb.toString(); 143 | }) 144 | .collect(joining("\n")); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/shareddata/RedisAsyncMap.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl.shareddata; 2 | 3 | import static java.util.concurrent.TimeUnit.MILLISECONDS; 4 | 5 | import io.vertx.core.Future; 6 | import io.vertx.core.Vertx; 7 | import io.vertx.core.shareddata.AsyncMap; 8 | import java.time.Duration; 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | import java.util.Map; 12 | import java.util.Set; 13 | import java.util.concurrent.CompletionStage; 14 | import org.redisson.api.RMapCache; 15 | 16 | /** 17 | * Redis implementation of AsyncMap. 18 | * 19 | * @param the map key type 20 | * @param the map value type 21 | * @author sasjo 22 | */ 23 | public class RedisAsyncMap implements AsyncMap { 24 | 25 | private final Vertx vertx; 26 | private final RMapCache map; 27 | 28 | /** 29 | * Create a new Redis async map. 30 | * 31 | * @param vertx the vertx instance 32 | * @param map the Redisson map backing the async map 33 | */ 34 | public RedisAsyncMap(Vertx vertx, RMapCache map) { 35 | this.vertx = vertx; 36 | this.map = map; 37 | } 38 | 39 | @Override 40 | public Future get(K k) { 41 | return Future.fromCompletionStage(map.getAsync(k), vertx.getOrCreateContext()); 42 | } 43 | 44 | @Override 45 | public Future put(K k, V v) { 46 | // putAsync is used because fastPutAsync fails acceptance tests. 47 | return Future.fromCompletionStage(map.putAsync(k, v), vertx.getOrCreateContext()) 48 | .map(result -> null); 49 | } 50 | 51 | @Override 52 | public Future put(K k, V v, long ttl) { 53 | // putAsync is used because fastPutAsync fails in acceptance tests. 54 | return Future.fromCompletionStage( 55 | map.putAsync(k, v, ttl, MILLISECONDS), vertx.getOrCreateContext()) 56 | .map(result -> null); 57 | } 58 | 59 | @Override 60 | public Future putIfAbsent(K k, V v) { 61 | return Future.fromCompletionStage(map.putIfAbsentAsync(k, v), vertx.getOrCreateContext()); 62 | } 63 | 64 | @Override 65 | public Future putIfAbsent(K k, V v, long ttl) { 66 | return Future.fromCompletionStage( 67 | map.putIfAbsentAsync(k, v, ttl, MILLISECONDS), vertx.getOrCreateContext()); 68 | } 69 | 70 | @Override 71 | public Future remove(K k) { 72 | return Future.fromCompletionStage(map.removeAsync(k), vertx.getOrCreateContext()); 73 | } 74 | 75 | @Override 76 | public Future removeIfPresent(K k, V v) { 77 | return Future.fromCompletionStage(map.removeAsync(k, v), vertx.getOrCreateContext()); 78 | } 79 | 80 | @Override 81 | public Future replace(K k, V v) { 82 | return Future.fromCompletionStage(map.replaceAsync(k, v), vertx.getOrCreateContext()); 83 | } 84 | 85 | private CompletionStage updateTimeToLive(K k, long ttl) { 86 | return map.expireEntryAsync(k, Duration.ofMillis(ttl), Duration.ofMillis(0)); 87 | } 88 | 89 | private CompletionStage succeededWith(R value) { 90 | return Future.succeededFuture(value).toCompletionStage(); 91 | } 92 | 93 | @Override 94 | public Future replace(K k, V v, long ttl) { 95 | return Future.fromCompletionStage( 96 | map.replaceAsync(k, v) 97 | .thenCompose( 98 | result -> { 99 | if (result != null) { 100 | // Replace was successful, update TTL 101 | return updateTimeToLive(k, ttl).thenCompose(b -> succeededWith(result)); 102 | } else { 103 | return succeededWith(null); 104 | } 105 | }), 106 | vertx.getOrCreateContext()); 107 | } 108 | 109 | @Override 110 | public Future replaceIfPresent(K k, V oldValue, V newValue) { 111 | return Future.fromCompletionStage( 112 | map.replaceAsync(k, oldValue, newValue), vertx.getOrCreateContext()); 113 | } 114 | 115 | @Override 116 | public Future replaceIfPresent(K k, V oldValue, V newValue, long ttl) { 117 | return Future.fromCompletionStage( 118 | map.replaceAsync(k, oldValue, newValue) 119 | .thenCompose( 120 | result -> { 121 | if (Boolean.TRUE.equals(result)) { 122 | // Replace was successful, update TTL 123 | return updateTimeToLive(k, ttl).thenCompose(b -> succeededWith(true)); 124 | } else { 125 | return succeededWith(false); 126 | } 127 | }), 128 | vertx.getOrCreateContext()); 129 | } 130 | 131 | @Override 132 | public Future clear() { 133 | return Future.fromCompletionStage(map.deleteAsync(), vertx.getOrCreateContext()) 134 | .map(result -> null); 135 | } 136 | 137 | @Override 138 | public Future size() { 139 | return Future.fromCompletionStage(map.sizeAsync(), vertx.getOrCreateContext()); 140 | } 141 | 142 | @Override 143 | public Future> keys() { 144 | return Future.fromCompletionStage(map.readAllKeySetAsync(), vertx.getOrCreateContext()); 145 | } 146 | 147 | @Override 148 | public Future> values() { 149 | return Future.fromCompletionStage(map.readAllValuesAsync(), vertx.getOrCreateContext()) 150 | .map(v -> v instanceof List ? (List) v : new ArrayList<>(v)); 151 | } 152 | 153 | @Override 154 | public Future> entries() { 155 | return Future.fromCompletionStage(map.readAllMapAsync(), vertx.getOrCreateContext()); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/RedissonContext.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static com.retailsvc.vertx.spi.cluster.redis.impl.CloseableLock.lock; 4 | 5 | import com.retailsvc.vertx.spi.cluster.redis.config.RedisConfig; 6 | import com.retailsvc.vertx.spi.cluster.redis.impl.codec.RedisMapCodec; 7 | import io.vertx.core.shareddata.AsyncMap; 8 | import java.util.Objects; 9 | import java.util.Optional; 10 | import java.util.concurrent.ConcurrentHashMap; 11 | import java.util.concurrent.ConcurrentMap; 12 | import java.util.concurrent.ExecutorService; 13 | import java.util.concurrent.Executors; 14 | import java.util.concurrent.locks.Lock; 15 | import java.util.concurrent.locks.ReentrantLock; 16 | import org.redisson.Redisson; 17 | import org.redisson.api.RedissonClient; 18 | import org.redisson.config.BaseConfig; 19 | import org.redisson.config.ClusterServersConfig; 20 | import org.redisson.config.Config; 21 | import org.redisson.config.ReplicatedServersConfig; 22 | 23 | /** 24 | * Redisson context with the active Redisson client. 25 | * 26 | * @author sasjo 27 | */ 28 | public final class RedissonContext { 29 | 30 | private final Config redisConfig; 31 | private final RedisKeyFactory keyFactory; 32 | private final RedisConfig config; 33 | private final ConcurrentMap> asyncMapCache = new ConcurrentHashMap<>(); 34 | private final ConcurrentMap locksCache = new ConcurrentHashMap<>(); 35 | 36 | private RedissonClient client; 37 | private ExecutorService lockReleaseExec; 38 | private final Lock lock = new ReentrantLock(); 39 | 40 | /** 41 | * Create a new Redisson context with specified configuration. 42 | * 43 | * @param config the redis configuration 44 | */ 45 | public RedissonContext(RedisConfig config) { 46 | this(config, RedissonContext.class.getClassLoader()); 47 | } 48 | 49 | /** 50 | * Create a new Redisson context with specified configuration. 51 | * 52 | * @param config the redis configuration 53 | * @param dataClassLoader class loader used to restore keys and values returned from Redis 54 | */ 55 | public RedissonContext(RedisConfig config, ClassLoader dataClassLoader) { 56 | Objects.requireNonNull(dataClassLoader); 57 | redisConfig = new Config(); 58 | 59 | BaseConfig serverConfig = 60 | switch (config.getClientType()) { 61 | case STANDALONE -> redisConfig 62 | .useSingleServer() 63 | .setAddress(config.getEndpoints().getFirst()); 64 | case CLUSTER -> { 65 | ClusterServersConfig clusterConfig = redisConfig.useClusterServers(); 66 | clusterConfig.setNodeAddresses(config.getEndpoints()); 67 | yield clusterConfig; 68 | } 69 | case REPLICATED -> { 70 | ReplicatedServersConfig replicatedConfig = redisConfig.useReplicatedServers(); 71 | replicatedConfig.setNodeAddresses(config.getEndpoints()); 72 | yield replicatedConfig; 73 | } 74 | }; 75 | 76 | Optional.ofNullable(config.getUsername()).ifPresent(serverConfig::setUsername); 77 | Optional.ofNullable(config.getPassword()).ifPresent(serverConfig::setPassword); 78 | Optional.ofNullable(config.getResponseTimeout()).ifPresent(serverConfig::setTimeout); 79 | 80 | redisConfig.setCodec(new RedisMapCodec(dataClassLoader)); 81 | if (dataClassLoader != getClass().getClassLoader()) { 82 | redisConfig.setUseThreadClassLoader(false); 83 | } 84 | keyFactory = new RedisKeyFactory(config.getKeyNamespace()); 85 | this.config = new RedisConfig(config); 86 | } 87 | 88 | /** 89 | * Get the Redis configuration. 90 | * 91 | * @return the redis configuration. 92 | */ 93 | public RedisConfig config() { 94 | return config; 95 | } 96 | 97 | /** 98 | * Get the Redis key factory. 99 | * 100 | * @return the key factory. 101 | */ 102 | public RedisKeyFactory keyFactory() { 103 | return keyFactory; 104 | } 105 | 106 | /** 107 | * Get the Redisson client. The client is lazily created the first time it is accessed. 108 | * 109 | * @return the Redisson client. 110 | */ 111 | public RedissonClient client() { 112 | try (var ignored = lock(lock)) { 113 | if (client == null) { 114 | client = Redisson.create(redisConfig); 115 | lockReleaseExec = 116 | Executors.newCachedThreadPool( 117 | Thread.ofPlatform().name("vertx-redis-service-release-lock-thread", 1).factory()); 118 | } 119 | return client; 120 | } 121 | } 122 | 123 | ConcurrentMap locksCache() { 124 | return locksCache; 125 | } 126 | 127 | ConcurrentMap> asyncMapCache() { 128 | return asyncMapCache; 129 | } 130 | 131 | ExecutorService lockReleaseExec() { 132 | return lockReleaseExec; 133 | } 134 | 135 | /** 136 | * Returns the Redisson config populated from the context. 137 | * 138 | *

Visible for tests. 139 | * 140 | * @return the Redisson config. 141 | */ 142 | Config getRedissonConfig() { 143 | return redisConfig; 144 | } 145 | 146 | /** Shutdown the Redisson client. */ 147 | public void shutdown() { 148 | try (var ignored = lock(lock)) { 149 | if (client != null) { 150 | client.shutdown(); 151 | lockReleaseExec.shutdown(); 152 | locksCache.clear(); 153 | asyncMapCache.clear(); 154 | } 155 | client = null; 156 | lockReleaseExec = null; 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/Throttling.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static com.retailsvc.vertx.spi.cluster.redis.impl.CloseableLock.lock; 4 | 5 | import java.util.concurrent.ConcurrentHashMap; 6 | import java.util.concurrent.ConcurrentMap; 7 | import java.util.concurrent.Executors; 8 | import java.util.concurrent.ScheduledExecutorService; 9 | import java.util.concurrent.TimeUnit; 10 | import java.util.concurrent.atomic.AtomicInteger; 11 | import java.util.concurrent.locks.Condition; 12 | import java.util.concurrent.locks.Lock; 13 | import java.util.concurrent.locks.ReentrantLock; 14 | import java.util.function.Consumer; 15 | 16 | /** 17 | * Throttle subscription catalog events. This class is based on the throttle implementation for Hazelcast. 19 | */ 20 | class Throttling { 21 | 22 | private enum State { 23 | NEW { 24 | @Override 25 | State pending() { 26 | return PENDING; 27 | } 28 | 29 | @Override 30 | State start() { 31 | return RUNNING; 32 | } 33 | }, 34 | PENDING { 35 | @Override 36 | State pending() { 37 | return this; 38 | } 39 | 40 | @Override 41 | State start() { 42 | return RUNNING; 43 | } 44 | }, 45 | RUNNING { 46 | @Override 47 | State pending() { 48 | return RUNNING_PENDING; 49 | } 50 | 51 | @Override 52 | State done() { 53 | return FINISHED; 54 | } 55 | }, 56 | RUNNING_PENDING { 57 | @Override 58 | State pending() { 59 | return this; 60 | } 61 | 62 | @Override 63 | State done() { 64 | return FINISHED_PENDING; 65 | } 66 | }, 67 | FINISHED { 68 | @Override 69 | State pending() { 70 | return FINISHED_PENDING; 71 | } 72 | 73 | @Override 74 | State next() { 75 | return null; 76 | } 77 | }, 78 | FINISHED_PENDING { 79 | @Override 80 | State pending() { 81 | return this; 82 | } 83 | 84 | @Override 85 | State next() { 86 | return NEW; 87 | } 88 | }; 89 | 90 | State pending() { 91 | throw new IllegalStateException(); 92 | } 93 | 94 | State start() { 95 | throw new IllegalStateException(); 96 | } 97 | 98 | State done() { 99 | throw new IllegalStateException(); 100 | } 101 | 102 | State next() { 103 | throw new IllegalStateException(); 104 | } 105 | } 106 | 107 | private static final long SCHEDULE_DELAY = 20; 108 | 109 | private final Consumer action; 110 | private final ScheduledExecutorService executorService; 111 | private final ConcurrentMap map; 112 | 113 | /** 114 | * The counter is incremented when a new event is received. 115 | * 116 | *

It is decremented: 117 | * 118 | *

    119 | *
  • immediately if the map already contains an entry for the corresponding address, or 120 | *
  • when the map entry is removed 121 | *
122 | * 123 | * When the close method is invoked, the counter is set to -1 and the previous value (N) is 124 | * stored. A negative counter value prevents new events from being handled. The close method 125 | * blocks until the counter reaches the value -(1 + N). This allows to stop the throttling 126 | * gracefully. 127 | */ 128 | private final AtomicInteger counter; 129 | 130 | private final Lock lock; 131 | private final Condition condition; 132 | 133 | Throttling(Consumer action) { 134 | this.action = action; 135 | this.executorService = 136 | Executors.newSingleThreadScheduledExecutor( 137 | r -> { 138 | Thread thread = new Thread(r, "vertx-redis-throttling-thread"); 139 | thread.setDaemon(true); 140 | return thread; 141 | }); 142 | map = new ConcurrentHashMap<>(); 143 | counter = new AtomicInteger(); 144 | lock = new ReentrantLock(); 145 | condition = lock.newCondition(); 146 | } 147 | 148 | public void onEvent(String address) { 149 | if (!tryIncrementCounter()) { 150 | return; 151 | } 152 | State curr = map.compute(address, (s, state) -> state == null ? State.NEW : state.pending()); 153 | if (curr == State.NEW) { 154 | executorService.execute(() -> run(address)); 155 | } else { 156 | decrementCounter(); 157 | } 158 | } 159 | 160 | private void run(String address) { 161 | map.computeIfPresent(address, (s, state) -> state.start()); 162 | try { 163 | action.accept(address); 164 | } finally { 165 | map.computeIfPresent(address, (s, state) -> state.done()); 166 | executorService.schedule(() -> checkState(address), SCHEDULE_DELAY, TimeUnit.MILLISECONDS); 167 | } 168 | } 169 | 170 | private void checkState(String address) { 171 | State curr = map.computeIfPresent(address, (s, state) -> state.next()); 172 | if (curr == State.NEW) { 173 | run(address); 174 | } else { 175 | decrementCounter(); 176 | } 177 | } 178 | 179 | private boolean tryIncrementCounter() { 180 | int i; 181 | do { 182 | i = counter.get(); 183 | if (i < 0) { 184 | return false; 185 | } 186 | } while (!counter.compareAndSet(i, i + 1)); 187 | return true; 188 | } 189 | 190 | private void decrementCounter() { 191 | if (counter.decrementAndGet() < 0) { 192 | try (var ignored = lock(lock)) { 193 | condition.signalAll(); 194 | } 195 | } 196 | } 197 | 198 | public void close() { 199 | try (var ignored = lock(lock)) { 200 | int i = counter.getAndSet(-1); 201 | if (i == 0) { 202 | return; 203 | } 204 | boolean interrupted = false; 205 | do { 206 | try { 207 | condition.await(); 208 | } catch (InterruptedException e) { // NOSONAR 209 | interrupted = true; 210 | } 211 | } while (counter.get() != -(i + 1)); 212 | if (interrupted) { 213 | Thread.currentThread().interrupt(); 214 | } 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/RedissonRedisInstance.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static java.util.concurrent.TimeUnit.MILLISECONDS; 4 | import static java.util.concurrent.TimeUnit.NANOSECONDS; 5 | 6 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManager; 7 | import com.retailsvc.vertx.spi.cluster.redis.RedisDataGrid; 8 | import com.retailsvc.vertx.spi.cluster.redis.RedisInstance; 9 | import com.retailsvc.vertx.spi.cluster.redis.Topic; 10 | import com.retailsvc.vertx.spi.cluster.redis.config.LockConfig; 11 | import com.retailsvc.vertx.spi.cluster.redis.config.RedisConfig; 12 | import com.retailsvc.vertx.spi.cluster.redis.impl.shareddata.RedisAsyncMap; 13 | import com.retailsvc.vertx.spi.cluster.redis.impl.shareddata.RedisCounter; 14 | import com.retailsvc.vertx.spi.cluster.redis.impl.shareddata.RedisLock; 15 | import io.vertx.core.Future; 16 | import io.vertx.core.Vertx; 17 | import io.vertx.core.VertxException; 18 | import io.vertx.core.shareddata.AsyncMap; 19 | import io.vertx.core.shareddata.Counter; 20 | import io.vertx.core.shareddata.Lock; 21 | import java.util.Map; 22 | import java.util.concurrent.BlockingDeque; 23 | import java.util.concurrent.BlockingQueue; 24 | import org.redisson.api.EvictionMode; 25 | import org.redisson.api.RMapCache; 26 | import org.redisson.api.RPermitExpirableSemaphore; 27 | import org.redisson.api.RSemaphore; 28 | import org.redisson.api.RedissonClient; 29 | import org.redisson.api.redisnode.RedisNodes; 30 | import org.slf4j.Logger; 31 | import org.slf4j.LoggerFactory; 32 | 33 | /** 34 | * Redis instance backed by Redisson. 35 | * 36 | * @author sasjo 37 | * @see RedisClusterManager 38 | * @see RedisDataGrid 39 | */ 40 | public final class RedissonRedisInstance implements RedisInstance { 41 | 42 | private static final Logger log = LoggerFactory.getLogger(RedissonRedisInstance.class); 43 | private static final long DEFAULT_LOCK_TIMEOUT = 10 * 1000L; 44 | 45 | private final Vertx vertx; 46 | private final RedisConfig config; 47 | private final RedisKeyFactory keyFactory; 48 | private final RedissonContext redissonContext; 49 | private final RedissonClient redisson; 50 | 51 | /** 52 | * Create a new instance. 53 | * 54 | * @param vertx the Vertx context 55 | * @param redissonContext the Redisson context 56 | */ 57 | public RedissonRedisInstance(Vertx vertx, RedissonContext redissonContext) { 58 | this.vertx = vertx; 59 | this.redissonContext = redissonContext; 60 | this.redisson = redissonContext.client(); 61 | this.config = redissonContext.config(); 62 | this.keyFactory = redissonContext.keyFactory(); 63 | } 64 | 65 | @Override 66 | public boolean ping() { 67 | return switch (config.getClientType()) { 68 | case STANDALONE -> redisson.getRedisNodes(RedisNodes.SINGLE).pingAll(); 69 | case CLUSTER, REPLICATED -> redisson.getRedisNodes(RedisNodes.CLUSTER).pingAll(); 70 | }; 71 | } 72 | 73 | @Override 74 | public Map getMap(String name) { 75 | return getMapCache(name); 76 | } 77 | 78 | @Override 79 | @SuppressWarnings("unchecked") 80 | public AsyncMap getAsyncMap(String name) { 81 | return (AsyncMap) 82 | redissonContext 83 | .asyncMapCache() 84 | .computeIfAbsent(name, key -> new RedisAsyncMap<>(vertx, getMapCache(key))); 85 | } 86 | 87 | private RMapCache getMapCache(String name) { 88 | RMapCache map = redisson.getMapCache(keyFactory.map(name)); 89 | log.debug("Create map '{}'", name); 90 | config 91 | .getMapConfig(name) 92 | .ifPresent( 93 | mapConfig -> { 94 | log.debug("Configure map '{}' with {}", name, mapConfig); 95 | map.setMaxSize( 96 | mapConfig.getMaxSize(), EvictionMode.valueOf(mapConfig.getEvictionMode().name())); 97 | }); 98 | return map; 99 | } 100 | 101 | @Override 102 | public Counter getCounter(String name) { 103 | return new RedisCounter(vertx, redisson.getAtomicLong(keyFactory.counter(name))); 104 | } 105 | 106 | @Override 107 | public Future getLock(String name) { 108 | return getLockWithTimeout(name, DEFAULT_LOCK_TIMEOUT); 109 | } 110 | 111 | @Override 112 | public Future getLockWithTimeout(String name, long timeout) { 113 | return vertx.executeBlocking( 114 | () -> { 115 | SemaphoreWrapper semaphore = 116 | redissonContext.locksCache().computeIfAbsent(name, this::createSemaphore); 117 | RedisLock lock; 118 | long remaining = timeout; 119 | do { 120 | long start = System.nanoTime(); 121 | try { 122 | lock = semaphore.tryAcquire(remaining, redissonContext.lockReleaseExec()); 123 | } catch (InterruptedException e) { 124 | Thread.currentThread().interrupt(); 125 | throw new VertxException("Interrupted while waiting for lock.", e); 126 | } 127 | remaining = remaining - MILLISECONDS.convert(System.nanoTime() - start, NANOSECONDS); 128 | } while (lock == null && remaining > 0); 129 | if (lock != null) { 130 | return lock; 131 | } else { 132 | throw new VertxException("Timed out waiting to get lock " + name); 133 | } 134 | }, 135 | false); 136 | } 137 | 138 | private SemaphoreWrapper createSemaphore(String name) { 139 | int leaseTime = 140 | redissonContext.config().getLockConfig(name).map(LockConfig::getLeaseTime).orElse(-1); 141 | if (leaseTime == -1) { 142 | log.debug("Create semaphore '{}'", name); 143 | RSemaphore semaphore = redisson.getSemaphore(keyFactory.lock(name)); 144 | semaphore.trySetPermits(1); 145 | return new SemaphoreWrapper(semaphore); 146 | } 147 | 148 | log.debug("Create semaphore '{}' with leaseTime={}", name, leaseTime); 149 | RPermitExpirableSemaphore semaphore = 150 | redisson.getPermitExpirableSemaphore(keyFactory.lock(name)); 151 | semaphore.trySetPermits(1); 152 | return new SemaphoreWrapper(semaphore, leaseTime); 153 | } 154 | 155 | @Override 156 | public BlockingQueue getBlockingQueue(String name) { 157 | return redisson.getBlockingQueue(keyFactory.build(name)); 158 | } 159 | 160 | @Override 161 | public BlockingDeque getBlockingDeque(String name) { 162 | return redisson.getBlockingDeque(keyFactory.build(name)); 163 | } 164 | 165 | @Override 166 | public Topic getTopic(Class type, String name) { 167 | return new RedisTopic<>(vertx, type, redisson.getTopic(keyFactory.build(name))); 168 | } 169 | 170 | @Override 171 | public void shutdown() { 172 | redissonContext.shutdown(); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/config/RedisConfigTest.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.config; 2 | 3 | import static java.util.Collections.singletonList; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 6 | import static org.junit.jupiter.api.Assertions.assertEquals; 7 | import static org.junit.jupiter.api.Assertions.assertNotEquals; 8 | import static org.junit.jupiter.api.Assertions.assertNotSame; 9 | import static org.junit.jupiter.api.Assertions.assertThrows; 10 | 11 | import io.vertx.core.json.JsonObject; 12 | import java.util.regex.Pattern; 13 | import org.junit.jupiter.api.Test; 14 | 15 | class RedisConfigTest { 16 | 17 | @Test 18 | void createDefaults() { 19 | RedisConfig config = new RedisConfig(); 20 | assertEquals(ClientType.STANDALONE, config.getClientType()); 21 | assertEquals(singletonList("redis://127.0.0.1:6379"), config.getEndpoints()); 22 | assertEquals("", config.getKeyNamespace()); 23 | } 24 | 25 | @Test 26 | void createFromSystemProperty() { 27 | try { 28 | System.setProperty("redis.connection.scheme", "rediss"); 29 | System.setProperty("redis.connection.host", "localhost"); 30 | System.setProperty("redis.connection.port", "8080"); 31 | System.setProperty("redis.key.namespace", "test"); 32 | 33 | RedisConfig config = new RedisConfig(); 34 | assertEquals("test", config.getKeyNamespace()); 35 | assertEquals(singletonList("rediss://localhost:8080"), config.getEndpoints()); 36 | } finally { 37 | System.clearProperty("redis.connection.scheme"); 38 | System.clearProperty("redis.connection.host"); 39 | System.clearProperty("redis.connection.port"); 40 | System.clearProperty("redis.key.namespace"); 41 | } 42 | } 43 | 44 | @Test 45 | void createFromSystemPropertyAddress() { 46 | try { 47 | System.setProperty("redis.connection.address", "redis://localhost:8080"); 48 | RedisConfig config = new RedisConfig(); 49 | assertEquals(singletonList("redis://localhost:8080"), config.getEndpoints()); 50 | } finally { 51 | System.clearProperty("redis.connection.address"); 52 | } 53 | } 54 | 55 | @Test 56 | void createInvalidEndpoint() { 57 | RedisConfig config = new RedisConfig(); 58 | assertThrows(IllegalArgumentException.class, () -> config.addEndpoint("@scheme!:invalid")); 59 | } 60 | 61 | @Test 62 | void findMapConfigByName() { 63 | RedisConfig config = new RedisConfig().addMap(new MapConfig("test").setMaxSize(10)); 64 | assertThat(config.getMapConfig("test")) 65 | .hasValueSatisfying( 66 | mapConfig -> { 67 | assertEquals("test", mapConfig.getName()); 68 | assertEquals(10, mapConfig.getMaxSize()); 69 | }); 70 | } 71 | 72 | @Test 73 | void findMapConfigByPattern() { 74 | RedisConfig config = 75 | new RedisConfig().addMap(new MapConfig(Pattern.compile("test:.*")).setMaxSize(1)); 76 | assertThat(config.getMapConfig("test:my-value")) 77 | .hasValueSatisfying( 78 | mapConfig -> { 79 | assertEquals("test:.*", mapConfig.getPattern()); 80 | assertEquals(1, mapConfig.getMaxSize()); 81 | }); 82 | } 83 | 84 | @Test 85 | void missingMapConfig() { 86 | assertThat(new RedisConfig().getMapConfig("test")).isEmpty(); 87 | } 88 | 89 | @Test 90 | void missingLockConfig() { 91 | assertThat(new RedisConfig().getLockConfig("test")).isEmpty(); 92 | } 93 | 94 | private RedisConfig complexConfig() { 95 | return new RedisConfig() 96 | .setClientType(ClientType.CLUSTER) 97 | .addEndpoint("rediss://localhost:8080") 98 | .addEndpoint("rediss://localhost:8081") 99 | .setResponseTimeout(5000) 100 | .addMap(new MapConfig("test").setMaxSize(10)) 101 | .addMap( 102 | new MapConfig(Pattern.compile("^prefix\\.*$")) 103 | .setMaxSize(100) 104 | .setEvictionMode(EvictionMode.LFU)) 105 | .addLock(new LockConfig("test").setLeaseTime(1000)) 106 | .addLock(new LockConfig(Pattern.compile("lock-.*")).setLeaseTime(-1)) 107 | .setUsername("my-user") 108 | .setKeyNamespace("test"); 109 | } 110 | 111 | @Test 112 | void toJson() { 113 | JsonObject json = complexConfig().toJson(); 114 | assertThat(json.isEmpty()).isFalse(); 115 | } 116 | 117 | @Test 118 | void fromJson() { 119 | RedisConfig expected = complexConfig(); 120 | JsonObject json = expected.toJson(); 121 | RedisConfig actual = new RedisConfig(json); 122 | assertEquals(expected, actual); 123 | assertEquals(expected.hashCode(), actual.hashCode()); 124 | } 125 | 126 | @Test 127 | void copy() { 128 | RedisConfig expected = complexConfig(); 129 | RedisConfig actual = new RedisConfig(expected); 130 | assertNotSame(expected, actual); 131 | assertEquals(expected, actual); 132 | actual.addMap(new MapConfig("test2").setMaxSize(1)); 133 | assertNotEquals(expected, actual); 134 | } 135 | 136 | @Test 137 | void configToString() { 138 | assertDoesNotThrow(() -> complexConfig().toString()); 139 | } 140 | 141 | @Test 142 | void mapConfigToString() { 143 | String toString = assertDoesNotThrow(() -> new MapConfig("test").toString()); 144 | assertThat(toString).contains("maxSize=0"); 145 | } 146 | 147 | @Test 148 | void lockConfigToString() { 149 | String toString = assertDoesNotThrow(() -> new LockConfig("test").toString()); 150 | assertThat(toString).contains("leaseTime=-1"); 151 | } 152 | 153 | @Test 154 | void setUsernameAndPassword() { 155 | RedisConfig config = new RedisConfig().setUsername("test").setPassword("secret"); 156 | assertEquals("test", config.getUsername()); 157 | assertEquals("secret", config.getPassword()); 158 | } 159 | 160 | @Test 161 | void passwordFromEnv() { 162 | try { 163 | System.setProperty("redis.connection.password", "prop-secret"); 164 | RedisConfig config = complexConfig(); 165 | assertEquals("my-user", config.getUsername()); 166 | assertEquals("prop-secret", config.getPassword()); 167 | } finally { 168 | System.clearProperty("redis.connection.password"); 169 | } 170 | } 171 | 172 | @Test 173 | void objectEquals() { 174 | var complex = complexConfig(); 175 | assertNotEquals(complex, new Object()); 176 | assertNotEquals(complex, new RedisConfig()); 177 | assertEquals(complex, complexConfig()); 178 | assertEquals(complex, complex); 179 | assertNotEquals(complex, new RedisConfig(complex).setResponseTimeout(20)); 180 | assertNotEquals(complex, new RedisConfig(complex).setUsername(null)); 181 | assertNotEquals(complex, new RedisConfig(complex).setPassword("not null")); 182 | assertNotEquals(complex, new RedisConfig(complex).setKeyNamespace("test2")); 183 | assertNotEquals(complex, new RedisConfig(complex).addEndpoint("redis://test")); 184 | assertNotEquals(complex, new RedisConfig(complex).setClientType(ClientType.REPLICATED)); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM set title of command window 39 | title %0 40 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 41 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 42 | 43 | @REM set %HOME% to equivalent of $HOME 44 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 45 | 46 | @REM Execute a user defined script before this one 47 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 48 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 49 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 50 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 51 | :skipRcPre 52 | 53 | @setlocal 54 | 55 | set ERROR_CODE=0 56 | 57 | @REM To isolate internal variables from possible post scripts, we use another setlocal 58 | @setlocal 59 | 60 | @REM ==== START VALIDATION ==== 61 | if not "%JAVA_HOME%" == "" goto OkJHome 62 | 63 | echo. 64 | echo Error: JAVA_HOME not found in your environment. >&2 65 | echo Please set the JAVA_HOME variable in your environment to match the >&2 66 | echo location of your Java installation. >&2 67 | echo. 68 | goto error 69 | 70 | :OkJHome 71 | if exist "%JAVA_HOME%\bin\java.exe" goto init 72 | 73 | echo. 74 | echo Error: JAVA_HOME is set to an invalid directory. >&2 75 | echo JAVA_HOME = "%JAVA_HOME%" >&2 76 | echo Please set the JAVA_HOME variable in your environment to match the >&2 77 | echo location of your Java installation. >&2 78 | echo. 79 | goto error 80 | 81 | @REM ==== END VALIDATION ==== 82 | 83 | :init 84 | 85 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 86 | @REM Fallback to current working directory if not found. 87 | 88 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 89 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 90 | 91 | set EXEC_DIR=%CD% 92 | set WDIR=%EXEC_DIR% 93 | :findBaseDir 94 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 95 | cd .. 96 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 97 | set WDIR=%CD% 98 | goto findBaseDir 99 | 100 | :baseDirFound 101 | set MAVEN_PROJECTBASEDIR=%WDIR% 102 | cd "%EXEC_DIR%" 103 | goto endDetectBaseDir 104 | 105 | :baseDirNotFound 106 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 107 | cd "%EXEC_DIR%" 108 | 109 | :endDetectBaseDir 110 | 111 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 112 | 113 | @setlocal EnableExtensions EnableDelayedExpansion 114 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 115 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 116 | 117 | :endReadAdditionalConfig 118 | 119 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 120 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 121 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 122 | 123 | set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 124 | 125 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 126 | IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B 127 | ) 128 | 129 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 130 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 131 | if exist %WRAPPER_JAR% ( 132 | if "%MVNW_VERBOSE%" == "true" ( 133 | echo Found %WRAPPER_JAR% 134 | ) 135 | ) else ( 136 | if not "%MVNW_REPOURL%" == "" ( 137 | SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 138 | ) 139 | if "%MVNW_VERBOSE%" == "true" ( 140 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 141 | echo Downloading from: %DOWNLOAD_URL% 142 | ) 143 | 144 | powershell -Command "&{"^ 145 | "$webclient = new-object System.Net.WebClient;"^ 146 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 147 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 148 | "}"^ 149 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ 150 | "}" 151 | if "%MVNW_VERBOSE%" == "true" ( 152 | echo Finished downloading %WRAPPER_JAR% 153 | ) 154 | ) 155 | @REM End of extension 156 | 157 | @REM Provide a "standardized" way to retrieve the CLI args that will 158 | @REM work with both Windows and non-Windows executions. 159 | set MAVEN_CMD_LINE_ARGS=%* 160 | 161 | %MAVEN_JAVA_EXE% ^ 162 | %JVM_CONFIG_MAVEN_PROPS% ^ 163 | %MAVEN_OPTS% ^ 164 | %MAVEN_DEBUG_OPTS% ^ 165 | -classpath %WRAPPER_JAR% ^ 166 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 167 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 168 | if ERRORLEVEL 1 goto error 169 | goto end 170 | 171 | :error 172 | set ERROR_CODE=1 173 | 174 | :end 175 | @endlocal & set ERROR_CODE=%ERROR_CODE% 176 | 177 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 178 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 179 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 180 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 181 | :skipRcPost 182 | 183 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 184 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 185 | 186 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 187 | 188 | cmd /C exit /B %ERROR_CODE% 189 | -------------------------------------------------------------------------------- /src/test/java/com/retailsvc/vertx/spi/cluster/redis/impl/ITRedisInstance.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static com.jayway.awaitility.Awaitility.await; 4 | import static java.util.Arrays.asList; 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 7 | import static org.junit.jupiter.api.Assertions.assertEquals; 8 | import static org.junit.jupiter.api.Assertions.assertFalse; 9 | import static org.junit.jupiter.api.Assertions.assertNotNull; 10 | import static org.junit.jupiter.api.Assertions.assertThrows; 11 | import static org.junit.jupiter.api.Assertions.assertTrue; 12 | 13 | import com.jayway.awaitility.Duration; 14 | import com.retailsvc.vertx.spi.cluster.redis.RedisClusterManager; 15 | import com.retailsvc.vertx.spi.cluster.redis.RedisDataGrid; 16 | import com.retailsvc.vertx.spi.cluster.redis.RedisInstance; 17 | import com.retailsvc.vertx.spi.cluster.redis.RedisTestContainerFactory; 18 | import com.retailsvc.vertx.spi.cluster.redis.Topic; 19 | import com.retailsvc.vertx.spi.cluster.redis.TopicSubscriber; 20 | import com.retailsvc.vertx.spi.cluster.redis.config.LockConfig; 21 | import com.retailsvc.vertx.spi.cluster.redis.config.MapConfig; 22 | import com.retailsvc.vertx.spi.cluster.redis.config.RedisConfig; 23 | import io.vertx.core.Vertx; 24 | import io.vertx.core.shareddata.AsyncMap; 25 | import io.vertx.core.shareddata.Counter; 26 | import java.util.ArrayList; 27 | import java.util.List; 28 | import java.util.concurrent.BlockingDeque; 29 | import java.util.concurrent.BlockingQueue; 30 | import java.util.concurrent.CopyOnWriteArrayList; 31 | import java.util.concurrent.ExecutionException; 32 | import java.util.concurrent.TimeUnit; 33 | import java.util.concurrent.atomic.AtomicBoolean; 34 | import java.util.concurrent.atomic.AtomicInteger; 35 | import org.junit.jupiter.api.BeforeEach; 36 | import org.junit.jupiter.api.Test; 37 | import org.testcontainers.containers.GenericContainer; 38 | import org.testcontainers.junit.jupiter.Container; 39 | import org.testcontainers.junit.jupiter.Testcontainers; 40 | 41 | /** Test redis specific behavior and data type configurations. */ 42 | @Testcontainers 43 | class ITRedisInstance { 44 | 45 | @Container public GenericContainer redis = RedisTestContainerFactory.newContainer(); 46 | 47 | private Vertx vertx; 48 | private RedisClusterManager clusterManager; 49 | private RedisConfig config; 50 | 51 | @BeforeEach 52 | void beforeEach() { 53 | String redisUrl = "redis://" + redis.getHost() + ":" + redis.getFirstMappedPort(); 54 | config = 55 | new RedisConfig() 56 | .addEndpoint(redisUrl) 57 | .addMap(new MapConfig("maxSize").setMaxSize(3)) 58 | .addLock(new LockConfig("leaseTime").setLeaseTime(1000)); 59 | clusterManager = new RedisClusterManager(config); 60 | Vertx.builder().withClusterManager(clusterManager).buildClustered(ar -> vertx = ar.result()); 61 | await().until(() -> vertx != null); 62 | } 63 | 64 | @Test 65 | void nullRedisInstance() { 66 | RedisClusterManager mgr = new RedisClusterManager(); 67 | assertFalse(mgr.isActive()); 68 | assertFalse(mgr.getRedisInstance().isPresent()); 69 | } 70 | 71 | @Test 72 | void getRedisInstance() { 73 | assertTrue(clusterManager.isActive()); 74 | assertTrue(clusterManager.getRedisInstance().isPresent()); 75 | } 76 | 77 | @Test 78 | void createFromInterface() { 79 | assertTrue(RedisInstance.create(vertx).isPresent()); 80 | } 81 | 82 | @Test 83 | void createEmptyFromInterface() { 84 | assertFalse(RedisInstance.create(null).isPresent()); 85 | } 86 | 87 | @Test 88 | void getLock() { 89 | AtomicInteger claimCount = new AtomicInteger(0); 90 | redisInstance() 91 | .getLock("getLock") 92 | .onSuccess( 93 | lock -> { 94 | claimCount.incrementAndGet(); 95 | lock.release(); 96 | }); 97 | await().atMost(2, TimeUnit.SECONDS).until(() -> assertEquals(1, claimCount.get())); 98 | } 99 | 100 | @Test 101 | void lockWithoutLeaseTime() { 102 | AtomicInteger claimCount = new AtomicInteger(0); 103 | AtomicBoolean lockTimeout = new AtomicBoolean(false); 104 | 105 | // Claim a lock but don't release it. 106 | vertx 107 | .sharedData() 108 | .getLockWithTimeout("forever", 1000) 109 | .onSuccess(lock -> claimCount.incrementAndGet()); 110 | await().atMost(2, TimeUnit.SECONDS).until(() -> assertEquals(1, claimCount.get())); 111 | 112 | // Try to claim the same lock. It should work after lease time expires. 113 | vertx.sharedData().getLockWithTimeout("forever", 1000).onFailure(t -> lockTimeout.set(true)); 114 | 115 | await().atMost(2, TimeUnit.SECONDS).until(lockTimeout::get); 116 | } 117 | 118 | @Test 119 | void lockLeaseTime() { 120 | AtomicInteger claimCount = new AtomicInteger(0); 121 | 122 | // Claim a lock but don't release it. 123 | vertx 124 | .sharedData() 125 | .getLockWithTimeout("leaseTime", 1000) 126 | .onSuccess(lock -> claimCount.incrementAndGet()); 127 | await().atMost(2, TimeUnit.SECONDS).until(() -> assertEquals(1, claimCount.get())); 128 | 129 | // Try to claim the same lock. It should work after lease time expires. 130 | vertx 131 | .sharedData() 132 | .getLockWithTimeout("leaseTime", 4000) 133 | .onSuccess( 134 | lock -> { 135 | claimCount.incrementAndGet(); 136 | lock.release(); 137 | }); 138 | 139 | await().atMost(2, TimeUnit.SECONDS).until(() -> assertEquals(2, claimCount.get())); 140 | } 141 | 142 | @Test 143 | void mapMaxSize() throws Exception { 144 | AtomicBoolean completed = new AtomicBoolean(false); 145 | 146 | List values1 = new ArrayList<>(); 147 | List values2 = new ArrayList<>(); 148 | 149 | AsyncMap map = 150 | vertx 151 | .sharedData() 152 | .getAsyncMap("maxSize") 153 | .toCompletionStage() 154 | .toCompletableFuture() 155 | .get(); 156 | 157 | map.put("1", "1") 158 | .compose(v -> map.put("2", "2")) 159 | .compose(v -> map.put("3", "3")) 160 | .compose(v -> map.values().onSuccess(values1::addAll)) 161 | .compose(v -> map.put("4", "4")) 162 | .compose(v -> map.put("5", "5")) 163 | .compose(v -> map.values().onSuccess(values2::addAll)) 164 | .onSuccess(v -> completed.set(true)); 165 | 166 | await().atMost(2, TimeUnit.SECONDS).until(completed::get); 167 | 168 | assertThat(values1).hasSameElementsAs(asList("1", "2", "3")); 169 | assertThat(values2).hasSameElementsAs(asList("3", "4", "5")); 170 | } 171 | 172 | private RedisInstance redisInstance() { 173 | return clusterManager.getRedisInstance().orElseThrow(IllegalStateException::new); 174 | } 175 | 176 | @Test 177 | void ping() { 178 | assertTrue(redisInstance().ping()); 179 | } 180 | 181 | @Test 182 | void blockingQueue() throws InterruptedException { 183 | BlockingQueue queue = redisInstance().getBlockingQueue("testQueue"); 184 | assertNotNull(queue); 185 | queue.add("1"); 186 | queue.add("2"); 187 | 188 | assertThat(queue.take()).isEqualTo("1"); 189 | assertThat(queue.take()).isEqualTo("2"); 190 | } 191 | 192 | @Test 193 | void blockingDeque() { 194 | BlockingDeque deque = redisInstance().getBlockingDeque("testDeque"); 195 | assertNotNull(deque); 196 | deque.push("1"); 197 | deque.push("2"); 198 | 199 | assertThat(deque.pop()).isEqualTo("2"); 200 | assertThat(deque.pop()).isEqualTo("1"); 201 | } 202 | 203 | @Test 204 | void topicWithSubscription() { 205 | Topic topic = redisInstance().getTopic(String.class, "testTopic"); 206 | List messages = new CopyOnWriteArrayList<>(); 207 | List receivedBy = new ArrayList<>(); 208 | TopicSubscriber subscriber = messages::add; 209 | 210 | AtomicInteger id = new AtomicInteger(); 211 | AtomicBoolean completed = new AtomicBoolean(false); 212 | topic 213 | .subscribe(subscriber) 214 | .onSuccess(id::set) 215 | .compose(v -> topic.publish("Hello")) 216 | .onSuccess(receivedBy::add) 217 | .compose(v -> topic.publish("World")) 218 | .onSuccess(receivedBy::add) 219 | .onComplete(v -> completed.set(true)); 220 | 221 | await().atMost(2, TimeUnit.SECONDS).until(completed::get); 222 | await() 223 | .atMost(2, TimeUnit.SECONDS) 224 | .until(() -> assertThat(messages).isNotEmpty().containsOnly("Hello", "World")); 225 | 226 | // Unregister and ensure we're not getting the last message. 227 | completed.set(false); 228 | topic 229 | .unsubscribe(id.get()) 230 | .compose(v -> topic.publish("Void")) 231 | .onSuccess(receivedBy::add) 232 | .onComplete(v -> completed.set(true)); 233 | 234 | await().atMost(2, TimeUnit.SECONDS).until(completed::get); 235 | assertThat(receivedBy).isEqualTo(asList(1L, 1L, 0L)); 236 | assertThat(messages).doesNotContain("Void"); 237 | } 238 | 239 | @Test 240 | void shutdownDataGrid() { 241 | RedisDataGrid dataGrid = RedisDataGrid.create(vertx, config); 242 | Counter c = assertDoesNotThrow(() -> dataGrid.getCounter("test")); 243 | 244 | assertDoesNotThrow(() -> c.incrementAndGet().toCompletionStage().toCompletableFuture().get()); 245 | 246 | assertDoesNotThrow(dataGrid::shutdown); 247 | await("Graceful shutdown").timeout(Duration.ONE_SECOND); 248 | 249 | ExecutionException ex = 250 | assertThrows( 251 | ExecutionException.class, 252 | () -> c.get().toCompletionStage().toCompletableFuture().get()); 253 | assertThat(ex).hasMessageContaining("Redisson is shutdown"); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Maven Central Version](https://img.shields.io/maven-central/v/com.retailsvc/vertx-redis-clustermanager?style=flat)](https://central.sonatype.com/artifact/com.retailsvc/vertx-redis-clustermanager) 2 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=extenda_vertx-redis-clustermanager&metric=alert_status&token=6d4cad0689d8f37a1f02630ddac30099ded3050c)](https://sonarcloud.io/summary/new_code?id=extenda_vertx-redis-clustermanager) 3 | 4 | # vertx-redis-clustermanager 5 | 6 | A Vert.x ClusterManager for Redis. The cluster manager allows teams to use Redis as a drop-in replacement for Hazelcast 7 | to run Vert.x in clustered high availability mode. 8 | 9 | The cluster manager is proven in production with [Redis CE][redis-ce] 10 | and [Google Cloud MemoryStore for Redis][memorystore] and can also be used 11 | with [AWS ElastiCache Cluster][elasticache], [Amazon MemoryDB][memorydb] and [Azure Redis Cache][azure]. 12 | 13 | [redis-ce]: https://redis.io/docs/latest/operate/oss_and_stack/ 14 | [memorystore]: https://cloud.google.com/memorystore/docs/redis 15 | [elasticache]: https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/WhatIs.html#WhatIs.Clusters 16 | [memorydb]: https://aws.amazon.com/memorydb/ 17 | [azure]: https://azure.microsoft.com/en-us/products/cache/ 18 | 19 | ## Usage 20 | 21 | ```xml 22 | 23 | com.retailsvc 24 | vertx-redis-clustermanager 25 | VERSION 26 | 27 | ``` 28 | 29 | ### Configuration 30 | 31 | The cluster manager can be configured either programmatically or with environment variables. There's two types of configuration 32 | 33 | * Redis connection settings 34 | * Data type configuration settings (maps, lock etc) 35 | 36 | ### Environment variables 37 | 38 | System properties or environment variables can be used to configure a standalone client. 39 | 40 | | System Property | Environment Variable | Default | Description | 41 | |---------------------------|---------------------------|------------------------------------------|------------------------------------------------------------------------------------------------------------------| 42 | | redis.connection.address | REDIS_CONNECTION_ADDRESS | Created from other connection properties | Set the fully qualified Redis address. This is an optional property. | 43 | | redis.connection.scheme | REDIS_CONNECTION_SCHEME | redis | The Redis scheme. Use `redis` for TCP and `rediss` for TLS. | 44 | | redis.connection.host | REDIS_CONNECTION_HOST | 127.0.0.1 | The Redis server hostname or IP address. | 45 | | redis.connection.port | REDIS_CONNECTION_PORT | 6379 | The Redis server port. | 46 | | redis.connection.username | REDIS_CONNECTION_USERNAME | | Optional username to use when connecting to Redis. | 47 | | redis.connection.password | REDIS_CONNECTION_PASSWORD | | Optional password to use when connecting to Redis. | 48 | | redis.key.namespace | REDIS_KEY_NAMESPACE | | Optional namespace to prefix all keys with. This is useful if the Redis instance is shared by multiple services. | 49 | 50 | If you need to configure a client type with multiple node addresses, you'll need to pass configuration to the cluster 51 | manager. The configuration can be loaded from a JSON file or inlined in Java. The above environment variables are 52 | respected from the Java API, but can be overridden. 53 | 54 | ### JSON configuration 55 | 56 | The cluster manager can be configured through JSON files. The file must be loaded and passed as a `RedisConfig` 57 | to the cluster manager constructor. The JSON configuration can be used to configure all properties, but it is not recommended to set any secret or sensitive 58 | information in JSON. 59 | 60 | The following properties are supported: 61 | 62 | | Property | Description | Default Value | 63 | |----------------------|------------------------------------------------------------------------------------------------------------------|---------------------------| 64 | | clientType | The Redis client type, one of `STANDALONE`, `CLUSTER`, `REPLICATED`. | STANDALONE | 65 | | endpoints.[] | An list of Redis addresses to use. | \[redis://127.0.0.1:6379] | 66 | | keyNamespace | Optional namespace to prefix all keys with. This is useful if the Redis instance is shared by multiple services. | | 67 | | responseTimeout | Optional response timeout to use for all Redis commands. | | 68 | | username | Optional username to use when connecting to Redis. Not recommended to be set from JSON. | | 69 | | password | Optional password to use when connecting to Redis. Not recommended to be set from JSON. | | 70 | | locks.[] | An list of lock configurations. | | 71 | | locks.[].name | The name of the lock for which to apply the configuration. One of `name` or `pattern` must be set. | | 72 | | locks.[].pattern | The name pattern of the lock(s) for which to apply the configuration. | | 73 | | locks.[].leaseTime | The lease time in milliseconds before the lock is automatically released. Use `-1` to indicate no timeout. | -1 | 74 | | maps.[] | A list of map configurations. | | 75 | | maps.[].name | The name of the lock for which to apply the configuration. One of `name` or `pattern` must be set. | | 76 | | maps.[].pattern | The name pattern of the map(s) for which to apply the configuration. | | 77 | | maps.[].evictionMode | The map eviction mode when size is exceeded. One of `LRU` or `LFU`. | LRU | 78 | | maps.[].maxSize | The max size (number of keys) allowed in the map. Use `0` to indicate no max size. | 0 | 79 | 80 | ### Configuration API 81 | 82 | The `RedisConfig` API can be used to set all configurable properties. It takes default values from the environment and 83 | system properties allow for injection of secrets while remaining configuration can be built at runtime. 84 | 85 | ```java 86 | import com.retailsvc.vertx.spi.cluster.redis.config.*; 87 | 88 | var config = new RedisConfig() 89 | .setClientType(ClientType.CLUSTER) 90 | .addAddress("redis://redis1.internal:6379") 91 | .addAddress("redis://redis2.internal:6379"); 92 | ``` 93 | 94 | ### Configuration for data types 95 | 96 | Redis maps and locks can be configured through the `RedisConfig` API, either in JSON or programmatically. It allows 97 | developers to control properties that aren't available on the `SharedData` API, such as map max size and eviction 98 | policies. 99 | 100 | * Locks and maps are configurable either by name or a regular expression pattern 101 | * Maps 102 | * Maximum size 103 | * Eviction policy when maximum size is reached 104 | * Time to live is NOT configurable as it is part of the `AsyncMap` API 105 | * Locks 106 | * Maximum lease time before auto release 107 | 108 | 109 | ### Instantiate the Cluster Manager 110 | 111 | If you use a standalone client without any custom configuration you can rely on service discovery and just create a 112 | clustered vertx instance. 113 | 114 | ```java 115 | Vertx vertx = Vertx.clusteredVertx(new VertxOptions()) 116 | .toCompletionStage() 117 | .toCompletableFuture() 118 | .get(); 119 | ``` 120 | 121 | If custom configuration is used, configure and add the cluster manager using the Vertx builder. 122 | In the example below, we load configuration from the environment and a JSON file. 123 | 124 | ```java 125 | private Vertx vertx; 126 | 127 | private RedisConfig loadConfig() { 128 | try { 129 | JsonObject json = new JsonObject(Buffer.buffer(Files.readAllBytes(Path.of("redis-config.json")))); 130 | return new RedisConfig(json); 131 | } catch (IOException e) { 132 | throw new UncheckedIOException("Failed to load RedisConfig", e); 133 | } 134 | } 135 | 136 | public void init() { 137 | ClusterManager clusterManager = new RedisClusterManager(loadConfig); 138 | Vertx.builder().withClusterManager(clusterManager).buildClustered(ar -> vertx = ar.result()); 139 | } 140 | ``` 141 | 142 | ## Development 143 | 144 | The project is built with OpenJDK 21 and Maven. 145 | 146 | To build the project with all tests, run 147 | ```bash 148 | ./mnw verify 149 | ``` 150 | 151 | Ensure you install and enable [pre-commit](https://pre-commit.com) before committing code. 152 | 153 | ```bash 154 | pre-commit install -t pre-commit -t commit-msg 155 | ``` 156 | 157 | When developing your applications, it can be handy to spin up a Redis with Docker. 158 | ```bash 159 | docker run --rm -it -p 6379:6379 redis:6-alpine redis-server --save '' 160 | ``` 161 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/config/RedisConfig.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.config; 2 | 3 | import static java.util.Collections.singletonList; 4 | import static java.util.Collections.unmodifiableList; 5 | 6 | import io.vertx.codegen.annotations.DataObject; 7 | import io.vertx.codegen.annotations.GenIgnore; 8 | import io.vertx.codegen.json.annotations.JsonGen; 9 | import io.vertx.core.json.JsonObject; 10 | import java.net.URI; 11 | import java.net.URISyntaxException; 12 | import java.util.ArrayList; 13 | import java.util.List; 14 | import java.util.Objects; 15 | import java.util.Optional; 16 | 17 | /** 18 | * Redis cluster manager configuration. 19 | * 20 | * @author sasjo 21 | */ 22 | @DataObject 23 | @JsonGen 24 | public class RedisConfig { 25 | 26 | /** The client type. */ 27 | private ClientType type = ClientType.STANDALONE; 28 | 29 | /** Redis key namespace. */ 30 | private String keyNamespace; 31 | 32 | /** Redis connection username. */ 33 | private String username; 34 | 35 | /** Redis connection password, */ 36 | private String password; 37 | 38 | /** Redis response timeout. */ 39 | private Integer responseTimeout; 40 | 41 | /** Default Redis URL. */ 42 | private final String defaultEndpoint; 43 | 44 | /** Redis endpoints. */ 45 | private List endpoints = new ArrayList<>(); 46 | 47 | /** Map configuration. */ 48 | private final List maps = new ArrayList<>(); 49 | 50 | /** Lock configuration. */ 51 | private final List locks = new ArrayList<>(); 52 | 53 | /** Create the default configuration from existing environment variables. */ 54 | public RedisConfig() { 55 | defaultEndpoint = RedisConfigProps.getDefaultEndpoint().toASCIIString(); 56 | keyNamespace = RedisConfigProps.getPropertyValue("redis.key.namespace"); 57 | username = RedisConfigProps.getPropertyValue("redis.connection.username", null); 58 | password = RedisConfigProps.getPropertyValue("redis.connection.password", null); 59 | } 60 | 61 | /** 62 | * Copy constructor. 63 | * 64 | * @param other the object to clone 65 | */ 66 | public RedisConfig(RedisConfig other) { 67 | defaultEndpoint = other.defaultEndpoint; 68 | type = other.type; 69 | keyNamespace = other.keyNamespace; 70 | username = other.username; 71 | password = other.password; 72 | responseTimeout = other.responseTimeout; 73 | endpoints = new ArrayList<>(other.endpoints); 74 | other.maps.stream().map(MapConfig::new).forEach(maps::add); 75 | other.locks.stream().map(LockConfig::new).forEach(locks::add); 76 | } 77 | 78 | /** 79 | * Copy from JSON constructor. 80 | * 81 | * @param json source JSON 82 | */ 83 | public RedisConfig(JsonObject json) { 84 | this(); 85 | RedisConfigConverter.fromJson(json, this); 86 | } 87 | 88 | /** 89 | * Set the key namespace to use as prefix for all keys created in Redis. 90 | * 91 | * @param keyNamespace the key namespace 92 | * @return fluent self 93 | */ 94 | public RedisConfig setKeyNamespace(String keyNamespace) { 95 | this.keyNamespace = keyNamespace; 96 | return this; 97 | } 98 | 99 | /** 100 | * Returns the key namespace used as prefix for all keys created in Redis. 101 | * 102 | * @return the key namespace 103 | */ 104 | public String getKeyNamespace() { 105 | return keyNamespace == null ? "" : keyNamespace; 106 | } 107 | 108 | /** 109 | * Set the client type. 110 | * 111 | * @param type the client type 112 | * @return fluent self 113 | */ 114 | public RedisConfig setClientType(ClientType type) { 115 | this.type = type; 116 | return this; 117 | } 118 | 119 | /** 120 | * Returns the client type. 121 | * 122 | * @return the client type 123 | */ 124 | public ClientType getClientType() { 125 | return type == null ? ClientType.STANDALONE : type; 126 | } 127 | 128 | /** 129 | * Returns the username used when connecting to Redis. 130 | * 131 | * @return the username to use when connecting to Redis or null to not use a username 132 | */ 133 | public String getUsername() { 134 | return username; 135 | } 136 | 137 | /** 138 | * Set the username to use when connecting to Redis. 139 | * 140 | *

Default value: null 141 | * 142 | * @param username the username to use 143 | * @return fluent self 144 | */ 145 | public RedisConfig setUsername(String username) { 146 | this.username = username; 147 | return this; 148 | } 149 | 150 | /** 151 | * Returns the password used when connecting to Redis. 152 | * 153 | * @return the password to use when connecting to Redis or null to not use a password 154 | */ 155 | public String getPassword() { 156 | return password; 157 | } 158 | 159 | /** 160 | * Set the password to use when connecting to Redis. 161 | * 162 | *

Default value: null 163 | * 164 | * @param password the password to use 165 | * @return fluent self 166 | */ 167 | public RedisConfig setPassword(String password) { 168 | this.password = password; 169 | return this; 170 | } 171 | 172 | /** 173 | * Set the Redis server response timeout. Starts to countdown when Redis command has been 174 | * successfully sent. If not set, a default value will be used. 175 | * 176 | * @param responseTimeout the response timeout in milliseconds 177 | * @return fluent self 178 | */ 179 | public RedisConfig setResponseTimeout(Integer responseTimeout) { 180 | this.responseTimeout = responseTimeout; 181 | return this; 182 | } 183 | 184 | /** 185 | * Get the Redis server response timeout. Starts to countdown when Redis command has been 186 | * successfully sent. 187 | * 188 | * @return the response timeout to use or null to use a default response timeout 189 | */ 190 | public Integer getResponseTimeout() { 191 | return responseTimeout; 192 | } 193 | 194 | /** 195 | * Add a client endpoint. 196 | * 197 | * @param redisUrl the endpoint connection URL to add 198 | * @return fluent self 199 | * @throws IllegalArgumentException if the given string violates RFC 2396 200 | */ 201 | public RedisConfig addEndpoint(String redisUrl) { 202 | try { 203 | new URI(redisUrl); 204 | } catch (URISyntaxException e) { 205 | throw new IllegalArgumentException("Illegal redis URL", e); 206 | } 207 | endpoints.add(redisUrl); 208 | return this; 209 | } 210 | 211 | /** 212 | * Get the configured URL endpoints. Depending on the client type, this method will return one or 213 | * more of the endpoints. 214 | * 215 | * @return the configured redis endpoints 216 | */ 217 | public List getEndpoints() { 218 | if (endpoints == null || endpoints.isEmpty()) { 219 | return singletonList(defaultEndpoint); 220 | } 221 | if (type == ClientType.STANDALONE) { 222 | return singletonList(endpoints.getFirst()); 223 | } 224 | return unmodifiableList(endpoints); 225 | } 226 | 227 | /** 228 | * Add a map configuration. 229 | * 230 | * @param mapConfig the map configuration 231 | * @return fluent self 232 | */ 233 | public RedisConfig addMap(MapConfig mapConfig) { 234 | maps.add(mapConfig); 235 | return this; 236 | } 237 | 238 | /** 239 | * Return all map configurations. 240 | * 241 | * @return the map configurations. 242 | */ 243 | public List getMaps() { 244 | return unmodifiableList(maps); 245 | } 246 | 247 | /** 248 | * Return all lock configurations. 249 | * 250 | * @return the lock configurations. 251 | */ 252 | public List getLocks() { 253 | return unmodifiableList(locks); 254 | } 255 | 256 | /** 257 | * Add a lock configuration. 258 | * 259 | * @param lockConfig the lock configuration 260 | * @return fluent self 261 | */ 262 | public RedisConfig addLock(LockConfig lockConfig) { 263 | locks.add(lockConfig); 264 | return this; 265 | } 266 | 267 | private > Optional findConfig(List list, String name) { 268 | if (list == null || list.isEmpty()) { 269 | return Optional.empty(); 270 | } 271 | return list.stream().filter(opt -> opt.matches(name)).findFirst(); 272 | } 273 | 274 | /** 275 | * Get the configuration for a named map. 276 | * 277 | * @param name the map name 278 | * @return the configuration if it exists 279 | */ 280 | @GenIgnore 281 | public Optional getMapConfig(String name) { 282 | return findConfig(maps, name); 283 | } 284 | 285 | /** 286 | * GEt the configuration for a named lock. 287 | * 288 | * @param name the lock name 289 | * @return the configuration if it exists 290 | */ 291 | @GenIgnore 292 | public Optional getLockConfig(String name) { 293 | return findConfig(locks, name); 294 | } 295 | 296 | /** 297 | * Converts this object to JSON notation. 298 | * 299 | * @return JSON 300 | */ 301 | public JsonObject toJson() { 302 | JsonObject json = new JsonObject(); 303 | RedisConfigConverter.toJson(this, json); 304 | return json; 305 | } 306 | 307 | @Override 308 | public boolean equals(Object o) { 309 | if (this == o) { 310 | return true; 311 | } 312 | if (!(o instanceof RedisConfig that)) { 313 | return false; 314 | } 315 | return type == that.type 316 | && Objects.equals(keyNamespace, that.keyNamespace) 317 | && Objects.equals(username, that.username) 318 | && Objects.equals(password, that.password) 319 | && Objects.equals(responseTimeout, that.responseTimeout) 320 | && Objects.equals(defaultEndpoint, that.defaultEndpoint) 321 | && Objects.equals(endpoints, that.endpoints) 322 | && Objects.equals(maps, that.maps) 323 | && Objects.equals(locks, that.locks); 324 | } 325 | 326 | @Override 327 | public int hashCode() { 328 | return Objects.hash( 329 | type, 330 | keyNamespace, 331 | username, 332 | password, 333 | responseTimeout, 334 | defaultEndpoint, 335 | endpoints, 336 | maps, 337 | locks); 338 | } 339 | 340 | @Override 341 | public String toString() { 342 | return toJson().encodePrettily(); 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/impl/SubscriptionCatalog.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis.impl; 2 | 3 | import static java.util.Collections.emptySet; 4 | 5 | import io.vertx.core.spi.cluster.NodeSelector; 6 | import io.vertx.core.spi.cluster.RegistrationInfo; 7 | import io.vertx.core.spi.cluster.RegistrationUpdateEvent; 8 | import java.util.ArrayList; 9 | import java.util.Collection; 10 | import java.util.Collections; 11 | import java.util.HashSet; 12 | import java.util.List; 13 | import java.util.Set; 14 | import java.util.concurrent.ConcurrentHashMap; 15 | import java.util.concurrent.ConcurrentMap; 16 | import java.util.concurrent.locks.Lock; 17 | import java.util.concurrent.locks.ReadWriteLock; 18 | import java.util.concurrent.locks.ReentrantReadWriteLock; 19 | import org.redisson.api.RSetMultimap; 20 | import org.redisson.api.RTopic; 21 | import org.redisson.api.RedissonClient; 22 | import org.slf4j.Logger; 23 | import org.slf4j.LoggerFactory; 24 | 25 | /** 26 | * Manage Vertx Event Bus subscriptions in Redis. 27 | * 28 | *

Inspired and based upon io.vertx.spi.cluster.hazelcast.impl.SubsMapHelper 29 | * 30 | * @author sasjo 31 | */ 32 | public class SubscriptionCatalog { 33 | 34 | private static final Logger log = LoggerFactory.getLogger(SubscriptionCatalog.class); 35 | 36 | private final RSetMultimap subsMap; 37 | private final NodeSelector nodeSelector; 38 | private final int listenerId; 39 | private final RTopic topic; 40 | 41 | private final ConcurrentMap> localSubs = new ConcurrentHashMap<>(); 42 | private final ConcurrentMap> ownSubs = new ConcurrentHashMap<>(); 43 | private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); 44 | private final Throttling throttling; 45 | 46 | /** 47 | * Create a new subscription catalog. 48 | * 49 | * @param redisson the redisson client 50 | * @param keyFactory the key factory 51 | * @param nodeSelector Vertx node selector 52 | */ 53 | public SubscriptionCatalog( 54 | RedissonClient redisson, RedisKeyFactory keyFactory, NodeSelector nodeSelector) { 55 | this.nodeSelector = nodeSelector; 56 | subsMap = redisson.getSetMultimap(keyFactory.vertx("subs")); 57 | topic = redisson.getTopic(keyFactory.topic("subs")); 58 | listenerId = topic.addListener(String.class, this::onMessage); 59 | throttling = new Throttling(this::getAndUpdate); 60 | } 61 | 62 | /** 63 | * Create a new subscription catalog. 64 | * 65 | * @param predecessor the previous subscription catalog 66 | * @param redisson the redisson client 67 | * @param redisKeyFactory the key factory 68 | * @param nodeSelector Vertx node selector 69 | */ 70 | public SubscriptionCatalog( 71 | SubscriptionCatalog predecessor, 72 | RedissonClient redisson, 73 | RedisKeyFactory redisKeyFactory, 74 | NodeSelector nodeSelector) { 75 | this(redisson, redisKeyFactory, nodeSelector); 76 | ownSubs.putAll(predecessor.ownSubs); 77 | localSubs.putAll(predecessor.localSubs); 78 | } 79 | 80 | private void onMessage(CharSequence channel, String address) { 81 | log.trace("Address [{}] updated", address); 82 | fireRegistrationUpdateEvent(address); 83 | } 84 | 85 | /** 86 | * Get the registered information about handlers for a given address. 87 | * 88 | * @param address a handler address 89 | * @return a list of registered information for the given address 90 | */ 91 | public List get(String address) { 92 | Lock lock = readWriteLock.readLock(); 93 | lock.lock(); 94 | try { 95 | Set remote = subsMap.getAll(address); 96 | Set local = localSubs.getOrDefault(address, emptySet()); 97 | List result; 98 | if (!local.isEmpty()) { 99 | result = new ArrayList<>(local.size() + remote.size()); 100 | result.addAll(local); 101 | } else { 102 | result = new ArrayList<>(remote.size()); 103 | } 104 | result.addAll(remote); 105 | return result; 106 | } finally { 107 | lock.unlock(); 108 | } 109 | } 110 | 111 | /** 112 | * Store registration information for the cluster manager. 113 | * 114 | * @param address the address to register 115 | * @param registrationInfo the registration information 116 | */ 117 | public void put(String address, RegistrationInfo registrationInfo) { 118 | Lock lock = readWriteLock.readLock(); 119 | lock.lock(); 120 | try { 121 | if (registrationInfo.localOnly()) { 122 | localSubs.compute(address, (k, v) -> addToSet(registrationInfo, v)); 123 | fireRegistrationUpdateEvent(address); 124 | } else { 125 | ownSubs.compute(address, (k, v) -> addToSet(registrationInfo, v)); 126 | subsMap.put(address, registrationInfo); 127 | topic.publish(address); 128 | } 129 | } finally { 130 | lock.unlock(); 131 | } 132 | } 133 | 134 | /** 135 | * Get the updated registration information and notify the node selector. This method is throttled 136 | * to not fire too often. 137 | * 138 | * @param address the modified address 139 | * @see Throttling 140 | */ 141 | private void getAndUpdate(String address) { 142 | if (nodeSelector.wantsUpdatesFor(address)) { 143 | List registrationInfos; 144 | try { 145 | registrationInfos = get(address); 146 | } catch (Exception e) { 147 | log.trace("A failure occurred while retrieving the updated registrations", e); 148 | registrationInfos = Collections.emptyList(); 149 | } 150 | nodeSelector.registrationsUpdated(new RegistrationUpdateEvent(address, registrationInfos)); 151 | } 152 | } 153 | 154 | /** 155 | * Fire a registration update event to the node selector. 156 | * 157 | * @param address the modified address 158 | */ 159 | private void fireRegistrationUpdateEvent(String address) { 160 | throttling.onEvent(address); 161 | } 162 | 163 | private Set addToSet( 164 | RegistrationInfo registrationInfo, Set value) { 165 | Set newValue = value != null ? value : ConcurrentHashMap.newKeySet(); 166 | newValue.add(registrationInfo); 167 | return newValue; 168 | } 169 | 170 | /** 171 | * Remove a registration from the cluster manager. 172 | * 173 | * @param address the address to unregister from 174 | * @param registrationInfo the registration information to remove 175 | */ 176 | public void remove(String address, RegistrationInfo registrationInfo) { 177 | Lock lock = readWriteLock.readLock(); 178 | lock.lock(); 179 | try { 180 | if (registrationInfo.localOnly()) { 181 | localSubs.computeIfPresent(address, (k, v) -> removeFromSet(registrationInfo, v)); 182 | fireRegistrationUpdateEvent(address); 183 | } else { 184 | ownSubs.computeIfPresent(address, (k, v) -> removeFromSet(registrationInfo, v)); 185 | subsMap.remove(address, registrationInfo); 186 | topic.publish(address); 187 | } 188 | } finally { 189 | lock.unlock(); 190 | } 191 | } 192 | 193 | private Set removeFromSet( 194 | RegistrationInfo registrationInfo, Set value) { 195 | value.remove(registrationInfo); 196 | return value.isEmpty() ? null : value; 197 | } 198 | 199 | /** 200 | * Remove subscriptions for all nodes in the passed set. 201 | * 202 | * @param nodeIds a set of nodes for which to remove subscriptions 203 | */ 204 | public void removeAllForNodes(Set nodeIds) { 205 | Set updated = new HashSet<>(); 206 | subsMap 207 | .entries() 208 | .forEach( 209 | entry -> { 210 | if (nodeIds.contains(entry.getValue().nodeId())) { 211 | subsMap.remove(entry.getKey(), entry.getValue()); 212 | updated.add(entry.getKey()); 213 | } 214 | }); 215 | updated.forEach(topic::publish); 216 | } 217 | 218 | /** 219 | * Remove subscriptions for nodes that are not part of the availableNodeIds 220 | * collection. Own subscriptions are never removed. 221 | * 222 | *

Unknown nodes with lingering state can observed in clusters that has scaled down to one and 223 | * then crashes. If a new node is not available to receive events when node entries expire in 224 | * Redis, the subscriptions will remain registered in Redis. 225 | * 226 | * @param self the node ID of this process 227 | * @param availableNodeIds a set of available nodes 228 | */ 229 | public void removeUnknownSubs(String self, Collection availableNodeIds) { 230 | Set known = new HashSet<>(availableNodeIds); 231 | known.add(self); 232 | 233 | Set updated = new HashSet<>(); 234 | subsMap 235 | .entries() 236 | .forEach( 237 | entry -> { 238 | if (!known.contains(entry.getValue().nodeId())) { 239 | log.warn( 240 | "Remove lingering subscriptions from unknown node [{}]", 241 | entry.getValue().nodeId()); 242 | subsMap.remove(entry.getKey(), entry.getValue()); 243 | updated.add(entry.getKey()); 244 | } 245 | }); 246 | updated.forEach(topic::publish); 247 | } 248 | 249 | /** Republish subscriptions that belongs to the current node (in which this is executed). */ 250 | public void republishOwnSubs() { 251 | Lock writeLock = readWriteLock.writeLock(); 252 | writeLock.lock(); 253 | try { 254 | Set updated = new HashSet<>(); 255 | ownSubs.forEach( 256 | (address, registrationInfos) -> 257 | registrationInfos.forEach( 258 | registrationInfo -> { 259 | subsMap.put(address, registrationInfo); 260 | updated.add(address); 261 | })); 262 | updated.forEach(topic::publish); 263 | } finally { 264 | writeLock.unlock(); 265 | } 266 | } 267 | 268 | /** Close the subscription catalog. */ 269 | public void close() { 270 | topic.removeListener(listenerId); 271 | throttling.close(); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/main/java/com/retailsvc/vertx/spi/cluster/redis/RedisClusterManager.java: -------------------------------------------------------------------------------- 1 | package com.retailsvc.vertx.spi.cluster.redis; 2 | 3 | import static java.util.Collections.singleton; 4 | 5 | import com.retailsvc.vertx.spi.cluster.redis.config.RedisConfig; 6 | import com.retailsvc.vertx.spi.cluster.redis.impl.CloseableLock; 7 | import com.retailsvc.vertx.spi.cluster.redis.impl.NodeInfoCatalog; 8 | import com.retailsvc.vertx.spi.cluster.redis.impl.NodeInfoCatalogListener; 9 | import com.retailsvc.vertx.spi.cluster.redis.impl.RedissonContext; 10 | import com.retailsvc.vertx.spi.cluster.redis.impl.RedissonRedisInstance; 11 | import com.retailsvc.vertx.spi.cluster.redis.impl.SubscriptionCatalog; 12 | import io.vertx.core.Promise; 13 | import io.vertx.core.Vertx; 14 | import io.vertx.core.VertxException; 15 | import io.vertx.core.impl.VertxInternal; 16 | import io.vertx.core.shareddata.AsyncMap; 17 | import io.vertx.core.shareddata.Counter; 18 | import io.vertx.core.shareddata.Lock; 19 | import io.vertx.core.spi.cluster.ClusterManager; 20 | import io.vertx.core.spi.cluster.NodeInfo; 21 | import io.vertx.core.spi.cluster.NodeListener; 22 | import io.vertx.core.spi.cluster.NodeSelector; 23 | import io.vertx.core.spi.cluster.RegistrationInfo; 24 | import java.util.List; 25 | import java.util.Map; 26 | import java.util.Optional; 27 | import java.util.UUID; 28 | import java.util.concurrent.atomic.AtomicBoolean; 29 | import java.util.concurrent.locks.ReentrantLock; 30 | import org.redisson.api.RedissonClient; 31 | import org.slf4j.Logger; 32 | import org.slf4j.LoggerFactory; 33 | 34 | /** 35 | * A Vert.x cluster manager for Redis. 36 | * 37 | * @author sasjo 38 | */ 39 | public class RedisClusterManager implements ClusterManager, NodeInfoCatalogListener { 40 | 41 | private static final Logger log = LoggerFactory.getLogger(RedisClusterManager.class); 42 | private final RedissonContext redissonContext; 43 | 44 | private VertxInternal vertx; 45 | private NodeSelector nodeSelector; 46 | private String nodeId; 47 | private NodeInfo nodeInfo; 48 | private NodeListener nodeListener; 49 | 50 | private final AtomicBoolean active = new AtomicBoolean(); 51 | private final ReentrantLock lock = new ReentrantLock(); 52 | 53 | private RedissonRedisInstance dataGrid; 54 | 55 | private NodeInfoCatalog nodeInfoCatalog; 56 | private SubscriptionCatalog subscriptionCatalog; 57 | 58 | /** 59 | * Create a Redis cluster manager with default configuration from system properties or environment 60 | * variables. 61 | */ 62 | public RedisClusterManager() { 63 | this(new RedisConfig()); 64 | } 65 | 66 | /** 67 | * Create a Redis cluster manager with specified configuration. 68 | * 69 | * @param config the redis configuration 70 | */ 71 | public RedisClusterManager(RedisConfig config) { 72 | this(config, RedisClusterManager.class.getClassLoader()); 73 | } 74 | 75 | /** 76 | * Create a Redis cluster manager with specified configuration. 77 | * 78 | * @param config the redis configuration 79 | * @param dataClassLoader class loader used to restore keys and values returned from Redis 80 | */ 81 | public RedisClusterManager(RedisConfig config, ClassLoader dataClassLoader) { 82 | redissonContext = new RedissonContext(config, dataClassLoader); 83 | } 84 | 85 | @Override 86 | public void init(Vertx vertx, NodeSelector nodeSelector) { 87 | this.vertx = (VertxInternal) vertx; 88 | this.nodeSelector = nodeSelector; 89 | } 90 | 91 | @Override 92 | public void getAsyncMap(String name, Promise> promise) { 93 | promise.complete(dataGrid.getAsyncMap(name)); 94 | } 95 | 96 | @Override 97 | public Map getSyncMap(String name) { 98 | return dataGrid.getMap(name); 99 | } 100 | 101 | @Override 102 | public void getLockWithTimeout(String name, long timeout, Promise promise) { 103 | dataGrid.getLockWithTimeout(name, timeout).onComplete(promise); 104 | } 105 | 106 | @Override 107 | public void getCounter(String name, Promise promise) { 108 | promise.complete(dataGrid.getCounter(name)); 109 | } 110 | 111 | @Override 112 | public String getNodeId() { 113 | return nodeId; 114 | } 115 | 116 | @Override 117 | public List getNodes() { 118 | return nodeInfoCatalog.getNodes(); 119 | } 120 | 121 | @Override 122 | public void nodeListener(NodeListener listener) { 123 | this.nodeListener = listener; 124 | } 125 | 126 | @Override 127 | public void setNodeInfo(NodeInfo nodeInfo, Promise promise) { 128 | try (var ignored = CloseableLock.lock(lock)) { 129 | this.nodeInfo = nodeInfo; 130 | } 131 | vertx 132 | .executeBlocking( 133 | () -> { 134 | nodeInfoCatalog.setNodeInfo(nodeInfo); 135 | return null; 136 | }, 137 | false) 138 | .onComplete(promise); 139 | } 140 | 141 | @Override 142 | public NodeInfo getNodeInfo() { 143 | try (var ignored = CloseableLock.lock(lock)) { 144 | return nodeInfo; 145 | } 146 | } 147 | 148 | @Override 149 | public void getNodeInfo(String nodeId, Promise promise) { 150 | vertx 151 | .executeBlocking( 152 | () -> { 153 | NodeInfo value = nodeInfoCatalog.get(nodeId); 154 | if (value != null) { 155 | return value; 156 | } else { 157 | throw new VertxException("Not a member of the cluster"); 158 | } 159 | }, 160 | false) 161 | .onComplete(promise); 162 | } 163 | 164 | @Override 165 | public void join(Promise promise) { 166 | vertx 167 | .executeBlocking( 168 | () -> { 169 | if (active.compareAndSet(false, true)) { 170 | try (var ignored = CloseableLock.lock(lock)) { 171 | nodeId = UUID.randomUUID().toString(); 172 | log.debug("Join cluster as {}", nodeId); 173 | dataGrid = new RedissonRedisInstance(vertx, redissonContext); 174 | createCatalogs(redissonContext.client()); 175 | } 176 | } else { 177 | log.warn("Already activated, nodeId: {}", nodeId); 178 | } 179 | return null; 180 | }) 181 | .onComplete(promise); 182 | } 183 | 184 | private void createCatalogs(RedissonClient redisson) { 185 | nodeInfoCatalog = 186 | new NodeInfoCatalog(vertx, redisson, redissonContext.keyFactory(), nodeId, this); 187 | if (subscriptionCatalog != null) { 188 | subscriptionCatalog = 189 | new SubscriptionCatalog( 190 | subscriptionCatalog, redisson, redissonContext.keyFactory(), nodeSelector); 191 | } else { 192 | subscriptionCatalog = 193 | new SubscriptionCatalog(redisson, redissonContext.keyFactory(), nodeSelector); 194 | } 195 | subscriptionCatalog.removeUnknownSubs(nodeId, nodeInfoCatalog.getNodes()); 196 | } 197 | 198 | private String logId(String nodeId) { 199 | try (var ignored = CloseableLock.lock(lock)) { 200 | return nodeId.equals(this.nodeId) ? "%s (self)".formatted(nodeId) : nodeId; 201 | } 202 | } 203 | 204 | @Override 205 | public void memberAdded(String nodeId) { 206 | try (var ignored = CloseableLock.lock(lock)) { 207 | if (isActive()) { 208 | if (log.isDebugEnabled()) { 209 | log.debug("Add member [{}]", logId(nodeId)); 210 | } 211 | if (nodeListener != null) { 212 | nodeListener.nodeAdded(nodeId); 213 | } 214 | log.debug("Nodes in catalog:\n{}", nodeInfoCatalog); 215 | } 216 | } 217 | } 218 | 219 | @Override 220 | public void memberRemoved(String nodeId) { 221 | try (var ignored = CloseableLock.lock(lock)) { 222 | if (isActive()) { 223 | if (log.isDebugEnabled()) { 224 | log.debug("Remove member [{}]", logId(nodeId)); 225 | } 226 | subscriptionCatalog.removeAllForNodes(singleton(nodeId)); 227 | 228 | log.debug("Nodes in catalog:\n{}", nodeInfoCatalog); 229 | 230 | // Register self again. 231 | registerSelfAgain(); 232 | 233 | if (nodeListener != null) { 234 | nodeListener.nodeLeft(nodeId); 235 | } 236 | } 237 | } 238 | } 239 | 240 | /** Re-register self in the cluster. */ 241 | private void registerSelfAgain() { 242 | try (var ignored = CloseableLock.lock(lock)) { 243 | nodeInfoCatalog.setNodeInfo(getNodeInfo()); 244 | nodeSelector.registrationsLost(); 245 | 246 | vertx.executeBlocking( 247 | () -> { 248 | subscriptionCatalog.republishOwnSubs(); 249 | return null; 250 | }, 251 | false); 252 | } 253 | } 254 | 255 | @Override 256 | public void leave(Promise promise) { 257 | vertx 258 | .executeBlocking( 259 | () -> { 260 | // We need this to be synchronized to prevent other calls from happening while leaving 261 | // the cluster, typically memberAdded and memberRemoved. 262 | if (active.compareAndSet(true, false)) { 263 | try (var ignored = CloseableLock.lock(lock)) { 264 | log.debug("Leave custer as {}", nodeId); 265 | 266 | // Stop catalog services. 267 | closeCatalogs(); 268 | 269 | // Remove self from cluster. 270 | subscriptionCatalog.removeAllForNodes(singleton(nodeId)); 271 | nodeInfoCatalog.remove(nodeId); 272 | 273 | // Disconnect from Redis 274 | redissonContext.shutdown(); 275 | } 276 | } else { 277 | log.warn("Already deactivated, nodeId: {}", nodeId); 278 | } 279 | return null; 280 | }) 281 | .onComplete(promise); 282 | } 283 | 284 | private void closeCatalogs() { 285 | subscriptionCatalog.close(); 286 | nodeInfoCatalog.close(); 287 | } 288 | 289 | @Override 290 | public boolean isActive() { 291 | return active.get(); 292 | } 293 | 294 | @Override 295 | public void addRegistration( 296 | String address, RegistrationInfo registrationInfo, Promise promise) { 297 | vertx 298 | .executeBlocking( 299 | () -> { 300 | subscriptionCatalog.put(address, registrationInfo); 301 | return null; 302 | }, 303 | false) 304 | .onComplete(promise); 305 | } 306 | 307 | @Override 308 | public void removeRegistration( 309 | String address, RegistrationInfo registrationInfo, Promise promise) { 310 | vertx 311 | .executeBlocking( 312 | () -> { 313 | subscriptionCatalog.remove(address, registrationInfo); 314 | return null; 315 | }, 316 | false) 317 | .onComplete(promise); 318 | } 319 | 320 | @Override 321 | public void getRegistrations(String address, Promise> promise) { 322 | vertx.executeBlocking(() -> subscriptionCatalog.get(address), false).onComplete(promise); 323 | } 324 | 325 | /** 326 | * Returns a Redis instance object. This method returns an empty optional when the cluster manager 327 | * is inactive. 328 | * 329 | * @return the redis instance if active. 330 | */ 331 | public Optional getRedisInstance() { 332 | if (!isActive()) { 333 | return Optional.empty(); 334 | } 335 | return Optional.ofNullable(dataGrid); 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /usr/local/etc/mavenrc ] ; then 40 | . /usr/local/etc/mavenrc 41 | fi 42 | 43 | if [ -f /etc/mavenrc ] ; then 44 | . /etc/mavenrc 45 | fi 46 | 47 | if [ -f "$HOME/.mavenrc" ] ; then 48 | . "$HOME/.mavenrc" 49 | fi 50 | 51 | fi 52 | 53 | # OS specific support. $var _must_ be set to either true or false. 54 | cygwin=false; 55 | darwin=false; 56 | mingw=false 57 | case "`uname`" in 58 | CYGWIN*) cygwin=true ;; 59 | MINGW*) mingw=true;; 60 | Darwin*) darwin=true 61 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 62 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 63 | if [ -z "$JAVA_HOME" ]; then 64 | if [ -x "/usr/libexec/java_home" ]; then 65 | export JAVA_HOME="`/usr/libexec/java_home`" 66 | else 67 | export JAVA_HOME="/Library/Java/Home" 68 | fi 69 | fi 70 | ;; 71 | esac 72 | 73 | if [ -z "$JAVA_HOME" ] ; then 74 | if [ -r /etc/gentoo-release ] ; then 75 | JAVA_HOME=`java-config --jre-home` 76 | fi 77 | fi 78 | 79 | if [ -z "$M2_HOME" ] ; then 80 | ## resolve links - $0 may be a link to maven's home 81 | PRG="$0" 82 | 83 | # need this for relative symlinks 84 | while [ -h "$PRG" ] ; do 85 | ls=`ls -ld "$PRG"` 86 | link=`expr "$ls" : '.*-> \(.*\)$'` 87 | if expr "$link" : '/.*' > /dev/null; then 88 | PRG="$link" 89 | else 90 | PRG="`dirname "$PRG"`/$link" 91 | fi 92 | done 93 | 94 | saveddir=`pwd` 95 | 96 | M2_HOME=`dirname "$PRG"`/.. 97 | 98 | # make it fully qualified 99 | M2_HOME=`cd "$M2_HOME" && pwd` 100 | 101 | cd "$saveddir" 102 | # echo Using m2 at $M2_HOME 103 | fi 104 | 105 | # For Cygwin, ensure paths are in UNIX format before anything is touched 106 | if $cygwin ; then 107 | [ -n "$M2_HOME" ] && 108 | M2_HOME=`cygpath --unix "$M2_HOME"` 109 | [ -n "$JAVA_HOME" ] && 110 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 111 | [ -n "$CLASSPATH" ] && 112 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 113 | fi 114 | 115 | # For Mingw, ensure paths are in UNIX format before anything is touched 116 | if $mingw ; then 117 | [ -n "$M2_HOME" ] && 118 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 119 | [ -n "$JAVA_HOME" ] && 120 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 121 | fi 122 | 123 | if [ -z "$JAVA_HOME" ]; then 124 | javaExecutable="`which javac`" 125 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 126 | # readlink(1) is not available as standard on Solaris 10. 127 | readLink=`which readlink` 128 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 129 | if $darwin ; then 130 | javaHome="`dirname \"$javaExecutable\"`" 131 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 132 | else 133 | javaExecutable="`readlink -f \"$javaExecutable\"`" 134 | fi 135 | javaHome="`dirname \"$javaExecutable\"`" 136 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 137 | JAVA_HOME="$javaHome" 138 | export JAVA_HOME 139 | fi 140 | fi 141 | fi 142 | 143 | if [ -z "$JAVACMD" ] ; then 144 | if [ -n "$JAVA_HOME" ] ; then 145 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 146 | # IBM's JDK on AIX uses strange locations for the executables 147 | JAVACMD="$JAVA_HOME/jre/sh/java" 148 | else 149 | JAVACMD="$JAVA_HOME/bin/java" 150 | fi 151 | else 152 | JAVACMD="`\\unset -f command; \\command -v java`" 153 | fi 154 | fi 155 | 156 | if [ ! -x "$JAVACMD" ] ; then 157 | echo "Error: JAVA_HOME is not defined correctly." >&2 158 | echo " We cannot execute $JAVACMD" >&2 159 | exit 1 160 | fi 161 | 162 | if [ -z "$JAVA_HOME" ] ; then 163 | echo "Warning: JAVA_HOME environment variable is not set." 164 | fi 165 | 166 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 167 | 168 | # traverses directory structure from process work directory to filesystem root 169 | # first directory with .mvn subdirectory is considered project base directory 170 | find_maven_basedir() { 171 | 172 | if [ -z "$1" ] 173 | then 174 | echo "Path not specified to find_maven_basedir" 175 | return 1 176 | fi 177 | 178 | basedir="$1" 179 | wdir="$1" 180 | while [ "$wdir" != '/' ] ; do 181 | if [ -d "$wdir"/.mvn ] ; then 182 | basedir=$wdir 183 | break 184 | fi 185 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 186 | if [ -d "${wdir}" ]; then 187 | wdir=`cd "$wdir/.."; pwd` 188 | fi 189 | # end of workaround 190 | done 191 | echo "${basedir}" 192 | } 193 | 194 | # concatenates all lines of a file 195 | concat_lines() { 196 | if [ -f "$1" ]; then 197 | echo "$(tr -s '\n' ' ' < "$1")" 198 | fi 199 | } 200 | 201 | BASE_DIR=`find_maven_basedir "$(pwd)"` 202 | if [ -z "$BASE_DIR" ]; then 203 | exit 1; 204 | fi 205 | 206 | ########################################################################################## 207 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 208 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 209 | ########################################################################################## 210 | if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then 211 | if [ "$MVNW_VERBOSE" = true ]; then 212 | echo "Found .mvn/wrapper/maven-wrapper.jar" 213 | fi 214 | else 215 | if [ "$MVNW_VERBOSE" = true ]; then 216 | echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." 217 | fi 218 | if [ -n "$MVNW_REPOURL" ]; then 219 | jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 220 | else 221 | jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" 222 | fi 223 | while IFS="=" read key value; do 224 | case "$key" in (wrapperUrl) jarUrl="$value"; break ;; 225 | esac 226 | done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" 227 | if [ "$MVNW_VERBOSE" = true ]; then 228 | echo "Downloading from: $jarUrl" 229 | fi 230 | wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" 231 | if $cygwin; then 232 | wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` 233 | fi 234 | 235 | if command -v wget > /dev/null; then 236 | if [ "$MVNW_VERBOSE" = true ]; then 237 | echo "Found wget ... using wget" 238 | fi 239 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 240 | wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 241 | else 242 | wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 243 | fi 244 | elif command -v curl > /dev/null; then 245 | if [ "$MVNW_VERBOSE" = true ]; then 246 | echo "Found curl ... using curl" 247 | fi 248 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 249 | curl -o "$wrapperJarPath" "$jarUrl" -f 250 | else 251 | curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f 252 | fi 253 | 254 | else 255 | if [ "$MVNW_VERBOSE" = true ]; then 256 | echo "Falling back to using Java to download" 257 | fi 258 | javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" 259 | # For Cygwin, switch paths to Windows format before running javac 260 | if $cygwin; then 261 | javaClass=`cygpath --path --windows "$javaClass"` 262 | fi 263 | if [ -e "$javaClass" ]; then 264 | if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 265 | if [ "$MVNW_VERBOSE" = true ]; then 266 | echo " - Compiling MavenWrapperDownloader.java ..." 267 | fi 268 | # Compiling the Java class 269 | ("$JAVA_HOME/bin/javac" "$javaClass") 270 | fi 271 | if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 272 | # Running the downloader 273 | if [ "$MVNW_VERBOSE" = true ]; then 274 | echo " - Running MavenWrapperDownloader.java ..." 275 | fi 276 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") 277 | fi 278 | fi 279 | fi 280 | fi 281 | ########################################################################################## 282 | # End of extension 283 | ########################################################################################## 284 | 285 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 286 | if [ "$MVNW_VERBOSE" = true ]; then 287 | echo $MAVEN_PROJECTBASEDIR 288 | fi 289 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 290 | 291 | # For Cygwin, switch paths to Windows format before running java 292 | if $cygwin; then 293 | [ -n "$M2_HOME" ] && 294 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 295 | [ -n "$JAVA_HOME" ] && 296 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 297 | [ -n "$CLASSPATH" ] && 298 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 299 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 300 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 301 | fi 302 | 303 | # Provide a "standardized" way to retrieve the CLI args that will 304 | # work with both Windows and non-Windows executions. 305 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" 306 | export MAVEN_CMD_LINE_ARGS 307 | 308 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 309 | 310 | exec "$JAVACMD" \ 311 | $MAVEN_OPTS \ 312 | $MAVEN_DEBUG_OPTS \ 313 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 314 | "-Dmaven.home=${M2_HOME}" \ 315 | "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 316 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 317 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------