Skip to content

fix: resolve infinite dirty-flag loop when local task has duplicate UID - #2486

Open
mvanhorn wants to merge 3 commits into
bitfireAT:mainfrom
mvanhorn:fix/2226-duplicate-uid-task-dirty-loop
Open

fix: resolve infinite dirty-flag loop when local task has duplicate UID#2486
mvanhorn wants to merge 3 commits into
bitfireAT:mainfrom
mvanhorn:fix/2226-duplicate-uid-task-dirty-loop

Conversation

@mvanhorn

Copy link
Copy Markdown
Contributor

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

  • Added findAllTasksWithSyncId(syncId) to DmfsRecurringTaskList that returns all main tasks matching a given _SYNC_ID by reusing the existing iterateTaskAndExceptions.
  • Overrode LocalTaskList.findByName to call findAllTasksWithSyncId instead of findTaskAndExceptions. 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.
  • Added three instrumented tests for findAllTasksWithSyncId in DmfsRecurringTaskListTest.

Checklist

  • The PR has a proper title, description and label.
  • I have self-reviewed the PR.
  • I have added documentation to complex functions and functions that can be used by other modules.
  • I have added reasonable tests or consciously decided to not add tests.

Fixes #2226

@CLAassistant

CLAassistant commented Jun 13, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@ArnyminerZ ArnyminerZ added the pr-bugfix Fixes something that isn't working (only used for PRs) label Jun 16, 2026
@ArnyminerZ
ArnyminerZ requested a review from Copilot June 16, 2026 10:04

@ArnyminerZ ArnyminerZ left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

polish: When using loggers, use the same pattern like in the rest of the repo please.

Example:

val log: Logger = Logger.getLogger(javaClass.name)

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>()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ArnyminerZ

Copy link
Copy Markdown
Member

Also please @mvanhorn sign the CLA

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) to DmfsRecurringTaskList to return all matching main tasks (with their exceptions).
  • Update LocalTaskList.findByName() to handle duplicate _SYNC_ID rows by reassigning _SYNC_ID for 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.

Comment on lines +122 to +128
// 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)
Comment on lines +143 to +148
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
Comment on lines +105 to +109
// 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 rfc2822 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@rfc2822 rfc2822 added the needs info Further information needed to continue label Jun 16, 2026
@mvanhorn

mvanhorn commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

recheck

@mvanhorn

mvanhorn commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

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
@mvanhorn
mvanhorn force-pushed the fix/2226-duplicate-uid-task-dirty-loop branch from c14974b to 78233d6 Compare July 10, 2026 16:10
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.
@mvanhorn

Copy link
Copy Markdown
Contributor Author

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.

@rfc2822

rfc2822 commented Jul 17, 2026

Copy link
Copy Markdown
Member

Thanks for the update. We unfortunately have to fix a few other things before we can have a look.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

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>
@mvanhorn

mvanhorn commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Merged current main into the branch (99d8a0d). It had drifted 137 commits and picked up conflicts in LocalTaskList.kt and the two DmfsRecurringTaskList files. Those are resolved and the PR is mergeable again.

Nothing under review changed, it's still the same fix from ac74f7c plus the merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs info Further information needed to continue pr-bugfix Fixes something that isn't working (only used for PRs)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Collection info: unsynchronized change in tasks doesn't go away

5 participants