Skip to content

Commit fccdcba

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

9 files changed

Lines changed: 839 additions & 74 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: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -962,34 +962,48 @@ def handle_fork(cls):
962962
def create_timer(cls, timeout, callback):
963963
raise NotImplementedError()
964964

965+
def _is_clean_close_error(self, exc):
966+
return not self.is_defunct and isinstance(exc, ConnectionShutdown)
967+
965968
@classmethod
966969
def factory(cls, endpoint, timeout, host_conn = None, *args, **kwargs):
967970
"""
968-
A factory function which returns connections which have
969-
succeeded in connecting and are ready for service (or
970-
raises an exception otherwise).
971+
A factory function which returns a connection once startup has
972+
completed, returns a closed connection if the server closes during
973+
startup, or raises an exception otherwise.
974+
975+
Pool callers pass ``host_conn`` so the connection can be tracked while
976+
startup is in progress and closed during pool shutdown.
971977
"""
972978
start = time.time()
973979
kwargs['connect_timeout'] = timeout
974980
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:
981+
pending_registered = False
982+
try:
983+
if host_conn is not None:
984+
host_conn._pending_connections.append(conn)
985+
pending_registered = True
986+
if host_conn.is_shutdown:
987+
conn.close()
988+
elapsed = time.time() - start
989+
conn.connected_event.wait(timeout - elapsed)
990+
if conn.last_error:
991+
if conn.is_unsupported_proto_version:
992+
raise ProtocolVersionUnsupported(endpoint, conn.protocol_version)
993+
if conn.is_closed and conn._is_clean_close_error(conn.last_error):
994+
return conn
995+
raise conn.last_error
996+
elif not conn.connected_event.is_set():
978997
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:
998+
raise OperationTimedOut("Timed out creating connection (%s seconds)" % timeout,
999+
timeout=timeout)
9921000
return conn
1001+
finally:
1002+
if pending_registered:
1003+
try:
1004+
host_conn._pending_connections.remove(conn)
1005+
except ValueError:
1006+
pass
9931007

9941008
def _build_ssl_context_from_options(self):
9951009

cassandra/io/twistedreactor.py

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from twisted.internet import reactor, protocol
2626
from twisted.internet.endpoints import connectProtocol, TCP4ClientEndpoint, SSL4ClientEndpoint
27+
from twisted.internet.error import ConnectionDone
2728
from twisted.internet.interfaces import IOpenSSLClientConnectionCreator
2829
from twisted.python.failure import Failure
2930
from zope.interface import implementer
@@ -198,6 +199,9 @@ def create_timer(cls, timeout, callback):
198199
cls._loop.add_timer(timer)
199200
return timer
200201

202+
def _is_clean_close_error(self, exc):
203+
return isinstance(exc, ConnectionDone) or Connection._is_clean_close_error(self, exc)
204+
201205
def __init__(self, *args, **kwargs):
202206
"""
203207
Initialization method.
@@ -209,7 +213,10 @@ def __init__(self, *args, **kwargs):
209213
"""
210214
Connection.__init__(self, *args, **kwargs)
211215

212-
self.is_closed = True
216+
# A scheduled or in-progress endpoint connection is still closeable.
217+
# Keeping it logically open lets pool shutdown cancel it before
218+
# connectionMade.
219+
self.is_closed = False
213220
self.connector = None
214221
self.transport = None
215222

@@ -226,9 +233,12 @@ def _check_pyopenssl(self):
226233

227234
def add_connection(self):
228235
"""
229-
Convenience function to connect and store the resulting
230-
connector.
236+
Convenience function to connect and store the resulting Deferred.
231237
"""
238+
with self.lock:
239+
if self.is_closed:
240+
return
241+
232242
host, port = self.endpoint.resolve()
233243
if self.ssl_context or self.ssl_options:
234244
# Can't use optionsForClientTLS here because it *forces* hostname verification.
@@ -257,16 +267,41 @@ def add_connection(self):
257267
port,
258268
timeout=self.connect_timeout
259269
)
260-
connectProtocol(endpoint, TwistedConnectionProtocol(self))
270+
271+
# connectProtocol returns a cancellable Deferred. Keep the final
272+
# shutdown check and assignment under the lock so close() either stops
273+
# this attempt before it starts or observes the Deferred and cancels it.
274+
with self.lock:
275+
if self.is_closed:
276+
return
277+
self.connector = connectProtocol(
278+
endpoint, TwistedConnectionProtocol(self))
279+
self.connector.addErrback(self._handle_connect_failure)
280+
281+
def _handle_connect_failure(self, failure):
282+
# Pool shutdown intentionally cancels the endpoint Deferred. Consume
283+
# that failure instead of leaving an unhandled cancellation in Twisted.
284+
with self.lock:
285+
if self.is_closed:
286+
return None
287+
return failure
261288

262289
def client_connection_made(self, transport):
263290
"""
264291
Called by twisted protocol when a connection attempt has
265292
succeeded.
266293
"""
267294
with self.lock:
268-
self.is_closed = False
269-
self.transport = transport
295+
if self.is_closed:
296+
close_transport = True
297+
else:
298+
close_transport = False
299+
self.transport = transport
300+
301+
if close_transport:
302+
reactor.callFromThread(transport.connector.disconnect)
303+
return
304+
270305
self._send_options_message()
271306

272307
def close(self):
@@ -277,18 +312,29 @@ def close(self):
277312
if self.is_closed:
278313
return
279314
self.is_closed = True
315+
connector = self.connector
316+
transport = self.transport
317+
318+
shutdown_error = None
319+
if not self.is_defunct:
320+
msg = "Connection to %s was closed" % self.endpoint
321+
if self.last_error:
322+
msg += ": %s" % (self.last_error,)
323+
shutdown_error = ConnectionShutdown(msg)
324+
if not self.connected_event.is_set():
325+
self.last_error = shutdown_error
326+
# Wake Connection.factory before waiting for reactor cleanup.
327+
self.connected_event.set()
280328

281329
log.debug("Closing connection (%s) to %s", id(self), self.endpoint)
282-
reactor.callFromThread(self.transport.connector.disconnect)
330+
if transport is not None:
331+
reactor.callFromThread(transport.connector.disconnect)
332+
elif connector is not None:
333+
reactor.callFromThread(connector.cancel)
283334
log.debug("Closed socket to %s", self.endpoint)
284335

285-
if not self.is_defunct:
286-
msg = "Connection to %s was closed" % self.endpoint
287-
if self.last_error:
288-
msg += ": %s" % (self.last_error,)
289-
self.error_all_requests(ConnectionShutdown(msg))
290-
# don't leave in-progress operations hanging
291-
self.connected_event.set()
336+
if shutdown_error is not None:
337+
self.error_all_requests(shutdown_error)
292338

293339
def handle_read(self):
294340
"""

cassandra/pool.py

Lines changed: 91 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,88 @@ 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+
replacement_is_pending = False
601+
replace_finished = False
599602

600-
log.debug("Replacing connection (%s) to %s", id(connection), self.host)
601-
try:
603+
try:
604+
with self._lock:
605+
if self.is_shutdown:
606+
return
607+
608+
log.debug("Replacing connection (%s) to %s", id(connection), self.host)
602609
if connection.features.shard_id in self._connections:
603610
del self._connections[connection.features.shard_id]
604-
if self.host.sharding_info and not self._session.cluster.shard_aware_options.disable:
611+
612+
needs_replacement = not (
613+
self.host.sharding_info and not self._session.cluster.shard_aware_options.disable)
614+
615+
if not needs_replacement:
605616
self._connecting.add(connection.features.shard_id)
606617
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()
618+
replace_finished = True
619+
620+
if needs_replacement:
621+
replacement = self._session.cluster.connection_factory(
622+
self.host.endpoint,
623+
host_conn=self,
624+
on_orphaned_stream_released=self.on_orphaned_stream_released)
625+
626+
# Connection.factory() stops tracking the connection before it
627+
# returns. Adopt it as pending before doing the blocking
628+
# keyspace setup so shutdown can still close it. The shutdown
629+
# check and adoption must be atomic to cover the handoff gap.
630+
with self._lock:
631+
if self.is_shutdown:
632+
close_replacement = True
633+
else:
634+
close_replacement = False
635+
self._pending_connections.append(replacement)
636+
replacement_is_pending = True
637+
638+
if close_replacement:
639+
replacement.close()
640+
return
641+
642+
if self._keyspace:
643+
replacement.set_keyspace_blocking(self._keyspace)
644+
645+
with self._lock:
646+
if self.is_shutdown:
647+
# shutdown() cleared the pending list and owns closing
648+
# the replacement.
649+
return
650+
else:
651+
self._pending_connections.remove(replacement)
652+
replacement_is_pending = False
653+
self._connections[replacement.features.shard_id] = replacement
654+
replace_finished = True
655+
except Exception:
656+
shutdown_owns_replacement = False
657+
with self._lock:
658+
if replacement_is_pending:
659+
try:
660+
self._pending_connections.remove(replacement)
661+
except ValueError:
662+
# shutdown() already drained and will close it.
663+
shutdown_owns_replacement = self.is_shutdown
664+
is_shutdown = self.is_shutdown
665+
666+
if replacement is not None and not shutdown_owns_replacement:
667+
replacement.close()
668+
669+
log.warning("Failed reconnecting %s. Retrying." % (self.host.endpoint,))
670+
if is_shutdown:
671+
return
672+
self._session.submit(self._replace, connection)
673+
else:
674+
if replace_finished:
675+
with self._lock:
676+
if self.is_shutdown:
677+
return
678+
self._is_replacing = False
679+
with self._stream_available_condition:
680+
self._stream_available_condition.notify()
620681

621682
def shutdown(self):
622683
log.debug("Shutting down connections to %s", self.host)
@@ -725,15 +786,21 @@ def _open_connection_to_missing_shard(self, shard_id):
725786
log.debug("shard_aware_endpoint=%r", shard_aware_endpoint)
726787
if shard_aware_endpoint:
727788
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)
789+
conn = self._session.cluster.connection_factory(
790+
shard_aware_endpoint,
791+
host_conn=self,
792+
on_orphaned_stream_released=self.on_orphaned_stream_released,
793+
shard_id=shard_id,
794+
total_shards=self.host.sharding_info.shards_count)
731795
conn.original_endpoint = self.host.endpoint
732796
except Exception as exc:
733797
log.error("Failed to open connection to %s, on shard_id=%i: %s", self.host, shard_id, exc)
734798
raise
735799
else:
736-
conn = self._session.cluster.connection_factory(self.host.endpoint, host_conn=self, on_orphaned_stream_released=self.on_orphaned_stream_released)
800+
conn = self._session.cluster.connection_factory(
801+
self.host.endpoint,
802+
host_conn=self,
803+
on_orphaned_stream_released=self.on_orphaned_stream_released)
737804

738805
log.debug(
739806
"Received a connection %s for shard_id=%i on host %s",

0 commit comments

Comments
 (0)