Skip to content

Commit 941a247

Browse files
committed
PYCBC-1928: Hold the close paths to one connection lifecycle model
Changes -------- * Gate the lazily constructed Transactions property on the same two guards every other cluster operation uses, in both trees, so a closed or never-connected cluster no longer hands back a live Transactions owning cleanup threads and a client-record registration * Stop close waiting on a connection it has to start, in the async cluster and bucket paths both, and stop a failed connect or open coming back out of close(): wait only for one already in flight, so a connection that comes up late is still torn down, and treat its failure as nothing to tear down * Make bucket close idempotent in both trees, matching cluster close, which already returns rather than raising on a closed or never-connected cluster * Add lifecycle suites for both trees, needing no cluster, and rework the transactions race suite for the new guard without losing its PYCBC-1821 coverage: removing the double-checked lock still fails it Change-Id: I64bb70abb0fd56b859b3c98113e1ff129a51c2ac Reviewed-on: https://review.couchbase.org/c/couchbase-python-client/+/252028 Tested-by: Build Bot <build@couchbase.com> Reviewed-by: Sergey Avseyev <sergey.avseyev@gmail.com>
1 parent a0f406c commit 941a247

9 files changed

Lines changed: 315 additions & 42 deletions

acouchbase/logic/bucket_impl.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,12 @@ def observability_instruments(self) -> ObservabilityInstruments:
110110

111111
async def close_bucket(self) -> None:
112112
"""**INTERNAL**"""
113-
await self.wait_until_bucket_connected()
113+
try:
114+
await self.wait_until_bucket_connected()
115+
except Exception:
116+
# The bucket never opened, so there is nothing to tear down, and the caller cannot
117+
# act on an open error raised out of close.
118+
return
114119
await self._client_adapter.execute_close_bucket_request(self._bucket_name)
115120

116121
async def ping(self, req: PingRequest) -> PingResult:

acouchbase/logic/client_adapter.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,12 @@ def _errback(exc) -> None:
130130
return ft
131131

132132
def execute_close_bucket_request(self, bucket_name: str) -> Future[None]:
133-
req = CloseBucketRequest(bucket_name)
134-
return self.execute_bucket_request(req)
133+
if self._closed or not self.connected:
134+
# execute_bucket_request guards for operations, which close is not
135+
ft = self.loop.create_future()
136+
ft.set_result(None)
137+
return ft
138+
return self.execute_bucket_request(CloseBucketRequest(bucket_name))
135139

136140
def execute_cluster_request(self, req: ClusterRequest) -> Future[Any]:
137141
self._ensure_not_closed()
@@ -299,15 +303,27 @@ def _errback(ret: Any) -> None:
299303

300304
req_dict = req.req_to_dict(callback=_callback, errback=_errback)
301305
if not self.connected:
302-
# If we're closed, don't try to reconnect just to close again
303-
if self._closed:
306+
if self._connect_ft is None:
307+
# Never attempted, so there is nothing to tear down. Starting a connection in
308+
# order to close it is the one thing close must not do.
309+
self._reset_connection()
304310
ft.set_result(None)
305311
return ft
306312

307-
chained_ft = self._execute_connect_request() if self._connect_ft is None else self._connect_ft
308-
chained_ft.add_done_callback(partial(self._execute_chained_req, ft, req.op_name, req_dict))
309-
else:
310-
self._execute_req(ft, req.op_name, req_dict)
313+
# A connect is already in flight. Wait for it, so a connection that comes up after
314+
# this call is still torn down, but never let its failure become a close failure:
315+
# nothing came up, and the caller cannot act on a connect error raised out of close.
316+
def _close_once_connected(chained_ft: Future[None]) -> None:
317+
if chained_ft.cancelled() or chained_ft.exception() is not None:
318+
self._reset_connection()
319+
ft.set_result(None)
320+
return
321+
self._execute_req(ft, req.op_name, req_dict)
322+
323+
self._connect_ft.add_done_callback(_close_once_connected)
324+
return ft
325+
326+
self._execute_req(ft, req.op_name, req_dict)
311327
return ft
312328

313329
def _execute_connect_request(self) -> Future[None]:

acouchbase/logic/cluster_impl.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ def transactions(self) -> Transactions:
160160
# Transactions.__init__ goes straight into the synchronous create_transactions (no executor
161161
# anywhere in the async path), which releases the GIL for a full cluster round-trip.
162162
# Use threading.Lock, not asyncio.Lock, for the same reason.
163+
self._client_adapter._ensure_not_closed()
164+
self._client_adapter._ensure_connected()
163165
if self._transactions is None:
164166
with self._transactions_lock:
165167
if self._transactions is None:
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright 2016-2026. Couchbase, Inc.
2+
# All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License")
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""
17+
The async close paths, held to the same lifecycle model as the blocking API: an operation
18+
raises on a cluster that never connected and on one that is closed, and a close is
19+
idempotent and never starts a connection in order to tear one down.
20+
21+
The async tree makes the last of those easy to get wrong, because a connect is normally
22+
already in flight by the time close is called, so close has a future to chain onto.
23+
Chaining is right only while that connect may still succeed; a connect that failed leaves
24+
nothing to close, and its error must not come back out of close().
25+
26+
No live cluster is needed. Every cluster here is built with
27+
skip_connect='TEST_SKIP_CONNECT', so no connect is ever started and the cluster sits in
28+
the never-connected state.
29+
"""
30+
31+
import pytest
32+
33+
from acouchbase.logic.cluster_impl import AsyncClusterImpl
34+
from couchbase.auth import PasswordAuthenticator
35+
from couchbase.options import ClusterOptions
36+
37+
BUCKET_NAME = 'default'
38+
39+
40+
class AsyncConnectionLifecycleTestSuite:
41+
TEST_MANIFEST = [
42+
'test_close_bucket_after_cluster_close_is_a_noop',
43+
'test_close_bucket_when_never_connected_is_a_noop',
44+
'test_close_connection_is_idempotent',
45+
'test_close_connection_when_never_connected_does_not_connect',
46+
'test_close_does_not_surface_a_failed_connect',
47+
'test_transactions_raises_when_never_connected',
48+
]
49+
50+
@pytest.fixture(name='unconnected_cluster')
51+
def cluster_without_core_connection(self, couchbase_config):
52+
conn_string = couchbase_config.get_connection_string()
53+
username, pw = couchbase_config.get_username_and_pw()
54+
return AsyncClusterImpl(conn_string,
55+
ClusterOptions(PasswordAuthenticator(username, pw)),
56+
skip_connect='TEST_SKIP_CONNECT')
57+
58+
@pytest.mark.asyncio
59+
async def test_close_connection_when_never_connected_does_not_connect(self, unconnected_cluster):
60+
adapter = unconnected_cluster.client_adapter
61+
adapter._execute_connect_request = _fail_if_called
62+
63+
await adapter.close_connection()
64+
65+
assert adapter._closed is True
66+
67+
@pytest.mark.asyncio
68+
async def test_close_connection_is_idempotent(self, unconnected_cluster):
69+
adapter = unconnected_cluster.client_adapter
70+
adapter._execute_connect_request = _fail_if_called
71+
72+
await adapter.close_connection()
73+
await adapter.close_connection()
74+
75+
assert adapter._closed is True
76+
77+
@pytest.mark.asyncio
78+
async def test_close_does_not_surface_a_failed_connect(self, unconnected_cluster):
79+
adapter = unconnected_cluster.client_adapter
80+
failed = adapter.loop.create_future()
81+
failed.set_exception(RuntimeError('connect failed'))
82+
adapter._connect_ft = failed
83+
84+
await adapter.close_connection()
85+
86+
assert adapter._closed is True
87+
88+
@pytest.mark.asyncio
89+
async def test_close_bucket_when_never_connected_is_a_noop(self, unconnected_cluster):
90+
await unconnected_cluster.client_adapter.execute_close_bucket_request(BUCKET_NAME)
91+
92+
@pytest.mark.asyncio
93+
async def test_close_bucket_after_cluster_close_is_a_noop(self, unconnected_cluster):
94+
adapter = unconnected_cluster.client_adapter
95+
adapter._execute_connect_request = _fail_if_called
96+
await adapter.close_connection()
97+
98+
await adapter.execute_close_bucket_request(BUCKET_NAME)
99+
100+
@pytest.mark.asyncio
101+
async def test_transactions_raises_when_never_connected(self, unconnected_cluster):
102+
with pytest.raises(RuntimeError):
103+
unconnected_cluster.transactions
104+
105+
106+
def _fail_if_called(*args, **kwargs):
107+
raise AssertionError('close started a connection in order to tear one down')
108+
109+
110+
class ClassicAsyncConnectionLifecycleTests(AsyncConnectionLifecycleTestSuite):
111+
@pytest.fixture(scope='class', autouse=True)
112+
def validate_test_manifest(self):
113+
def valid_test_method(meth):
114+
attr = getattr(ClassicAsyncConnectionLifecycleTests, meth)
115+
return callable(attr) and not meth.startswith('__') and meth.startswith('test')
116+
method_list = [meth for meth in dir(ClassicAsyncConnectionLifecycleTests) if valid_test_method(meth)]
117+
manifest_invalid = set(AsyncConnectionLifecycleTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
118+
if manifest_invalid:
119+
pytest.fail(f'Test manifest not validated. Missing/extra tests: {manifest_invalid}.')

acouchbase/tests/transactions_property_race_t.py

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,20 @@
2525
cluster round-trip, so the racers here are OS threads, not tasks -- the guard
2626
is a threading.Lock for the same reason.
2727
28-
No live cluster is needed. The property never touches the network before
29-
constructing a Transactions object, so the cluster is built with
28+
No live cluster is needed. The cluster is built with
3029
skip_connect='TEST_SKIP_CONNECT' (same mechanism couchbase/tests/connection_t.py
3130
uses) and exercised purely at the Python layer, with Transactions swapped for a
3231
fake that holds the construction window open long enough to hit the race
33-
reliably.
32+
reliably. The property gates on the connection before it constructs anything,
33+
so the race fixtures stub that guard out; the guard itself is covered separately
34+
below.
3435
"""
3536

3637
import threading
3738

3839
import pytest
3940

4041
from acouchbase.cluster import AsyncCluster
41-
from acouchbase.logic.client_adapter import AsyncClientAdapter
4242
from couchbase.auth import PasswordAuthenticator
4343
from couchbase.options import ClusterOptions
4444

@@ -53,6 +53,8 @@ class TransactionsPropertyRaceTestSuite:
5353
TEST_MANIFEST = [
5454
'test_close_during_first_access_closes_instance',
5555
'test_concurrent_first_access_returns_single_instance',
56+
'test_transactions_raises_when_closed',
57+
'test_transactions_raises_when_not_connected',
5658
]
5759

5860
@pytest.fixture(name='tracking_transactions')
@@ -93,52 +95,57 @@ def cluster_without_core_connection(self, couchbase_config):
9395
ClusterOptions(PasswordAuthenticator(username, pw)),
9496
skip_connect='TEST_SKIP_CONNECT')
9597

96-
@pytest.fixture(name='stub_adapter_close')
97-
def stub_async_adapter_close(self, monkeypatch):
98-
"""Stub out the core half of close_connection().
98+
@pytest.fixture(name='race_cluster')
99+
def cluster_with_the_connection_guard_stubbed(self, unconnected_cluster, monkeypatch):
100+
"""The race is in the property's check-then-set, not in its guard."""
101+
monkeypatch.setattr(unconnected_cluster._impl._client_adapter, '_ensure_connected', lambda: None)
102+
return unconnected_cluster
99103

100-
When not connected, AsyncClientAdapter chains a connect request ahead of the
101-
close (client_adapter.py:301), so under skip_connect the close would establish
102-
the very connection this test skipped. The sync adapter early-outs instead and
103-
needs no stub, so this keeps both tests network-free and asserting only the
104-
lock ordering.
105-
"""
104+
@pytest.mark.asyncio
105+
async def test_transactions_raises_when_not_connected(self, tracking_transactions, unconnected_cluster):
106+
with pytest.raises(RuntimeError):
107+
unconnected_cluster.transactions
108+
109+
assert tracking_transactions.instances_created == 0
110+
111+
@pytest.mark.asyncio
112+
async def test_transactions_raises_when_closed(self, tracking_transactions, race_cluster):
113+
await race_cluster.close()
106114

107-
async def _noop(self):
108-
self._closed = True
115+
with pytest.raises(RuntimeError):
116+
race_cluster.transactions
109117

110-
monkeypatch.setattr(AsyncClientAdapter, 'close_connection', _noop)
118+
assert tracking_transactions.instances_created == 0
111119

112120
@pytest.mark.asyncio
113-
@pytest.mark.usefixtures('stub_adapter_close')
114-
async def test_close_during_first_access_closes_instance(self, tracking_transactions, unconnected_cluster):
121+
async def test_close_during_first_access_closes_instance(self, tracking_transactions, race_cluster):
115122
result = {}
116123

117124
def touch():
118125
# Off-loop: how the property stays reachable while close() runs on the loop.
119-
result['txns'] = unconnected_cluster.transactions
126+
result['txns'] = race_cluster.transactions
120127

121128
toucher = threading.Thread(target=touch)
122129
toucher.start()
123130
# Proceed only once the property is inside Transactions(), so close() races it.
124131
assert tracking_transactions.construction_started.wait(timeout=10), 'construction never started'
125-
await unconnected_cluster.close()
132+
await race_cluster.close()
126133
toucher.join(timeout=10)
127134
assert not toucher.is_alive(), 'thread failed to join, possible deadlock'
128135

129136
txns = result.get('txns')
130137
assert txns is not None
131138
assert tracking_transactions.instances_created == 1
132139
assert txns.close_count == 1, 'the instance published by the racing first access was never closed'
133-
assert unconnected_cluster._impl._transactions is None, 'a live Transactions outlived cluster.close()'
140+
assert race_cluster._impl._transactions is None, 'a live Transactions outlived cluster.close()'
134141

135-
def test_concurrent_first_access_returns_single_instance(self, tracking_transactions, unconnected_cluster):
142+
def test_concurrent_first_access_returns_single_instance(self, tracking_transactions, race_cluster):
136143
barrier = threading.Barrier(NUM_THREADS)
137144
results = [None] * NUM_THREADS
138145

139146
def touch(idx):
140147
barrier.wait()
141-
results[idx] = unconnected_cluster.transactions
148+
results[idx] = race_cluster.transactions
142149

143150
threads = [threading.Thread(target=touch, args=(i,)) for i in range(NUM_THREADS)]
144151
for t in threads:

couchbase/logic/client_adapter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ def _ensure_connected(self) -> None:
7878

7979
def close_bucket(self, bucket_name: str) -> None:
8080
"""**INTERNAL**"""
81-
self._ensure_not_closed()
82-
self._ensure_connected()
81+
if self._closed or not self.connected:
82+
return # nothing was opened, so there is nothing to tear down
8383
self.execute_bucket_request(CloseBucketRequest(bucket_name))
8484

8585
def close_connection(self) -> None:

couchbase/logic/cluster_impl.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ def observability_instruments(self) -> ObservabilityInstruments:
162162
@property
163163
def transactions(self) -> Transactions:
164164
"""**INTERNAL**"""
165+
self._client_adapter._ensure_not_closed()
166+
self._client_adapter._ensure_connected()
165167
if self._transactions is None:
166168
with self._transactions_lock:
167169
if self._transactions is None:

0 commit comments

Comments
 (0)