From 26591e14dc236dac884998eb789df6cea2b9c521 Mon Sep 17 00:00:00 2001 From: "vyatkin.v" Date: Tue, 31 Mar 2026 11:46:01 +0700 Subject: [PATCH 1/2] SOLR-18130: add universal connection string support for CloudSolrClient.Builder SOLR-18130: add universal connection string support for CloudSolrClient.Builder --- .../impl/CloudClientConnectionString.java | 85 ++++++++ .../client/solrj/impl/CloudSolrClient.java | 38 ++++ .../impl/CloudClientConnectionStringTest.java | 185 ++++++++++++++++++ .../solrj/impl/CloudHttp2SolrClientTest.java | 39 +++- 4 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudClientConnectionString.java create mode 100644 solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudClientConnectionStringTest.java diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudClientConnectionString.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudClientConnectionString.java new file mode 100644 index 000000000000..e393967200ed --- /dev/null +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudClientConnectionString.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.client.solrj.impl; + +import java.net.URI; +import java.util.List; +import org.apache.solr.common.util.StrUtils; + +/** Universal connection string parser logic. */ +public record CloudClientConnectionString(boolean isZk, List quorumItems, String zkChroot) { + + public CloudClientConnectionString { + if (quorumItems == null || quorumItems.isEmpty()) { + throw new IllegalArgumentException("No valid hosts/urls found"); + } + } + + public static boolean isValidHttpUrl(String s) { + if (s == null || s.isBlank()) return false; + + try { + URI uri = new URI(s); + + return ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) + && uri.getHost() != null; + + } catch (Exception e) { + return false; + } + } + + public static CloudClientConnectionString parse(String connectionString) { + if (connectionString == null || connectionString.trim().isEmpty()) { + throw new IllegalArgumentException("Connection string must not be null or empty"); + } + connectionString = connectionString.trim(); + if (connectionString.contains("://")) { + return parseHttpQuorum(connectionString); + } + return parseZkQuorum(connectionString); + } + + private static CloudClientConnectionString parseZkQuorum(String connectionString) { + String zkChroot = null; + String zkHosts = connectionString; + int slashIndex = connectionString.indexOf('/'); + if (slashIndex != -1) { + zkHosts = connectionString.substring(0, slashIndex); + zkChroot = connectionString.substring(slashIndex); + } + List quorumItems = StrUtils.split(zkHosts, ',').stream().map(String::trim).toList(); + for (String host : quorumItems) { + if (host == null || host.isBlank()) { + throw new IllegalArgumentException("Empty host in Zookeeper connection string"); + } + } + return new CloudClientConnectionString(true, quorumItems, zkChroot); + } + + private static CloudClientConnectionString parseHttpQuorum(String connectionString) { + List httpUrls = + StrUtils.split(connectionString, ',').stream().map(String::trim).toList(); + for (String part : httpUrls) { + if (!isValidHttpUrl(part)) { + throw new IllegalArgumentException("Malformed HTTP(S) URL: " + part); + } + } + return new CloudClientConnectionString(false, httpUrls, null); + } +} diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java index 4f71b0cba79b..35790013032e 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java @@ -241,6 +241,44 @@ public Builder(ClusterStateProvider stateProvider) { this.stateProvider = stateProvider; } + /** + * Creates a client builder based on a connection string of 2 possible formats: + * + *
    + *
  • ZooKeeper connection string (optionally with chroot), e.g. {@code + * zk1:2181,zk2:2181,zk3:2181/solr} + *
  • Comma-separated list of Solr node base URLs (HTTP or HTTPS), e.g. {@code + * http://solr1:8983/solr,http://solr2:8983/solr} + *
+ * + *

Usage examples: + * + *

{@code
+     * // ZooKeeper with chroot
+     * new CloudSolrClient.Builder("zk1:2181,zk2:2181,zk3:2181/solr");
+     *
+     * // ZooKeeper without chroot
+     * new CloudSolrClient.Builder("zk1:2181,zk2:2181,zk3:2181");
+     *
+     * // Direct HTTPS connections
+     * new CloudSolrClient.Builder("https://solr1:8983/solr,https://solr2:8983/solr");
+     * }
+ * + * @param connectionString a string specifying either ZooKeeper connection string or HTTP(S) + * Solr URLs + * @throws IllegalArgumentException if string is null, empty, or malformed + * @since 11.0.0 + */ + public Builder(String connectionString) { + CloudClientConnectionString connStr = CloudClientConnectionString.parse(connectionString); + if (connStr.isZk()) { + this.zkHosts = connStr.quorumItems(); + this.zkChroot = connStr.zkChroot(); + } else { + this.solrUrls = connStr.quorumItems(); + } + } + /** Whether to use the default ZK ACLs when building a ZK Client. */ public Builder canUseZkACLs(boolean canUseZkACLs) { this.canUseZkACLs = canUseZkACLs; diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudClientConnectionStringTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudClientConnectionStringTest.java new file mode 100644 index 000000000000..e5781eb4cafa --- /dev/null +++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudClientConnectionStringTest.java @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.solrj.impl; + +import java.util.Collection; +import java.util.List; +import java.util.Set; +import org.apache.solr.SolrTestCase; +import org.junit.Assert; +import org.junit.Test; + +public class CloudClientConnectionStringTest extends SolrTestCase { + + private static boolean collectionEqual(Collection coll, Collection coll2) { + return coll.size() == coll2.size() + && Set.copyOf(coll).containsAll(coll2) + && Set.copyOf(coll2).containsAll(coll); + } + + @Test + public void testBuildQuorumForZk() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse("zookeeper1:2181,zookeeper2:2181,zookeeper3:2181/solr"); + Assert.assertTrue(parsed.isZk()); + List expectedQuorum = List.of("zookeeper1:2181", "zookeeper2:2181", "zookeeper3:2181"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertEquals("/solr", parsed.zkChroot()); + } + + @Test + public void testBuildQuorumForZkIpV4() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse( + "192.194.10.10:2181,192.194.10.11:2181,192.194.10.12:2181/solr"); + Assert.assertTrue(parsed.isZk()); + List expectedQuorum = + List.of("192.194.10.10:2181", "192.194.10.11:2181", "192.194.10.12:2181"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertEquals("/solr", parsed.zkChroot()); + } + + @Test + public void testBuildQuorumForZkIpV6() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse( + "[2001:db8:85a3::8a2e:370:7334]:2181," + + "[2001:db8:85a3::8a2e:370:7335]:2181," + + "[2001:db8:85a3::8a2e:370:7336]:2181/solr"); + Assert.assertTrue(parsed.isZk()); + List expectedQuorum = + List.of( + "[2001:db8:85a3::8a2e:370:7334]:2181", + "[2001:db8:85a3::8a2e:370:7335]:2181", + "[2001:db8:85a3::8a2e:370:7336]:2181"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertEquals("/solr", parsed.zkChroot()); + } + + @Test + public void testBuildQuorumForZkNoChroot() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse("zookeeper1:2181,zookeeper2:2181,zookeeper3:2181"); + Assert.assertTrue(parsed.isZk()); + List expectedQuorum = List.of("zookeeper1:2181", "zookeeper2:2181", "zookeeper3:2181"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertNull(parsed.zkChroot()); + } + + @Test + public void testBuildQuorumForZkWithSpaces() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse( + " zookeeper1:2181 , zookeeper2:2181 , zookeeper3:2181 /solr "); + Assert.assertTrue(parsed.isZk()); + List expectedQuorum = List.of("zookeeper1:2181", "zookeeper2:2181", "zookeeper3:2181"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertEquals("/solr", parsed.zkChroot()); + } + + @Test + public void testBuildQuorumForHttp() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse( + "http://solr1:8983/solr,http://solr2:8983/solr,http://solr3:8983/solr"); + Assert.assertFalse(parsed.isZk()); + List expectedQuorum = + List.of("http://solr1:8983/solr", "http://solr2:8983/solr", "http://solr3:8983/solr"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertNull(parsed.zkChroot()); + } + + @Test + public void testBuildQuorumForHttps() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse( + "https://solr1:8983/solr,https://solr2:8983/solr,https://solr3:8983/solr"); + Assert.assertFalse(parsed.isZk()); + List expectedQuorum = + List.of("https://solr1:8983/solr", "https://solr2:8983/solr", "https://solr3:8983/solr"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertNull(parsed.zkChroot()); + } + + @Test + public void testBuildQuorumForIpV4() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse( + "http://192.194.10.10:8983/solr," + + "http://192.194.10.11:8983/solr," + + "http://192.194.10.12:8983/solr"); + Assert.assertFalse(parsed.isZk()); + List expectedQuorum = + List.of( + "http://192.194.10.10:8983/solr", + "http://192.194.10.11:8983/solr", + "http://192.194.10.12:8983/solr"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertNull(parsed.zkChroot()); + } + + @Test + public void testBuildQuorumForIpV6() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse( + "http://[2001:db8:85a3::8a2e:370:7334]:8983/solr," + + "http://[2001:db8:85a3::8a2e:370:7334]:8983/solr," + + "http://[2001:db8:85a3::8a2e:370:7334]:8983/solr"); + Assert.assertFalse(parsed.isZk()); + List expectedQuorum = + List.of( + "http://[2001:db8:85a3::8a2e:370:7334]:8983/solr", + "http://[2001:db8:85a3::8a2e:370:7334]:8983/solr", + "http://[2001:db8:85a3::8a2e:370:7334]:8983/solr"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertNull(parsed.zkChroot()); + } + + @Test + public void testBuildQuorumForHttpWithWhiteSpaces() { + CloudClientConnectionString parsed = + CloudClientConnectionString.parse( + " http://solr1:8983/solr , http://solr2:8983/solr , http://solr3:8983/solr "); + Assert.assertFalse(parsed.isZk()); + List expectedQuorum = + List.of("http://solr1:8983/solr", "http://solr2:8983/solr", "http://solr3:8983/solr"); + Assert.assertTrue(collectionEqual(expectedQuorum, parsed.quorumItems())); + Assert.assertNull(parsed.zkChroot()); + } + + @Test + public void testEmptyOrNullConnStringException() { + Assert.assertThrows( + IllegalArgumentException.class, () -> CloudClientConnectionString.parse(" ")); + } + + @Test + public void testMixedItemsException() { + Assert.assertThrows( + IllegalArgumentException.class, + () -> CloudClientConnectionString.parse("http://solr1:8983,solr2:8983,solr3:8983")); + } + + @Test + public void testMalformedUrlException() { + Assert.assertThrows( + IllegalArgumentException.class, + () -> + CloudClientConnectionString.parse( + "http://solr)1:8983,http://solr2:8983,http://solr3:8983")); + } +} diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudHttp2SolrClientTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudHttp2SolrClientTest.java index e80c60242a7f..ea596e7a11af 100644 --- a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudHttp2SolrClientTest.java +++ b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/CloudHttp2SolrClientTest.java @@ -105,6 +105,8 @@ public class CloudHttp2SolrClientTest extends SolrCloudTestCase { private static CloudHttp2SolrClient httpJettyBasedCloudSolrClient = null; private static CloudHttp2SolrClient httpJdkBasedCloudSolrClient = null; private static CloudHttp2SolrClient zkBasedCloudSolrClient = null; + private static CloudHttp2SolrClient connectionStringZkBasedCloudSolrClient = null; + private static CloudHttp2SolrClient connectionStringHttpBasedCloudSolrClient = null; @BeforeClass public static void setupCluster() throws Exception { @@ -161,6 +163,22 @@ public static void setupCluster() throws Exception { assertTrue(zkBasedCloudSolrClient.getHttpClient() instanceof HttpJettySolrClient); assertTrue( zkBasedCloudSolrClient.getClusterStateProvider() instanceof ZkClientClusterStateProvider); + + String zkConnString = cluster.getZkServer().getZkAddress(); + connectionStringZkBasedCloudSolrClient = new CloudSolrClient.Builder(zkConnString).build(); + assertTrue( + connectionStringZkBasedCloudSolrClient.getHttpClient() instanceof HttpJettySolrClient); + assertTrue( + connectionStringZkBasedCloudSolrClient.getClusterStateProvider() + instanceof ZkClientClusterStateProvider); + + String httpConnString = String.join(",", solrUrls); + connectionStringHttpBasedCloudSolrClient = new CloudSolrClient.Builder(httpConnString).build(); + assertTrue( + connectionStringHttpBasedCloudSolrClient.getHttpClient() instanceof HttpJettySolrClient); + assertTrue( + connectionStringHttpBasedCloudSolrClient.getClusterStateProvider() + instanceof HttpClusterStateProvider); } @AfterClass @@ -168,23 +186,28 @@ public static void tearDownAfterClass() throws Exception { IOUtils.closeQuietly(httpJettyBasedCloudSolrClient); IOUtils.closeQuietly(httpJdkBasedCloudSolrClient); IOUtils.closeQuietly(zkBasedCloudSolrClient); + IOUtils.closeQuietly(connectionStringZkBasedCloudSolrClient); + IOUtils.closeQuietly(connectionStringHttpBasedCloudSolrClient); shutdownCluster(); httpJettyBasedCloudSolrClient = null; httpJdkBasedCloudSolrClient = null; zkBasedCloudSolrClient = null; + connectionStringZkBasedCloudSolrClient = null; + connectionStringHttpBasedCloudSolrClient = null; } /** Randomly return the cluster's ZK based CSC, or HttpClusterProvider based CSC. */ private CloudSolrClient getRandomClient() { - int randInt = random().nextInt(3); - if (randInt == 0) { - return zkBasedCloudSolrClient; - } - if (randInt == 1) { - return httpJettyBasedCloudSolrClient; - } - return httpJdkBasedCloudSolrClient; + CloudSolrClient[] clients = { + zkBasedCloudSolrClient, + httpJettyBasedCloudSolrClient, + httpJdkBasedCloudSolrClient, + connectionStringZkBasedCloudSolrClient, + connectionStringHttpBasedCloudSolrClient + }; + + return clients[random().nextInt(clients.length)]; } @Test From 1ed3f011c6fe53a09bcaa0ea662df9beca56489b Mon Sep 17 00:00:00 2001 From: "vyatkin.v" Date: Thu, 2 Apr 2026 01:34:51 +0700 Subject: [PATCH 2/2] SOLR-18130: Replace ZooKeeper solr client builder usage with connection string in SolrClientCache --- .../solr/client/solrj/io/SolrClientCache.java | 30 +++++++++++-------- .../client/solrj/io/SolrClientCacheTest.java | 24 +++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java index 3f5013fe664c..0f6667dbced8 100644 --- a/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java +++ b/solr/solrj-streaming/src/java/org/apache/solr/client/solrj/io/SolrClientCache.java @@ -18,7 +18,6 @@ import java.io.Closeable; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -26,6 +25,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.impl.CloudClientConnectionString; import org.apache.solr.client.solrj.impl.CloudSolrClient; import org.apache.solr.client.solrj.impl.HttpSolrClientBase; import org.apache.solr.client.solrj.impl.HttpSolrClientBuilderBase; @@ -77,29 +77,33 @@ public void setDefaultZKHost(String zkHost) { } } - public synchronized CloudSolrClient getCloudSolrClient(String zkHost) { + public synchronized CloudSolrClient getCloudSolrClient(String connectionString) { ensureOpen(); - Objects.requireNonNull(zkHost, "ZooKeeper host cannot be null!"); - if (solrClients.containsKey(zkHost)) { - return (CloudSolrClient) solrClients.get(zkHost); + Objects.requireNonNull(connectionString, "Connection string cannot be null!"); + if (solrClients.containsKey(connectionString)) { + return (CloudSolrClient) solrClients.get(connectionString); } // Can only use ZK ACLs if there is a default ZK Host, and the given ZK host contains that // default. // Basically the ZK ACLs are assumed to be only used for the default ZK host, // thus we should only provide the ACLs to that Zookeeper instance. - String zkHostNoChroot = zkHost.split("/")[0]; - boolean canUseACLs = - Optional.ofNullable(defaultZkHost.get()).map(zkHostNoChroot::equals).orElse(false); + boolean canUseACLs = false; + CloudClientConnectionString cloudClientConnectionString = + CloudClientConnectionString.parse(connectionString); + if (cloudClientConnectionString.isZk()) { + String zkHostNoChroot = connectionString.split("/")[0]; + canUseACLs = + Optional.ofNullable(defaultZkHost.get()).map(zkHostNoChroot::equals).orElse(false); + } - final var client = newCloudSolrClient(zkHost, httpSolrClient, canUseACLs); - solrClients.put(zkHost, client); + final var client = newCloudSolrClient(connectionString, httpSolrClient, canUseACLs); + solrClients.put(connectionString, client); return client; } protected CloudSolrClient newCloudSolrClient( - String zkHost, HttpSolrClientBase httpSolrClient, boolean canUseACLs) { - final List hosts = List.of(zkHost); - var builder = new CloudSolrClient.Builder(hosts, Optional.empty()); + String connectionString, HttpSolrClientBase httpSolrClient, boolean canUseACLs) { + var builder = new CloudSolrClient.Builder(connectionString); builder.canUseZkACLs(canUseACLs); // using internal builder to ensure the internal client gets closed builder = builder.withHttpClientBuilder(newHttpSolrClientBuilder(null, httpSolrClient)); diff --git a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/SolrClientCacheTest.java b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/SolrClientCacheTest.java index 91b5364ed7f2..f38bc5776515 100644 --- a/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/SolrClientCacheTest.java +++ b/solr/solrj-streaming/src/test/org/apache/solr/client/solrj/io/SolrClientCacheTest.java @@ -17,13 +17,16 @@ package org.apache.solr.client.solrj.io; import java.util.Map; +import org.apache.solr.client.solrj.impl.CloudSolrClient; import org.apache.solr.cloud.SolrCloudTestCase; import org.apache.solr.common.SolrException; +import org.apache.solr.common.cloud.ClusterState; import org.apache.solr.common.cloud.DigestZkACLProvider; import org.apache.solr.common.cloud.DigestZkCredentialsProvider; import org.apache.solr.common.cloud.SolrZkClient; import org.apache.solr.common.cloud.VMParamsZkCredentialsInjector; import org.junit.AfterClass; +import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; @@ -66,6 +69,27 @@ public void testZkACLsNotUsedWithDifferentZkHost() { } } + @Test + public void testGetClientWithHttp() { + String solrUrl = cluster.getJettySolrRunner(0).getBaseUrl().toString(); + try (SolrClientCache cache = new SolrClientCache()) { + CloudSolrClient cloudSolrClient = cache.getCloudSolrClient(solrUrl); + ClusterState clusterState = cloudSolrClient.getClusterStateProvider().getClusterState(); + Assert.assertEquals(1, clusterState.getLiveNodes().size()); + } + } + + @Test + public void testGetClientWithZookeeper() { + String zkConnectionString = zkClient().getZkServerAddress(); + try (SolrClientCache cache = new SolrClientCache()) { + cache.setDefaultZKHost(zkClient().getZkServerAddress()); + CloudSolrClient cloudSolrClient = cache.getCloudSolrClient(zkConnectionString); + ClusterState clusterState = cloudSolrClient.getClusterStateProvider().getClusterState(); + Assert.assertEquals(1, clusterState.getLiveNodes().size()); + } + } + @Test public void testZkACLsUsedWithDifferentChroot() { try (SolrClientCache cache = new SolrClientCache()) {