SQLAlchemy pool with DuckDB dialect: diagnosing intermittent exhaustion across forked workers
0 reputation · 02 Dec 2024, 04:34 UTC
0 reputation · 02 Dec 2024, 04:34 UTC
I am running DuckDB (assume v0.10.x) through the SQLAlchemy DuckDB dialect inside a multi-process web service (gunicorn-style workers). Connections are acquired from a SQLAlchemy QueuePool and released back after each request. Intermittently, the pool reports exhaustion even though the workload is well below the configured pool size.
DuckDB connections are in-process handles with no server-side limit, so exhaustion must come from the client pool or unreleased references. Two behaviors make this hard to pin down: the DuckDB Python module can keep a native connection alive until all derived cursors are released, and uncommitted transactions are silently rolled back on close(), which may hide application-level leaks. Additionally, each forked worker inherits its own DuckDB state, so a pool sized for the parent process may not match per-worker reality. I also understand the dialect may not enable pool_pre_ping by default, raising the question of whether stale connections after a fork are being counted as live.
close() affect when the native handle is actually released back for reuse, and is this consistent across v0.9 and v0.10?pool_pre_ping meaningful for an embedded engine like DuckDB, or is connection liveness purely a client-side reference-counting concern?32775 reputation · 02 Dec 2024, 07:49 UTC
The most likely cause of intermittent pool exhaustion in a gunicorn-style environment is the inheritance of the SQLAlchemy Engine and its QueuePool from the parent process. When a process forks, the child inherits the memory state of the parent, including the pool's internal connection tracking. However, the underlying native DuckDB handles are not safely shareable across process boundaries, leading to a state where the pool believes connections are active or available when they are actually stale or invalid.
SQLAlchemy engines and pools are not fork-safe. If the engine is instantiated at the module level (before the fork), every worker inherits the same pool state. This often manifests as intermittent exhaustion because the child processes may be attempting to use inherited connections that are logically "checked out" or corrupted, effectively reducing the available pool size to zero or causing deadlocks.
Engine instance inside the worker process after the fork. In Gunicorn, this is typically handled within the post_fork hook.engine.dispose() immediately after the fork in each worker. This clears the inherited pool and forces the worker to create its own fresh connections.DuckDB's silent rollback of uncommitted transactions upon close() affects the transactional state, not the pool availability. A connection is only returned to the SQLAlchemy QueuePool when the SQLAlchemy session or connection object is explicitly closed or garbage collected.
If you are seeing exhaustion while workload is low, it suggests a reference leak. Because DuckDB is an embedded database, a native connection may remain open as long as any derived cursor or result set is still in scope. If your application leaks these objects, the SQLAlchemy connection remains "checked out," regardless of whether a transaction was rolled back.
Enabling pool_pre_ping=True is generally ineffective for DuckDB. This feature is designed to detect dropped TCP connections to a remote server (e.g., PostgreSQL). Since DuckDB is an in-process library, there is no network socket to "ping." It will not detect or fix the state corruption caused by process forking; only proper engine disposal or per-worker instantiation solves that issue.
workers=1. If the exhaustion disappears, the issue is confirmed as fork-inheritance of the pool.import logging
logging.getLogger('sqlalchemy.pool').setLevel(logging.DEBUG)
Engine is instantiated at the top level of your application module before the web server forks its workers.Missing Diagnostic: Are you using a file-backed DuckDB database? If so, please specify if workers are connecting in read_only=True mode, as file-locking contention can sometimes mimic pool exhaustion by blocking connection acquisition.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.