Spreadsheet import: every row runs in one request and one transaction, so a sheet within the 5,000-row cap cannot finish #555
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#555
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 reading the import path and timingcsv_import.performagainst the snapshot.Related to #220.
Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atcore/csv_import.py:37-38(MAX_BYTES,MAX_ROWS) andcore/csv_import.py:923-965(perform, withtransaction.atomic()at:941); the other locations have not moved.The spreadsheet import accepts up to 2 MB and
MAX_ROWS = 5000, but it imports every row inside the request, inside onetransaction.atomic(), at 18 to 26 queries a row, with a cost per row that grows with what is already there. A sheet well inside the cap takes longer than gunicorn's 120-second worker timeout: the worker is killed, the transaction rolls back, nothing is imported, and on SQLite the instance's write lock is held the whole time.core/views_import.py:17-63import_csv(nonon_atomic_requests),core/csv_import.py:33-34(MAX_BYTES,MAX_ROWS),core/csv_import.py:928-970perform(with transaction.atomic():at:946),notifications/slow.py:120-148(anybody_wantsqueries the notifier connections on every status change),docker/Dockerfile:354(--timeout 120).performasks whether a listing has the sameurl, whether a company has the name case-insensitively, whether a Wikidata identifier matches, gets or creates the company, looks for a twin application onapplied_at__date, creates the posting and the application, and runschange_status, which records events and asksslow.tell→anybody_wants, aConnectionquery, even for a person with no notifier. None of the lookups is resolved once per sheet. Measured on the snapshot: 200 rows with URLs in 3.7 s and 5,144 queries, the next 200 in 4.8 s; the other reviewer measured 1,500 rows at 52.8 s (35 ms a row, against 28 ms at 500). At that rate 5,000 rows is well past 120 s on a desktop, and a small machine reaches the limit far sooner. Meanwhile, because the view is insideATOMIC_REQUESTSand SQLite opens itIMMEDIATE, every other request of the instance waits for the lock and fails with database is locked after 20 s.non_atomic_requestsview that answers with the page that watches it. Insideperform: resolve each distinct company name once into a dict; fetch the existing posting URLs and the(company, title, applied date)twins in one query each before the loop; askanybody_wantsonce per import (or do not announce imported history at all); and commit in chunks (for example 200 rows) so the lock is released between them. If the cap stays at 5,000 rows, test that size; otherwise lowerMAX_ROWSto what one run can do.Done when
tests/test_csv_import.pyimports a generated sheet ofMAX_ROWSrows insidedjango_assert_max_num_querieswith a bound independent of the row count (plus a small constant per row for the inserts), and fails today.tests/test_slow_requests.pylists the import address inSLOW.Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#555).