Skip to content

Run each task at most once at a time (unique WorkManager work per task) - #423

Open
whatgoodarewords wants to merge 1 commit into
newhinton:masterfrom
whatgoodarewords:fix/unique-task-work
Open

whatgoodarewords wants to merge 1 commit into
newhinton:masterfrom
whatgoodarewords:fix/unique-task-work

Conversation

@whatgoodarewords

Copy link
Copy Markdown

SyncManager.queue(taskID) calls plain WorkManager.enqueue(), so every request for a task adds another SyncWorker for it, even when one is already running. Requests come from several places:

  • a trigger firing (TriggerService.queueTrigger)
  • Start Task in the task list
  • boot catch-up (BootReciever re-queues triggers)
  • shortcuts, the restart action on a failure notification, follow-up tasks

WorkManager also resumes interrupted work after a reboot, so a reboot during a long sync gives you the resumed run plus the boot catch-up run.

I hit this with a 6-hour trigger on a first full copy of about 26 GB over a slow uplink. The copy takes longer than six hours, so the trigger started a second rclone copy of the same task against the same remote while the first one was still going. Two identical copies then compete for the same bandwidth and write the same files.

Change

  • queue(taskID) now uses enqueueUniqueWork("task-<id>", ExistingWorkPolicy.KEEP, request). If that task already has a run queued or running, the new request is dropped. Once the run finishes, fails or is cancelled, the next request starts a new run as before.
  • Follow-up tasks go through a new queueFollowup(taskID) with ExistingWorkPolicy.APPEND_OR_REPLACE. A worker queues its follow-up while it is still running, so with KEEP a task that names itself as its own follow-up (selectable in the task editor, e.g. "on fail, run this task again") would be dropped. Appending runs it once after the current run ends, which keeps the current behaviour for that setup.
  • queueEphemeral and EphemeralTaskManager are unchanged. They use random ids, so they never collide.

Different tasks still run in parallel. This PR does not limit concurrency across tasks (#402).

Cancelling still works

  • The request keeps its tag (the task id), so SyncManager.cancel(tag) / cancelAllWorkByTag cancels it as before.
  • The notification's Cancel action uses createCancelPendingIntent(workId). The worker's id is the same UUID whether or not the work is unique, so this path does not change.

Testing

  • ./gradlew :app:compileOssDebugKotlin :app:testOssDebugUnitTest -x :rclone:buildAll passes (existing 14 unit tests).
  • I also checked the behaviour with a Robolectric + work-testing test. It is not in this PR because it adds four test dependencies (robolectric, work-testing, androidx.test:core, concurrent-futures); I can add it as a second commit if you want it. The test uses a worker that never finishes, standing in for a long copy:
    • queue(42) three times: master leaves 3 RUNNING workers for the task (the test fails there), this branch leaves 1.
    • queue(1) and queue(2): both tasks run.
    • queue(7) then queueFollowup(7): one RUNNING, one BLOCKED behind it.
    • cancel("9") cancels the run, and a later queue(9) starts a new one.
    • cancelWorkById (what the notification's Cancel action calls) cancels the run.

🤖 Generated with Claude Code

SyncManager.queue() used plain WorkManager.enqueue(), so a trigger
firing, Start Task, boot catch-up or WorkManager resuming interrupted
work could start a second SyncWorker for a task that was still running.

Enqueue task runs as unique work named task-<id> with KEEP, so extra
requests are dropped while a run is pending or running. Follow-ups use
APPEND_OR_REPLACE so a task that follows up on itself runs again after
the current run instead of being dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant