Skip to content

Commit 0b2900d

Browse files
authored
PYTHON-5413 Handle flaky tests (#2395)
1 parent 578c6c2 commit 0b2900d

26 files changed

+305
-119
lines changed

.evergreen/generated_configs/functions.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ functions:
145145
- MONGODB_API_VERSION
146146
- REQUIRE_API_VERSION
147147
- DEBUG_LOG
148+
- DISABLE_FLAKY
148149
- ORCHESTRATION_FILE
149150
- OCSP_SERVER_TYPE
150151
- VERSION

.evergreen/scripts/generate_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,7 @@ def create_run_tests_func():
10841084
"MONGODB_API_VERSION",
10851085
"REQUIRE_API_VERSION",
10861086
"DEBUG_LOG",
1087+
"DISABLE_FLAKY",
10871088
"ORCHESTRATION_FILE",
10881089
"OCSP_SERVER_TYPE",
10891090
"VERSION",

.evergreen/scripts/setup_tests.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,6 @@ def handle_test_env() -> None:
162162
write_env("PIP_PREFER_BINARY") # Prefer binary dists by default.
163163
write_env("UV_FROZEN") # Do not modify lock files.
164164

165-
# Skip CSOT tests on non-linux platforms.
166-
if PLATFORM != "linux":
167-
write_env("SKIP_CSOT_TESTS")
168-
169165
# Set an environment variable for the test name and sub test name.
170166
write_env(f"TEST_{test_name.upper()}")
171167
write_env("TEST_NAME", test_name)

CONTRIBUTING.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,15 @@ If you are running one of the `no-responder` tests, omit the `run-server` step.
404404
- Regenerate the test variants and tasks using `pre-commit run --all-files generate-config`.
405405
- Make sure to add instructions for running the test suite to `CONTRIBUTING.md`.
406406

407+
## Handling flaky tests
408+
409+
We have a custom `flaky` decorator in [test/asynchronous/utils.py](test/asynchronous/utils.py) that can be used for
410+
tests that are `flaky`. By default the decorator only applies when not running on CPython on Linux, since other
411+
runtimes tend to have more variation. When using the `flaky` decorator, open a corresponding ticket and
412+
a use the ticket number as the "reason" parameter to the decorator, e.g. `@flaky(reason="PYTHON-1234")`.
413+
When running tests locally (not in CI), the `flaky` decorator will be disabled unless `ENABLE_FLAKY` is set.
414+
To disable the `flaky` decorator in CI, you can use `evergreen patch --param DISABLE_FLAKY=1`.
415+
407416
## Specification Tests
408417

409418
The MongoDB [specifications repository](https://github.com/mongodb/specifications)

test/__init__.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import warnings
3333
from asyncio import iscoroutinefunction
3434

35+
from pymongo.errors import AutoReconnect
3536
from pymongo.synchronous.uri_parser import parse_uri
3637

3738
try:
@@ -1219,12 +1220,17 @@ def teardown():
12191220
c = client_context.client
12201221
if c:
12211222
if not client_context.is_data_lake:
1222-
c.drop_database("pymongo-pooling-tests")
1223-
c.drop_database("pymongo_test")
1224-
c.drop_database("pymongo_test1")
1225-
c.drop_database("pymongo_test2")
1226-
c.drop_database("pymongo_test_mike")
1227-
c.drop_database("pymongo_test_bernie")
1223+
try:
1224+
c.drop_database("pymongo-pooling-tests")
1225+
c.drop_database("pymongo_test")
1226+
c.drop_database("pymongo_test1")
1227+
c.drop_database("pymongo_test2")
1228+
c.drop_database("pymongo_test_mike")
1229+
c.drop_database("pymongo_test_bernie")
1230+
except AutoReconnect:
1231+
# PYTHON-4982
1232+
if sys.implementation.name.lower() != "pypy":
1233+
raise
12281234
c.close()
12291235
print_running_clients()
12301236

test/asynchronous/__init__.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from asyncio import iscoroutinefunction
3434

3535
from pymongo.asynchronous.uri_parser import parse_uri
36+
from pymongo.errors import AutoReconnect
3637

3738
try:
3839
import ipaddress
@@ -1235,12 +1236,17 @@ async def async_teardown():
12351236
c = async_client_context.client
12361237
if c:
12371238
if not async_client_context.is_data_lake:
1238-
await c.drop_database("pymongo-pooling-tests")
1239-
await c.drop_database("pymongo_test")
1240-
await c.drop_database("pymongo_test1")
1241-
await c.drop_database("pymongo_test2")
1242-
await c.drop_database("pymongo_test_mike")
1243-
await c.drop_database("pymongo_test_bernie")
1239+
try:
1240+
await c.drop_database("pymongo-pooling-tests")
1241+
await c.drop_database("pymongo_test")
1242+
await c.drop_database("pymongo_test1")
1243+
await c.drop_database("pymongo_test2")
1244+
await c.drop_database("pymongo_test_mike")
1245+
await c.drop_database("pymongo_test_bernie")
1246+
except AutoReconnect:
1247+
# PYTHON-4982
1248+
if sys.implementation.name.lower() != "pypy":
1249+
raise
12441250
await c.close()
12451251
print_running_clients()
12461252

test/asynchronous/test_client_bulk_write.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
async_client_context,
2626
unittest,
2727
)
28+
from test.asynchronous.utils import flaky
2829
from test.utils_shared import (
2930
OvertCommandListener,
3031
)
@@ -619,16 +620,17 @@ async def test_15_unacknowledged_write_across_batches(self):
619620
# https://github.com/mongodb/specifications/blob/master/source/client-side-operations-timeout/tests/README.md#11-multi-batch-bulkwrites
620621
class TestClientBulkWriteCSOT(AsyncIntegrationTest):
621622
async def asyncSetUp(self):
622-
if os.environ.get("SKIP_CSOT_TESTS", ""):
623-
raise unittest.SkipTest("SKIP_CSOT_TESTS is set, skipping...")
624623
await super().asyncSetUp()
625624
self.max_write_batch_size = await async_client_context.max_write_batch_size
626625
self.max_bson_object_size = await async_client_context.max_bson_size
627626
self.max_message_size_bytes = await async_client_context.max_message_size_bytes
628627

629628
@async_client_context.require_version_min(8, 0, 0, -24)
630629
@async_client_context.require_failCommand_fail_point
630+
@flaky(reason="PYTHON-5290", max_runs=3, affects_cpython_linux=True)
631631
async def test_timeout_in_multi_batch_bulk_write(self):
632+
if sys.platform != "linux" and "CI" in os.environ:
633+
self.skipTest("PYTHON-3522 CSOT test runs too slow on Windows and MacOS")
632634
_OVERHEAD = 500
633635

634636
internal_client = await self.async_rs_or_single_client(timeoutMS=None)

test/asynchronous/test_csot.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from test.asynchronous import AsyncIntegrationTest, async_client_context, unittest
2525
from test.asynchronous.unified_format import generate_test_classes
26+
from test.asynchronous.utils import flaky
2627

2728
import pymongo
2829
from pymongo import _csot
@@ -43,9 +44,8 @@
4344
class TestCSOT(AsyncIntegrationTest):
4445
RUN_ON_LOAD_BALANCER = True
4546

47+
@flaky(reason="PYTHON-3522")
4648
async def test_timeout_nested(self):
47-
if os.environ.get("SKIP_CSOT_TESTS", ""):
48-
raise unittest.SkipTest("SKIP_CSOT_TESTS is set, skipping...")
4949
coll = self.db.coll
5050
self.assertEqual(_csot.get_timeout(), None)
5151
self.assertEqual(_csot.get_deadline(), float("inf"))
@@ -82,9 +82,8 @@ async def test_timeout_nested(self):
8282
self.assertEqual(_csot.get_rtt(), 0.0)
8383

8484
@async_client_context.require_change_streams
85+
@flaky(reason="PYTHON-3522")
8586
async def test_change_stream_can_resume_after_timeouts(self):
86-
if os.environ.get("SKIP_CSOT_TESTS", ""):
87-
raise unittest.SkipTest("SKIP_CSOT_TESTS is set, skipping...")
8887
coll = self.db.test
8988
await coll.insert_one({})
9089
async with await coll.watch() as stream:

test/asynchronous/test_cursor.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
sys.path[0:0] = [""]
3232

3333
from test.asynchronous import AsyncIntegrationTest, async_client_context, unittest
34+
from test.asynchronous.utils import flaky
3435
from test.utils_shared import (
3536
AllowListEventListener,
3637
EventListener,
@@ -1406,9 +1407,8 @@ async def test_to_list_length(self):
14061407
docs = await c.to_list(3)
14071408
self.assertEqual(len(docs), 2)
14081409

1410+
@flaky(reason="PYTHON-3522")
14091411
async def test_to_list_csot_applied(self):
1410-
if os.environ.get("SKIP_CSOT_TESTS", ""):
1411-
raise unittest.SkipTest("SKIP_CSOT_TESTS is set, skipping...")
14121412
client = await self.async_single_client(timeoutMS=500, w=1)
14131413
coll = client.pymongo.test
14141414
# Initialize the client with a larger timeout to help make test less flakey
@@ -1449,9 +1449,8 @@ async def test_command_cursor_to_list_length(self):
14491449
self.assertEqual(len(await result.to_list(1)), 1)
14501450

14511451
@async_client_context.require_failCommand_blockConnection
1452+
@flaky(reason="PYTHON-3522")
14521453
async def test_command_cursor_to_list_csot_applied(self):
1453-
if os.environ.get("SKIP_CSOT_TESTS", ""):
1454-
raise unittest.SkipTest("SKIP_CSOT_TESTS is set, skipping...")
14551454
client = await self.async_single_client(timeoutMS=500, w=1)
14561455
coll = client.pymongo.test
14571456
# Initialize the client with a larger timeout to help make test less flakey

test/asynchronous/test_encryption.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import warnings
3333
from test.asynchronous import AsyncIntegrationTest, AsyncPyMongoTestCase, async_client_context
3434
from test.asynchronous.test_bulk import AsyncBulkTestBase
35+
from test.asynchronous.utils import flaky
3536
from test.asynchronous.utils_spec_runner import AsyncSpecRunner, AsyncSpecTestCreator
3637
from threading import Thread
3738
from typing import Any, Dict, Mapping, Optional
@@ -3247,6 +3248,7 @@ async def test_kms_retry(self):
32473248
class TestAutomaticDecryptionKeys(AsyncEncryptionIntegrationTest):
32483249
@async_client_context.require_no_standalone
32493250
@async_client_context.require_version_min(7, 0, -1)
3251+
@flaky(reason="PYTHON-4982")
32503252
async def asyncSetUp(self):
32513253
await super().asyncSetUp()
32523254
self.key1_document = json_data("etc", "data", "keys", "key1-document.json")
@@ -3489,6 +3491,8 @@ async def test_implicit_session_ignored_when_unsupported(self):
34893491

34903492
self.assertNotIn("lsid", self.listener.started_events[1].command)
34913493

3494+
await self.mongocryptd_client.close()
3495+
34923496
async def test_explicit_session_errors_when_unsupported(self):
34933497
self.listener.reset()
34943498
async with self.mongocryptd_client.start_session() as s:
@@ -3501,6 +3505,8 @@ async def test_explicit_session_errors_when_unsupported(self):
35013505
):
35023506
await self.mongocryptd_client.db.test.insert_one({"x": 1}, session=s)
35033507

3508+
await self.mongocryptd_client.close()
3509+
35043510

35053511
if __name__ == "__main__":
35063512
unittest.main()

test/asynchronous/test_retryable_writes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import pprint
2121
import sys
2222
import threading
23-
from test.asynchronous.utils import async_set_fail_point
23+
from test.asynchronous.utils import async_set_fail_point, flaky
2424

2525
sys.path[0:0] = [""]
2626

@@ -466,6 +466,7 @@ class TestPoolPausedError(AsyncIntegrationTest):
466466
@async_client_context.require_failCommand_blockConnection
467467
@async_client_context.require_retryable_writes
468468
@client_knobs(heartbeat_frequency=0.05, min_heartbeat_interval=0.05)
469+
@flaky(reason="PYTHON-5291")
469470
async def test_pool_paused_error_is_retryable(self):
470471
cmap_listener = CMAPListener()
471472
cmd_listener = OvertCommandListener()

test/asynchronous/test_server_selection_in_window.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from pathlib import Path
2222
from test.asynchronous import AsyncIntegrationTest, async_client_context, unittest
2323
from test.asynchronous.helpers import ConcurrentRunner
24+
from test.asynchronous.utils import flaky
2425
from test.asynchronous.utils_selection_tests import create_topology
2526
from test.asynchronous.utils_spec_runner import AsyncSpecTestCreator
2627
from test.utils_shared import (
@@ -137,6 +138,7 @@ async def frequencies(self, client, listener, n_finds=10):
137138

138139
@async_client_context.require_failCommand_appName
139140
@async_client_context.require_multiple_mongoses
141+
@flaky(reason="PYTHON-3689")
140142
async def test_load_balancing(self):
141143
listener = OvertCommandListener()
142144
cmap_listener = CMAPListener()

test/asynchronous/test_srv_polling.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import asyncio
1919
import sys
2020
import time
21+
from test.asynchronous.utils import flaky
2122
from test.utils_shared import FunctionCallRecorder
2223
from typing import Any
2324

@@ -254,6 +255,7 @@ def final_callback():
254255
# Nodelist should reflect new valid DNS resolver response.
255256
await self.assert_nodelist_change(response_final, client)
256257

258+
@flaky(reason="PYTHON-5315")
257259
async def test_recover_from_initially_empty_seedlist(self):
258260
def empty_seedlist():
259261
return []

test/asynchronous/unified_format.py

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,11 @@
3535
client_knobs,
3636
unittest,
3737
)
38-
from test.asynchronous.utils import async_get_pool
38+
from test.asynchronous.utils import async_get_pool, flaky
3939
from test.asynchronous.utils_spec_runner import SpecRunnerTask
4040
from test.unified_format_shared import (
4141
KMS_TLS_OPTS,
4242
PLACEHOLDER_MAP,
43-
SKIP_CSOT_TESTS,
4443
EventListenerUtil,
4544
MatchEvaluatorUtil,
4645
coerce_result,
@@ -519,20 +518,38 @@ def maybe_skip_test(self, spec):
519518
self.skipTest("Implement PYTHON-1894")
520519
if "timeoutMS applied to entire download" in spec["description"]:
521520
self.skipTest("PyMongo's open_download_stream does not cap the stream's lifetime")
522-
if (
523-
"Error returned from connection pool clear with interruptInUseConnections=true is retryable"
524-
in spec["description"]
525-
and not _IS_SYNC
526-
):
527-
self.skipTest("PYTHON-5170 tests are flakey")
528-
if "Driver extends timeout while streaming" in spec["description"] and not _IS_SYNC:
529-
self.skipTest("PYTHON-5174 tests are flakey")
530521

531522
class_name = self.__class__.__name__.lower()
532523
description = spec["description"].lower()
533524
if "csot" in class_name:
534-
if "gridfs" in class_name and sys.platform == "win32":
535-
self.skipTest("PYTHON-3522 CSOT GridFS tests are flaky on Windows")
525+
# Skip tests that are too slow to run on a given platform.
526+
slow_macos = [
527+
"operation fails after two consecutive socket timeouts.*",
528+
"operation succeeds after one socket timeout.*",
529+
"Non-tailable cursor lifetime remaining timeoutMS applied to getMore if timeoutMode is unset",
530+
]
531+
slow_win32 = [
532+
*slow_macos,
533+
"maxTimeMS value in the command is less than timeoutMS",
534+
"timeoutMS applies to whole operation.*",
535+
]
536+
slow_pypy = [
537+
"timeoutMS applies to whole operation.*",
538+
]
539+
if "CI" in os.environ and sys.platform == "win32" and "gridfs" in class_name:
540+
self.skipTest("PYTHON-3522 CSOT GridFS test runs too slow on Windows")
541+
if "CI" in os.environ and sys.platform == "win32":
542+
for pat in slow_win32:
543+
if re.match(pat.lower(), description):
544+
self.skipTest("PYTHON-3522 CSOT test runs too slow on Windows")
545+
if "CI" in os.environ and sys.platform == "darwin":
546+
for pat in slow_macos:
547+
if re.match(pat.lower(), description):
548+
self.skipTest("PYTHON-3522 CSOT test runs too slow on MacOS")
549+
if "CI" in os.environ and sys.implementation.name.lower() == "pypy":
550+
for pat in slow_pypy:
551+
if re.match(pat.lower(), description):
552+
self.skipTest("PYTHON-3522 CSOT test runs too slow on PyPy")
536553
if "change" in description or "change" in class_name:
537554
self.skipTest("CSOT not implemented for watch()")
538555
if "cursors" in class_name:
@@ -1353,38 +1370,31 @@ async def verify_outcome(self, spec):
13531370
self.assertListEqual(sorted_expected_documents, actual_documents)
13541371

13551372
async def run_scenario(self, spec, uri=None):
1356-
if "csot" in self.id().lower() and SKIP_CSOT_TESTS:
1357-
raise unittest.SkipTest("SKIP_CSOT_TESTS is set, skipping...")
1358-
13591373
# Kill all sessions before and after each test to prevent an open
13601374
# transaction (from a test failure) from blocking collection/database
13611375
# operations during test set up and tear down.
13621376
await self.kill_all_sessions()
13631377

1364-
if "csot" in self.id().lower():
1365-
# Retry CSOT tests up to 2 times to deal with flakey tests.
1366-
attempts = 3
1367-
for i in range(attempts):
1368-
try:
1369-
return await self._run_scenario(spec, uri)
1370-
except (AssertionError, OperationFailure) as exc:
1371-
if isinstance(exc, OperationFailure) and (
1372-
_IS_SYNC or "failpoint" not in exc._message
1373-
):
1374-
raise
1375-
if i < attempts - 1:
1376-
print(
1377-
f"Retrying after attempt {i+1} of {self.id()} failed with:\n"
1378-
f"{traceback.format_exc()}",
1379-
file=sys.stderr,
1380-
)
1381-
await self.asyncSetUp()
1382-
continue
1383-
raise
1384-
return None
1385-
else:
1386-
await self._run_scenario(spec, uri)
1387-
return None
1378+
# Handle flaky tests.
1379+
flaky_tests = [
1380+
("PYTHON-5170", ".*test_discovery_and_monitoring.*"),
1381+
("PYTHON-5174", ".*Driver_extends_timeout_while_streaming"),
1382+
("PYTHON-5315", ".*TestSrvPolling.test_recover_from_initially_.*"),
1383+
("PYTHON-4987", ".*UnknownTransactionCommitResult_labels_to_connection_errors"),
1384+
("PYTHON-3689", ".*TestProse.test_load_balancing"),
1385+
("PYTHON-3522", ".*csot.*"),
1386+
]
1387+
for reason, flaky_test in flaky_tests:
1388+
if re.match(flaky_test.lower(), self.id().lower()) is not None:
1389+
func_name = self.id()
1390+
options = dict(reason=reason, reset_func=self.asyncSetUp, func_name=func_name)
1391+
if "csot" in func_name.lower():
1392+
options["max_runs"] = 3
1393+
options["affects_cpython_linux"] = True
1394+
decorator = flaky(**options)
1395+
await decorator(self._run_scenario)(spec, uri)
1396+
return
1397+
await self._run_scenario(spec, uri)
13881398

13891399
async def _run_scenario(self, spec, uri=None):
13901400
# maybe skip test manually

0 commit comments

Comments
 (0)