Slow requests: views that wait on a remote host still hold SQLite's write lock (the fix for #220 missed them) #357
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#357
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?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, by listing every view that reachespostulo.plugins.http, SMTP or a plugin's network call and resolving each address to see whether its callback carries_non_atomic_requests.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atjobs/forms.py:308-313(CompanyForm.apply_logo);accounts/forms.py:445(ProfileForm._save_picture);api/api.py:605-614(_read),:422-450(create_capture, theCapturecreate at:482, theerrands.sendcalls at:508-533),:567-574(preview_capture) and:826(OUTSIDE_A_TRANSACTION);plugins/views.py:307-342(ConnectionConsentCallbackView),:361-411(ConnectionTestView) and:446-459(ConnectionSyncNowView);core/server_views.py:766(EmailConnectionTestView),:877(EmailTestView),:1304(PluginActionView),:1369(_confirm),:1408(_refresh) and:1438(_install);plugins/fetching.py:50-52(the capture timeouts) andplugins/http.py:22(MAX_REDIRECTS). The other places are where they were, and none of these views has been markedtransaction.non_atomic_requestssince.ATOMIC_REQUESTSis on (config/settings/base.py:140) and a SQLite file is opened withtransaction_mode: IMMEDIATE(config/sqlite.py:49), so a request takes the database's write lock before its view runs and keeps it until the response.config/sqlite.py:20-30states the rule that follows: "a view which does something slow does not belong in a request-long transaction at all". A dozen views that wait on somebody else's server are still inside one. While one of them waits, every other request (read-only pages included), the scheduler anddb_workerqueue behind it, and whatever waits past the twenty-second busy timeout fails with database is locked.Related to #220, which is closed: it moved capture, Find logo, PDF drawing and the export out of the request's transaction, and #247 then turned several of them into errands. This issue adds the views neither reached. Two of them were named before and closed over: #206 said "
LogoFormMixinalso runslogos.from_urlinside the request transaction", and #247 ("Not in this") deferred the capture API as "a change of its own"; no open issue carries either.Where: none of the following is marked
transaction.non_atomic_requests(resolved on the snapshot;jobs:capture_create,jobs:company_logo_actionand the API'sattach_renderingare).resume/views.py:353-391,LinkCheckView(resume:link_check,resume:link_check_all).resume/links.py:47-56,check_all, asks every link in turn with no overall deadline;resume/links.py:33and:67-73allow 8 seconds per phase for aHEAD, then often aGET, with up to three redirects.jobs/views.py:364-379,LogoFormMixin.form_valid, callsform.apply_logo(self.object)after the save, onCompanyCreateViewandCompanyUpdateView(jobs/views.py:382-408).jobs/forms.py:307-312,CompanyForm.apply_logo, callslogos.from_url;jobs/logos.py:109-121,download, waits up toTIMEOUT = 8.0(jobs/logos.py:75) per phase and follows up to three redirects (plugins/http.py:23). OnlyCompanyLogoActionView(jobs/views.py:436) was moved.accounts/avatars.py:111-131,fetch_gravatar(http.client(timeout=8.0), then a profile save), called fromaccounts/views.py:227-243,GravatarRefreshView.post; fromaccounts/forms.py:444,ProfileForm._save_picture, when Use my Gravatar is switched on, inside thetransaction.atomic()ofProfileView.form_valid(accounts/views.py:177); and fromaccounts/signals.py:82-91, theemail_changedreceiver, during allauth's email view.api/api.py:603-612,_read, callsfetch_pagewhen nohtmlis sent (ten seconds for the page, five forrobots.txt, up to three redirects:plugins/fetching.py:44-46). It is reached fromcreate_capture(api/api.py:420-448) andpreview_capture(api/api.py:565-572).OUTSIDE_A_TRANSACTION(api/api.py:824) holds onlyattach_rendering. The same request then callserrands.send("notify", ...)(api/api.py:506-531), and withPOSTULO_BACKGROUND_WORKoff (the default,config/settings/base.py:637)errands.sendperforms the errand at once (core/errands.py:98-101), so each notifier's network call also happens inside the capture request's transaction, with or withouthtml.tests/security/test_captured_pages.py:590-601,test_a_slow_upload_does_not_hold_the_database, asserts that/api/v1/capturesis not non-atomic, so the suite pins the defect.plugins/views.py:360-410,ConnectionTestView.post(a token refresh, thenplugin.test: a login or a real request to the connected server);plugins/views.py:445-458,ConnectionSyncNowView.post(a wholesync_connection: a CardDAV, CalDAV or IMAP run);plugins/views.py:306-341,ConnectionConsentCallbackView.get(the OAuth token exchange inconsent.finishandmail_auth.finish_consent).documents/views.py:679,SendCopiesNowView.post(documents:upload_archive,documents:rendered_archive):send_now(documents/archiving.py:224-242) uploads the document to every store it is missing from.core/server_views.py:765,EmailConnectionTestView, and:876,EmailTestView(an SMTP connection, a real message);core/server_views.py:1303,PluginActionView:_refresh(:1407) fetches every catalogue,_install(:1437) fetches them again and downloads a release with a 120-second timeout (plugins/catalogue.py:296) before the install and verification subprocesses, and_confirm(:1368) waits on the same subprocesses for an uploaded package.tests/test_slow_requests.py:36-45,SLOW, names none of these addresses, and the test settings replaceDATABASESwithoutATOMIC_REQUESTS(config/settings/test.py:13), so nothing in the suite runs these views inside a request's transaction.What happens: on the default install (SQLite, three gunicorn workers, no errand worker):
resume/candidate.py:84). If the loop passes gunicorn's--timeout 120(docker/Dockerfile:354), the worker is killed, the person gets a 502 and the transaction rolls back, so none of the results already fetched are kept.INSERTorUPDATEis held through the download; the timeout is per network operation, so a host that trickles bytes holds it for longer than one timeout.capturesscope sendsPOST /api/v1/captureswith aurland nohtml(the wiki's firstcurlexample on The capture API), orPOST /api/v1/captures/preview. The lock is held through the robots check, the page and any redirects.POSTULO_CAPTURE_RATE(default30/h) bounds how often an account can do this, not how long each call holds the lock.A replay during the review (link checks patched to sleep, the file options of
config/sqlite.py,ATOMIC_REQUESTSon) measured a second account's/applications/at 0.04 s alone, 4.4 s behind one 5-second link check, and a 500 after 22 s behind three 8-second ones.Who can trigger it: any signed-in person, with a button the page offers on their own data, or any holder of a capture token; the staff pages need an administrator. Nothing is disclosed: the cost falls on everybody else's requests.
Why it matters: one person's ordinary action stalls or breaks every other account's pages and the scheduler's reminders. A 500 in the middle of a form loses what was typed and invites a retry that creates a duplicate (#206). A gunicorn worker is tied up for the same time. On PostgreSQL the cost is smaller: a transaction left open across the network. The docstring of
config/sqlite.pysays the slow views are out of the request's transaction, which is not yet true. The Gravatar case alone would be minor, since the fetch is rare and opt-in; the link check, the company form and the capture API are not.Fix: the rule #220 set, applied to each view: mark it
transaction.non_atomic_requestsand wrap only its writes in shorttransaction.atomic()blocks, or hand the work to an errand as #247 did for Find logo.LinkCheckView:@method_decorator(transaction.non_atomic_requests, name="dispatch");links.checkalready saves each link with its ownsave(), which then commits by itself. Givecheck_allan overall deadline well inside gunicorn's timeout (about 60 seconds), stop there, and say how many links were not reached.logo_urlchanged send thelogoerrand with a newurlaction infind_a_logo(jobs/slow.py:130-157) that callslogos.from_url, then redirect to the errand page asCompanyLogoActionViewdoes. If it has to stay inline, mark both views non-atomic and fetch after thetransaction.atomic()thatCompanyIdentifiersMixin.form_valid(jobs/views.py:350-361) already puts around the company and its identifiers;logos.storewrites in a transaction of its own.fetch_gravatarfetch first and write after. MarkGravatarRefreshViewnon-atomic. MarkProfileViewnon-atomic too and fetch after its existing atomic block, which keepsgravatar_outcomefor the message. In theemail_changedreceiver, which runs in allauth's view, schedule the fetch withtransaction.on_commitor an errand.create_captureandpreview_capturetoOUTSIDE_A_TRANSACTION.GET /api/v1/capturesis answered by the same address function, so it leaves the transaction too; it only reads, and the comment should say so. Wrap the capture's own writes (theCapturecreate atapi/api.py:480,pages.keep_source_quietly,remembered.after_capture,answer.keep) in short atomic blocks. Leavethrottle.captureand theerrands.sendcalls outside them: the first for the reasonCaptureCreateView's docstring gives, the second so that an inline notifier no longer runs under the lock.idempotency.oncealready claims its key in a transaction of its own (api/idempotency.py:84).connection.record_test, the sync's bookkeeping, the copy status and the consent's saves in their own atomic blocks;send_nowalready does so forschedule_copies. Sync now and the catalogue actions are long enough to be better as new errands.config/sqlite.pydocstring if any view is left inside its transaction deliberately.Not in this: notifications sent through
slow.tellfromchange_statusand the other application services also run inline in the caller's transaction when no worker is configured, but their fix istransaction.on_commitintell, not moving a view out of its transaction; that is a separate issue from this review.Done when
tests/test_slow_requests.py:SLOWlistsresume:link_check,resume:link_check_all,jobs:company_create,jobs:company_update(or a test shows that saving a company with a new logo address sends an errand and does not calllogos.downloadin the request),accounts:avatar_refresh,accounts:profile,connections:test,connections:sync_now,connections:consent_callback,documents:upload_archive,documents:rendered_archive,server:email_test,server:email_connection_testandserver:plugin_action;test_the_slow_views_do_not_hold_the_database_while_they_workfails for each today. Each write these views make is shown to sit in a transaction of its own, astest_a_capture_is_written_in_a_transaction_of_its_owndoes for the web capture.tests/security/test_captured_pages.py::test_a_slow_upload_does_not_hold_the_databaseno longer asserts that/api/v1/capturesis atomic, and asserts that it and/api/v1/captures/previewcarry_non_atomic_requests, as it does for the rendering upload.tests/test_avatars.py, markeddjango_db(transaction=True)and run withATOMIC_REQUESTSon, replaces the HTTP client with one that assertsnot connection.in_atomic_blockwhenget()is called, and drives the three paths (a profile save with the tick, Fetch my Gravatar again, a primary address change); it fails today on all three.tests/test_logos.py, set up the same way, posts the company form with a new logo address withlogos.downloadpatched to record whether a transaction is open, and asserts that none is, or that an errand was queued instead.tests/test_links_and_letters.pywithlinks._askpatched to advance a fake clock showscheck_allstopping at its deadline, keeping the results it had, and the page saying how many were left.