Leases: on PostgreSQL two processes can both take an expired scheduler, backup or sync lease #1130
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#1130
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?
What exists today
Three leases are taken with
cache.addon the default cache, which is Django'sDatabaseCacheunless the operator setsPOSTULO_CACHE_URL(config/settings/base.py:213-215):core/scheduler.py:91-109only_one_pass, called fromnotifications/management/commands/send_due_reminders.py:198.core/backups.py:245-261take, called by the scheduled backup and by the Back up now errand (core/slow.py:137).plugins/syncing.py:81-113sync_connection, called by the scheduler (plugins/syncing.py:197) and by Sync now (plugins/views.py:580).Django 6.1.2's
DatabaseCache.addgoes through_base_set("add", ...)(.venv/Lib/site-packages/django/core/cache/backends/db.py). Insidetransaction.atomicit runs a plainSELECT cache_key, expires ... WHERE cache_key = %s(noFOR UPDATE), and if a row exists withexpires < nowit runsUPDATE ... SET value, expires WHERE cache_key = %sand returnsTrue. TheUPDATEdoes not repeat theexpires < nowcondition.On PostgreSQL under READ COMMITTED, two processes A and B that both run the
SELECTbefore either commits both see the expired row. A'sUPDATEtakes the row lock; B'sUPDATEwaits, and when A commits PostgreSQL re-evaluates B'sWHERE cache_key = %son the new row version, which still matches, so B overwrites A's token and also returnsTrue. Both run. A then finds at its end that the key holds B's token and leaves it, so the two runs overlap fully.The other cases are safe: with no row, both
INSERTand the loser'sIntegrityErroris caught (except DatabaseError: return False); with a live row, theINSERTfails the same way. On SQLite theSELECTandUPDATErun insideBEGIN IMMEDIATE(config/sqlite.py:52), so they are serialised and the race cannot happen. Redis and Memcached have an atomic add.An expired row remains whenever a holder outlived its lease or died without the
finally(a killed worker, an OOM, a restart past the grace period): a sync pastLEASE_SECONDS(15 min,plugins/syncing.py:44), a reminders pass pastevery * 2, a backup killed mid-way (R3). The race then needs two contenders inside the same few milliseconds, which the scheduler and a person pressing Sync now or Back up now can be. Rare, but it is the exact overlap #586 and #576 set out to prevent (duplicated DAV contacts in #586).No measurement: there is no local PostgreSQL in the brief's limits; the evidence is the code path above.
What would change
A small lease table of Postulo's own (
core/leases.py, a model withkeyunique,token,expires) with three operations that are atomic on both databases:INSERT; onIntegrityError,UPDATE ... SET token, expires WHERE key = %s AND expires < now, and the caller holds the lease if and only if the row count is 1. On PostgreSQL the waitingUPDATEre-checksexpires < nowon the committed row and matches nothing.DELETE WHERE key = %s AND token = %s, which is also the atomic compare-and-delete thatscheduler.only_one_passandsync_connectionsay the cache lacks (the remaining gap noted in #576 and #988).UPDATE ... SET expires WHERE key = %s AND token = %s, which R3 needs.The three callers switch to it; the lease no longer depends on which cache the operator chose. Library check:
django-pglocksand PostgreSQL advisory locks are PostgreSQL only and are held by a connection, which a lease across a long task is not; the cache libraries offer nothing that works on SQLite and PostgreSQL alike. A dozen lines of ORM is the smaller dependency.Tests: one per operation, plus a PostgreSQL-only test in the CI Postgres job that runs two
takecalls on two connections over an expired row and expects one winner.What it saves or protects
small: protects the "one at a time" promise of the scheduler pass, the backup and each sync connection on PostgreSQL with the default database cache; the window is narrow (milliseconds, and only over an expired row)
Accessibility
none
Decisions
select_for_update? Recommended: own table.DatabaseCachecannot be changed, and an operator's Redis cache would make the lease vanish on a cache flush.renewoperation added here, so R3 lands after this one.Found by the efficiency and reliability audit of 2026-10-30 (finding R2; index postulo/postulo#1108). Checked against
mainatdaee30b15(2026-10-30).