Description
Feature form acts on a stale feature ID after fid geodiff remapping - save/delete hits a different user's feature
When a pull rebases a GPKG layer, geodiff renumbers the primary keys of local inserts that have not been pushed yet (GEODIFF debug: mapping <old>-><new>). The app does not react to this in any way: open feature forms keep the FeatureLayerPair snapshot they captured before the sync, and save() / deleteFeature() act on that cached fid.
The fid the user's own feature vacates is immediately occupied by another user's feature that arrived in the same pull. So the save or delete lands on somebody else's feature.
Internal support ticket: https://support.lutraconsulting.co.uk/odoo/helpdesk.ticket/16595
Environment
Production
Application (+ app version, build, operating system)
2026.3.0
Android 17
Steps to reproduce
Two clients on the same Mergin project and GPKG layer: QGIS desktop with the Mergin Maps plugin as user B, the mobile app as user A. Both must start on the same project version. Let M be the current max fid in the layer.
- Phone: turn autosync off. Record point and save it - it gets
fid M+1 and stays unpushed.
- Plugin: add a point to the same layer, save layer edits, and sync. The server now holds the point from the plugin at
fid M+1
- Phone: Turn autosync back on. Tap the point from (1) to open preview. (
panelState is "preview", so mIsSyncPaused is still false. The form controller now holds a snapshot carrying fid M+1.)
- Wait a few seconds until the project gets synced. (While rebasing on the server version, geodiff remaps the fid of the phone point)
- Tap
Edit feature. (This is a state change only - the featureLayerPair is not re-read.)
- Delete the feature.
Expected: the feature the user selected (phone point) is deleted.
Actual: the plugin point from step 2 (the other user's feature) is deleted. Phone point is still on the map. On the next sync, the deletion propagates to the server, and the plugin point disappears for everyone.
Editing an attribute and saving at step 6 instead of deleting shows the same defect silently: the form values (incl. geometry) are written onto the plugin point.
A bit of extra debugging info for the devs:
Four things combine:
-
Autosync is not paused in the preview panel state. The pause is only
set on entering the "form" state
(MMFormController.qml:90) and
cleared when the form stack empties
(MMFormStackController.qml:284).
The preview sheet — where the snapshot is captured, and where a user can sit
indefinitely — never pauses. The poll timer runs every 10 s and triggers a
sync after 60 s of inactivity
(autosynccontroller.cpp:49-59).
-
preview → form does not re-read the feature. It is a bare state
assignment (MMFormController.qml:171,173),
so the stale FeatureLayerPair is carried into the editable form.
-
Nothing invalidates open forms after a rebase.
MerginApi::finishProjectSync only emits projectReloadNeededAfterSync when
the GPKG schema changed, the .qgz changed, or auth config changed
(merginapi.cpp:3719), and that signal is the only
thing the app reacts to (main.cpp:687). Row renumbering
does not qualify, so no reload, no layer refresh, no form invalidation.
-
Write paths trust the cached id.
AttributeController::deleteFeature() →
layer()->deleteFeature( mFeatureLayerPair.feature().id() )
(attributecontroller.cpp:1193)
AttributeController::save() → layer()->updateFeature( feat, true )
with the pre-sync snapshot
(attributecontroller.cpp:1270),
which matches by feat.id().
Related defect found while investigating (not the trigger above)
AutosyncController::checkSyncRequiredAfterAppStateChange
(autosynccontroller.cpp:75-92) emits
projectSyncRequested without checking mIsSyncPaused, unlike
syncLayerChange() (autosynccontroller.cpp:69).
Any background → foreground round trip with ≥60 s since the last sync therefore
starts a full pull+rebase regardless of whether a feature form is open.
This was not reproducible as a trigger for the bug above in testing, but it
defeats the form pause and should be guarded regardless.
Also note the pause only suppresses new triggers — a sync already in flight
when a form opens continues to completion, rebase included.
Suggested fixes
Ordered by how much they actually close:
- Re-resolve before writing. In
AttributeController::save() and
deleteFeature(), re-fetch the feature and verify identity before applying
the change, rather than trusting the cached fid. Bail out with a visible
error if it no longer matches. Closes the defect wherever it is triggered
from.
- Invalidate open forms after a rebase. Have the pull report which layers
were rebased and close / reload any open form or preview bound to them.
geodiff already computes the remap table (we log it) — exposing it would let
the app rewrite cached ids instead of discarding the form.
- Pause autosync in the
preview state too, and guard
checkSyncRequiredAfterAppStateChange with mIsSyncPaused. Narrows the
window but does not close it on its own — a sync already in flight still gets
through.
Description
Feature form acts on a stale feature ID after fid geodiff remapping - save/delete hits a different user's feature
When a pull rebases a GPKG layer, geodiff renumbers the primary keys of local inserts that have not been pushed yet (
GEODIFF debug: mapping <old>-><new>). The app does not react to this in any way: open feature forms keep the FeatureLayerPair snapshot they captured before the sync, and save() / deleteFeature() act on that cached fid.The fid the user's own feature vacates is immediately occupied by another user's feature that arrived in the same pull. So the save or delete lands on somebody else's feature.
Internal support ticket: https://support.lutraconsulting.co.uk/odoo/helpdesk.ticket/16595
Environment
Production
Application (+ app version, build, operating system)
2026.3.0
Android 17
Steps to reproduce
Two clients on the same Mergin project and GPKG layer: QGIS desktop with the Mergin Maps plugin as user B, the mobile app as user A. Both must start on the same project version. Let
Mbe the current maxfidin the layer.fid M+1and stays unpushed.fid M+1panelStateis"preview", somIsSyncPausedis still false. The form controller now holds a snapshot carryingfid M+1.)Edit feature. (This is a state change only - thefeatureLayerPairis not re-read.)Expected: the feature the user selected (phone point) is deleted.
Actual: the plugin point from step 2 (the other user's feature) is deleted. Phone point is still on the map. On the next sync, the deletion propagates to the server, and the plugin point disappears for everyone.
Editing an attribute and saving at step 6 instead of deleting shows the same defect silently: the form values (incl. geometry) are written onto the plugin point.
A bit of extra debugging info for the devs:
Four things combine:
Autosync is not paused in the
previewpanel state. The pause is onlyset on entering the
"form"state(MMFormController.qml:90) and
cleared when the form stack empties
(MMFormStackController.qml:284).
The preview sheet — where the snapshot is captured, and where a user can sit
indefinitely — never pauses. The poll timer runs every 10 s and triggers a
sync after 60 s of inactivity
(autosynccontroller.cpp:49-59).
preview→formdoes not re-read the feature. It is a bare stateassignment (MMFormController.qml:171,173),
so the stale
FeatureLayerPairis carried into the editable form.Nothing invalidates open forms after a rebase.
MerginApi::finishProjectSynconly emitsprojectReloadNeededAfterSyncwhenthe GPKG schema changed, the
.qgzchanged, or auth config changed(merginapi.cpp:3719), and that signal is the only
thing the app reacts to (main.cpp:687). Row renumbering
does not qualify, so no reload, no layer refresh, no form invalidation.
Write paths trust the cached id.
AttributeController::deleteFeature()→layer()->deleteFeature( mFeatureLayerPair.feature().id() )(attributecontroller.cpp:1193)
AttributeController::save()→layer()->updateFeature( feat, true )with the pre-sync snapshot
(attributecontroller.cpp:1270),
which matches by
feat.id().Related defect found while investigating (not the trigger above)
AutosyncController::checkSyncRequiredAfterAppStateChange(autosynccontroller.cpp:75-92) emits
projectSyncRequestedwithout checkingmIsSyncPaused, unlikesyncLayerChange()(autosynccontroller.cpp:69).Any background → foreground round trip with ≥60 s since the last sync therefore
starts a full pull+rebase regardless of whether a feature form is open.
This was not reproducible as a trigger for the bug above in testing, but it
defeats the form pause and should be guarded regardless.
Also note the pause only suppresses new triggers — a sync already in flight
when a form opens continues to completion, rebase included.
Suggested fixes
Ordered by how much they actually close:
AttributeController::save()anddeleteFeature(), re-fetch the feature and verify identity before applyingthe change, rather than trusting the cached
fid. Bail out with a visibleerror if it no longer matches. Closes the defect wherever it is triggered
from.
were rebased and close / reload any open form or preview bound to them.
geodiff already computes the remap table (we log it) — exposing it would let
the app rewrite cached ids instead of discarding the form.
previewstate too, and guardcheckSyncRequiredAfterAppStateChangewithmIsSyncPaused. Narrows thewindow but does not close it on its own — a sync already in flight still gets
through.