Repository: EventStore/EventStoreDB-Client-Java (java)

Issue:
Should DNS discovery use all IPs in a multi-address DNS name as cluster seeds?
My experience is that DNS discovery fails when 1 node out of 3 is down, and discovery spends all of `maxDiscoverAttempts` trying to get gossip from the node that is down, instead of also considering the other 2 nodes' IPs registered with a multi-address DNS name.

I was able to implement the behaviour I expect like this

```java
# ClusterDiscovery.java:

    void discover(ConnectionState state) {
-       List<InetSocketAddress> candidates = new ArrayList<>(this.seeds);
+       List<InetSocketAddress> candidates = new ArrayList<>();
+
+       if (state.getSettings().isDnsDiscover()) {
+           try {
+               InetSocketAddress dnsSeed = this.seeds.get(0);
+
+               // Resolve cluster DNS name
+               candidates = Arrays.stream(InetAddress.getAllByName(dnsSeed.getHostName()))
+                   .map(addr -> new InetSocketAddress(addr, dnsSeed.getPort()))
+                   .collect(Collectors.toList());
+                  
+           } catch (UnknownHostException e) {
+               throw new UncheckedIOException(e);
+           }
+       } else {
+           candidates = new ArrayList<>(this.seeds);
+       }

        if (candidates.size() > 1) {
```
I'm not sure, however, if you'd prefer to delegate somehow this behaviour to the gRPC client, since it's the gRPC client that currently does the lookup.

Pull request description:
Add support for direct DNS lookups behind a feature flag.
Added: Add support for direct DNS lookups behind a feature flag.

Fixes #273 

### description

With this feature, the client will directly perform DNS lookups when probing for candidates during the connection process to a cluster of nodes. This mirrors the behavior from when the TCP API was still supported.

You can enable that feature using a connection string or a the connection builder.

### connection string

```
esdb+discover://my.esdbcluster.com:1234?feature=dns-lookup
```

### connection builder

```java
     EventStoreDBClientSettings.builder()
        .dnsDiscover(true)
        .addHost("my.esdbcluster.com", 1234)
        .feature("dns-lookup")
        .buildConnectionSettings()
```


Patch:
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/ClientFeatureFlags.java b/db-client-java/src/main/java/com/eventstore/dbclient/ClientFeatureFlags.java
new file mode 100644
index 0000000..619353e
--- /dev/null
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/ClientFeatureFlags.java
@@ -0,0 +1,10 @@
+package com.eventstore.dbclient;
+
+public final class ClientFeatureFlags {
+    /**
+     * Enables direct DNS name resolution, retrieving all IP addresses associated with a given hostname. This
+     * functionality was initially implemented to support the now-deprecated TCP API. It is particularly useful in
+     * scenarios involving clusters, where node discovery is enabled.
+     */
+    public static final String DNS_LOOKUP = "dns-lookup";
+}
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/ClusterDiscovery.java b/db-client-java/src/main/java/com/eventstore/dbclient/ClusterDiscovery.java
index 9abd66e..7170136 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/ClusterDiscovery.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/ClusterDiscovery.java
@@ -1,5 +1,9 @@
 package com.eventstore.dbclient;
 
+import com.eventstore.dbclient.resolution.DeferredNodeResolution;
+import com.eventstore.dbclient.resolution.DeprecatedNodeResolution;
+import com.eventstore.dbclient.resolution.FixedSeedsNodeResolution;
+import com.eventstore.dbclient.resolution.NodeResolution;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -13,15 +17,18 @@ import java.util.concurrent.TimeoutException;
 class ClusterDiscovery implements Discovery {
     private static final Logger logger = LoggerFactory.getLogger(ClusterDiscovery.class);
     private final NodeSelector nodeSelector;
-    private final List<InetSocketAddress> seeds;
+    private final NodeResolution resolution;
 
     ClusterDiscovery(EventStoreDBClientSettings settings) {
         this.nodeSelector = new NodeSelector(settings.getNodePreference());
 
         if (settings.isDnsDiscover()) {
-            this.seeds = Collections.singletonList(settings.getHosts()[0]);
+            if (settings.getFeatures().contains(ClientFeatureFlags.DNS_LOOKUP))
+                this.resolution = new DeprecatedNodeResolution(settings.getHosts()[0]);
+            else
+                this.resolution = new DeferredNodeResolution(settings.getHosts()[0]);
         } else {
-            this.seeds = Arrays.asList(settings.getHosts());
+            this.resolution = new FixedSeedsNodeResolution(settings.getHosts());
         }
     }
 
@@ -43,7 +50,7 @@ class ClusterDiscovery implements Discovery {
     }
 
     void discover(ConnectionState state) {
-        List<InetSocketAddress> candidates = new ArrayList<>(this.seeds);
+        List<InetSocketAddress> candidates = resolution.resolve();
 
         if (candidates.size() > 1) {
             Collections.shuffle(candidates);
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionSettingsBuilder.java b/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionSettingsBuilder.java
index 49571de..8ea7c00 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionSettingsBuilder.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/ConnectionSettingsBuilder.java
@@ -31,6 +31,7 @@ public class ConnectionSettingsBuilder {
     private Long _defaultDeadline = null;
     private List<ClientInterceptor> _interceptors = new ArrayList<>();
     private String _tlsCaFile = null;
+    private Set<String> _features = new HashSet<>();
 
     ConnectionSettingsBuilder() {}
 
@@ -54,7 +55,8 @@ public class ConnectionSettingsBuilder {
                 _keepAliveInterval,
                 _defaultDeadline,
                 _interceptors,
-                _tlsCaFile);
+                _tlsCaFile,
+                _features);
     }
 
     /**
@@ -219,6 +221,22 @@ public class ConnectionSettingsBuilder {
         return this;
     }
 
+    /**
+     * Add feature flags.
+     */
+    public ConnectionSettingsBuilder features(String... features) {
+        this._features.addAll(Arrays.asList(features));
+        return this;
+    }
+
+    /**
+     * Add feature flag.
+     */
+    public ConnectionSettingsBuilder feature(String feature) {
+        this._features.add(feature);
+        return this;
+    }
+
     void parseGossipSeed(String host) {
         String[] hostParts = host.split(":");
 
@@ -436,6 +454,10 @@ public class ConnectionSettingsBuilder {
                     userKeyFile = entry[1];
                     break;
 
+                case "feature":
+                    builder._features.add(value);
+                    break;
+
                 default:
                     logger.warn(String.format("Unknown setting '%s' is ignored", entry[0]));
                     break;
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientSettings.java b/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientSettings.java
index 4e198b2..8d044e6 100644
--- a/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientSettings.java
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/EventStoreDBClientSettings.java
@@ -4,6 +4,7 @@ import io.grpc.ClientInterceptor;
 
 import java.net.InetSocketAddress;
 import java.util.List;
+import java.util.Set;
 
 /**
  * Gathers all the settings related to a gRPC client with an EventStoreDB database.
@@ -39,6 +40,7 @@ public class EventStoreDBClientSettings {
     private final Long defaultDeadline;
     private final List<ClientInterceptor> interceptors;
     private final String tlsCaFile;
+    private final Set<String> features;
 
     /**
      * If the dns discovery is enabled.
@@ -160,6 +162,11 @@ public class EventStoreDBClientSettings {
         return tlsCaFile;
     }
 
+    /**
+     * Feature flags
+     */
+    public Set<String> getFeatures() { return features; }
+
     EventStoreDBClientSettings(
             boolean dnsDiscover,
             int maxDiscoverAttempts,
@@ -175,7 +182,8 @@ public class EventStoreDBClientSettings {
             long keepAliveInterval,
             Long defaultDeadline,
             List<ClientInterceptor> interceptors,
-            String tlsCaFile
+            String tlsCaFile,
+            Set<String> features
     ) {
         this.dnsDiscover = dnsDiscover;
         this.maxDiscoverAttempts = maxDiscoverAttempts;
@@ -192,6 +200,7 @@ public class EventStoreDBClientSettings {
         this.defaultDeadline = defaultDeadline;
         this.interceptors = interceptors;
         this.tlsCaFile = tlsCaFile;
+        this.features = features;
     }
 
     /**
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/resolution/DeferredNodeResolution.java b/db-client-java/src/main/java/com/eventstore/dbclient/resolution/DeferredNodeResolution.java
new file mode 100644
index 0000000..56cb39f
--- /dev/null
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/resolution/DeferredNodeResolution.java
@@ -0,0 +1,18 @@
+package com.eventstore.dbclient.resolution;
+
+import java.net.InetSocketAddress;
+import java.util.Collections;
+import java.util.List;
+
+public class DeferredNodeResolution implements NodeResolution {
+    private final InetSocketAddress address;
+
+    public DeferredNodeResolution(InetSocketAddress address) {
+        this.address = address;
+    }
+
+    @Override
+    public List<InetSocketAddress> resolve() {
+        return Collections.singletonList(address);
+    }
+}
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/resolution/DeprecatedNodeResolution.java b/db-client-java/src/main/java/com/eventstore/dbclient/resolution/DeprecatedNodeResolution.java
new file mode 100644
index 0000000..17b861b
--- /dev/null
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/resolution/DeprecatedNodeResolution.java
@@ -0,0 +1,27 @@
+package com.eventstore.dbclient.resolution;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class DeprecatedNodeResolution implements NodeResolution {
+    private final InetSocketAddress address;
+
+    public DeprecatedNodeResolution(InetSocketAddress address) {
+        this.address = address;
+    }
+
+    @Override
+    public List<InetSocketAddress> resolve() {
+        try {
+            return Arrays.stream(InetAddress.getAllByName(address.getHostName()))
+                    .map(addr -> new InetSocketAddress(addr, address.getPort()))
+                    .collect(Collectors.toList());
+        } catch (UnknownHostException e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/resolution/FixedSeedsNodeResolution.java b/db-client-java/src/main/java/com/eventstore/dbclient/resolution/FixedSeedsNodeResolution.java
new file mode 100644
index 0000000..858d316
--- /dev/null
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/resolution/FixedSeedsNodeResolution.java
@@ -0,0 +1,18 @@
+package com.eventstore.dbclient.resolution;
+
+import java.net.InetSocketAddress;
+import java.util.Arrays;
+import java.util.List;
+
+public class FixedSeedsNodeResolution implements NodeResolution {
+    private final InetSocketAddress[] seeds;
+
+    public FixedSeedsNodeResolution(InetSocketAddress[] seeds) {
+        this.seeds = seeds;
+    }
+
+    @Override
+    public List<InetSocketAddress> resolve() {
+        return Arrays.asList(seeds);
+    }
+}
diff --git a/db-client-java/src/main/java/com/eventstore/dbclient/resolution/NodeResolution.java b/db-client-java/src/main/java/com/eventstore/dbclient/resolution/NodeResolution.java
new file mode 100644
index 0000000..6cd8742
--- /dev/null
+++ b/db-client-java/src/main/java/com/eventstore/dbclient/resolution/NodeResolution.java
@@ -0,0 +1,8 @@
+package com.eventstore.dbclient.resolution;
+
+import java.net.InetSocketAddress;
+import java.util.List;
+
+public interface NodeResolution {
+    List<InetSocketAddress> resolve();
+}


Test patch:
diff --git a/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseValidConnectionStringTests.java b/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseValidConnectionStringTests.java
index 1adffb5..9a5ed76 100644
--- a/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseValidConnectionStringTests.java
+++ b/db-client-java/src/test/java/com/eventstore/dbclient/misc/ParseValidConnectionStringTests.java
@@ -110,6 +110,14 @@ public class ParseValidConnectionStringTests {
                 Arguments.of(
                         "esdb://127.0.0.1:21573?userCertFile=/path/to/cert&userKeyFile=/path/to/key",
                         "{\"dnsDiscover\":false,\"maxDiscoverAttempts\":3,\"discoveryInterval\":500,\"gossipTimeout\":3000,\"nodePreference\":\"leader\",\"tls\":true,\"tlsVerifyCert\":true,\"throwOnAppendFailure\":true,\"hosts\":[{\"address\":\"127.0.0.1\",\"port\":21573}], \"defaultClientCertificate\": {\"clientCertFile\": \"/path/to/cert\", \"clientKeyFile\": \"/path/to/key\"}}"
+                ),
+                Arguments.of(
+                        "esdb://localhost?feature=foobar",
+                        "{\"dnsDiscover\":false,\"maxDiscoverAttempts\":3,\"discoveryInterval\":500,\"gossipTimeout\":3000,\"nodePreference\":\"leader\",\"tls\":true,\"tlsVerifyCert\":true,\"throwOnAppendFailure\":true,\"hosts\":[{\"address\":\"localhost\",\"port\":2113}], \"features\": \"foobar\"}"
+                ),
+                Arguments.of(
+                        "esdb://localhost?feature=foobar&feature=baz",
+                        "{\"dnsDiscover\":false,\"maxDiscoverAttempts\":3,\"discoveryInterval\":500,\"gossipTimeout\":3000,\"nodePreference\":\"leader\",\"tls\":true,\"tlsVerifyCert\":true,\"throwOnAppendFailure\":true,\"hosts\":[{\"address\":\"localhost\",\"port\":2113}], \"features\": [\"foobar\", \"baz\"]}"
                 )
         );
     }
@@ -215,6 +223,15 @@ public class ParseValidConnectionStringTests {
             builder.addHost(new InetSocketAddress(host.get("address").asText(), host.get("port").asInt()));
         });
 
+        if (tree.get("features") != null) {
+           JsonNode features = tree.get("features");
+
+           if (features.isArray())
+               features.elements().forEachRemaining(feature -> builder.feature(feature.asText()));
+           else
+               builder.feature(features.asText());
+        }
+
         return builder.buildConnectionSettings();
     }
 }