Skip to content

Commit 78aa667

Browse files
committed
PYTHON-1934 retryWrites=True with MMAPv1 storage engine should raise an actionable error
1 parent e6eecb0 commit 78aa667

File tree

9 files changed

+88
-10
lines changed

9 files changed

+88
-10
lines changed

.evergreen/config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,7 +1471,9 @@ buildvariants:
14711471
then:
14721472
add_tasks:
14731473
- "test-4.0-standalone"
1474+
- "test-4.0-replica_set"
14741475
- "test-3.6-standalone"
1476+
- "test-3.6-replica_set"
14751477
- "test-3.4-standalone"
14761478
- "test-3.2-standalone"
14771479
- if:

doc/changelog.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ Version 3.9 adds support for MongoDB 4.2. Highlights include:
6868
- Connections now survive primary step-down. Applications should expect less
6969
socket connection turnover during replica set elections.
7070

71+
Unavoidable breaking changes:
72+
73+
- Applications that use MongoDB with the MMAPv1 storage engine must now
74+
explicitly disable retryable writes via the connection string
75+
(e.g. ``MongoClient("mongodb://my.mongodb.cluster/db?retryWrites=false")``) or
76+
the :class:`~pymongo.mongo_client.MongoClient` constructor's keyword argument
77+
(e.g. ``MongoClient("mongodb://my.mongodb.cluster/db", retryWrites=False)``)
78+
to avoid running into :class:`~pymongo.errors.OperationFailure` exceptions
79+
during write operations. The MMAPv1 storage engine is deprecated and does
80+
not support retryable writes which are now turned on by default.
81+
7182
.. _URI options specification: https://github.com/mongodb/specifications/blob/master/source/uri-options/uri-options.rst
7283

7384

pymongo/mongo_client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,6 +1397,14 @@ def is_retrying():
13971397
retrying = True
13981398
last_error = exc
13991399
except OperationFailure as exc:
1400+
# retryWrites on MMAPv1 should raise an actionable error.
1401+
if (exc.code == 20 and
1402+
str(exc).startswith("Transaction numbers")):
1403+
errmsg = (
1404+
"This MongoDB deployment does not support "
1405+
"retryable writes. Please add retryWrites=false "
1406+
"to your connection string.")
1407+
raise OperationFailure(errmsg, exc.code, exc.details)
14001408
if not retryable or is_retrying():
14011409
raise
14021410
if exc.code not in helpers._RETRYABLE_ERROR_CODES:

test/__init__.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ def _init_client(self):
256256
self.cmd_line = self.client.admin.command('getCmdLineOpts')
257257

258258
self.server_status = self.client.admin.command('serverStatus')
259+
if self.storage_engine == "mmapv1":
260+
# MMAPv1 does not support retryWrites=True.
261+
self.default_client_options['retryWrites'] = False
262+
259263
ismaster = self.ismaster
260264
self.sessions_enabled = 'logicalSessionTimeoutMinutes' in ismaster
261265

@@ -349,6 +353,14 @@ def has_secondaries(self):
349353
return False
350354
return bool(len(self.client.secondaries))
351355

356+
@property
357+
def storage_engine(self):
358+
try:
359+
return self.server_status.get("storageEngine", {}).get("name")
360+
except AttributeError:
361+
# Raised if self.server_status is None.
362+
return None
363+
352364
def _check_user_provided(self):
353365
"""Return True if db_user/db_password is already an admin user."""
354366
client = pymongo.MongoClient(
@@ -449,14 +461,7 @@ def require_no_mmap(self, func):
449461
def is_not_mmap():
450462
if self.is_mongos:
451463
return True
452-
try:
453-
storage_engine = self.server_status.get(
454-
'storageEngine').get('name')
455-
except AttributeError:
456-
# Raised if the storageEngine key does not exist or if
457-
# self.server_status is None.
458-
return False
459-
return storage_engine != 'mmapv1'
464+
return self.storage_engine != 'mmapv1'
460465

461466
return self._require(
462467
is_not_mmap, "Storage engine must not be MMAPv1", func=func)
@@ -618,11 +623,15 @@ def require_sessions(self, func):
618623
func=func)
619624

620625
def supports_transactions(self):
626+
if self.storage_engine == 'mmapv1':
627+
return False
628+
621629
if self.version.at_least(4, 1, 8):
622630
return self.is_mongos or self.is_rs
623631

624632
if self.version.at_least(4, 0):
625633
return self.is_rs
634+
626635
return False
627636

628637
def require_transactions(self, func):

test/test_examples.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@ def test_delete(self):
663663

664664
@client_context.require_version_min(3, 5, 11)
665665
@client_context.require_replica_set
666+
@client_context.require_no_mmap
666667
def test_change_streams(self):
667668
db = client_context.client.pymongo_test
668669
done = False
@@ -1117,6 +1118,7 @@ def shipment_transaction(session):
11171118
class TestCausalConsistencyExamples(IntegrationTest):
11181119
@client_context.require_version_min(3, 6, 0)
11191120
@client_context.require_secondaries_count(1)
1121+
@client_context.require_no_mmap
11201122
def test_causal_consistency(self):
11211123
# Causal consistency examples
11221124
client = self.client

test/test_retryable_reads.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class TestSpec(SpecRunner):
5454

5555
@classmethod
5656
@client_context.require_version_min(4, 0)
57+
# TODO: remove this once PYTHON-1948 is done.
58+
@client_context.require_no_mmap
5759
def setUpClass(cls):
5860
super(TestSpec, cls).setUpClass()
5961
if client_context.is_mongos and client_context.version[:2] <= (4, 0):
@@ -65,8 +67,13 @@ def maybe_skip_scenario(self, test):
6567
'listCollectionObjects', 'listIndexNames', 'listDatabaseObjects']
6668
for name in skip_names:
6769
if name.lower() in test['description'].lower():
68-
raise unittest.SkipTest(
69-
'PyMongo does not support %s' % (name,))
70+
self.skipTest('PyMongo does not support %s' % (name,))
71+
72+
# Skip changeStream related tests on MMAPv1.
73+
test_name = self.id().rsplit('.')[-1]
74+
if ('changestream' in test_name.lower() and
75+
client_context.storage_engine == 'mmapv1'):
76+
self.skipTest("MMAPv1 does not support change streams.")
7077

7178
def get_scenario_coll_name(self, scenario_def):
7279
"""Override a test's collection name to support GridFS tests."""

test/test_retryable_writes.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def run_test_ops(self, sessions, collection, test):
8080

8181
def create_test(scenario_def, test, name):
8282
@client_context.require_test_commands
83+
@client_context.require_no_mmap
8384
def run_scenario(self):
8485
self.run_scenario(scenario_def, test)
8586

@@ -176,9 +177,41 @@ def tearDownClass(cls):
176177
super(IgnoreDeprecationsTest, cls).tearDownClass()
177178

178179

180+
class TestRetryableWritesMMAPv1(IgnoreDeprecationsTest):
181+
182+
@classmethod
183+
def setUpClass(cls):
184+
super(TestRetryableWritesMMAPv1, cls).setUpClass()
185+
# Speed up the tests by decreasing the heartbeat frequency.
186+
cls.knobs = client_knobs(heartbeat_frequency=0.1,
187+
min_heartbeat_interval=0.1)
188+
cls.knobs.enable()
189+
cls.client = rs_or_single_client(retryWrites=True)
190+
cls.db = cls.client.pymongo_test
191+
192+
@classmethod
193+
def tearDownClass(cls):
194+
cls.knobs.disable()
195+
196+
@client_context.require_version_min(3, 5)
197+
@client_context.require_no_standalone
198+
def test_actionable_error_message(self):
199+
if client_context.storage_engine != 'mmapv1':
200+
raise SkipTest('This cluster is not running MMAPv1')
201+
202+
expected_msg = ("This MongoDB deployment does not support retryable "
203+
"writes. Please add retryWrites=false to your "
204+
"connection string.")
205+
for method, args, kwargs in retryable_single_statement_ops(
206+
self.db.retryable_write_test):
207+
with self.assertRaisesRegex(OperationFailure, expected_msg):
208+
method(*args, **kwargs)
209+
210+
179211
class TestRetryableWrites(IgnoreDeprecationsTest):
180212

181213
@classmethod
214+
@client_context.require_no_mmap
182215
def setUpClass(cls):
183216
super(TestRetryableWrites, cls).setUpClass()
184217
# Speed up the tests by decreasing the heartbeat frequency.
@@ -429,6 +462,7 @@ def test_batch_splitting_retry_fails(self):
429462
class TestRetryableWritesTxnNumber(IgnoreDeprecationsTest):
430463
@client_context.require_version_min(3, 6)
431464
@client_context.require_replica_set
465+
@client_context.require_no_mmap
432466
def test_increment_transaction_id_without_sending_command(self):
433467
"""Test that the txnNumber field is properly incremented, even when
434468
the first attempt fails before sending the command.

test/test_session.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,7 @@ def test_server_not_causal(self):
10571057
self.assertIsNone(act)
10581058

10591059
@client_context.require_no_standalone
1060+
@client_context.require_no_mmap
10601061
def test_read_concern(self):
10611062
with self.client.start_session(causal_consistency=True) as s:
10621063
coll = self.client.pymongo_test.test

test/utils_spec_runner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,10 @@ def run_scenario(self, scenario_def, test):
467467
listener = OvertCommandListener()
468468
# Create a new client, to avoid interference from pooled sessions.
469469
client_options = self.parse_client_options(test['clientOptions'])
470+
# MMAPv1 does not support retryable writes.
471+
if (client_options.get('retryWrites') is True and
472+
client_context.storage_engine == 'mmapv1'):
473+
self.skipTest("MMAPv1 does not support retryWrites=True")
470474
use_multi_mongos = test['useMultipleMongoses']
471475
if client_context.is_mongos and use_multi_mongos:
472476
client = rs_client(client_context.mongos_seeds(),

0 commit comments

Comments
 (0)