From e3e1640c6123ebc026ab1ec5a16d5daca4ced908 Mon Sep 17 00:00:00 2001 From: Kweku Adams Date: Tue, 26 Mar 2019 15:48:51 -0700 Subject: [PATCH] Reducing quota expired check churn. QuotaController would check if an app had used up all of its quota every time the 'time remaining' had passed. However, if there was a session at the very beginning of the window, the app would still have quota since it would phase out as time went on. QC would then reschedule another check for the same amount of time in the future and this could continue for a while. We avoid this repeated and unnecessary check by including any session phase outs as part of the calculation for when to check if the quota has expired. Bug: 126948148 Test: atest com.android.server.job.controllers.QuotaControllerTest Test: atest CtsJobSchedulerTestCases Change-Id: I0bfa0163b243558c2834ba6e460db63124425d9a --- .../job/controllers/QuotaController.java | 89 +++++++++- .../job/controllers/QuotaControllerTest.java | 160 +++++++++++++++++- 2 files changed, 246 insertions(+), 3 deletions(-) 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()); } }