SACGF / SACGF/variantgrid

Web request concurrency: gunicorn runs 8 sync workers — evaluate gthread (and why not async Django)

Open
#1,786 0 comments 0 reactions 0 assignees View on GitHub
Deployment enhancement
Dominant language
Python
Stars
30
Forks
3
Avg merge
9h 28m
Merged PRs (30d)
42

Description

🤖 Written by Claude

## Summary

Gunicorn runs 8 **sync** workers, so the site handles at most 8 concurrent requests. A worker blocked on a slow Postgres query (variant/classification grids, streaming exports) is capacity sitting idle burning no CPU. Switching to `gthread` workers would give ~32 concurrent requests for roughly the same memory, with no application code change.

This issue also records why **async Django is not the answer**, so the question doesn't get re-opened later.

## Why not async Django

In our installed Django 6.1, every async ORM method is a thread-pool wrapper, not a native async driver:

```
django/db/models/query.py:657: async def acount(self): return await sync_to_async(self.count)()
django/db/models/query.py:695: async def aget(self, *a, **k): return await sync_to_async(self.get)(*a, **k)
```

`django/db/backends/postgresql/` contains no `AsyncConnection` usage — psycopg3 is installed but driven synchronously. So `await Variant.objects.aget(...)` hands the query to a worker thread and waits; it does not yield the event loop. For a request path that is overwhelmingly Postgres-bound, async buys nothing the thread pool doesn't already give us.

Cost side: ~206k lines across 1343 files with zero existing `async def`. Async is viral — `GuardianPermissionsMixin.filter_for_user()`, the jqGrid/DataTables base classes, preview signal handlers, `NotificationBuilder`, and every middleware including `GlobalLoginRequiredMiddleware` would need to change or be wrapped. Mixed sync/async Django is where `SynchronousOnlyOperation` and connection-leak bugs live.

Revisit only if Django ships a native async Postgres backend, or if we add websockets (live analysis progress, collaborative classification editing).

## Proposal

```diff
# config/systemd/gunicorn.service
- --bind 127.0.0.1:8000 -t 3600 -w 8 --pid /run/gunicorn/gunicorn.pid \
+ --bind 127.0.0.1:8000 -t 3600 -w 8 --worker-class gthread --threads 4 \
+ --max-requests 1000 --max-requests-jitter 100 --pid /run/gunicorn/gunicorn.pid \
```

`gthread` is built into gunicorn — no new dependency, unlike gevent/eventlet.

| | today (`-w 8` sync) | `-w 8 --threads 4` |
|---|---|---|
| Concurrent requests | 8 | 32 |
| OS processes | 8 | 8 |
| RSS | ~345 MB × 8 ≈ 2.7 GB (measured) | ~same, threads share the heap |
| Blocked on DB | worker idle, capacity lost | other threads keep working |
| Keep-alive / slow clients | consume a whole worker | held by the accept loop |

Reaching 32-way concurrency with sync workers would need `-w 32` ≈ 11 GB RSS, against 15 GB total shared with Postgres and 20 Celery processes.

### Why threads help despite the GIL

Django is on psycopg3 (`is_psycopg3: True`), whose binary implementation releases the GIL while waiting on the server. While one thread waits on a slow grid query, others run template rendering, permission filtering and serialisation. This is **latency hiding, not more CPU** — at 4 cores, `-w 8` is already CPU-oversubscribed.

## Checks before rolling out

**Database connections** — the main risk. `CONN_MAX_AGE: 60` (`default_settings.py:102`) plus thread-local connections means every thread holds its own:

```
today: 8 gunicorn + 20 celery = ~28
with threads: 8 × 4 = 32 + 20 celery = ~52
```

Postgres defaults to `max_connections = 100`. 52 fits, but headroom shrinks and each idle backend costs ~5–10 MB. Verify per deployment:

```bash
sudo -u postgres psql -tAc "show max_connections"
sudo -u postgres psql -c "select state, count(*) from pg_stat_activity group by state"
```

Past ~75 we'd want PgBouncer (transaction mode) or psycopg3 pooling — `psycopg-pool==3.3.1` is already in `requirements.txt`.

**Thread safety** — audited, looks clean:
- No module-level mutable globals in `library`, `snpdb`, `genes`, `annotation`, `classification`, `analysis`
- 3 `global` statements total; 2 are Celery prefork init, off the web path
- The one on the request path (`analysis/views/views_json.py:116`, `NODE_TYPES_HASH`) is an idempotent lazy cache — racing threads compute equivalent dicts, last write wins. Benign.
- 17 `lru_cache`/`@cache` uses, all thread-safe
- No `os.chdir`, no `signal.signal`
- **Still to check:** C-extension singletons, specifically the HGVS converter and SeqRepo objects in `genes/hgvs/`. Per-call or per-request construction is fine; a shared module-level instance needs a look.

**Timeout blast radius** — `-t 3600` guards the worker heartbeat. Under sync workers a recycled worker kills 1 request; under gthread it kills all 4 in-flight on that worker. Mostly theoretical at 3600s, but relevant if we ever tighten the timeout.

## Measurement plan

Roll to one deployment at `--threads 4`, observe for a week:

1. `pg_stat_activity` count settles ~52 and does not climb
2. p95 latency on heavy grid pages — this is what should improve
3. Load average vs 4 cores — if already CPU-saturated, threads surface it as queueing rather than fixing it

Outcomes:
- p95 improves, connections comfortable → consider `--threads 8`
- p95 flat → concurrency was never the bottleneck; a useful negative result pointing at query optimisation
- p95 worse → CPU-bound; the fix is more cores or faster queries

Rollback is deleting the flags and restarting. No code change, no migration.

## Unrelated but adjacent

`config/systemd/gunicorn.service` also runs `--log-level debug`, which writes a lot and costs time on the request path. Worth dropping while we're in that file.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with config/systemd/gunicorn.service and default_settings.py:102, then review the thread-safety notes including analysis/views/views_json.py:116 and the shared objects in genes/hgvs/. Check PostgreSQL connection counts with the provided pg_stat_activity commands, deploy --threads 4 to one environment, and observe p95 latency and load for a week. Done means the connection count remains safe and the measured outcome supports rollout, tuning, or rollback.

Written by the indexing model from the issue text.

Assessment

Tech stack
django, postgresql, python
Domain
backend, databases, infrastructure, performance
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.