Skip to content

Documentation on using connection pools with forking is incorrect #7815

Closed

Description

@adeason

Describe the bug

The documentation in doc/build/core/pooling.rst (section _pooling_multiprocessing) currently suggests calling Engine.dipose when forking, but the specific details of doing so seems incorrect and leads to errors when followed. Specifically I'm talking about bullet point 2, which says to call Engine.dispose "as soon one is within the new process", and provides example code that calls dispose() in the child.

For mysql/mariadb, if there's a connection in the parent process inherited by the child, closing the connection in the child process not only closes the file descriptor socket, but also sends a kind of "quit" message on the socket (I'm not sure of the exact name, I'm not familiar with the mysql protocol). So, the connection becomes unusable in the parent process, and trying to use it results in mysql error 2013 "Lost connection to MySQL server during query" or 2006 "MySQL server has gone away". I haven't looked into the details of any other database drivers, but it seems like there's a decent chance others work like this, as well.

Even if the parent or child process doesn't touch the connection at all, it's possible this same situation could occur if the relevant objects become GC'd and close the underlying conn. The only way I'm aware of to avoid this is to close the connection in the parent process, before the fork occurs. The only way to handle it in the child would be to close the underlying socket without sending the "quit" message over the socket, but it looks like that's not really practical (if even possible) with any mysql drivers I've looked at, since the socket fd tends to be a private internal thing you're not supposed to be messing with.

Another minor wrinkle here is that in the child process, any connections created tend to not be closed automatically like they would be in the parent process. With multiprocessing.Process, the child process exits with os._exit(), to deliberately avoid running any code in callers, and so any connection objects aren't GC'd or cleaned up in parent finally: clauses or anything like that. As a result, the underlying socket gets closed when the process exits without sending the ending "quit" message on the protocol, which gets logged as a warning by default with mariadbd, something like "5704 [Warning] Aborted connection 5704 to db: 'xxx' user: 'xxx' host: 'xxx' (Got an error reading communication packets)". This isn't a big problem, but is a bit rude; the suggested solution in the documentation could avoid this by just running Engine.dispose() before the child exits.

I assume the situation is very different when using multiprocessing on macos or windows, because the default forking method has very different semantics, but I haven't checked if this is relevant there.

To Reproduce

#!/usr/bin/python3
# forktest_broken.py
import multiprocessing
import os
import sqlalchemy as sa

engine = sa.create_engine(os.environ['DB_URL'])

def run_query():
    with engine.connect() as conn:
        conn.execute(sa.text('SELECT 1'))
    print("[pid %d] query finished" % os.getpid())

def run_in_process():
    engine.dispose()
    run_query()

run_query()

p = multiprocessing.Process(target=run_in_process)
p.start()
p.join()

run_query()

Error

$ DB_URL='xxx' ./forktest_broken.py
[pid 79334] query finished
[pid 79335] query finished
Traceback (most recent call last):
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/engine/base.py", line 1802, in _execute_context
    self.dialect.do_execute(
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/engine/default.py", line 732, in do_execute
    cursor.execute(statement, parameters)
  File "/usr/lib64/python3.10/site-packages/MySQLdb/cursors.py", line 209, in execute
    res = self._query(query)
  File "/usr/lib64/python3.10/site-packages/MySQLdb/cursors.py", line 315, in _query
    db.query(q)
  File "/usr/lib64/python3.10/site-packages/MySQLdb/connections.py", line 239, in query
    _mysql.connection.query(self, query)
MySQLdb._exceptions.OperationalError: (2013, 'Lost connection to MySQL server during query')

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/adeason/./forktest_broken.py", line 24, in <module>
    run_query()
  File "/home/adeason/./forktest_broken.py", line 11, in run_query
    conn.execute(sa.text('SELECT 1'))
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/engine/base.py", line 1289, in execute
    return meth(self, multiparams, params, _EMPTY_EXECUTION_OPTS)
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/sql/elements.py", line 325, in _execute_on_connection
    return connection._execute_clauseelement(
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/engine/base.py", line 1481, in _execute_clauseelement
    ret = self._execute_context(
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/engine/base.py", line 1845, in _execute_context
    self._handle_dbapi_exception(
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/engine/base.py", line 2026, in _handle_dbapi_exception
    util.raise_(
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/util/compat.py", line 207, in raise_
    raise exception
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/engine/base.py", line 1802, in _execute_context
    self.dialect.do_execute(
  File "/usr/lib64/python3.10/site-packages/sqlalchemy/engine/default.py", line 732, in do_execute
    cursor.execute(statement, parameters)
  File "/usr/lib64/python3.10/site-packages/MySQLdb/cursors.py", line 209, in execute
    res = self._query(query)
  File "/usr/lib64/python3.10/site-packages/MySQLdb/cursors.py", line 315, in _query
    db.query(q)
  File "/usr/lib64/python3.10/site-packages/MySQLdb/connections.py", line 239, in query
    _mysql.connection.query(self, query)
sqlalchemy.exc.OperationalError: (MySQLdb._exceptions.OperationalError) (2013, 'Lost connection to MySQL server during query')
[SQL: SELECT 1]
(Background on this error at: https://sqlalche.me/e/14/e3q8)

Versions

  • OS: Fedora 35 (also Debian 11, CentOS7/8)
  • Python: 3.10.2
  • SQLAlchemy: 1.4.31
  • Database: mariadb
  • DBAPI (eg: psycopg, cx_oracle, mysqlclient): MySQLdb

Additional context

Here is an example of a snippet of code that closes the connection in the parent like I've suggested:

#!/usr/bin/python3

import multiprocessing
import os
import sqlalchemy as sa

engine = sa.create_engine(os.environ['DB_URL'])

class Process(multiprocessing.Process):
    def start(self):
        engine.dispose()
        super().start()

    def run(self):
        run_query()
        engine.dispose() # not strictly necessary, just to be nice

def run_query():
    with engine.connect() as conn:
        conn.execute(sa.text('SELECT 1'))
    print("[pid %d] query finished" % os.getpid())

run_query()

p = Process()
p.start()
p.join()

run_query()

And an example run:

$ DB_URL='mysql://xxx' ./forktest_fix.py                                            
[pid 79362] query finished
[pid 79363] query finished
[pid 79362] query finished

Something like this example is what I'd suggest in the documentation. I can submit a PR for changing the docs, but I thought I should make sure I have some agreement here and I haven't gotten everything completely wrong.

Activity

  1. added this to the 1.4.x milestone on Mar 11, 2022
  2. zzzeek commented on Mar 11, 2022

    @zzzeek
    Member

    sure, we likely never expected that the parent engine was used for a program that does the work in forks. will add this now

  3. added a commit that references this issue on Mar 11, 2022
    b13b7d3
  4. added 2 commits that reference this issue on Mar 11, 2022
    c7c6b70
    8857417
  5. zzzeek commented on Mar 11, 2022

    @zzzeek
    Member

    ill have to remember why the doc ended up being the way it was, because of course if you close the connections in the child proc, that messes up the parent. maybe older versions of engine.dispose() didn't actually close all the connections, but that seems a little unlikely (but maybe). in practice we normally used the event -based approach afterwards which solves the problem more granularly.

    overall a better solution entirely would be a parameter on dispose() that just loses the connection pool and doesn't try to close the connections. that's pretty easy to do. not sure why the whole thing hasn't really come up.

  6. adeason commented on Mar 11, 2022

    @adeason
    Author
  7. zzzeek commented on Mar 11, 2022

    @zzzeek
    Member

    On Fri, 11 Mar 2022 12:50:46 -0800 mike bayer @.***> wrote: overall a better solution entirely would be a parameter on dispose() that just loses the connection pool and doesn't try to close the connections. that's pretty easy to do.
    But doesn't that mean the conns could never be closed? The associated memory and sockets, etc, would never get freed/closed, even if the conn's gone in the parent, because we'd never know when it's "safe". Maybe that's not so bad if there's a small number of conns or the processes are short-lived, but there could be a lot of conns kept open for a while. But maybe that's really rare.

    I thought that the parent would keep using those connections in the parent. but yes I guess the sockets being sent over still represent resource use in the child as well, even though the child doesnt have access to them? I hardly know very well how that works out in practice.

    technically the "event based" approach has this problem too, if it is in fact a problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions