Move the slow work onto django-tasks-db, and show a "working…" state #247
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
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#247
Loading…
Add table
Add a link
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?
django-tasks-dbis configured (config/settings/base.py,TASKS["default"]["BACKEND"]),django_tasks_dbis installed, and nothing anywhere enqueues anything.core/server_views.py:_queued_tasksalready countsDBTaskResultrows with statusREADYfor the Overview page, so server settings can report a queue that is structurally always
empty.
#220 took the slow views out of the request's transaction, so one of them no longer holds
the SQLite write lock while it works. That was the half that was a bug. It did not make them
fast, and it did not stop them occupying a gunicorn worker for the whole time: a capture
still waits up to fifteen seconds with a tab spinning, a CV is still rendered while somebody
watches, and an export of a large account is still one long request. This is the other half.
What moves
jobs/capture_views.py,CaptureCreateView.post.fetch_pageallows ten seconds for the page and five for
robots.txt.jobs/views.py,CompanyLogoActionView.post. Find logo reads thecompany's site and then tries up to six images, one round trip each.
documents/views.py,CVExportView.postandSendDocumentsView.post;applications/views.py,ReportPDFView. On the Chromium backend this is a browser launchplus a render; on a small machine it is why the container now allows a worker two minutes
instead of thirty seconds (#220).
core/views_export.py,export_download. Written to a temporaryfile rather than the
BytesIOincore/export.py:write_archive, and handed over as adownload once it exists.
api/api.py:_capturecallsnotify()synchronously, so a batch of forty from the browser extension waits on every notifier's
network timeout, forty times.
notifications/service.py:notifyis already written tosurvive a failing notifier, so it is the delivery that is in the wrong place.
Two things the queue needs before any of this is useful
manage.py db_worker. Neither Compose file mentions it and theDockerfile runs only gunicorn, so queueing work today means queueing it for ever. The
obvious shape is the one the scheduler already has: a second service under a profile,
sharing the volume and the database URL, with
POSTULO_SKIP_MIGRATE=1andPOSTULO_SKIP_PLUGIN_SYNC=1(#221), and a healthcheck that notices when it has stopped.the work falls back to running inline when no worker is configured, or the person is told
plainly, on the page, that what they pressed is waiting for a worker that is not running.
A button that silently does nothing is worse than a slow button.
The UI half
Each of these is a POST that today answers with the finished thing. Queued, each has to
answer with a pending state that htmx polls until it resolves:
small model holding the task, its owner, its kind and its outcome;
live region so the change is announced rather than merely painted;
address that names a task;
— a rendered document is already in Sent documents, an export archive is nowhere yet;
Worth deciding deliberately
document in an account, left on disk if nobody downloads it. It needs an owner, an expiry,
something that deletes it, and it must be reachable only through an ownership-checked view
like every other personal document.
throttle.captureisconsumed in the view, and #220 deliberately left it outside any transaction. Moving the
fetch must not move the counting, or an account can queue a thousand fetches for one.
api/idempotency.pyclaims a key before the work starts and gives it upif the request failed. With the work in a worker, "the request failed" and "the work
failed" stop being the same event.
snapshot_reportalready dedupes onsource_text; nothing else does.db_workeris another writer on the SQLite file, and should follow the rule #220established: short transactions around writes, nothing slow inside one.
documents/pdf.py, in-process and bounded, usedonly by
ReportPDFView.get. If rendering moves to a worker, a per-worker cache is adifferent cache from the one the web process would read, and the note there explaining why
it is not in Django's cache — the default cache is a table in the same database — still
applies.
Not in this
The
non_atomic_requestswork is done (#220), except for the capture API: django-ninjaresolves one Django view per path, inside
PathView.get_view(), so opting that one endpointout means either reaching into the router or opting the whole API out at once and making
every endpoint responsible for its own transactions. Worth doing, a change of its own, and
possibly moot if the fetch and the
notifyboth move onto the queue here.Three refinements, all found inside what is already installed
Checked against the versions in the lock: Django 6.1.1 and
django-tasks-db0.13.0.1. "It must be optional" is a settings value, not a fallback branch
Point 2 above asks for the work to run inline when no worker is configured, or for the
person to be told plainly. Django ships the first of those:
django.tasks.backendsholdsimmediateanddummyalongsidebase.So a single-container install sets
ImmediateBackendand everyenqueue()runs in therequest, on the same code path, with nothing in the views asking whether a worker exists.
That removes the branch this issue was bracing for.
Two things to say plainly about it:
regression, and the honest answer for somebody running one container — but it means the
"working…" state described below has to render correctly for a task that is already
finished by the time the response is written.
DummyBackendis the wrong choice here. It accepts work and discards it, which isprecisely the "button that silently does nothing" this issue warns against. It belongs in
tests and nowhere else.
The setting then becomes part of the self-hosting documentation: one container, immediate;
worker running, database backend.
2. Nothing prunes
DBTaskResult, and the command already existsdjango-tasks-dbshipsmanage.py prune_db_task_results, taking--min-age-daysand aseparate minimum age for failed results (defaulting to the same). Nothing runs it: the
scheduler service runs
send_due_reminders --loop --every 300and nothing else.The moment this issue lands, every capture, logo fetch, PDF render, export and API
notification writes a row that is never removed — an unbounded table, one row per
background action, on SQLite by default. It should be a second pass in the scheduler
alongside the reminders, and the two ages should differ: a succeeded result is worth days,
a failed one is worth however long somebody might reasonably come looking for it.
Worth deciding here rather than discovering on an instance a year old.
3. There are no task-level retries, and this issue is what makes that matter
django_tasks_db.utils.retryis an internal decorator guarding the worker's own databasewrites against lock contention. It is not "re-run this task". Nothing re-runs a failed task.
That is fine today because nothing is queued. It stops being fine with the list above,
because two of the five are network operations against an address somebody else chose:
fetch_pageallows ten seconds for the page and five forrobots.txt, and Find logomakes up to six round trips to a site we do not control.
Today a timeout is a visible failure and the person presses the button again. Queued, it
becomes a row with
FAILEDstatus, behind a spinner that stops, with no button. A retrypolicy is a decision this issue has to make, not one it inherits — whether that is
re-enqueue with a delay, or no retry and a clear failure the person can act on. Either is
defensible; silence is not.
One line for the Overview page
_queued_taskscounts rows with statusREADY. Once work is genuinely queued, the numberthat matters is
FAILED— because a queue that is always empty and a queue full offailures produce the same Overview today. That is one more count and one more sentence, and
it belongs with this change rather than after it.
And the consequence for a self-hosted install
With
db_workeradded, a full deployment is three processes: gunicorn, the scheduler, andthe worker. Both extras are Compose profiles, so a default install gets neither.
ImmediateBackendis what keeps that default working rather than quietly broken, which isthe same argument point 2 was already making — it just has a name now.
Landed on
mainas46ef9919d.