Skip to content

fix(autoupload): throttle rapid worker restarts, decouple charging constraint, and delay foreground service - #17973

Closed
JanisPlayer wants to merge 1 commit into
nextcloud:masterfrom
JanisPlayer:fix-autoupload-reschedule-loop
Closed

JanisPlayer wants to merge 1 commit into
nextcloud:masterfrom
JanisPlayer:fix-autoupload-reschedule-loop

Conversation

@JanisPlayer

@JanisPlayer JanisPlayer commented Nov 5, 2026 •

Copy link
Copy Markdown

Overview

Resolves / addresses #17895 ("Auto Upload high CPU usage").

During active reproduction on device, an live ADB diagnostic and codebase analysis was conducted using Google Antigravity with Gemini 3.8 Flash (High).


Problem & Root Cause

When Auto Upload is configured with "Only while charging" on devices with battery protection enabled (such as Samsung's "Protect Battery" capping at 80%, or Android 15/16 charging limits), the app enters an unthrottled infinite start-cancel-reschedule loop at ~60–80 Hz (every 10–15 ms).

This resulted in:

  • com.nextcloud.client pinned at 100% CPU.
  • system_server pinned at >200% CPU (overwhelmed with millions of JobScheduler requests).
  • Over 7.48 million jobs (#AutoUploadWorker#@androidx.work.systemjobscheduler) scheduled within a single hour.
  • Severe battery drain, thermal throttling, and device overheating (>40°C).

Root Cause Details:

  1. Battery Protection vs WorkManager Constraint Desynchronization:
    • When battery protection limits charging to 80%, Android sets BatteryManager.EXTRA_STATUS = BATTERY_STATUS_NOT_CHARGING (value 4), while EXTRA_PLUGGED remains AC or USB.
    • The Android OS JobScheduler (BatteryController) treats AC power connection as satisfying the charging requirement and fires onStartJob.
    • In Nextcloud, WorkManager constraints were configured with .setRequiresCharging(true) via SupportedNetworkTransports.getConstraints(...). WorkManager's in-process BatteryChargingTracker strictly checks status == BATTERY_STATUS_CHARGING (2) || status == BATTERY_STATUS_FULL (5). Because status == 4, WorkManager evaluates charging as false.
  2. Immediate trySetForeground() Promotion:
    • AutoUploadWorker.doWork() was calling trySetForeground() right at the top, even for empty folders or before checking isBlocked().
    • As soon as trySetForeground() is called, SystemForegroundDispatcher activates constraint tracking. WorkManager detects constraints as unmet, immediately cancels the worker via StopWorkRunnable, and notifies JobScheduler with reschedule = true.
  3. Zero Backoff on Constraint Cancellation:
    • Preempted constraint cancellations in WorkManager reset the work state to ENQUEUED without incrementing retry attempts. JobScheduler sees the job ready immediately (as it considers OS constraints met), re-triggering onStartJob within ~10 ms.

Solution & Changes

This PR resolves the issue via four complementary safeguards:

  1. General Execution Throttle / Debounce (AutoUploadWorker.kt):
    • Added a 10-second minimum execution cooldown (MIN_EXECUTION_INTERVAL_MS) per synced folder in AutoUploadWorker.doWork().
    • If a worker is re-triggered within 10 seconds of a previous run (and not manually overridden), it gracefully returns Result.success(). Returning success informs JobScheduler that the work is finished, immediately breaking any rapid loop.
  2. Reliable Charging Check via PowerManagementService:
    • Nextcloud's PowerManagementService.battery.isCharging already checks BatteryManager.EXTRA_PLUGGED (USB, AC, Wireless).
    • Moved the charging-only requirement check into isBlocked(syncedFolderId):
      if (syncedFolder.isChargingOnly && !overridePowerSaving && !powerManagementService.battery.isCharging) {
          return true
      }
      This properly supports Samsung Protect Battery and Android 15/16 charging limits without failing at 80%.
  3. Decouple requiresCharging from WorkManager's Constraints:
    • Removed .setRequiresCharging(requiresCharging) from SupportedNetworkTransports.getConstraints(...).
    • Because isBlocked() handles the check accurately via PowerManagementService, WorkManager's internal and flawed BatteryChargingTracker is no longer triggered on foreground promotion.
  4. Postpone trySetForeground():
    • Moved trySetForeground() from the very start of doWork() down to immediately before uploadFiles(syncedFolder).
    • When checking folders with no changes or when hasNothingToDo() is true, the worker scans and finishes cleanly in the background without creating notification flicker or attaching foreground constraint listeners.

Diagnostics & Trace Data

Full Perfetto traces (~138 MB) and bugreport zips (~92 MB) captured during this live incident are available if maintainers require further inspection.

@JanisPlayer
JanisPlayer marked this pull request as draft November 5, 2026 12:59
@JanisPlayer

Copy link
Copy Markdown
Author

Apologies to the maintainers! My AI agent was a bit too enthusiastic and opened this PR prematurely. Please treat this as a 'Draft' or a detailed proposal for now.

The goal was to provide the technical analysis and a potential solution as a comment to save you some investigative work. I still need to properly test the implementation on my device to ensure there are no side effects.

I will update the PR once I've verified the fix, or feel free to use the analysis to implement it your own way! Thanks for your patience. 🙏

@JanisPlayer JanisPlayer mentioned this pull request Nov 10, 2026
4 tasks done
@JanisPlayer

Copy link
Copy Markdown
Author

Note: I'm sharing this AI-generated code review of the initial draft as an additional reference to help with the investigation. As mentioned earlier, due to my limited Kotlin/Android experience, I can't personally judge the code quality or architectural details, but I hope these thoughts are still helpful to you!


Follow-up Review by AI & Proposed Improvements for this Draft

After further AI-assisted review of the current diff and on-device testing (see the live logcat / dumpsys analysis in #17895), the draft stops the rapid worker-restart loop, but a few edge cases should be refined before considering it ready:

1. Return Result.retry() when throttled (using atomic compute and elapsedRealtime)

Currently, the throttle check returns Result.success(), which marks the unique work as completed instead of postponing it. If an upload was interrupted shortly before, it may not be retried until the next periodic rescan.

Since startAutoUpload() already configures BackoffPolicy.LINEAR with a 300-second delay, returning Result.retry() preserves the pending work and retries it with backoff. Using SystemClock.elapsedRealtime() inside an atomic compute block also avoids wall-clock issues and check-then-act races:

private fun isThrottled(syncFolderId: Long): Boolean {
    val now = SystemClock.elapsedRealtime()
    var throttled = false

    lastExecutionTimestampByFolder.compute(syncFolderId) { _, lastExecution ->
        if (lastExecution != null && now - lastExecution < MIN_EXECUTION_INTERVAL_MS) {
            throttled = true
            lastExecution
        } else {
            now
        }
    }

    return throttled
}

2. Remove the charging check from isBlocked() and check conditions after hasNothingToDo()

  • The charging check introduced in isBlocked() by this draft should be removed; otherwise, it exits with Result.success() before reaching the retry logic below.
  • Moving the throttle and condition checks after hasNothingToDo() ensures that empty scans finish immediately with Result.success() instead of consuming a retry attempt, while protecting the foreground-promotion (trySetForeground()) and upload phase from rapid restart loops.
  • Using getUploadSettings(syncedFolder) instead of syncedFolder.isChargingOnly directly also keeps behavior consistent with branded/light configurations (R.bool.syncedFolder_light) and allows checking Wi-Fi before starting the foreground service:
// Inside doWork(), after isBlocked(syncFolderId) and autoUploadHelper.insertEntries(syncedFolder):
if (hasNothingToDo()) {
    return Result.success()
}

if (!overridePowerSaving && isThrottled(syncFolderId)) {
    Log_OC.w(TAG, "Throttling rapid restart for folder $syncFolderId. Retrying with backoff.")
    return Result.retry()
}

val (needsCharging, needsWifi, _) = getUploadSettings(syncedFolder)

if (needsCharging && !overridePowerSaving &&
    !powerManagementService.battery.isCharging
) {
    Log_OC.d(TAG, "Postponing upload: charging required, but device is not connected to power")
    return Result.retry()
}

if (needsWifi && !connectivityService.connectivity.isWifi) {
    Log_OC.d(TAG, "Postponing upload: Wi-Fi required, but condition not met")
    return Result.retry()
}

trySetForeground()
uploadFiles(syncedFolder)

3. Architectural trade-off regarding setRequiresCharging

On the affected Samsung device, disabling the 80% battery-protection limit stopped the loop (EXTRA_PLUGGED > 0 vs. EXTRA_STATUS = 4 / NOT_CHARGING), confirming that the reported battery status conflicts with WorkManager’s charging constraint when promoting the worker to a foreground service.

Removing setRequiresCharging and returning Result.retry() avoids this constraint loop and preserves pending work, with the trade-off that reconnecting power or Wi-Fi will not necessarily trigger an immediate run—execution remains subject to the linear backoff delay (5, 10, 15 minutes, etc.) or the next periodic rescan.

Does removing the charging constraint and checking the effective charging/Wi-Fi requirements before foreground promotion fit the intended architecture, or would you prefer a different scheduling strategy to handle this constraint mismatch?

@alperozturk96

Copy link
Copy Markdown
Collaborator

Replaced via #18049.

Because it hides the bug instead of fixing it. No need for throttle and ignore user's choice. Removing setRequiresCharging
That constraint is what starts the upload when the phone gets plugged in. Without it, uploads wait for the 15-minute rescan, and the worker starts on every photo and every rescan just to exit right away.

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.

2 participants