1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.adservices.shared.spe.framework;
18 
19 import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_ERROR_REPORTED__ERROR_CODE__SPE_JOB_EXECUTION_FAILURE;
20 import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_ERROR_REPORTED__ERROR_CODE__SPE_JOB_ON_STOP_EXECUTION_FAILURE;
21 import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__COMMON;
22 import static com.android.adservices.shared.spe.JobServiceConstants.ERROR_CODE_JOB_SCHEDULER_IS_UNAVAILABLE;
23 import static com.android.adservices.shared.spe.JobServiceConstants.JOB_ENABLED_STATUS_ENABLED;
24 import static com.android.adservices.shared.spe.JobServiceConstants.SKIP_REASON_JOB_NOT_CONFIGURED;
25 import static com.android.adservices.shared.spe.framework.ExecutionResult.CANCELLED_BY_SCHEDULER;
26 import static com.android.adservices.shared.spe.framework.ExecutionResult.FAILURE_WITHOUT_RETRY;
27 import static com.android.adservices.shared.spe.framework.ExecutionResult.FAILURE_WITH_RETRY;
28 import static com.android.adservices.shared.spe.framework.ExecutionRuntimeParameters.convertJobParameters;
29 import static com.android.internal.annotations.VisibleForTesting.Visibility.PROTECTED;
30 
31 import android.annotation.Nullable;
32 import android.app.job.JobParameters;
33 import android.app.job.JobScheduler;
34 import android.app.job.JobService;
35 
36 import com.android.adservices.shared.errorlogging.AdServicesErrorLogger;
37 import com.android.adservices.shared.proto.JobPolicy;
38 import com.android.adservices.shared.spe.JobUtil;
39 import com.android.adservices.shared.spe.logging.JobServiceLogger;
40 import com.android.adservices.shared.spe.scheduling.BackoffPolicy;
41 import com.android.adservices.shared.spe.scheduling.PolicyJobScheduler;
42 import com.android.adservices.shared.util.LogUtil;
43 import com.android.internal.annotations.VisibleForTesting;
44 
45 import com.google.common.util.concurrent.FluentFuture;
46 import com.google.common.util.concurrent.ListenableFuture;
47 
48 import java.util.Map;
49 import java.util.concurrent.CancellationException;
50 import java.util.concurrent.ConcurrentHashMap;
51 import java.util.concurrent.Executor;
52 
53 /**
54  * The execution part of SPE (Scheduling Policy Engine) framework on top of Platform's {@link
55  * JobScheduler} to provide simple and reliable background job implementations. See the scheduling
56  * part in {@link PolicyJobScheduler}.
57  *
58  * <p>To onboard SPE instance for your own module, it needs to,
59  *
60  * <ul>
61  *   <li>Implement this class {@link AbstractJobService} by providing a {@link JobServiceFactory}
62  *       that configures specific components for your module. See details in {@link
63  *       JobServiceFactory}.
64  *   <li>Register your module's instance of {@link AbstractJobService} in the Manifest.xml as a
65  *       service.
66  *   <li>Create an instance of {@link PolicyJobScheduler} for your module and use this instance to
67  *       schedule jobs in your module.
68  *   <li>(Optional) Create a Flag in the flag server and point {@link
69  *       JobServiceFactory#getModuleJobPolicy()} to this flag to get the encoded String, so that the
70  *       {@link PolicyJobScheduler} can sync the {@link JobPolicy} from the flag server.
71  * </ul>
72  */
73 public abstract class AbstractJobService extends JobService {
74     // Store the running listenable futures used by onStopJob() to cancel the running execution
75     // future if there is any unsatisfied constraint. Note the instance of this Service class won't
76     // be destroyed if onStartJob() hasn't finished. And in onStopJob(), the live listenable future
77     // will be cancelled. Therefore, the lifetime of the futures stored in this map should be no
78     // longer than the lifetime of this Service class.
79     protected final ConcurrentHashMap<Integer, ListenableFuture<ExecutionResult>>
80             mRunningFuturesMap = new ConcurrentHashMap<>();
81 
82     private JobServiceFactory mJobServiceFactory;
83     private JobServiceLogger mJobServiceLogger;
84     private AdServicesErrorLogger mErrorLogger;
85     private Executor mExecutor;
86     private Map<Integer, String> mJobIdToNameMap;
87 
getJobServiceFactory()88     protected abstract JobServiceFactory getJobServiceFactory();
89 
90     @Override
onCreate()91     public void onCreate() {
92         super.onCreate();
93         mJobServiceFactory = getJobServiceFactory();
94         mJobServiceLogger = mJobServiceFactory.getJobServiceLogger();
95         mExecutor = mJobServiceFactory.getBackgroundExecutor();
96         mJobIdToNameMap = mJobServiceFactory.getJobIdToNameMap();
97         mErrorLogger = mJobServiceFactory.getErrorLogger();
98     }
99 
100     @Override
onStartJob(JobParameters params)101     public boolean onStartJob(JobParameters params) {
102         int jobId = params.getJobId();
103         JobUtil.logV("Starting executing onStartJob() for jobId = %d.", jobId);
104 
105         mJobServiceLogger.recordOnStartJob(jobId);
106 
107         String jobName = getJobName(params, jobId);
108         if (jobName == null) {
109             return false;
110         }
111 
112         LogUtil.v("Running onStartJob() for %s, jobId = %d.", jobName, jobId);
113 
114         JobWorker worker = getJobWorker(params, jobId, jobName);
115         if (worker == null) {
116             return false;
117         }
118 
119         JobUtil.logV("Begin job execution for %s...", jobName);
120         ListenableFuture<ExecutionResult> executionFuture = mRunningFuturesMap.get(jobId);
121         // Cancel the unfinished future for the same job. This should rarely happen due to
122         // JobScheduler doesn't call onStartJob() again before the end of previous call on the same
123         // job (with the same job ID). Nevertheless, do the defensive programming here.
124         if (executionFuture != null) {
125             executionFuture.cancel(/* mayInterruptIfRunning= */ true);
126         }
127         executionFuture = worker.getExecutionFuture(this, convertJobParameters(params));
128         mRunningFuturesMap.put(jobId, executionFuture);
129 
130         // Add Logging callback.
131         // TODO(b/331285831): Standardize the usage of unused future.
132         ListenableFuture<Void> unusedFuture =
133                 onJobPostExecution(
134                         executionFuture, jobId, jobName, params, worker.getBackoffPolicy());
135 
136         return true;
137     }
138 
139     @Override
onStopJob(JobParameters params)140     public boolean onStopJob(JobParameters params) {
141         int jobId = params.getJobId();
142         String jobName = getJobName(params, jobId);
143 
144         JobUtil.logV("Running onStopJob() for %s...", jobName);
145 
146         JobWorker worker = getJobWorker(params, jobId, jobName);
147         // This should never happen as onStartJob() has done the null check, but do it here to
148         // bypass the null-check warning below.
149         if (worker == null) {
150             return false;
151         }
152 
153         // Cancel the running execution future.
154         ListenableFuture<ExecutionResult> executionFuture = mRunningFuturesMap.get(jobId);
155         if (executionFuture != null) {
156             executionFuture.cancel(/* mayInterruptIfRunning= */ true);
157         }
158 
159         // Execute customized logic if the execution is stopped by the JobScheduler.
160         // TODO(b/326150705): Add a callback for this future and maybe log the result.
161         @SuppressWarnings("unused")
162         ListenableFuture<Void> executionStopFuture =
163                 FluentFuture.from(worker.getExecutionStopFuture(this, convertJobParameters(params)))
164                         .catching(
165                                 RuntimeException.class,
166                                 e -> {
167                                     LogUtil.e(
168                                             e,
169                                             "The customized logic in onStopJob() encounters error"
170                                                     + " for %s!",
171                                             jobName);
172                                     mErrorLogger.logErrorWithExceptionInfo(
173                                             e,
174                                             AD_SERVICES_ERROR_REPORTED__ERROR_CODE__SPE_JOB_ON_STOP_EXECUTION_FAILURE,
175                                             AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__COMMON);
176                                     return null;
177                                 },
178                                 mExecutor);
179 
180         // Add logging.
181         boolean shouldReschedule = worker.getBackoffPolicy().shouldRetryOnExecutionStop();
182         mJobServiceLogger.recordOnStopJob(params, jobId, shouldReschedule);
183 
184         return shouldReschedule;
185     }
186 
187     /**
188      * Skips the execution and also cancels the job.
189      *
190      * @param params the {@link JobParameters} when the execution is invoked.
191      * @param skipReason the reason to skip the current job.
192      */
193     @VisibleForTesting(visibility = PROTECTED)
skipAndCancelBackgroundJob(JobParameters params, int skipReason)194     public void skipAndCancelBackgroundJob(JobParameters params, int skipReason) {
195         mExecutor.execute(
196                 () -> {
197                     int jobId = params.getJobId();
198                     JobScheduler jobScheduler = this.getSystemService(JobScheduler.class);
199 
200                     if (jobScheduler != null) {
201                         jobScheduler.cancel(jobId);
202                         LogUtil.d("Job %d has been cancelled.", jobId);
203                     } else {
204                         LogUtil.e(
205                                 "Cannot fetch JobScheduler! Failed to cancel %s.",
206                                 mJobIdToNameMap.get(jobId));
207                         mErrorLogger.logError(
208                                 ERROR_CODE_JOB_SCHEDULER_IS_UNAVAILABLE,
209                                 AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__COMMON);
210                     }
211 
212                     mJobServiceLogger.recordJobSkipped(jobId, skipReason);
213 
214                     jobFinished(params, /* wantsReschedule= */ false);
215                 });
216     }
217 
218     @Nullable
getJobWorker(JobParameters params, int jobId, String jobName)219     protected JobWorker getJobWorker(JobParameters params, int jobId, String jobName) {
220         JobWorker worker = mJobServiceFactory.getJobWorkerInstance(jobId);
221         if (worker == null) {
222             skipForJobInfoNotConfigured(params);
223             return null;
224         }
225 
226         int isEnabled = worker.getJobEnablementStatus();
227         if (isEnabled != JOB_ENABLED_STATUS_ENABLED) {
228             LogUtil.v("Stop execution and cancel %s due to reason %d", jobName, isEnabled);
229 
230             skipAndCancelBackgroundJob(params, isEnabled);
231             return null;
232         }
233 
234         return worker;
235     }
236 
237     // Logs the execution stats and decides whether to retry the job.
238     @VisibleForTesting
onJobPostExecution( ListenableFuture<ExecutionResult> executionFuture, int jobId, String jobName, JobParameters params, BackoffPolicy backoffPolicy)239     ListenableFuture<Void> onJobPostExecution(
240             ListenableFuture<ExecutionResult> executionFuture,
241             int jobId,
242             String jobName,
243             JobParameters params,
244             BackoffPolicy backoffPolicy) {
245         return FluentFuture.from(executionFuture)
246                 // Handle execution failures. Fall back to RETRY or FAILURE based on BackoffPolicy.
247                 .catching(
248                         Exception.class,
249                         e -> {
250                             LogUtil.e(e, "Encounter Job Execution Failure for Job %s.", jobName);
251 
252                             if (e instanceof CancellationException) {
253                                 return CANCELLED_BY_SCHEDULER;
254                             }
255 
256                             mErrorLogger.logErrorWithExceptionInfo(
257                                     e,
258                                     AD_SERVICES_ERROR_REPORTED__ERROR_CODE__SPE_JOB_EXECUTION_FAILURE,
259                                     AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__COMMON);
260 
261                             return backoffPolicy.shouldRetryOnExecutionFailure()
262                                     ? FAILURE_WITH_RETRY
263                                     : FAILURE_WITHOUT_RETRY;
264                         },
265                         mExecutor)
266                 .transform(
267                         executionResult -> {
268                             JobUtil.logV(
269                                     "Job execution is finished for %s, result is %s.",
270                                     jobName, executionResult);
271 
272                             // onStopJob() handles both logging and whether to retry. Skip them
273                             // here.
274                             if (!executionResult.equals(CANCELLED_BY_SCHEDULER)) {
275                                 mJobServiceLogger.recordJobFinished(jobId, executionResult);
276                             }
277 
278                             jobFinished(params, executionResult.equals(FAILURE_WITH_RETRY));
279                             return null;
280                         },
281                         mExecutor);
282     }
283 
284     @VisibleForTesting
skipForJobInfoNotConfigured(JobParameters params)285     protected void skipForJobInfoNotConfigured(JobParameters params) {
286         LogUtil.e(
287                 "Disabling %d job because it's not configured in JobInfo or"
288                         + " JobConfig! Please check the setup.",
289                 params.getJobId());
290 
291         skipAndCancelBackgroundJob(params, SKIP_REASON_JOB_NOT_CONFIGURED);
292     }
293 
getJobName(JobParameters parameters, int jobId)294     private String getJobName(JobParameters parameters, int jobId) {
295         if (mJobIdToNameMap == null) {
296             skipForJobInfoNotConfigured(parameters);
297             return null;
298         }
299 
300         String jobName = mJobIdToNameMap.get(jobId);
301 
302         if (jobName == null) {
303             skipForJobInfoNotConfigured(parameters);
304             return null;
305         }
306 
307         return jobName;
308     }
309 }
310