Skip to content

Commit 52a9312

Browse files
nikagraclaude
andauthored
feat: fall back to contact points on control reconnect (DRIVER-201) (#1065)
The driver reconnects on addresses it resolved once and never re-reads DNS (#215). Default `fallback-to-original-contact-points` to true: once a reconnection round has exhausted the live nodes, the retained unresolved contact points are tried again, so their names are resolved afresh. Init and fallback build that plan through one path. isControlNode matches the pending node by reference and re-checks the identified node, because the endpoint equals() was a blocking lookup per event with unresolved placeholders in the plan. A new MockResolverIT case covers recovery to new addresses. Fixes #215 Refs: #890 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9c6646e commit 52a9312

12 files changed

Lines changed: 493 additions & 28 deletions

File tree

core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,8 +701,17 @@ public enum DefaultDriverOption implements DriverOption {
701701
CONTROL_CONNECTION_AGREEMENT_WARN("advanced.control-connection.schema-agreement.warn-on-failure"),
702702

703703
/**
704-
* Whether to forcibly add original contact points held by MetadataManager to the reconnection
705-
* plan, in case there is no live nodes available according to LBP. Experimental.
704+
* Whether to append the original contact points held by MetadataManager to the control
705+
* connection's reconnection plan, after the live nodes reported by the load balancing policy.
706+
* Defaults to {@code true}.
707+
*
708+
* <p>This is the driver's DNS re-resolution path. A metadata node discovered from the peers table
709+
* holds an address resolved once and never re-resolved; a contact point given as a hostname is
710+
* kept unresolved (the default) and looked up again through Netty's resolver on each connect, so
711+
* once the live nodes are exhausted the contact points find a cluster that moved to new
712+
* addresses, as soon as the JVM's DNS cache ({@code networkaddress.cache.ttl}) has expired. A
713+
* resolved contact point ({@code advanced.resolve-contact-points = true}, or a programmatic
714+
* resolved address) is appended as it is and never re-resolved.
706715
*
707716
* <p>Value-type: boolean
708717
*/

core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ protected static void fillWithDriverDefaults(OptionsMap map) {
369369
map.put(TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_INTERVAL, Duration.ofMillis(200));
370370
map.put(TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT, Duration.ofSeconds(10));
371371
map.put(TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_WARN, true);
372-
map.put(TypedDriverOption.CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS, false);
372+
map.put(TypedDriverOption.CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS, true);
373373
map.put(TypedDriverOption.PREPARE_ON_ALL_NODES, true);
374374
map.put(TypedDriverOption.REPREPARE_ENABLED, true);
375375
map.put(TypedDriverOption.REPREPARE_CHECK_SYSTEM_TABLE, false);

core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,13 @@ public String toString() {
600600
public static final TypedDriverOption<Boolean> CONTROL_CONNECTION_AGREEMENT_WARN =
601601
new TypedDriverOption<>(
602602
DefaultDriverOption.CONTROL_CONNECTION_AGREEMENT_WARN, GenericType.BOOLEAN);
603-
/** Whether to forcibly try original contacts if no live nodes are available */
603+
/**
604+
* Whether to append the original contact points to the control connection's reconnection plan,
605+
* after the live nodes reported by the load balancing policy (defaults to {@code true}).
606+
*
607+
* <p>A contact point given as a hostname is kept unresolved and looked up again on each connect,
608+
* so this is how the driver picks up changed DNS records once the live nodes are exhausted.
609+
*/
604610
public static final TypedDriverOption<Boolean> CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS =
605611
new TypedDriverOption<>(
606612
DefaultDriverOption.CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS, GenericType.BOOLEAN);

core/src/main/java/com/datastax/oss/driver/internal/core/control/ControlConnection.java

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@
6161
import java.util.List;
6262
import java.util.Map;
6363
import java.util.Map.Entry;
64-
import java.util.Objects;
6564
import java.util.Queue;
6665
import java.util.WeakHashMap;
6766
import java.util.concurrent.CompletableFuture;
@@ -499,6 +498,34 @@ private void connect(
499498
node,
500499
new Exception("Channel closed during endpoint resolve")));
501500
connect(nodes, newErrors, onSuccess, onFailure);
501+
} else if (isUnusableForControl(resolvedNode)) {
502+
// Events name the identified node, not the placeholder we
503+
// dialled, so isControlNode() cannot match them without a
504+
// blocking endpoint lookup. Re-checked here instead.
505+
controlNodeState = ControlNodeState.NONE;
506+
LOG.debug(
507+
"[{}] New channel opened ({}) but {} is ignored, removed "
508+
+ "or forced down, closing and trying next node",
509+
logPrefix,
510+
channel,
511+
resolvedNode);
512+
// Null out before forceClose() so that onChannelClosed() does not
513+
// start a redundant reconnection on top of the connect() retry
514+
// below.
515+
ControlConnection.this.channel = null;
516+
channel.forceClose();
517+
// Recorded like every other drop; on init this list is what the
518+
// user sees. A reconnection discards it, leaving the debug log.
519+
List<Entry<Node, Throwable>> newErrors =
520+
(errors == null) ? new ArrayList<>() : errors;
521+
newErrors.add(
522+
new SimpleEntry<>(
523+
node,
524+
new Exception(
525+
"Control node "
526+
+ resolvedNode
527+
+ " is ignored, removed or forced down")));
528+
connect(nodes, newErrors, onSuccess, onFailure);
502529
} else {
503530
controlNodeState = new ControlNodeState(resolvedNode, null);
504531
context
@@ -687,12 +714,32 @@ private boolean isControlNode(Node eventNode) {
687714
&& eventNode.getHostId().equals(state.current.getHostId())) {
688715
return true;
689716
}
690-
if (state.current == null
691-
&& state.pending != null
692-
&& Objects.equals(eventNode.getEndPoint(), state.pending.getEndPoint())) {
693-
return true;
694-
}
695-
return false;
717+
// Reference identity, not endpoint equality: with unresolved contact points in the plan,
718+
// DefaultEndPoint.equals resolves the unresolved side of a mixed pair -- a blocking lookup on
719+
// this admin executor, during the DNS outage the fallback exists for. An event naming the
720+
// metadata node for a host still being identified cannot match here at all; connect()
721+
// re-checks that node with isUnusableForControl() once the resolve completes.
722+
return state.current == null && state.pending != null && eventNode == state.pending;
723+
}
724+
725+
/**
726+
* Whether an event has already marked this node unusable for the control connection: ignored by
727+
* the load balancing policy, or removed or forced down.
728+
*
729+
* <p>{@link #connect} runs the same checks on the node it dialled, before adopting the channel;
730+
* this one runs a turn later, on the node that channel was identified as.
731+
*
732+
* <p>Keyed by node instance, so it cannot see a node {@code MetadataManager#registerNode}
733+
* minted fresh for an unknown host id: that host is resurrected, not rejected. Detecting it
734+
* would need a removal record keyed by host id -- rejecting host ids absent from the metadata
735+
* would also reject the new nodes of a cluster that moved, which is what the fallback exists to
736+
* recover.
737+
*/
738+
private boolean isUnusableForControl(Node node) {
739+
NodeState state = lastNodeState.get(node);
740+
return lastNodeDistance.get(node) == NodeDistance.IGNORED
741+
|| (lastNodeState.containsKey(node)
742+
&& (state == null /*(removed)*/ || state == NodeState.FORCED_DOWN));
696743
}
697744

698745
private void onDistanceEvent(DistanceEvent event) {

core/src/main/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapper.java

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,7 @@
3535
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableSet;
3636
import edu.umd.cs.findbugs.annotations.NonNull;
3737
import edu.umd.cs.findbugs.annotations.Nullable;
38-
import java.util.ArrayList;
39-
import java.util.Collections;
4038
import java.util.HashMap;
41-
import java.util.List;
4239
import java.util.Map;
4340
import java.util.Queue;
4441
import java.util.Set;
@@ -164,9 +161,7 @@ private Queue<Node> newQueryPlan(
164161
case BEFORE_INIT:
165162
case DURING_INIT:
166163
// The contact points are not stored in the metadata yet:
167-
List<Node> nodes = new ArrayList<>(context.getMetadataManager().getContactPoints());
168-
Collections.shuffle(nodes);
169-
return new ConcurrentLinkedQueue<>(nodes);
164+
return contactPointPlan();
170165
case RUNNING:
171166
LoadBalancingPolicy policy = policiesPerProfile.get(executionProfileName);
172167
if (policy == null) {
@@ -190,22 +185,32 @@ public Queue<Node> newControlReconnectionQueryPlan() {
190185

191186
// Before RUNNING, newQueryPlan() already built the plan from the contact points, so appending
192187
// them again would only duplicate every entry. Once RUNNING, the plan comes from the policy and
193-
// is an immutable QueryPlan (add()/addAll() throw), so concatenate rather than mutate. The
194-
// nodes retained by MetadataManager are appended, not fresh copies: their identity stays stable
195-
// across reconnection rounds, and no throwaway node is minted per round.
188+
// is an immutable QueryPlan (add()/addAll() throw), so concatenate rather than mutate.
196189
if (state == State.RUNNING
197190
&& context
198191
.getConfig()
199192
.getDefaultProfile()
200193
.getBoolean(DefaultDriverOption.CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS)) {
201-
Object[] contactNodes = context.getMetadataManager().getContactPoints().toArray();
202-
ArrayUtils.shuffleHead(contactNodes, contactNodes.length);
203-
return new CompositeQueryPlan(regularQueryPlan, new SimpleQueryPlan(contactNodes));
194+
return new CompositeQueryPlan(regularQueryPlan, contactPointPlan());
204195
}
205196

206197
return regularQueryPlan;
207198
}
208199

200+
/**
201+
* The retained contact points, in random order: the whole plan before the session is initialized,
202+
* and what the control connection falls back to once the policy's plan is exhausted. One path for
203+
* both, so a fallback round tries exactly what the initial connection tried. The nodes are the
204+
* ones {@code MetadataManager} retains, not fresh copies: their identity stays stable across
205+
* reconnection rounds, and no throwaway node is minted per round.
206+
*/
207+
@NonNull
208+
private Queue<Node> contactPointPlan() {
209+
Object[] contactNodes = context.getMetadataManager().getContactPoints().toArray();
210+
ArrayUtils.shuffleHead(contactNodes, contactNodes.length);
211+
return new SimpleQueryPlan(contactNodes);
212+
}
213+
209214
// when it comes in from the outside
210215
private void onNodeStateEvent(NodeStateEvent event) {
211216
eventFilter.accept(event);

core/src/main/java/com/datastax/oss/driver/internal/core/metadata/MetadataManager.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ public boolean wasImplicitContactPoint() {
190190
* they are never added to metadata and never exposed to user-facing APIs (events, {@link
191191
* com.datastax.oss.driver.api.core.metadata.Metadata#getNodes()}, or {@link
192192
* com.datastax.oss.driver.api.core.metadata.NodeStateListener} callbacks).
193+
*
194+
* <p>The metadata node stores {@code nodeInfo.getEndPoint()} as-is and never re-resolves it. A
195+
* contact-point hostname is re-read only through the control connection's reconnection fallback
196+
* ({@code advanced.control-connection.reconnection.fallback-to-original-contact-points}), which
197+
* re-offers the retained, still-unresolved contact points to Netty's resolver on each connect.
193198
*/
194199
public CompletionStage<Node> registerNode(NodeInfo nodeInfo) {
195200
Preconditions.checkNotNull(nodeInfo.getHostId(), "Cannot register node without hostId");

core/src/main/resources/reference.conf

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2347,14 +2347,40 @@ datastax-java-driver {
23472347
}
23482348

23492349
reconnection {
2350-
# Whether to forcibly add original contact points held by MetadataManager to the reconnection plan,
2351-
# in case there is no live nodes available according to LBP.
2352-
# Experimental.
2350+
# Whether to append the original contact points to the control connection's reconnection
2351+
# plan, after the live nodes reported by the load balancing policy.
2352+
#
2353+
# This is the driver's DNS re-resolution path. A metadata node discovered from the peers table
2354+
# holds an address resolved once and never re-resolved. A contact point given as a hostname is
2355+
# kept unresolved (see `advanced.resolve-contact-points`) and looked up again through Netty's
2356+
# resolver on each connect, so once a reconnection round has exhausted the live nodes, the
2357+
# contact points find a cluster that moved to new addresses -- as soon as the JVM's DNS cache
2358+
# (`networkaddress.cache.ttl`) has expired. A resolved contact point
2359+
# (`resolve-contact-points = true`, or a programmatic resolved address) is appended as it is
2360+
# and never re-resolved.
2361+
#
2362+
# The cost: the contact points cannot be deduplicated against the live nodes at plan time
2363+
# (hostnames against resolved addresses), so a round that exhausts the live nodes then tries
2364+
# each contact point once more -- one connect (`advanced.connection.connect-timeout`) plus the
2365+
# identity read (`advanced.control-connection.timeout`) each, serially, with the control
2366+
# connection down meanwhile. Neither timeout covers the name lookup: Netty resolves before
2367+
# it connects, so an unanswered DNS query costs the JVM resolver's own timeout per contact
2368+
# point. Set this to false when the contact points are IP literals or their records never
2369+
# change, or to keep reconnection rounds short; the control connection then re-resolves
2370+
# nothing.
2371+
#
2372+
# A contact point is only an address, not a node: whichever node answers there is registered
2373+
# under its own host id, so a node the driver had removed can come back this way if it is
2374+
# still listening behind the record (a decommissioned node during a rolling replacement, for
2375+
# instance). That is inherent to recovering through contact points -- the driver cannot tell
2376+
# a node that should be gone from a genuinely new one at a moved address, and rejecting host
2377+
# ids it does not know would defeat the point. It corrects itself on the next node refresh
2378+
# that reaches a node still in the cluster.
23532379
#
23542380
# Required: yes
23552381
# Modifiable at runtime: yes, the new value will be used for checks issued after the change.
23562382
# Overridable in a profile: no
2357-
fallback-to-original-contact-points = false
2383+
fallback-to-original-contact-points = true
23582384
}
23592385
}
23602386

0 commit comments

Comments
 (0)