Skip to content

Commit 5bfa9e1

Browse files
committed
control-connection: tolerate schema agreement connection loss
1 parent 0842348 commit 5bfa9e1

2 files changed

Lines changed: 228 additions & 14 deletions

File tree

cassandra/cluster.py

Lines changed: 156 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3531,6 +3531,7 @@ def __init__(self, cluster, timeout,
35313531
# shutdown) since implementing __del__ disables the cycle detector
35323532
self._cluster = weakref.proxy(cluster)
35333533
self._connection = None
3534+
self._last_connection_endpoint = None
35343535
self._timeout = timeout
35353536

35363537
self._schema_event_refresh_window = schema_event_refresh_window
@@ -3564,6 +3565,7 @@ def _set_new_connection(self, conn):
35643565
with self._lock:
35653566
old = self._connection
35663567
self._connection = conn
3568+
self._last_connection_endpoint = conn.endpoint
35673569

35683570
if old:
35693571
log.debug("[control connection] Closing old connection %r, replacing with %r", old, conn)
@@ -4094,33 +4096,28 @@ def wait_for_schema_agreement(self, connection=None, preloaded_results=None, wai
40944096

40954097
if not connection:
40964098
connection = self._connection
4099+
local_address = self._schema_agreement_endpoint(connection)
40974100

40984101
if preloaded_results:
40994102
log.debug("[control connection] Attempting to use preloaded results for schema agreement")
41004103

41014104
peers_result = preloaded_results[0]
41024105
local_result = preloaded_results[1]
4103-
schema_mismatches = self._get_schema_mismatches(peers_result, local_result, connection.endpoint)
4106+
schema_mismatches = self._get_schema_mismatches(peers_result, local_result, local_address)
41044107
if schema_mismatches is None:
41054108
return True
41064109

41074110
log.debug("[control connection] Waiting for schema agreement")
41084111
start = self._time.time()
41094112
elapsed = 0
4110-
cl = ConsistencyLevel.ONE
41114113
schema_mismatches = None
4112-
select_peers_query = self._get_peers_query(self.PeersQueryType.PEERS_SCHEMA, connection)
41134114

41144115
while elapsed < total_timeout:
4115-
peers_query = QueryMessage(query=maybe_add_timeout_to_query(select_peers_query, self._metadata_request_timeout),
4116-
consistency_level=cl)
4117-
local_query = QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_SCHEMA_LOCAL, self._metadata_request_timeout),
4118-
consistency_level=cl)
41194116
try:
41204117
remaining = total_timeout - elapsed
41214118
timeout = min(self._timeout, remaining) if self._timeout is not None else remaining
4122-
peers_result, local_result = connection.wait_for_responses(
4123-
peers_query, local_query, timeout=timeout)
4119+
peers_result, local_result, local_address = self._get_schema_agreement_results(
4120+
connection, timeout)
41244121
except OperationTimedOut as timeout:
41254122
log.debug("[control connection] Timed out waiting for "
41264123
"response during schema agreement check: %s", timeout)
@@ -4131,9 +4128,13 @@ def wait_for_schema_agreement(self, connection=None, preloaded_results=None, wai
41314128
log.debug("[control connection] Aborting wait for schema match due to shutdown")
41324129
return None
41334130
else:
4134-
raise
4131+
log.debug("[control connection] Connection lost during schema agreement check")
4132+
return False
4133+
except (ConnectionBusy, ConnectionException, NoConnectionsAvailable) as exc:
4134+
log.debug("[control connection] Unable to check schema agreement: %s", exc)
4135+
return False
41354136

4136-
schema_mismatches = self._get_schema_mismatches(peers_result, local_result, connection.endpoint)
4137+
schema_mismatches = self._get_schema_mismatches(peers_result, local_result, local_address)
41374138
if schema_mismatches is None:
41384139
return True
41394140

@@ -4142,9 +4143,150 @@ def wait_for_schema_agreement(self, connection=None, preloaded_results=None, wai
41424143
elapsed = self._time.time() - start
41434144

41444145
log.warning("Node %s is reporting a schema disagreement: %s",
4145-
connection.endpoint, schema_mismatches)
4146+
local_address, schema_mismatches)
41464147
return False
41474148

4149+
def _get_schema_agreement_results(self, connection, timeout):
4150+
if self._is_connection_unusable(connection):
4151+
return self._get_schema_agreement_results_from_session(connection, timeout)
4152+
4153+
try:
4154+
return self._get_schema_agreement_results_from_connection(connection, timeout)
4155+
except OperationTimedOut:
4156+
raise
4157+
except (ConnectionBusy, ConnectionException):
4158+
if self._is_shutdown:
4159+
raise
4160+
self.return_connection(connection)
4161+
log.debug("[control connection] Falling back to session connection for schema agreement")
4162+
return self._get_schema_agreement_results_from_session(connection, timeout)
4163+
4164+
def _get_schema_agreement_results_from_connection(self, connection, timeout):
4165+
peers_query, local_query = self._schema_agreement_queries(connection)
4166+
peers_result, local_result = connection.wait_for_responses(peers_query, local_query, timeout=timeout)
4167+
return peers_result, local_result, connection.endpoint
4168+
4169+
def _get_schema_agreement_results_from_session(self, connection, timeout):
4170+
endpoint = self._schema_agreement_endpoint(connection)
4171+
deadline = None if timeout is None else time.time() + timeout
4172+
4173+
pool, borrowed_connection, request_id = self._borrow_schema_agreement_connection(endpoint, timeout)
4174+
peers_query, _ = self._schema_agreement_queries(borrowed_connection)
4175+
local_address = borrowed_connection.endpoint
4176+
peers_result = self._wait_for_borrowed_schema_response(
4177+
pool, borrowed_connection, request_id, peers_query, self._remaining_timeout(deadline))
4178+
4179+
pool, borrowed_connection, request_id = self._borrow_schema_agreement_connection(
4180+
endpoint, self._remaining_timeout(deadline))
4181+
_, local_query = self._schema_agreement_queries(borrowed_connection)
4182+
local_result = self._wait_for_borrowed_schema_response(
4183+
pool, borrowed_connection, request_id, local_query, self._remaining_timeout(deadline))
4184+
4185+
return peers_result, local_result, local_address
4186+
4187+
def _schema_agreement_queries(self, connection):
4188+
cl = ConsistencyLevel.ONE
4189+
select_peers_query = self._get_peers_query(self.PeersQueryType.PEERS_SCHEMA, connection)
4190+
peers_query = QueryMessage(query=maybe_add_timeout_to_query(select_peers_query, self._metadata_request_timeout),
4191+
consistency_level=cl)
4192+
local_query = QueryMessage(query=maybe_add_timeout_to_query(self._SELECT_SCHEMA_LOCAL, self._metadata_request_timeout),
4193+
consistency_level=cl)
4194+
return peers_query, local_query
4195+
4196+
def _remaining_timeout(self, deadline):
4197+
if deadline is None:
4198+
return None
4199+
return max(0, deadline - time.time())
4200+
4201+
def _borrow_schema_agreement_connection(self, endpoint, timeout):
4202+
last_error = None
4203+
for pool in self._schema_agreement_pools(endpoint):
4204+
try:
4205+
return (pool,) + pool.borrow_connection(timeout=timeout)
4206+
except (NoConnectionsAvailable, ConnectionBusy, ConnectionException) as exc:
4207+
last_error = exc
4208+
log.debug("[control connection] Unable to borrow connection for schema agreement: %s", exc)
4209+
4210+
if last_error:
4211+
raise last_error
4212+
raise NoConnectionsAvailable("No session connection available for schema agreement on %s" % (endpoint,))
4213+
4214+
def _schema_agreement_pools(self, endpoint):
4215+
if endpoint is None:
4216+
return
4217+
4218+
host = self._cluster.metadata.get_host(endpoint)
4219+
for session in tuple(getattr(self._cluster, 'sessions', ())):
4220+
pools = getattr(session, '_pools', {})
4221+
pool = pools.get(host) if host else None
4222+
if pool is None:
4223+
for pool_host, candidate_pool in tuple(pools.items()):
4224+
if getattr(pool_host, 'endpoint', None) == endpoint:
4225+
pool = candidate_pool
4226+
break
4227+
if pool and not pool.is_shutdown:
4228+
yield pool
4229+
4230+
def _wait_for_borrowed_schema_response(self, pool, connection, request_id, message, timeout):
4231+
event = Event()
4232+
responses = []
4233+
4234+
def callback(response):
4235+
responses.append(response)
4236+
event.set()
4237+
4238+
sent = False
4239+
orphaned = False
4240+
try:
4241+
connection.send_msg(message, request_id, callback)
4242+
sent = True
4243+
if not event.wait(timeout):
4244+
orphaned = self._orphan_borrowed_request(pool, connection, request_id)
4245+
raise OperationTimedOut(timeout=timeout, in_flight=getattr(connection, 'in_flight', None))
4246+
4247+
response = responses[0]
4248+
if isinstance(response, Exception):
4249+
if hasattr(response, 'to_exception'):
4250+
response = response.to_exception()
4251+
raise response
4252+
return response
4253+
except Exception:
4254+
if not sent:
4255+
self._return_request_id_if_unused(connection, request_id)
4256+
raise
4257+
finally:
4258+
if not orphaned:
4259+
pool.return_connection(connection)
4260+
4261+
def _orphan_borrowed_request(self, pool, connection, request_id):
4262+
try:
4263+
connection._requests.pop(request_id)
4264+
except KeyError:
4265+
return False
4266+
4267+
with connection.lock:
4268+
connection.orphaned_request_ids.add(request_id)
4269+
if len(connection.orphaned_request_ids) >= connection.orphaned_threshold:
4270+
connection.orphaned_threshold_reached = True
4271+
4272+
pool.return_connection(connection, stream_was_orphaned=True)
4273+
return True
4274+
4275+
def _return_request_id_if_unused(self, connection, request_id):
4276+
with connection.lock:
4277+
if request_id in connection._requests or request_id in connection.orphaned_request_ids:
4278+
return
4279+
if request_id not in connection.request_ids:
4280+
connection.request_ids.append(request_id)
4281+
4282+
def _schema_agreement_endpoint(self, connection):
4283+
return getattr(connection, 'endpoint', None) or self._last_connection_endpoint
4284+
4285+
def _is_connection_unusable(self, connection):
4286+
return (connection is None or
4287+
getattr(connection, 'is_closed', False) or
4288+
getattr(connection, 'is_defunct', False))
4289+
41484290
def _get_schema_mismatches(self, peers_result, local_result, local_address):
41494291
peers_result = dict_factory(peers_result.column_names, peers_result.parsed_rows)
41504292

@@ -4265,7 +4407,8 @@ def get_connections(self):
42654407
return [c] if c else []
42664408

42674409
def return_connection(self, connection):
4268-
if connection is self._connection and (connection.is_defunct or connection.is_closed):
4410+
if (connection is self._connection and
4411+
(getattr(connection, 'is_defunct', False) or getattr(connection, 'is_closed', False))):
42694412
self.reconnect()
42704413

42714414

tests/unit/test_control_connection.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@
1414

1515
import unittest
1616

17+
from collections import deque
1718
from concurrent.futures import ThreadPoolExecutor
19+
from threading import Lock
1820
from unittest.mock import Mock, ANY, call
1921

2022
from cassandra import OperationTimedOut, SchemaTargetType, SchemaChangeType
2123
from cassandra.protocol import ResultMessage, RESULT_KIND_ROWS
2224
from cassandra.cluster import ControlConnection, _Scheduler, ProfileManager, EXEC_PROFILE_DEFAULT, ExecutionProfile
2325
from cassandra.pool import Host
24-
from cassandra.connection import EndPoint, DefaultEndPoint, DefaultEndPointFactory
26+
from cassandra.connection import EndPoint, DefaultEndPoint, DefaultEndPointFactory, ConnectionShutdown
2527
from cassandra.policies import (SimpleConvictionPolicy, RoundRobinPolicy,
2628
ConstantReconnectionPolicy, IdentityTranslator)
2729

@@ -106,6 +108,7 @@ class MockCluster(object):
106108
def __init__(self):
107109
self.metadata = MockMetadata()
108110
self.added_hosts = []
111+
self.sessions = []
109112
self.scheduler = Mock(spec=_Scheduler)
110113
self.executor = Mock(spec=ThreadPoolExecutor)
111114
self.profile_manager.profiles[EXEC_PROFILE_DEFAULT] = ExecutionProfile(RoundRobinPolicy())
@@ -146,6 +149,7 @@ def _node_meta_results(local_results, peer_results):
146149
class MockConnection(object):
147150

148151
is_defunct = False
152+
is_closed = False
149153

150154
def __init__(self):
151155
self.endpoint = DefaultEndPoint("192.168.1.0")
@@ -169,6 +173,29 @@ def __init__(self):
169173
self.wait_for_responses = Mock(return_value=_node_meta_results(self.local_results, self.peer_results))
170174

171175

176+
class MockBorrowedConnection(MockConnection):
177+
178+
is_closed = False
179+
180+
def __init__(self, responses):
181+
super(MockBorrowedConnection, self).__init__()
182+
self.responses = deque(responses)
183+
self.lock = Lock()
184+
self.in_flight = 0
185+
self._requests = {}
186+
self.request_ids = deque()
187+
self.orphaned_request_ids = set()
188+
self.orphaned_threshold = 100
189+
self.orphaned_threshold_reached = False
190+
self.send_msg = Mock(side_effect=self._send_msg)
191+
192+
def _send_msg(self, message, request_id, callback):
193+
self._requests[request_id] = callback
194+
callback(self.responses.popleft())
195+
self._requests.pop(request_id, None)
196+
self.request_ids.append(request_id)
197+
198+
172199
class FakeTime(object):
173200

174201
def __init__(self):
@@ -301,6 +328,50 @@ def test_wait_for_schema_agreement_none_timeout(self):
301328
cc._time = self.time
302329
assert cc.wait_for_schema_agreement()
303330

331+
def test_wait_for_schema_agreement_falls_back_to_session_connection(self):
332+
"""
333+
If the control connection is broken, use a session connection from the
334+
same host for schema agreement.
335+
"""
336+
self.connection.wait_for_responses.side_effect = ConnectionShutdown("closed")
337+
host = self.cluster.metadata.get_host(self.connection.endpoint)
338+
borrowed_connection = MockBorrowedConnection(
339+
_node_meta_results(self.connection.local_results, self.connection.peer_results))
340+
341+
pool = Mock()
342+
pool.is_shutdown = False
343+
pool.borrow_connection.side_effect = [(borrowed_connection, 1), (borrowed_connection, 2)]
344+
session = Mock()
345+
session._pools = {host: pool}
346+
self.cluster.sessions = [session]
347+
348+
assert self.control_connection.wait_for_schema_agreement()
349+
assert pool.borrow_connection.call_count == 2
350+
assert pool.return_connection.call_count == 2
351+
352+
def test_wait_for_schema_agreement_falls_back_when_control_connection_is_gone(self):
353+
self.control_connection._last_connection_endpoint = self.connection.endpoint
354+
self.control_connection._connection = None
355+
host = self.cluster.metadata.get_host(self.connection.endpoint)
356+
borrowed_connection = MockBorrowedConnection(
357+
_node_meta_results(self.connection.local_results, self.connection.peer_results))
358+
359+
pool = Mock()
360+
pool.is_shutdown = False
361+
pool.borrow_connection.side_effect = [(borrowed_connection, 1), (borrowed_connection, 2)]
362+
session = Mock()
363+
session._pools = {host: pool}
364+
self.cluster.sessions = [session]
365+
366+
assert self.control_connection.wait_for_schema_agreement()
367+
assert pool.borrow_connection.call_count == 2
368+
assert pool.return_connection.call_count == 2
369+
370+
def test_wait_for_schema_agreement_tolerates_missing_connection(self):
371+
self.control_connection._connection = None
372+
373+
assert not self.control_connection.wait_for_schema_agreement()
374+
304375
def test_refresh_nodes_and_tokens(self):
305376
self.control_connection.refresh_node_list_and_token_map()
306377
meta = self.cluster.metadata

0 commit comments

Comments
 (0)