Skip to content

Commit 74d63d1

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

7 files changed

Lines changed: 562 additions & 57 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: 63 additions & 24 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
@@ -593,30 +596,60 @@ def on_orphaned_stream_released(self):
593596
self._stream_available_condition.notify()
594597

595598
def _replace(self, connection):
596-
with self._lock:
597-
if self.is_shutdown:
598-
return
599+
replacement = None
600+
replace_finished = False
599601

600-
log.debug("Replacing connection (%s) to %s", id(connection), self.host)
601-
try:
602+
try:
603+
with self._lock:
604+
if self.is_shutdown:
605+
return
606+
607+
log.debug("Replacing connection (%s) to %s", id(connection), self.host)
602608
if connection.features.shard_id in self._connections:
603609
del self._connections[connection.features.shard_id]
604-
if self.host.sharding_info and not self._session.cluster.shard_aware_options.disable:
610+
611+
needs_replacement = not (
612+
self.host.sharding_info and not self._session.cluster.shard_aware_options.disable)
613+
614+
if not needs_replacement:
605615
self._connecting.add(connection.features.shard_id)
606616
self._session.submit(self._open_connection_to_missing_shard, connection.features.shard_id)
607-
else:
608-
connection = self._session.cluster.connection_factory(self.host.endpoint,
609-
on_orphaned_stream_released=self.on_orphaned_stream_released)
610-
if self._keyspace:
611-
connection.set_keyspace_blocking(self._keyspace)
612-
self._connections[connection.features.shard_id] = connection
613-
except Exception:
614-
log.warning("Failed reconnecting %s. Retrying." % (self.host.endpoint,))
615-
self._session.submit(self._replace, connection)
616-
else:
617-
self._is_replacing = False
618-
with self._stream_available_condition:
619-
self._stream_available_condition.notify()
617+
replace_finished = True
618+
619+
if needs_replacement:
620+
replacement = self._session.cluster.connection_factory(
621+
self.host.endpoint,
622+
host_conn=self,
623+
on_orphaned_stream_released=self.on_orphaned_stream_released)
624+
if self._keyspace:
625+
replacement.set_keyspace_blocking(self._keyspace)
626+
627+
with self._lock:
628+
if self.is_shutdown:
629+
close_replacement = True
630+
else:
631+
close_replacement = False
632+
self._connections[replacement.features.shard_id] = replacement
633+
replacement = None
634+
replace_finished = True
635+
636+
if close_replacement:
637+
replacement.close()
638+
return
639+
except Exception:
640+
log.warning("Failed reconnecting %s. Retrying." % (self.host.endpoint,))
641+
with self._lock:
642+
if self.is_shutdown:
643+
return
644+
self._session.submit(self._replace, connection)
645+
else:
646+
if replace_finished:
647+
with self._lock:
648+
if self.is_shutdown:
649+
return
650+
self._is_replacing = False
651+
with self._stream_available_condition:
652+
self._stream_available_condition.notify()
620653

621654
def shutdown(self):
622655
log.debug("Shutting down connections to %s", self.host)
@@ -725,15 +758,21 @@ def _open_connection_to_missing_shard(self, shard_id):
725758
log.debug("shard_aware_endpoint=%r", shard_aware_endpoint)
726759
if shard_aware_endpoint:
727760
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)
761+
conn = self._session.cluster.connection_factory(
762+
shard_aware_endpoint,
763+
host_conn=self,
764+
on_orphaned_stream_released=self.on_orphaned_stream_released,
765+
shard_id=shard_id,
766+
total_shards=self.host.sharding_info.shards_count)
731767
conn.original_endpoint = self.host.endpoint
732768
except Exception as exc:
733769
log.error("Failed to open connection to %s, on shard_id=%i: %s", self.host, shard_id, exc)
734770
raise
735771
else:
736-
conn = self._session.cluster.connection_factory(self.host.endpoint, host_conn=self, on_orphaned_stream_released=self.on_orphaned_stream_released)
772+
conn = self._session.cluster.connection_factory(
773+
self.host.endpoint,
774+
host_conn=self,
775+
on_orphaned_stream_released=self.on_orphaned_stream_released)
737776

738777
log.debug(
739778
"Received a connection %s for shard_id=%i on host %s",
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
# Best-effort test cleanup; close can race with the listener thread.
80+
pass
81+
self.thread.join(2)
82+
83+
84+
class MaintenanceModeConnectionTest(unittest.TestCase):
85+
86+
def setUp(self):
87+
self.cluster = TestCluster(contact_points=[], connect_timeout=2)
88+
self.cluster.connection_class.initialize_reactor()
89+
90+
def tearDown(self):
91+
self.cluster.shutdown()
92+
93+
def test_startup_close_is_observable_low_level_but_rejected_by_cluster_factory(self):
94+
"""
95+
Exercise the real reactor/factory path against a socket that behaves
96+
like a node rejecting regular CQL traffic while in maintenance mode.
97+
"""
98+
endpoint, server = self._new_endpoint_and_server()
99+
try:
100+
conn = self.cluster.connection_class.factory(
101+
endpoint,
102+
self.cluster.connect_timeout,
103+
**self.cluster._make_connection_kwargs(endpoint, {}))
104+
105+
assert conn.is_closed
106+
self._assert_server_saw_options_frame(server)
107+
finally:
108+
server.close()
109+
110+
endpoint, server = self._new_endpoint_and_server()
111+
try:
112+
with pytest.raises(ConnectionShutdown) as exc_info:
113+
self.cluster.connection_factory(endpoint)
114+
115+
assert "closed during the startup handshake" in str(exc_info.value)
116+
self._assert_server_saw_options_frame(server)
117+
finally:
118+
server.close()
119+
120+
def test_cluster_connect_reports_startup_close_as_unavailable_host(self):
121+
endpoint, server = self._new_endpoint_and_server(max_connections=4)
122+
cluster = TestCluster(
123+
contact_points=[endpoint],
124+
connect_timeout=2,
125+
control_connection_timeout=2)
126+
cluster.connection_class.initialize_reactor()
127+
128+
try:
129+
with pytest.raises(NoHostAvailable) as exc_info:
130+
cluster.connect()
131+
132+
errors = exc_info.value.errors
133+
assert errors
134+
assert any(isinstance(exc, ConnectionShutdown) for exc in errors.values())
135+
assert any("closed during the startup handshake" in str(exc)
136+
for exc in errors.values())
137+
self._assert_server_saw_options_frame(server)
138+
finally:
139+
cluster.shutdown()
140+
server.close()
141+
142+
def _new_endpoint_and_server(self, max_connections=1):
143+
server = MaintenanceModeCqlServer(max_connections=max_connections)
144+
assert server.ready.wait(2)
145+
return DefaultEndPoint('127.0.0.1', server.port), server
146+
147+
def _assert_server_saw_options_frame(self, server):
148+
assert server.received_frame.wait(2)
149+
assert server.error is None
150+
assert server.frames
151+
assert len(server.frames[0]) >= 5
152+
assert server.frames[0][4] == 0x05 # OPTIONS

0 commit comments

Comments
 (0)