Skip to content

[Bug] WEB_SERVER_CONCURRENCY docs recommend "2 × CPU cores + 1", which is 3x slower than 4 workers and can exhaust RAM #129

Description

@Spinnich

RomM version
5.2.0 (recommendation text is unchanged on current master)

Describe the bug
Two places document WEB_SERVER_CONCURRENCY with a worker-count recommendation that makes
RomM slower and can exhaust memory on a typical self-hosted box:

  • env.template:133 - WEB_SERVER_CONCURRENCY=1 # Number of worker processes (recommended: 2 × CPU cores + 1)
  • examples/docker-compose.example.yml:23 - WEB_SERVER_CONCURRENCY=3 # How many workers you want available for API calls and tasks we recommend 2 x CPU + 1

Three problems:

  1. The heuristic is for the wrong worker class. 2 x CPU cores + 1 is gunicorn's
    guidance for sync workers, where each worker serves exactly one request at a time and
    you over-provision to hide blocking I/O. RomM runs
    --worker-class uvicorn_worker.UvicornWorker with --worker-connections 1000
    (docker/init_scripts/init:140-146), and its endpoints are plain def (for example
    get_roms at backend/endpoints/roms/__init__.py:411), so Starlette already serves them
    concurrently in its threadpool inside a single worker. The rule does not transfer.

  2. "and tasks" is inaccurate. WEB_SERVER_CONCURRENCY appears exactly once in the
    entrypoint, on the gunicorn API server (docker/init_scripts/init:141). Background tasks
    run in separate RQ worker processes started by start_bin_rq_worker /
    start_bin_rq_scan_worker (init:262-270), which this variable does not govern.

  3. The two files disagree on the default (1 vs 3), and neither mentions the RAM cost.

To Reproduce

  1. Run RomM 5.2.0 on a host with N cores and a library where a single platform holds a few
    thousand ROMs (I used 4,644 ROMs over 4 platforms).
  2. Set WEB_SERVER_CONCURRENCY to 2 * N + 1 as the docs recommend.
  3. Load the gallery, which fires roughly 14 concurrent API calls.
  4. Compare against WEB_SERVER_CONCURRENCY=4.

Expected behavior
Following the documented recommendation should improve throughput, or at least not hurt it,
and should not risk exhausting host memory.

Actual behavior
Measured with real gunicorn, 14 concurrent gallery requests, RomM 5.2.0, 4,644 ROMs over
4 platforms, 16-core / 7.4 GB host (so the documented value is 33), slowest TTFB in the
burst:

Workers Slowest TTFB Peak DB connections Errors
1 (current default) 6.474 s 5 0
2 3.515 s 8 0
4 1.939 s 14 0
8 2.007 s 14 0
16 1.784 s 16 0
33 (2 x cores + 1) 5.767 s 17 0

Returns flatten after about 4 workers. At 33, processes contend for 16 cores on CPU-bound
work and lose more than they gain: 3x slower than 4 workers, and barely better than the
default of 1
.

Memory is the harder limit. 33 workers measured 5.31 GB PSS (~160 MB per worker, shared
pages counted once), leaving 587 MB free on a 7.4 GB host. On an 8-core NAS the
recommendation gives 17 workers, roughly 2.7 GB, competing with every other container.

Server (please complete the following information):

  • OS: Ubuntu 24.04 (Docker host), 16 cores / 7.4 GB RAM
  • Version: RomM 5.2.0, MariaDB 11.3.2

Additional context

There is also a connection-pool ceiling worth documenting, though I could not provoke it in
practice. Each worker process gets its own SQLAlchemy QueuePool with the library defaults
pool_size=5, max_overflow=10, never overridden at
backend/handler/database/base_handler.py:10-12, so 15 connections per process. At 33
workers that ceilings at 495 connections, plus 15 each for the RQ worker and RQ scan worker,
against MariaDB's default max_connections of 151. Pools only grow on demand, so realistic
page-load bursts peaked at just 14-17 connections even with 16 workers and produced no
errors, but the setting is over-subscribed on paper and would bite a busy multi-user
instance.

Suggested wording, replacing the current note in both files:

Number of API worker processes. Start at 4. Raise it only if you have several people
using the instance at once, and budget roughly 160 MB of RAM per worker. This does not
control background tasks, which run in their own RQ worker processes.

This came out of investigating #4382, where the default of 1 worker turned out to be the
reason a single gallery request measured 3-4 s.

AI disclosure: the measurements and analysis here were produced with Claude Code.

Activity

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions