The scheduler can send twice, dies on one error, is always "unhealthy", and nothing notices when it stops #221
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#221
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?
The scheduler (
send_due_reminders --loop) is the weakest part of operating Postulo. Found in the 2026-09-15 code audit.Sending
send_due_reminders.py:40-58andapplications/quiet.py:86-91notify, then stampnotified_atrow by row, with no claim, no transaction and no lock between runs. Cron plus--loop(both offered incompose.yml), or a restart mid-pass, resends everything notified but not yet stamped.documents/archiving.py:149-170does not claimpending_copiesrows, so overlapping runs or Send now canputthe same copy twice, although the wiki says "nothing is ever sent twice".handle()(:82-102) has no try/except around each pass and never callsclose_old_connections(). A SQLite lock timeout, a dropped PostgreSQL connection or a reminder whose posting has no company kills the process, and the restart re-runs the whole entrypoint.run_syncs()runs inline (plugins/syncing.py:109-117), so a slow sync holds up everything after it.Operating it
HEALTHCHECKcurls:8000/healthz(Dockerfile:252-253); the scheduler serves nothing, andcompose.ymldoes not override the check.POSTULO_SKIP_MIGRATE=1butdepends_on: [postulo]has nocondition: service_healthy, so new code can start against an unmigrated schema. It also runsplugins syncat the same moment as the web container, against a shared record with no locking.core/metrics.pysays it answers "is the scheduler still going round", but there is no last-pass timestamp.postulo_pending{kind="reminders"}counts every undone reminder, due or not.Proposal
UPDATE … SET notified_at=now WHERE pk=… AND notified_at IS NULL, sending only if a row changed. The same for store copies (select_for_update(skip_locked=True)or a sending state).cache.addwith a timeout, or a database row) so two schedulers never overlap.try/except Exceptionwithlogger.exceptionper item and per pass, plusclose_old_connections()each iteration.postulo_scheduler_last_pass_timestamp_seconds,postulo_overdue{kind="reminders"}(due, not notified, older than 15 minutes) andpostulo_failures{kind="syncs"}. Document a sample alert.depends_on: postulo: condition: service_healthy, andPOSTULO_SKIP_PLUGIN_SYNC=1on the scheduler.django-tasks-db, and show a "working…" state #247