Sync connections: nothing stops one connection running twice at once, and the DAV sync then duplicates contacts #586
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#586
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
95348908dand postulo-dav at 97abc9779, looking for what serialises two runs of one sync connection and simulating the overlap against the plugin's in-memory server.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atplugins/views.py:452-456(ConnectionSyncNowView.post) andnotifications/management/commands/send_due_reminders.py:189-198(the pass underonly_one_pass);plugins/syncing.py:70-96andcore/scheduler.py:91-98have not moved.sync_connectiontakes no lock. The scheduler's lease covers one scheduler pass against another, and Sync now callssync_connectiondirectly, so a press of Sync now during a pass, or from two tabs, runs the same connection twice at once. A sync plugin decides what to push and what to adopt from the links it read at the start, so two runs that overlap each do the other's work again.plugins/syncing.py:70-96(sync_connection),plugins/views.py:451-455(ConnectionSyncNowView.post),core/scheduler.py:91-98(only_one_pass) as used innotifications/management/commands/send_due_reminders.py:187-196,config/settings/base.py:140(ATOMIC_REQUESTS). In postulo-dav:src/postulo_dav/sync.py:437-457(a new contact is pushed to a randomuuid4()address),:527-550(every unlinked card is adopted),:576-577(an interview's first push).If-None-Match: *cannot refuse, and adopts the first card as a new contact. Simulated in one process by running a second sync between the first one'sPUTand itsSyncLink.bind: one contact ended as two cards, and after the following run as three contacts of the same name. For interviews the address is<uid>.ics, so the second run meets a 412 that nothing catches atsync.py:577and fails. A server that enforces unique UIDs refuses the second card and the run fails there instead. The double-submit guard instatic/js/app.jscovers one form on one page, not a second tab or the scheduler.sync_connection, in the shape ofonly_one_pass, and let the run that does not get it return a report that says a sync is already running (a translated sentence; Sync now shows it as information, not as a failure). The lease has to be visible outside the request's transaction: the default cache is the database cache on the same connection, so acache.addmade inside anATOMIC_REQUESTSrequest is not committed until the request ends. Either take it on a path that commits at once, or run Sync now as an errand outside the request. In postulo-dav, deriving a new card's address from its UID would letIf-None-Match: *refuse a second push; related to postulo/postulo-dav#7, which pairs by UID and makes a 412 on one item a note.Done when
tests/test_syncs.pyhas a test with a sync plugin whosesynccallssync_connectionon its own connection: the inner call returns the "already running" report without calling the plugin, and the lease is gone afterwards, also when the plugin raises.