ClusterReplicaExposureIT.java

package redis.clients.jedis.scenario;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.redis.test.fi.Scenario;
import com.redis.test.fi.ScenarioKey;
import com.redis.test.fi.SlotMigrateEffect;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.MaintenanceNotificationsConfig;
import redis.clients.jedis.Protocol;
import redis.clients.jedis.RedisClusterClient;
import redis.clients.jedis.resps.ClusterShardInfo;
import redis.clients.jedis.resps.ClusterShardNodeInfo;
import redis.clients.jedis.util.SafeEncoder;

/**
 * Whether the Redis Enterprise OSS Cluster API exposes a database's replicas to a cluster client.
 * Jedis learns the topology from {@code CLUSTER SLOTS}
 * ({@code JedisClusterInfoCache.discoverClusterSlots}): in every slot-range entry the first node is
 * the master and every further node a replica, which the client only routes to with
 * {@code readOnlyForRedisClusterReplicas()}. This test provisions a replicated OSS-cluster database
 * (the {@code failover} rows are the only replicated dbconfigs the FI offers), asks every node the
 * client found the same question, and asserts what the server exposes, cross-checked with
 * {@code CLUSTER SHARDS} (role per node), {@code CLUSTER NODES} (flags) and
 * {@code INFO replication} (the proxy's own view of its replicas).
 * <p>
 * Finding on 2026-10-20 (RE with Redis 8.6.2, OSS cluster API,
 * {@code proxy_policy=all-master-shards}, 6 shards replicated): one node entry per range on every
 * node and no replica entries; {@code CLUSTER SHARDS} lists only {@code role=master};
 * {@code CLUSTER NODES} lists only masters; {@code INFO replication} on each node reports
 * {@code role:master} with one connected replica at a placeholder address ({@code 0.0.0.0:0}). The
 * replicas exist but are not addressable through the cluster API, so a
 * {@code readOnlyForRedisClusterReplicas()} client has no replica pools and its replica reads fall
 * back to the master. The assertions pin that down: if RE starts exposing replicas,
 * {@link #replicasAreNotExposedByTheClusterApi} fails and names the entries it saw.
 */
@Tag("scenario")
public class ClusterReplicaExposureIT extends ClusterMaintNotificationsScenarioBase {

  private static final Logger log = LoggerFactory.getLogger(ClusterReplicaExposureIT.class);

  /** A CLUSTER SLOTS range is [start, end, master, replica...]. */
  private static final int MASTER_NODE_INDEX = 2;

  @Test
  @Timeout(300)
  void replicasAreNotExposedByTheClusterApi() {
    Scenario scenario = assumeOffered(
      ScenarioKey.of(SlotMigrateEffect.SLOT_SHUFFLE, "failover", "external/ip"));
    Map<String, Object> dbConfig = scenario.requirement().dbConfig();
    assertEquals(Boolean.TRUE, dbConfig.get("replication"),
      scenario + ": the row must be replicated for this test to mean anything");
    int shards = ((Number) dbConfig.get("shards_count")).intValue();

    deployDatabase(scenario);
    openVerificationClient(database);
    Set<String> clientNodes = nodeKeys(verification);
    log.info("{}: {} shards, replication on; the client built pools for {}", scenario, shards,
      clientNodes);

    Observations obs = new Observations();
    Set<String> mastersSeen = new LinkedHashSet<>();
    for (String nodeKey : clientNodes) {
      HostAndPort node = HostAndPort.from(nodeKey);
      try (Jedis jedis = new Jedis(node, clientConfig(database).build())) {
        // the exact command the client's topology discovery sends
        SlotsView slots = SlotsView.parse(jedis.clusterSlots());
        mastersSeen.addAll(slots.masters);
        log.info(
          "{}: CLUSTER SLOTS via {}: {} ranges covering {} slots, masters {}, replica"
              + " entries {}",
          scenario, node, slots.ranges, slots.slotsCovered, slots.masters, slots.replicas);
        obs.check("CLUSTER SLOTS via " + node + " covers every slot",
          () -> assertEquals(Protocol.CLUSTER_HASHSLOTS, slots.slotsCovered, "slots covered"));
        obs.check("CLUSTER SLOTS via " + node + " exposes no replica",
          () -> assertTrue(slots.replicas.isEmpty(), "replica entries exposed: " + slots.replicas));

        List<String> nonMasters = new ArrayList<>();
        List<ClusterShardInfo> shardInfos = jedis.clusterShards();
        for (ClusterShardInfo shard : shardInfos) {
          for (ClusterShardNodeInfo n : shard.getNodes()) {
            if (!"master".equals(n.getRole())) {
              nonMasters.add(n.getRole() + "@" + n.getEndpoint() + ":" + n.getPort());
            }
          }
        }
        log.info("{}: CLUSTER SHARDS via {}: {} shards, non-master nodes {}", scenario, node,
          shardInfos.size(), nonMasters);
        obs.check("CLUSTER SHARDS via " + node + " lists only masters",
          () -> assertTrue(nonMasters.isEmpty(), "non-master nodes: " + nonMasters));

        List<String> replicaLines = new ArrayList<>();
        for (String line : jedis.clusterNodes().split("\n")) {
          String[] fields = line.trim().split(" ");
          if (fields.length > 2 && (fields[2].contains("slave") || fields[2].contains("replica"))) {
            replicaLines.add(line.trim());
          }
        }
        obs.check("CLUSTER NODES via " + node + " lists only masters",
          () -> assertTrue(replicaLines.isEmpty(), "replica lines: " + replicaLines));

        String replication = jedis.info("replication");
        String role = infoField(replication, "role");
        int connectedReplicas = Integer.parseInt(infoField(replication, "connected_slaves"));
        log.info("{}: INFO replication via {}: role={}, connected_slaves={}", scenario, node, role,
          connectedReplicas);
        obs.check("INFO replication via " + node + " shows a master with replicas behind it",
          () -> {
            assertEquals("master", role, "role");
            assertTrue(connectedReplicas > 0, "connected_slaves");
          });
      }
    }
    obs.check("the client's node set is exactly the masters CLUSTER SLOTS names",
      () -> assertEquals(mastersSeen, clientNodes));

    // the client built to read from replicas: no replica pools, so no node beyond the masters
    RedisClusterClient readFromReplicas = openClient(database,
      MaintenanceNotificationsConfig.DISABLED, config -> config.readOnlyForRedisClusterReplicas(),
      builder -> {
      });
    obs.check("a readOnlyForRedisClusterReplicas client has no pool beyond the masters",
      () -> assertEquals(clientNodes, nodeKeys(readFromReplicas)));
    obs.assertAllPassed();
  }

  private static String infoField(String info, String key) {
    for (String line : info.split("\r?\n")) {
      if (line.startsWith(key + ":")) {
        return line.substring(key.length() + 1).trim();
      }
    }
    throw new AssertionError("INFO field " + key + " missing in: " + info);
  }

  /** What a raw CLUSTER SLOTS reply says about masters and replicas. */
  private static final class SlotsView {
    int ranges;
    long slotsCovered;
    final Set<String> masters = new LinkedHashSet<>();
    /** {@code host:port [start-end]} of every node entry after the master. */
    final List<String> replicas = new ArrayList<>();

    @SuppressWarnings("unchecked")
    static SlotsView parse(List<Object> reply) {
      SlotsView view = new SlotsView();
      for (Object rangeObj : reply) {
        List<Object> range = (List<Object>) rangeObj;
        long start = (Long) range.get(0);
        long end = (Long) range.get(1);
        view.ranges++;
        view.slotsCovered += end - start + 1;
        for (int i = MASTER_NODE_INDEX; i < range.size(); i++) {
          List<Object> hostInfo = (List<Object>) range.get(i);
          if (hostInfo.isEmpty()) {
            continue;
          }
          String key = SafeEncoder.encode((byte[]) hostInfo.get(0)) + ":" + hostInfo.get(1);
          if (i == MASTER_NODE_INDEX) {
            view.masters.add(key);
          } else {
            view.replicas.add(key + " [" + start + "-" + end + "]");
          }
        }
      }
      return view;
    }
  }
}