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"); + } + +}