-
Notifications
You must be signed in to change notification settings - Fork 262
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
fix: incorrect concurrent usage of connection and transaction #546
Conversation
Welcome and thanks for contributing! For some context, I'm a member of encode but not a maintainer of this project. Reading this out of curiosity and just have a couple notes about proper usage of the context var. Someone else with more context for the project will have to do a review of the changes too. |
Thanks for the review. I'm eager to move this forward. We use databases in one of our products, and when load testing this certainly fixes an issue with concurrency. Even your two comments were pretty insightful, so if you have another encode member who is interested in taking a look that would be great. I'll also try the module level context var solution you helped me discover in our codebase to see if that can still fix the issue. As is stands Databases was (and continues) leaking memory from it's usage of non-module level contextvars, but if I can fix that here too then that's great! |
Also related #155 |
Do you have a minimal executable example I could use to explore this? |
I think #155 should be close enough, but I'll try and get a dedicated MCVE up here today |
@madkinsz Here's a minimal example as a test - turns out I could get it pretty small. This fails on master, but passes perfectly fine here: # file: tests/test_concurrent_tasks.py
import pytest
import asyncio
from tests.test_databases import async_adapter, DATABASE_URLS
from databases import Database
@pytest.mark.parametrize("database_url", DATABASE_URLS)
@async_adapter
async def test_concurrent_tasks(database_url: str):
"""
Test concurrent tasks.
"""
async with Database(database_url) as database:
# This could be an eg, fastapi / starlette endpoint
@database.transaction()
async def read_items() -> dict:
return dict(await database.fetch_one(query="SELECT 1 AS value"))
# Run 10 concurrent **tasks**
tasks = (asyncio.create_task(read_items()) for _ in range(10))
responses = await asyncio.gather(*tasks, return_exceptions=True)
# Check the responses
assert all(isinstance(response, dict) for response in responses)
assert all(response["value"] == 1 for response in responses) I'm using
|
0c5e047
to
4cd7451
Compare
Rebased to sign commits, all new and old tests passing, switched to module level contextvars to allow for the garbage collector to clean up unreferenced objects. Edit: And now all linting checks pass too |
Hey! I took some time to poke around at this today. It seems a little wild to use a For example, I've implemented this at master...madkinsz:example/instance-safe and it passes the MRE you provided (I only checked SQLite because I didn't want to deal with docker-compose) Curious for your thoughts |
I like that take too. Looking back I was a little too committed to getting the I'll give it a go with the other databases right now, and in the project were we initially ran into this problem. |
…x-transaction-contextvar
Yeah, this seems great. I've adopted/merged your changes into this MR to accompany the new tests I'm contributing here. I kept a few of the assertions I had added when resolving conflicts - I saw a few of them in the existing code. Let me know what encode's stance is on Thanks for your help on this, I'm excited for sentry to finally stop notifying us about the errors from this issue! |
Glad you think it makes sense too! I think my last concern is the event-loop compatibility bit (e.g. trio). @tomchristie Do you think you'd be willing to give this a look soon or point me to a better reviewer? |
I don't think Databases supports other event loop implementations like trio, curio, etc. I see an old PR #357 that was working on adding anyio support, but it's in draft still. |
Oh and another thing I found just now with the current implementation - context variables undergo a shallow clone when context is copied for new tasks. That means that we had been undoing any isolation that context variables had been providing by mutating the WeakKeyDictionary if it had been created before any descendant tasks started. Simple fix though, just need to treat those dictionaries as immutable. |
Connections are once again stored as state on the Database instance, keyed by the current asyncio.Task. Each task acquires it's own connection, and a WeakKeyDictionary allows the connection to be discarded if the owning task is garbage collected. TransactionBackends are still stored as contextvars, and a connection must be explicitly provided to descendant tasks if active transaction state is to be inherited.
bc28059
to
b94f097
Compare
Anything more I can do for anyone here to get this reviewed and released? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this has landed in a good place. However, I am not a frequent maintainer of this repository and we'll need someone else to look it over before it can be merged.
Thanks for your patience! |
Thanks for the merge! Could we get @Kludex or @aminalaee to cut a new release so that we can actually have this bug corrected in our codebases? Is there a changelog or blog article I can help write for this? Is there a release cadence or policy that my team can plan from? |
@zevisert as far as I can tell, I can make a release. I do not think anyone else is available to maintain this project. I'd definitely appreciate if you opened a pull request for a changelog entry at https://github.com/encode/databases/blob/master/CHANGELOG.md There is no planned release cadence for this project. I'd like to release this change and some other fixes but there is little active development here. |
Note
EDIT: This PR contains some back and forth on how databases should handle concurrent connections across tasks. If you're looking into databases concurrency model, it's worth taking a read through all of our comments. The specific issue with the
@Database.transaction
decorator was fixed, but it required digging into and changing how connection state is tracked acrossasyncio.Tasks
usingasyncio.ContextVars
This commit should fix an issue with shared state with transactions.
The main issue is very well described by @circlingthesun here: #123 (comment) - we also ran into this most reliably using locust to load test as well, but did find this issue a few times in normal usage.
The big problem is that using the
@db.transaction
decorator creates a newDatabases.Transaction
instance that, when called creates a context manager for a transaction. The problem is that in concurrent frameworks, such as ASGI servers like starlette / fastapi, it's possible to have two concurrent tasks running. Since the Transaction instance is shared between tasks, it'sself
variable is comparable to shared global state, concurrent code can't trust that no race condition has occurred without some helper mechanism like a ContextVar.Here's a mermaid diagram trying to show this:
All I've done with this PR is move the shared global state of
self._connection
andself._transaction
into local or contextvars instead of instance variables.Related issues are: #123 #134
Possibly related: #340
Related meta issue: #456