1 /*
2  * Copyright (C) 2009 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.common;
18 
19 import android.content.SharedPreferences;
20 import android.text.format.Time;
21 
22 import java.util.Map;
23 import java.util.TreeMap;
24 
25 /**
26  * Tracks the success/failure history of a particular network operation in
27  * persistent storage and computes retry strategy accordingly.  Handles
28  * exponential backoff, periodic rescheduling, event-driven triggering,
29  * retry-after moratorium intervals, etc. based on caller-specified parameters.
30  *
31  * <p>This class does not directly perform or invoke any operations,
32  * it only keeps track of the schedule.  Somebody else needs to call
33  * {@link #getNextTimeMillis} as appropriate and do the actual work.
34  */
35 public class OperationScheduler {
36     /** Tunable parameter options for {@link #getNextTimeMillis}. */
37     public static class Options {
38         /** Wait this long after every error before retrying. */
39         public long backoffFixedMillis = 0;
40 
41         /** Wait this long times the number of consecutive errors so far before retrying. */
42         public long backoffIncrementalMillis = 5000;
43 
44         /** Wait this long times 2^(number of consecutive errors so far) before retrying. */
45         public int backoffExponentialMillis = 0;
46 
47         /** Maximum duration of moratorium to honor.  Mostly an issue for clock rollbacks. */
48         public long maxMoratoriumMillis = 24 * 3600 * 1000;
49 
50         /** Minimum duration after success to wait before allowing another trigger. */
51         public long minTriggerMillis = 0;
52 
53         /** Automatically trigger this long after the last success. */
54         public long periodicIntervalMillis = 0;
55 
56         @Override
toString()57         public String toString() {
58             if (backoffExponentialMillis > 0) {
59                 return String.format(
60                     "OperationScheduler.Options[backoff=%.1f+%.1f+%.1f max=%.1f min=%.1f period=%.1f]",
61                     backoffFixedMillis / 1000.0, backoffIncrementalMillis / 1000.0,
62                     backoffExponentialMillis / 1000.0,
63                     maxMoratoriumMillis / 1000.0, minTriggerMillis / 1000.0,
64                     periodicIntervalMillis / 1000.0);
65             } else {
66                 return String.format(
67                     "OperationScheduler.Options[backoff=%.1f+%.1f max=%.1f min=%.1f period=%.1f]",
68                     backoffFixedMillis / 1000.0, backoffIncrementalMillis / 1000.0,
69                     maxMoratoriumMillis / 1000.0, minTriggerMillis / 1000.0,
70                     periodicIntervalMillis / 1000.0);
71             }
72         }
73     }
74 
75     private static final String PREFIX = "OperationScheduler_";
76     private final SharedPreferences mStorage;
77 
78     /**
79      * Initialize the scheduler state.
80      * @param storage to use for recording the state of operations across restarts/reboots
81      */
OperationScheduler(SharedPreferences storage)82     public OperationScheduler(SharedPreferences storage) {
83         mStorage = storage;
84     }
85 
86     /**
87      * Parse scheduler options supplied in this string form:
88      *
89      * <pre>
90      * backoff=(fixed)+(incremental)[+(exponential)] max=(maxmoratorium) min=(mintrigger) [period=](interval)
91      * </pre>
92      *
93      * All values are times in (possibly fractional) <em>seconds</em> (not milliseconds).
94      * Omitted settings are left at whatever existing default value was passed in.
95      *
96      * <p>
97      * The default options: <code>backoff=0+5 max=86400 min=0 period=0</code><br>
98      * Fractions are OK: <code>backoff=+2.5 period=10.0</code><br>
99      * The "period=" can be omitted: <code>3600</code><br>
100      *
101      * @param spec describing some or all scheduler options.
102      * @param options to update with parsed values.
103      * @return the options passed in (for convenience)
104      * @throws IllegalArgumentException if the syntax is invalid
105      */
parseOptions(String spec, Options options)106     public static Options parseOptions(String spec, Options options)
107             throws IllegalArgumentException {
108         for (String param : spec.split(" +")) {
109             if (param.length() == 0) continue;
110             if (param.startsWith("backoff=")) {
111                 String[] pieces = param.substring(8).split("\\+");
112                 if (pieces.length > 3) {
113                     throw new IllegalArgumentException("bad value for backoff: [" + spec + "]");
114                 }
115                 if (pieces.length > 0 && pieces[0].length() > 0) {
116                     options.backoffFixedMillis = parseSeconds(pieces[0]);
117                 }
118                 if (pieces.length > 1 && pieces[1].length() > 0) {
119                     options.backoffIncrementalMillis = parseSeconds(pieces[1]);
120                 }
121                 if (pieces.length > 2 && pieces[2].length() > 0) {
122                     options.backoffExponentialMillis = (int)parseSeconds(pieces[2]);
123                 }
124             } else if (param.startsWith("max=")) {
125                 options.maxMoratoriumMillis = parseSeconds(param.substring(4));
126             } else if (param.startsWith("min=")) {
127                 options.minTriggerMillis = parseSeconds(param.substring(4));
128             } else if (param.startsWith("period=")) {
129                 options.periodicIntervalMillis = parseSeconds(param.substring(7));
130             } else {
131                 options.periodicIntervalMillis = parseSeconds(param);
132             }
133         }
134         return options;
135     }
136 
parseSeconds(String param)137     private static long parseSeconds(String param) throws NumberFormatException {
138         return (long) (Float.parseFloat(param) * 1000);
139     }
140 
141     /**
142      * Compute the time of the next operation.  Does not modify any state
143      * (unless the clock rolls backwards, in which case timers are reset).
144      *
145      * @param options to use for this computation.
146      * @return the wall clock time ({@link System#currentTimeMillis()}) when the
147      * next operation should be attempted -- immediately, if the return value is
148      * before the current time.
149      */
getNextTimeMillis(Options options)150     public long getNextTimeMillis(Options options) {
151         boolean enabledState = mStorage.getBoolean(PREFIX + "enabledState", true);
152         if (!enabledState) return Long.MAX_VALUE;
153 
154         boolean permanentError = mStorage.getBoolean(PREFIX + "permanentError", false);
155         if (permanentError) return Long.MAX_VALUE;
156 
157         // We do quite a bit of limiting to prevent a clock rollback from totally
158         // hosing the scheduler.  Times which are supposed to be in the past are
159         // clipped to the current time so we don't languish forever.
160 
161         int errorCount = mStorage.getInt(PREFIX + "errorCount", 0);
162         long now = currentTimeMillis();
163         long lastSuccessTimeMillis = getTimeBefore(PREFIX + "lastSuccessTimeMillis", now);
164         long lastErrorTimeMillis = getTimeBefore(PREFIX + "lastErrorTimeMillis", now);
165         long triggerTimeMillis = mStorage.getLong(PREFIX + "triggerTimeMillis", Long.MAX_VALUE);
166         long moratoriumSetMillis = getTimeBefore(PREFIX + "moratoriumSetTimeMillis", now);
167         long moratoriumTimeMillis = getTimeBefore(PREFIX + "moratoriumTimeMillis",
168                 moratoriumSetMillis + options.maxMoratoriumMillis);
169 
170         long time = triggerTimeMillis;
171         if (options.periodicIntervalMillis > 0) {
172             time = Math.min(time, lastSuccessTimeMillis + options.periodicIntervalMillis);
173         }
174 
175         time = Math.max(time, moratoriumTimeMillis);
176         time = Math.max(time, lastSuccessTimeMillis + options.minTriggerMillis);
177         if (errorCount > 0) {
178             int shift = errorCount-1;
179             // backoffExponentialMillis is an int, so we can safely
180             // double it 30 times without overflowing a long.
181             if (shift > 30) shift = 30;
182             long backoff = options.backoffFixedMillis +
183                 (options.backoffIncrementalMillis * errorCount) +
184                 (((long)options.backoffExponentialMillis) << shift);
185 
186             // Treat backoff like a moratorium: don't let the backoff time grow too large.
187             backoff = Math.min(backoff, options.maxMoratoriumMillis);
188 
189             time = Math.max(time, lastErrorTimeMillis + backoff);
190         }
191         return time;
192     }
193 
194     /**
195      * Return the last time the operation completed.  Does not modify any state.
196      *
197      * @return the wall clock time when {@link #onSuccess()} was last called.
198      */
getLastSuccessTimeMillis()199     public long getLastSuccessTimeMillis() {
200         return mStorage.getLong(PREFIX + "lastSuccessTimeMillis", 0);
201     }
202 
203     /**
204      * Return the last time the operation was attempted.  Does not modify any state.
205      *
206      * @return the wall clock time when {@link #onSuccess()} or {@link
207      * #onTransientError()} was last called.
208      */
getLastAttemptTimeMillis()209     public long getLastAttemptTimeMillis() {
210         return Math.max(
211                 mStorage.getLong(PREFIX + "lastSuccessTimeMillis", 0),
212                 mStorage.getLong(PREFIX + "lastErrorTimeMillis", 0));
213     }
214 
215     /**
216      * Fetch a {@link SharedPreferences} property, but force it to be before
217      * a certain time, updating the value if necessary.  This is to recover
218      * gracefully from clock rollbacks which could otherwise strand our timers.
219      *
220      * @param name of SharedPreferences key
221      * @param max time to allow in result
222      * @return current value attached to key (default 0), limited by max
223      */
getTimeBefore(String name, long max)224     private long getTimeBefore(String name, long max) {
225         long time = mStorage.getLong(name, 0);
226         if (time > max) {
227             time = max;
228             SharedPreferencesCompat.apply(mStorage.edit().putLong(name, time));
229         }
230         return time;
231     }
232 
233     /**
234      * Request an operation to be performed at a certain time.  The actual
235      * scheduled time may be affected by error backoff logic and defined
236      * minimum intervals.  Use {@link Long#MAX_VALUE} to disable triggering.
237      *
238      * @param millis wall clock time ({@link System#currentTimeMillis()}) to
239      * trigger another operation; 0 to trigger immediately
240      */
setTriggerTimeMillis(long millis)241     public void setTriggerTimeMillis(long millis) {
242         SharedPreferencesCompat.apply(
243                 mStorage.edit().putLong(PREFIX + "triggerTimeMillis", millis));
244     }
245 
246     /**
247      * Forbid any operations until after a certain (absolute) time.
248      * Limited by {@link Options#maxMoratoriumMillis}.
249      *
250      * @param millis wall clock time ({@link System#currentTimeMillis()})
251      * when operations should be allowed again; 0 to remove moratorium
252      */
setMoratoriumTimeMillis(long millis)253     public void setMoratoriumTimeMillis(long millis) {
254         SharedPreferencesCompat.apply(mStorage.edit()
255                    .putLong(PREFIX + "moratoriumTimeMillis", millis)
256                    .putLong(PREFIX + "moratoriumSetTimeMillis", currentTimeMillis()));
257     }
258 
259     /**
260      * Forbid any operations until after a certain time, as specified in
261      * the format used by the HTTP "Retry-After" header.
262      * Limited by {@link Options#maxMoratoriumMillis}.
263      *
264      * @param retryAfter moratorium time in HTTP format
265      * @return true if a time was successfully parsed
266      */
setMoratoriumTimeHttp(String retryAfter)267     public boolean setMoratoriumTimeHttp(String retryAfter) {
268         try {
269             long ms = Long.parseLong(retryAfter) * 1000;
270             setMoratoriumTimeMillis(ms + currentTimeMillis());
271             return true;
272         } catch (NumberFormatException nfe) {
273             try {
274                 setMoratoriumTimeMillis(LegacyHttpDateTime.parse(retryAfter));
275                 return true;
276             } catch (IllegalArgumentException iae) {
277                 return false;
278             }
279         }
280     }
281 
282     /**
283      * Enable or disable all operations.  When disabled, all calls to
284      * {@link #getNextTimeMillis} return {@link Long#MAX_VALUE}.
285      * Commonly used when data network availability goes up and down.
286      *
287      * @param enabled if operations can be performed
288      */
setEnabledState(boolean enabled)289     public void setEnabledState(boolean enabled) {
290         SharedPreferencesCompat.apply(
291                 mStorage.edit().putBoolean(PREFIX + "enabledState", enabled));
292     }
293 
294     /**
295      * Report successful completion of an operation.  Resets all error
296      * counters, clears any trigger directives, and records the success.
297      */
onSuccess()298     public void onSuccess() {
299         resetTransientError();
300         resetPermanentError();
301         SharedPreferencesCompat.apply(mStorage.edit()
302                 .remove(PREFIX + "errorCount")
303                 .remove(PREFIX + "lastErrorTimeMillis")
304                 .remove(PREFIX + "permanentError")
305                 .remove(PREFIX + "triggerTimeMillis")
306                 .putLong(PREFIX + "lastSuccessTimeMillis", currentTimeMillis()));
307     }
308 
309     /**
310      * Report a transient error (usually a network failure).  Increments
311      * the error count and records the time of the latest error for backoff
312      * purposes.
313      */
onTransientError()314     public void onTransientError() {
315         SharedPreferences.Editor editor = mStorage.edit();
316         editor.putLong(PREFIX + "lastErrorTimeMillis", currentTimeMillis());
317         editor.putInt(PREFIX + "errorCount",
318                 mStorage.getInt(PREFIX + "errorCount", 0) + 1);
319         SharedPreferencesCompat.apply(editor);
320     }
321 
322     /**
323      * Reset all transient error counts, allowing the next operation to proceed
324      * immediately without backoff.  Commonly used on network state changes, when
325      * partial progress occurs (some data received), and in other circumstances
326      * where there is reason to hope things might start working better.
327      */
resetTransientError()328     public void resetTransientError() {
329         SharedPreferencesCompat.apply(mStorage.edit().remove(PREFIX + "errorCount"));
330     }
331 
332     /**
333      * Report a permanent error that will not go away until further notice.
334      * No operation will be scheduled until {@link #resetPermanentError()}
335      * is called.  Commonly used for authentication failures (which are reset
336      * when the accounts database is updated).
337      */
onPermanentError()338     public void onPermanentError() {
339         SharedPreferencesCompat.apply(mStorage.edit().putBoolean(PREFIX + "permanentError", true));
340     }
341 
342     /**
343      * Reset any permanent error status set by {@link #onPermanentError},
344      * allowing operations to be scheduled as normal.
345      */
resetPermanentError()346     public void resetPermanentError() {
347         SharedPreferencesCompat.apply(mStorage.edit().remove(PREFIX + "permanentError"));
348     }
349 
350     /**
351      * Return a string description of the scheduler state for debugging.
352      */
toString()353     public String toString() {
354         StringBuilder out = new StringBuilder("[OperationScheduler:");
355         TreeMap<String, Object> copy = new TreeMap<String, Object>(mStorage.getAll());  // Sort keys
356         for (Map.Entry<String, Object> e : copy.entrySet()) {
357             String key = e.getKey();
358             if (key.startsWith(PREFIX)) {
359                 if (key.endsWith("TimeMillis")) {
360                     Time time = new Time();
361                     time.set((Long) e.getValue());
362                     out.append(" ").append(key.substring(PREFIX.length(), key.length() - 10));
363                     out.append("=").append(time.format("%Y-%m-%d/%H:%M:%S"));
364                 } else {
365                     out.append(" ").append(key.substring(PREFIX.length()));
366                     Object v = e.getValue();
367                     if (v == null) {
368                         out.append("=(null)");
369                     } else {
370                         out.append("=").append(v.toString());
371                     }
372                 }
373             }
374         }
375         return out.append("]").toString();
376     }
377 
378     /**
379      * Gets the current time.  Can be overridden for unit testing.
380      *
381      * @return {@link System#currentTimeMillis()}
382      */
currentTimeMillis()383     protected long currentTimeMillis() {
384         return System.currentTimeMillis();
385     }
386 }
387