GreedyScheduler.java

/*
 * Copyright 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.work.impl.background.greedy;

import static android.os.Build.VERSION.SDK_INT;

import static androidx.work.WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS;
import static androidx.work.impl.constraints.WorkConstraintsTrackerKt.listen;
import static androidx.work.impl.model.WorkSpecKt.generationalId;

import static java.lang.Math.max;

import android.content.Context;
import android.text.TextUtils;

import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.work.Configuration;
import androidx.work.Logger;
import androidx.work.RunnableScheduler;
import androidx.work.WorkInfo;
import androidx.work.impl.ExecutionListener;
import androidx.work.impl.Processor;
import androidx.work.impl.Scheduler;
import androidx.work.impl.StartStopToken;
import androidx.work.impl.StartStopTokens;
import androidx.work.impl.WorkLauncher;
import androidx.work.impl.constraints.ConstraintsState;
import androidx.work.impl.constraints.OnConstraintsStateChangedListener;
import androidx.work.impl.constraints.WorkConstraintsTracker;
import androidx.work.impl.constraints.trackers.Trackers;
import androidx.work.impl.model.WorkGenerationalId;
import androidx.work.impl.model.WorkSpec;
import androidx.work.impl.utils.ProcessUtils;
import androidx.work.impl.utils.taskexecutor.TaskExecutor;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import kotlinx.coroutines.Job;

/**
 * A greedy {@link Scheduler} that schedules unconstrained, non-timed work.  It intentionally does
 * not acquire any WakeLocks, instead trying to brute-force them as time allows before the process
 * gets killed.
 */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public class GreedyScheduler implements Scheduler, OnConstraintsStateChangedListener,
        ExecutionListener {

    private static final String TAG = Logger.tagWithPrefix("GreedyScheduler");

    /**
     * GreedyScheduler will start throttle workspec if it sees the same work being retried
     * within process's lifetime.
     */
    private static final int NON_THROTTLE_RUN_ATTEMPT_COUNT = 5;

    private final Context mContext;
    private final Map<WorkGenerationalId, Job> mConstrainedWorkSpecs = new HashMap<>();
    private DelayedWorkTracker mDelayedWorkTracker;
    private boolean mRegisteredExecutionListener;
    private final Object mLock = new Object();
    private final StartStopTokens mStartStopTokens = new StartStopTokens();
    private final Processor mProcessor;
    private final WorkLauncher mWorkLauncher;

    private final Configuration mConfiguration;

    private final Map<WorkGenerationalId, AttemptData> mFirstRunAttempts = new HashMap<>();
    // Internal State
    Boolean mInDefaultProcess;

    private final WorkConstraintsTracker mConstraintsTracker;
    private final TaskExecutor mTaskExecutor;
    private final TimeLimiter mTimeLimiter;

    public GreedyScheduler(
            @NonNull Context context,
            @NonNull Configuration configuration,
            @NonNull Trackers trackers,
            @NonNull Processor processor,
            @NonNull WorkLauncher workLauncher,
            @NonNull TaskExecutor taskExecutor
    ) {
        mContext = context;
        RunnableScheduler runnableScheduler = configuration.getRunnableScheduler();
        mDelayedWorkTracker = new DelayedWorkTracker(this, runnableScheduler,
                configuration.getClock());
        mTimeLimiter = new TimeLimiter(runnableScheduler, workLauncher);
        mTaskExecutor = taskExecutor;
        mConstraintsTracker = new WorkConstraintsTracker(trackers);
        mConfiguration = configuration;
        mProcessor = processor;
        mWorkLauncher = workLauncher;
    }

    @VisibleForTesting
    public void setDelayedWorkTracker(@NonNull DelayedWorkTracker delayedWorkTracker) {
        mDelayedWorkTracker = delayedWorkTracker;
    }

    @Override
    public boolean hasLimitedSchedulingSlots() {
        return false;
    }

    @Override
    public void schedule(@NonNull WorkSpec... workSpecs) {
        if (mInDefaultProcess == null) {
            checkDefaultProcess();
        }

        if (!mInDefaultProcess) {
            Logger.get().info(TAG, "Ignoring schedule request in a secondary process");
            return;
        }

        registerExecutionListenerIfNeeded();

        // Keep track of the list of new WorkSpecs whose constraints need to be tracked.
        // Add them to the known list of constrained WorkSpecs and call replace() on
        // WorkConstraintsTracker. That way we only need to synchronize on the part where we
        // are updating mConstrainedWorkSpecs.
        Set<WorkSpec> constrainedWorkSpecs = new HashSet<>();
        Set<String> constrainedWorkSpecIds = new HashSet<>();

        for (WorkSpec workSpec : workSpecs) {
            // it doesn't help against races, but reduces useless load in the system
            WorkGenerationalId id = generationalId(workSpec);
            if (mStartStopTokens.contains(id)) {
                continue;
            }
            long throttled = throttleIfNeeded(workSpec);
            long nextRunTime = max(workSpec.calculateNextRunTime(), throttled);
            long now = mConfiguration.getClock().currentTimeMillis();
            if (workSpec.state == WorkInfo.State.ENQUEUED) {
                if (now < nextRunTime) {
                    // Future work
                    if (mDelayedWorkTracker != null) {
                        mDelayedWorkTracker.schedule(workSpec, nextRunTime);
                    }
                } else if (workSpec.hasConstraints()) {
                    if (SDK_INT >= 23 && workSpec.constraints.requiresDeviceIdle()) {
                        // Ignore requests that have an idle mode constraint.
                        Logger.get().debug(TAG,
                                "Ignoring " + workSpec + ". Requires device idle.");
                    } else if (SDK_INT >= 24 && workSpec.constraints.hasContentUriTriggers()) {
                        // Ignore requests that have content uri triggers.
                        Logger.get().debug(TAG,
                                "Ignoring " + workSpec + ". Requires ContentUri triggers.");
                    } else {
                        constrainedWorkSpecs.add(workSpec);
                        constrainedWorkSpecIds.add(workSpec.id);
                    }
                } else {
                    // it doesn't help against races, but reduces useless load in the system
                    if (!mStartStopTokens.contains(generationalId(workSpec))) {
                        Logger.get().debug(TAG, "Starting work for " + workSpec.id);
                        StartStopToken token = mStartStopTokens.tokenFor(workSpec);
                        mTimeLimiter.track(token);
                        mWorkLauncher.startWork(token);
                    }
                }
            }
        }

        // onExecuted() which is called on the main thread also modifies the list of mConstrained
        // WorkSpecs. Therefore we need to lock here.
        synchronized (mLock) {
            if (!constrainedWorkSpecs.isEmpty()) {
                String formattedIds = TextUtils.join(",", constrainedWorkSpecIds);
                Logger.get().debug(TAG, "Starting tracking for " + formattedIds);
                for (WorkSpec spec: constrainedWorkSpecs) {
                    WorkGenerationalId id = generationalId(spec);
                    if (!mConstrainedWorkSpecs.containsKey(id)) {
                        Job job = listen(mConstraintsTracker, spec,
                                mTaskExecutor.getTaskCoroutineDispatcher(), this);
                        mConstrainedWorkSpecs.put(id, job);
                    }
                }
            }
        }
    }

    private void checkDefaultProcess() {
        mInDefaultProcess = ProcessUtils.isDefaultProcess(mContext, mConfiguration);
    }

    @Override
    public void cancel(@NonNull String workSpecId) {
        if (mInDefaultProcess == null) {
            checkDefaultProcess();
        }

        if (!mInDefaultProcess) {
            Logger.get().info(TAG, "Ignoring schedule request in non-main process");
            return;
        }

        registerExecutionListenerIfNeeded();
        Logger.get().debug(TAG, "Cancelling work ID " + workSpecId);
        if (mDelayedWorkTracker != null) {
            mDelayedWorkTracker.unschedule(workSpecId);
        }
        // onExecutionCompleted does the cleanup.
        for (StartStopToken id : mStartStopTokens.remove(workSpecId)) {
            mTimeLimiter.cancel(id);
            mWorkLauncher.stopWork(id);
        }
    }

    @Override
    public void onConstraintsStateChanged(@NonNull WorkSpec workSpec,
            @NonNull ConstraintsState state) {
        WorkGenerationalId id = generationalId(workSpec);
        if (state instanceof ConstraintsState.ConstraintsMet) {
            // it doesn't help against races, but reduces useless load in the system
            if (!mStartStopTokens.contains(id)) {
                Logger.get().debug(TAG, "Constraints met: Scheduling work ID " + id);
                StartStopToken token = mStartStopTokens.tokenFor(id);
                mTimeLimiter.track(token);
                mWorkLauncher.startWork(token);
            }
        } else {
            Logger.get().debug(TAG, "Constraints not met: Cancelling work ID " + id);
            StartStopToken runId = mStartStopTokens.remove(id);
            if (runId != null) {
                mTimeLimiter.cancel(runId);
                int reason = ((ConstraintsState.ConstraintsNotMet) state).getReason();
                mWorkLauncher.stopWorkWithReason(runId, reason);
            }
        }
    }

    @Override
    public void onExecuted(@NonNull WorkGenerationalId id, boolean needsReschedule) {
        StartStopToken token = mStartStopTokens.remove(id);
        if (token != null) {
            mTimeLimiter.cancel(token);
        }
        removeConstraintTrackingFor(id);

        if (!needsReschedule) {
            // finished execution rather than being interrupted
            synchronized (mLock) {
                mFirstRunAttempts.remove(id);
            }
        }
        // onExecuted does not need to worry about unscheduling WorkSpecs with the mDelayedTracker.
        // This is because, after onExecuted(), all schedulers are asked to cancel.
    }

    private void removeConstraintTrackingFor(@NonNull WorkGenerationalId id) {
        Job job;
        synchronized (mLock) {
            // This is synchronized because onExecuted is on the main thread but
            // Schedulers#schedule() can modify the list of mConstrainedWorkSpecs on the task
            // executor thread.
            job = mConstrainedWorkSpecs.remove(id);
        }
        if (job != null) {
            Logger.get().debug(TAG, "Stopping tracking for " + id);
            job.cancel(null);
        }
    }

    private void registerExecutionListenerIfNeeded() {
        // This method needs to be called *after* Processor is created, since Processor needs
        // Schedulers and is created after this class.
        if (!mRegisteredExecutionListener) {
            mProcessor.addExecutionListener(this);
            mRegisteredExecutionListener = true;
        }
    }

    private long throttleIfNeeded(WorkSpec workSpec) {
        synchronized (mLock) {
            WorkGenerationalId id = generationalId(workSpec);
            AttemptData firstRunAttempt = mFirstRunAttempts.get(id);
            if (firstRunAttempt == null) {
                firstRunAttempt = new AttemptData(workSpec.runAttemptCount,
                        mConfiguration.getClock().currentTimeMillis());
                mFirstRunAttempts.put(id, firstRunAttempt);
            }
            return firstRunAttempt.mTimeStamp
                    + max(workSpec.runAttemptCount - firstRunAttempt.mRunAttemptCount
                    - NON_THROTTLE_RUN_ATTEMPT_COUNT, 0) * DEFAULT_BACKOFF_DELAY_MILLIS;
        }
    }

    private static class AttemptData {
        final int mRunAttemptCount;
        final long mTimeStamp;

        private AttemptData(int runAttemptCount, long timeStamp) {
            this.mRunAttemptCount = runAttemptCount;
            this.mTimeStamp = timeStamp;
        }
    }
}