summaryrefslogtreecommitdiffstats
path: root/common/java/com/android/common/OperationScheduler.java
blob: 261b15d5f92396fd15b8a0165e643ef67b6403dd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
/*
 * Copyright (C) 2009 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.common;

import android.content.SharedPreferences;
import android.net.http.AndroidHttpClient;
import android.text.format.Time;

import java.util.Map;
import java.util.TreeSet;

/**
 * Tracks the success/failure history of a particular network operation in
 * persistent storage and computes retry strategy accordingly.  Handles
 * exponential backoff, periodic rescheduling, event-driven triggering,
 * retry-after moratorium intervals, etc. based on caller-specified parameters.
 *
 * <p>This class does not directly perform or invoke any operations,
 * it only keeps track of the schedule.  Somebody else needs to call
 * {@link #getNextTimeMillis()} as appropriate and do the actual work.
 */
public class OperationScheduler {
    /** Tunable parameter options for {@link #getNextTimeMillis}. */
    public static class Options {
        /** Wait this long after every error before retrying. */
        public long backoffFixedMillis = 0;

        /** Wait this long times the number of consecutive errors so far before retrying. */
        public long backoffIncrementalMillis = 5000;

        /** Wait this long times 2^(number of consecutive errors so far) before retrying. */
        public int backoffExponentialMillis = 0;

        /** Maximum duration of moratorium to honor.  Mostly an issue for clock rollbacks. */
        public long maxMoratoriumMillis = 24 * 3600 * 1000;

        /** Minimum duration after success to wait before allowing another trigger. */
        public long minTriggerMillis = 0;

        /** Automatically trigger this long after the last success. */
        public long periodicIntervalMillis = 0;

        @Override
        public String toString() {
            if (backoffExponentialMillis > 0) {
                return String.format(
                    "OperationScheduler.Options[backoff=%.1f+%.1f+%.1f max=%.1f min=%.1f period=%.1f]",
                    backoffFixedMillis / 1000.0, backoffIncrementalMillis / 1000.0,
                    backoffExponentialMillis / 1000.0,
                    maxMoratoriumMillis / 1000.0, minTriggerMillis / 1000.0,
                    periodicIntervalMillis / 1000.0);
            } else {
                return String.format(
                    "OperationScheduler.Options[backoff=%.1f+%.1f max=%.1f min=%.1f period=%.1f]",
                    backoffFixedMillis / 1000.0, backoffIncrementalMillis / 1000.0,
                    maxMoratoriumMillis / 1000.0, minTriggerMillis / 1000.0,
                    periodicIntervalMillis / 1000.0);
            }
        }
    }

    private static final String PREFIX = "OperationScheduler_";
    private final SharedPreferences mStorage;

    /**
     * Initialize the scheduler state.
     * @param storage to use for recording the state of operations across restarts/reboots
     */
    public OperationScheduler(SharedPreferences storage) {
        mStorage = storage;
    }

    /**
     * Parse scheduler options supplied in this string form:
     *
     * <pre>
     * backoff=(fixed)+(incremental)[+(exponential)] max=(maxmoratorium) min=(mintrigger) [period=](interval)
     * </pre>
     *
     * All values are times in (possibly fractional) <em>seconds</em> (not milliseconds).
     * Omitted settings are left at whatever existing default value was passed in.
     *
     * <p>
     * The default options: <code>backoff=0+5 max=86400 min=0 period=0</code><br>
     * Fractions are OK: <code>backoff=+2.5 period=10.0</code><br>
     * The "period=" can be omitted: <code>3600</code><br>
     *
     * @param spec describing some or all scheduler options.
     * @param options to update with parsed values.
     * @return the options passed in (for convenience)
     * @throws IllegalArgumentException if the syntax is invalid
     */
    public static Options parseOptions(String spec, Options options)
            throws IllegalArgumentException {
        for (String param : spec.split(" +")) {
            if (param.length() == 0) continue;
            if (param.startsWith("backoff=")) {
                String[] pieces = param.substring(8).split("\\+");
                if (pieces.length > 3) {
                    throw new IllegalArgumentException("bad value for backoff: [" + spec + "]");
                }
                if (pieces.length > 0 && pieces[0].length() > 0) {
                    options.backoffFixedMillis = parseSeconds(pieces[0]);
                }
                if (pieces.length > 1 && pieces[1].length() > 0) {
                    options.backoffIncrementalMillis = parseSeconds(pieces[1]);
                }
                if (pieces.length > 2 && pieces[2].length() > 0) {
                    options.backoffExponentialMillis = (int)parseSeconds(pieces[2]);
                }
            } else if (param.startsWith("max=")) {
                options.maxMoratoriumMillis = parseSeconds(param.substring(4));
            } else if (param.startsWith("min=")) {
                options.minTriggerMillis = parseSeconds(param.substring(4));
            } else if (param.startsWith("period=")) {
                options.periodicIntervalMillis = parseSeconds(param.substring(7));
            } else {
                options.periodicIntervalMillis = parseSeconds(param);
            }
        }
        return options;
    }

    private static long parseSeconds(String param) throws NumberFormatException {
        return (long) (Float.parseFloat(param) * 1000);
    }

    /**
     * Compute the time of the next operation.  Does not modify any state
     * (unless the clock rolls backwards, in which case timers are reset).
     *
     * @param options to use for this computation.
     * @return the wall clock time ({@link System#currentTimeMillis()}) when the
     * next operation should be attempted -- immediately, if the return value is
     * before the current time.
     */
    public long getNextTimeMillis(Options options) {
        boolean enabledState = mStorage.getBoolean(PREFIX + "enabledState", true);
        if (!enabledState) return Long.MAX_VALUE;

        boolean permanentError = mStorage.getBoolean(PREFIX + "permanentError", false);
        if (permanentError) return Long.MAX_VALUE;

        // We do quite a bit of limiting to prevent a clock rollback from totally
        // hosing the scheduler.  Times which are supposed to be in the past are
        // clipped to the current time so we don't languish forever.

        int errorCount = mStorage.getInt(PREFIX + "errorCount", 0);
        long now = currentTimeMillis();
        long lastSuccessTimeMillis = getTimeBefore(PREFIX + "lastSuccessTimeMillis", now);
        long lastErrorTimeMillis = getTimeBefore(PREFIX + "lastErrorTimeMillis", now);
        long triggerTimeMillis = mStorage.getLong(PREFIX + "triggerTimeMillis", Long.MAX_VALUE);
        long moratoriumSetMillis = getTimeBefore(PREFIX + "moratoriumSetTimeMillis", now);
        long moratoriumTimeMillis = getTimeBefore(PREFIX + "moratoriumTimeMillis",
                moratoriumSetMillis + options.maxMoratoriumMillis);

        long time = triggerTimeMillis;
        if (options.periodicIntervalMillis > 0) {
            time = Math.min(time, lastSuccessTimeMillis + options.periodicIntervalMillis);
        }

        time = Math.max(time, moratoriumTimeMillis);
        time = Math.max(time, lastSuccessTimeMillis + options.minTriggerMillis);
        if (errorCount > 0) {
            int shift = errorCount-1;
            // backoffExponentialMillis is an int, so we can safely
            // double it 30 times without overflowing a long.
            if (shift > 30) shift = 30;
            long backoff = options.backoffFixedMillis +
                (options.backoffIncrementalMillis * errorCount) +
                (((long)options.backoffExponentialMillis) << shift);

            // Treat backoff like a moratorium: don't let the backoff
            // time grow too large.
            if (moratoriumTimeMillis > 0 && backoff > moratoriumTimeMillis) {
                backoff = moratoriumTimeMillis;
            }

            time = Math.max(time, lastErrorTimeMillis + backoff);
        }
        return time;
    }

    /**
     * Return the last time the operation completed.  Does not modify any state.
     *
     * @return the wall clock time when {@link #onSuccess()} was last called.
     */
    public long getLastSuccessTimeMillis() {
        return mStorage.getLong(PREFIX + "lastSuccessTimeMillis", 0);
    }

    /**
     * Return the last time the operation was attempted.  Does not modify any state.
     *
     * @return the wall clock time when {@link #onSuccess()} or {@link
     * #onTransientError()} was last called.
     */
    public long getLastAttemptTimeMillis() {
        return Math.max(
                mStorage.getLong(PREFIX + "lastSuccessTimeMillis", 0),
                mStorage.getLong(PREFIX + "lastErrorTimeMillis", 0));
    }

    /**
     * Fetch a {@link SharedPreferences} property, but force it to be before
     * a certain time, updating the value if necessary.  This is to recover
     * gracefully from clock rollbacks which could otherwise strand our timers.
     *
     * @param name of SharedPreferences key
     * @param max time to allow in result
     * @return current value attached to key (default 0), limited by max
     */
    private long getTimeBefore(String name, long max) {
        long time = mStorage.getLong(name, 0);
        if (time > max) {
            time = max;
            SharedPreferencesCompat.apply(mStorage.edit().putLong(name, time));
        }
        return time;
    }

    /**
     * Request an operation to be performed at a certain time.  The actual
     * scheduled time may be affected by error backoff logic and defined
     * minimum intervals.  Use {@link Long#MAX_VALUE} to disable triggering.
     *
     * @param millis wall clock time ({@link System#currentTimeMillis()}) to
     * trigger another operation; 0 to trigger immediately
     */
    public void setTriggerTimeMillis(long millis) {
        SharedPreferencesCompat.apply(
                mStorage.edit().putLong(PREFIX + "triggerTimeMillis", millis));
    }

    /**
     * Forbid any operations until after a certain (absolute) time.
     * Limited by {@link #Options.maxMoratoriumMillis}.
     *
     * @param millis wall clock time ({@link System#currentTimeMillis()})
     * when operations should be allowed again; 0 to remove moratorium
     */
    public void setMoratoriumTimeMillis(long millis) {
        SharedPreferencesCompat.apply(mStorage.edit()
                   .putLong(PREFIX + "moratoriumTimeMillis", millis)
                   .putLong(PREFIX + "moratoriumSetTimeMillis", currentTimeMillis()));
    }

    /**
     * Forbid any operations until after a certain time, as specified in
     * the format used by the HTTP "Retry-After" header.
     * Limited by {@link #Options.maxMoratoriumMillis}.
     *
     * @param retryAfter moratorium time in HTTP format
     * @return true if a time was successfully parsed
     */
    public boolean setMoratoriumTimeHttp(String retryAfter) {
        try {
            long ms = Long.valueOf(retryAfter) * 1000;
            setMoratoriumTimeMillis(ms + currentTimeMillis());
            return true;
        } catch (NumberFormatException nfe) {
            try {
                setMoratoriumTimeMillis(AndroidHttpClient.parseDate(retryAfter));
                return true;
            } catch (IllegalArgumentException iae) {
                return false;
            }
        }
    }

    /**
     * Enable or disable all operations.  When disabled, all calls to
     * {@link #getNextTimeMillis()} return {@link Long#MAX_VALUE}.
     * Commonly used when data network availability goes up and down.
     *
     * @param enabled if operations can be performed
     */
    public void setEnabledState(boolean enabled) {
        SharedPreferencesCompat.apply(
                mStorage.edit().putBoolean(PREFIX + "enabledState", enabled));
    }

    /**
     * Report successful completion of an operation.  Resets all error
     * counters, clears any trigger directives, and records the success.
     */
    public void onSuccess() {
        resetTransientError();
        resetPermanentError();
        SharedPreferencesCompat.apply(mStorage.edit()
                .remove(PREFIX + "errorCount")
                .remove(PREFIX + "lastErrorTimeMillis")
                .remove(PREFIX + "permanentError")
                .remove(PREFIX + "triggerTimeMillis")
                .putLong(PREFIX + "lastSuccessTimeMillis", currentTimeMillis()));
    }

    /**
     * Report a transient error (usually a network failure).  Increments
     * the error count and records the time of the latest error for backoff
     * purposes.
     */
    public void onTransientError() {
        SharedPreferences.Editor editor = mStorage.edit();
        editor.putLong(PREFIX + "lastErrorTimeMillis", currentTimeMillis());
        editor.putInt(PREFIX + "errorCount",
                mStorage.getInt(PREFIX + "errorCount", 0) + 1);
        SharedPreferencesCompat.apply(editor);
    }

    /**
     * Reset all transient error counts, allowing the next operation to proceed
     * immediately without backoff.  Commonly used on network state changes, when
     * partial progress occurs (some data received), and in other circumstances
     * where there is reason to hope things might start working better.
     */
    public void resetTransientError() {
        SharedPreferencesCompat.apply(mStorage.edit().remove(PREFIX + "errorCount"));
    }

    /**
     * Report a permanent error that will not go away until further notice.
     * No operation will be scheduled until {@link #resetPermanentError()}
     * is called.  Commonly used for authentication failures (which are reset
     * when the accounts database is updated).
     */
    public void onPermanentError() {
        SharedPreferencesCompat.apply(mStorage.edit().putBoolean(PREFIX + "permanentError", true));
    }

    /**
     * Reset any permanent error status set by {@link #onPermanentError},
     * allowing operations to be scheduled as normal.
     */
    public void resetPermanentError() {
        SharedPreferencesCompat.apply(mStorage.edit().remove(PREFIX + "permanentError"));
    }

    /**
     * Return a string description of the scheduler state for debugging.
     */
    public String toString() {
        StringBuilder out = new StringBuilder("[OperationScheduler:");
        for (String key : new TreeSet<String>(mStorage.getAll().keySet())) {  // Sort keys
            if (key.startsWith(PREFIX)) {
                if (key.endsWith("TimeMillis")) {
                    Time time = new Time();
                    time.set(mStorage.getLong(key, 0));
                    out.append(" ").append(key.substring(PREFIX.length(), key.length() - 10));
                    out.append("=").append(time.format("%Y-%m-%d/%H:%M:%S"));
                } else {
                    out.append(" ").append(key.substring(PREFIX.length()));
                    out.append("=").append(mStorage.getAll().get(key).toString());
                }
            }
        }
        return out.append("]").toString();
    }

    /**
     * Gets the current time.  Can be overridden for unit testing.
     *
     * @return {@link System#currentTimeMillis()}
     */
    protected long currentTimeMillis() {
        return System.currentTimeMillis();
    }
}