diff --git a/services/core/java/com/android/server/job/controllers/QuotaController.java b/services/core/java/com/android/server/job/controllers/QuotaController.java index 5a0b991bc7de0..1820acfaf2937 100644 --- a/services/core/java/com/android/server/job/controllers/QuotaController.java +++ b/services/core/java/com/android/server/job/controllers/QuotaController.java @@ -769,6 +769,91 @@ public final class QuotaController extends StateController { mMaxExecutionTimeMs - stats.executionTimeInMaxPeriodMs); } + /** + * Returns the amount of time, in milliseconds, until the package would have reached its + * duration quota, assuming it has a job counting towards its quota the entire time. This takes + * into account any {@link TimingSession}s that may roll out of the window as the job is + * running. + */ + @VisibleForTesting + long getTimeUntilQuotaConsumedLocked(final int userId, @NonNull final String packageName) { + final long nowElapsed = sElapsedRealtimeClock.millis(); + final int standbyBucket = JobSchedulerService.standbyBucketForPackage( + packageName, userId, nowElapsed); + if (standbyBucket == NEVER_INDEX) { + return 0; + } + List sessions = mTimingSessions.get(userId, packageName); + if (sessions == null || sessions.size() == 0) { + return mAllowedTimePerPeriodMs; + } + + final ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket); + final long startWindowElapsed = nowElapsed - stats.windowSizeMs; + final long startMaxElapsed = nowElapsed - MAX_PERIOD_MS; + final long allowedTimeRemainingMs = mAllowedTimePerPeriodMs - stats.executionTimeInWindowMs; + final long maxExecutionTimeRemainingMs = + mMaxExecutionTimeMs - stats.executionTimeInMaxPeriodMs; + + // Regular ACTIVE case. Since the bucket size equals the allowed time, the app jobs can + // essentially run until they reach the maximum limit. + if (stats.windowSizeMs == mAllowedTimePerPeriodMs) { + return calculateTimeUntilQuotaConsumedLocked( + sessions, startMaxElapsed, maxExecutionTimeRemainingMs); + } + + // Need to check both max time and period time in case one is less than the other. + // For example, max time remaining could be less than bucket time remaining, but sessions + // contributing to the max time remaining could phase out enough that we'd want to use the + // bucket value. + return Math.min( + calculateTimeUntilQuotaConsumedLocked( + sessions, startMaxElapsed, maxExecutionTimeRemainingMs), + calculateTimeUntilQuotaConsumedLocked( + sessions, startWindowElapsed, allowedTimeRemainingMs)); + } + + /** + * Calculates how much time it will take, in milliseconds, until the quota is fully consumed. + * + * @param windowStartElapsed The start of the window, in the elapsed realtime timebase. + * @param deadSpaceMs How much time can be allowed to count towards the quota + */ + private long calculateTimeUntilQuotaConsumedLocked(@NonNull List sessions, + final long windowStartElapsed, long deadSpaceMs) { + long timeUntilQuotaConsumedMs = 0; + long start = windowStartElapsed; + for (int i = 0; i < sessions.size(); ++i) { + TimingSession session = sessions.get(i); + + if (session.endTimeElapsed < windowStartElapsed) { + // Outside of window. Ignore. + continue; + } else if (session.startTimeElapsed <= windowStartElapsed) { + // Overlapping session. Can extend time by portion of session in window. + timeUntilQuotaConsumedMs += session.endTimeElapsed - windowStartElapsed; + start = session.endTimeElapsed; + } else { + // Completely within the window. Can only consider if there's enough dead space + // to get to the start of the session. + long diff = session.startTimeElapsed - start; + if (diff > deadSpaceMs) { + break; + } + timeUntilQuotaConsumedMs += diff + + (session.endTimeElapsed - session.startTimeElapsed); + deadSpaceMs -= diff; + start = session.endTimeElapsed; + } + } + // Will be non-zero if the loop didn't look at any sessions. + timeUntilQuotaConsumedMs += deadSpaceMs; + if (timeUntilQuotaConsumedMs > mMaxExecutionTimeMs) { + Slog.wtf(TAG, "Calculated quota consumed time too high: " + timeUntilQuotaConsumedMs); + } + return timeUntilQuotaConsumedMs; + } + /** Returns the execution stats of the app in the most recent window. */ @VisibleForTesting @NonNull @@ -1483,7 +1568,7 @@ public final class QuotaController extends StateController { return; } Message msg = mHandler.obtainMessage(MSG_REACHED_QUOTA, mPkg); - final long timeRemainingMs = getRemainingExecutionTimeLocked(mPkg.userId, + final long timeRemainingMs = getTimeUntilQuotaConsumedLocked(mPkg.userId, mPkg.packageName); if (DEBUG) { Slog.i(TAG, "Job for " + mPkg + " has " + timeRemainingMs + "ms left."); @@ -1642,6 +1727,8 @@ public final class QuotaController extends StateController { // job is currently running. // Reschedule message Message rescheduleMsg = obtainMessage(MSG_REACHED_QUOTA, pkg); + timeRemainingMs = getTimeUntilQuotaConsumedLocked(pkg.userId, + pkg.packageName); if (DEBUG) { Slog.d(TAG, pkg + " has " + timeRemainingMs + "ms left."); } diff --git a/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java b/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java index cad71a26a76b7..08f6a372de861 100644 --- a/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java +++ b/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java @@ -722,6 +722,147 @@ public class QuotaControllerTest { assertEquals(expectedStats, newStatsRare); } + /** + * Test getTimeUntilQuotaConsumedLocked when the determination is based within the bucket + * window. + */ + @Test + public void testGetTimeUntilQuotaConsumedLocked_BucketWindow() { + final long now = JobSchedulerService.sElapsedRealtimeClock.millis(); + // Close to RARE boundary. + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession(now - (24 * HOUR_IN_MILLIS - 30 * SECOND_IN_MILLIS), + 30 * SECOND_IN_MILLIS, 5)); + // Far away from FREQUENT boundary. + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession(now - (7 * HOUR_IN_MILLIS), 3 * MINUTE_IN_MILLIS, 5)); + // Overlap WORKING_SET boundary. + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession(now - (2 * HOUR_IN_MILLIS + MINUTE_IN_MILLIS), + 3 * MINUTE_IN_MILLIS, 5)); + // Close to ACTIVE boundary. + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession(now - (9 * MINUTE_IN_MILLIS), 3 * MINUTE_IN_MILLIS, 5)); + + setStandbyBucket(RARE_INDEX); + assertEquals(30 * SECOND_IN_MILLIS, + mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + assertEquals(MINUTE_IN_MILLIS, + mQuotaController.getTimeUntilQuotaConsumedLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + setStandbyBucket(FREQUENT_INDEX); + assertEquals(MINUTE_IN_MILLIS, + mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + assertEquals(MINUTE_IN_MILLIS, + mQuotaController.getTimeUntilQuotaConsumedLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + setStandbyBucket(WORKING_INDEX); + assertEquals(5 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + assertEquals(7 * MINUTE_IN_MILLIS, + mQuotaController.getTimeUntilQuotaConsumedLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // ACTIVE window = allowed time, so jobs can essentially run non-stop until they reach the + // max execution time. + setStandbyBucket(ACTIVE_INDEX); + assertEquals(7 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + assertEquals(mConstants.QUOTA_CONTROLLER_MAX_EXECUTION_TIME_MS - 9 * MINUTE_IN_MILLIS, + mQuotaController.getTimeUntilQuotaConsumedLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + } + + /** + * Test getTimeUntilQuotaConsumedLocked when the app is close to the max execution limit. + */ + @Test + public void testGetTimeUntilQuotaConsumedLocked_MaxExecution() { + final long now = JobSchedulerService.sElapsedRealtimeClock.millis(); + // Overlap boundary. + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession( + now - (24 * HOUR_IN_MILLIS + 8 * MINUTE_IN_MILLIS), 4 * HOUR_IN_MILLIS, 5)); + + setStandbyBucket(WORKING_INDEX); + assertEquals(8 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + // Max time will phase out, so should use bucket limit. + assertEquals(10 * MINUTE_IN_MILLIS, + mQuotaController.getTimeUntilQuotaConsumedLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE).clear(); + // Close to boundary. + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession(now - (24 * HOUR_IN_MILLIS - MINUTE_IN_MILLIS), + 4 * HOUR_IN_MILLIS - 5 * MINUTE_IN_MILLIS, 5)); + + setStandbyBucket(WORKING_INDEX); + assertEquals(5 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + assertEquals(10 * MINUTE_IN_MILLIS, + mQuotaController.getTimeUntilQuotaConsumedLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE).clear(); + // Far from boundary. + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession( + now - (20 * HOUR_IN_MILLIS), 4 * HOUR_IN_MILLIS - 3 * MINUTE_IN_MILLIS, 5)); + + setStandbyBucket(WORKING_INDEX); + assertEquals(3 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + assertEquals(3 * MINUTE_IN_MILLIS, + mQuotaController.getTimeUntilQuotaConsumedLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + } + + /** + * Test getTimeUntilQuotaConsumedLocked when the max execution time and bucket window time + * remaining are equal. + */ + @Test + public void testGetTimeUntilQuotaConsumedLocked_EqualTimeRemaining() { + final long now = JobSchedulerService.sElapsedRealtimeClock.millis(); + setStandbyBucket(FREQUENT_INDEX); + + // Overlap boundary. + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession( + now - (24 * HOUR_IN_MILLIS + 11 * MINUTE_IN_MILLIS), + 4 * HOUR_IN_MILLIS, + 5)); + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession( + now - (8 * HOUR_IN_MILLIS + MINUTE_IN_MILLIS), 3 * MINUTE_IN_MILLIS, 5)); + + // Both max and bucket time have 8 minutes left. + assertEquals(8 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + // Max time essentially free. Bucket time has 2 min phase out plus original 8 minute + // window time. + assertEquals(10 * MINUTE_IN_MILLIS, + mQuotaController.getTimeUntilQuotaConsumedLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE).clear(); + // Overlap boundary. + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession( + now - (24 * HOUR_IN_MILLIS + MINUTE_IN_MILLIS), 2 * MINUTE_IN_MILLIS, 5)); + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession( + now - (20 * HOUR_IN_MILLIS), + 3 * HOUR_IN_MILLIS + 48 * MINUTE_IN_MILLIS, + 5)); + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession( + now - (8 * HOUR_IN_MILLIS + MINUTE_IN_MILLIS), 3 * MINUTE_IN_MILLIS, 5)); + + // Both max and bucket time have 8 minutes left. + assertEquals(8 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + // Max time only has one minute phase out. Bucket time has 2 minute phase out. + assertEquals(9 * MINUTE_IN_MILLIS, + mQuotaController.getTimeUntilQuotaConsumedLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + } + @Test public void testIsWithinQuotaLocked_NeverApp() { assertFalse(mQuotaController.isWithinQuotaLocked(0, "com.android.test.never", NEVER_INDEX)); @@ -1902,7 +2043,10 @@ public class QuotaControllerTest { // window, so as the package "reaches its quota" it will have more to keep running. mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, createTimingSession(now - 2 * HOUR_IN_MILLIS, - 10 * MINUTE_IN_MILLIS - remainingTimeMs, 1)); + 10 * SECOND_IN_MILLIS - remainingTimeMs, 1)); + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, + createTimingSession(now - HOUR_IN_MILLIS, + 9 * MINUTE_IN_MILLIS + 50 * SECOND_IN_MILLIS, 1)); assertEquals(remainingTimeMs, mQuotaController.getRemainingExecutionTimeLocked(jobStatus)); // Start the job. @@ -1919,6 +2063,18 @@ public class QuotaControllerTest { // amount of remaining time left its quota. assertEquals(remainingTimeMs, mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); - verify(handler, atLeast(1)).sendMessageDelayed(any(), eq(remainingTimeMs)); + // Handler is told to check when the quota will be consumed, not when the initial + // remaining time is over. + verify(handler, atLeast(1)).sendMessageDelayed(any(), eq(10 * SECOND_IN_MILLIS)); + verify(handler, never()).sendMessageDelayed(any(), eq(remainingTimeMs)); + + // After 10 seconds, the job should finally be out of quota. + advanceElapsedClock(10 * SECOND_IN_MILLIS - remainingTimeMs); + // Wait for some extra time to allow for job processing. + verify(mJobSchedulerService, + timeout(12 * SECOND_IN_MILLIS).times(1)) + .onControllerStateChanged(); + assertFalse(jobStatus.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA)); + verify(handler, never()).sendMessageDelayed(any(), anyInt()); } }