SQLite: read-only views that serve a picture, a script or a poll take the write lock for the whole request #1129
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#1129
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What exists today
config/settings/base.py:178setsATOMIC_REQUESTSandconfig/sqlite.py:51-55gives every file-backed SQLite connectiontransaction_mode: IMMEDIATE. Django'sBaseHandler.make_view_atomic(django/core/handlers/base.py:347) wraps every view not markednon_atomic_requestsintransaction.atomic, andAtomic.__enter__callsset_autocommit(False, force_begin_transaction_with_broken_autocommit=True), which on SQLite runs_start_transaction_under_autocommitand so issuesBEGIN IMMEDIATE(django/db/backends/sqlite3/base.py:321-331). The write lock is therefore taken on view entry, before any query and even for a view that makes none, and held until the view returns.The
config/sqlite.pydocstring (lines 15-33) accepts this as the price of never failing a write, and #357 moved the slow views out view by view (29non_atomic_requestsuses today). The views that only read, are fetched many at a time by the browser or on a timer, and gain nothing from a transaction are still inside one:jobs/views.py:1473CompanyLogoView.get: one request per company logo on the board and lists (application_card.html:40,application_row.html:31,company_row.html:18), so a board with 50 cards is 50 serialised write-lock acquisitions on a cold cache.accounts/views.py:368AvatarView.get: the profile picture on every page that shows it.notifications/views.py:27worker(/sw.js): reads a cached file, no query at all, yet takes the lock.core/views.py:140manifest: reads instance settings.core/views_errands.py:60errand_state: polled every 2 s (POLL_SECONDS) while slow work runs, and the slow work is exactly what holds the lock indb_worker; it usesrender(), so the template is drawn inside the transaction too.Measurement (
work/r1_lock.pyin the audit scratchpad, plainsqlite3with WAL, the same options): with one connection holdingBEGIN IMMEDIATEfor 1 s, a second connection'sBEGIN IMMEDIATE; SELECT; COMMITtook 990 ms; the sameSELECTin autocommit, or in a deferred transaction, took 0.1 ms, because WAL readers do not wait for the writer.The notices poll (
notifications/views.py:40waiting) was also named by the scout. It writes (it deletes expired notices and marks the handed ones shown,notifications/inbox.py:75-93), runs once a minute per open tab, and is correct either way; it is not part of this fix (see Decisions).What would change
Mark the five read-only views above
transaction.non_atomic_requests(method_decorator(..., name="dispatch")on the two class views, the plain decorator on the three functions), with a one-line reason each in the style of #357, and add them to the list in theconfig/sqlite.pydocstring. On PostgreSQL nothing changes in practice: each view runs one or two reads in autocommit instead of inside a transaction.A test beside
tests/security/test_captured_pages.py:601-605asserts_non_atomic_requests == {"default"}on each resolved view, so a refactor does not silently put them back.No library is involved; this is Django's own switch.
What it saves or protects
medium: a logo, avatar, service worker, manifest or errand poll no longer queues behind every writer; measured 990 ms wait against 0.1 ms for the same read when another connection holds the lock for 1 s
Accessibility
none
Decisions
config/sqlite.py:29-31already decided; a blanket rule would also release GET views that write (for example aget_or_createon first visit).Found by the efficiency and reliability audit of 2026-10-30 (finding R1; index postulo/postulo#1108). Checked against
mainatdaee30b15(2026-10-30).