Skip to content

Replace Gunicorn with Granian to improve pgAdmin 4 performance#9786

Open
Smartappli wants to merge 7 commits intopgadmin-org:masterfrom
Smartappli:master
Open

Replace Gunicorn with Granian to improve pgAdmin 4 performance#9786
Smartappli wants to merge 7 commits intopgadmin-org:masterfrom
Smartappli:master

Conversation

@Smartappli
Copy link
Copy Markdown

@Smartappli Smartappli commented Mar 25, 2026

This change proposes replacing Gunicorn with Granian as the application server for pgAdmin 4 in order to improve overall performance, scalability, and efficiency.

Granian offers a modern Rust-based runtime with strong ASGI/WSGI support, lower overhead, and better concurrency handling compared to Gunicorn. For pgAdmin 4, this could lead to:

  • improved request throughput,
  • reduced response latency,
  • better resource utilization,
  • and improved behavior under concurrent workloads.

The goal of this change is to modernize the deployment stack while maintaining compatibility with the existing pgAdmin 4 application architecture.

Further benchmarking and validation may be required to confirm gains across different deployment environments.

Summary by CodeRabbit

  • Chores

    • Switched the container runtime server from Gunicorn to Granian.
    • Removed server-specific configuration and dependency, simplifying the image.
    • Updated container startup to support UNIX socket and TCP binding, refined TLS handling, and changed access-log behavior.
  • Tests

    • Fixed CSRF test token generation to run with the correct request environment.

Updated granian package version from 2.2.5 to 2.7.2 in Dockerfile.
…anian

Replace Gunicorn with Granian in Docker runtime
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

Walkthrough

Replaces Gunicorn with Granian as the container runtime and updates Docker build/entrypoint accordingly; deletes Gunicorn-specific config; adjusts entrypoint CLI flags, bind/address logic, TLS and access-log handling; and tweaks a test utility to pass explicit request environ overrides when generating CSRF tokens.

Changes

Cohort / File(s) Summary
Docker build
Dockerfile
Installs granian==2.7.2 (removed gunicorn==23.0.0) and only copies pkg/docker/run_pgadmin.py into the image (stopped copying gunicorn_config.py).
Entrypoint script
pkg/docker/entrypoint.sh
Replaced Gunicorn launch with /venv/bin/granian, introduced BIND_ARGS (--uds or --host/--port), mapped TLS args to Granian (--ssl-keyfile/--ssl-certificate), changed access-log semantics (--access-log / --no-access-log), and preserved GUNICORN_THREADS via --blocking-threads.
Removed config
pkg/docker/gunicorn_config.py
Deleted file and its logconfig/JSON logger setup and the gunicorn.SERVER_SOFTWARE override.
Test utilities
web/regression/python_test_utils/csrf_test_client.py
TestClient.generate_csrf_token() now calls self.app.test_request_context(...) with explicit environ_overrides including wsgi.url_scheme and HTTP_HOST.

Sequence Diagram(s)

sequenceDiagram
    participant Entrypoint as Entrypoint Script
    participant Granian as /venv/bin/granian
    participant App as run_pgadmin:app (WSGI)
    participant Client as Client (HTTP)
    Entrypoint->>Granian: build CLI args (--interface wsgi --workers 1 --blocking-threads ... ${BIND_ARGS} [--ssl-keyfile/--ssl-certificate] [--access-log/--no-access-log])\nexec granian run_pgadmin:app
    Granian->>App: load WSGI app
    Client->>Granian: request (via TCP host:port or UDS)
    Granian->>App: forward request (WSGI call)
    App-->>Granian: response
    Granian-->>Client: send response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main objective: replacing Gunicorn with Granian to improve pgAdmin 4 performance, which aligns directly with all substantial changes across Dockerfile, entrypoint.sh, gunicorn_config.py deletion, and supporting test fixes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
pkg/docker/entrypoint.sh (2)

204-204: Environment variable naming: Consider documenting backward compatibility.

The script retains GUNICORN_THREADS and GUNICORN_ACCESS_LOGFILE environment variable names for backward compatibility, which is good. However, users may be confused by Gunicorn-prefixed variables when running Granian.

Consider adding a comment explaining this is for backward compatibility, or adding support for new GRANIAN_* aliases in the future.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/docker/entrypoint.sh` at line 204, Add a short inline comment above the
exec line explaining that GUNICORN_* vars (e.g., GUNICORN_THREADS and
GUNICORN_ACCESS_LOGFILE) are kept for backward compatibility when running
Granian, and optionally add support for GRanian-prefixed aliases by reading
GRANIAN_THREADS and GRANIAN_ACCESS_LOGFILE (fallback to GUNICORN_* if unset)
before composing ACCESS_LOG_ARGS, TLS_ARGS, and BIND_ARGS so users can use
either naming convention.

141-143: Stale comment references Gunicorn.

The comment on line 141 still mentions "Gunicorn" but the server has been changed to Granian.

📝 Suggested fix
-    # Initialize DB before starting Gunicorn
+    # Initialize DB before starting Granian
     # Importing pgadmin4 (from this script) is enough
     /venv/bin/python3 run_pgadmin.py
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/docker/entrypoint.sh` around lines 141 - 143, Update the stale comment
that mentions "Gunicorn" to reference the current server "Granian" instead;
locate the comment above the run_pgadmin.py invocation (the line commenting DB
initialization) and change the wording from "Initialize DB before starting
Gunicorn" to something like "Initialize DB before starting Granian" so the
comment matches the actual server being used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/docker/entrypoint.sh`:
- Around line 192-196: The current branch only enables access logs when
GUNICORN_ACCESS_LOGFILE equals "-" which breaks support for file paths; update
the logic around the GUNICORN_ACCESS_LOGFILE check so it handles three cases:
"-" -> set ACCESS_LOG_ARGS to enable logging to stdout (e.g., "--access-log"),
empty/unset -> set ACCESS_LOG_ARGS to disable access logging (e.g.,
"--no-access-log"), and any other non-empty value -> treat it as a file path by
setting ACCESS_LOG_ARGS to the flag that enables logging to a file (e.g.,
including the file path) and emit a warning via the existing logger/echo
indicating a custom file path is being used; reference the
GUNICORN_ACCESS_LOGFILE env var and ACCESS_LOG_ARGS variable when making this
change.

---

Nitpick comments:
In `@pkg/docker/entrypoint.sh`:
- Line 204: Add a short inline comment above the exec line explaining that
GUNICORN_* vars (e.g., GUNICORN_THREADS and GUNICORN_ACCESS_LOGFILE) are kept
for backward compatibility when running Granian, and optionally add support for
GRanian-prefixed aliases by reading GRANIAN_THREADS and GRANIAN_ACCESS_LOGFILE
(fallback to GUNICORN_* if unset) before composing ACCESS_LOG_ARGS, TLS_ARGS,
and BIND_ARGS so users can use either naming convention.
- Around line 141-143: Update the stale comment that mentions "Gunicorn" to
reference the current server "Granian" instead; locate the comment above the
run_pgadmin.py invocation (the line commenting DB initialization) and change the
wording from "Initialize DB before starting Gunicorn" to something like
"Initialize DB before starting Granian" so the comment matches the actual server
being used.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 60c8a8bb-2fcb-494b-8259-8f4f362199d9

📥 Commits

Reviewing files that changed from the base of the PR and between 2576548 and 0138ee9.

📒 Files selected for processing (3)
  • Dockerfile
  • pkg/docker/entrypoint.sh
  • pkg/docker/gunicorn_config.py
💤 Files with no reviewable changes (1)
  • pkg/docker/gunicorn_config.py

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/docker/entrypoint.sh`:
- Around line 201-203: The TLS branch that sets TLS_ARGS when PGADMIN_ENABLE_TLS
is present currently only adds --ssl-keyfile/--ssl-certificate and relies on
defaults that may raise the minimum protocol to TLS1.3; update entrypoint.sh so
TLS_ARGS also explicitly sets the minimum protocol (e.g., add --ssl-protocol-min
tls1.2) or read a new env var (e.g., PGADMIN_SSL_MIN_PROTOCOL) to allow opt-in
upgrades, ensuring the change is applied where TLS_ARGS is constructed and used.
- Around line 206-207: The entrypoint currently sets Granian's thread pool via
--blocking-threads using the GUNICORN_THREADS env var but omits --backpressure,
so connection admission uses Granian's default; update the exec invocation that
runs granian (the line invoking /venv/bin/granian with --interface, --workers,
--blocking-threads and existing ${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS}) to
add an explicit --backpressure flag wired to an environment variable (e.g.
${GUNICORN_BACKPRESSURE:-<sensible-default>}) so deployment concurrency tuning
is respected and overload admission is predictable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6d180b36-538e-4cb8-9cc8-b3be3b260adc

📥 Commits

Reviewing files that changed from the base of the PR and between 2d40dae and d8e6d0a.

📒 Files selected for processing (1)
  • pkg/docker/entrypoint.sh

Comment on lines +201 to +203
TLS_ARGS=""
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and inspect the actual file
find . -name "entrypoint.sh" -path "*/docker/*" | head -5

Repository: pgadmin-org/pgadmin4

Length of output: 90


🏁 Script executed:

# Check if Granian is referenced elsewhere in the entrypoint
rg "granian|tls|ssl|TLS|SSL" pkg/docker/entrypoint.sh -i -A 2 -B 2 | head -80

Repository: pgadmin-org/pgadmin4

Length of output: 1198


🌐 Web query:

Granian 2.7.2 --ssl-protocol-min default value documentation

💡 Result:

In Granian 2.7.2, the CLI option:

  • --ssl-protocol-min [tls1.2|tls1.3]

has a default value of tls1.3 (env var GRANIAN_SSL_PROTOCOL_MIN). [1]


🌐 Web query:

Gunicorn SSL certificate keyfile default TLS version

💡 Result:

  • keyfile (private key) default: None (no key configured unless you set it). (docs.gunicorn.org)
  • certfile default: None. (docs.gunicorn.org)
  • Gunicorn “default TLS version” (ssl_version) default: ssl.PROTOCOL_TLS (configured in Gunicorn as _SSLMethod.PROTOCOL_TLS). This means Gunicorn will negotiate the highest protocol version supported by both client and server, with the effective enabled/disabled versions depending on your Python + OpenSSL build and defaults. (docs.gunicorn.org)

How to explicitly control it:

  • Set a minimum (common choice): ssl_version = "TLSv1_2" (Gunicorn example also notes this as minimum TLS 1.2 for HTTP/2). (gunicorn.org)
  • For finer control (Gunicorn ≥ 20.2): use ssl_context and set context.minimum_version (e.g., TLS 1.3). (docs.gunicorn.org)

Citations:


🏁 Script executed:

# Check if there are any existing GRANIAN_* environment variables in the codebase to confirm naming convention
rg "GRANIAN_" pkg/docker/ -i

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Check Python and OpenSSL versions in the Dockerfile
rg "FROM|python|openssl" pkg/docker/Dockerfile -i -A 1 | head -30

Repository: pgadmin-org/pgadmin4

Length of output: 125


🏁 Script executed:

# Find the Dockerfile
find . -name "Dockerfile*" -type f | head -10

Repository: pgadmin-org/pgadmin4

Length of output: 76


🏁 Script executed:

# Check the environment variable pattern used in entrypoint.sh
rg "^\s*\$\{[A-Z_]+:-" pkg/docker/entrypoint.sh | head -10

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Check environment variable patterns in entrypoint.sh
rg "\$\{" pkg/docker/entrypoint.sh | head -20

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

# Examine the Dockerfile to understand the base image and Python version
head -30 ./Dockerfile

Repository: pgadmin-org/pgadmin4

Length of output: 860


🏁 Script executed:

# Get all environment variable patterns with defaults in entrypoint.sh
grep -E '\$\{[A-Z_]+:-' pkg/docker/entrypoint.sh

Repository: pgadmin-org/pgadmin4

Length of output: 860


🏁 Script executed:

# Look for the section with TLS and surrounding context to understand the env var pattern
sed -n '190,210p' pkg/docker/entrypoint.sh

Repository: pgadmin-org/pgadmin4

Length of output: 769


Don't silently raise the TLS floor to 1.3.

Granian 2.7.2 defaults --ssl-protocol-min to tls1.3, while Gunicorn's ssl.PROTOCOL_TLS defaults to negotiating TLS 1.2+. Because this branch only adds --ssl-keyfile/--ssl-certificate, TLS-enabled containers now reject TLS 1.2 clients without any opt-in. If that isn't intentional, set an explicit minimum or surface it as a configurable migration knob.

🛠️ Suggested change
 TLS_ARGS=""
 if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
-    TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert"
+    TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert --ssl-protocol-min ${GRANIAN_SSL_PROTOCOL_MIN:-tls1.2}"
 fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
TLS_ARGS=""
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert"
TLS_ARGS=""
if [ -n "${PGADMIN_ENABLE_TLS}" ]; then
TLS_ARGS="--ssl-keyfile /certs/server.key --ssl-certificate /certs/server.cert --ssl-protocol-min ${GRANIAN_SSL_PROTOCOL_MIN:-tls1.2}"
fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/docker/entrypoint.sh` around lines 201 - 203, The TLS branch that sets
TLS_ARGS when PGADMIN_ENABLE_TLS is present currently only adds
--ssl-keyfile/--ssl-certificate and relies on defaults that may raise the
minimum protocol to TLS1.3; update entrypoint.sh so TLS_ARGS also explicitly
sets the minimum protocol (e.g., add --ssl-protocol-min tls1.2) or read a new
env var (e.g., PGADMIN_SSL_MIN_PROTOCOL) to allow opt-in upgrades, ensuring the
change is applied where TLS_ARGS is constructed and used.

Comment on lines +206 to +207
# Keep the existing environment variables for backward compatibility.
exec /venv/bin/granian --interface wsgi --workers 1 --blocking-threads "${GUNICORN_THREADS:-25}" ${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "entrypoint.sh" -type f | head -20

Repository: pgadmin-org/pgadmin4

Length of output: 90


🏁 Script executed:

cat -n pkg/docker/entrypoint.sh | sed -n '200,215p'

Repository: pgadmin-org/pgadmin4

Length of output: 495


🌐 Web query:

Granian 2.7.2 backpressure blocking-threads difference WSGI

💡 Result:

In Granian 2.7.2, nothing changed about backpressure or blocking-threads specifically—the 2.7.2 patch release is about ASGI websocket handling/metrics fixes (e.g., rejection flow, Django Channels hang) and dependency bumps. [1]

What “backpressure” is (and why it matters for WSGI)

Granian’s backpressure is a per-worker safeguard that stops the worker accept-loop from taking more connections when “too much work” is already queued for Python. Think of it as a server-level secondary backlog (in addition to the OS listen backlog). [2]

Key WSGI nuance: because it acts at the connection accept level, it limits connections, not individual HTTP requests; lots of keep-alive connections (common behind reverse proxies) can “consume” backpressure capacity. [2]

What “blocking-threads” is (and how it differs)

Granian’s blocking threads are the threads in a worker that interact with the Python interpreter (i.e., execute your app and contend for the GIL). [2]

Granian explicitly notes:

  • On async interfaces (ASGI/RSGI), this setting is effectively fixed to 1 (interop mainly happens via the asyncio event loop holding the GIL). [2]
  • On WSGI (sync), it becomes the maximum number of Python-executing threads per worker (i.e., your WSGI thread pool). [2]

Practical difference for WSGI

  • --blocking-threads = “How many requests can run Python code concurrently (per worker)?”
  • --backpressure = “How much total concurrency/queueing pressure (per worker) will I allow before I stop accepting more connections?”

Granian generally recommends not over-tuning the threadpool and instead setting an appropriate backpressure limit for your environment/workload. [2][3]

Rule of thumb for WSGI tuning

  • If your WSGI requests are mostly CPU-bound / rarely release the GIL, increasing backpressure won’t help much (requests just wait on the GIL). [2]
  • If your WSGI requests spend time in I/O that releases the GIL (DB/network), a higher backpressure can help keep the worker busy without letting the queue explode. [2]

Sources: GitHub release notes for 2.7.2 [1]; Granian README / docs on workers, blocking threads, and backpressure [2]; PyPI project description mirroring the same guidance [3].


Add explicit Granian backpressure; GUNICORN_THREADS alone is not enough.

Granian treats --blocking-threads (Python thread pool size) and --backpressure (connection admission control) as separate knobs. With only --blocking-threads set from GUNICORN_THREADS, request admission falls back to Granian's default backpressure, which does not account for your deployment's concurrency tuning. This leaves the server vulnerable to queue buildup under concurrent load. Wire --backpressure explicitly to maintain predictable overload behavior.

🛠️ Suggested change
 # Keep the existing environment variables for backward compatibility.
-exec /venv/bin/granian --interface wsgi --workers 1 --blocking-threads "${GUNICORN_THREADS:-25}" ${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
+GRANIAN_BACKPRESSURE="${GRANIAN_BACKPRESSURE:-${GUNICORN_THREADS:-25}}"
+exec /venv/bin/granian --interface wsgi --workers 1 \
+    --blocking-threads "${GUNICORN_THREADS:-25}" \
+    --backpressure "${GRANIAN_BACKPRESSURE}" \
+    ${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Keep the existing environment variables for backward compatibility.
exec /venv/bin/granian --interface wsgi --workers 1 --blocking-threads "${GUNICORN_THREADS:-25}" ${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
# Keep the existing environment variables for backward compatibility.
GRANIAN_BACKPRESSURE="${GRANIAN_BACKPRESSURE:-${GUNICORN_THREADS:-25}}"
exec /venv/bin/granian --interface wsgi --workers 1 \
--blocking-threads "${GUNICORN_THREADS:-25}" \
--backpressure "${GRANIAN_BACKPRESSURE}" \
${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS} run_pgadmin:app
🧰 Tools
🪛 Shellcheck (0.11.0)

[info] 207-207: Double quote to prevent globbing and word splitting.

(SC2086)


[info] 207-207: Double quote to prevent globbing and word splitting.

(SC2086)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/docker/entrypoint.sh` around lines 206 - 207, The entrypoint currently
sets Granian's thread pool via --blocking-threads using the GUNICORN_THREADS env
var but omits --backpressure, so connection admission uses Granian's default;
update the exec invocation that runs granian (the line invoking
/venv/bin/granian with --interface, --workers, --blocking-threads and existing
${ACCESS_LOG_ARGS} ${TLS_ARGS} ${BIND_ARGS}) to add an explicit --backpressure
flag wired to an environment variable (e.g.
${GUNICORN_BACKPRESSURE:-<sensible-default>}) so deployment concurrency tuning
is respected and overload admission is predictable.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant