Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit f811ee04 authored by Amith Yamasani's avatar Amith Yamasani
Browse files

Introduce RateLimitingCache helper class

It allows rate-limiting of AIDL calls that return a value that doesn't
change often and can be cached for retrieval if the caller makes too
many calls in close succession. Use for API calls that don't have
side-effects on the other end of the AIDL.

Bug: 345299715
Test: atest RateLimitingCacheTest
Flag: EXEMPT bugfix

Change-Id: I7703379183a993f7aa58270cce3d9c5c1b839978
parent 6a3e6b1a
Loading
Loading
Loading
Loading
+135 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2024 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 com.android.internal.util;

import android.os.SystemClock;

/**
 * A speed/rate limiting cache that's used to cache a value to be returned as long as period hasn't
 * elapsed and then fetches a new value after period has elapsed. Use this when AIDL calls are
 * expensive but the value returned by those APIs don't change often enough (or the recency doesn't
 * matter as much), to incur the cost every time. This class maintains the last fetch time and
 * fetches a new value when period has passed. Do not use this for API calls that have side-effects.
 * <p>
 * By passing in an optional <code>count</code> during creation, this can be used as a rate
 * limiter that allows up to <code>count</code> calls per period to be passed on to the query
 * and then the cached value is returned for the remainder of the period. It uses a simple fixed
 * window method to track rate. Use a window and count appropriate for bursts of calls and for
 * high latency/cost of the AIDL call.
 *
 * @param <Value> The type of the return value
 * @hide
 */
@android.ravenwood.annotation.RavenwoodKeepWholeClass
public class RateLimitingCache<Value> {

    private volatile Value mCurrentValue;
    private volatile long mLastTimestamp; // Can be last fetch time or window start of fetch time
    private final long mPeriodMillis; // window size
    private final int mLimit; // max per window
    private int mCount = 0; // current count within window
    private long mRandomOffset; // random offset to avoid batching of AIDL calls at window boundary

    /**
     * The interface to fetch the actual value, if the cache is null or expired.
     * @hide
     * @param <V> The return value type
     */
    public interface ValueFetcher<V> {
        /** Called when the cache needs to be updated.
         * @return the latest value fetched from the source
         */
        V fetchValue();
    }

    /**
     * Create a speed limiting cache that returns the same value until periodMillis has passed
     * and then fetches a new value via the {@link ValueFetcher}.
     *
     * @param periodMillis time to wait before fetching a new value. Use a negative period to
     *                     indicate the value never changes and is fetched only once and
     *                     cached. A value of 0 will mean always fetch a new value.
     */
    public RateLimitingCache(long periodMillis) {
        this(periodMillis, 1);
    }

    /**
     * Create a rate-limiting cache that allows up to <code>count</code> number of AIDL calls per
     * period before it starts returning a cached value. The count resets when the next period
     * begins.
     *
     * @param periodMillis the window of time in which <code>count</code> calls will fetch the
     *                     newest value from the AIDL call.
     * @param count how many times during the period it's ok to forward the request to the fetcher
     *              in the {@link #get(ValueFetcher)} method.
     */
    public RateLimitingCache(long periodMillis, int count) {
        mPeriodMillis = periodMillis;
        mLimit = count;
        if (mLimit > 1 && periodMillis > 1) {
            mRandomOffset = (long) (Math.random() * (periodMillis / 2));
        }
    }

    /**
     * Returns the current time in <code>elapsedRealtime</code>. Can be overridden to use
     * a different timebase that is monotonically increasing; for example, uptimeMillis()
     * @return a monotonically increasing time in milliseconds
     */
    protected long getTime() {
        return SystemClock.elapsedRealtime();
    }

    /**
     * Returns either the cached value, if called more frequently than the specific rate, or
     * a new value is fetched and cached. Warning: if the caller is likely to mutate the returned
     * object, override this method and make a clone before returning it.
     * @return the cached or latest value
     */
    public Value get(ValueFetcher<Value> query) {
        // If the value never changes
        if (mPeriodMillis < 0 && mLastTimestamp != 0) {
            return mCurrentValue;
        }

        synchronized (this) {
            // Get the current time and add a random offset to avoid colliding with other
            // caches with similar harmonic window boundaries
            final long now = getTime() + mRandomOffset;
            final boolean newWindow = now - mLastTimestamp >= mPeriodMillis;
            if (newWindow || mCount < mLimit) {
                // Fetch a new value
                mCurrentValue = query.fetchValue();

                // If rate limiting, set timestamp to start of this window
                if (mLimit > 1) {
                    mLastTimestamp = now - (now % mPeriodMillis);
                } else {
                    mLastTimestamp = now;
                }

                if (newWindow) {
                    mCount = 1;
                } else {
                    mCount++;
                }
            }
            return mCurrentValue;
        }
    }
}
+158 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2024 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 com.android.internal.util;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import android.os.SystemClock;

import androidx.test.runner.AndroidJUnit4;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

/**
 * Test the RateLimitingCache class.
 */
@RunWith(AndroidJUnit4.class)
public class RateLimitingCacheTest {

    private int mCounter = 0;

    @Before
    public void before() {
        mCounter = -1;
    }

    RateLimitingCache.ValueFetcher<Integer> mFetcher = () -> {
        return ++mCounter;
    };

    /**
     * Test zero period passed into RateLimitingCache. A new value should be returned for each
     * time the cache's get() is invoked.
     */
    @Test
    public void testTtl_Zero() {
        RateLimitingCache<Integer> s = new RateLimitingCache<>(0);

        int first = s.get(mFetcher);
        assertEquals(first, 0);
        int second = s.get(mFetcher);
        assertEquals(second, 1);
        SystemClock.sleep(20);
        int third = s.get(mFetcher);
        assertEquals(third, 2);
    }

    /**
     * Test a period of 100ms passed into RateLimitingCache. A new value should not be fetched
     * any more frequently than every 100ms.
     */
    @Test
    public void testTtl_100() {
        RateLimitingCache<Integer> s = new RateLimitingCache<>(100);

        int first = s.get(mFetcher);
        assertEquals(first, 0);
        int second = s.get(mFetcher);
        // Too early to change
        assertEquals(second, 0);
        SystemClock.sleep(150);
        int third = s.get(mFetcher);
        // Changed by now
        assertEquals(third, 1);
        int fourth = s.get(mFetcher);
        // Too early to change again
        assertEquals(fourth, 1);
    }

    /**
     * Test a negative period passed into RateLimitingCache. A new value should only be fetched the
     * first call to get().
     */
    @Test
    public void testTtl_Negative() {
        RateLimitingCache<Integer> s = new RateLimitingCache<>(-1);

        int first = s.get(mFetcher);
        assertEquals(first, 0);
        SystemClock.sleep(200);
        // Should return the original value every time
        int second = s.get(mFetcher);
        assertEquals(second, 0);
    }

    /**
     * Test making tons of calls to the speed-limiter and make sure number of fetches does not
     * exceed expected number of fetches.
     */
    @Test
    public void testTtl_Spam() {
        RateLimitingCache<Integer> s = new RateLimitingCache<>(100);
        assertCount(s, 1000, 7, 15);
    }

    /**
     * Test rate-limiting across multiple periods and make sure the expected number of fetches is
     * within the specified rate.
     */
    @Test
    public void testRate_10hz() {
        RateLimitingCache<Integer> s = new RateLimitingCache<>(1000, 10);
        // At 10 per second, 2 seconds should not exceed about 30, assuming overlap into left and
        // right windows that allow 10 each
        assertCount(s, 2000, 20, 33);
    }

    /**
     * Test that using a different timebase works correctly.
     */
    @Test
    public void testTimebase() {
        RateLimitingCache<Integer> s = new RateLimitingCache<>(1000, 10) {
            @Override
            protected long getTime() {
                return SystemClock.elapsedRealtime() / 2;
            }
        };
        // Timebase is moving at half the speed, so only allows for 1 second worth in 2 seconds.
        assertCount(s, 2000, 10, 22);
    }

    /**
     * Helper to make repeated calls every 5 millis to verify the number of expected fetches for
     * the given parameters.
     * @param cache the cache object
     * @param period the period for which to make get() calls
     * @param minCount the lower end of the expected number of fetches, with a margin for error
     * @param maxCount the higher end of the expected number of fetches, with a margin for error
     */
    private void assertCount(RateLimitingCache<Integer> cache, long period,
            int minCount, int maxCount) {
        long startTime = SystemClock.elapsedRealtime();
        while (SystemClock.elapsedRealtime() < startTime + period) {
            int value = cache.get(mFetcher);
            SystemClock.sleep(5);
        }
        int latest = cache.get(mFetcher);
        assertTrue("Latest should be between " + minCount + " and " + maxCount
                        + " but is " + latest, latest <= maxCount && latest >= minCount);
    }
}
+1 −0
Original line number Original line Diff line number Diff line
@@ -304,6 +304,7 @@ com.android.internal.util.ProcFileReader
com.android.internal.util.QuickSelect
com.android.internal.util.QuickSelect
com.android.internal.util.RingBuffer
com.android.internal.util.RingBuffer
com.android.internal.util.SizedInputStream
com.android.internal.util.SizedInputStream
com.android.internal.util.RateLimitingCache
com.android.internal.util.StringPool
com.android.internal.util.StringPool
com.android.internal.util.TokenBucket
com.android.internal.util.TokenBucket
com.android.internal.util.XmlPullParserWrapper
com.android.internal.util.XmlPullParserWrapper