Skip to content

fix: Dispose SQL registry connection pools after Gunicorn fork - #7065

Open
casaar97 wants to merge 2 commits into
feast-dev:masterfrom
casaar97:casaar97/sql-registry-postfork-dispose
Open

casaar97 wants to merge 2 commits into
feast-dev:masterfrom
casaar97:casaar97/sql-registry-postfork-dispose

Conversation

@casaar97

Copy link
Copy Markdown
Contributor

What this PR does / why we need it:

feast serve --workers N builds the FastAPI app, and with it the FeatureStore and its registry, in the Gunicorn master before it forks the workers. get_app() creates VectorStoreRegistry(store), whose first refresh() lists feature views, which creates the SqlRegistry and runs queries in the master. SQLAlchemy's QueuePool keeps that connection idle in the master, and every forked worker inherits the pool and checks out the same DBAPI connection, so two processes talk over one socket.

With MySQL over TLS this fails with (2013, 'Lost connection to MySQL server during query ([SSL] record layer failure ...)'), either as a sqlalchemy.pool "Exception during reset or similar", or, when it hits async_refresh() in the lifespan, as "Application startup failed", after which Gunicorn halts the whole server ("Worker failed to boot."). Without TLS the same sharing gives other errors (see #5784 for a Postgres ResourceClosedError). Workers re-forked through --max-requests inherit the master's pool again. Details and a deterministic reproduction are in #5784.

This applies SQLAlchemy's documented recipe ("Using Connection Pools with Multiprocessing or os.fork()"): engine.dispose(close=False) in the child. close=False matters: a plain dispose() would close the inherited connections in the child and send a disconnect on a socket the master still shares (sqlalchemy/sqlalchemy#7815).

  • BaseRegistry gains a no-op reset_after_fork(), so file, remote and other registries are unaffected.
  • SqlRegistry.reset_after_fork() disposes write_engine, and read_engine when it is a separate engine, with close=False.
  • FeastServeApplication.load_config always sets a Gunicorn post_fork hook, _gunicorn_post_fork, which calls it. Unlike the metrics post_worker_init / child_exit hooks, it is set for every run, because the bug does not depend on metrics. post_fork runs in the child right after the fork, before the worker loads the app or runs the lifespan, and on every re-fork. The hook does nothing when no registry was built, and never builds one.

#5795 targets the same issue; this PR keeps the change to the registry engines and uses close=False.

Which issue(s) this PR fixes:

Fixes #5784

Misc

Tests:

  • test_sql_registry.py: test_forked_child_does_not_reuse_parent_pooled_connection uses the file-backed SQLite SqlRegistry fixture (a real QueuePool) and os.fork(). Without the fix, the child checks out the parent's DBAPI connection object. With it, the child gets its own. Two more tests check that reset_after_fork() disposes the write engine, and the read engine when read_path is set, with close=False.
  • test_feature_server.py: the post_fork hook is registered with and without metrics, it calls reset_after_fork(), and it does not build a registry when none exists.
  • Manual: MySQL 8.4 with TLS in Docker, feast serve --workers 2 behind a 10 ms latency proxy, 10 runs on each side. Before (master): "Exception during reset or similar" / "Lost connection to MySQL server during query" in 2 of 10 runs, one of them with [SSL] record layer failure. After (this PR): 0 of 10. Both workers report "Application startup complete" in every run on both sides.

ruff check, ruff format --check and mypy feast pass. The touched unit test files pass (206 tests).

@casaar97
casaar97 requested a review from a team as a code owner October 23, 2026 10:36
@codecov-commenter

codecov-commenter commented Oct 23, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 51.31%. Comparing base (b576c45) to head (8557e38).
⚠️ Report is 8 commits behind head on master.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #7065      +/-   ##
==========================================
+ Coverage   51.28%   51.31%   +0.03%     
==========================================
  Files         458      458              
  Lines       57409    57418       +9     
  Branches     8415     8417       +2     
==========================================
+ Hits        29443    29465      +22     
+ Misses      25957    25945      -12     
+ Partials     2009     2008       -1     
Flag Coverage Δ
go-feature-server 30.58% <ø> (ø)
python-unit 52.79% <100.00%> (+0.03%) ⬆️
Files with missing lines Coverage Δ
sdk/python/feast/feature_server.py 66.26% <100.00%> (+1.81%) ⬆️
sdk/python/feast/infra/registry/base_registry.py 74.16% <100.00%> (+0.09%) ⬆️
sdk/python/feast/infra/registry/sql.py 61.25% <100.00%> (+0.21%) ⬆️

... and 1 file with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update b576c45...8557e38. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@casaar97
casaar97 force-pushed the casaar97/sql-registry-postfork-dispose branch from 26042ff to b891917 Compare October 27, 2026 11:04
Carlos Sánchez added 2 commits November 4, 2026 10:42
Gunicorn workers inherited the parent's pooled SQL registry connections,
so several workers shared one socket and the server crashed on startup.
A post_fork hook, set for every run, now disposes the pools with
close=False, so the parent's connections are left open.

Fixes feast-dev#5784

Signed-off-by: Carlos Sánchez <[email protected]>
@casaar97
casaar97 force-pushed the casaar97/sql-registry-postfork-dispose branch from 3bcc3de to 8557e38 Compare November 4, 2026 09:42

@tair800 tair800 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the focused fix and the detailed analysis in #5784. The approach looks right to me: post_fork runs in the worker right after os.fork() and before worker.init_process() (gunicorn's Arbiter.spawn_worker), and dispose(close=False) is the variant SQLAlchemy recommends after a fork.

An AI-assisted reproduction with standalone scripts (SQLAlchemy 2.0.54 on Linux, with a stand-in driver whose close() sends a quit message like PyMySQL's COM_QUIT; Feast itself and its test suite were not run) showed the difference: without a reset, the child received the parent's connection and both processes read each other's replies; with dispose(close=False), each process had its own session; with a plain dispose(), the child's quit ended the parent's session.

One dependency-compatibility point inline; it does not change the approach.

Disclosure: this review was prepared with an AI assistant, which also ran the standalone reproductions described here.

# close=False because the parent still owns those connections; closing
# them would send a disconnect on a socket the parent shares. See
# https://docs.sqlalchemy.org/en/20/core/pooling.html#using-connection-pools-with-multiprocessing-or-os-fork
self.write_engine.dispose(close=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Engine.dispose(close=...) was added in SQLAlchemy 1.4.33. Feast's core dependency is SQLAlchemy[mypy]>1 (pyproject.toml line 31; line 59 has SQLAlchemy>=1.4.19), and the comment at line 107 of this file says 1.4.x is still supported. On 1.4.0–1.4.32 this call raises; an AI-assisted check against the 1.4.32 and 1.4.33 sources gives:

sqlalchemy 1.4.32: engine.dispose(close=False) -> TypeError: Engine.dispose() got an unexpected keyword argument 'close'
sqlalchemy 1.4.33: engine.dispose(close=False) -> OK

Because this runs in post_fork, an exception there should make the worker exit with a boot error and gunicorn stop the server. That last step comes from reading gunicorn's arbiter, not from running feast serve.

close= was introduced in 1.4.33, but 1.4.33 and 1.4.34 were both yanked from PyPI (an ORM insert regression in 1.4.33, and a caching issue in 1.4.34 that 1.4.35 resolves), which makes 1.4.35 the appropriate minimum supported version. Would raising the floor to SQLAlchemy[mypy]>=1.4.35 work for you?

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The feast server crashes when starting multiple workers with SQL registry

4 participants