Skip to content

PostgreSQL connections leaked by scheduled/background jobs (scoped_session never closed outside Flask requests) #3556

Description

@creaux

Describe the bug

When Bazarr is configured to use PostgreSQL, connections leak steadily over time from scheduled/background jobs (e.g. the periodic Sonarr/Radarr sync), independent of the leak already fixed in #3361 (which was specific to the /api/system/status endpoint). Left unattended, this eventually exhausts the PostgreSQL role's connection limit and Bazarr starts failing with:

(psycopg2.OperationalError) connection to server at "postgres" ... failed: FATAL:  sorry, too many clients already

In my case this also caused a shared-Postgres-instance side effect: Jellyseerr, which uses the same Postgres server for its own database, was unable to log users in because no connections were available for it either.

Root cause (from reading app/database.py and app/app.py on main)

# app/database.py
session_factory = sessionmaker(bind=engine)
database = scoped_session(session_factory)   # thread-local session
# app/app.py
@app.before_request
def _db_connect():
    database.begin()

@app.teardown_request
def _db_close(exc):
    database.close()

scoped_session (default scope: current thread) hands each thread its own persistent Session, which holds one checked-out PostgreSQL connection open until database.close()/database.remove() is called for that thread. That cleanup is wired only to Flask's before_request/teardown_request hooks, i.e. it only runs for HTTP/API requests.

app/scheduler.py's background jobs (APScheduler) run independently of Flask's request cycle — I don't see any app_context()/request_context() wrapping around scheduled job execution. So whenever a scheduled job (e.g. the periodic library sync) touches the database on a scheduler worker thread, it opens a Postgres connection via scoped_session that is never closed. The only other place database.remove() is called is an atexit hook, which only fires on process shutdown — not after each job.

The NullPool used for the Postgres engine (create_engine(url, poolclass=NullPool, isolation_level="AUTOCOMMIT")) doesn't mitigate this: NullPool means SQLAlchemy doesn't pool at all, so a connection is only actually released back to Postgres when the session is explicitly closed — which for scheduler jobs, never happens.

To Reproduce

  1. Configure Bazarr to use PostgreSQL with a Postgres role/database that has a modest connection limit.
  2. Leave Bazarr running with its default scheduled jobs enabled (hourly sync, etc.) and do not touch the web UI/API at all.
  3. Watch pg_stat_activity for the Bazarr role over several hours/days — a new idle connection accumulates roughly in step with each scheduled job run, and is never released.
  4. Eventually the role hits its connection limit and scheduled jobs start failing with "too many clients"/"too many connections for role" errors.

Expected behavior

Connections opened by scheduled/background jobs should be returned/closed when the job finishes, the same way teardown_request does for HTTP requests — e.g. by wrapping job execution so it also calls database.close()/database.remove() on completion (or by disposing engine-per-job / using an app context around scheduled job execution).

Software

  • Bazarr: v1.5.6 (linuxserver/bazarr:latest, built 2026-05-12)
  • Database: PostgreSQL 16
  • Also reproduced by inspecting the current app/database.py / app/app.py on main — the thread-scoped-session-without-per-job-teardown pattern is unchanged, so this looks like it's still present.

Additional context

This is distinct from #3361, which fixed a different leak (a raw engine connection opened per /api/system/status poll). That fix (caching the DB/migration version at init_db() startup) doesn't touch the before_request/teardown_request mechanism, and doesn't affect the scheduler-thread leak described here.

As a workaround, I've set idle_session_timeout on the Postgres server so it force-closes long-idle leaked connections, which prevents exhaustion but obviously doesn't fix the underlying leak.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions