diff --git a/application.properties b/application.properties
index 9698f0b..e77bc5d 100644
--- a/application.properties
+++ b/application.properties
@@ -11,3 +11,5 @@ simple.metrics.dumpRate=PT5S
management.otlp.metrics.export.enabled=false
management.otlp.metrics.export.url=http://localhost:4318/v1/metrics
management.otlp.metrics.export.step=PT1S
+
+management.tracing.enabled=false
diff --git a/pom.xml b/pom.xml
index 8faad50..b6ef1f6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -17,7 +17,7 @@
17
UTF-8
- 7.0.0-SNAPSHOT
+ 7.0.0
@@ -69,10 +69,6 @@
io.micrometer
micrometer-core
-
- io.micrometer
- micrometer-registry-influx
-
io.micrometer
micrometer-registry-otlp
@@ -111,6 +107,11 @@
jedis
${jedis.version}
+
+ io.github.resilience4j
+ resilience4j-all
+ 1.7.1
+
io.github.resilience4j
resilience4j-circuitbreaker
@@ -126,8 +127,24 @@
jedis-test-app
-
+
+
+ src/main/resources
+ true
+
+ jedis-version.properties
+
+
+
+ src/main/resources
+ false
+
+ jedis-version.properties
+
+
+
+
net.revelc.code.formatter
formatter-maven-plugin
@@ -145,7 +162,6 @@
validate
-
@@ -155,10 +171,6 @@
redis.clients.jedis.test.JedisTestApplication
-
-
-
-
diff --git a/runner-config-multidb.yaml b/runner-config-multidb.yaml
new file mode 100644
index 0000000..0faa623
--- /dev/null
+++ b/runner-config-multidb.yaml
@@ -0,0 +1,75 @@
+runner:
+ redis:
+ clientName: "jedis-test-app-multidb"
+ #username: "default"
+ #password: "foobared"
+ verifyPeer: false
+
+ test:
+ mode: multidb # Multi-database mode with failover support
+ clients: 1 # Number of client instances
+ connectionsPerClient: 100 # Number of connections per client (used as default pool size)
+ threadsPerConnection: 1 # Number of threads sharing same connection
+ workload:
+ type: redis_commands
+ maxDuration: PT10S
+ options:
+ valueSize: 100 # 100 characters
+ iterationCount: 10000 # How many iterations to run of the workload before finishing
+ keyRangeMin: 0 # 0 Minimum key range
+ keyRangeMax: 1000 # 10000 Maximum key range
+
+ clientOptions: # Jedis connection-level options
+ timeoutOptions:
+ fixedTimeout: PT2S # Socket/read timeout
+ socketOptions:
+ connectTimeout: PT2S # TCP connect timeout
+ pool:
+ maxIdle: 100
+ minIdle: 1
+ maxWait: PT3S
+ blockWhenExhausted: true
+ testWhileIdle: true
+ timeBetweenEvictionRuns: PT10S
+
+ # Multi-database configuration for failover support
+ # NOTE: For testing with a single Redis instance, we use 127.0.0.1 and localhost
+ # as different endpoints (they resolve to the same server but are treated as different by Jedis)
+ # In production, you would use different Redis servers (e.g., different hosts or ports)
+ # Connection pool settings are taken from clientOptions.pool section (shared across all databases)
+ multiDbConfig:
+ databases:
+ # Primary database (higher weight = higher priority)
+ - endpoint: redis://localhost:6379/0
+ weight: 1.0
+ healthCheckEnabled: true
+ healthCheckType: ping # Options: ping, lagaware
+
+ # Secondary database (lower weight = lower priority, used for failover)
+ # Using 127.0.0.1 instead of localhost to create a different endpoint identifier
+ - endpoint: redis://127.0.0.1:6379/0
+ weight: 0.5
+ healthCheckEnabled: true
+ healthCheckType: ping
+
+ # Circuit breaker configuration for failure detection
+ failureDetector:
+ slidingWindowSize: 1000 # Sliding window size in number of calls
+ thresholdMinNumberOfFailures: 500 # Minimum number of failures before circuit breaker is tripped
+ failureRateThreshold: 50.0 # Percentage of failures to trigger circuit breaker
+
+ # Retry configuration
+ commandRetry:
+ maxAttempts: 3 # Maximum number of retry attempts (including the initial call)
+ waitDuration: 500 # Number of milliseconds to wait between retry attempts
+ exponentialBackoffMultiplier: 2 # Exponential backoff factor
+
+ # Failback configuration
+ failbackSupported: true # Enable automatic failback
+ failbackCheckInterval: 1000 # Check every second for unhealthy database recovery
+ gracePeriod: 2000 # Keep database disabled for 2 seconds after it becomes unhealthy
+
+ # Failover behavior
+ fastFailover: true # Force closing connections to unhealthy database on failover
+ retryOnFailover: false # Do not retry failed commands during failover
+
diff --git a/src/main/java/redis/clients/jedis/test/JedisWorkloadRunner.java b/src/main/java/redis/clients/jedis/test/JedisWorkloadRunner.java
index 230216a..e43b169 100644
--- a/src/main/java/redis/clients/jedis/test/JedisWorkloadRunner.java
+++ b/src/main/java/redis/clients/jedis/test/JedisWorkloadRunner.java
@@ -7,6 +7,8 @@
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
+import java.io.File;
+
@Component
public class JedisWorkloadRunner {
@@ -21,6 +23,58 @@ public class JedisWorkloadRunner {
public JedisWorkloadRunner(MetricsReporter metricsReporter, WorkloadRunnerConfig config) {
this.metricsReporter = metricsReporter;
this.config = config;
+
+ // Initialize SSL configuration at startup
+ initializeSslConfiguration();
+ }
+
+ /**
+ * Initialize SSL/TLS configuration for the application.
+ *
+ * This method configures JVM-wide SSL truststore properties based on the runner configuration. The truststore is used for
+ * all SSL/TLS connections made by the application, including: - Redis connections over SSL/TLS - LagAware health check REST
+ * API calls - Any other HTTPS connections
+ */
+ private void initializeSslConfiguration() {
+ WorkloadRunnerConfig.SslConfig sslConfig = config.getSsl();
+
+ if (sslConfig == null || sslConfig.getTruststorePath() == null) {
+ log.debug("No SSL truststore configuration provided, using JVM defaults");
+ return;
+ }
+
+ String truststorePath = sslConfig.getTruststorePath();
+ String truststorePassword = sslConfig.getTruststorePassword();
+ String truststoreType = sslConfig.getTruststoreType() != null ? sslConfig.getTruststoreType() : "JKS";
+
+ File truststoreFile = new File(truststorePath);
+ if (!truststoreFile.exists()) {
+ log.warn("SSL truststore file not found at: {}", truststoreFile.getAbsolutePath());
+ log.warn("SSL connections may fail if server certificates cannot be verified");
+ return;
+ }
+
+ if (!truststoreFile.canRead()) {
+ log.warn("SSL truststore file is not readable at: {}", truststoreFile.getAbsolutePath());
+ log.warn("SSL connections may fail if server certificates cannot be verified");
+ return;
+ }
+
+ log.info("Configuring JVM-wide SSL truststore");
+ log.info(" Truststore path: {}", truststoreFile.getAbsolutePath());
+ log.info(" Truststore type: {}", truststoreType);
+
+ // Set JVM-wide SSL truststore properties
+ // These properties are used by:
+ // - HttpsURLConnection (used by LagAware REST API calls)
+ // - SSLContext.getDefault() (used by various SSL/TLS clients)
+ System.setProperty("javax.net.ssl.trustStore", truststoreFile.getAbsolutePath());
+ if (truststorePassword != null) {
+ System.setProperty("javax.net.ssl.trustStorePassword", truststorePassword);
+ }
+ System.setProperty("javax.net.ssl.trustStoreType", truststoreType);
+
+ log.info("JVM-wide SSL truststore configured successfully");
}
public void run() {
@@ -29,8 +83,12 @@ public void run() {
runner = new StandaloneJedisWorkloadRunner(config, metricsReporter);
log.info("Running standalone workload (Jedis/UnifiedJedis)");
}
- default -> throw new IllegalArgumentException("Unsupported mode for Jedis app: " + config.getTest().getMode()
- + ". Only STANDALONE is supported for Jedis right now.");
+ case "MULTIDB" -> {
+ runner = new MultiDbJedisWorkloadRunner(config, metricsReporter);
+ log.info("Running multi-database workload with failover support (MultiDbClient)");
+ }
+ default -> throw new IllegalArgumentException(
+ "Unsupported mode for Jedis app: " + config.getTest().getMode() + ". Supported modes: STANDALONE, MULTIDB");
}
runner.run();
}
diff --git a/src/main/java/redis/clients/jedis/test/MultiDbJedisWorkloadRunner.java b/src/main/java/redis/clients/jedis/test/MultiDbJedisWorkloadRunner.java
new file mode 100644
index 0000000..e2e6838
--- /dev/null
+++ b/src/main/java/redis/clients/jedis/test/MultiDbJedisWorkloadRunner.java
@@ -0,0 +1,421 @@
+package redis.clients.jedis.test;
+
+import redis.clients.jedis.test.config.WorkloadRunnerConfig;
+import redis.clients.jedis.test.config.WorkloadRunnerConfig.WorkloadConfig;
+import redis.clients.jedis.test.config.WorkloadRunnerConfig.MultiDbConfigOptions;
+import redis.clients.jedis.test.config.WorkloadRunnerConfig.MultiDbConfigOptions.DatabaseConfigOptions;
+import redis.clients.jedis.test.config.WorkloadRunnerConfig.MultiDbConfigOptions.CircuitBreakerConfigOptions;
+import redis.clients.jedis.test.config.WorkloadRunnerConfig.MultiDbConfigOptions.RetryConfigOptions;
+import redis.clients.jedis.test.config.WorkloadRunnerConfig.MultiDbConfigOptions.HealthCheckConfigOptions;
+import redis.clients.jedis.test.config.WorkloadRunnerConfig.ClientOptionsConfig.PoolOptionsConfig;
+import redis.clients.jedis.test.metrics.MetricsReporter;
+import redis.clients.jedis.test.workloads.BaseWorkload;
+import redis.clients.jedis.test.workloads.JedisRedisCommandsWorkload;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import redis.clients.jedis.*;
+import redis.clients.jedis.mcf.LagAwareStrategy;
+import redis.clients.jedis.util.JedisURIHelper;
+import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
+
+import java.util.function.Supplier;
+
+import javax.net.ssl.SSLParameters;
+import java.net.URI;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Multi-database Jedis workload runner with failover support
+ */
+public class MultiDbJedisWorkloadRunner implements JedisRunner {
+
+ private static final Logger log = LoggerFactory.getLogger(MultiDbJedisWorkloadRunner.class);
+
+ private final WorkloadRunnerConfig config;
+
+ private final MetricsReporter metricsReporter;
+
+ private final ExecutorService executor = Executors.newCachedThreadPool();
+
+ private final List clients = new ArrayList<>();
+
+ public MultiDbJedisWorkloadRunner(WorkloadRunnerConfig config, MetricsReporter metricsReporter) {
+ this.config = Objects.requireNonNull(config);
+ this.metricsReporter = Objects.requireNonNull(metricsReporter);
+ }
+
+ public void run() {
+ for (int i = 0; i < config.getTest().getClients(); i++) {
+ MultiDbClient client = createMultiDbClient(config);
+ clients.add(client);
+ }
+
+ metricsReporter.recordStartTime();
+ try {
+ CompletableFuture all = executeWorkloads(clients);
+ all.whenComplete((v, t) -> metricsReporter.recordEndTime())
+ .thenRun(() -> log.info("All MultiDb Jedis tasks completed. Exiting..."));
+ // Wait for all workloads to complete
+ all.join();
+ } finally {
+ executor.shutdown();
+ }
+ }
+
+ private CompletableFuture executeWorkloads(List clients) {
+ List> futures = new ArrayList<>();
+ WorkloadConfig workloadConfig = config.getTest().getWorkload();
+
+ for (MultiDbClient conn : clients) {
+ for (int j = 0; j < config.getTest().getThreadsPerConnection(); j++) {
+ BaseWorkload workload = createWorkload(conn, workloadConfig);
+ workload.metricsReporter(metricsReporter);
+ futures.add(submit(workload, workloadConfig));
+ }
+ }
+
+ return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
+ }
+
+ private BaseWorkload createWorkload(UnifiedJedis conn, WorkloadConfig config) {
+ CommonWorkloadOptions options = DefaultWorkloadOptions.create(config.getOptions());
+ return switch (config.getType()) {
+ case "redis_commands" -> new JedisRedisCommandsWorkload(conn, options);
+ default -> throw new IllegalArgumentException(
+ "Invalid workload specified for Jedis multidb mode: " + config.getType());
+ };
+ }
+
+ private CompletableFuture submit(BaseWorkload task, WorkloadConfig config) {
+ ContinuousWorkload cw = new ContinuousWorkload(task, config);
+ return CompletableFuture.runAsync(cw::run, executor).thenApply(v -> cw);
+ }
+
+ private MultiDbClient createMultiDbClient(WorkloadRunnerConfig c) {
+ MultiDbConfigOptions multiDbConfigOptions = c.getMultiDbConfig();
+ if (multiDbConfigOptions == null) {
+ throw new IllegalArgumentException("runner.multiDbConfig must be provided for multidb mode");
+ }
+
+ if (multiDbConfigOptions.getDatabases() == null || multiDbConfigOptions.getDatabases().isEmpty()) {
+ throw new IllegalArgumentException("runner.multiDbConfig.databases must be provided and non-empty");
+ }
+
+ log.info("Creating MultiDbClient with {} database(s)", multiDbConfigOptions.getDatabases().size());
+
+ // Build MultiDbConfig
+ MultiDbConfig.Builder multiConfigBuilder = MultiDbConfig.builder();
+
+ // Add databases
+ for (DatabaseConfigOptions dbOpts : multiDbConfigOptions.getDatabases()) {
+ MultiDbConfig.DatabaseConfig dbConfig = buildDatabaseConfig(dbOpts, c);
+ multiConfigBuilder.database(dbConfig);
+ log.info("Added database: {} with weight {}", dbOpts.getEndpoint(), dbOpts.getWeight());
+ }
+
+ // Configure circuit breaker (failure detector)
+ if (multiDbConfigOptions.getFailureDetector() != null) {
+ CircuitBreakerConfigOptions cbOpts = multiDbConfigOptions.getFailureDetector();
+ MultiDbConfig.CircuitBreakerConfig.Builder cbBuilder = MultiDbConfig.CircuitBreakerConfig.builder();
+
+ if (cbOpts.getSlidingWindowSize() != null) {
+ cbBuilder.slidingWindowSize(cbOpts.getSlidingWindowSize());
+ }
+ if (cbOpts.getThresholdMinNumberOfFailures() != null) {
+ cbBuilder.minNumOfFailures(cbOpts.getThresholdMinNumberOfFailures());
+ }
+ if (cbOpts.getFailureRateThreshold() != null) {
+ cbBuilder.failureRateThreshold(cbOpts.getFailureRateThreshold());
+ }
+
+ multiConfigBuilder.failureDetector(cbBuilder.build());
+ log.info("Configured circuit breaker: slidingWindowSize={}, minFailures={}, failureRateThreshold={}",
+ cbOpts.getSlidingWindowSize(), cbOpts.getThresholdMinNumberOfFailures(), cbOpts.getFailureRateThreshold());
+ }
+
+ // Configure retry
+ if (multiDbConfigOptions.getCommandRetry() != null) {
+ RetryConfigOptions retryOpts = multiDbConfigOptions.getCommandRetry();
+ MultiDbConfig.RetryConfig.Builder retryBuilder = MultiDbConfig.RetryConfig.builder();
+
+ if (retryOpts.getMaxAttempts() != null) {
+ retryBuilder.maxAttempts(retryOpts.getMaxAttempts());
+ }
+ if (retryOpts.getWaitDuration() != null) {
+ retryBuilder.waitDuration(retryOpts.getWaitDuration());
+ }
+ if (retryOpts.getExponentialBackoffMultiplier() != null) {
+ retryBuilder.exponentialBackoffMultiplier(retryOpts.getExponentialBackoffMultiplier());
+ }
+
+ multiConfigBuilder.commandRetry(retryBuilder.build());
+ log.info("Configured retry: maxAttempts={}, waitDuration={}, backoffMultiplier={}", retryOpts.getMaxAttempts(),
+ retryOpts.getWaitDuration(), retryOpts.getExponentialBackoffMultiplier());
+ }
+
+ // Configure failback
+ if (multiDbConfigOptions.getFailbackSupported() != null) {
+ multiConfigBuilder.failbackSupported(multiDbConfigOptions.getFailbackSupported());
+ }
+ if (multiDbConfigOptions.getFailbackCheckInterval() != null) {
+ multiConfigBuilder.failbackCheckInterval(multiDbConfigOptions.getFailbackCheckInterval());
+ }
+ if (multiDbConfigOptions.getGracePeriod() != null) {
+ multiConfigBuilder.gracePeriod(multiDbConfigOptions.getGracePeriod());
+ }
+ if (multiDbConfigOptions.getFastFailover() != null) {
+ multiConfigBuilder.fastFailover(multiDbConfigOptions.getFastFailover());
+ }
+ if (multiDbConfigOptions.getRetryOnFailover() != null) {
+ multiConfigBuilder.retryOnFailover(multiDbConfigOptions.getRetryOnFailover());
+ }
+
+ // Build and create client
+ MultiDbConfig multiConfig = multiConfigBuilder.build();
+ MultiDbClient client = MultiDbClient.builder().multiDbConfig(multiConfig)
+ .databaseSwitchListener(
+ event -> log.info("Database switched to: {} (reason: {})", event.getEndpoint(), event.getReason()))
+ .build();
+
+ log.info("MultiDbClient created successfully");
+ return client;
+ }
+
+ private MultiDbConfig.DatabaseConfig buildDatabaseConfig(DatabaseConfigOptions dbOpts, WorkloadRunnerConfig c) {
+ if (dbOpts.getEndpoint() == null || dbOpts.getEndpoint().isBlank()) {
+ throw new IllegalArgumentException("Database endpoint must be provided");
+ }
+
+ URI endpointUri = URI.create(dbOpts.getEndpoint());
+ HostAndPort hostAndPort = JedisURIHelper.getHostAndPort(endpointUri);
+
+ // Build JedisClientConfig
+ boolean sslEnabled = JedisURIHelper.isRedisSSLScheme(endpointUri);
+ DefaultJedisClientConfig.Builder cfg = DefaultJedisClientConfig.builder().user(JedisURIHelper.getUser(endpointUri))
+ .password(JedisURIHelper.getPassword(endpointUri)).database(JedisURIHelper.getDBIndex(endpointUri))
+ .protocol(JedisURIHelper.getRedisProtocol(endpointUri)).ssl(sslEnabled);
+
+ // Username/password from config take precedence
+ WorkloadRunnerConfig.RedisConfig rc = c.getRedis();
+ if (rc != null) {
+ if (rc.getUsername() != null) {
+ cfg.user(rc.getUsername());
+ }
+ if (rc.getPassword() != null) {
+ cfg.password(rc.getPassword());
+ }
+ }
+
+ // Timeouts from clientOptions
+ WorkloadRunnerConfig.ClientOptionsConfig co = c.getClientOptions();
+ Duration socketTimeout = null;
+ Duration blockingSocketTimeout = null;
+ Duration connectTimeout = null;
+ if (co != null) {
+ if (co.getTimeoutOptions() != null) {
+ socketTimeout = co.getTimeoutOptions().getFixedTimeout();
+ blockingSocketTimeout = co.getTimeoutOptions().getBlockingSocketTimeout();
+ }
+ if (co.getSocketOptions() != null) {
+ connectTimeout = co.getSocketOptions().getConnectTimeout();
+ }
+ }
+
+ // Fallbacks
+ if (socketTimeout == null) {
+ socketTimeout = Duration.ofMillis(2000);
+ }
+ if (connectTimeout == null) {
+ connectTimeout = Duration.ofMillis(2000);
+ }
+
+ if (socketTimeout != null) {
+ cfg.socketTimeoutMillis(Math.toIntExact(socketTimeout.toMillis()));
+ }
+ if (blockingSocketTimeout != null) {
+ cfg.blockingSocketTimeoutMillis(Math.toIntExact(blockingSocketTimeout.toMillis()));
+ }
+ if (connectTimeout != null) {
+ cfg.connectionTimeoutMillis(Math.toIntExact(connectTimeout.toMillis()));
+ }
+
+ // SSL peer verification
+ if (sslEnabled && rc != null) {
+ SSLParameters sslParams = new SSLParameters();
+ sslParams.setEndpointIdentificationAlgorithm(rc.isVerifyPeer() ? "HTTPS" : null);
+ cfg.sslParameters(sslParams);
+ }
+
+ JedisClientConfig clientCfg = cfg.build();
+
+ // Build DatabaseConfig
+ MultiDbConfig.DatabaseConfig.Builder dbBuilder = MultiDbConfig.DatabaseConfig.builder(hostAndPort, clientCfg);
+
+ // Weight
+ if (dbOpts.getWeight() != null) {
+ dbBuilder.weight(dbOpts.getWeight());
+ }
+
+ // Connection pool config - use global clientOptions pool configuration
+ if (co != null && co.getPool() != null) {
+ GenericObjectPoolConfig poolCfg = buildPoolConfig(co.getPool(), c);
+ dbBuilder.connectionPoolConfig(poolCfg);
+ }
+
+ // Health check enabled/disabled
+ if (dbOpts.getHealthCheckEnabled() != null) {
+ dbBuilder.healthCheckEnabled(dbOpts.getHealthCheckEnabled());
+ }
+
+ // Health check strategy (ping or lagaware)
+ String healthCheckType = dbOpts.getHealthCheckType();
+ if ("lagaware".equalsIgnoreCase(healthCheckType)) {
+ log.info("Configuring LagAware health check strategy for {}", dbOpts.getEndpoint());
+ MultiDbConfig.StrategySupplier lagAwareStrategy = createLagAwareStrategy(dbOpts, hostAndPort);
+ if (lagAwareStrategy != null) {
+ dbBuilder.healthCheckStrategySupplier(lagAwareStrategy);
+ log.info("LagAware health check strategy configured successfully");
+ } else {
+ log.warn("Failed to configure LagAware strategy, falling back to default ping strategy");
+ }
+ } else if ("ping".equalsIgnoreCase(healthCheckType)) {
+ log.info("Using default ping health check strategy for {}", dbOpts.getEndpoint());
+ // Default ping strategy is used automatically, no need to configure
+ } else if (healthCheckType != null) {
+ log.warn("Unknown health check type '{}', using default ping strategy", healthCheckType);
+ }
+
+ return dbBuilder.build();
+ }
+
+ /**
+ * Create LagAware health check strategy for Redis Enterprise Active-Active deployments.
+ *
+ * This strategy uses the Redis Enterprise REST API to monitor replication lag and determine database health based on lag
+ * thresholds.
+ */
+ private MultiDbConfig.StrategySupplier createLagAwareStrategy(DatabaseConfigOptions dbOpts, HostAndPort dbHostPort) {
+ try {
+ // Get lagaware configuration from database options or health check config
+ HealthCheckConfigOptions healthCheckConfig = dbOpts.getHealthCheckConfig();
+
+ String restEndpoint = null;
+ String restUsername = null;
+ String restPassword = null;
+
+ if (healthCheckConfig != null) {
+ if (healthCheckConfig.getRestEndpoint() != null) {
+ restEndpoint = healthCheckConfig.getRestEndpoint();
+ }
+ if (healthCheckConfig.getRestUsername() != null) {
+ restUsername = healthCheckConfig.getRestUsername();
+ }
+ if (healthCheckConfig.getRestPassword() != null) {
+ restPassword = healthCheckConfig.getRestPassword();
+ }
+ }
+
+ // Validate required configuration
+ if (restEndpoint == null || restEndpoint.isBlank()) {
+ log.error("LagAware strategy requires restEndpoint to be configured");
+ return null;
+ }
+
+ if (restUsername == null || restPassword == null) {
+ log.warn("LagAware strategy: REST API credentials not provided, authentication may fail");
+ }
+
+ // Parse REST endpoint to get host and port
+ URI restUri = URI.create(restEndpoint);
+ HostAndPort restHostPort = new HostAndPort(restUri.getHost(), restUri.getPort() > 0 ? restUri.getPort() : 9443);
+
+ log.info("LagAware strategy configuration: restEndpoint={}", restEndpoint);
+
+ // Create credentials supplier
+ final String finalUsername = restUsername;
+ final String finalPassword = restPassword;
+ Supplier credentialsSupplier = () -> new DefaultRedisCredentials(finalUsername, finalPassword);
+
+ // Build LagAware configuration
+ var lagConfigBuilder = LagAwareStrategy.Config.builder(restHostPort, credentialsSupplier);
+
+ // Apply health check configuration options
+ if (healthCheckConfig.getInterval() != null) {
+ lagConfigBuilder.interval(Math.toIntExact(healthCheckConfig.getInterval().toMillis()));
+ log.debug("LagAware: interval={}", healthCheckConfig.getInterval());
+ }
+ if (healthCheckConfig.getTimeout() != null) {
+ lagConfigBuilder.timeout(Math.toIntExact(healthCheckConfig.getTimeout().toMillis()));
+ log.debug("LagAware: timeout={}", healthCheckConfig.getTimeout());
+ }
+ if (healthCheckConfig.getNumProbes() != null) {
+ lagConfigBuilder.numProbes(healthCheckConfig.getNumProbes());
+ log.debug("LagAware: numProbes={}", healthCheckConfig.getNumProbes());
+ }
+ if (healthCheckConfig.getDelayInBetweenProbes() != null) {
+ lagConfigBuilder.delayInBetweenProbes(Math.toIntExact(healthCheckConfig.getDelayInBetweenProbes().toMillis()));
+ log.debug("LagAware: delayInBetweenProbes={}", healthCheckConfig.getDelayInBetweenProbes());
+
+ }
+
+ // Configure SSL options for REST API calls
+ // Note: JVM-wide truststore is configured by SslConfigurationInitializer at application startup
+ lagConfigBuilder.sslOptions(SslOptions.builder().sslVerifyMode(SslVerifyMode.CA).build());
+
+ LagAwareStrategy.Config lagConfig = lagConfigBuilder.build();
+
+ // Return strategy supplier
+ return (hostAndPort, jedisClientConfig) -> new LagAwareStrategy(lagConfig);
+
+ } catch (Exception e) {
+ log.error("Failed to create LagAware health check strategy", e);
+ return null;
+ }
+ }
+
+ private GenericObjectPoolConfig buildPoolConfig(PoolOptionsConfig pc, WorkloadRunnerConfig c) {
+ GenericObjectPoolConfig poolCfg = new GenericObjectPoolConfig<>();
+
+ WorkloadRunnerConfig.TestConfig tc = c.getTest();
+ int poolSize = Math.max(1, tc.getConnectionsPerClient());
+
+ poolCfg.setMaxTotal(pc.getMaxTotal() != null ? pc.getMaxTotal() : poolSize);
+ poolCfg.setMaxIdle(pc.getMaxIdle() != null ? pc.getMaxIdle() : poolSize);
+ poolCfg.setMinIdle(pc.getMinIdle() != null ? pc.getMinIdle() : Math.min(1, poolSize));
+ poolCfg.setBlockWhenExhausted(pc.getBlockWhenExhausted() != null ? pc.getBlockWhenExhausted() : true);
+ poolCfg.setMaxWait(pc.getMaxWait() != null ? pc.getMaxWait() : Duration.ofSeconds(1));
+ poolCfg.setTestWhileIdle(pc.getTestWhileIdle() != null ? pc.getTestWhileIdle() : true);
+ poolCfg.setTimeBetweenEvictionRuns(
+ pc.getTimeBetweenEvictionRuns() != null ? pc.getTimeBetweenEvictionRuns() : Duration.ofSeconds(1));
+
+ return poolCfg;
+ }
+
+ @Override
+ public void close() {
+ log.info("MultiDbJedisWorkloadRunner stopping...");
+ executor.shutdown();
+ try {
+ if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
+ executor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ executor.shutdownNow();
+ }
+ for (MultiDbClient c : clients) {
+ try {
+ c.close();
+ } catch (Exception ignore) {
+ }
+ }
+ log.info("MultiDbJedisWorkloadRunner stopped.");
+ }
+
+}
diff --git a/src/main/java/redis/clients/jedis/test/StandaloneJedisWorkloadRunner.java b/src/main/java/redis/clients/jedis/test/StandaloneJedisWorkloadRunner.java
index de7f7e4..51e1820 100644
--- a/src/main/java/redis/clients/jedis/test/StandaloneJedisWorkloadRunner.java
+++ b/src/main/java/redis/clients/jedis/test/StandaloneJedisWorkloadRunner.java
@@ -79,6 +79,8 @@ public void run() {
CompletableFuture all = executeWorkloads(clients);
all.whenComplete((v, t) -> metricsReporter.recordEndTime())
.thenRun(() -> log.info("All Jedis tasks completed. Exiting..."));
+ // Wait for all workloads to complete
+ all.join();
} finally {
executor.shutdown();
}
diff --git a/src/main/java/redis/clients/jedis/test/config/WorkloadRunnerConfig.java b/src/main/java/redis/clients/jedis/test/config/WorkloadRunnerConfig.java
index 8a33ff2..a71ecca 100644
--- a/src/main/java/redis/clients/jedis/test/config/WorkloadRunnerConfig.java
+++ b/src/main/java/redis/clients/jedis/test/config/WorkloadRunnerConfig.java
@@ -24,6 +24,10 @@ public class WorkloadRunnerConfig {
private ClusterClientOptionsConfig clusterClientOptions;
+ private MultiDbConfigOptions multiDbConfig;
+
+ private SslConfig ssl;
+
public RedisConfig getRedis() {
return redis;
}
@@ -56,10 +60,26 @@ public void setClusterClientOptions(ClusterClientOptionsConfig clusterClientOpti
this.clusterClientOptions = clusterClientOptions;
}
+ public MultiDbConfigOptions getMultiDbConfig() {
+ return multiDbConfig;
+ }
+
+ public void setMultiDbConfig(MultiDbConfigOptions multiDbConfig) {
+ this.multiDbConfig = multiDbConfig;
+ }
+
+ public SslConfig getSsl() {
+ return ssl;
+ }
+
+ public void setSsl(SslConfig ssl) {
+ this.ssl = ssl;
+ }
+
@Override
public String toString() {
return "WorkloadRunnerConfig{" + "redis=" + redis + ", test=" + test + ", clientOptions=" + clientOptions
- + ", clusterClientOptions=" + clusterClientOptions + '}';
+ + ", clusterClientOptions=" + clusterClientOptions + ", multiDbConfig=" + multiDbConfig + ", ssl=" + ssl + '}';
}
public static class RedisConfig {
@@ -117,9 +137,8 @@ public void setEndpoints(List endpoints) {
@Override
public String toString() {
return "RedisConfig{username='" + username + '\'' + ", password='"
- + Optional.ofNullable(password).map(p -> "*".repeat(p.length())).orElse("") + '\''
- + ", verifyPeer=" + verifyPeer + ", clientName='" + clientName + '\''
- + ", endpoints=" + endpoints + '}';
+ + Optional.ofNullable(password).map(p -> "*".repeat(p.length())).orElse("") + '\'' + ", verifyPeer="
+ + verifyPeer + ", clientName='" + clientName + '\'' + ", endpoints=" + endpoints + '}';
}
}
@@ -720,4 +739,388 @@ public void setMovingEndpointAddressType(String movingEndpointAddressType) {
}
+ /**
+ * Configuration for multi-database failover support
+ */
+ public static class MultiDbConfigOptions {
+
+ private List databases;
+
+ private CircuitBreakerConfigOptions failureDetector;
+
+ private RetryConfigOptions commandRetry;
+
+ private Boolean failbackSupported;
+
+ private Integer failbackCheckInterval;
+
+ private Integer gracePeriod;
+
+ private Boolean fastFailover;
+
+ private Boolean retryOnFailover;
+
+ public List getDatabases() {
+ return databases;
+ }
+
+ public void setDatabases(List databases) {
+ this.databases = databases;
+ }
+
+ public CircuitBreakerConfigOptions getFailureDetector() {
+ return failureDetector;
+ }
+
+ public void setFailureDetector(CircuitBreakerConfigOptions failureDetector) {
+ this.failureDetector = failureDetector;
+ }
+
+ public RetryConfigOptions getCommandRetry() {
+ return commandRetry;
+ }
+
+ public void setCommandRetry(RetryConfigOptions commandRetry) {
+ this.commandRetry = commandRetry;
+ }
+
+ public Boolean getFailbackSupported() {
+ return failbackSupported;
+ }
+
+ public void setFailbackSupported(Boolean failbackSupported) {
+ this.failbackSupported = failbackSupported;
+ }
+
+ public Integer getFailbackCheckInterval() {
+ return failbackCheckInterval;
+ }
+
+ public void setFailbackCheckInterval(Integer failbackCheckInterval) {
+ this.failbackCheckInterval = failbackCheckInterval;
+ }
+
+ public Integer getGracePeriod() {
+ return gracePeriod;
+ }
+
+ public void setGracePeriod(Integer gracePeriod) {
+ this.gracePeriod = gracePeriod;
+ }
+
+ public Boolean getFastFailover() {
+ return fastFailover;
+ }
+
+ public void setFastFailover(Boolean fastFailover) {
+ this.fastFailover = fastFailover;
+ }
+
+ public Boolean getRetryOnFailover() {
+ return retryOnFailover;
+ }
+
+ public void setRetryOnFailover(Boolean retryOnFailover) {
+ this.retryOnFailover = retryOnFailover;
+ }
+
+ @Override
+ public String toString() {
+ return "MultiDbConfigOptions{" + "databases=" + databases + ", failureDetector=" + failureDetector
+ + ", commandRetry=" + commandRetry + ", failbackSupported=" + failbackSupported + ", failbackCheckInterval="
+ + failbackCheckInterval + ", gracePeriod=" + gracePeriod + ", fastFailover=" + fastFailover
+ + ", retryOnFailover=" + retryOnFailover + '}';
+ }
+
+ public static class DatabaseConfigOptions {
+
+ private String endpoint;
+
+ private Float weight;
+
+ private String healthCheckType; // "ping" or "lagaware"
+
+ private HealthCheckConfigOptions healthCheckConfig;
+
+ private Boolean healthCheckEnabled;
+
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ public void setEndpoint(String endpoint) {
+ this.endpoint = endpoint;
+ }
+
+ public Float getWeight() {
+ return weight;
+ }
+
+ public void setWeight(Float weight) {
+ this.weight = weight;
+ }
+
+ public String getHealthCheckType() {
+ return healthCheckType;
+ }
+
+ public void setHealthCheckType(String healthCheckType) {
+ this.healthCheckType = healthCheckType;
+ }
+
+ public HealthCheckConfigOptions getHealthCheckConfig() {
+ return healthCheckConfig;
+ }
+
+ public void setHealthCheckConfig(HealthCheckConfigOptions healthCheckConfig) {
+ this.healthCheckConfig = healthCheckConfig;
+ }
+
+ public Boolean getHealthCheckEnabled() {
+ return healthCheckEnabled;
+ }
+
+ public void setHealthCheckEnabled(Boolean healthCheckEnabled) {
+ this.healthCheckEnabled = healthCheckEnabled;
+ }
+
+ @Override
+ public String toString() {
+ return "DatabaseConfigOptions{" + "endpoint='" + endpoint + '\'' + ", weight=" + weight + ", healthCheckType='"
+ + healthCheckType + '\'' + ", healthCheckConfig=" + healthCheckConfig + ", healthCheckEnabled="
+ + healthCheckEnabled + '}';
+ }
+
+ }
+
+ public static class CircuitBreakerConfigOptions {
+
+ private Integer slidingWindowSize;
+
+ private Integer thresholdMinNumberOfFailures;
+
+ private Float failureRateThreshold;
+
+ public Integer getSlidingWindowSize() {
+ return slidingWindowSize;
+ }
+
+ public void setSlidingWindowSize(Integer slidingWindowSize) {
+ this.slidingWindowSize = slidingWindowSize;
+ }
+
+ public Integer getThresholdMinNumberOfFailures() {
+ return thresholdMinNumberOfFailures;
+ }
+
+ public void setThresholdMinNumberOfFailures(Integer thresholdMinNumberOfFailures) {
+ this.thresholdMinNumberOfFailures = thresholdMinNumberOfFailures;
+ }
+
+ public Float getFailureRateThreshold() {
+ return failureRateThreshold;
+ }
+
+ public void setFailureRateThreshold(Float failureRateThreshold) {
+ this.failureRateThreshold = failureRateThreshold;
+ }
+
+ @Override
+ public String toString() {
+ return "CircuitBreakerConfigOptions{" + "slidingWindowSize=" + slidingWindowSize
+ + ", thresholdMinNumberOfFailures=" + thresholdMinNumberOfFailures + ", failureRateThreshold="
+ + failureRateThreshold + '}';
+ }
+
+ }
+
+ public static class RetryConfigOptions {
+
+ private Integer maxAttempts;
+
+ private Integer waitDuration;
+
+ private Integer exponentialBackoffMultiplier;
+
+ public Integer getMaxAttempts() {
+ return maxAttempts;
+ }
+
+ public void setMaxAttempts(Integer maxAttempts) {
+ this.maxAttempts = maxAttempts;
+ }
+
+ public Integer getWaitDuration() {
+ return waitDuration;
+ }
+
+ public void setWaitDuration(Integer waitDuration) {
+ this.waitDuration = waitDuration;
+ }
+
+ public Integer getExponentialBackoffMultiplier() {
+ return exponentialBackoffMultiplier;
+ }
+
+ public void setExponentialBackoffMultiplier(Integer exponentialBackoffMultiplier) {
+ this.exponentialBackoffMultiplier = exponentialBackoffMultiplier;
+ }
+
+ @Override
+ public String toString() {
+ return "RetryConfigOptions{" + "maxAttempts=" + maxAttempts + ", waitDuration=" + waitDuration
+ + ", exponentialBackoffMultiplier=" + exponentialBackoffMultiplier + '}';
+ }
+
+ }
+
+ public static class HealthCheckConfigOptions {
+
+ private Duration interval;
+
+ private Duration timeout;
+
+ private Integer numProbes;
+
+ private String policy;
+
+ private Duration delayInBetweenProbes;
+
+ // LagAware specific options
+ private Long maxLag;
+
+ private String restEndpoint;
+
+ private String restUsername;
+
+ private String restPassword;
+
+ public Duration getInterval() {
+ return interval;
+ }
+
+ public void setInterval(Duration interval) {
+ this.interval = interval;
+ }
+
+ public Duration getTimeout() {
+ return timeout;
+ }
+
+ public void setTimeout(Duration timeout) {
+ this.timeout = timeout;
+ }
+
+ public Integer getNumProbes() {
+ return numProbes;
+ }
+
+ public void setNumProbes(Integer numProbes) {
+ this.numProbes = numProbes;
+ }
+
+ public String getPolicy() {
+ return policy;
+ }
+
+ public void setPolicy(String policy) {
+ this.policy = policy;
+ }
+
+ public Duration getDelayInBetweenProbes() {
+ return delayInBetweenProbes;
+ }
+
+ public void setDelayInBetweenProbes(Duration delayInBetweenProbes) {
+ this.delayInBetweenProbes = delayInBetweenProbes;
+ }
+
+ public Long getMaxLag() {
+ return maxLag;
+ }
+
+ public void setMaxLag(Long maxLag) {
+ this.maxLag = maxLag;
+ }
+
+ public String getRestEndpoint() {
+ return restEndpoint;
+ }
+
+ public void setRestEndpoint(String restEndpoint) {
+ this.restEndpoint = restEndpoint;
+ }
+
+ public String getRestUsername() {
+ return restUsername;
+ }
+
+ public void setRestUsername(String restUsername) {
+ this.restUsername = restUsername;
+ }
+
+ public String getRestPassword() {
+ return restPassword;
+ }
+
+ public void setRestPassword(String restPassword) {
+ this.restPassword = restPassword;
+ }
+
+ @Override
+ public String toString() {
+ return "HealthCheckConfigOptions{" + "interval=" + interval + ", timeout=" + timeout + ", numProbes="
+ + numProbes + ", policy='" + policy + '\'' + ", delayInBetweenProbes=" + delayInBetweenProbes
+ + ", maxLag=" + maxLag + ", restEndpoint='" + restEndpoint + '\'' + ", restUsername='" + restUsername
+ + '\'' + '}';
+ }
+
+ }
+
+ }
+
+ /**
+ * SSL/TLS configuration for secure connections
+ */
+ public static class SslConfig {
+
+ private String truststorePath;
+
+ private String truststorePassword;
+
+ private String truststoreType = "JKS";
+
+ public String getTruststorePath() {
+ return truststorePath;
+ }
+
+ public void setTruststorePath(String truststorePath) {
+ this.truststorePath = truststorePath;
+ }
+
+ public String getTruststorePassword() {
+ return truststorePassword;
+ }
+
+ public void setTruststorePassword(String truststorePassword) {
+ this.truststorePassword = truststorePassword;
+ }
+
+ public String getTruststoreType() {
+ return truststoreType;
+ }
+
+ public void setTruststoreType(String truststoreType) {
+ this.truststoreType = truststoreType;
+ }
+
+ @Override
+ public String toString() {
+ return "SslConfig{" + "truststorePath='" + truststorePath + '\'' + ", truststorePassword='"
+ + Optional.ofNullable(truststorePassword).map(p -> "*".repeat(p.length())).orElse("") + '\''
+ + ", truststoreType='" + truststoreType + '\'' + '}';
+ }
+
+ }
+
}
diff --git a/src/main/java/redis/clients/jedis/test/metrics/MetricsReporter.java b/src/main/java/redis/clients/jedis/test/metrics/MetricsReporter.java
index c8370d8..e5d367b 100644
--- a/src/main/java/redis/clients/jedis/test/metrics/MetricsReporter.java
+++ b/src/main/java/redis/clients/jedis/test/metrics/MetricsReporter.java
@@ -1,5 +1,6 @@
package redis.clients.jedis.test.metrics;
+import redis.clients.jedis.test.config.TestRunProperties;
import redis.clients.jedis.test.workloads.BaseWorkload;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
@@ -11,10 +12,19 @@
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
-
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
+import java.util.Collection;
import java.util.Map;
+import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@@ -35,9 +45,17 @@ public class MetricsReporter {
private final SimpleMeterRegistry simpleMeterRegistry;
+ private final TestRunProperties testRunProperties;
+
@Value("${runner.test.workload.type}")
private String workloadType;
+ @Value("${runner.test.mode:unknown}")
+ private String testMode;
+
+ @Value("${logging.file.path:logs}")
+ private String logPath;
+
private final Map commandLatencyTimers = new ConcurrentHashMap<>();
private final Timer commandLatencyTotalTimer;
@@ -48,13 +66,17 @@ public class MetricsReporter {
private final Counter commandErrorTotalCounter;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
private volatile Instant testRunStart;
private volatile Instant testRunEnd;
- public MetricsReporter(MeterRegistry meterRegistry, SimpleMeterRegistry simpleMeterRegistry) {
+ public MetricsReporter(MeterRegistry meterRegistry, SimpleMeterRegistry simpleMeterRegistry,
+ TestRunProperties testRunProperties) {
this.meterRegistry = meterRegistry;
this.simpleMeterRegistry = simpleMeterRegistry;
+ this.testRunProperties = testRunProperties;
this.commandLatencyTotalTimer = createCommandLatencyTotalTimer();
this.commandErrorTotalCounter = createCommandErrorTotalCounter();
}
@@ -80,6 +102,7 @@ public void recordEndTime() {
Timer.builder(REDIS_TEST_DURATION).description("Measures the duration of the test run").register(meterRegistry)
.record(Duration.between(testRunStart, testRunEnd));
dumpFinalSummary();
+ dumpFinalResult();
}
public record CommandKey(String commandName, OperationStatus status) {
@@ -171,4 +194,167 @@ else if (p.percentile() == 0.99)
}
}
+ public void dumpFinalResult() {
+ try {
+ ObjectNode result = buildFinalResultJson();
+ String jsonResult = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(result);
+
+ // Log to console
+ log.info("=== FINAL TEST RESULTS ===");
+ log.info(jsonResult);
+ log.info("=== END FINAL TEST RESULTS ===");
+
+ // Write to file
+ writeResultsToFile(jsonResult);
+
+ } catch (Exception e) {
+ log.error("Error generating final result", e);
+ }
+ }
+
+ private void writeResultsToFile(String jsonResult) {
+ try {
+ // Create logs directory if it doesn't exist
+ Path logDir = Paths.get(logPath);
+ if (!Files.exists(logDir)) {
+ Files.createDirectories(logDir);
+ log.info("Created log directory: {}", logDir.toAbsolutePath());
+ }
+
+ // Write to test-run-summary.json
+ Path summaryFile = logDir.resolve("test-run-summary.json");
+ Files.write(summaryFile, jsonResult.getBytes());
+ log.info("Final test results written to: {}", summaryFile.toAbsolutePath());
+
+ } catch (IOException e) {
+ log.error("Failed to write final results to file", e);
+ }
+ }
+
+ private ObjectNode buildFinalResultJson() {
+ ObjectNode result = objectMapper.createObjectNode();
+
+ // Basic run information
+ result.put("app_name", testRunProperties.getAppName());
+ result.put("instance_id", testRunProperties.getInstanceId());
+ result.put("run_id", testRunProperties.getRunId());
+ result.put("version", getJedisVersion());
+ result.put("client_mode", testMode != null ? testMode : "unknown");
+
+ // Test duration
+ result.put("workload_name", workloadType != null ? workloadType : "unknown");
+
+ // Command counts and success rate
+ RedisOperationsStatsSummary redisOperationsStatsSummary = getRedisOperationsStatsSummary();
+ result.put("total_commands_count", redisOperationsStatsSummary.totalCommands);
+ result.put("successful_commands_count", redisOperationsStatsSummary.successfulCommands);
+ result.put("failed_commands_count", redisOperationsStatsSummary.failedCommands);
+ result.put("success_rate",
+ String.format("%.2f%%", redisOperationsStatsSummary.totalCommands > 0
+ ? (redisOperationsStatsSummary.successfulCommands * 100.0 / redisOperationsStatsSummary.totalCommands)
+ : 0.0));
+
+ // Run timestamps (as epoch seconds)
+ result.put("run_start", testRunStart != null ? testRunStart.getEpochSecond() : -1);
+ result.put("run_end", testRunEnd != null ? testRunEnd.getEpochSecond() : -1);
+
+ // Latency statistics
+ LatencyStats latencyStats = getLatencyStats();
+ result.put("min_latency_ms", latencyStats.min());
+ result.put("max_latency_ms", latencyStats.max());
+ result.put("median_latency_ms", latencyStats.median());
+ result.put("p95_latency_ms", latencyStats.p95());
+ result.put("p99_latency_ms", latencyStats.p99());
+
+ return result;
+ }
+
+ record RedisOperationsStatsSummary(long totalCommands, long successfulCommands, long failedCommands) {
+ }
+
+ private RedisOperationsStatsSummary getRedisOperationsStatsSummary() {
+ if (simpleMeterRegistry == null) {
+ return new RedisOperationsStatsSummary(0, 0, 0);
+ }
+
+ // Command counts and success rate
+ long totalCommands = 0;
+ long successfulCommands = 0;
+ long failedCommands = 0;
+ Collection operationDurationTimers = simpleMeterRegistry.find(REDIS_OPERATION_DURATION).timers();
+ for (Timer m : operationDurationTimers) {
+ String status = m.getId().getTag("status");
+ if (status == null) {
+ totalCommands += m.count();
+ } else {
+ switch (status) {
+ case "success":
+ totalCommands += m.count();
+ successfulCommands += m.count();
+ break;
+ case "error":
+ totalCommands += m.count();
+ failedCommands += m.count();
+ break;
+ default:
+ totalCommands += m.count();
+ break;
+ }
+ }
+ }
+
+ return new RedisOperationsStatsSummary(totalCommands, successfulCommands, failedCommands);
+ }
+
+ record LatencyStats(double min, double max, double median, double p95, double p99) {
+ }
+
+ private LatencyStats getLatencyStats() {
+ if (simpleMeterRegistry == null) {
+ return new LatencyStats(-1, -1, -1, -1, -1);
+ }
+
+ Timer durationTotalTimer = simpleMeterRegistry.find(REDIS_OPERATION_DURATION_TOTAL).timer();
+ if (durationTotalTimer == null) {
+ return new LatencyStats(-1, -1, -1, -1, -1);
+ }
+ HistogramSnapshot snapshot = durationTotalTimer.takeSnapshot();
+ ValueAtPercentile[] percentiles = snapshot.percentileValues();
+ double median = -1;
+ double p95 = -1;
+ double p99 = -1;
+ for (ValueAtPercentile p : percentiles) {
+ if (p.percentile() == 0.5) {
+ median = p.value(TimeUnit.MILLISECONDS);
+ } else if (p.percentile() == 0.95) {
+ p95 = p.value(TimeUnit.MILLISECONDS);
+ } else if (p.percentile() == 0.99) {
+ p99 = p.value(TimeUnit.MILLISECONDS);
+ }
+ }
+
+ return new LatencyStats(0, snapshot.max(TimeUnit.MILLISECONDS), median, p95, p99);
+ }
+
+ String getJedisVersion() {
+ // Read version from application's jedis-version.properties
+ // This file is filtered by Maven during build to inject the jedis.version property from pom.xml
+ try {
+ Properties props = new Properties();
+ try (InputStream is = getClass().getClassLoader().getResourceAsStream("jedis-version.properties")) {
+ if (is != null) {
+ props.load(is);
+ String version = props.getProperty("jedis.version");
+ if (version != null && !version.isEmpty()) {
+ return version;
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.error("Could not read Jedis version from jedis-version.properties", e);
+ }
+
+ return "unknown";
+ }
+
}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 9b98296..4c864d4 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -3,10 +3,7 @@ spring.application.name=jedis-test-app
# Metrics Configuration defaults
management.metrics.enable.all=false
management.metrics.enable.redis=true
-management.metrics.enable.lettuce=true
-
-# InfluxDB Configuration for Micrometer (Spring Boot 3.x)
-management.influx.metrics.export.enabled=false
+management.metrics.enable.jedis=true
# OpenTelemetry Configuration for Micrometer (Spring Boot 3.x)
management.otlp.metrics.export.enabled=false
diff --git a/src/main/resources/jedis-version.properties b/src/main/resources/jedis-version.properties
new file mode 100644
index 0000000..171bd14
--- /dev/null
+++ b/src/main/resources/jedis-version.properties
@@ -0,0 +1,4 @@
+# This file is filtered by Maven during build to inject the jedis.version property
+# Spring Boot uses @ delimiters for resource filtering
+jedis.version=@jedis.version@
+
diff --git a/src/test/java/redis/clients/jedis/test/metrics/MetricsReporterTest.java b/src/test/java/redis/clients/jedis/test/metrics/MetricsReporterTest.java
new file mode 100644
index 0000000..c7a5721
--- /dev/null
+++ b/src/test/java/redis/clients/jedis/test/metrics/MetricsReporterTest.java
@@ -0,0 +1,129 @@
+package redis.clients.jedis.test.metrics;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+import redis.clients.jedis.test.config.TestRunProperties;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for MetricsReporter class, specifically focusing on the getJedisVersion() method.
+ */
+@ExtendWith(MockitoExtension.class)
+class MetricsReporterTest {
+
+ @Mock
+ private TestRunProperties testRunProperties;
+
+ private MetricsReporter metricsReporter;
+
+ @BeforeEach
+ void setUp() {
+ // Use real SimpleMeterRegistry instances instead of mocks
+ // to avoid NullPointerException when creating timers and counters
+ SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
+ SimpleMeterRegistry simpleMeterRegistry = new SimpleMeterRegistry();
+ metricsReporter = new MetricsReporter(meterRegistry, simpleMeterRegistry, testRunProperties);
+ }
+
+ @Test
+ void getJedisVersion_shouldReturnActualVersion() {
+ // When
+ String version = metricsReporter.getJedisVersion();
+
+ // Then
+ assertThat(version).as("Version should be retrieved from pom.properties").isNotNull().isNotEmpty()
+ .isNotEqualTo("unknown").matches("^\\d+\\.\\d+\\.\\d+.*$");
+ }
+
+ @Test
+ void getJedisVersion_shouldReturnValidVersionFormat() {
+ // When
+ String version = metricsReporter.getJedisVersion();
+
+ // Then
+ // Version should be in format like "7.0.0" or "7.0.0-SNAPSHOT"
+ assertThat(version).matches("^\\d+\\.\\d+\\.\\d+.*$|unknown");
+ }
+
+ @Test
+ void getJedisVersion_shouldReturnConsistentValue() {
+ // When
+ String version1 = metricsReporter.getJedisVersion();
+ String version2 = metricsReporter.getJedisVersion();
+
+ // Then
+ assertThat(version1).isEqualTo(version2);
+ }
+
+ @Test
+ void getJedisVersion_shouldNotReturnNull() {
+ // When
+ String version = metricsReporter.getJedisVersion();
+
+ // Then
+ assertThat(version).isNotNull();
+ }
+
+ @Test
+ void getJedisVersion_shouldNotReturnEmptyString() {
+ // When
+ String version = metricsReporter.getJedisVersion();
+
+ // Then
+ assertThat(version).isNotEmpty();
+ }
+
+ @Test
+ void buildFinalResultJson_shouldIncludeClientMode() throws Exception {
+ // Given
+ when(testRunProperties.getAppName()).thenReturn("test-app");
+ when(testRunProperties.getInstanceId()).thenReturn("test-instance");
+ when(testRunProperties.getRunId()).thenReturn("test-run");
+ ReflectionTestUtils.setField(metricsReporter, "testMode", "standalone");
+ ReflectionTestUtils.setField(metricsReporter, "workloadType", "redis_commands");
+
+ // When
+ Object resultNode = ReflectionTestUtils.invokeMethod(metricsReporter, "buildFinalResultJson");
+
+ // Then
+ assertThat(resultNode).isNotNull();
+ ObjectMapper mapper = new ObjectMapper();
+ String jsonString = mapper.writeValueAsString(resultNode);
+ JsonNode jsonNode = mapper.readTree(jsonString);
+
+ assertThat(jsonNode.has("client_mode")).isTrue();
+ assertThat(jsonNode.get("client_mode").asText()).isEqualTo("standalone");
+ }
+
+ @Test
+ void buildFinalResultJson_shouldDefaultToUnknownWhenClientModeIsNull() throws Exception {
+ // Given
+ when(testRunProperties.getAppName()).thenReturn("test-app");
+ when(testRunProperties.getInstanceId()).thenReturn("test-instance");
+ when(testRunProperties.getRunId()).thenReturn("test-run");
+ ReflectionTestUtils.setField(metricsReporter, "testMode", null);
+ ReflectionTestUtils.setField(metricsReporter, "workloadType", "redis_commands");
+
+ // When
+ Object resultNode = ReflectionTestUtils.invokeMethod(metricsReporter, "buildFinalResultJson");
+
+ // Then
+ assertThat(resultNode).isNotNull();
+ ObjectMapper mapper = new ObjectMapper();
+ String jsonString = mapper.writeValueAsString(resultNode);
+ JsonNode jsonNode = mapper.readTree(jsonString);
+
+ assertThat(jsonNode.has("client_mode")).isTrue();
+ assertThat(jsonNode.get("client_mode").asText()).isEqualTo("unknown");
+ }
+
+}