Slow work runs inside requests, and on SQLite every request holds the write lock while it waits #220

Closed
opened 2026-09-15 21:23:22 +00:00 by tiagoagueda · 0 comments
Owner

One slow request can stall the whole instance. django-tasks-db is configured (config/settings/base.py:499-503) and nothing enqueues anything. Found in the 2026-09-15 code audit.

Why one request blocks everyone

  • ATOMIC_REQUESTS is on (config/settings/base.py:122), and SQLite runs with transaction_mode: IMMEDIATE (config/sqlite.py:37).
  • No view opts out; non_atomic_requests appears nowhere.
  • So every request, read-only GETs included, takes the database write lock as soon as the view starts and keeps it until the response.
  • The sqlite.py docstring says the wait is "milliseconds", which is only true for fast requests.

The requests that are not fast

  • Capture: CaptureCreateView.post → fetch_page, with a 10 s timeout plus 5 s for robots.txt (plugins/fetching.py:44-46).
  • Logos: CompanyLogoActionView fetches remote images.
  • PDFs: ReportPDFView, CVExportView and SendDocuments render in the request. pdf.py also launches a new Chromium for every document.
  • Export: export_download builds a zip of every file the account owns, in memory (core/views_export.py:33). The export and delete-account pages build the whole export just to show counts (views_export.py:16, accounts/views.py:360).
  • Capture API: api/api.py:290-312 calls notify() synchronously, so a batch from the extension waits on every notifier's network timeout.

A single capture from a slow job board, or one CV PDF on a Raspberry Pi, holds up the other gunicorn workers, the scheduler and db_worker. Anything that waits past the 20 s busy timeout fails with "database is locked" (the same shape as #206). Gunicorn's default 30 s --timeout can also kill a worker mid-render (Dockerfile:256-260).

Proposal

  1. Now: mark the slow views non_atomic_requests and wrap only their writes in short transaction.atomic() blocks. Consider making plain GETs non-atomic on SQLite.
  2. Structurally: move work to django-tasks-db and show a "working…" state that htmx polls:
    • PDF rendering;
    • capture fetching;
    • logo fetching;
    • export archives, written to a temporary file;
    • notification delivery from the API.
  3. Export counts from count() queries, not build_document.
  4. Draw every document in one POST from a single Chromium, and cache draft PDF bytes by the HTML's sha256.
  5. Document GUNICORN_CMD_ARGS and default to --timeout 120 --max-requests 500 --max-requests-jitter 50.
One slow request can stall the whole instance. `django-tasks-db` is configured (`config/settings/base.py:499-503`) and nothing enqueues anything. Found in the 2026-09-15 code audit. ## Why one request blocks everyone - `ATOMIC_REQUESTS` is on (`config/settings/base.py:122`), and SQLite runs with `transaction_mode: IMMEDIATE` (`config/sqlite.py:37`). - No view opts out; `non_atomic_requests` appears nowhere. - So every request, read-only GETs included, takes the database write lock as soon as the view starts and keeps it until the response. - The `sqlite.py` docstring says the wait is "milliseconds", which is only true for fast requests. ## The requests that are not fast - **Capture:** `CaptureCreateView.post` → `fetch_page`, with a 10 s timeout plus 5 s for robots.txt (`plugins/fetching.py:44-46`). - **Logos:** `CompanyLogoActionView` fetches remote images. - **PDFs:** `ReportPDFView`, `CVExportView` and `SendDocuments` render in the request. `pdf.py` also launches a new Chromium for every document. - **Export:** `export_download` builds a zip of every file the account owns, in memory (`core/views_export.py:33`). The export and delete-account pages build the whole export just to show counts (`views_export.py:16`, `accounts/views.py:360`). - **Capture API:** `api/api.py:290-312` calls `notify()` synchronously, so a batch from the extension waits on every notifier's network timeout. A single capture from a slow job board, or one CV PDF on a Raspberry Pi, holds up the other gunicorn workers, the scheduler and `db_worker`. Anything that waits past the 20 s busy timeout fails with "database is locked" (the same shape as #206). Gunicorn's default 30 s `--timeout` can also kill a worker mid-render (`Dockerfile:256-260`). ## Proposal 1. **Now:** mark the slow views `non_atomic_requests` and wrap only their writes in short `transaction.atomic()` blocks. Consider making plain GETs non-atomic on SQLite. 2. **Structurally:** move work to `django-tasks-db` and show a "working…" state that htmx polls: - PDF rendering; - capture fetching; - logo fetching; - export archives, written to a temporary file; - notification delivery from the API. 3. Export counts from `count()` queries, not `build_document`. 4. Draw every document in one POST from a single Chromium, and cache draft PDF bytes by the HTML's sha256. 5. Document `GUNICORN_CMD_ARGS` and default to `--timeout 120 --max-requests 500 --max-requests-jitter 50`.
tiagoagueda added this to the 0.4.0 milestone 2026-09-15 21:33:22 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#220
No description provided.