Skip to content

Commit a8630ad

Browse files
committed
connection: tolerate clean startup close
1 parent bcc2d3d commit a8630ad

7 files changed

Lines changed: 453 additions & 40 deletions

File tree

cassandra/cluster.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,14 @@ def _execution_profile_to_string(name):
345345
return '"%s"' % (name,)
346346

347347

348+
def _raise_if_connection_closed_during_startup(conn, endpoint):
349+
if conn.is_closed:
350+
endpoint = getattr(conn, 'endpoint', endpoint)
351+
raise ConnectionShutdown(
352+
"Connection to %s was closed during the startup handshake" % (endpoint,),
353+
endpoint)
354+
355+
348356
class ExecutionProfile(object):
349357
load_balancing_policy = None
350358
"""
@@ -1749,11 +1757,22 @@ def connection_factory(self, endpoint, host_conn = None, *args, **kwargs):
17491757
Intended for internal use only.
17501758
"""
17511759
kwargs = self._make_connection_kwargs(endpoint, kwargs)
1752-
return self.connection_class.factory(endpoint, self.connect_timeout, host_conn, *args, **kwargs)
1760+
conn = self.connection_class.factory(endpoint, self.connect_timeout, host_conn, *args, **kwargs)
1761+
_raise_if_connection_closed_during_startup(conn, endpoint)
1762+
return conn
17531763

17541764
def _make_connection_factory(self, host, *args, **kwargs):
1755-
kwargs = self._make_connection_kwargs(host.endpoint, kwargs)
1756-
return partial(self.connection_class.factory, host.endpoint, self.connect_timeout, *args, **kwargs)
1765+
endpoint = host.endpoint
1766+
connection_class = self.connection_class
1767+
connect_timeout = self.connect_timeout
1768+
kwargs = self._make_connection_kwargs(endpoint, kwargs)
1769+
1770+
def connection_factory():
1771+
conn = connection_class.factory(endpoint, connect_timeout, *args, **kwargs)
1772+
_raise_if_connection_closed_during_startup(conn, endpoint)
1773+
return conn
1774+
1775+
return connection_factory
17571776

17581777
def _make_connection_kwargs(self, endpoint, kwargs_dict):
17591778
if self._auth_provider_callable:

cassandra/connection.py

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -965,31 +965,42 @@ def create_timer(cls, timeout, callback):
965965
@classmethod
966966
def factory(cls, endpoint, timeout, host_conn = None, *args, **kwargs):
967967
"""
968-
A factory function which returns connections which have
969-
succeeded in connecting and are ready for service (or
970-
raises an exception otherwise).
968+
A factory function which returns a connection once startup has
969+
completed, returns a closed connection if the server closes during
970+
startup, or raises an exception otherwise.
971+
972+
Pool callers pass ``host_conn`` so the connection can be tracked while
973+
startup is in progress and closed during pool shutdown.
971974
"""
972975
start = time.time()
973976
kwargs['connect_timeout'] = timeout
974977
conn = cls(endpoint, *args, **kwargs)
975-
if host_conn is not None:
976-
host_conn._pending_connections.append(conn)
977-
if host_conn.is_shutdown:
978+
pending_registered = False
979+
try:
980+
if host_conn is not None:
981+
host_conn._pending_connections.append(conn)
982+
pending_registered = True
983+
if host_conn.is_shutdown:
984+
conn.close()
985+
elapsed = time.time() - start
986+
conn.connected_event.wait(timeout - elapsed)
987+
if conn.last_error:
988+
if conn.is_unsupported_proto_version:
989+
raise ProtocolVersionUnsupported(endpoint, conn.protocol_version)
990+
if conn.is_closed and not conn.is_defunct and isinstance(conn.last_error, ConnectionShutdown):
991+
return conn
992+
raise conn.last_error
993+
elif not conn.connected_event.is_set():
978994
conn.close()
979-
elapsed = time.time() - start
980-
conn.connected_event.wait(timeout - elapsed)
981-
if conn.last_error:
982-
if conn.is_unsupported_proto_version:
983-
raise ProtocolVersionUnsupported(endpoint, conn.protocol_version)
984-
raise conn.last_error
985-
elif not conn.connected_event.is_set():
986-
conn.close()
987-
raise OperationTimedOut("Timed out creating connection (%s seconds)" % timeout,
988-
timeout=timeout)
989-
elif conn.is_closed:
990-
raise ConnectionShutdown("Connection to %s was closed by server" % conn.endpoint)
991-
else:
995+
raise OperationTimedOut("Timed out creating connection (%s seconds)" % timeout,
996+
timeout=timeout)
992997
return conn
998+
finally:
999+
if pending_registered:
1000+
try:
1001+
host_conn._pending_connections.remove(conn)
1002+
except ValueError:
1003+
pass
9931004

9941005
def _build_ssl_context_from_options(self):
9951006

cassandra/pool.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,10 @@ def __init__(self, host, host_distance, session):
426426
return
427427

428428
log.debug("Initializing connection for host %s", self.host)
429-
first_connection = session.cluster.connection_factory(self.host.endpoint, on_orphaned_stream_released=self.on_orphaned_stream_released)
429+
first_connection = session.cluster.connection_factory(
430+
self.host.endpoint,
431+
host_conn=self,
432+
on_orphaned_stream_released=self.on_orphaned_stream_released)
430433
log.debug("First connection created to %s for shard_id=%i", self.host, first_connection.features.shard_id)
431434
self._connections[first_connection.features.shard_id] = first_connection
432435
self._keyspace = session.keyspace
@@ -605,8 +608,10 @@ def _replace(self, connection):
605608
self._connecting.add(connection.features.shard_id)
606609
self._session.submit(self._open_connection_to_missing_shard, connection.features.shard_id)
607610
else:
608-
connection = self._session.cluster.connection_factory(self.host.endpoint,
609-
on_orphaned_stream_released=self.on_orphaned_stream_released)
611+
connection = self._session.cluster.connection_factory(
612+
self.host.endpoint,
613+
host_conn=self,
614+
on_orphaned_stream_released=self.on_orphaned_stream_released)
610615
if self._keyspace:
611616
connection.set_keyspace_blocking(self._keyspace)
612617
self._connections[connection.features.shard_id] = connection
@@ -725,15 +730,21 @@ def _open_connection_to_missing_shard(self, shard_id):
725730
log.debug("shard_aware_endpoint=%r", shard_aware_endpoint)
726731
if shard_aware_endpoint:
727732
try:
728-
conn = self._session.cluster.connection_factory(shard_aware_endpoint, host_conn=self, on_orphaned_stream_released=self.on_orphaned_stream_released,
729-
shard_id=shard_id,
730-
total_shards=self.host.sharding_info.shards_count)
733+
conn = self._session.cluster.connection_factory(
734+
shard_aware_endpoint,
735+
host_conn=self,
736+
on_orphaned_stream_released=self.on_orphaned_stream_released,
737+
shard_id=shard_id,
738+
total_shards=self.host.sharding_info.shards_count)
731739
conn.original_endpoint = self.host.endpoint
732740
except Exception as exc:
733741
log.error("Failed to open connection to %s, on shard_id=%i: %s", self.host, shard_id, exc)
734742
raise
735743
else:
736-
conn = self._session.cluster.connection_factory(self.host.endpoint, host_conn=self, on_orphaned_stream_released=self.on_orphaned_stream_released)
744+
conn = self._session.cluster.connection_factory(
745+
self.host.endpoint,
746+
host_conn=self,
747+
on_orphaned_stream_released=self.on_orphaned_stream_released)
737748

738749
log.debug(
739750
"Received a connection %s for shard_id=%i on host %s",
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Copyright DataStax, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import socket
16+
import unittest
17+
from threading import Event, Thread
18+
19+
import pytest
20+
21+
from cassandra.cluster import NoHostAvailable
22+
from cassandra.connection import ConnectionShutdown, DefaultEndPoint
23+
from tests.integration import TestCluster
24+
25+
26+
class MaintenanceModeCqlServer(object):
27+
"""
28+
Minimal CQL listener that accepts a startup attempt and then closes the
29+
socket without replying, matching Scylla's maintenance-mode failure shape.
30+
"""
31+
32+
def __init__(self, max_connections=1):
33+
self._closed = False
34+
self._max_connections = max_connections
35+
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
36+
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
37+
self._sock.bind(('127.0.0.1', 0))
38+
self._sock.listen(max_connections)
39+
self._sock.settimeout(0.2)
40+
self.port = self._sock.getsockname()[1]
41+
self.frames = []
42+
self.ready = Event()
43+
self.received_frame = Event()
44+
self.error = None
45+
self.thread = Thread(target=self._run)
46+
self.thread.daemon = True
47+
self.thread.start()
48+
49+
def _run(self):
50+
self.ready.set()
51+
try:
52+
while len(self.frames) < self._max_connections and not self._closed:
53+
try:
54+
client, _ = self._sock.accept()
55+
except socket.timeout:
56+
continue
57+
58+
with client:
59+
client.settimeout(2)
60+
frame = b''
61+
while len(frame) < 9:
62+
chunk = client.recv(9 - len(frame))
63+
if not chunk:
64+
break
65+
frame += chunk
66+
self.frames.append(frame)
67+
self.received_frame.set()
68+
except Exception as exc:
69+
if not self._closed:
70+
self.error = exc
71+
finally:
72+
self.received_frame.set()
73+
74+
def close(self):
75+
self._closed = True
76+
try:
77+
self._sock.close()
78+
except socket.error:
79+
pass
80+
self.thread.join(2)
81+
82+
83+
class MaintenanceModeConnectionTest(unittest.TestCase):
84+
85+
def setUp(self):
86+
self.cluster = TestCluster(contact_points=[], connect_timeout=2)
87+
self.cluster.connection_class.initialize_reactor()
88+
89+
def tearDown(self):
90+
self.cluster.shutdown()
91+
92+
def test_startup_close_is_observable_low_level_but_rejected_by_cluster_factory(self):
93+
"""
94+
Exercise the real reactor/factory path against a socket that behaves
95+
like a node rejecting regular CQL traffic while in maintenance mode.
96+
"""
97+
endpoint, server = self._new_endpoint_and_server()
98+
try:
99+
conn = self.cluster.connection_class.factory(
100+
endpoint,
101+
self.cluster.connect_timeout,
102+
**self.cluster._make_connection_kwargs(endpoint, {}))
103+
104+
assert conn.is_closed
105+
self._assert_server_saw_options_frame(server)
106+
finally:
107+
server.close()
108+
109+
endpoint, server = self._new_endpoint_and_server()
110+
try:
111+
with pytest.raises(ConnectionShutdown) as exc_info:
112+
self.cluster.connection_factory(endpoint)
113+
114+
assert "closed during the startup handshake" in str(exc_info.value)
115+
self._assert_server_saw_options_frame(server)
116+
finally:
117+
server.close()
118+
119+
def test_cluster_connect_reports_startup_close_as_unavailable_host(self):
120+
endpoint, server = self._new_endpoint_and_server(max_connections=4)
121+
cluster = TestCluster(
122+
contact_points=[endpoint],
123+
connect_timeout=2,
124+
control_connection_timeout=2)
125+
cluster.connection_class.initialize_reactor()
126+
127+
try:
128+
with pytest.raises(NoHostAvailable) as exc_info:
129+
cluster.connect()
130+
131+
errors = exc_info.value.errors
132+
assert errors
133+
assert any(isinstance(exc, ConnectionShutdown) for exc in errors.values())
134+
assert any("closed during the startup handshake" in str(exc)
135+
for exc in errors.values())
136+
self._assert_server_saw_options_frame(server)
137+
finally:
138+
cluster.shutdown()
139+
server.close()
140+
141+
def _new_endpoint_and_server(self, max_connections=1):
142+
server = MaintenanceModeCqlServer(max_connections=max_connections)
143+
assert server.ready.wait(2)
144+
return DefaultEndPoint('127.0.0.1', server.port), server
145+
146+
def _assert_server_saw_options_frame(self, server):
147+
assert server.received_frame.wait(2)
148+
assert server.error is None
149+
assert server.frames
150+
assert len(server.frames[0]) >= 5
151+
assert server.frames[0][4] == 0x05 # OPTIONS

tests/unit/test_cluster.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
InvalidRequest, Unauthorized, AuthenticationFailed, OperationTimedOut, UnsupportedOperation, RequestValidationException, ConfigurationException, ProtocolVersion
2626
from cassandra.cluster import _Scheduler, Session, Cluster, ResultSet, SchemaAgreementScope, ControlConnectionQueryFallback, default_lbp_factory, \
2727
ExecutionProfile, _ConfigMode, EXEC_PROFILE_DEFAULT
28-
from cassandra.connection import ConnectionBusy, ConnectionException
28+
from cassandra.connection import ConnectionBusy, ConnectionException, ConnectionShutdown
2929
from cassandra.pool import Host
3030
from cassandra.policies import HostDistance, RetryPolicy, RoundRobinPolicy, DowngradingConsistencyRetryPolicy, SimpleConvictionPolicy
3131
from cassandra.query import SimpleStatement, named_tuple_factory, tuple_factory
@@ -268,16 +268,58 @@ def test_connection_factory_passes_compression_kwarg(self):
268268
]
269269

270270
for supported, configured, expected in scenarios:
271+
connection = Mock(is_closed=False)
271272
with patch.dict('cassandra.cluster.locally_supported_compressions', supported, clear=True):
272-
with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value='connection') as factory:
273+
with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value=connection) as factory:
273274
cluster = Cluster(compression=configured)
274275
conn = cluster.connection_factory(endpoint)
275276

276-
assert conn == 'connection'
277+
assert conn is connection
277278
assert factory.call_count == 1
278279
assert factory.call_args.kwargs['compression'] == expected
279280
assert cluster.compression == expected
280281

282+
def test_reconnection_factory_rejects_startup_close(self):
283+
endpoint = Mock(address='127.0.0.1')
284+
host = Mock(endpoint=endpoint)
285+
connection = Mock(is_closed=False)
286+
287+
with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value=connection) as factory:
288+
cluster = Cluster()
289+
conn_factory = cluster._make_connection_factory(host)
290+
conn = conn_factory()
291+
292+
assert conn is connection
293+
assert factory.call_count == 1
294+
295+
def test_connection_factory_rejects_startup_close(self):
296+
endpoint = Mock(address='127.0.0.1')
297+
connection = Mock(is_closed=True)
298+
connection.endpoint = endpoint
299+
300+
with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value=connection):
301+
cluster = Cluster()
302+
303+
with pytest.raises(ConnectionShutdown) as exc_info:
304+
cluster.connection_factory(endpoint)
305+
306+
assert "closed during the startup handshake" in str(exc_info.value)
307+
308+
def test_reconnection_factory_rejects_startup_close_result(self):
309+
endpoint = Mock(address='127.0.0.1')
310+
host = Mock(endpoint=endpoint)
311+
connection = Mock(is_closed=True)
312+
connection.endpoint = endpoint
313+
314+
with patch.object(Cluster.connection_class, 'factory', autospec=True, return_value=connection):
315+
cluster = Cluster()
316+
conn_factory = cluster._make_connection_factory(host)
317+
318+
with pytest.raises(ConnectionShutdown) as exc_info:
319+
conn_factory()
320+
321+
assert "closed during the startup handshake" in str(exc_info.value)
322+
281323

282324
class SchedulerTest(unittest.TestCase):
283325
# TODO: this suite could be expanded; for now just adding a test covering a ticket

0 commit comments

Comments
 (0)