Scheduler: the closing and quiet announcers loop once per row, not per owner, because DISTINCT takes in the default ordering #400
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#400
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, reading the announcers insend_due_remindersand printing their owner querysets' SQL.Checked again on 2026-10-01 against
mainat0a5cad587: still present.JobPosting.Meta.orderingis now atjobs/models.py:959;jobs/closing.py,applications/quiet.py,applications/models.py:427anddocker/compose.yml:63have not moved.Both announcers start by asking which owners have anything to announce, with
.values_list("owner", flat=True).distinct(). The models carry a default ordering, and Django adds ordering columns to aSELECT DISTINCT, so the question returns one row per listing or application rather than one per owner. Each row then runs the whole per-owner pass again. The results stay correct, because the stamps leave nothing fresh after the first pass for an owner, but the scheduler repeats the work every five minutes, and for the quiet announcer the cost grows with the square of a person's applications.jobs/closing.py:77-88(announce_closing_postings), overJobPosting.Meta.ordering = ("-noted_at", "-created_at")atjobs/models.py:956;applications/quiet.py:70-81(announce_quiet_applications), overApplication.Meta.ordering = ("-created_at",)atapplications/models.py:427.SELECT DISTINCT "jobs_jobposting"."owner_id" AS "owner", "jobs_jobposting"."noted_at", "jobs_jobposting"."created_at" ... ORDER BY "jobs_jobposting"."noted_at" DESC, ...andSELECT DISTINCT "applications_application"."owner_id" AS "owner", "applications_application"."created_at" ... ORDER BY "applications_application"."created_at" DESC. So the closing announcer iterates once per listing with a closing date today or later, each iteration loading the user (_owner_of) and runningclosing_for; the quiet announcer iterates once per application of every active account, whatever its status, each iteration loading the user and running the annotatedquiet()query over all that person's applications. A person with 300 applications costs 600 queries, 300 of them scanning 300 applications with activity subqueries, on every pass (docker/compose.yml:63runs a pass every 300 seconds).jobs/closing.py:77-78promises "three questions rather than a thousand", and the code asks one per row instead. On SQLite the passes hold the database for the whole loop, in step with the web workers, for work that finds nothing new..values_list("owner", flat=True).order_by().distinct().Done when
tests/test_deadlines.py: with two listings closing within the notice for one owner,announce_closing_postingsruns a fixed number of queries (django_assert_num_queries) that does not grow with a third listing.tests/test_quiet.py: the same forannounce_quiet_applicationswith several applications for one owner.Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#400).