Skip to content

PYTHON-5071 Use one event loop for all asyncio tests #2086

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 30, 2025

Conversation

ShaneHarvey
Copy link
Member

PYTHON-5071 Use one event loop for all asyncio tests

@NoahStapp
Copy link
Contributor

The PyPy variant failure looks like the same asyncio SSL bug we've been tracking in pypy/pypy#5131. The other failures are all test.asynchronous.test_client.TestClient.test_continuous_network_errors, but it's not clear why this change would cause that failure.

@ShaneHarvey
Copy link
Member Author

I suspect the test_continuous_network_errors errors are a side effect of having too many unopened clients or perhaps too many unawaited SDAM tasks. I removed the logic to reinit the client_context on each test. Let's see if that fixes it.

@ShaneHarvey
Copy link
Member Author

I also fixed an issue in loop management which was causing some tests to encounter RuntimeError: <asyncio.locks.Condition object at 0x1095c2250 [locked]> is bound to a different event loop in asyncTearDown. The fix was make the loop initialization lazy sot hat it gets created on the first async test, instead of in __init__ which gets called at when collecting the tests.

@ShaneHarvey
Copy link
Member Author

ShaneHarvey commented Jan 29, 2025

My latest commit fixes this pytest-asyncio error when tearing down the async test suite:

ERROR test/test_write_concern.py::TestWriteConcern::test_invalid - DeprecationWarning: pytest-asyncio detected an unclosed event loop when tearing down the event_loop
fixture: <_UnixSelectorEventLoop running=False closed=False debug=False>
pytest-asyncio will close the event loop for you, but future versions of the
library will no longer do so. In order to ensure compatibility with future
versions, please make sure that:
    1. Any custom "event_loop" fixture properly closes the loop after yielding it
    2. The scopes of your custom "event_loop" fixtures do not overlap
    3. Your code does not modify the event loop in async fixtures or tests

To fix it, we now use scope=session in conftest and bind to pytest's global event loop. This way pytest manages creating and destroying the loop.

@ShaneHarvey ShaneHarvey marked this pull request as ready for review January 29, 2025 20:00
@ShaneHarvey
Copy link
Member Author

I went back to package scope because I believe that's actually what we want. With "session" scope, the async teardown doesn't happen until after all the sync tests run which causes problems because the client has been paused that whole time. This actually highlights a problem with async MongoClient, if the loop is paused for a while and then resumed the client's SDAM state will be out of date. This is similar to the sigstop/sigcont behavior.

async def async_setup():
if not _IS_SYNC:
global LOOP
LOOP = asyncio.get_running_loop()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this supposed to be get_loop instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is the correct method to call within an async function.

Copy link
Contributor

@NoahStapp NoahStapp Jan 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I think we still have a loop mismatch here, although I might be misunderstanding: this will use the loop pytest creates for our package-scoped test_setup_and_teardown fixture. Then, our first async testcase will create its own separate loop that will be used for each test. So our async_client_context will be on one pytest-managed loop, and all of our test cases will be on one self-managed loop created by get_loop.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our pyproject.toml also sets the default loop_scope to be session instead of package FYI.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either one is fine because get_loop calls get_running_loop first but I updated to use get_loop to be more consistent.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Attempting to use "package" in pyproject.toml results in this error:

____________________________________________________ ERROR at setup of TestCommon.test_mongo_client ____________________________________________________
file /Users/shane/git/mongo-python-driver/test/asynchronous/test_common.py, line 135
      async def test_mongo_client(self):
          pair = await async_client_context.pair
          m = await self.async_rs_or_single_client(w=0)
          coll = m.pymongo_test.write_concern_test
          await coll.drop()
          doc = {"_id": ObjectId()}
          await coll.insert_one(doc)
          self.assertTrue(await coll.insert_one(doc))
          coll = coll.with_options(write_concern=WriteConcern(w=1))
          with self.assertRaises(OperationFailure):
              await coll.insert_one(doc)

          m = await self.async_rs_or_single_client()
          coll = m.pymongo_test.write_concern_test
          new_coll = coll.with_options(write_concern=WriteConcern(w=0))
          self.assertTrue(await new_coll.insert_one(doc))
          with self.assertRaises(OperationFailure):
              await coll.insert_one(doc)

          m = await self.async_rs_or_single_client(
              f"mongodb://{pair}/", replicaSet=async_client_context.replica_set_name
          )

          coll = m.pymongo_test.write_concern_test
          with self.assertRaises(OperationFailure):
              await coll.insert_one(doc)
          m = await self.async_rs_or_single_client(
              f"mongodb://{pair}/?w=0", replicaSet=async_client_context.replica_set_name
          )

          coll = m.pymongo_test.write_concern_test
          await coll.insert_one(doc)

          # Equality tests
          direct = await connected(await self.async_single_client(w=0))
          direct2 = await connected(
              await self.async_single_client(f"mongodb://{pair}/?w=0", **self.credentials)
          )
          self.assertEqual(direct, direct2)
          self.assertFalse(direct != direct2)
file /Users/shane/git/mongo-python-driver/test/asynchronous/conftest.py, line 25
  @pytest_asyncio.fixture(scope="package", autouse=True)
  async def test_setup_and_teardown():
      await async_setup()
      yield
      await async_teardown()
E       fixture 'test/asynchronous::<event_loop>' not found
>       available fixtures: _session_event_loop, _unittest_setUpClass_fixture_TestCommon, anyio_backend, anyio_backend_name, anyio_backend_options, cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, event_loop, event_loop_policy, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, test/asynchronous/test_common.py::<event_loop>, test/asynchronous/test_common.py::TestCommon::<event_loop>, test_setup_and_teardown, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory, unused_tcp_port, unused_tcp_port_factory, unused_udp_port, unused_udp_port_factory
>       use 'pytest --fixtures [testpath]' for help on them.

"session" seems to be the right thing for what we want there.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened pytest-dev/pytest-asyncio#1052 about that exact error, it persists even if you explicitly mark a fixture as package.

With any pytest-asyncio scope, we'll still have two separate loops. That doesn't seem to be causing test failures, but it's something to be aware of for future debugging 😅

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks I subscribed to that issue. However I did verify that the current code only ever uses 1 loop for the whole async test suite. Printing the loop that pytest-asyncio creates shows this: event_loop_fixture_id='_session_event_loop' which means the asyncio_default_fixture_loop_scope="session" in pyproject is behaving as expected.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean that get_loop is picking up and using pytest's _session_event_loop? Or that that loop is separate from the loop the tests use that get_loop creates?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first one.

@ShaneHarvey ShaneHarvey requested a review from NoahStapp January 29, 2025 22:48
@ShaneHarvey
Copy link
Member Author

Ready for another look.

@ShaneHarvey ShaneHarvey merged commit 01f659c into mongodb:master Jan 30, 2025
44 of 46 checks passed
@ShaneHarvey ShaneHarvey deleted the PYTHON-5071 branch January 30, 2025 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants