Scheduler: a pass that outlives its lease deletes the next pass's lease, so a third pass can start #576
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#576
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 scheduler's lease and what each step of a pass claims.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atnotifications/management/commands/send_due_reminders.py:189(with scheduler.only_one_pass(every * 2)); the other locations have not moved.The pass lease that #221 added releases itself unconditionally. A pass that runs longer than the lease's lifetime ends by deleting the lease that a later pass has since taken, which lets yet another pass start beside that one. Reminders, webhooks and store copies claim their own rows, so they are not sent twice, but sync connections claim nothing, so overlapping passes can run the same connection's sync at the same time.
core/scheduler.py:90-98(only_one_pass);notifications/management/commands/send_due_reminders.py:187(with scheduler.only_one_pass(every * 2));plugins/syncing.py:70-109(sync_connection,due_connections).only_one_passtakes the lease withcache.add(LEASE_KEY, timezone.now().isoformat(), max(seconds, 60)), 600 seconds with the default--every 300, and infinallyrunscache.delete(LEASE_KEY)whenever it took it, without checking that the key still holds its own value.--loop, which the Compose files mention as alternatives) takes it.due_connections()selects bysynced_at, whichsync_connectionwrites only when the sync has finished, so B and C both run every connection that is due, concurrently.SyncLinkrows; a sync that creates records or links on first sight can create them twice.uuid4().hex) as the lease's value and release it only if the key still holds that token; the database cache has no atomic compare-and-delete, so a smallSchedulerLeaserow updated withfilter(owner=token).delete()is the dependable form. Separately, give a sync a claim of its own (stampsynced_at, or asyncing_sincecolumn, with a conditionalUPDATEbefore calling the plugin, as reminders already do).Related to #221, which added the lease and the per-row claims.
Done when
tests/test_scheduler.pythat entersonly_one_pass, replaces the key's value as a later pass would, leaves the block, and asserts the later pass's key is still present.tests/test_scheduler.py(or the sync tests) that callsrun_syncswhile another run holds the connection's claim, and asserts the plugin'ssyncis called once.