fix: resolve infinite dirty-flag loop when local task has duplicate UID - #2486
fix: resolve infinite dirty-flag loop when local task has duplicate UID#2486mvanhorn wants to merge 3 commits into
Conversation
ArnyminerZ
left a comment
There was a problem hiding this comment.
Just some minor comments. Waiting for Copilot's review, and @rfc2822's approval
| // locally with a UID that already exists on the server: one entry is already synced (has | ||
| // an eTag) and another was never successfully uploaded (dirty, no eTag). Reassign the | ||
| // dirty/no-eTag duplicate a fresh UUID so it can be uploaded as a genuinely new resource. | ||
| val logger = Logger.getLogger(javaClass.name) |
There was a problem hiding this comment.
polish: When using loggers, use the same pattern like in the rest of the repo please.
Example:
I mean, define it globally at class-level instead of in-line
| * @return list of all matching tasks (each with their exceptions); may be empty | ||
| */ | ||
| fun findAllTasksWithSyncId(syncId: String): List<TaskAndExceptions> { | ||
| val result = mutableListOf<TaskAndExceptions>() |
There was a problem hiding this comment.
suggestion: Please use buildList with iteration as in #2509 since that is the new method of building lists, instead of creating mutable lists and appending.
|
Also please @mvanhorn sign the CLA |
There was a problem hiding this comment.
Pull request overview
This PR fixes an infinite “dirty/unsynchronized change” loop when the local task database contains multiple main tasks sharing the same _SYNC_ID (e.g., a locally created task used a UID already present on the server). It does so by enabling discovery of all tasks with a given sync ID and resolving the collision by reassigning _SYNC_ID for dirty, never-uploaded duplicates.
Changes:
- Add
findAllTasksWithSyncId(syncId)toDmfsRecurringTaskListto return all matching main tasks (with their exceptions). - Update
LocalTaskList.findByName()to handle duplicate_SYNC_IDrows by reassigning_SYNC_IDfor dirty/no-eTag duplicates so they can be uploaded as distinct resources. - Add instrumented tests covering
findAllTasksWithSyncId()behavior for 0/1/multiple matches.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| synctools/src/main/kotlin/at/bitfire/synctools/storage/tasks/DmfsRecurringTaskList.kt | Adds API to fetch all main tasks matching a _SYNC_ID (with exceptions). |
| synctools/src/androidTest/kotlin/at/bitfire/synctools/storage/tasks/DmfsRecurringTaskListTest.kt | Adds instrumentation tests for the new multi-match lookup. |
| core/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt | Adjusts lookup-by-name to resolve duplicate _SYNC_ID collisions by reassigning new IDs to dirty/no-eTag duplicates. |
| // Return the first match that still has the original name (i.e. the synced one with an eTag). | ||
| val synced = matches.firstOrNull { task -> | ||
| val values = task.main.entityValues | ||
| val hasETag = values.getAsString(DmfsTasksContract.COLUMN_ETAG) != null | ||
| hasETag | ||
| } ?: matches.first() | ||
| return LocalTask(recurringTaskList, synced) |
| contentValuesOf( | ||
| Tasks.LIST_ID to taskList.id, | ||
| Tasks._SYNC_ID to syncId, | ||
| Tasks.TITLE to "Dirty Local Task", | ||
| Tasks.DTSTART to now + 3600000, | ||
| Tasks.TZ to timeZoneId |
| // There are multiple tasks with the same _SYNC_ID. This happens when a task was created | ||
| // locally with a UID that already exists on the server: one entry is already synced (has | ||
| // an eTag) and another was never successfully uploaded (dirty, no eTag). Reassign the | ||
| // dirty/no-eTag duplicate a fresh UUID so it can be uploaded as a genuinely new resource. | ||
| val logger = Logger.getLogger(javaClass.name) |
rfc2822
left a comment
There was a problem hiding this comment.
Blocking this for now because this change affects the core sync algorithm and could have a lot of unknown side effects.
We should probably have a look later and properly understand the problem and the solution.
|
recheck |
|
Completely understand the caution - this touches the core sync path and deserves scrutiny. If it helps de-risk: I can add focused regression tests around the duplicate-UID case (duplicate-UID task stays clean after a round-trip, single-UID behavior unchanged), or split the loop-breaking guard from the rest so it lands separately. Happy to wait for your call either way. CLA signed. |
When a task is created locally with a UID that already exists on the server, DmfsRecurringTaskList.findTaskAndExceptions returns only the first match causing an endless dirty-flag loop (HTTP 412 ignored, server copy never re-downloaded, _dirty=1 forever). Add findAllTasksWithSyncId() to return all main tasks with a given _SYNC_ID. Override LocalTaskList.findByName() to use it: on a duplicate, any task that is dirty and has no eTag is assigned a fresh UUID so it can be uploaded as a genuinely new resource, while the already-synced copy is returned to the sync algorithm as before. Fixes bitfireAT#2226
c14974b to
78233d6
Compare
Address review feedback: - findByName now re-queries findAllTasksWithSyncId after reassigning the dirty/no-eTag duplicate's _SYNC_ID, and selects from the remaining rows (preferring an eTag, else null), so it never returns a LocalTask backed by a _SYNC_ID that no longer exists in the provider. - Move the logger to a class-level property. - Build the result of findAllTasksWithSyncId with buildList. - Set _DIRTY on the dirty task in the recurring-task-list test. - Add a LocalTaskListTest regression test for the duplicate _SYNC_ID case.
|
Fixed the blocking one first: findByName now re-queries findAllTasksWithSyncId after the reassignment loop and picks from the rows that still match the name (preferring an eTag, null if none remain), so it no longer returns a LocalTask whose _SYNC_ID was just reassigned out from under it. Alongside that (ac74f7c): moved the logger to a class-level property, switched findAllTasksWithSyncId to buildList per #2509, set _DIRTY on the dirty task in the recurring-task-list test, and added a LocalTaskListTest for the duplicate _SYNC_ID case (two tasks sharing a sync id, one eTag'd and one dirty/no-eTag, asserting the dirty one is reassigned and the returned task is the surviving eTag'd one). I could not run the Gradle/Robolectric suite locally since AGP would not resolve offline in my environment, so I am leaning on CI for the full run; the new test passed standalone ktlint. |
|
Thanks for the update. We unfortunately have to fix a few other things before we can have a look. |
|
Following up on this one. The changes you requested went in at ac74f7c and all five review threads are resolved, so I think this is waiting on a re-review rather than on me. I re-checked the branch today: the requested changes are all present and the diff is still scoped to the four expected files. I was not able to run the Gradle build or tests locally in my sandbox, so if CI is happy that is the stronger signal here. Whenever you have a moment. |
Resolved conflicts, preserving this PR's change on top of upstream's restructuring. Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Purpose
Fixes an infinite dirty-flag loop that prevents the "unsynchronized change" badge from clearing when a locally created task shares a UID with a task already on the server (issue #2226, root cause confirmed by @rfc2822).
Short description
findAllTasksWithSyncId(syncId)toDmfsRecurringTaskListthat returns all main tasks matching a given_SYNC_IDby reusing the existingiterateTaskAndExceptions.LocalTaskList.findByNameto callfindAllTasksWithSyncIdinstead offindTaskAndExceptions. When exactly one match is found, behavior is unchanged. When multiple tasks share the same_SYNC_ID, any task that is both dirty and has no eTag (never successfully uploaded) is assigned a fresh UUID so the sync algorithm can upload it as a new resource, while the already-synced task (with an eTag) is returned as before.findAllTasksWithSyncIdinDmfsRecurringTaskListTest.Checklist
Fixes #2226